cumstack 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/cli/build.js +19 -0
- package/cli/builder.js +172 -0
- package/cli/create.js +36 -0
- package/cli/dev.js +109 -0
- package/cli/index.js +65 -0
- package/index.js +22 -0
- package/package.json +67 -0
- package/src/app/client/Twink.js +57 -0
- package/src/app/client/components.js +28 -0
- package/src/app/client/hmr.js +161 -0
- package/src/app/client/index.js +144 -0
- package/src/app/client.js +599 -0
- package/src/app/index.js +8 -0
- package/src/app/server/hono-utils.js +292 -0
- package/src/app/server/index.js +457 -0
- package/src/app/server/jsx.js +168 -0
- package/src/app/server.js +373 -0
- package/src/app/shared/i18n.js +271 -0
- package/src/app/shared/language-codes.js +199 -0
- package/src/app/shared/reactivity.js +259 -0
- package/src/app/shared/router.js +153 -0
- package/src/app/shared/utils.js +127 -0
- package/templates/monorepo/README.md +27 -0
- package/templates/monorepo/api/package.json +13 -0
- package/templates/monorepo/app/package.json +19 -0
- package/templates/monorepo/app/src/entry.client.jsx +4 -0
- package/templates/monorepo/app/src/entry.server.jsx +14 -0
- package/templates/monorepo/app/src/main.css +7 -0
- package/templates/monorepo/app/src/pages/404.jsx +9 -0
- package/templates/monorepo/app/src/pages/Home.jsx +8 -0
- package/templates/monorepo/app/wrangler.toml +35 -0
- package/templates/monorepo/package.json +18 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cumstack Server Module
|
|
3
|
+
* server-side rendering with hono
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { renderToString } from './server/jsx.js';
|
|
8
|
+
import { h } from './server/jsx.js';
|
|
9
|
+
import { initI18n, setLanguage, extractLanguageFromRoute } from './shared/i18n.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* escape html to prevent xss
|
|
13
|
+
* @param {string} str - String to escape
|
|
14
|
+
* @returns {string} Escaped string
|
|
15
|
+
*/
|
|
16
|
+
function escapeHtml(str) {
|
|
17
|
+
if (!str) return '';
|
|
18
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* create a new server context to avoid global state pollution
|
|
23
|
+
*/
|
|
24
|
+
function createServerContext() {
|
|
25
|
+
return {
|
|
26
|
+
routeRegistry: new Map(),
|
|
27
|
+
i18nConfig: null,
|
|
28
|
+
initialized: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// server context - will be reset per foxgirl call
|
|
33
|
+
let serverContext = createServerContext();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* detect language from request
|
|
37
|
+
* @param {Request} request - HTTP request
|
|
38
|
+
* @param {Object} config - i18n configuration
|
|
39
|
+
* @returns {string} Detected language code
|
|
40
|
+
*/
|
|
41
|
+
function detectLanguageFromRequest(request, config) {
|
|
42
|
+
const url = new URL(request.url);
|
|
43
|
+
|
|
44
|
+
// check url path first if explicit routing
|
|
45
|
+
if (config.explicitRouting) {
|
|
46
|
+
const { language } = extractLanguageFromRoute(url.pathname);
|
|
47
|
+
if (language) return language;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// check accept-language header
|
|
51
|
+
if (config.defaultLng === 'auto') {
|
|
52
|
+
const acceptLang = request.headers.get('accept-language');
|
|
53
|
+
if (acceptLang) {
|
|
54
|
+
const detectedLang = acceptLang
|
|
55
|
+
.split(',')
|
|
56
|
+
.map((l) => l.split(';')[0].trim().split('-')[0])
|
|
57
|
+
.find((l) => config.supportedLanguages.includes(l));
|
|
58
|
+
if (detectedLang) return detectedLang;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// use default or fallback
|
|
63
|
+
return config.defaultLng === 'auto' ? config.fallbackLng : config.defaultLng;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* router component (server)
|
|
68
|
+
* @param {Object} props - Component props
|
|
69
|
+
* @param {Object} [props.i18nOpt] - i18n configuration
|
|
70
|
+
* @param {*} props.children - Child elements
|
|
71
|
+
* @returns {*} Children
|
|
72
|
+
*/
|
|
73
|
+
export function Router({ i18nOpt, children }) {
|
|
74
|
+
if (i18nOpt) {
|
|
75
|
+
serverContext.i18nConfig = i18nOpt;
|
|
76
|
+
// initialize i18n on server
|
|
77
|
+
if (!serverContext.initialized) {
|
|
78
|
+
initI18n({
|
|
79
|
+
defaultLanguage: i18nOpt.defaultLng || i18nOpt.fallbackLng || 'en',
|
|
80
|
+
detectBrowser: false,
|
|
81
|
+
});
|
|
82
|
+
serverContext.initialized = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return children;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* route component (server)
|
|
90
|
+
* @param {Object} props - Component props
|
|
91
|
+
* @param {string} props.path - Route path
|
|
92
|
+
* @param {Function} [props.component] - Component function
|
|
93
|
+
* @param {*} [props.element] - Element to render
|
|
94
|
+
* @param {*} [props.children] - Child elements
|
|
95
|
+
* @returns {null}
|
|
96
|
+
*/
|
|
97
|
+
export function Route({ path, component, element, children }) {
|
|
98
|
+
// register route during server initialization
|
|
99
|
+
// only register if not already registered to prevent duplicates
|
|
100
|
+
if (!serverContext.routeRegistry.has(path)) {
|
|
101
|
+
if (component) serverContext.routeRegistry.set(path, component);
|
|
102
|
+
else if (element) serverContext.routeRegistry.set(path, () => element);
|
|
103
|
+
else if (children) serverContext.routeRegistry.set(path, () => children);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* FoxgirlCreampie component
|
|
110
|
+
* @param {Object} props - Component props
|
|
111
|
+
* @param {*} props.children - Child elements
|
|
112
|
+
* @returns {*} Children
|
|
113
|
+
*/
|
|
114
|
+
export function FoxgirlCreampie({ children }) {
|
|
115
|
+
return children;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* html document template
|
|
120
|
+
* @param {Object} props - Document props
|
|
121
|
+
* @param {string} [props.title] - Page title
|
|
122
|
+
* @param {string} props.content - Rendered HTML content
|
|
123
|
+
* @param {string} props.language - Language code
|
|
124
|
+
* @param {string} [props.theme] - Theme name
|
|
125
|
+
* @param {Array<string>} [props.scripts] - Additional scripts
|
|
126
|
+
* @param {Array<string>} [props.styles] - Additional stylesheets
|
|
127
|
+
* @param {string} [props.appName] - Application name
|
|
128
|
+
* @returns {Object} JSX element
|
|
129
|
+
*/
|
|
130
|
+
function Document({ content, language, theme = 'dark', scripts = [], styles = [], appName = 'cumstack App' }) {
|
|
131
|
+
// sanitize data for json embedding to prevent xss
|
|
132
|
+
const sanitizedData = {
|
|
133
|
+
language: escapeHtml(language),
|
|
134
|
+
theme: escapeHtml(theme),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return h(
|
|
138
|
+
'html',
|
|
139
|
+
{ lang: language },
|
|
140
|
+
h(
|
|
141
|
+
'head',
|
|
142
|
+
{},
|
|
143
|
+
h('meta', { charset: 'utf-8' }),
|
|
144
|
+
h('meta', {
|
|
145
|
+
name: 'viewport',
|
|
146
|
+
content: 'width=device-width, initial-scale=1',
|
|
147
|
+
})
|
|
148
|
+
),
|
|
149
|
+
h(
|
|
150
|
+
'body',
|
|
151
|
+
{ className: `theme-${theme}`, style: 'margin: 0; padding: 0' },
|
|
152
|
+
h('div', { id: 'app', 'data-cumstack-ssr': 'true', innerHTML: content }),
|
|
153
|
+
h('script', {
|
|
154
|
+
id: 'cumstack-data',
|
|
155
|
+
type: 'application/json',
|
|
156
|
+
innerHTML: JSON.stringify(sanitizedData),
|
|
157
|
+
}),
|
|
158
|
+
h('script', { type: 'module', src: '/main.client.js' }),
|
|
159
|
+
...scripts.map((src) => h('script', { type: 'module', src }))
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* match and render route
|
|
166
|
+
* @param {string} pathname - URL pathname
|
|
167
|
+
* @param {string} language - Language code
|
|
168
|
+
* @returns {Object|null} Rendered component and extracted params
|
|
169
|
+
*/
|
|
170
|
+
function matchAndRenderRoute(pathname, language) {
|
|
171
|
+
// remove language prefix if present
|
|
172
|
+
let cleanPath = pathname;
|
|
173
|
+
if (serverContext.i18nConfig?.explicitRouting) {
|
|
174
|
+
const { path } = extractLanguageFromRoute(pathname);
|
|
175
|
+
cleanPath = path || '/';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// try exact match first
|
|
179
|
+
if (serverContext.routeRegistry.has(cleanPath)) {
|
|
180
|
+
const component = serverContext.routeRegistry.get(cleanPath);
|
|
181
|
+
return {
|
|
182
|
+
content: component ? component({ params: {} }) : null,
|
|
183
|
+
params: {},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// try wildcard match with param extraction
|
|
188
|
+
for (const [pattern, component] of serverContext.routeRegistry.entries()) {
|
|
189
|
+
if (pattern.includes(':') || pattern === '*') {
|
|
190
|
+
// extract parameter names
|
|
191
|
+
const paramNames = [];
|
|
192
|
+
const regexPattern = pattern
|
|
193
|
+
.replace(/:(\w+)/g, (_, name) => {
|
|
194
|
+
paramNames.push(name);
|
|
195
|
+
return '([^/]+)';
|
|
196
|
+
})
|
|
197
|
+
.replace('*', '(.*)');
|
|
198
|
+
const regex = new RegExp('^' + regexPattern + '$');
|
|
199
|
+
const match = cleanPath.match(regex);
|
|
200
|
+
if (match) {
|
|
201
|
+
// extract params
|
|
202
|
+
const params = {};
|
|
203
|
+
paramNames.forEach((name, i) => (params[name] = match[i + 1]));
|
|
204
|
+
return { content: component ? component({ params }) : null, params };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 404
|
|
210
|
+
return {
|
|
211
|
+
content: h(
|
|
212
|
+
'div',
|
|
213
|
+
{ className: 'page not-found' },
|
|
214
|
+
h('h1', {}, '404 - Not Found'),
|
|
215
|
+
h('p', {}, 'The page you are looking for does not exist'),
|
|
216
|
+
h('a', { href: '/' }, 'Go Home')
|
|
217
|
+
),
|
|
218
|
+
params: {},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* create server handler with hono
|
|
224
|
+
* @param {Function} app - App component function
|
|
225
|
+
* @param {Object} [options] - Server options
|
|
226
|
+
* @param {Function} [options.middlewares] - Custom middleware function
|
|
227
|
+
* @param {Function} [options.routes] - Custom API routes function
|
|
228
|
+
* @param {string} [options.appName] - Application name
|
|
229
|
+
* @param {string} [options.theme] - Default theme
|
|
230
|
+
* @param {Array<string>} [options.scripts] - Additional scripts
|
|
231
|
+
* @param {Array<string>} [options.styles] - Additional stylesheets
|
|
232
|
+
* @returns {Function} Hono fetch handler
|
|
233
|
+
*/
|
|
234
|
+
export function foxgirl(app, options = {}) {
|
|
235
|
+
serverContext = createServerContext();
|
|
236
|
+
const honoApp = new Hono();
|
|
237
|
+
const { appName = 'cumstack App', theme = 'dark', scripts = [], styles = [] } = options;
|
|
238
|
+
app();
|
|
239
|
+
// middleware: set request context (avoid global pollution)
|
|
240
|
+
honoApp.use('*', async (c, next) => {
|
|
241
|
+
// store env in request context instead of global
|
|
242
|
+
c.set('deployment', c.env?.CF_VERSION_METADATA ?? null);
|
|
243
|
+
c.set('env', c.env ?? {});
|
|
244
|
+
c.set('environment', c.env?.ENVIRONMENT ?? 'development');
|
|
245
|
+
await next();
|
|
246
|
+
});
|
|
247
|
+
if (options.middlewares && typeof options.middlewares === 'function') options.middlewares(honoApp);
|
|
248
|
+
honoApp.use('*', async (c, next) => {
|
|
249
|
+
const language = detectLanguageFromRequest(
|
|
250
|
+
c.req.raw,
|
|
251
|
+
serverContext.i18nConfig || {
|
|
252
|
+
supportedLanguages: ['en'],
|
|
253
|
+
explicitRouting: false,
|
|
254
|
+
defaultLng: 'en',
|
|
255
|
+
fallbackLng: 'en',
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
setLanguage(language);
|
|
259
|
+
c.set('language', language);
|
|
260
|
+
await next();
|
|
261
|
+
});
|
|
262
|
+
// register all routes with hono
|
|
263
|
+
const registeredPatterns = new Set();
|
|
264
|
+
for (const [path, component] of serverContext.routeRegistry.entries()) {
|
|
265
|
+
// handle both explicit language routes and base routes
|
|
266
|
+
const patterns = [];
|
|
267
|
+
if (serverContext.i18nConfig?.explicitRouting) {
|
|
268
|
+
serverContext.i18nConfig.supportedLanguages.forEach((lang) => {
|
|
269
|
+
patterns.push(`/${lang}${path === '/' ? '' : path}`);
|
|
270
|
+
if (path === '/') patterns.push(`/${lang}`);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
patterns.push(path);
|
|
274
|
+
patterns.forEach((pattern) => {
|
|
275
|
+
if (!registeredPatterns.has(pattern)) {
|
|
276
|
+
registeredPatterns.add(pattern);
|
|
277
|
+
honoApp.get(pattern, async (c) => {
|
|
278
|
+
try {
|
|
279
|
+
const language = c.get('language');
|
|
280
|
+
const url = new URL(c.req.url);
|
|
281
|
+
const { content, params } = matchAndRenderRoute(url.pathname, language);
|
|
282
|
+
const appContent = h('div', { className: 'app-root' }, content);
|
|
283
|
+
const html = renderToString(
|
|
284
|
+
Document({
|
|
285
|
+
title: appName,
|
|
286
|
+
content: renderToString(appContent),
|
|
287
|
+
language,
|
|
288
|
+
theme,
|
|
289
|
+
scripts,
|
|
290
|
+
styles,
|
|
291
|
+
appName,
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
const response = c.html('<!DOCTYPE html>' + html);
|
|
295
|
+
response.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
296
|
+
return response;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error(`Route error [${c.req.method} ${c.req.url}]:`, error);
|
|
299
|
+
const errorHtml = `
|
|
300
|
+
<!DOCTYPE html>
|
|
301
|
+
<html>
|
|
302
|
+
<head><title>500 - Server Error</title></head>
|
|
303
|
+
<body>
|
|
304
|
+
<h1>Internal Server Error</h1>
|
|
305
|
+
<p>An error occurred while processing your request.</p>
|
|
306
|
+
${c.get('environment') !== 'production' ? `<pre>${escapeHtml(error.stack)}</pre>` : ''}
|
|
307
|
+
</body>
|
|
308
|
+
</html>
|
|
309
|
+
`;
|
|
310
|
+
return c.html(errorHtml, 500);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (options.routes && typeof options.routes === 'function') options.routes(honoApp);
|
|
317
|
+
// 404 handler
|
|
318
|
+
honoApp.notFound((c) => {
|
|
319
|
+
const language = c.get('language') || 'en';
|
|
320
|
+
const notFoundContent = h(
|
|
321
|
+
'div',
|
|
322
|
+
{ className: 'page not-found' },
|
|
323
|
+
h('h1', {}, '404 - Not Found'),
|
|
324
|
+
h('p', {}, 'The page you are looking for does not exist'),
|
|
325
|
+
h('a', { href: '/' }, 'Go Home')
|
|
326
|
+
);
|
|
327
|
+
const html = renderToString(
|
|
328
|
+
Document({
|
|
329
|
+
title: '404 - Not Found',
|
|
330
|
+
content: renderToString(notFoundContent),
|
|
331
|
+
language,
|
|
332
|
+
theme,
|
|
333
|
+
appName,
|
|
334
|
+
})
|
|
335
|
+
);
|
|
336
|
+
return c.html('<!DOCTYPE html>' + html, 404);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// error handler
|
|
340
|
+
honoApp.onError((err, c) => {
|
|
341
|
+
console.error(`Server error [${c.req.method} ${c.req.url}]:`, err);
|
|
342
|
+
const errorHtml = `
|
|
343
|
+
<!DOCTYPE html>
|
|
344
|
+
<html>
|
|
345
|
+
<head><title>500 - Server Error</title></head>
|
|
346
|
+
<body>
|
|
347
|
+
<h1>Internal Server Error</h1>
|
|
348
|
+
<p>An unexpected error occurred.</p>
|
|
349
|
+
${c.get('environment') !== 'production' ? `<pre>${escapeHtml(err.stack || err.message)}</pre>` : ''}
|
|
350
|
+
</body>
|
|
351
|
+
</html>
|
|
352
|
+
`;
|
|
353
|
+
return c.html(errorHtml, 500);
|
|
354
|
+
});
|
|
355
|
+
// return hono's fetch handler
|
|
356
|
+
return honoApp.fetch.bind(honoApp);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* export route registry for client access
|
|
361
|
+
* @returns {Map} Current route registry
|
|
362
|
+
*/
|
|
363
|
+
export function getRouteRegistry() {
|
|
364
|
+
return serverContext.routeRegistry;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* get i18n configuration
|
|
369
|
+
* @returns {Object|null} Current i18n config
|
|
370
|
+
*/
|
|
371
|
+
export function getI18nConfig() {
|
|
372
|
+
return serverContext.i18nConfig;
|
|
373
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cumstack i18n System
|
|
3
|
+
* built-in internationalization support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createMoan } from './reactivity.js';
|
|
7
|
+
import { isValidLanguageCode } from './language-codes.js';
|
|
8
|
+
|
|
9
|
+
// translation store
|
|
10
|
+
const translations = new Map();
|
|
11
|
+
let i18nConfiguration = {
|
|
12
|
+
supportedLanguages: null, // null means use all registered
|
|
13
|
+
explicitRouting: false,
|
|
14
|
+
defaultLanguage: null,
|
|
15
|
+
fallbackLanguage: null,
|
|
16
|
+
storageKeys: {
|
|
17
|
+
page: 'pLng',
|
|
18
|
+
user: 'uLng',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
let [currentLanguage, setCurrentLanguage] = createMoan(null);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* get supported languages from config or all registered translations
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
function getSupportedLanguages() {
|
|
28
|
+
const registered = Array.from(translations.keys());
|
|
29
|
+
// if supportedLanguages is configured, filter registered translations
|
|
30
|
+
if (i18nConfiguration.supportedLanguages && Array.isArray(i18nConfiguration.supportedLanguages)) {
|
|
31
|
+
return registered.filter((lang) => i18nConfiguration.supportedLanguages.includes(lang));
|
|
32
|
+
}
|
|
33
|
+
return registered;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* register translations for a language
|
|
38
|
+
* @param {string} lang - Language code (must be valid ISO 639-1)
|
|
39
|
+
* @param {Record<string, string>} messages - Translation messages
|
|
40
|
+
*/
|
|
41
|
+
export function registerTranslations(lang, messages) {
|
|
42
|
+
if (!isValidLanguageCode(lang)) {
|
|
43
|
+
console.warn(`Invalid language code: ${lang}. Must be a valid ISO 639-1 code.`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
translations.set(lang, messages);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* get translation for a key
|
|
51
|
+
* @param {string} key - Translation key
|
|
52
|
+
* @param {Record<string, any>} params - Interpolation params
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function t(key, params = {}) {
|
|
56
|
+
const lang = currentLanguage();
|
|
57
|
+
const fallback = i18nConfiguration.fallbackLanguage || getSupportedLanguages()[0];
|
|
58
|
+
const messages = translations.get(lang) || translations.get(fallback) || {};
|
|
59
|
+
let message = messages[key] || key;
|
|
60
|
+
// interpolate params
|
|
61
|
+
Object.keys(params).forEach((param) => (message = message.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param])));
|
|
62
|
+
return message;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* set current language
|
|
67
|
+
* @param {string} lang - Language code
|
|
68
|
+
* @param {boolean} isExplicitRoute - Whether this is from an explicit language route
|
|
69
|
+
*/
|
|
70
|
+
export function setLanguage(lang, isExplicitRoute = false) {
|
|
71
|
+
const supportedLanguages = getSupportedLanguages();
|
|
72
|
+
if (supportedLanguages.includes(lang)) {
|
|
73
|
+
setCurrentLanguage(lang);
|
|
74
|
+
// store in localstorage if available
|
|
75
|
+
if (typeof window !== 'undefined') {
|
|
76
|
+
const pageKey = i18nConfiguration.storageKeys?.page || 'pLng';
|
|
77
|
+
const userKey = i18nConfiguration.storageKeys?.user || 'uLng';
|
|
78
|
+
localStorage.setItem('language', lang);
|
|
79
|
+
// if it's an explicit route, store as preferred language
|
|
80
|
+
if (isExplicitRoute) localStorage.setItem(pageKey, lang);
|
|
81
|
+
// always update user language
|
|
82
|
+
localStorage.setItem(userKey, lang);
|
|
83
|
+
document.documentElement.lang = lang;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* get current language
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function getLanguage() {
|
|
93
|
+
return currentLanguage();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* get all translations for a language
|
|
98
|
+
* @param {string} lang - Language code
|
|
99
|
+
* @returns {Record<string, string>}
|
|
100
|
+
*/
|
|
101
|
+
export function getTranslations(lang = null) {
|
|
102
|
+
const targetLang = lang || currentLanguage();
|
|
103
|
+
return translations.get(targetLang) || {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* detect language from browser
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function detectBrowserLanguage() {
|
|
111
|
+
if (typeof window === 'undefined') return i18nConfiguration.fallbackLanguage || getSupportedLanguages()[0];
|
|
112
|
+
const supportedLanguages = getSupportedLanguages();
|
|
113
|
+
// check localstorage first
|
|
114
|
+
const stored = localStorage.getItem('language');
|
|
115
|
+
if (stored && supportedLanguages.includes(stored)) return stored;
|
|
116
|
+
// check navigator language
|
|
117
|
+
const browserLang = navigator.language.split('-')[0];
|
|
118
|
+
return supportedLanguages.includes(browserLang) ? browserLang : i18nConfiguration.fallbackLanguage || getSupportedLanguages()[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* get user language from localstorage
|
|
123
|
+
* @returns {string|null}
|
|
124
|
+
*/
|
|
125
|
+
export function getUserLanguage() {
|
|
126
|
+
if (typeof window === 'undefined') return null;
|
|
127
|
+
const supportedLanguages = getSupportedLanguages();
|
|
128
|
+
const userKey = i18nConfiguration.storageKeys?.user || 'uLng';
|
|
129
|
+
const userLang = localStorage.getItem(userKey);
|
|
130
|
+
return userLang && supportedLanguages.includes(userLang) ? userLang : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* get preferred language from localstorage
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
export function getPreferredLanguage() {
|
|
138
|
+
if (typeof window === 'undefined') return null;
|
|
139
|
+
const supportedLanguages = getSupportedLanguages();
|
|
140
|
+
const pageKey = i18nConfiguration.storageKeys?.page || 'pLng';
|
|
141
|
+
const prefLang = localStorage.getItem(pageKey);
|
|
142
|
+
return prefLang && supportedLanguages.includes(prefLang) ? prefLang : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* set preferred language
|
|
147
|
+
* @param {string} lang - Language code
|
|
148
|
+
*/
|
|
149
|
+
export function setPreferredLanguage(lang) {
|
|
150
|
+
const supportedLanguages = getSupportedLanguages();
|
|
151
|
+
const pageKey = i18nConfiguration.storageKeys?.page || 'pLng';
|
|
152
|
+
if (typeof window !== 'undefined' && supportedLanguages.includes(lang)) localStorage.setItem(pageKey, lang);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* clear preferred language from localstorage
|
|
157
|
+
*/
|
|
158
|
+
export function clearPreferredLanguage() {
|
|
159
|
+
if (typeof window !== 'undefined') {
|
|
160
|
+
const pageKey = i18nConfiguration.storageKeys?.page || 'pLng';
|
|
161
|
+
localStorage.removeItem(pageKey);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* initialize i18n system
|
|
167
|
+
* @param {Object} options - Initialization options
|
|
168
|
+
* @param {Array<string>} [options.supportedLanguages] - List of supported language codes
|
|
169
|
+
* @param {boolean} [options.explicitRouting] - Enable language prefix in routes
|
|
170
|
+
* @param {string} [options.defaultLanguage] - Default language ('auto' for detection)
|
|
171
|
+
* @param {string} [options.fallbackLanguage] - Fallback language
|
|
172
|
+
* @param {Object} [options.storageKeys] - Custom localStorage keys
|
|
173
|
+
* @param {boolean} [options.detectBrowser] - Enable browser language detection
|
|
174
|
+
* @returns {Object} i18n utilities (language signal, setLanguage, t)
|
|
175
|
+
*/
|
|
176
|
+
export function initI18n(options = {}) {
|
|
177
|
+
// merge configuration
|
|
178
|
+
i18nConfiguration = {
|
|
179
|
+
...i18nConfiguration,
|
|
180
|
+
supportedLanguages: options.supportedLanguages || null,
|
|
181
|
+
explicitRouting: options.explicitRouting !== undefined ? options.explicitRouting : false,
|
|
182
|
+
defaultLanguage: options.defaultLanguage || options.defaultLng || null,
|
|
183
|
+
fallbackLanguage: options.fallbackLanguage || options.fallbackLng || null,
|
|
184
|
+
storageKeys: {
|
|
185
|
+
page: options.storageKeys?.page || options.storageKeys?.preferred || 'pLng',
|
|
186
|
+
user: options.storageKeys?.user || 'uLng',
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const supportedLanguages = getSupportedLanguages();
|
|
190
|
+
const configDefaultLang = i18nConfiguration.defaultLanguage;
|
|
191
|
+
const fallbackLang = i18nConfiguration.fallbackLanguage || supportedLanguages[0];
|
|
192
|
+
const { detectBrowser = true } = options;
|
|
193
|
+
let initialLang = fallbackLang;
|
|
194
|
+
// handle 'auto' detection or browser detection
|
|
195
|
+
if (configDefaultLang === 'auto' || (detectBrowser && typeof window !== 'undefined')) initialLang = detectBrowserLanguage();
|
|
196
|
+
else if (configDefaultLang && configDefaultLang !== 'auto') initialLang = configDefaultLang;
|
|
197
|
+
setCurrentLanguage(initialLang);
|
|
198
|
+
if (typeof window !== 'undefined') document.documentElement.lang = initialLang;
|
|
199
|
+
return {
|
|
200
|
+
language: currentLanguage,
|
|
201
|
+
setLanguage,
|
|
202
|
+
t,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* localize a route path with language prefix
|
|
208
|
+
* @param {string} path - Route path
|
|
209
|
+
* @param {string|null} [lang] - Language code (defaults to current language)
|
|
210
|
+
* @returns {string} Localized route path
|
|
211
|
+
*/
|
|
212
|
+
export function localizeRoute(path, lang = null) {
|
|
213
|
+
const language = lang || currentLanguage();
|
|
214
|
+
const defaultLang = i18nConfiguration.fallbackLanguage || getSupportedLanguages()[0];
|
|
215
|
+
if (!i18nConfiguration.explicitRouting && language === defaultLang) return path;
|
|
216
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
217
|
+
return `/${language}${normalizedPath}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* extract language from localized route
|
|
222
|
+
* @param {string} path - Route path
|
|
223
|
+
* @returns {{ language: string, path: string }}
|
|
224
|
+
*/
|
|
225
|
+
export function extractLanguageFromRoute(path) {
|
|
226
|
+
const segments = path.split('/').filter(Boolean);
|
|
227
|
+
const supportedLanguages = getSupportedLanguages();
|
|
228
|
+
if (segments.length > 0 && supportedLanguages.includes(segments[0])) {
|
|
229
|
+
return {
|
|
230
|
+
language: segments[0],
|
|
231
|
+
path: '/' + segments.slice(1).join('/'),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
language: i18nConfiguration.fallbackLanguage || getSupportedLanguages()[0],
|
|
236
|
+
path,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* create language switcher helper
|
|
242
|
+
* @param {Function} navigate - Navigation function
|
|
243
|
+
* @returns {Function} Language switcher function
|
|
244
|
+
*/
|
|
245
|
+
export function createLanguageSwitcher(navigate) {
|
|
246
|
+
return (lang) => {
|
|
247
|
+
const currentPath = typeof window !== 'undefined' ? window.location.pathname : '/';
|
|
248
|
+
const { path } = extractLanguageFromRoute(currentPath);
|
|
249
|
+
const newPath = localizeRoute(path, lang);
|
|
250
|
+
setLanguage(lang);
|
|
251
|
+
navigate?.(newPath);
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* get the configured default language
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
export function getDefaultLanguage() {
|
|
260
|
+
return i18nConfiguration.fallbackLanguage || getSupportedLanguages()[0];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* get the i18n configuration
|
|
265
|
+
* @returns {Object}
|
|
266
|
+
*/
|
|
267
|
+
export function getI18nConfiguration() {
|
|
268
|
+
return i18nConfiguration;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { currentLanguage };
|