@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
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 (
|
|
34
|
-
|
|
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 (
|
|
35
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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 || [];
|
package/dist/runtime/router.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/runtime/router.js
CHANGED
|
@@ -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', () =>
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/dist/shared/types.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/shared/utils.d.ts
CHANGED
|
@@ -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
|
*/
|