blumenjs 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Blumen API Route Helpers
3
+ *
4
+ * Runtime utilities for API route handlers.
5
+ * Import these in your app/api/ route.ts files.
6
+ *
7
+ * Example usage:
8
+ * import { json, notFound } from '../../shared/api';
9
+ * export function GET(req) { return json({ message: 'Hello' }); }
10
+ */
11
+
12
+ // ── Request type ──────────────────────────────────────────────
13
+
14
+ /**
15
+ * Request object passed to API route handlers.
16
+ * Contains the parsed HTTP method, path, params, query, headers, and body.
17
+ */
18
+ export interface BlumenAPIRequest {
19
+ /** HTTP method (GET, POST, PUT, DELETE, PATCH) */
20
+ method: string;
21
+ /** The request URL path (e.g. "/api/users/42") */
22
+ path: string;
23
+ /** Dynamic route parameters (e.g. { id: "42" } for /api/users/[id]) */
24
+ params: Record<string, string>;
25
+ /** URL query string parameters */
26
+ query: Record<string, string[]>;
27
+ /** A subset of request headers (forwarded from Go) */
28
+ headers: Record<string, string>;
29
+ /** Parsed request body (JSON). Undefined for GET/DELETE. */
30
+ body: any;
31
+ }
32
+
33
+ // ── Response type ─────────────────────────────────────────────
34
+
35
+ /**
36
+ * Response object returned from API route handlers.
37
+ * The framework serialises this and sends it back to the client.
38
+ */
39
+ export interface BlumenAPIResponse {
40
+ /** HTTP status code (default 200) */
41
+ status: number;
42
+ /** Response headers */
43
+ headers: Record<string, string>;
44
+ /** Response body (will be JSON-stringified) */
45
+ body: any;
46
+ }
47
+
48
+ // ── Response helpers ──────────────────────────────────────────
49
+
50
+ /**
51
+ * Return a JSON response.
52
+ *
53
+ * Example:
54
+ * return json({ users: [] });
55
+ * return json({ created: true }, { status: 201 });
56
+ */
57
+ export function json(
58
+ data: any,
59
+ init?: { status?: number; headers?: Record<string, string> },
60
+ ): BlumenAPIResponse {
61
+ return {
62
+ status: init?.status ?? 200,
63
+ headers: {
64
+ "Content-Type": "application/json",
65
+ ...(init?.headers || {}),
66
+ },
67
+ body: data,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Return a redirect response.
73
+ *
74
+ * Example:
75
+ * return redirect('/login');
76
+ * return redirect('/new-url', 301); // permanent redirect
77
+ */
78
+ export function redirect(url: string, status: number = 302): BlumenAPIResponse {
79
+ return {
80
+ status,
81
+ headers: { Location: url },
82
+ body: null,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Return a 404 Not Found response.
88
+ *
89
+ * Example:
90
+ * const user = await findUser(req.params.id);
91
+ * if (!user) return notFound();
92
+ */
93
+ export function notFound(message: string = "Not Found"): BlumenAPIResponse {
94
+ return {
95
+ status: 404,
96
+ headers: { "Content-Type": "application/json" },
97
+ body: { error: message },
98
+ };
99
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * 🌸 Blumen Configuration
3
+ *
4
+ * Central configuration file for your Blumen application.
5
+ * Controls plugins, i18n, middleware, and framework behavior.
6
+ *
7
+ * @example
8
+ * import { defineConfig } from "@/shared/blumenConfig";
9
+ *
10
+ * export default defineConfig({
11
+ * plugins: [analyticsPlugin(), sentryPlugin()],
12
+ * i18n: { locales: ["en", "fr"], defaultLocale: "en" },
13
+ * });
14
+ */
15
+
16
+ import React from "react";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Blumen Plugin interface.
22
+ * Plugins can wrap the app with providers, inject head elements,
23
+ * and hook into lifecycle events.
24
+ */
25
+ export interface BlumenPlugin {
26
+ /** Unique plugin name */
27
+ name: string;
28
+
29
+ /**
30
+ * Wrap the entire application with a provider component.
31
+ * Called during both SSR and client-side rendering.
32
+ *
33
+ * @example
34
+ * wrapApp: (children) => <ThemeProvider>{children}</ThemeProvider>
35
+ */
36
+ wrapApp?: (children: React.ReactNode) => React.ReactNode;
37
+
38
+ /**
39
+ * Inject elements into <head> during SSR.
40
+ * Return an array of React elements (meta tags, link tags, scripts).
41
+ */
42
+ head?: () => React.ReactNode[];
43
+
44
+ /**
45
+ * Called once on client-side after hydration.
46
+ * Use for analytics init, service workers, etc.
47
+ */
48
+ onClientInit?: () => void;
49
+
50
+ /**
51
+ * Called on every client-side route change.
52
+ */
53
+ onRouteChange?: (path: string) => void;
54
+ }
55
+
56
+ /**
57
+ * i18n configuration.
58
+ */
59
+ export interface I18nConfig {
60
+ /** Supported locale codes (e.g. ["en", "fr", "de"]) */
61
+ locales: string[];
62
+
63
+ /** Default locale when none is detected */
64
+ defaultLocale: string;
65
+
66
+ /** Locale detection strategy */
67
+ detection?: "header" | "path" | "cookie" | "none";
68
+
69
+ /** Path to translation files directory (default: "locales/") */
70
+ translationsDir?: string;
71
+ }
72
+
73
+ /**
74
+ * Main Blumen configuration.
75
+ */
76
+ export interface BlumenConfig {
77
+ /** Application name */
78
+ appName?: string;
79
+
80
+ /** Plugins to load */
81
+ plugins?: BlumenPlugin[];
82
+
83
+ /** Internationalization settings */
84
+ i18n?: I18nConfig;
85
+
86
+ /** Custom webpack config overrides (advanced) */
87
+ webpack?: (config: any) => any;
88
+ }
89
+
90
+ // ── Config Helpers ─────────────────────────────────────────────
91
+
92
+ let _config: BlumenConfig | null = null;
93
+
94
+ /**
95
+ * Define a Blumen configuration with type checking.
96
+ */
97
+ export function defineConfig(config: BlumenConfig): BlumenConfig {
98
+ _config = config;
99
+ return config;
100
+ }
101
+
102
+ /**
103
+ * Get the current configuration.
104
+ */
105
+ export function getConfig(): BlumenConfig {
106
+ return _config || {};
107
+ }
108
+
109
+ /**
110
+ * Apply all plugin wrappers to children.
111
+ */
112
+ export function applyPluginWrappers(
113
+ children: React.ReactNode,
114
+ ): React.ReactNode {
115
+ const config = getConfig();
116
+ if (!config.plugins) return children;
117
+
118
+ let wrapped = children;
119
+ // Apply plugins in reverse so the first plugin is the outermost wrapper
120
+ for (let i = config.plugins.length - 1; i >= 0; i--) {
121
+ const plugin = config.plugins[i];
122
+ if (plugin.wrapApp) {
123
+ wrapped = plugin.wrapApp(wrapped);
124
+ }
125
+ }
126
+ return wrapped;
127
+ }
128
+
129
+ /**
130
+ * Collect all head elements from plugins.
131
+ */
132
+ export function collectPluginHeadElements(): React.ReactNode[] {
133
+ const config = getConfig();
134
+ if (!config.plugins) return [];
135
+
136
+ const elements: React.ReactNode[] = [];
137
+ for (const plugin of config.plugins) {
138
+ if (plugin.head) {
139
+ elements.push(...plugin.head());
140
+ }
141
+ }
142
+ return elements;
143
+ }
144
+
145
+ /**
146
+ * Initialize all plugins on the client side.
147
+ */
148
+ export function initClientPlugins(): void {
149
+ const config = getConfig();
150
+ if (!config.plugins) return;
151
+
152
+ for (const plugin of config.plugins) {
153
+ if (plugin.onClientInit) {
154
+ try {
155
+ plugin.onClientInit();
156
+ } catch (err) {
157
+ console.error(`Plugin "${plugin.name}" init error:`, err);
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Notify all plugins of a route change.
165
+ */
166
+ export function notifyPluginRouteChange(path: string): void {
167
+ const config = getConfig();
168
+ if (!config.plugins) return;
169
+
170
+ for (const plugin of config.plugins) {
171
+ if (plugin.onRouteChange) {
172
+ try {
173
+ plugin.onRouteChange(path);
174
+ } catch (err) {
175
+ console.error(`Plugin "${plugin.name}" route change error:`, err);
176
+ }
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * 🌸 Blumen i18n — Internationalization System
3
+ *
4
+ * Built-in multi-language support with:
5
+ * - Automatic locale detection (Accept-Language, path prefix, cookie)
6
+ * - React context for current locale
7
+ * - useTranslation hook for translations
8
+ * - Type-safe translation keys
9
+ *
10
+ * @example
11
+ * // Setup: create locale files
12
+ * // locales/en.json → { "greeting": "Hello", "nav.home": "Home" }
13
+ * // locales/fr.json → { "greeting": "Bonjour", "nav.home": "Accueil" }
14
+ *
15
+ * // In your component:
16
+ * import { useTranslation } from "@/shared/i18n";
17
+ *
18
+ * function MyPage() {
19
+ * const { t, locale, setLocale } = useTranslation();
20
+ * return <h1>{t("greeting")}</h1>;
21
+ * }
22
+ */
23
+
24
+ import React, { createContext, useContext, useState, useCallback, useMemo } from "react";
25
+
26
+ // ── Types ──────────────────────────────────────────────────────
27
+
28
+ export interface TranslationMap {
29
+ [key: string]: string | TranslationMap;
30
+ }
31
+
32
+ export interface I18nContextValue {
33
+ /** Current locale code (e.g. "en", "fr") */
34
+ locale: string;
35
+
36
+ /** All available locales */
37
+ locales: string[];
38
+
39
+ /** Change the current locale */
40
+ setLocale: (locale: string) => void;
41
+
42
+ /** Translate a key with optional interpolation */
43
+ t: (key: string, params?: Record<string, string | number>) => string;
44
+
45
+ /** Get the direction (ltr/rtl) for the current locale */
46
+ dir: "ltr" | "rtl";
47
+ }
48
+
49
+ // RTL language codes
50
+ const RTL_LOCALES = new Set(["ar", "he", "fa", "ur", "ps", "sd", "yi"]);
51
+
52
+ // ── Translation Registry ───────────────────────────────────────
53
+
54
+ const translations = new Map<string, TranslationMap>();
55
+
56
+ /**
57
+ * Register translations for a locale.
58
+ */
59
+ export function registerTranslations(locale: string, messages: TranslationMap): void {
60
+ const existing = translations.get(locale) || {};
61
+ translations.set(locale, { ...existing, ...flattenMessages(messages) });
62
+ }
63
+
64
+ /**
65
+ * Register multiple locales at once.
66
+ */
67
+ export function registerAllTranslations(allMessages: Record<string, TranslationMap>): void {
68
+ for (const [locale, messages] of Object.entries(allMessages)) {
69
+ registerTranslations(locale, messages);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Flatten nested translation objects into dot-notation keys.
75
+ * { nav: { home: "Home" } } → { "nav.home": "Home" }
76
+ */
77
+ function flattenMessages(obj: TranslationMap, prefix = ""): Record<string, string> {
78
+ const result: Record<string, string> = {};
79
+ for (const [key, value] of Object.entries(obj)) {
80
+ const fullKey = prefix ? `${prefix}.${key}` : key;
81
+ if (typeof value === "string") {
82
+ result[fullKey] = value;
83
+ } else {
84
+ Object.assign(result, flattenMessages(value, fullKey));
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+
90
+ // ── Locale Detection ───────────────────────────────────────────
91
+
92
+ /**
93
+ * Detect the user's preferred locale.
94
+ */
95
+ export function detectLocale(
96
+ supportedLocales: string[],
97
+ defaultLocale: string,
98
+ strategy: "header" | "path" | "cookie" | "none" = "header",
99
+ requestHeaders?: Record<string, string>,
100
+ path?: string,
101
+ ): string {
102
+ if (strategy === "none") return defaultLocale;
103
+
104
+ // 1. Check path prefix (e.g. /fr/about → fr)
105
+ if (strategy === "path" && path) {
106
+ const segments = path.split("/").filter(Boolean);
107
+ if (segments.length > 0 && supportedLocales.includes(segments[0])) {
108
+ return segments[0];
109
+ }
110
+ }
111
+
112
+ // 2. Check cookie
113
+ if (typeof document !== "undefined") {
114
+ const match = document.cookie.match(/(?:^|;\s*)blumen_locale=([^;]*)/);
115
+ if (match && supportedLocales.includes(match[1])) {
116
+ return match[1];
117
+ }
118
+ }
119
+
120
+ // 3. Check Accept-Language header (server-side)
121
+ if (strategy === "header" && requestHeaders) {
122
+ const acceptLang = requestHeaders["accept-language"] || requestHeaders["Accept-Language"];
123
+ if (acceptLang) {
124
+ const preferred = parseAcceptLanguage(acceptLang);
125
+ for (const lang of preferred) {
126
+ // Exact match
127
+ if (supportedLocales.includes(lang)) return lang;
128
+ // Prefix match (en-US → en)
129
+ const prefix = lang.split("-")[0];
130
+ if (supportedLocales.includes(prefix)) return prefix;
131
+ }
132
+ }
133
+ }
134
+
135
+ // 4. Check browser language
136
+ if (typeof navigator !== "undefined") {
137
+ const browserLang = navigator.language?.split("-")[0];
138
+ if (browserLang && supportedLocales.includes(browserLang)) {
139
+ return browserLang;
140
+ }
141
+ }
142
+
143
+ return defaultLocale;
144
+ }
145
+
146
+ /**
147
+ * Parse Accept-Language header into ordered locale list.
148
+ * "en-US,en;q=0.9,fr;q=0.8" → ["en-US", "en", "fr"]
149
+ */
150
+ function parseAcceptLanguage(header: string): string[] {
151
+ return header
152
+ .split(",")
153
+ .map((part) => {
154
+ const [lang, q] = part.trim().split(";q=");
155
+ return { lang: lang.trim(), quality: q ? parseFloat(q) : 1 };
156
+ })
157
+ .sort((a, b) => b.quality - a.quality)
158
+ .map((item) => item.lang);
159
+ }
160
+
161
+ // ── React Context ──────────────────────────────────────────────
162
+
163
+ const I18nContext = createContext<I18nContextValue | null>(null);
164
+
165
+ export interface I18nProviderProps {
166
+ /** Initial locale */
167
+ locale: string;
168
+ /** Supported locales */
169
+ locales: string[];
170
+ /** Children */
171
+ children: React.ReactNode;
172
+ }
173
+
174
+ /**
175
+ * I18n Provider — wraps your app to provide translation context.
176
+ */
177
+ export function I18nProvider({ locale: initialLocale, locales, children }: I18nProviderProps) {
178
+ const [locale, setLocaleState] = useState(initialLocale);
179
+
180
+ const setLocale = useCallback((newLocale: string) => {
181
+ if (!locales.includes(newLocale)) {
182
+ console.warn(`Locale "${newLocale}" is not supported. Supported: ${locales.join(", ")}`);
183
+ return;
184
+ }
185
+ setLocaleState(newLocale);
186
+ // Persist in cookie
187
+ if (typeof document !== "undefined") {
188
+ document.cookie = `blumen_locale=${newLocale}; path=/; max-age=31536000; SameSite=Lax`;
189
+ }
190
+ }, [locales]);
191
+
192
+ const t = useCallback((key: string, params?: Record<string, string | number>): string => {
193
+ const messages = translations.get(locale);
194
+ let value = messages?.[key] as string | undefined;
195
+
196
+ if (!value) {
197
+ // Fallback: try the key itself as the value (useful for simple cases)
198
+ return interpolate(key, params);
199
+ }
200
+
201
+ return interpolate(value, params);
202
+ }, [locale]);
203
+
204
+ const dir = useMemo(() => RTL_LOCALES.has(locale.split("-")[0]) ? "rtl" as const : "ltr" as const, [locale]);
205
+
206
+ const value = useMemo<I18nContextValue>(() => ({
207
+ locale,
208
+ locales,
209
+ setLocale,
210
+ t,
211
+ dir,
212
+ }), [locale, locales, setLocale, t, dir]);
213
+
214
+ return React.createElement(I18nContext.Provider, { value }, children);
215
+ }
216
+
217
+ /**
218
+ * Interpolate {{param}} placeholders in a translation string.
219
+ */
220
+ function interpolate(str: string, params?: Record<string, string | number>): string {
221
+ if (!params) return str;
222
+ return str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
223
+ return params[key] !== undefined ? String(params[key]) : `{{${key}}}`;
224
+ });
225
+ }
226
+
227
+ // ── Hooks ──────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Access the i18n context in any component.
231
+ */
232
+ export function useTranslation(): I18nContextValue {
233
+ const ctx = useContext(I18nContext);
234
+ if (!ctx) {
235
+ // Fallback for apps without i18n configured
236
+ return {
237
+ locale: "en",
238
+ locales: ["en"],
239
+ setLocale: () => {},
240
+ t: (key: string) => key,
241
+ dir: "ltr",
242
+ };
243
+ }
244
+ return ctx;
245
+ }
246
+
247
+ /**
248
+ * Get just the current locale (lighter than useTranslation).
249
+ */
250
+ export function useLocale(): string {
251
+ const { locale } = useTranslation();
252
+ return locale;
253
+ }
254
+
255
+ // ── Language Switcher Component ────────────────────────────────
256
+
257
+ export interface LanguageSwitcherProps {
258
+ /** Custom class name */
259
+ className?: string;
260
+ /** Custom style */
261
+ style?: React.CSSProperties;
262
+ }
263
+
264
+ /**
265
+ * Ready-to-use language switcher dropdown.
266
+ */
267
+ export function LanguageSwitcher({ className, style }: LanguageSwitcherProps) {
268
+ const { locale, locales, setLocale } = useTranslation();
269
+
270
+ return React.createElement("select", {
271
+ value: locale,
272
+ onChange: (e: any) => setLocale(e.target.value),
273
+ className,
274
+ style: { ...style },
275
+ "aria-label": "Select language",
276
+ },
277
+ ...locales.map((loc) =>
278
+ React.createElement("option", { key: loc, value: loc }, loc.toUpperCase())
279
+ ),
280
+ );
281
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Blumen Prefetch Cache
3
+ *
4
+ * Stores pre-fetched route data so that SPA navigation is instant.
5
+ * Entries expire after 30 seconds to ensure freshness.
6
+ *
7
+ * Used by:
8
+ * - Link component (populates cache on hover)
9
+ * - RouterContext navigate() (reads from cache before fetching)
10
+ */
11
+
12
+ interface CacheEntry {
13
+ data: any;
14
+ timestamp: number;
15
+ }
16
+
17
+ // Cache entries expire after 30 seconds
18
+ const CACHE_TTL = 30_000;
19
+
20
+ // Maximum number of cached entries
21
+ const MAX_ENTRIES = 20;
22
+
23
+ const cache = new Map<string, CacheEntry>();
24
+
25
+ // Track in-flight requests to prevent duplicate fetches
26
+ const inflight = new Map<string, Promise<any>>();
27
+
28
+ /**
29
+ * Get cached data for a path, or undefined if expired/missing.
30
+ */
31
+ export function getCached(path: string): any | undefined {
32
+ const entry = cache.get(path);
33
+ if (!entry) return undefined;
34
+
35
+ // Check if expired
36
+ if (Date.now() - entry.timestamp > CACHE_TTL) {
37
+ cache.delete(path);
38
+ return undefined;
39
+ }
40
+
41
+ return entry.data;
42
+ }
43
+
44
+ /**
45
+ * Store data in the prefetch cache.
46
+ */
47
+ export function setCache(path: string, data: any): void {
48
+ // Evict oldest entries if cache is full
49
+ if (cache.size >= MAX_ENTRIES) {
50
+ const firstKey = cache.keys().next().value;
51
+ if (firstKey) cache.delete(firstKey);
52
+ }
53
+
54
+ cache.set(path, {
55
+ data,
56
+ timestamp: Date.now(),
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Prefetch the JS chunk for a route by injecting a <link rel="prefetch"> tag.
62
+ * This tells the browser to download the chunk in idle time so it's cached
63
+ * when the user actually navigates.
64
+ */
65
+ const prefetchedChunks = new Set<string>();
66
+
67
+ function prefetchChunk(path: string): void {
68
+ if (typeof document === "undefined") return;
69
+
70
+ try {
71
+ // Dynamic import to avoid circular dependency in SSR
72
+ // The routeChunkMap is only available on the client
73
+ const routeChunkMap = (window as any).__BLUMEN_CHUNK_MAP__;
74
+ if (!routeChunkMap) return;
75
+
76
+ const chunkName = routeChunkMap[path];
77
+ if (!chunkName || prefetchedChunks.has(chunkName)) return;
78
+
79
+ prefetchedChunks.add(chunkName);
80
+
81
+ // In dev mode, chunks are served from WDS
82
+ const isDev = process.env.NODE_ENV === "development";
83
+ const chunkSrc = isDev
84
+ ? `http://localhost:3100/static/js/chunks/${chunkName}.js`
85
+ : `/static/js/chunks/${chunkName}.js`;
86
+
87
+ const link = document.createElement("link");
88
+ link.rel = "prefetch";
89
+ link.as = "script";
90
+ link.href = chunkSrc;
91
+ document.head.appendChild(link);
92
+ } catch {
93
+ // Silently ignore — prefetching is best-effort
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Prefetch route data for a path.
99
+ * Returns immediately if already cached or in-flight.
100
+ * Fetches in the background and stores the result.
101
+ * Also prefetches the page's JS chunk for instant code loading.
102
+ */
103
+ export function prefetch(path: string): void {
104
+ // Prefetch the JS chunk (best-effort, non-blocking)
105
+ prefetchChunk(path);
106
+
107
+ // Already cached and fresh
108
+ if (getCached(path) !== undefined) return;
109
+
110
+ // Already in-flight
111
+ if (inflight.has(path)) return;
112
+
113
+ // Start the fetch
114
+ const promise = fetch(path, {
115
+ headers: { "X-Blumen-Data": "1" },
116
+ })
117
+ .then((res) => (res.ok ? res.json() : null))
118
+ .then((data) => {
119
+ if (data) {
120
+ setCache(path, data);
121
+ }
122
+ inflight.delete(path);
123
+ return data;
124
+ })
125
+ .catch(() => {
126
+ inflight.delete(path);
127
+ });
128
+
129
+ inflight.set(path, promise);
130
+ }
131
+
132
+ /**
133
+ * Consume cached data for a path (get and remove from cache).
134
+ * Used by navigate() so data is fetched fresh on next visit.
135
+ */
136
+ export function consumeCached(path: string): any | undefined {
137
+ const data = getCached(path);
138
+ if (data !== undefined) {
139
+ cache.delete(path);
140
+ }
141
+ return data;
142
+ }