@vmz/core 0.1.7 → 0.1.9
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/dist/client-nav.js +65 -2
- package/dist/list-client-components.d.ts +25 -0
- package/dist/list-client-components.js +120 -0
- package/dist/serve-host.mjs +88 -49
- package/dist/server.js +2 -2
- package/package.json +5 -1
package/dist/client-nav.js
CHANGED
|
@@ -437,10 +437,11 @@ export function installClientNavigation(opts = {}) {
|
|
|
437
437
|
let localeTransitionGeneration = 0;
|
|
438
438
|
/**
|
|
439
439
|
* Atomic LocaleTransition (browser host slice):
|
|
440
|
-
* validate → realize path → navigate/fetch → commit locale attrs from SSR HTML
|
|
440
|
+
* - prefix: validate → realize path → navigate/fetch → commit locale attrs from SSR HTML
|
|
441
|
+
* - none: validate → Host persist (localStorage+cookie) → commit attrs → reload (v1; no URL rewrite)
|
|
441
442
|
* Failure keeps the previous locale surface (no half-page commit).
|
|
442
443
|
* @param {string} toLocale
|
|
443
|
-
* @param {{ replace?: boolean }} [opts]
|
|
444
|
+
* @param {{ replace?: boolean, reload?: boolean }} [opts]
|
|
444
445
|
*/
|
|
445
446
|
async function transitionLocale(toLocale, opts = {}) {
|
|
446
447
|
const fromLocale = doc.documentElement?.getAttribute('data-locale') || null;
|
|
@@ -480,6 +481,10 @@ export function installClientNavigation(opts = {}) {
|
|
|
480
481
|
win.__vmzLastLocaleTransition = out;
|
|
481
482
|
return out;
|
|
482
483
|
}
|
|
484
|
+
const strategy = routing.strategy || 'prefix';
|
|
485
|
+
if (strategy === 'none') {
|
|
486
|
+
return transitionLocaleNone(toLocale, fromLocale, opts);
|
|
487
|
+
}
|
|
483
488
|
const gen = ++localeTransitionGeneration;
|
|
484
489
|
const targetHref = realizePathForLocale(loc.pathname + loc.search + loc.hash, toLocale, routing);
|
|
485
490
|
const result = await transitionTo(targetHref, { replace: opts.replace !== false, softFail: true });
|
|
@@ -539,6 +544,64 @@ export function installClientNavigation(opts = {}) {
|
|
|
539
544
|
win.__vmzLastLocaleTransition = out;
|
|
540
545
|
return out;
|
|
541
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* `routing.strategy: 'none'` — LocaleId is Host preference, not URL.
|
|
549
|
+
* Persist → commit document attrs + hint → full reload so `#locales/*` re-resolve (I2 v1).
|
|
550
|
+
* @param {string} toLocale
|
|
551
|
+
* @param {string | null} fromLocale
|
|
552
|
+
* @param {{ reload?: boolean }} [opts]
|
|
553
|
+
*/
|
|
554
|
+
function transitionLocaleNone(toLocale, fromLocale, opts = {}) {
|
|
555
|
+
const STORE_KEY = 'vmz.locale';
|
|
556
|
+
try {
|
|
557
|
+
try {
|
|
558
|
+
localStorage.setItem(STORE_KEY, toLocale);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
/* private mode */
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
doc.cookie = `${STORE_KEY}=${encodeURIComponent(toLocale)}; path=/; max-age=31536000; SameSite=Lax`;
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
/* ignore */
|
|
568
|
+
}
|
|
569
|
+
if (doc.documentElement) {
|
|
570
|
+
doc.documentElement.setAttribute('data-locale', toLocale);
|
|
571
|
+
doc.documentElement.setAttribute('lang', toLocale);
|
|
572
|
+
}
|
|
573
|
+
if (win)
|
|
574
|
+
win.__vmzLocaleIdHint = toLocale;
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
const out = {
|
|
578
|
+
status: 'rolled_back',
|
|
579
|
+
fromLocale,
|
|
580
|
+
toLocale,
|
|
581
|
+
reason: 'persist_failed',
|
|
582
|
+
detail: err && err.message ? String(err.message) : String(err),
|
|
583
|
+
};
|
|
584
|
+
if (win)
|
|
585
|
+
win.__vmzLastLocaleTransition = out;
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
const out = {
|
|
589
|
+
status: 'committed',
|
|
590
|
+
fromLocale,
|
|
591
|
+
toLocale,
|
|
592
|
+
reason: 'ok',
|
|
593
|
+
strategy: 'none',
|
|
594
|
+
href: loc.pathname + loc.search,
|
|
595
|
+
reload: opts.reload !== false,
|
|
596
|
+
};
|
|
597
|
+
if (win)
|
|
598
|
+
win.__vmzLastLocaleTransition = out;
|
|
599
|
+
// Reload so generated `#locales` modules re-run __vmzLocaleId() with new preference.
|
|
600
|
+
if (opts.reload !== false && loc && typeof loc.reload === 'function') {
|
|
601
|
+
loc.reload();
|
|
602
|
+
}
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
542
605
|
/**
|
|
543
606
|
* @returns {{ strategy?: string, defaultPrefix?: string, defaultLocale?: string, locales?: string[] } | null}
|
|
544
607
|
*/
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover compiled client component modules from dist (deployment graph or components/).
|
|
3
|
+
* Shared by serve-host SSR and static emit assemble.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} dir
|
|
7
|
+
* @returns {Promise<Array<{ name: string, entry: string }>>}
|
|
8
|
+
*/
|
|
9
|
+
export declare function listClientComponents(dir: any): Promise<any[]>;
|
|
10
|
+
/**
|
|
11
|
+
* Sync variant for callers that already use fs sync (legacy static-emit helpers).
|
|
12
|
+
* @param {string} dir
|
|
13
|
+
* @returns {Array<{ name: string, entry: string }>}
|
|
14
|
+
*/
|
|
15
|
+
export declare function listClientComponentsSync(dir: any): any[];
|
|
16
|
+
/**
|
|
17
|
+
* Import all (or filtered) client components and register for SSR / static emit.
|
|
18
|
+
* @param {string} distDir
|
|
19
|
+
* @param {(map: Record<string, unknown>) => void} registerComponents
|
|
20
|
+
* @param {{
|
|
21
|
+
* cacheBust?: string | number,
|
|
22
|
+
* include?: (entry: { name: string, entry: string }) => boolean,
|
|
23
|
+
* }} [opts]
|
|
24
|
+
*/
|
|
25
|
+
export declare function preloadComponentRegistry(distDir: any, registerComponents: any, opts?: {}): Promise<{}>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Discover compiled client component modules from dist (deployment graph or components/).
|
|
4
|
+
* Shared by serve-host SSR and static emit assemble.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} dir
|
|
12
|
+
* @returns {Promise<Array<{ name: string, entry: string }>>}
|
|
13
|
+
*/
|
|
14
|
+
export async function listClientComponents(dir) {
|
|
15
|
+
/** @type {Map<string, { name: string, entry: string }>} */
|
|
16
|
+
const byName = new Map();
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
|
|
19
|
+
const dep = JSON.parse(raw);
|
|
20
|
+
for (const unit of dep.units || []) {
|
|
21
|
+
if (unit?.kind !== 'component')
|
|
22
|
+
continue;
|
|
23
|
+
const chunkId = String(unit.chunkId || '');
|
|
24
|
+
const name = chunkId.split('/').pop();
|
|
25
|
+
if (!name)
|
|
26
|
+
continue;
|
|
27
|
+
const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
|
|
28
|
+
byName.set(name, { name, entry });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* fall through to directory scan */
|
|
33
|
+
}
|
|
34
|
+
if (byName.size === 0) {
|
|
35
|
+
const folder = path.join(dir, 'components');
|
|
36
|
+
let files = [];
|
|
37
|
+
try {
|
|
38
|
+
files = await readdir(folder);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
for (const f of files.filter((name) => name.endsWith('.client.js'))) {
|
|
44
|
+
const name = f.replace(/\.client\.js$/, '');
|
|
45
|
+
byName.set(name, { name, entry: `components/${name}.client.js` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Sync variant for callers that already use fs sync (legacy static-emit helpers).
|
|
52
|
+
* @param {string} dir
|
|
53
|
+
* @returns {Array<{ name: string, entry: string }>}
|
|
54
|
+
*/
|
|
55
|
+
export function listClientComponentsSync(dir) {
|
|
56
|
+
/** @type {Map<string, { name: string, entry: string }>} */
|
|
57
|
+
const byName = new Map();
|
|
58
|
+
const deploymentPath = path.join(dir, 'vmz-deployment.json');
|
|
59
|
+
if (fs.existsSync(deploymentPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const dep = JSON.parse(fs.readFileSync(deploymentPath, 'utf8'));
|
|
62
|
+
for (const unit of dep.units || []) {
|
|
63
|
+
if (unit?.kind !== 'component')
|
|
64
|
+
continue;
|
|
65
|
+
const chunkId = String(unit.chunkId || '');
|
|
66
|
+
const name = chunkId.split('/').pop();
|
|
67
|
+
if (!name)
|
|
68
|
+
continue;
|
|
69
|
+
const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
|
|
70
|
+
byName.set(name, { name, entry });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* fall through */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (byName.size === 0) {
|
|
78
|
+
const folder = path.join(dir, 'components');
|
|
79
|
+
let files = [];
|
|
80
|
+
try {
|
|
81
|
+
files = fs.readdirSync(folder);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
for (const f of files.filter((name) => name.endsWith('.client.js'))) {
|
|
87
|
+
const name = f.replace(/\.client\.js$/, '');
|
|
88
|
+
byName.set(name, { name, entry: `components/${name}.client.js` });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Import all (or filtered) client components and register for SSR / static emit.
|
|
95
|
+
* @param {string} distDir
|
|
96
|
+
* @param {(map: Record<string, unknown>) => void} registerComponents
|
|
97
|
+
* @param {{
|
|
98
|
+
* cacheBust?: string | number,
|
|
99
|
+
* include?: (entry: { name: string, entry: string }) => boolean,
|
|
100
|
+
* }} [opts]
|
|
101
|
+
*/
|
|
102
|
+
export async function preloadComponentRegistry(distDir, registerComponents, opts = {}) {
|
|
103
|
+
const entries = await listClientComponents(distDir);
|
|
104
|
+
/** @type {Record<string, unknown>} */
|
|
105
|
+
const map = {};
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (opts.include && !opts.include(entry))
|
|
108
|
+
continue;
|
|
109
|
+
const abs = path.join(distDir, entry.entry);
|
|
110
|
+
let href = pathToFileURL(abs).href;
|
|
111
|
+
if (opts.cacheBust != null && opts.cacheBust !== '') {
|
|
112
|
+
href = `${href}?t=${encodeURIComponent(String(opts.cacheBust))}`;
|
|
113
|
+
}
|
|
114
|
+
const mod = await import(href);
|
|
115
|
+
map[entry.name] = mod.default;
|
|
116
|
+
}
|
|
117
|
+
if (Object.keys(map).length)
|
|
118
|
+
registerComponents(map);
|
|
119
|
+
return map;
|
|
120
|
+
}
|
package/dist/serve-host.mjs
CHANGED
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* Dev resolve hook propagates `?t=` onto nested relative `file:` imports under
|
|
17
17
|
* dist so soft reload does not keep a stale `lib/*.js` ESM cache entry.
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync } from 'node:fs';
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
20
|
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
21
21
|
import http from 'node:http';
|
|
22
22
|
import { createRequire, registerHooks } from 'node:module';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
25
|
import { registerComponents, renderToStream, renderToString } from './vmz-dom.js';
|
|
26
|
+
import { listClientComponents } from './list-client-components.js';
|
|
26
27
|
import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
|
|
27
28
|
const require = createRequire(import.meta.url);
|
|
28
29
|
const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -75,6 +76,8 @@ if (isDev) {
|
|
|
75
76
|
}
|
|
76
77
|
/** @type {number} */
|
|
77
78
|
let reloadToken = Date.now();
|
|
79
|
+
/** @type {string | null} Correlatable build id from vmz dev (Living §12.8). */
|
|
80
|
+
let lastDevBuildId = null;
|
|
78
81
|
/** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
|
|
79
82
|
let pageCatalog = [];
|
|
80
83
|
/** @type {Map<string, any>} */
|
|
@@ -152,7 +155,7 @@ async function renderPageStream(pathname, opts = {}) {
|
|
|
152
155
|
if (isDev && lastDevError && pageCtors.size === 0) {
|
|
153
156
|
return { status: 500, stream: emitDevErrorHtml(lastDevError) };
|
|
154
157
|
}
|
|
155
|
-
const localePlan = resolveLocalePath(pathname);
|
|
158
|
+
const localePlan = resolveLocalePath(pathname, opts.cookieHeader);
|
|
156
159
|
if (localePlan.redirectTo) {
|
|
157
160
|
return { status: 302, redirect: localePlan.redirectTo, headers: { Location: localePlan.redirectTo } };
|
|
158
161
|
}
|
|
@@ -477,10 +480,13 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
|
|
|
477
480
|
`d.innerHTML="<div style='max-width:56rem;margin:0 auto'><p style='color:#f87171;font-weight:700'>Dev Error</p><pre style='white-space:pre-wrap'>"+String(e.message||e).replace(/[<>&]/g,function(c){return {"<":"<",">":">","&":"&"}[c]})+"</pre></div>";` +
|
|
478
481
|
`document.documentElement.appendChild(d);})();</script>`
|
|
479
482
|
: '';
|
|
483
|
+
const buildIdBoot = isDev && lastDevBuildId ? `\n <script>window.__VMZ_DEV_BUILD_ID__=${JSON.stringify(lastDevBuildId)};</script>` : '';
|
|
480
484
|
if (signal?.aborted)
|
|
481
485
|
return;
|
|
482
486
|
const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
|
|
483
487
|
const themeBoot = themeBootstrapScript();
|
|
488
|
+
const localeBoot = localeBootstrapScript();
|
|
489
|
+
const faviconHead = siteFaviconHeadHtml();
|
|
484
490
|
const propsJson = JSON.stringify(props ?? {});
|
|
485
491
|
const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
|
|
486
492
|
const dir = localeCtx.dir || 'ltr';
|
|
@@ -552,9 +558,9 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
|
|
|
552
558
|
...(cssHref ? { cssEntry: cssHref } : {}),
|
|
553
559
|
isErrorDocument: false,
|
|
554
560
|
htmlExtraAttrs,
|
|
555
|
-
headExtraHtml: themeBoot
|
|
561
|
+
headExtraHtml: `${themeBoot}${localeBoot}${faviconHead}`,
|
|
556
562
|
moduleScriptSrc: entrySrc,
|
|
557
|
-
bodyTailHtml: `${live}${bootOverlay}`,
|
|
563
|
+
bodyTailHtml: `${live}${bootOverlay}${buildIdBoot}`,
|
|
558
564
|
});
|
|
559
565
|
}
|
|
560
566
|
const server = http.createServer((req, res) => {
|
|
@@ -670,7 +676,7 @@ process.on('SIGINT', () => {
|
|
|
670
676
|
* Re-import routes / pages / components with a new cache-bust token.
|
|
671
677
|
* Keeps the HTTP server process alive (no Node restart).
|
|
672
678
|
* Failed reloads keep the previous in-memory modules (Vite-like resilience).
|
|
673
|
-
* @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], emitted?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
|
|
679
|
+
* @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], emitted?: string[], full?: boolean, islandHmr?: boolean, buildId?: string, sourceRevision?: string, bundleRevision?: string, changed?: string[] } }} [opts]
|
|
674
680
|
*/
|
|
675
681
|
async function softReload(opts = {}) {
|
|
676
682
|
const prevToken = reloadToken;
|
|
@@ -683,6 +689,11 @@ async function softReload(opts = {}) {
|
|
|
683
689
|
const emitted = opts.payload?.emitted ?? [];
|
|
684
690
|
const full = opts.payload?.full;
|
|
685
691
|
const islandHmr = Boolean(opts.payload?.islandHmr);
|
|
692
|
+
const buildId = opts.payload?.buildId != null ? String(opts.payload.buildId) : null;
|
|
693
|
+
const sourceRevision = opts.payload?.sourceRevision != null ? String(opts.payload.sourceRevision) : null;
|
|
694
|
+
const bundleRevision = opts.payload?.bundleRevision != null ? String(opts.payload.bundleRevision) : null;
|
|
695
|
+
if (buildId)
|
|
696
|
+
lastDevBuildId = buildId;
|
|
686
697
|
const reloadAllPages = shouldReloadAllPages({ full, affected, emitted, islandHmr });
|
|
687
698
|
try {
|
|
688
699
|
try {
|
|
@@ -772,13 +783,21 @@ async function softReload(opts = {}) {
|
|
|
772
783
|
affectedChunks: affected,
|
|
773
784
|
seedChunks: seeds,
|
|
774
785
|
token: reloadToken,
|
|
786
|
+
buildId: buildId || lastDevBuildId,
|
|
787
|
+
sourceRevision,
|
|
788
|
+
bundleRevision,
|
|
789
|
+
serveRevision: String(reloadToken),
|
|
775
790
|
full: Boolean(full),
|
|
776
791
|
eventOnlyShell,
|
|
777
792
|
}));
|
|
778
793
|
if (!opts.quiet) {
|
|
779
794
|
const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
|
|
780
795
|
const scope = islandHmr ? 'island' : reloadAllPages ? 'all-pages' : `pages=${nextCtors.size}`;
|
|
781
|
-
|
|
796
|
+
const bid = buildId || lastDevBuildId;
|
|
797
|
+
const rev = bid || sourceRevision || bundleRevision
|
|
798
|
+
? ` buildId=${bid || '-'} source=${sourceRevision || '-'} bundle=${bundleRevision || '-'} serve=${reloadToken}`
|
|
799
|
+
: ` t=${reloadToken}`;
|
|
800
|
+
console.log(`vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${pageCatalog.length};${rev}${aff})`);
|
|
782
801
|
}
|
|
783
802
|
return {
|
|
784
803
|
affectedChunks: affected,
|
|
@@ -790,6 +809,11 @@ async function softReload(opts = {}) {
|
|
|
790
809
|
pageCount: pageCatalog.length,
|
|
791
810
|
reloadedPages: islandHmr ? 0 : nextCtors.size,
|
|
792
811
|
reloadAllPages,
|
|
812
|
+
buildId: buildId || lastDevBuildId,
|
|
813
|
+
sourceRevision,
|
|
814
|
+
bundleRevision,
|
|
815
|
+
serveRevision: String(reloadToken),
|
|
816
|
+
token: reloadToken,
|
|
793
817
|
};
|
|
794
818
|
}
|
|
795
819
|
catch (err) {
|
|
@@ -899,11 +923,14 @@ function escapeHtml(s) {
|
|
|
899
923
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
900
924
|
}
|
|
901
925
|
/**
|
|
902
|
-
* Resolve LocaleId
|
|
903
|
-
*
|
|
926
|
+
* Resolve LocaleId for this request.
|
|
927
|
+
* - `prefix`: LocaleId from URL path (existing).
|
|
928
|
+
* - `none`: Host preference from cookie `vmz.locale` (validated), else defaultLocale.
|
|
929
|
+
* URL never carries LocaleId.
|
|
904
930
|
* @param {string} pathname
|
|
931
|
+
* @param {string | undefined} cookieHeader
|
|
905
932
|
*/
|
|
906
|
-
function resolveLocalePath(pathname) {
|
|
933
|
+
function resolveLocalePath(pathname, cookieHeader) {
|
|
907
934
|
const raw = String(pathname || '/');
|
|
908
935
|
const normalized = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw || '/';
|
|
909
936
|
if (!localeArtifact) {
|
|
@@ -913,6 +940,17 @@ function resolveLocalePath(pathname) {
|
|
|
913
940
|
const defaultLocale = localeArtifact.defaultLocale || supported[0] || 'en';
|
|
914
941
|
const directions = Object.fromEntries((localeArtifact.locales || []).map((l) => [l.id, l.direction || 'ltr']));
|
|
915
942
|
const routing = localeArtifact.routing || {};
|
|
943
|
+
const strategy = routing.strategy || 'prefix';
|
|
944
|
+
if (strategy === 'none') {
|
|
945
|
+
const preferred = readCookie(cookieHeader, LOCALE_STORE_KEY);
|
|
946
|
+
const localeId = preferred && supported.includes(preferred) ? preferred : defaultLocale;
|
|
947
|
+
return {
|
|
948
|
+
localeId,
|
|
949
|
+
dir: directions[localeId] || 'ltr',
|
|
950
|
+
restPath: normalized,
|
|
951
|
+
redirectTo: null,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
916
954
|
const parts = normalized.split('/').filter(Boolean);
|
|
917
955
|
let localeId = null;
|
|
918
956
|
let restPath = normalized;
|
|
@@ -1036,46 +1074,6 @@ async function loadPageCtor(chunkId) {
|
|
|
1036
1074
|
pageCtors.set(chunkId, mod.default);
|
|
1037
1075
|
return mod.default;
|
|
1038
1076
|
}
|
|
1039
|
-
/**
|
|
1040
|
-
* @param {string} dir
|
|
1041
|
-
* @returns {Promise<Array<{ name: string, entry: string }>>}
|
|
1042
|
-
*/
|
|
1043
|
-
async function listClientComponents(dir) {
|
|
1044
|
-
/** @type {Map<string, { name: string, entry: string }>} */
|
|
1045
|
-
const byName = new Map();
|
|
1046
|
-
try {
|
|
1047
|
-
const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
|
|
1048
|
-
const dep = JSON.parse(raw);
|
|
1049
|
-
for (const unit of dep.units || []) {
|
|
1050
|
-
if (unit?.kind !== 'component')
|
|
1051
|
-
continue;
|
|
1052
|
-
const chunkId = String(unit.chunkId || '');
|
|
1053
|
-
const name = chunkId.split('/').pop();
|
|
1054
|
-
if (!name)
|
|
1055
|
-
continue;
|
|
1056
|
-
const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
|
|
1057
|
-
byName.set(name, { name, entry });
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
catch {
|
|
1061
|
-
/* fall through to directory scan */
|
|
1062
|
-
}
|
|
1063
|
-
if (byName.size === 0) {
|
|
1064
|
-
const folder = path.join(dir, 'components');
|
|
1065
|
-
let files = [];
|
|
1066
|
-
try {
|
|
1067
|
-
files = await readdir(folder);
|
|
1068
|
-
}
|
|
1069
|
-
catch {
|
|
1070
|
-
return [];
|
|
1071
|
-
}
|
|
1072
|
-
for (const f of files.filter((name) => name.endsWith('.client.js'))) {
|
|
1073
|
-
const name = f.replace(/\.client\.js$/, '');
|
|
1074
|
-
byName.set(name, { name, entry: `components/${name}.client.js` });
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1078
|
-
}
|
|
1079
1077
|
/**
|
|
1080
1078
|
* Discover compiled page modules. Prefer Route Graph `pathPattern` from
|
|
1081
1079
|
* `vmz-deployment.json`; fall back to walking `pages/**` (file-route only).
|
|
@@ -1442,6 +1440,8 @@ function requireNativeGenerator() {
|
|
|
1442
1440
|
* Style Theme cookie / localStorage key (host contract, not a second theme API).
|
|
1443
1441
|
*/
|
|
1444
1442
|
const THEME_STORE_KEY = 'vmz-theme';
|
|
1443
|
+
/** Host preference key for `routing.strategy: 'none'` (cookie + localStorage). */
|
|
1444
|
+
const LOCALE_STORE_KEY = 'vmz.locale';
|
|
1445
1445
|
/**
|
|
1446
1446
|
* @param {string} dir
|
|
1447
1447
|
* @returns {Promise<{ cssEntry: string|null, styleTheme: typeof styleTheme, styleBundleHash: string|null }>}
|
|
@@ -1517,6 +1517,45 @@ function themeBootstrapScript() {
|
|
|
1517
1517
|
const key = JSON.stringify(THEME_STORE_KEY);
|
|
1518
1518
|
return ` <script>(function(){try{var k=${key},attr=${attr},ids=${ids};var id=localStorage.getItem(k);if(!id||ids.indexOf(id)<0)return;document.documentElement.setAttribute(attr,id);}catch(e){}})();</script>\n`;
|
|
1519
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* LocaleId as client state (routing.strategy = none): apply localStorage before any
|
|
1522
|
+
* page/client module runs so `#locales/*` pick the right variant. Prefix strategy
|
|
1523
|
+
* keeps LocaleId in the URL — no boot rewrite.
|
|
1524
|
+
* Also mirrors into cookie so the next SSR negotiate sees Host preference.
|
|
1525
|
+
*/
|
|
1526
|
+
function localeBootstrapScript() {
|
|
1527
|
+
if (!localeArtifact)
|
|
1528
|
+
return '';
|
|
1529
|
+
const routing = localeArtifact.routing || {};
|
|
1530
|
+
if ((routing.strategy || 'prefix') !== 'none')
|
|
1531
|
+
return '';
|
|
1532
|
+
const ids = (localeArtifact.locales || []).map((l) => l.id).filter(Boolean);
|
|
1533
|
+
if (!ids.length)
|
|
1534
|
+
return '';
|
|
1535
|
+
const key = JSON.stringify(LOCALE_STORE_KEY);
|
|
1536
|
+
const idList = JSON.stringify(ids);
|
|
1537
|
+
return ` <script>(function(){try{var k=${key},ids=${idList};var id=localStorage.getItem(k);if(!id||ids.indexOf(id)<0)return;document.documentElement.setAttribute("data-locale",id);document.documentElement.setAttribute("lang",id);window.__vmzLocaleIdHint=id;document.cookie=k+"="+encodeURIComponent(id)+"; path=/; max-age=31536000; SameSite=Lax";}catch(e){}})();</script>\n`;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Site favicon links from build artifact `_vmz/site-favicon.json` (author SVG → PNG/ICO).
|
|
1541
|
+
* Empty when skipped / missing — do not invent broken <link>s.
|
|
1542
|
+
*/
|
|
1543
|
+
function siteFaviconHeadHtml() {
|
|
1544
|
+
try {
|
|
1545
|
+
const p = path.join(distDir, '_vmz', 'site-favicon.json');
|
|
1546
|
+
if (!existsSync(p))
|
|
1547
|
+
return '';
|
|
1548
|
+
// Sync read: head is per-request; file is tiny and rebuilt with dist.
|
|
1549
|
+
const raw = readFileSync(p, 'utf8');
|
|
1550
|
+
const j = JSON.parse(raw);
|
|
1551
|
+
if (j?.status !== 'ready' || typeof j.headHtml !== 'string')
|
|
1552
|
+
return '';
|
|
1553
|
+
return j.headHtml;
|
|
1554
|
+
}
|
|
1555
|
+
catch {
|
|
1556
|
+
return '';
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1520
1559
|
/**
|
|
1521
1560
|
* @param {string|undefined} header
|
|
1522
1561
|
* @param {string} name
|
package/dist/server.js
CHANGED
|
@@ -226,7 +226,7 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
226
226
|
return await writeFetchResponse(res, response);
|
|
227
227
|
}
|
|
228
228
|
// Static first for assets + DocumentMount (`/d/…`) so docs aren't swallowed by SSR 404 shells.
|
|
229
|
-
//
|
|
229
|
+
// static route HTML (`index.html`, `about/index.html`, …) is a CDN/deploy projection only —
|
|
230
230
|
// when Server Host SSR is active, those files must not shadow live render (local/dev ≡ SSR truth).
|
|
231
231
|
if (verb === 'GET' && opts.distDir) {
|
|
232
232
|
const nodePath = await import('node:path');
|
|
@@ -404,7 +404,7 @@ function safeDistFile(distDir, pathname, nodePath) {
|
|
|
404
404
|
return full;
|
|
405
405
|
}
|
|
406
406
|
/**
|
|
407
|
-
*
|
|
407
|
+
* static profile emits per-route HTML beside client assets. That HTML is for CDN / local-static
|
|
408
408
|
* delivery hosts — not for Server Host when SSR is available. DocumentMount stays static.
|
|
409
409
|
* @param {string} file
|
|
410
410
|
* @param {string} pathname
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vmz/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
|
|
6
6
|
"exports": {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"./client-nav": {
|
|
28
28
|
"types": "./dist/client-nav.d.ts",
|
|
29
29
|
"default": "./dist/client-nav.js"
|
|
30
|
+
},
|
|
31
|
+
"./component-registry": {
|
|
32
|
+
"types": "./dist/list-client-components.d.ts",
|
|
33
|
+
"default": "./dist/list-client-components.js"
|
|
30
34
|
}
|
|
31
35
|
},
|
|
32
36
|
"files": [
|