@nuraly/lumenjs 0.1.0 → 0.1.1

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.
Files changed (35) hide show
  1. package/dist/build/build.js +19 -1
  2. package/dist/build/serve-i18n.d.ts +6 -0
  3. package/dist/build/serve-i18n.js +26 -0
  4. package/dist/build/serve-loaders.js +7 -2
  5. package/dist/build/serve-ssr.js +3 -3
  6. package/dist/build/serve.js +20 -4
  7. package/dist/dev-server/config.d.ts +6 -0
  8. package/dist/dev-server/config.js +27 -1
  9. package/dist/dev-server/index-html.d.ts +7 -0
  10. package/dist/dev-server/index-html.js +15 -2
  11. package/dist/dev-server/middleware/locale.d.ts +20 -0
  12. package/dist/dev-server/middleware/locale.js +55 -0
  13. package/dist/dev-server/plugins/vite-plugin-i18n.d.ts +15 -0
  14. package/dist/dev-server/plugins/vite-plugin-i18n.js +71 -0
  15. package/dist/dev-server/plugins/vite-plugin-loaders.js +16 -2
  16. package/dist/dev-server/plugins/vite-plugin-routes.js +1 -11
  17. package/dist/dev-server/plugins/vite-plugin-source-annotator.js +8 -1
  18. package/dist/dev-server/plugins/vite-plugin-virtual-modules.d.ts +5 -0
  19. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +52 -22
  20. package/dist/dev-server/server.d.ts +1 -1
  21. package/dist/dev-server/server.js +37 -10
  22. package/dist/dev-server/ssr-render.d.ts +1 -1
  23. package/dist/dev-server/ssr-render.js +23 -8
  24. package/dist/editor/editor-bridge.d.ts +1 -1
  25. package/dist/editor/inline-text-edit.js +15 -4
  26. package/dist/runtime/i18n.d.ts +56 -0
  27. package/dist/runtime/i18n.js +100 -0
  28. package/dist/runtime/router-data.js +9 -0
  29. package/dist/runtime/router-hydration.js +15 -1
  30. package/dist/runtime/router.d.ts +4 -0
  31. package/dist/runtime/router.js +20 -4
  32. package/dist/shared/types.d.ts +7 -0
  33. package/dist/shared/utils.d.ts +7 -0
  34. package/dist/shared/utils.js +16 -0
  35. package/package.json +1 -1
@@ -4,6 +4,7 @@ import fs from 'fs';
4
4
  import { getSharedViteConfig } from '../dev-server/server.js';
5
5
  import { readProjectConfig } from '../dev-server/config.js';
6
6
  import { generateIndexHtml } from '../dev-server/index-html.js';
7
+ import { filePathToTagName } from '../shared/utils.js';
7
8
  import { scanPages, scanLayouts, scanApiRoutes, getLayoutDirsForPage } from './scan.js';
8
9
  export async function buildProject(options) {
9
10
  const { projectDir } = options;
@@ -18,7 +19,7 @@ export async function buildProject(options) {
18
19
  fs.rmSync(outDir, { recursive: true });
19
20
  }
20
21
  fs.mkdirSync(outDir, { recursive: true });
21
- const { title, integrations } = readProjectConfig(projectDir);
22
+ const { title, integrations, i18n: i18nConfig } = readProjectConfig(projectDir);
22
23
  const shared = getSharedViteConfig(projectDir, { mode: 'production', integrations });
23
24
  // Scan pages, layouts, and API routes for the manifest
24
25
  const pageEntries = scanPages(pagesDir);
@@ -141,14 +142,30 @@ export async function buildProject(options) {
141
142
  fs.unlinkSync(ssrEntryPath);
142
143
  }
143
144
  }
145
+ // --- Copy locales ---
146
+ if (i18nConfig) {
147
+ const localesDir = path.join(projectDir, 'locales');
148
+ const outLocalesDir = path.join(outDir, 'locales');
149
+ if (fs.existsSync(localesDir)) {
150
+ fs.mkdirSync(outLocalesDir, { recursive: true });
151
+ for (const file of fs.readdirSync(localesDir)) {
152
+ if (file.endsWith('.json')) {
153
+ fs.copyFileSync(path.join(localesDir, file), path.join(outLocalesDir, file));
154
+ }
155
+ }
156
+ console.log(`[LumenJS] Copied ${i18nConfig.locales.length} locale(s) to output.`);
157
+ }
158
+ }
144
159
  // --- Write manifest ---
145
160
  const manifest = {
146
161
  routes: pageEntries.map(e => {
147
162
  const routeLayouts = getLayoutDirsForPage(e.filePath, pagesDir, layoutEntries);
163
+ const relPath = path.relative(pagesDir, e.filePath).replace(/\\/g, '/');
148
164
  return {
149
165
  path: e.routePath,
150
166
  module: e.hasLoader ? `pages/${e.name}.js` : '',
151
167
  hasLoader: e.hasLoader,
168
+ tagName: filePathToTagName(relPath),
152
169
  ...(routeLayouts.length > 0 ? { layouts: routeLayouts } : {}),
153
170
  };
154
171
  }),
@@ -162,6 +179,7 @@ export async function buildProject(options) {
162
179
  module: e.hasLoader ? (e.dir ? `layouts/${e.dir}/_layout.js` : 'layouts/_layout.js') : '',
163
180
  hasLoader: e.hasLoader,
164
181
  })),
182
+ ...(i18nConfig ? { i18n: i18nConfig } : {}),
165
183
  };
166
184
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
167
185
  console.log('[LumenJS] Build complete.');
@@ -0,0 +1,6 @@
1
+ import http from 'http';
2
+ /**
3
+ * Handle `/__nk_i18n/<locale>.json` requests in production.
4
+ * Reads from the built `locales/` directory.
5
+ */
6
+ export declare function handleI18nRequest(localesDir: string, locales: string[], pathname: string, req: http.IncomingMessage, res: http.ServerResponse): boolean;
@@ -0,0 +1,26 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { sendCompressed } from './serve-static.js';
4
+ /**
5
+ * Handle `/__nk_i18n/<locale>.json` requests in production.
6
+ * Reads from the built `locales/` directory.
7
+ */
8
+ export function handleI18nRequest(localesDir, locales, pathname, req, res) {
9
+ const match = pathname.match(/^\/__nk_i18n\/([a-z]{2}(?:-[a-zA-Z]+)?)\.json$/);
10
+ if (!match)
11
+ return false;
12
+ const locale = match[1];
13
+ if (!locales.includes(locale)) {
14
+ res.writeHead(404, { 'Content-Type': 'application/json' });
15
+ res.end(JSON.stringify({ error: 'Unknown locale' }));
16
+ return true;
17
+ }
18
+ const filePath = path.join(localesDir, `${locale}.json`);
19
+ if (!fs.existsSync(filePath)) {
20
+ sendCompressed(req, res, 200, 'application/json', '{}');
21
+ return true;
22
+ }
23
+ const content = fs.readFileSync(filePath, 'utf-8');
24
+ sendCompressed(req, res, 200, 'application/json', content);
25
+ return true;
26
+ }
@@ -31,7 +31,10 @@ export async function handleLayoutLoaderRequest(manifest, serverDir, queryString
31
31
  res.end(JSON.stringify({ __nk_no_loader: true }));
32
32
  return;
33
33
  }
