@nuraly/lumenjs 0.1.0 → 0.1.2

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 (36) hide show
  1. package/README.md +76 -0
  2. package/dist/build/build.js +19 -1
  3. package/dist/build/serve-i18n.d.ts +6 -0
  4. package/dist/build/serve-i18n.js +26 -0
  5. package/dist/build/serve-loaders.js +7 -2
  6. package/dist/build/serve-ssr.js +3 -3
  7. package/dist/build/serve.js +20 -4
  8. package/dist/dev-server/config.d.ts +6 -0
  9. package/dist/dev-server/config.js +27 -1
  10. package/dist/dev-server/index-html.d.ts +7 -0
  11. package/dist/dev-server/index-html.js +15 -2
  12. package/dist/dev-server/middleware/locale.d.ts +20 -0
  13. package/dist/dev-server/middleware/locale.js +55 -0
  14. package/dist/dev-server/plugins/vite-plugin-i18n.d.ts +15 -0
  15. package/dist/dev-server/plugins/vite-plugin-i18n.js +71 -0
  16. package/dist/dev-server/plugins/vite-plugin-loaders.js +16 -2
  17. package/dist/dev-server/plugins/vite-plugin-routes.js +1 -11
  18. package/dist/dev-server/plugins/vite-plugin-source-annotator.js +8 -1
  19. package/dist/dev-server/plugins/vite-plugin-virtual-modules.d.ts +5 -0
  20. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +52 -22
  21. package/dist/dev-server/server.d.ts +1 -1
  22. package/dist/dev-server/server.js +48 -10
  23. package/dist/dev-server/ssr-render.d.ts +1 -1
  24. package/dist/dev-server/ssr-render.js +23 -8
  25. package/dist/editor/editor-bridge.d.ts +1 -1
  26. package/dist/editor/inline-text-edit.js +15 -4
  27. package/dist/runtime/i18n.d.ts +56 -0
  28. package/dist/runtime/i18n.js +100 -0
  29. package/dist/runtime/router-data.js +9 -0
  30. package/dist/runtime/router-hydration.js +15 -1
  31. package/dist/runtime/router.d.ts +4 -0
  32. package/dist/runtime/router.js +20 -4
  33. package/dist/shared/types.d.ts +7 -0
  34. package/dist/shared/utils.d.ts +7 -0
  35. package/dist/shared/utils.js +16 -0
  36. package/package.json +11 -6
@@ -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,17 +104,39 @@ 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',
98
111
  configureServer(server) {
99
112
  server.middlewares.use((req, res, next) => {
113
+ // Guard against malformed percent-encoded URLs that crash Vite's transformIndexHtml
114
+ if (req.url) {
115
+ try {
116
+ decodeURIComponent(req.url);
117
+ }
118
+ catch {
119
+ res.statusCode = 400;
120
+ res.end('Bad Request');
121
+ return;
122
+ }
123
+ }
100
124
  if (req.url && !req.url.startsWith('/@') && !req.url.startsWith('/node_modules') &&
101
125
  !req.url.startsWith('/api/') && !req.url.startsWith('/__nk_loader/') &&
126
+ !req.url.startsWith('/__nk_i18n/') &&
102
127
  !req.url.includes('.') && req.method === 'GET') {
103
- const pathname = req.url.split('?')[0];
128
+ let pathname = req.url.split('?')[0];
129
+ // Resolve locale from URL/cookie/header
130
+ let locale;
131
+ let translations;
132
+ if (i18nConfig) {
133
+ const localeResult = resolveLocale(pathname, i18nConfig, req.headers);
134
+ locale = localeResult.locale;
135
+ pathname = localeResult.pathname;
136
+ translations = loadTranslationsFromDisk(projectDir, locale);
137
+ }
104
138
  const SSR_PLACEHOLDER = '<!--__NK_SSR_CONTENT__-->';
105
- ssrRenderPage(server, pagesDir, pathname, req.headers).then(async (ssrResult) => {
139
+ ssrRenderPage(server, pagesDir, pathname, req.headers, locale).then(async (ssrResult) => {
106
140
  if (ssrResult?.redirect) {
107
141
  res.writeHead(ssrResult.redirect.status, { Location: ssrResult.redirect.location });
108
142
  res.end();
@@ -115,6 +149,9 @@ export async function createDevServer(options) {
115
149
  loaderData: ssrResult?.loaderData,
116
150
  layoutsData: ssrResult?.layoutsData,
117
151
  integrations,
152
+ locale,
153
+ i18nConfig: i18nConfig || undefined,
154
+ translations,
118
155
  });
119
156
  const transformed = await server.transformIndexHtml(req.url, shellHtml);
120
157
  const finalHtml = ssrResult
@@ -125,7 +162,7 @@ export async function createDevServer(options) {
125
162
  res.end(finalHtml);
126
163
  }).catch(err => {
127
164
  console.error('[LumenJS] SSR/HTML generation error:', err);
128
- const html = generateIndexHtml({ title, editorMode, integrations });
165
+ const html = generateIndexHtml({ title, editorMode, integrations, locale, i18nConfig: i18nConfig || undefined, translations });
129
166
  server.transformIndexHtml(req.url, html).then(transformed => {
130
167
  res.setHeader('Content-Type', 'text/html');
131
168
  res.setHeader('Cache-Control', 'no-store');
@@ -142,6 +179,7 @@ export async function createDevServer(options) {
142
179
  esbuild: shared.esbuild,
143
180
  optimizeDeps: {
144
181
  include: ['lit', 'lit/decorators.js', 'lit/directive.js', 'lit/directive-helpers.js', 'lit/async-directive.js', 'lit-html', 'lit-element', '@lit/reactive-element'],
182
+ exclude: ['@lumenjs/i18n'],
145
183
  },
146
184
  ssr: {
147
185
  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
  */