@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
package/README.md CHANGED
@@ -105,6 +105,7 @@ Loaders run server-side on initial load (SSR) and are fetched via `/__nk_loader/
105
105
  | `query` | `Record<string, string>` | Query string parameters |
106
106
  | `url` | `string` | Request pathname |
107
107
  | `headers` | `Record<string, any>` | Request headers |
108
+ | `locale` | `string` | Current locale (when i18n is configured) |
108
109
 
109
110
  ### Redirects
110
111
 
@@ -223,6 +224,81 @@ Pages with loaders are automatically server-rendered using `@lit-labs/ssr`:
223
224
 
224
225
  Pages without loaders render client-side only (SPA mode). If SSR fails, LumenJS falls back gracefully to client-side rendering.
225
226
 
227
+ ## Internationalization (i18n)
228
+
229
+ LumenJS has built-in i18n support with URL-prefix-based locale routing.
230
+
231
+ ### Setup
232
+
233
+ 1. Add i18n config to `lumenjs.config.ts`:
234
+
235
+ ```typescript
236
+ export default {
237
+ title: 'My App',
238
+ i18n: {
239
+ locales: ['en', 'fr'],
240
+ defaultLocale: 'en',
241
+ prefixDefault: false, // / instead of /en/
242
+ },
243
+ };
244
+ ```
245
+
246
+ 2. Create translation files in `locales/`:
247
+
248
+ ```
249
+ my-app/
250
+ ├── locales/
251
+ │ ├── en.json # { "home.title": "Welcome", "nav.docs": "Docs" }
252
+ │ └── fr.json # { "home.title": "Bienvenue", "nav.docs": "Documentation" }
253
+ ├── pages/
254
+ └── lumenjs.config.ts
255
+ ```
256
+
257
+ ### Usage
258
+
259
+ ```typescript
260
+ import { t, getLocale, setLocale } from '@lumenjs/i18n';
261
+
262
+ @customElement('page-index')
263
+ export class PageIndex extends LitElement {
264
+ render() {
265
+ return html`<h1>${t('home.title')}</h1>`;
266
+ }
267
+ }
268
+ ```
269
+
270
+ ### API
271
+
272
+ | Function | Description |
273
+ |---|---|
274
+ | `t(key)` | Returns the translated string for the key, or the key itself if not found |
275
+ | `getLocale()` | Returns the current locale string |
276
+ | `setLocale(locale)` | Switches locale — sets cookie, navigates to the localized URL |
277
+
278
+ ### Locale Resolution
279
+
280
+ Locale is resolved in this order:
281
+
282
+ 1. URL prefix: `/fr/about` → locale `fr`, pathname `/about`
283
+ 2. Cookie `nk-locale` (set on explicit locale switch)
284
+ 3. `Accept-Language` header (SSR)
285
+ 4. Config `defaultLocale`
286
+
287
+ ### URL Routing
288
+
289
+ With `prefixDefault: false`, the default locale uses clean URLs:
290
+
291
+ | URL | Locale | Page |
292
+ |---|---|---|
293
+ | `/about` | `en` (default) | `pages/about.ts` |
294
+ | `/fr/about` | `fr` | `pages/about.ts` |
295
+
296
+ Routes are locale-agnostic — you don't need separate pages per locale. The router strips the locale prefix before matching and prepends it during navigation.
297
+
298
+ ### SSR
299
+
300
+ Translations are server-rendered. The `<html lang="...">` attribute is set dynamically, and translations are inlined in the response for hydration without flash of untranslated content.
301
+
226
302
  ## Integrations
227
303
 
228
304
  ### Tailwind CSS
@@ -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;