34
- const result = await mod.loader({ params: {}, query: {}, url: `/__layout/${dir}`, headers });
34
+ // Parse locale from query for layout loader
35
+ const locale = query.__locale;
36
+ delete query.__locale;
37
+ const result = await mod.loader({ params: {}, query: {}, url: `/__layout/${dir}`, headers, locale });
35
38
  if (isRedirectResponse(result)) {
36
39
  res.writeHead(result.status || 302, { Location: result.location });
37
40
  res.end();
@@ -91,7 +94,9 @@ export async function handleLoaderRequest(manifest, serverDir, pagesDir, pathnam
91
94
  res.end(JSON.stringify({ __nk_no_loader: true }));
92
95
  return;
93
96
  }
94
- const result = await mod.loader({ params: matched.params, query, url: pagePath, headers });
97
+ const locale = query.__locale;
98
+ delete query.__locale;
99
+ const result = await mod.loader({ params: matched.params, query, url: pagePath, headers, locale });
95
100
  if (isRedirectResponse(result)) {
96
101
  res.writeHead(result.status || 302, { Location: result.location });
97
102
  res.end();
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { stripOuterLitMarkers, dirToLayoutTagName, findTagName, isRedirectResponse } from '../shared/utils.js';
3
+ import { stripOuterLitMarkers, dirToLayoutTagName, isRedirectResponse } from '../shared/utils.js';
4
4
  import { matchRoute } from '../shared/route-matching.js';
5
5
  import { sendCompressed } from './serve-static.js';
6
6
  export async function handlePageRoute(manifest, serverDir, pagesDir, pathname, queryString, indexHtmlShell, title, ssrRuntime, req, res) {
@@ -23,8 +23,8 @@ export async function handlePageRoute(manifest, serverDir, pagesDir, pathname, q
23
23
  return;
24
24
  }
25
25
  }
26
- // Find tag name from module
27
- const tagName = findTagName(mod);
26
+ // Use tag name from route manifest (matches client router)
27
+ const tagName = matched.route.tagName;
28
28
  // Run layout loaders
29
29
  const layoutDirs = (allMatched || matched).route.layouts || [];
30
30
  const layoutsData = [];
@@ -8,6 +8,8 @@ import { handleApiRoute } from './serve-api.js';
8
8
  import { handleLoaderRequest, handleLayoutLoaderRequest } from './serve-loaders.js';
9
9
  import { handlePageRoute } from './serve-ssr.js';
10
10
  import { renderErrorPage } from './error-page.js';
11
+ import { handleI18nRequest } from './serve-i18n.js';
12
+ import { resolveLocale } from '../dev-server/middleware/locale.js';
11
13
  export async function serveProject(options) {
12
14
  const { projectDir } = options;
13
15
  const port = options.port || 3000;
@@ -21,6 +23,7 @@ export async function serveProject(options) {
21
23
  }
22
24
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
23
25
  const { title } = readProjectConfig(projectDir);
26
+ const localesDir = path.join(outDir, 'locales');
24
27
  // Read the built index.html shell
25
28
  const indexHtmlPath = path.join(clientDir, 'index.html');
26
29
  if (!fs.existsSync(indexHtmlPath)) {
@@ -54,18 +57,31 @@ export async function serveProject(options) {
54
57
  if (served)
55
58
  return;
56
59
  }
57
- // 3. Layout loader endpoint
60
+ // 3. i18n translation endpoint
61
+ if (pathname.startsWith('/__nk_i18n/') && manifest.i18n) {
62
+ handleI18nRequest(localesDir, manifest.i18n.locales, pathname, req, res);
63
+ return;
64
+ }
65
+ // 4. Layout loader endpoint
58
66
  if (pathname === '/__nk_loader/__layout/' || pathname === '/__nk_loader/__layout') {
59
67
  await handleLayoutLoaderRequest(manifest, serverDir, queryString, req.headers, res);
60
68
  return;
61
69
  }
62
- // 4. Loader endpoint for client-side navigation
70
+ // 5. Loader endpoint for client-side navigation
63
71
  if (pathname.startsWith('/__nk_loader/')) {
64
72
  await handleLoaderRequest(manifest, serverDir, pagesDir, pathname, queryString, req.headers, res);
65
73
  return;
66
74
  }
67
- // 5. Page routes SSR render
68
- await handlePageRoute(manifest, serverDir, pagesDir, pathname, queryString, indexHtmlShell, title, ssrRuntime, req, res);
75
+ // 6. Resolve locale and strip prefix for page routing
76
+ let resolvedPathname = pathname;
77
+ let locale;
78
+ if (manifest.i18n) {
79
+ const result = resolveLocale(pathname, manifest.i18n, req.headers);
80
+ resolvedPathname = result.pathname;
81
+ locale = result.locale;
82
+ }
83
+ // 7. Page routes — SSR render
84
+ await handlePageRoute(manifest, serverDir, pagesDir, resolvedPathname, queryString, indexHtmlShell, title, ssrRuntime, req, res);
69
85
  }
70
86
  catch (err) {
71
87
  console.error('[LumenJS] Request error:', err);
@@ -1,6 +1,12 @@
1
+ export interface I18nConfig {
2
+ locales: string[];
3
+ defaultLocale: string;
4
+ prefixDefault: boolean;
5
+ }
1
6
  export interface ProjectConfig {
2
7
  title: string;
3
8
  integrations: string[];
9
+ i18n?: I18nConfig;
4
10
  }
5
11
  /**
6
12
  * Reads the project config from lumenjs.config.ts.
@@ -26,7 +26,33 @@ export function readProjectConfig(projectDir) {
26
26
  }
27
27
  catch { /* use defaults */ }
28
28
  }
29
- return { title, integrations };
29
+ // Parse i18n config (reuse the same file read)
30
+ let i18n;
31
+ if (fs.existsSync(configPath)) {
32
+ try {
33
+ const configContent = fs.readFileSync(configPath, 'utf-8');
34
+ const i18nMatch = configContent.match(/i18n\s*:\s*\{([\s\S]*?)\}/);
35
+ if (i18nMatch) {
36
+ const block = i18nMatch[1];
37
+ const localesMatch = block.match(/locales\s*:\s*\[([^\]]*)\]/);
38
+ const defaultMatch = block.match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
39
+ const prefixMatch = block.match(/prefixDefault\s*:\s*(true|false)/);
40
+ if (localesMatch && defaultMatch) {
41
+ const locales = localesMatch[1]
42
+ .split(',')
43
+ .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
44
+ .filter(Boolean);
45
+ i18n = {
46
+ locales,
47
+ defaultLocale: defaultMatch[1],
48
+ prefixDefault: prefixMatch ? prefixMatch[1] === 'true' : false,
49
+ };
50
+ }
51
+ }
52
+ }
53
+ catch { /* ignore */ }
54
+ }
55
+ return { title, integrations, ...(i18n ? { i18n } : {}) };
30
56
  }
