@vmz/core 0.1.7 → 0.1.8
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/serve-host.mjs +89 -9
- package/package.json +1 -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
|
*/
|
package/dist/serve-host.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
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';
|
|
@@ -75,6 +75,8 @@ if (isDev) {
|
|
|
75
75
|
}
|
|
76
76
|
/** @type {number} */
|
|
77
77
|
let reloadToken = Date.now();
|
|
78
|
+
/** @type {string | null} Correlatable build id from vmz dev (Living §12.8). */
|
|
79
|
+
let lastDevBuildId = null;
|
|
78
80
|
/** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
|
|
79
81
|
let pageCatalog = [];
|
|
80
82
|
/** @type {Map<string, any>} */
|
|
@@ -152,7 +154,7 @@ async function renderPageStream(pathname, opts = {}) {
|
|
|
152
154
|
if (isDev && lastDevError && pageCtors.size === 0) {
|
|
153
155
|
return { status: 500, stream: emitDevErrorHtml(lastDevError) };
|
|
154
156
|
}
|
|
155
|
-
const localePlan = resolveLocalePath(pathname);
|
|
157
|
+
const localePlan = resolveLocalePath(pathname, opts.cookieHeader);
|
|
156
158
|
if (localePlan.redirectTo) {
|
|
157
159
|
return { status: 302, redirect: localePlan.redirectTo, headers: { Location: localePlan.redirectTo } };
|
|
158
160
|
}
|
|
@@ -477,10 +479,15 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
|
|
|
477
479
|
`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
480
|
`document.documentElement.appendChild(d);})();</script>`
|
|
479
481
|
: '';
|
|
482
|
+
const buildIdBoot = isDev && lastDevBuildId
|
|
483
|
+
? `\n <script>window.__VMZ_DEV_BUILD_ID__=${JSON.stringify(lastDevBuildId)};</script>`
|
|
484
|
+
: '';
|
|
480
485
|
if (signal?.aborted)
|
|
481
486
|
return;
|
|
482
487
|
const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
|
|
483
488
|
const themeBoot = themeBootstrapScript();
|
|
489
|
+
const localeBoot = localeBootstrapScript();
|
|
490
|
+
const faviconHead = siteFaviconHeadHtml();
|
|
484
491
|
const propsJson = JSON.stringify(props ?? {});
|
|
485
492
|
const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
|
|
486
493
|
const dir = localeCtx.dir || 'ltr';
|
|
@@ -552,9 +559,9 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
|
|
|
552
559
|
...(cssHref ? { cssEntry: cssHref } : {}),
|
|
553
560
|
isErrorDocument: false,
|
|
554
561
|
htmlExtraAttrs,
|
|
555
|
-
headExtraHtml: themeBoot
|
|
562
|
+
headExtraHtml: `${themeBoot}${localeBoot}${faviconHead}`,
|
|
556
563
|
moduleScriptSrc: entrySrc,
|
|
557
|
-
bodyTailHtml: `${live}${bootOverlay}`,
|
|
564
|
+
bodyTailHtml: `${live}${bootOverlay}${buildIdBoot}`,
|
|
558
565
|
});
|
|
559
566
|
}
|
|
560
567
|
const server = http.createServer((req, res) => {
|
|
@@ -670,7 +677,7 @@ process.on('SIGINT', () => {
|
|
|
670
677
|
* Re-import routes / pages / components with a new cache-bust token.
|
|
671
678
|
* Keeps the HTTP server process alive (no Node restart).
|
|
672
679
|
* 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]
|
|
680
|
+
* @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], emitted?: string[], full?: boolean, islandHmr?: boolean, buildId?: string, sourceRevision?: string, bundleRevision?: string, changed?: string[] } }} [opts]
|
|
674
681
|
*/
|
|
675
682
|
async function softReload(opts = {}) {
|
|
676
683
|
const prevToken = reloadToken;
|
|
@@ -683,6 +690,11 @@ async function softReload(opts = {}) {
|
|
|
683
690
|
const emitted = opts.payload?.emitted ?? [];
|
|
684
691
|
const full = opts.payload?.full;
|
|
685
692
|
const islandHmr = Boolean(opts.payload?.islandHmr);
|
|
693
|
+
const buildId = opts.payload?.buildId != null ? String(opts.payload.buildId) : null;
|
|
694
|
+
const sourceRevision = opts.payload?.sourceRevision != null ? String(opts.payload.sourceRevision) : null;
|
|
695
|
+
const bundleRevision = opts.payload?.bundleRevision != null ? String(opts.payload.bundleRevision) : null;
|
|
696
|
+
if (buildId)
|
|
697
|
+
lastDevBuildId = buildId;
|
|
686
698
|
const reloadAllPages = shouldReloadAllPages({ full, affected, emitted, islandHmr });
|
|
687
699
|
try {
|
|
688
700
|
try {
|
|
@@ -772,13 +784,21 @@ async function softReload(opts = {}) {
|
|
|
772
784
|
affectedChunks: affected,
|
|
773
785
|
seedChunks: seeds,
|
|
774
786
|
token: reloadToken,
|
|
787
|
+
buildId: buildId || lastDevBuildId,
|
|
788
|
+
sourceRevision,
|
|
789
|
+
bundleRevision,
|
|
790
|
+
serveRevision: String(reloadToken),
|
|
775
791
|
full: Boolean(full),
|
|
776
792
|
eventOnlyShell,
|
|
777
793
|
}));
|
|
778
794
|
if (!opts.quiet) {
|
|
779
795
|
const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
|
|
780
796
|
const scope = islandHmr ? 'island' : reloadAllPages ? 'all-pages' : `pages=${nextCtors.size}`;
|
|
781
|
-
|
|
797
|
+
const bid = buildId || lastDevBuildId;
|
|
798
|
+
const rev = bid || sourceRevision || bundleRevision
|
|
799
|
+
? ` buildId=${bid || '-'} source=${sourceRevision || '-'} bundle=${bundleRevision || '-'} serve=${reloadToken}`
|
|
800
|
+
: ` t=${reloadToken}`;
|
|
801
|
+
console.log(`vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${pageCatalog.length};${rev}${aff})`);
|
|
782
802
|
}
|
|
783
803
|
return {
|
|
784
804
|
affectedChunks: affected,
|
|
@@ -790,6 +810,11 @@ async function softReload(opts = {}) {
|
|
|
790
810
|
pageCount: pageCatalog.length,
|
|
791
811
|
reloadedPages: islandHmr ? 0 : nextCtors.size,
|
|
792
812
|
reloadAllPages,
|
|
813
|
+
buildId: buildId || lastDevBuildId,
|
|
814
|
+
sourceRevision,
|
|
815
|
+
bundleRevision,
|
|
816
|
+
serveRevision: String(reloadToken),
|
|
817
|
+
token: reloadToken,
|
|
793
818
|
};
|
|
794
819
|
}
|
|
795
820
|
catch (err) {
|
|
@@ -899,11 +924,14 @@ function escapeHtml(s) {
|
|
|
899
924
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
900
925
|
}
|
|
901
926
|
/**
|
|
902
|
-
* Resolve LocaleId
|
|
903
|
-
*
|
|
927
|
+
* Resolve LocaleId for this request.
|
|
928
|
+
* - `prefix`: LocaleId from URL path (existing).
|
|
929
|
+
* - `none`: Host preference from cookie `vmz.locale` (validated), else defaultLocale.
|
|
930
|
+
* URL never carries LocaleId.
|
|
904
931
|
* @param {string} pathname
|
|
932
|
+
* @param {string | undefined} cookieHeader
|
|
905
933
|
*/
|
|
906
|
-
function resolveLocalePath(pathname) {
|
|
934
|
+
function resolveLocalePath(pathname, cookieHeader) {
|
|
907
935
|
const raw = String(pathname || '/');
|
|
908
936
|
const normalized = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw || '/';
|
|
909
937
|
if (!localeArtifact) {
|
|
@@ -913,6 +941,17 @@ function resolveLocalePath(pathname) {
|
|
|
913
941
|
const defaultLocale = localeArtifact.defaultLocale || supported[0] || 'en';
|
|
914
942
|
const directions = Object.fromEntries((localeArtifact.locales || []).map((l) => [l.id, l.direction || 'ltr']));
|
|
915
943
|
const routing = localeArtifact.routing || {};
|
|
944
|
+
const strategy = routing.strategy || 'prefix';
|
|
945
|
+
if (strategy === 'none') {
|
|
946
|
+
const preferred = readCookie(cookieHeader, LOCALE_STORE_KEY);
|
|
947
|
+
const localeId = preferred && supported.includes(preferred) ? preferred : defaultLocale;
|
|
948
|
+
return {
|
|
949
|
+
localeId,
|
|
950
|
+
dir: directions[localeId] || 'ltr',
|
|
951
|
+
restPath: normalized,
|
|
952
|
+
redirectTo: null,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
916
955
|
const parts = normalized.split('/').filter(Boolean);
|
|
917
956
|
let localeId = null;
|
|
918
957
|
let restPath = normalized;
|
|
@@ -1442,6 +1481,8 @@ function requireNativeGenerator() {
|
|
|
1442
1481
|
* Style Theme cookie / localStorage key (host contract, not a second theme API).
|
|
1443
1482
|
*/
|
|
1444
1483
|
const THEME_STORE_KEY = 'vmz-theme';
|
|
1484
|
+
/** Host preference key for `routing.strategy: 'none'` (cookie + localStorage). */
|
|
1485
|
+
const LOCALE_STORE_KEY = 'vmz.locale';
|
|
1445
1486
|
/**
|
|
1446
1487
|
* @param {string} dir
|
|
1447
1488
|
* @returns {Promise<{ cssEntry: string|null, styleTheme: typeof styleTheme, styleBundleHash: string|null }>}
|
|
@@ -1517,6 +1558,45 @@ function themeBootstrapScript() {
|
|
|
1517
1558
|
const key = JSON.stringify(THEME_STORE_KEY);
|
|
1518
1559
|
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
1560
|
}
|
|
1561
|
+
/**
|
|
1562
|
+
* LocaleId as client state (routing.strategy = none): apply localStorage before any
|
|
1563
|
+
* page/client module runs so `#locales/*` pick the right variant. Prefix strategy
|
|
1564
|
+
* keeps LocaleId in the URL — no boot rewrite.
|
|
1565
|
+
* Also mirrors into cookie so the next SSR negotiate sees Host preference.
|
|
1566
|
+
*/
|
|
1567
|
+
function localeBootstrapScript() {
|
|
1568
|
+
if (!localeArtifact)
|
|
1569
|
+
return '';
|
|
1570
|
+
const routing = localeArtifact.routing || {};
|
|
1571
|
+
if ((routing.strategy || 'prefix') !== 'none')
|
|
1572
|
+
return '';
|
|
1573
|
+
const ids = (localeArtifact.locales || []).map((l) => l.id).filter(Boolean);
|
|
1574
|
+
if (!ids.length)
|
|
1575
|
+
return '';
|
|
1576
|
+
const key = JSON.stringify(LOCALE_STORE_KEY);
|
|
1577
|
+
const idList = JSON.stringify(ids);
|
|
1578
|
+
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`;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Site favicon links from build artifact `_vmz/site-favicon.json` (author SVG → PNG/ICO).
|
|
1582
|
+
* Empty when skipped / missing — do not invent broken <link>s.
|
|
1583
|
+
*/
|
|
1584
|
+
function siteFaviconHeadHtml() {
|
|
1585
|
+
try {
|
|
1586
|
+
const p = path.join(distDir, '_vmz', 'site-favicon.json');
|
|
1587
|
+
if (!existsSync(p))
|
|
1588
|
+
return '';
|
|
1589
|
+
// Sync read: head is per-request; file is tiny and rebuilt with dist.
|
|
1590
|
+
const raw = readFileSync(p, 'utf8');
|
|
1591
|
+
const j = JSON.parse(raw);
|
|
1592
|
+
if (j?.status !== 'ready' || typeof j.headHtml !== 'string')
|
|
1593
|
+
return '';
|
|
1594
|
+
return j.headHtml;
|
|
1595
|
+
}
|
|
1596
|
+
catch {
|
|
1597
|
+
return '';
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1520
1600
|
/**
|
|
1521
1601
|
* @param {string|undefined} header
|
|
1522
1602
|
* @param {string} name
|