@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.
- package/README.md +76 -0
- package/dist/build/build.js +19 -1
- package/dist/build/serve-i18n.d.ts +6 -0
- package/dist/build/serve-i18n.js +26 -0
- package/dist/build/serve-loaders.js +7 -2
- package/dist/build/serve-ssr.js +3 -3
- package/dist/build/serve.js +20 -4
- package/dist/dev-server/config.d.ts +6 -0
- package/dist/dev-server/config.js +27 -1
- package/dist/dev-server/index-html.d.ts +7 -0
- package/dist/dev-server/index-html.js +15 -2
- package/dist/dev-server/middleware/locale.d.ts +20 -0
- package/dist/dev-server/middleware/locale.js +55 -0
- package/dist/dev-server/plugins/vite-plugin-i18n.d.ts +15 -0
- package/dist/dev-server/plugins/vite-plugin-i18n.js +71 -0
- package/dist/dev-server/plugins/vite-plugin-loaders.js +16 -2
- package/dist/dev-server/plugins/vite-plugin-routes.js +1 -11
- package/dist/dev-server/plugins/vite-plugin-source-annotator.js +8 -1
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.d.ts +5 -0
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +52 -22
- package/dist/dev-server/server.d.ts +1 -1
- package/dist/dev-server/server.js +48 -10
- package/dist/dev-server/ssr-render.d.ts +1 -1
- package/dist/dev-server/ssr-render.js +23 -8
- package/dist/editor/editor-bridge.d.ts +1 -1
- package/dist/editor/inline-text-edit.js +15 -4
- package/dist/runtime/i18n.d.ts +56 -0
- package/dist/runtime/i18n.js +100 -0
- package/dist/runtime/router-data.js +9 -0
- package/dist/runtime/router-hydration.js +15 -1
- package/dist/runtime/router.d.ts +4 -0
- package/dist/runtime/router.js +20 -4
- package/dist/shared/types.d.ts +7 -0
- package/dist/shared/utils.d.ts +7 -0
- package/dist/shared/utils.js +16 -0
- 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
|
package/dist/build/build.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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();
|
package/dist/build/serve-ssr.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { stripOuterLitMarkers, dirToLayoutTagName,
|
|
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
|
-
//
|
|
27
|
-
const tagName =
|
|
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 = [];
|
package/dist/build/serve.js
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
68
|
-
|
|
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
|
-
|
|
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="
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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, '"').replace(/\$\{/g, '__NK_EXPR__');
|
|
30
30
|
return `<${tag}${attrStr} data-nk-dynamic="${escaped}">${content}</`;
|
|
31
31
|
});
|
|
32
|
-
|
|
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;
|