31
57
  /**
32
58
  * Reads the project title from lumenjs.config.ts (or returns default).
@@ -8,6 +8,13 @@ export interface IndexHtmlOptions {
8
8
  data: any;
9
9
  }>;
10
10
  integrations?: string[];
11
+ locale?: string;
12
+ i18nConfig?: {
13
+ locales: string[];
14
+ defaultLocale: string;
15
+ prefixDefault: boolean;
16
+ };
17
+ translations?: Record<string, string>;
11
18
  }
12
19
  /**
13
20
  * Generates the index.html shell that loads the LumenJS app.
@@ -19,16 +19,28 @@ export function generateIndexHtml(options) {
19
19
  : options.loaderData;
20
20
  loaderDataScript = `<script type="application/json" id="__nk_ssr_data__">${JSON.stringify(ssrData).replace(/</g, '\\u003c')}</script>`;
21
21
  }
22
+ // i18n: inline translations and config for client hydration
23
+ let i18nScript = '';
24
+ if (options.i18nConfig && options.locale && options.translations) {
25
+ const i18nData = {
26
+ config: options.i18nConfig,
27
+ locale: options.locale,
28
+ translations: options.translations,
29
+ };
30
+ i18nScript = `<script type="application/json" id="__nk_i18n__">${JSON.stringify(i18nData).replace(/</g, '\\u003c')}</script>`;
31
+ }
32
+ // i18n module is loaded via imports from router-hydration, no separate script needed
22
33
  const hydrateScript = isSSR
23
34
  ? `<script type="module">import '@lit-labs/ssr-client/lit-element-hydrate-support.js';</script>`
24
35
  : '';
36
+ const htmlLang = options.locale || 'en';
25
37
  return `<!DOCTYPE html>
26
- <html lang="en">
38
+ <html lang="${htmlLang}">
27
39
  <head>
28
40
  <meta charset="UTF-8" />
29
41
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
30
42
  <title>${escapeHtml(options.title)}</title>
31
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@nuralyui/themes@latest/dist/default.css">${options.integrations?.includes('tailwind') ? '\n <link rel="stylesheet" href="/styles/tailwind.css">' : ''}
43
+ ${options.integrations?.includes('nuralyui') ? '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@nuralyui/themes@latest/dist/default.css">' : ''}${options.integrations?.includes('tailwind') ? '\n <script type="module">import "/styles/tailwind.css";</script>' : ''}
32
44
  <style>
33
45
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
34
46
  body { font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; }
@@ -36,6 +48,7 @@ export function generateIndexHtml(options) {
36
48
  </style>
37
49
  </head>
38
50
  <body>
51
+ ${i18nScript}
39
52
  ${loaderDataScript}
40
53
  ${appTag}
41
54
  ${hydrateScript}
@@ -0,0 +1,20 @@
1
+ export interface I18nConfig {
2
+ locales: string[];
3
+ defaultLocale: string;
4
+ prefixDefault: boolean;
5
+ }
6
+ export interface LocaleResult {
7
+ locale: string;
8
+ pathname: string;
9
+ }
10
+ /**
11
+ * Extract the locale from the request URL, cookie, or Accept-Language header.
12
+ * Returns the resolved locale and the pathname with the locale prefix stripped.
13
+ *
14
+ * Resolution order:
15
+ * 1. URL prefix: /fr/about → locale "fr", pathname "/about"
16
+ * 2. Cookie "nk-locale"
17
+ * 3. Accept-Language header
18
+ * 4. Config defaultLocale
19
+ */
20
+ export declare function resolveLocale(pathname: string, config: I18nConfig, headers?: Record<string, string | string[] | undefined>): LocaleResult;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Extract the locale from the request URL, cookie, or Accept-Language header.
3
+ * Returns the resolved locale and the pathname with the locale prefix stripped.
4
+ *
5
+ * Resolution order:
6
+ * 1. URL prefix: /fr/about → locale "fr", pathname "/about"
7
+ * 2. Cookie "nk-locale"
8
+ * 3. Accept-Language header
9
+ * 4. Config defaultLocale
10
+ */
11
+ export function resolveLocale(pathname, config, headers) {
12
+ // 1. URL prefix
13
+ for (const loc of config.locales) {
14
+ if (pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)) {
15
+ return { locale: loc, pathname: pathname.slice(loc.length + 1) || '/' };
16
+ }
17
+ }
18
+ // 2. Cookie
19
+ const cookieHeader = headers?.cookie;
20
+ if (typeof cookieHeader === 'string') {
21
+ const match = cookieHeader.match(/(?:^|;\s*)nk-locale=([^;]+)/);
22
+ if (match && config.locales.includes(match[1])) {
23
+ return { locale: match[1], pathname };
24
+ }
25
+ }
26
+ // 3. Accept-Language
27
+ const acceptLang = headers?.['accept-language'];
28
+ if (typeof acceptLang === 'string') {
29
+ const preferred = parseAcceptLanguage(acceptLang);
30
+ for (const lang of preferred) {
31
+ const short = lang.split('-')[0];
32
+ if (config.locales.includes(short)) {
33
+ return { locale: short, pathname };
34
+ }
35
+ if (config.locales.includes(lang)) {
36
+ return { locale: lang, pathname };
37
+ }
38
+ }
39
+ }
40
+ // 4. Default
41
+ return { locale: config.defaultLocale, pathname };
42
+ }
43
+ /**
44
+ * Parse the Accept-Language header into a sorted list of language codes.
45
+ */
46
+ function parseAcceptLanguage(header) {
47
+ return header
48
+ .split(',')
49
+ .map(part => {
50
+ const [lang, q] = part.trim().split(';q=');
51
+ return { lang: lang.trim().toLowerCase(), q: q ? parseFloat(q) : 1 };
52
+ })
53
+ .sort((a, b) => b.q - a.q)
54
+ .map(e => e.lang);
55
+ }
@@ -0,0 +1,15 @@
1
+ import { Plugin } from 'vite';
2
+ import type { I18nConfig } from '../middleware/locale.js';
3
+ /**
4
+ * Vite plugin for LumenJS i18n support.
5
+ *
6
+ * - Serves `/__nk_i18n/<locale>.json` with translation files
7
+ * - Provides a virtual module `@lumenjs/i18n` that re-exports the runtime
8
+ * - Watches locale files for HMR
9
+ */
10
+ export declare function i18nPlugin(projectDir: string, config: I18nConfig): Plugin;
11
+ /**
12
+ * Load translations for a locale from the project's locales/ directory.
13
+ * Used during SSR to inline translations in the HTML.
14
+ */
15
+ export declare function loadTranslationsFromDisk(projectDir: string, locale: string): Record<string, string>;
@@ -0,0 +1,71 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ /**
4
+ * Vite plugin for LumenJS i18n support.
5
+ *
6
+ * - Serves `/__nk_i18n/<locale>.json` with translation files
7
+ * - Provides a virtual module `@lumenjs/i18n` that re-exports the runtime
8
+ * - Watches locale files for HMR
9
+ */
10
+ export function i18nPlugin(projectDir, config) {
11
+ const localesDir = path.join(projectDir, 'locales');
12
+ return {
13
+ name: 'lumenjs-i18n',
14
+ configureServer(server) {
15
+ // Serve translation JSON files
16
+ server.middlewares.use((req, res, next) => {
17
+ if (!req.url?.startsWith('/__nk_i18n/'))
18
+ return next();
19
+ const match = req.url.match(/^\/__nk_i18n\/([a-z]{2}(?:-[a-zA-Z]+)?)\.json$/);
20
+ if (!match)
21
+ return next();
22
+ const locale = match[1];
23
+ if (!config.locales.includes(locale)) {
24
+ res.statusCode = 404;
25
+ res.end(JSON.stringify({ error: 'Unknown locale' }));
26
+ return;
27
+ }
28
+ const filePath = path.join(localesDir, `${locale}.json`);
29
+ if (!fs.existsSync(filePath)) {
30
+ res.statusCode = 200;
31
+ res.setHeader('Content-Type', 'application/json');
32
+ res.end('{}');
33
+ return;
34
+ }
35
+ try {
36
+ const content = fs.readFileSync(filePath, 'utf-8');
37
+ // Validate JSON
38
+ JSON.parse(content);
39
+ res.statusCode = 200;
40
+ res.setHeader('Content-Type', 'application/json');
41
+ res.setHeader('Cache-Control', 'no-store');
42
+ res.end(content);
43
+ }
44
+ catch {
45
+ res.statusCode = 500;
46
+ res.setHeader('Content-Type', 'application/json');
47
+ res.end(JSON.stringify({ error: 'Invalid locale file' }));
48
+ }
49
+ });
50
+ // Watch locale directory for changes and trigger HMR
51
+ if (fs.existsSync(localesDir)) {
52
+ server.watcher.add(localesDir);
53
+ }
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * Load translations for a locale from the project's locales/ directory.
59
+ * Used during SSR to inline translations in the HTML.
60
+ */
61
+ export function loadTranslationsFromDisk(projectDir, locale) {
62
+ const filePath = path.join(projectDir, 'locales', `${locale}.json`);
63
+ if (!fs.existsSync(filePath))
64
+ return {};
65
+ try {
66
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
67
+ }
68
+ catch {
69
+ return {};
70
+ }
71
+ }
@@ -83,7 +83,10 @@ export function lumenLoadersPlugin(pagesDir) {
83
83
  res.end(JSON.stringify({ __nk_no_loader: true }));
84
84
  return;
85
85
  }
86
- const result = await mod.loader({ params, query, url: pagePath, headers: req.headers });
86
+ // Extract locale from query if provided by the client router
87
+ const locale = query.__locale;
88
+ delete query.__locale;
89
+ const result = await mod.loader({ params, query, url: pagePath, headers: req.headers, locale });
87
90
  if (isRedirectResponse(result)) {
88
91
  res.statusCode = result.status || 302;
89
92
  res.setHeader('Location', result.location);
@@ -195,7 +198,18 @@ async function handleLayoutLoader(server, pagesDir, dir, req, res) {
195
198
  res.end(JSON.stringify({ __nk_no_loader: true }));
196
199
  return;
197
200
  }
198
- const result = await mod.loader({ params: {}, query: {}, url: `/__layout/${dir}`, headers: req.headers });
201
+ // Parse locale from query for layout loader requests
202
+ const query = {};
203
+ const reqUrl = req.url || '';
204
+ const qs = reqUrl.split('?')[1];
205
+ if (qs) {
206
+ for (const pair of qs.split('&')) {
207
+ const [key, val] = pair.split('=');
208
+ query[decodeURIComponent(key)] = decodeURIComponent(val || '');
209
+ }
210
+ }
211
+ const locale = query.__locale;
212
+ const result = await mod.loader({ params: {}, query: {}, url: `/__layout/${dir}`, headers: req.headers, locale });
199
213
  if (isRedirectResponse(result)) {
200
214
  res.statusCode = result.status || 302;
201
215
  res.setHeader('Location', result.location);
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { dirToLayoutTagName, fileHasLoader, filePathToRoute } from '../../shared/utils.js';
3
+ import { dirToLayoutTagName, fileHasLoader, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
4
4
  const VIRTUAL_MODULE_ID = 'virtual:lumenjs-routes';
5
5
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
6
6
  /**
@@ -68,16 +68,6 @@ export function lumenRoutesPlugin(pagesDir) {
68
68
  }
69
69
  }
70
70
  }
71
- function filePathToTagName(filePath) {
72
- const name = filePath
73
- .replace(/\.(ts|js)$/, '')
74
- .replace(/\\/g, '-')
75
- .replace(/\//g, '-')
76
- .replace(/\[\.\.\.([^\]]+)\]/g, '$1')
77
- .replace(/\[([^\]]+)\]/g, '$1')
78
- .toLowerCase();
79
- return `page-${name}`;
80
- }
81
71
  /** Build the layout chain for a route based on its file path within pages/ */
82
72
  function getLayoutChain(componentPath, layouts) {
83
73
  const relativeToPages = path.relative(pagesDir, componentPath).replace(/\\/g, '/');
@@ -29,7 +29,14 @@ export function sourceAnnotatorPlugin(projectDir) {
29
29
  const escaped = content.trim().replace(/"/g, '&quot;').replace(/\$\{/g, '__NK_EXPR__');
30
30
  return `<${tag}${attrStr} data-nk-dynamic="${escaped}">${content}</`;
31
31
  });
32
- return 'html`' + dynamicAnnotated + '`';
32
+ // Detect t('key') calls inside template expressions and add data-nk-i18n-key
33
+ const i18nAnnotated = dynamicAnnotated.replace(/<(h[1-6]|p|span|a|label|li|button|div)(\s[^>]*)?>([^<]*\$\{t\(['"]([^'"]+)['"]\)\}[^<]*)<\//gi, (m, tag, attrs, content, key) => {
34
+ const attrStr = attrs || '';
35
+ if (attrStr.includes('data-nk-i18n-key'))
36
+ return m;
37
+ return `<${tag}${attrStr} data-nk-i18n-key="${key}">${content}</`;
38
+ });
39
+ return 'html`' + i18nAnnotated + '`';
33
40
  });
34
41
  if (transformed !== code) {
35
42
  return { code: transformed, map: null };
@@ -1,5 +1,10 @@
1
1
  import { Plugin } from 'vite';
2
2
  /**
3
3
  * Virtual module plugin — serves compiled LumenJS runtime and editor modules.
4
+ * Rewrites relative imports between split sub-modules to virtual module paths.
5
+ *
6
+ * i18n is resolved via resolve.alias (physical file) rather than as a virtual
7
+ * module, because Vite's import-analysis rejects bare @-prefixed specifiers
8
+ * that go through the virtual module path.
4
9
  */
5
10
  export declare function virtualModulesPlugin(runtimeDir: string, editorDir: string): Plugin;
@@ -2,36 +2,66 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  /**
4
4
  * Virtual module plugin — serves compiled LumenJS runtime and editor modules.
5
+ * Rewrites relative imports between split sub-modules to virtual module paths.
6
+ *
7
+ * i18n is resolved via resolve.alias (physical file) rather than as a virtual
8
+ * module, because Vite's import-analysis rejects bare @-prefixed specifiers
9
+ * that go through the virtual module path.
5
10
  */
6
11
  export function virtualModulesPlugin(runtimeDir, editorDir) {
12
+ const runtimeModules = {
13
+ 'app-shell': 'app-shell.js',
14
+ 'router': 'router.js',
15
+ 'router-data': 'router-data.js',
16
+ 'router-hydration': 'router-hydration.js',
17
+ 'i18n': 'i18n.js',
18
+ };
19
+ // Modules resolved via resolve.alias instead of virtual module.
20
+ // They still appear in the map so relative import rewrites work.
21
+ const aliasedModules = new Set(['i18n']);
22
+ const editorModules = {
23
+ 'editor-bridge': 'editor-bridge.js',
24
+ 'element-annotator': 'element-annotator.js',
25
+ 'click-select': 'click-select.js',
26
+ 'hover-detect': 'hover-detect.js',
27
+ 'inline-text-edit': 'inline-text-edit.js',
28
+ };
29
+ function rewriteRelativeImports(code, modules) {
30
+ for (const name of Object.keys(modules)) {
31
+ const file = modules[name];
32
+ // Aliased modules use @lumenjs/name (resolved by Vite alias).
33
+ // Virtual modules use /@lumenjs/name (resolved by this plugin).
34
+ const prefix = aliasedModules.has(name) ? '@lumenjs' : '/@lumenjs';
35
+ code = code.replace(new RegExp(`from\\s+['"]\\.\\/${file.replace('.', '\\.')}['"]`, 'g'), `from '${prefix}/${name}'`);
36
+ }
37
+ return code;
38
+ }
7
39
  return {
8
40
  name: 'lumenjs-virtual-modules',
41
+ enforce: 'pre',
9
42
  resolveId(id) {
10
- if (id === '/@lumenjs/app-shell')
11
- return '\0lumenjs:app-shell';
12
- if (id === '/@lumenjs/router')
13
- return '\0lumenjs:router';
14
- if (id === '/@lumenjs/editor-bridge')
15
- return '\0lumenjs:editor-bridge';
16
- if (id === '/@lumenjs/element-annotator')
17
- return '\0lumenjs:element-annotator';
43
+ const match = id.match(/^\/@lumenjs\/(.+)$/);
44
+ if (!match)
45
+ return;
46
+ const name = match[1];
47
+ // Skip aliased modules — they're resolved via resolve.alias
48
+ if (aliasedModules.has(name))
49
+ return;
50
+ if (runtimeModules[name] || editorModules[name]) {
51
+ return `\0lumenjs:${name}`;
52
+ }
18
53
  },
19
54
  load(id) {
20
- if (id === '\0lumenjs:app-shell') {
21
- let code = fs.readFileSync(path.join(runtimeDir, 'app-shell.js'), 'utf-8');
22
- code = code.replace(/from\s+['"]\.\/router\.js['"]/g, "from '/@lumenjs/router'");
23
- return code;
24
- }
25
- if (id === '\0lumenjs:router') {
26
- return fs.readFileSync(path.join(runtimeDir, 'router.js'), 'utf-8');
27
- }
28
- if (id === '\0lumenjs:editor-bridge') {
29
- let code = fs.readFileSync(path.join(editorDir, 'editor-bridge.js'), 'utf-8');
30
- code = code.replace(/from\s+['"]\.\/element-annotator\.js['"]/g, "from '/@lumenjs/element-annotator'");
31
- return code;
55
+ if (!id.startsWith('\0lumenjs:'))
56
+ return;
57
+ const name = id.slice('\0lumenjs:'.length);
58
+ if (runtimeModules[name]) {
59
+ const code = fs.readFileSync(path.join(runtimeDir, runtimeModules[name]), 'utf-8');
60
+ return rewriteRelativeImports(code, runtimeModules);
32
61
  }
33
- if (id === '\0lumenjs:element-annotator') {
34
- return fs.readFileSync(path.join(editorDir, 'element-annotator.js'), 'utf-8');
62
+ if (editorModules[name]) {
63
+ const code = fs.readFileSync(path.join(editorDir, editorModules[name]), 'utf-8');
64
+ return rewriteRelativeImports(code, editorModules);
35
65
  }
36
66
  }
37
67
  };
@@ -1,6 +1,6 @@
1
1
  import { ViteDevServer, UserConfig, Plugin } from 'vite';
2
2
  export { readProjectConfig, readProjectTitle, getLumenJSNodeModules, getLumenJSDirs } from './config.js';
3
- export type { ProjectConfig } from './config.js';
3
+ export type { ProjectConfig, I18nConfig } from './config.js';
4
4
  export { getNuralyUIAliases, resolveNuralyUIPaths } from './nuralyui-aliases.js';
5
5
  export interface DevServerOptions {
6
6
  projectDir: string;
@@ -15,6 +15,8 @@ import { autoImportPlugin } from './plugins/vite-plugin-auto-import.js';
15
15
  import { litHmrPlugin } from './plugins/vite-plugin-lit-hmr.js';
16
16
  import { sourceAnnotatorPlugin } from './plugins/vite-plugin-source-annotator.js';
17
17
  import { virtualModulesPlugin } from './plugins/vite-plugin-virtual-modules.js';
18
+ import { i18nPlugin, loadTranslationsFromDisk } from './plugins/vite-plugin-i18n.js';
19
+ import { resolveLocale } from './middleware/locale.js';
18
20
  // Re-export for backwards compatibility
19
21
  export { readProjectConfig, readProjectTitle, getLumenJSNodeModules, getLumenJSDirs } from './config.js';
20
22
  export { getNuralyUIAliases, resolveNuralyUIPaths } from './nuralyui-aliases.js';
@@ -28,14 +30,21 @@ export function getSharedViteConfig(projectDir, options) {
28
30
  const pagesDir = path.join(projectDir, 'pages');
29
31
  const lumenNodeModules = getLumenJSNodeModules();
30
32
  const { runtimeDir, editorDir } = getLumenJSDirs();
31
- // Resolve NuralyUI paths for aliases
32
- const nuralyUIPaths = resolveNuralyUIPaths(projectDir);
33
+ // Resolve NuralyUI paths for aliases (only when nuralyui integration is enabled)
33
34
  const aliases = {};
34
- if (nuralyUIPaths) {
35
- Object.assign(aliases, getNuralyUIAliases(nuralyUIPaths.componentsPath, nuralyUIPaths.commonPath));
35
+ if (options?.integrations?.includes('nuralyui')) {
36
+ const nuralyUIPaths = resolveNuralyUIPaths(projectDir);
37
+ if (nuralyUIPaths) {
38
+ Object.assign(aliases, getNuralyUIAliases(nuralyUIPaths.componentsPath, nuralyUIPaths.commonPath));
39
+ }
36
40
  }
37
41
  const resolve = {
38
- alias: { ...aliases },
42
+ alias: {
43
+ ...aliases,
44
+ // Map @lumenjs/i18n to the physical dist file so Vite resolves it
45
+ // without going through node_modules (it's not an npm package).
46
+ '@lumenjs/i18n': path.join(runtimeDir, 'i18n.js'),
47
+ },
39
48
  conditions: isDev ? ['development', 'browser'] : ['browser'],
40
49
  dedupe: ['lit', 'lit-html', 'lit-element', '@lit/reactive-element'],
41
50
  };
@@ -51,9 +60,12 @@ export function getSharedViteConfig(projectDir, options) {
51
60
  lumenRoutesPlugin(pagesDir),
52
61
  lumenLoadersPlugin(pagesDir),
53
62
  litDedupPlugin(lumenNodeModules, isDev),
54
- autoImportPlugin(projectDir),
55
63
  virtualModulesPlugin(runtimeDir, editorDir),
56
64
  ];
65
+ // Conditionally add NuralyUI auto-import plugin
66
+ if (options?.integrations?.includes('nuralyui')) {
67
+ plugins.push(autoImportPlugin(projectDir));
68
+ }
57
69
  // Conditionally add Tailwind plugin from the project's node_modules
58
70
  if (options?.integrations?.includes('tailwind')) {
59
71
  try {
@@ -74,7 +86,7 @@ export async function createDevServer(options) {
74
86
  const apiDir = path.join(projectDir, 'api');
75
87
  const publicDir = path.join(projectDir, 'public');
76
88
  const config = readProjectConfig(projectDir);
77
- const { title, integrations } = config;
89
+ const { title, integrations, i18n: i18nConfig } = config;
78
90
  const shared = getSharedViteConfig(projectDir, { integrations });
79
91
  const server = await createViteServer({
80
92
  root: projectDir,
@@ -92,6 +104,7 @@ export async function createDevServer(options) {
92
104
  ...shared.plugins,
93
105
  lumenApiRoutesPlugin(apiDir, projectDir),
94
106
  litHmrPlugin(projectDir),
107
+ ...(i18nConfig ? [i18nPlugin(projectDir, i18nConfig)] : []),
95
108
  ...(editorMode ? [sourceAnnotatorPlugin(projectDir)] : []),
96
109
  {
97
110
  name: 'lumenjs-index-html',
@@ -99,10 +112,20 @@ export async function createDevServer(options) {
99
112
  server.middlewares.use((req, res, next) => {
100
113
  if (req.url && !req.url.startsWith('/@') && !req.url.startsWith('/node_modules') &&
101
114
  !req.url.startsWith('/api/') && !req.url.startsWith('/__nk_loader/') &&
115
+ !req.url.startsWith('/__nk_i18n/') &&
102
116
  !req.url.includes('.') && req.method === 'GET') {
103
- const pathname = req.url.split('?')[0];
117
+ let pathname = req.url.split('?')[0];
118
+ // Resolve locale from URL/cookie/header
119
+ let locale;
120
+ let translations;
121
+ if (i18nConfig) {
122
+ const localeResult = resolveLocale(pathname, i18nConfig, req.headers);
123
+ locale = localeResult.locale;
124
+ pathname = localeResult.pathname;
125
+ translations = loadTranslationsFromDisk(projectDir, locale);
126
+ }
104
127
  const SSR_PLACEHOLDER = '<!--__NK_SSR_CONTENT__-->';
105
- ssrRenderPage(server, pagesDir, pathname, req.headers).then(async (ssrResult) => {
128
+ ssrRenderPage(server, pagesDir, pathname, req.headers, locale).then(async (ssrResult) => {
106
129
  if (ssrResult?.redirect) {
107
130
  res.writeHead(ssrResult.redirect.status, { Location: ssrResult.redirect.location });
108
131
  res.end();
@@ -115,6 +138,9 @@ export async function createDevServer(options) {
115
138
  loaderData: ssrResult?.loaderData,
116
139
  layoutsData: ssrResult?.layoutsData,
117
140
  integrations,
141
+ locale,
142
+ i18nConfig: i18nConfig || undefined,
143
+ translations,
118
144
  });
119
145
  const transformed = await server.transformIndexHtml(req.url, shellHtml);
120
146
  const finalHtml = ssrResult
@@ -125,7 +151,7 @@ export async function createDevServer(options) {
125
151
  res.end(finalHtml);
126
152
  }).catch(err => {
127
153
  console.error('[LumenJS] SSR/HTML generation error:', err);
128
- const html = generateIndexHtml({ title, editorMode, integrations });
154
+ const html = generateIndexHtml({ title, editorMode, integrations, locale, i18nConfig: i18nConfig || undefined, translations });
129
155
  server.transformIndexHtml(req.url, html).then(transformed => {
130
156
  res.setHeader('Content-Type', 'text/html');
131
157
  res.setHeader('Cache-Control', 'no-store');
@@ -142,6 +168,7 @@ export async function createDevServer(options) {
142
168
  esbuild: shared.esbuild,
143
169
  optimizeDeps: {
144
170
  include: ['lit', 'lit/decorators.js', 'lit/directive.js', 'lit/directive-helpers.js', 'lit/async-directive.js', 'lit-html', 'lit-element', '@lit/reactive-element'],
171
+ exclude: ['@lumenjs/i18n'],
145
172
  },
146
173
  ssr: {
147
174
  noExternal: true,
@@ -9,7 +9,7 @@ export interface LayoutSSRData {
9
9
  *
10
10
  * Returns pre-rendered HTML and loader data, or null on failure (falls back to CSR).
11
11
  */
12
- export declare function ssrRenderPage(server: ViteDevServer, pagesDir: string, pathname: string, headers?: Record<string, string | string[] | undefined>): Promise<{
12
+ export declare function ssrRenderPage(server: ViteDevServer, pagesDir: string, pathname: string, headers?: Record<string, string | string[] | undefined>, locale?: string): Promise<{
13
13
  html: string;
14
14
  loaderData: any;
15
15
  layoutsData?: LayoutSSRData[];
@@ -1,15 +1,16 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs';
3
3
  import { resolvePageFile, extractRouteParams } from './plugins/vite-plugin-loaders.js';
4
- import { stripOuterLitMarkers, dirToLayoutTagName, findTagName } from '../shared/utils.js';
4
+ import { stripOuterLitMarkers, dirToLayoutTagName, filePathToTagName } from '../shared/utils.js';
5
5
  import { installDomShims } from '../shared/dom-shims.js';
6
+ import { loadTranslationsFromDisk } from './plugins/vite-plugin-i18n.js';
6
7
  /**
7
8
  * Server-side render a LumenJS page using @lit-labs/ssr.
8
9
  * Wraps the page in its layout chain if layouts exist.
9
10
  *
10
11
  * Returns pre-rendered HTML and loader data, or null on failure (falls back to CSR).
11
12
  */
12
- export async function ssrRenderPage(server, pagesDir, pathname, headers) {
13
+ export async function ssrRenderPage(server, pagesDir, pathname, headers, locale) {
13
14
  try {
14
15
  const filePath = resolvePageFile(pagesDir, pathname);
15
16
  if (!filePath)
@@ -19,6 +20,21 @@ export async function ssrRenderPage(server, pagesDir, pathname, headers) {
19
20
  await server.ssrLoadModule('@lit-labs/ssr/lib/install-global-dom-shim.js');
20
21
  // Patch missing DOM APIs that NuralyUI components may use during SSR
21
22
  installDomShims();
23
+ // Initialize i18n in the SSR context so t() works during render
24
+ if (locale) {
25
+ const projectDir = path.resolve(pagesDir, '..');
26
+ const translations = loadTranslationsFromDisk(projectDir, locale);
27
+ try {
28
+ // Load the same i18n module the page will import (via resolve.alias)
29
+ const i18nMod = await server.ssrLoadModule('@lumenjs/i18n');
30
+ if (i18nMod?.initI18n) {
31
+ i18nMod.initI18n({ locales: [], defaultLocale: locale, prefixDefault: false }, locale, translations);
32
+ }
33
+ }
34
+ catch {
35
+ // i18n module not available — translations will show keys
36
+ }
37
+ }
22
38
  // Invalidate SSR module cache so we always get fresh content after file edits.
23
39
  // Also clear the custom element from the SSR registry so the new class is used.
24
40
  const g = globalThis;
@@ -29,15 +45,14 @@ export async function ssrRenderPage(server, pagesDir, pathname, headers) {
29
45
  // Run loader if present
30
46
  let loaderData = undefined;
31
47
  if (mod.loader && typeof mod.loader === 'function') {
32
- loaderData = await mod.loader({ params, query: {}, url: pathname, headers: headers || {} });
48
+ loaderData = await mod.loader({ params, query: {}, url: pathname, headers: headers || {}, locale });
33
49
  if (loaderData && typeof loaderData === 'object' && loaderData.__nk_redirect) {
34
50
  return { html: '', loaderData: null, redirect: { location: loaderData.location, status: loaderData.status || 302 } };
35
51
  }
36
52
  }
37
- // Determine the custom element tag name
38
- const tagName = findTagName(mod);
39
- if (!tagName)
40
- return null;
53
+ // Determine the custom element tag name from file path (matches client router)
54
+ const relPath = path.relative(pagesDir, filePath).replace(/\\/g, '/');
55
+ const tagName = filePathToTagName(relPath);
41
56
  // Discover layout chain for this page
42
57
  const layoutChain = discoverLayoutChain(pagesDir, filePath);
43
58
  const layoutsData = [];
@@ -50,7 +65,7 @@ export async function ssrRenderPage(server, pagesDir, pathname, headers) {
50
65
  const layoutMod = await server.ssrLoadModule(layout.filePath);
51
66
  let layoutLoaderData = undefined;
52
67
  if (layoutMod.loader && typeof layoutMod.loader === 'function') {
53
- layoutLoaderData = await layoutMod.loader({ params: {}, query: {}, url: pathname, headers: headers || {} });
68
+ layoutLoaderData = await layoutMod.loader({ params: {}, query: {}, url: pathname, headers: headers || {}, locale });
54
69
  if (layoutLoaderData && typeof layoutLoaderData === 'object' && layoutLoaderData.__nk_redirect) {
55
70
  return { html: '', loaderData: null, redirect: { location: layoutLoaderData.location, status: layoutLoaderData.status || 302 } };
56
71
  }
@@ -1,5 +1,5 @@
1
1
  export interface NkEditorMessage {
2
- type: 'NK_READY' | 'NK_ELEMENT_CLICKED' | 'NK_ELEMENT_HOVERED' | 'NK_SELECT_ELEMENT' | 'NK_HIGHLIGHT_ELEMENT' | 'NK_TEXT_CHANGED' | 'NK_SET_PREVIEW_MODE';
2
+ type: 'NK_READY' | 'NK_ELEMENT_CLICKED' | 'NK_ELEMENT_HOVERED' | 'NK_SELECT_ELEMENT' | 'NK_HIGHLIGHT_ELEMENT' | 'NK_TEXT_CHANGED' | 'NK_TRANSLATION_CHANGED' | 'NK_SET_PREVIEW_MODE';
3
3
  payload?: any;
4
4
  }
5
5
  export declare function isPreviewMode(): boolean;
@@ -93,10 +93,21 @@ export function setupInlineTextEdit() {
93
93
  editingEl.style.minWidth = '';
94
94
  editingEl = null;
95
95
  if (newText !== originalText) {
96
- sendToHost({
97
- type: 'NK_TEXT_CHANGED',
98
- payload: { sourceFile, line, originalText, newText }
99
- });
96
+ // Check if this element has an i18n key — if so, send a translation change
97
+ const i18nKey = textEl.getAttribute('data-nk-i18n-key');
98
+ if (i18nKey) {
99
+ const locale = document.documentElement.lang || 'en';
100
+ sendToHost({
101
+ type: 'NK_TRANSLATION_CHANGED',
102
+ payload: { key: i18nKey, locale, originalText, newText }
103
+ });
104
+ }
105
+ else {
106
+ sendToHost({
107
+ type: 'NK_TEXT_CHANGED',
108
+ payload: { sourceFile, line, originalText, newText }
109
+ });
110
+ }
100
111
  }
101
112
  };
102
113
  textEl.addEventListener('blur', commitEdit, { once: true });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * LumenJS i18n runtime — provides translation lookup, locale management,
3
+ * and translation loading for both SSR and client-side navigation.
4
+ */
5
+ /**
6
+ * Look up a translation key. Returns the translated string, or the key itself
7
+ * if no translation is found.
8
+ */
9
+ export declare function t(key: string): string;
10
+ /** Returns the current locale. */
11
+ export declare function getLocale(): string;
12
+ /** Returns the i18n config, or null if i18n is not enabled. */
13
+ export declare function getI18nConfig(): {
14
+ locales: string[];
15
+ defaultLocale: string;
16
+ prefixDefault: boolean;
17
+ } | null;
18
+ /**
19
+ * Switch to a new locale. Navigates to the same pathname under the new
20
+ * locale prefix and sets the `nk-locale` cookie.
21
+ */
22
+ export declare function setLocale(locale: string): void;
23
+ /**
24
+ * Initialise the i18n runtime with config and translations.
25
+ * Called during hydration (from SSR data) or on first load.
26
+ */
27
+ export declare function initI18n(config: {
28
+ locales: string[];
29
+ defaultLocale: string;
30
+ prefixDefault: boolean;
31
+ }, locale: string, trans: Record<string, string>): void;
32
+ /**
33
+ * Load translations for a locale from the server and swap them in.
34
+ * Used during client-side locale switches.
35
+ */
36
+ export declare function loadTranslations(locale: string): Promise<void>;
37
+ /**
38
+ * Strip the locale prefix from a pathname.
39
+ * /fr/about → /about
40
+ * /about → /about
41
+ */
42
+ export declare function stripLocalePrefix(pathname: string): string;
43
+ /**
44
+ * Prepend the locale prefix to a pathname.
45
+ * (fr, /about) → /fr/about
46
+ * (en, /about) → /about (when prefixDefault=false)
47
+ */
48
+ export declare function buildLocalePath(locale: string, pathname: string): string;
49
+ /**
50
+ * Detect the locale from a URL pathname.
51
+ * Returns the locale and the pathname with the prefix stripped.
52
+ */
53
+ export declare function detectLocaleFromPath(pathname: string): {
54
+ locale: string;
55
+ pathname: string;
56
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * LumenJS i18n runtime — provides translation lookup, locale management,
3
+ * and translation loading for both SSR and client-side navigation.
4
+ */
5
+ let currentLocale = 'en';
6
+ let translations = {};
7
+ let i18nConfig = null;
8
+ /**
9
+ * Look up a translation key. Returns the translated string, or the key itself
10
+ * if no translation is found.
11
+ */
12
+ export function t(key) {
13
+ return translations[key] ?? key;
14
+ }
15
+ /** Returns the current locale. */
16
+ export function getLocale() {
17
+ return currentLocale;
18
+ }
19
+ /** Returns the i18n config, or null if i18n is not enabled. */
20
+ export function getI18nConfig() {
21
+ return i18nConfig;
22
+ }
23
+ /**
24
+ * Switch to a new locale. Navigates to the same pathname under the new
25
+ * locale prefix and sets the `nk-locale` cookie.
26
+ */
27
+ export function setLocale(locale) {
28
+ if (!i18nConfig || !i18nConfig.locales.includes(locale))
29
+ return;
30
+ document.cookie = `nk-locale=${locale};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`;
31
+ const pathname = stripLocalePrefix(location.pathname);
32
+ const newPath = buildLocalePath(locale, pathname);
33
+ location.href = newPath;
34
+ }
35
+ /**
36
+ * Initialise the i18n runtime with config and translations.
37
+ * Called during hydration (from SSR data) or on first load.
38
+ */
39
+ export function initI18n(config, locale, trans) {
40
+ i18nConfig = config;
41
+ currentLocale = locale;
42
+ translations = trans;
43
+ }
44
+ /**
45
+ * Load translations for a locale from the server and swap them in.
46
+ * Used during client-side locale switches.
47
+ */
48
+ export async function loadTranslations(locale) {
49
+ const res = await fetch(`/__nk_i18n/${locale}.json`);
50
+ if (!res.ok) {
51
+ console.error(`[i18n] Failed to load translations for locale "${locale}"`);
52
+ return;
53
+ }
54
+ translations = await res.json();
55
+ currentLocale = locale;
56
+ }
57
+ /**
58
+ * Strip the locale prefix from a pathname.
59
+ * /fr/about → /about
60
+ * /about → /about
61
+ */
62
+ export function stripLocalePrefix(pathname) {
63
+ if (!i18nConfig)
64
+ return pathname;
65
+ for (const loc of i18nConfig.locales) {
66
+ if (loc === i18nConfig.defaultLocale && !i18nConfig.prefixDefault)
67
+ continue;
68
+ if (pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)) {
69
+ return pathname.slice(loc.length + 1) || '/';
70
+ }
71
+ }
72
+ return pathname;
73
+ }
74
+ /**
75
+ * Prepend the locale prefix to a pathname.
76
+ * (fr, /about) → /fr/about
77
+ * (en, /about) → /about (when prefixDefault=false)
78
+ */
79
+ export function buildLocalePath(locale, pathname) {
80
+ if (!i18nConfig)
81
+ return pathname;
82
+ if (locale === i18nConfig.defaultLocale && !i18nConfig.prefixDefault) {
83
+ return pathname;
84
+ }
85
+ return `/${locale}${pathname === '/' ? '' : pathname}` || `/${locale}`;
86
+ }
87
+ /**
88
+ * Detect the locale from a URL pathname.
89
+ * Returns the locale and the pathname with the prefix stripped.
90
+ */
91
+ export function detectLocaleFromPath(pathname) {
92
+ if (!i18nConfig)
93
+ return { locale: 'en', pathname };
94
+ for (const loc of i18nConfig.locales) {
95
+ if (pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)) {
96
+ return { locale: loc, pathname: pathname.slice(loc.length + 1) || '/' };
97
+ }
98
+ }
99
+ return { locale: i18nConfig.defaultLocale, pathname };
100
+ }
@@ -1,8 +1,13 @@
1
+ import { getI18nConfig, getLocale } from './i18n.js';
1
2
  export async function fetchLoaderData(pathname, params) {
2
3
  const url = new URL(`/__nk_loader${pathname}`, location.origin);
3
4
  if (Object.keys(params).length > 0) {
4
5
  url.searchParams.set('__params', JSON.stringify(params));
5
6
  }
7
+ const config = getI18nConfig();
8
+ if (config) {
9
+ url.searchParams.set('__locale', getLocale());
10
+ }
6
11
  const res = await fetch(url.toString());
7
12
  if (!res.ok) {
8
13
  throw new Error(`Loader returned ${res.status}`);
@@ -15,6 +20,10 @@ export async function fetchLoaderData(pathname, params) {
15
20
  export async function fetchLayoutLoaderData(dir) {
16
21
  const url = new URL(`/__nk_loader/__layout/`, location.origin);
17
22
  url.searchParams.set('__dir', dir);
23
+ const config = getI18nConfig();
24
+ if (config) {
25
+ url.searchParams.set('__locale', getLocale());
26
+ }
18
27
  const res = await fetch(url.toString());
19
28
  if (!res.ok) {
20
29
  throw new Error(`Layout loader returned ${res.status}`);
@@ -1,10 +1,24 @@
1
+ import { initI18n, stripLocalePrefix, getI18nConfig } from './i18n.js';
1
2
  /**
2
3
  * Hydrate the initial SSR-rendered route.
3
4
  * Sets loaderData on existing DOM elements BEFORE loading modules to avoid
4
5
  * hydration mismatches with Lit's microtask-based hydration.
5
6
  */
6
7
  export async function hydrateInitialRoute(routes, outlet, matchRoute, onHydrated) {
7
- const match = matchRoute(location.pathname);
8
+ // Read i18n data and init before anything else
9
+ const i18nScript = document.getElementById('__nk_i18n__');
10
+ if (i18nScript) {
11
+ try {
12
+ const i18nData = JSON.parse(i18nScript.textContent || '');
13
+ initI18n(i18nData.config, i18nData.locale, i18nData.translations);
14
+ }
15
+ catch { /* ignore */ }
16
+ i18nScript.remove();
17
+ }
18
+ // Strip locale prefix for route matching (routes are locale-agnostic)
19
+ const config = getI18nConfig();
20
+ const matchPath = config ? stripLocalePrefix(location.pathname) : location.pathname;
21
+ const match = matchRoute(matchPath);
8
22
  if (!match)
9
23
  return;
10
24
  const layouts = match.route.layouts || [];
@@ -32,4 +32,8 @@ export declare class NkRouter {
32
32
  private buildLayoutTree;
33
33
  private createPageElement;
34
34
  private handleLinkClick;
35
+ /** Strip locale prefix from a path for internal route matching. */
36
+ private stripLocale;
37
+ /** Prepend locale prefix for browser-facing URLs. */
38
+ private withLocale;
35
39
  }
@@ -1,5 +1,6 @@
1
1
  import { fetchLoaderData, fetchLayoutLoaderData, render404 } from './router-data.js';
2
2
  import { hydrateInitialRoute } from './router-hydration.js';
3
+ import { getI18nConfig, getLocale, stripLocalePrefix, buildLocalePath } from './i18n.js';
3
4
  /**
4
5
  * Simple client-side router for LumenJS pages.
5
6
  * Handles popstate and link clicks for SPA navigation.
@@ -17,7 +18,10 @@ export class NkRouter {
17
18
  ...r,
18
19
  ...this.compilePattern(r.path),
19
20
  }));
20
- window.addEventListener('popstate', () => this.navigate(location.pathname, false));
21
+ window.addEventListener('popstate', () => {
22
+ const path = this.stripLocale(location.pathname);
23
+ this.navigate(path, false);
24
+ });
21
25
  document.addEventListener('click', (e) => this.handleLinkClick(e));
22
26
  if (hydrate) {
23
27
  hydrateInitialRoute(this.routes, this.outlet, (p) => this.matchRoute(p), (tag, layoutTags, params) => {
@@ -27,7 +31,8 @@ export class NkRouter {
27
31
  });
28
32
  }
29
33
  else {
30
- this.navigate(location.pathname, false);
34
+ const path = this.stripLocale(location.pathname);
35
+ this.navigate(path, false);
31
36
  }
32
37
  }
33
38
  compilePattern(path) {
@@ -48,7 +53,8 @@ export class NkRouter {
48
53
  return;
49
54
  }
50
55
  if (pushState) {
51
- history.pushState(null, '', pathname);
56
+ const localePath = this.withLocale(pathname);
57
+ history.pushState(null, '', localePath);
52
58
  }
53
59
  this.params = match.params;
54
60
  // Lazy-load the page component if not yet registered
@@ -197,6 +203,16 @@ export class NkRouter {
197
203
  if (!href || href.startsWith('http') || href.startsWith('#') || anchor.hasAttribute('target'))
198
204
  return;
199
205
  event.preventDefault();
200
- this.navigate(href);
206
+ this.navigate(this.stripLocale(href));
207
+ }
208
+ /** Strip locale prefix from a path for internal route matching. */
209
+ stripLocale(path) {
210
+ const config = getI18nConfig();
211
+ return config ? stripLocalePrefix(path) : path;
212
+ }
213
+ /** Prepend locale prefix for browser-facing URLs. */
214
+ withLocale(path) {
215
+ const config = getI18nConfig();
216
+ return config ? buildLocalePath(getLocale(), path) : path;
201
217
  }
202
218
  }
@@ -7,10 +7,17 @@ export interface ManifestRoute {
7
7
  path: string;
8
8
  module: string;
9
9
  hasLoader: boolean;
10
+ tagName?: string;
10
11
  layouts?: string[];
11
12
  }
13
+ export interface I18nManifest {
14
+ locales: string[];
15
+ defaultLocale: string;
16
+ prefixDefault: boolean;
17
+ }
12
18
  export interface BuildManifest {
13
19
  routes: ManifestRoute[];
14
20
  apiRoutes: ManifestRoute[];
15
21
  layouts: ManifestLayout[];
22
+ i18n?: I18nManifest;
16
23
  }
@@ -17,6 +17,13 @@ export declare function dirToLayoutTagName(dir: string): string;
17
17
  * Pages use @customElement('page-xxx') which registers the element.
18
18
  */
19
19
  export declare function findTagName(mod: Record<string, any>): string | null;
20
+ /**
21
+ * Convert a relative file path within pages/ to a page tag name.
22
+ * 'index.ts' → 'page-index'
23
+ * 'docs/api-routes.ts' → 'page-docs-api-routes'
24
+ * 'blog/[slug].ts' → 'page-blog-slug'
25
+ */
26
+ export declare function filePathToTagName(filePath: string): string;
20
27
  /**
21
28
  * Check if a redirect response was returned from a loader.
22
29
  */
@@ -49,6 +49,22 @@ export function findTagName(mod) {
49
49
  }
50
50
  return null;
51
51
  }
52
+ /**
53
+ * Convert a relative file path within pages/ to a page tag name.
54
+ * 'index.ts' → 'page-index'
55
+ * 'docs/api-routes.ts' → 'page-docs-api-routes'
56
+ * 'blog/[slug].ts' → 'page-blog-slug'
57
+ */
58
+ export function filePathToTagName(filePath) {
59
+ const name = filePath
60
+ .replace(/\.(ts|js)$/, '')
61
+ .replace(/\\/g, '-')
62
+ .replace(/\//g, '-')
63
+ .replace(/\[\.\.\.([^\]]+)\]/g, '$1')
64
+ .replace(/\[([^\]]+)\]/g, '$1')
65
+ .toLowerCase();
66
+ return `page-${name}`;
67
+ }
52
68
  /**
53
69
  * Check if a redirect response was returned from a loader.
54
70
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuraly/lumenjs",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",