@sudajs/cli 0.13.4 → 0.15.0

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/index.d.ts CHANGED
@@ -1702,6 +1702,7 @@ declare function fetchJson<T>(url: string, options?: RequestInit & {
1702
1702
  declare function validateThemeTailwindContract(root: string): Promise<void>;
1703
1703
  declare function validateTheme(root: string): Promise<ValidatedTheme>;
1704
1704
  declare function validateThemeLocales(root: string): Promise<void>;
1705
+ declare function validateThemePreviewLocales(root: string, previewLocales: string[] | undefined): Promise<void>;
1705
1706
  declare function toViteModuleUrl(root: string, absolutePath: string): string;
1706
1707
  declare function buildTheme(root: string): Promise<ValidatedTheme>;
1707
1708
  declare function finalizeTheme(root: string): Promise<ValidatedTheme>;
@@ -1728,11 +1729,20 @@ declare const __testUtils: {
1728
1729
  validateThemeName: typeof validateThemeName;
1729
1730
  validateThemeTailwindContract: typeof validateThemeTailwindContract;
1730
1731
  validateThemeLocales: typeof validateThemeLocales;
1732
+ validateThemePreviewLocales: typeof validateThemePreviewLocales;
1733
+ resolveThemePreviewLocale: typeof resolveThemePreviewLocale;
1734
+ withSerializedVitePreviewLocaleLoad: typeof withSerializedVitePreviewLocaleLoad;
1731
1735
  toViteModuleUrl: typeof toViteModuleUrl;
1732
1736
  updateRemotePageDraft: typeof updateRemotePageDraft;
1733
1737
  };
1734
1738
  declare function watchTheme(root: string, port: number): Promise<void>;
1739
+ declare function withSerializedVitePreviewLocaleLoad<Result>(server: object, load: () => Promise<Result>): Promise<Result>;
1735
1740
  declare function resolveDevStarterSlug(pathname: string): string | null | undefined;
1741
+ declare function resolveThemePreviewLocale(params: {
1742
+ previewLocales: string[] | undefined;
1743
+ explicitLang?: string | null;
1744
+ acceptLanguage?: string;
1745
+ }): string;
1736
1746
  /**
1737
1747
  * Theme-project-scoped renderer bundle. We deliberately resolve `react`,
1738
1748
  * `react-dom/server` and `@sudajs/theme-engine/server` through the theme
@@ -1751,7 +1761,7 @@ interface ThemeRenderer {
1751
1761
  renderToString: typeof renderToString;
1752
1762
  }
1753
1763
  declare function loadThemeScopedRenderer(themeRoot: string): Promise<ThemeRenderer>;
1754
- declare function renderDevStarterPageHtml(theme: ValidatedTheme, page: ThemeStarterPage, renderer: ThemeRenderer): string;
1764
+ declare function renderDevStarterPageHtml(theme: ValidatedTheme, page: ThemeStarterPage, renderer: ThemeRenderer, locale?: string, preserveLang?: boolean): string;
1755
1765
  interface ScreenshotOptions extends ThemeRootOptions {
1756
1766
  device?: string[] | string;
1757
1767
  output?: string;
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { themeDesignSystemSchema, createThemeAgentManifest, createSudaPageAgentRuntime, checkThemeModule, formatThemeCheckResult, agentValidationResultSchema, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
12
- import { themeManifestSchema } from '@sudajs/theme-engine/server';
12
+ import { themeManifestSchema, runWithThemePreviewLocale } from '@sudajs/theme-engine/server';
13
13
  import { Command } from 'commander';
14
14
  import { build } from 'esbuild';
15
15
  import { z } from 'zod';
@@ -580,85 +580,105 @@ export const { createRoot, hydrateRoot, version } = ReactDOMClient;
580
580
  }
581
581
  };
582
582
  }
583
- function createHostPuckCoreShimPlugin() {
583
+ function createRejectEditorRuntimeDependencyPlugin() {
584
584
  return {
585
- name: "suda-host-puck-core-shim",
585
+ name: "suda-reject-editor-runtime-dependency",
586
586
  setup(build) {
587
- build.onResolve({ filter: /^@puckeditor\/core$/ }, () => ({
588
- path: "puck-core-host-shim",
589
- namespace: "suda-shim"
590
- }));
591
- build.onLoad({ filter: /^puck-core-host-shim$/, namespace: "suda-shim" }, () => ({
592
- loader: "js",
593
- contents: `
594
- const PuckCore = globalThis.__SUDA_PUCK_CORE__;
595
- if (!PuckCore) {
596
- throw new Error("Missing host Puck core runtime (__SUDA_PUCK_CORE__).");
597
- }
598
- export default PuckCore;
599
- export const {
600
- Action,
601
- ActionBar,
602
- AutoField,
603
- Button,
604
- Drawer,
605
- DropZone,
606
- FieldLabel,
607
- Group,
608
- IconButton,
609
- Label,
610
- Puck,
611
- Render,
612
- RichTextMenu,
613
- Separator,
614
- blocksPlugin,
615
- createUsePuck,
616
- fieldsPlugin,
617
- legacySideBarPlugin,
618
- migrate,
619
- outlinePlugin,
620
- overrideKeys,
621
- registerOverlayPortal,
622
- renderContext,
623
- resolveAllData,
624
- setDeep,
625
- transformProps,
626
- useGetPuck,
627
- usePuck,
628
- walkTree,
629
- } = PuckCore;
630
- `
587
+ build.onResolve({ filter: /^@puckeditor\/(?:core|plugin-ai)(?:\/.*)?$/ }, (args) => ({
588
+ errors: [
589
+ {
590
+ text: `${args.path} is an editor dependency and cannot be imported by the public theme client runtime.`
591
+ }
592
+ ]
631
593
  }));
632
594
  }
633
595
  };
634
596
  }
635
- function createThemeEngineRuntimeAliasPlugin(root) {
597
+ function createHostThemeRuntimeShimPlugin() {
636
598
  return {
637
- name: "suda-theme-engine-runtime-alias",
599
+ name: "suda-host-theme-runtime-shim",
638
600
  setup(build) {
639
- const themeRequire = createRequire(path2.join(root, "package.json"));
640
- const runtimeEntry = themeRequire.resolve("@sudajs/theme-engine/runtime");
641
601
  build.onResolve({ filter: /^@sudajs\/theme-engine\/runtime$/ }, () => ({
642
- path: runtimeEntry
602
+ path: "theme-runtime-host-shim",
603
+ namespace: "suda-shim"
604
+ }));
605
+ build.onLoad({ filter: /^theme-runtime-host-shim$/, namespace: "suda-shim" }, () => ({
606
+ loader: "js",
607
+ contents: `
608
+ const ThemeRuntime = globalThis.__SUDA_THEME_RUNTIME__;
609
+ if (!ThemeRuntime) {
610
+ throw new Error("Missing host theme runtime (__SUDA_THEME_RUNTIME__).");
611
+ }
612
+ export const {
613
+ SudaLucideIcon,
614
+ isSudaLucideIconName,
615
+ normalizeSudaLucideIconName,
616
+ colorSchemeField,
617
+ createColorSchemeDefault,
618
+ createThemeCssVariables,
619
+ designSystemField,
620
+ resolveColorSchemeTokens,
621
+ SUDA_COLOR_TOKEN_KEYS,
622
+ createThemeDesignCssVariables,
623
+ createThemeDesignDefault,
624
+ resolveThemeDesignTokens,
625
+ SUDA_CUSTOM_DESIGN_PRESET_ID,
626
+ SUDA_DESIGN_COLOR_KEYS,
627
+ SUDA_DESIGN_RADIUS_KEYS,
628
+ compileSudaBlockSlots,
629
+ isSudaLocalBlockComponent,
630
+ SUDA_LOCAL_BLOCK_CATEGORY,
631
+ SUDA_LOCAL_BLOCK_TYPE_PREFIX,
632
+ cmsPaginationUrl,
633
+ getIcpRecord,
634
+ getCmsContent,
635
+ getContactForm,
636
+ getPostResource,
637
+ getWhiteLabel,
638
+ resolveAsset,
639
+ postResourceKey,
640
+ getPageSlot,
641
+ THEME_PAGE_SLOT,
642
+ createThemeAsset,
643
+ createThemeAssetPath,
644
+ createThemeAssetResolver,
645
+ resolveThemeClientRuntimeUrl,
646
+ resolveThemeRuntimeUrl,
647
+ resolveAssetPath,
648
+ THEME_CLIENT_RUNTIME_ARTIFACT,
649
+ clearThemeLocale,
650
+ createThemeTranslate,
651
+ installThemeLocale,
652
+ clearThemePreviewLocale,
653
+ createThemePreviewTranslate,
654
+ installThemePreviewLocale,
655
+ } = ThemeRuntime;
656
+ `
643
657
  }));
644
658
  }
645
659
  };
646
660
  }
647
- function createThemeEngineIconAliasPlugin(root) {
661
+ function createHostThemeIconsShimPlugin() {
648
662
  return {
649
- name: "suda-theme-engine-icon-alias",
663
+ name: "suda-host-theme-icons-shim",
650
664
  setup(build) {
651
- const themeRequire = createRequire(path2.join(root, "package.json"));
652
- const themeEngineEntry = themeRequire.resolve("@sudajs/theme-engine");
653
- const entryDir = path2.dirname(themeEngineEntry);
654
- const packageRoot2 = path2.basename(entryDir) === "dist" || path2.basename(entryDir) === "src" ? path2.dirname(entryDir) : entryDir;
655
- const sourceEntry = path2.join(packageRoot2, "src", "icons", "lucide-icon.hydrate.client.tsx");
656
665
  build.onResolve({ filter: /^@sudajs\/theme-engine\/icons$/ }, () => {
657
- if (existsSync(sourceEntry)) {
658
- return { path: sourceEntry };
659
- }
660
- return { path: themeRequire.resolve("@sudajs/theme-engine/icons") };
666
+ return { path: "theme-icons-host-shim", namespace: "suda-shim" };
661
667
  });
668
+ build.onLoad({ filter: /^theme-icons-host-shim$/, namespace: "suda-shim" }, () => ({
669
+ loader: "js",
670
+ contents: `
671
+ const ThemeIcons = globalThis.__SUDA_THEME_ICONS__;
672
+ if (!ThemeIcons) {
673
+ throw new Error("Missing host theme icon runtime (__SUDA_THEME_ICONS__).");
674
+ }
675
+ export const {
676
+ SudaLucideIcon,
677
+ isSudaLucideIconName,
678
+ normalizeSudaLucideIconName,
679
+ } = ThemeIcons;
680
+ `
681
+ }));
662
682
  }
663
683
  };
664
684
  }
@@ -779,9 +799,9 @@ ${issues}`);
779
799
  if (module.manifest.entry !== "index.js") {
780
800
  throw new Error('manifest.entry must be artifact-local: "index.js".');
781
801
  }
782
- if (module.manifest.clientEntry !== void 0 && module.manifest.clientEntry !== "runtime.client.js") {
802
+ if ("clientEntry" in module.manifest) {
783
803
  throw new Error(
784
- 'manifest.clientEntry must be artifact-local: "runtime.client.js" when provided.'
804
+ "manifest.clientEntry is no longer supported. The CLI owns the runtime.client.js artifact."
785
805
  );
786
806
  }
787
807
  assertRecord(module.pageConfig?.components, "pageConfig.components");
@@ -839,6 +859,7 @@ async function validateTheme(root) {
839
859
  validateThemeModule(module);
840
860
  await validateThemeTailwindContract(root);
841
861
  await validateThemeLocales(root);
862
+ await validateThemePreviewLocales(root, module.manifest.previewLocales);
842
863
  const result = {
843
864
  root,
844
865
  module,
@@ -885,7 +906,16 @@ async function listLocaleFiles(localesDir) {
885
906
  return [];
886
907
  }
887
908
  const entries = await readdir(localesDir, { withFileTypes: true });
888
- return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
909
+ return entries.filter(
910
+ (entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith(".content.json")
911
+ ).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
912
+ }
913
+ async function listPreviewLocaleFiles(localesDir) {
914
+ if (!await pathExists(localesDir)) {
915
+ return [];
916
+ }
917
+ const entries = await readdir(localesDir, { withFileTypes: true });
918
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".content.json")).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
889
919
  }
890
920
  async function validateThemeLocales(root) {
891
921
  const localesDir = path2.join(root, "dist", "locales");
@@ -921,6 +951,47 @@ ${details}`
921
951
  }
922
952
  }
923
953
  }
954
+ async function validateThemePreviewLocales(root, previewLocales) {
955
+ const localesDir = path2.join(root, "dist", "locales");
956
+ const files = await listPreviewLocaleFiles(localesDir);
957
+ const declared = previewLocales ?? [];
958
+ const expectedFiles = new Set(declared.map((locale) => `${locale}.content.json`));
959
+ const undeclared = files.filter((file) => !expectedFiles.has(file));
960
+ const missing = [...expectedFiles].filter((file) => !files.includes(file));
961
+ if (missing.length > 0 || undeclared.length > 0) {
962
+ const details = [
963
+ ...missing.map((file) => ` - missing locales/${file}`),
964
+ ...undeclared.map((file) => ` - undeclared locales/${file}`)
965
+ ].join("\n");
966
+ throw new Error(`Theme preview locale files do not match manifest.previewLocales:
967
+ ${details}`);
968
+ }
969
+ if (declared.length === 0) {
970
+ return;
971
+ }
972
+ const referenceFile = "en.content.json";
973
+ const referenceMessages = await readLocaleJson(path2.join(localesDir, referenceFile));
974
+ const referenceKeys = new Set(collectLocaleKeys(referenceMessages).sort());
975
+ for (const file of files) {
976
+ if (file === referenceFile) {
977
+ continue;
978
+ }
979
+ const messages = await readLocaleJson(path2.join(localesDir, file));
980
+ const keys = new Set(collectLocaleKeys(messages).sort());
981
+ const missingKeys = [...referenceKeys].filter((key) => !keys.has(key));
982
+ const extraKeys = [...keys].filter((key) => !referenceKeys.has(key));
983
+ if (missingKeys.length > 0 || extraKeys.length > 0) {
984
+ const details = [
985
+ ...missingKeys.map((key) => ` - missing ${key}`),
986
+ ...extraKeys.map((key) => ` - extra ${key}`)
987
+ ].join("\n");
988
+ throw new Error(
989
+ `Theme preview locale ${file} does not match ${referenceFile} key set:
990
+ ${details}`
991
+ );
992
+ }
993
+ }
994
+ }
924
995
  var VITE_CONFIG_FILES = [
925
996
  "vite.config.ts",
926
997
  "vite.config.mts",
@@ -950,18 +1021,6 @@ async function loadThemeVite(root) {
950
1021
  }
951
1022
  return await import(pathToFileURL(viteEntry).href);
952
1023
  }
953
- async function findClientEntry(root) {
954
- const candidates = [
955
- path2.join(root, "src", "runtime.client.tsx"),
956
- path2.join(root, "src", "runtime.client.ts")
957
- ];
958
- for (const candidate of candidates) {
959
- if (await pathExists(candidate)) {
960
- return candidate;
961
- }
962
- }
963
- throw new Error(`Missing runtime entry. Add src/runtime.client.ts(x) in ${root}.`);
964
- }
965
1024
  async function findServerEntry(root) {
966
1025
  const candidates = [path2.join(root, "src", "index.tsx"), path2.join(root, "src", "index.ts")];
967
1026
  for (const candidate of candidates) {
@@ -1062,38 +1121,74 @@ async function copyThemeLocales(root) {
1062
1121
  await copyDirectory(sourceLocales, path2.join(root, "dist", "locales"));
1063
1122
  }
1064
1123
  async function buildClientRuntime(root, minify) {
1065
- const entryPoint = await findClientEntry(root);
1066
- if (!await pathExists(entryPoint)) {
1067
- throw new Error(`Missing client entry: ${entryPoint}`);
1124
+ const legacyEntries = [
1125
+ path2.join(root, "src", "runtime.client.ts"),
1126
+ path2.join(root, "src", "runtime.client.tsx")
1127
+ ];
1128
+ if (legacyEntries.some((entry) => existsSync(entry))) {
1129
+ throw new Error(
1130
+ "Theme-authored src/runtime.client.ts(x) is no longer supported. Move optional browser setup to src/client.ts as clientHooks; the CLI owns runtime.client.js."
1131
+ );
1068
1132
  }
1069
1133
  const packageJson = JSON.parse(await readFile(path2.join(root, "package.json"), "utf8"));
1070
1134
  const themeVersion = typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
1135
+ const clientHooksPath = path2.join(root, "src", "client.ts");
1136
+ const generatedEntry = path2.join(root, ".suda-build", "runtime.client.ts");
1137
+ const clientHooksImport = await pathExists(clientHooksPath) ? 'import { clientHooks } from "../src/client.js";' : "const clientHooks = {};";
1138
+ await mkdir(path2.dirname(generatedEntry), { recursive: true });
1139
+ await writeFile(
1140
+ generatedEntry,
1141
+ [
1142
+ '"use client";',
1143
+ 'import type { ThemeClientHooks, ThemeClientRuntime, ThemeRenderModule } from "@sudajs/theme-engine/runtime";',
1144
+ 'import * as themeConfig from "../src/config.js";',
1145
+ clientHooksImport,
1146
+ "const theme: ThemeRenderModule = {",
1147
+ " pageConfig: themeConfig.pageConfig,",
1148
+ " layoutConfig: themeConfig.layoutConfig,",
1149
+ "};",
1150
+ 'if ("rendering" in themeConfig && themeConfig.rendering !== undefined) {',
1151
+ " theme.rendering = themeConfig.rendering;",
1152
+ "}",
1153
+ "const hooks: ThemeClientHooks = clientHooks;",
1154
+ "const runtime: ThemeClientRuntime = { theme };",
1155
+ "if (hooks.initialize !== undefined) {",
1156
+ " runtime.initialize = hooks.initialize;",
1157
+ "}",
1158
+ "export default runtime;",
1159
+ ""
1160
+ ].join("\n"),
1161
+ "utf8"
1162
+ );
1071
1163
  await mkdir(path2.join(root, "dist"), { recursive: true });
1072
1164
  const hostReactShimPlugin = createHostReactShimPlugin();
1073
- const hostPuckCoreShimPlugin = createHostPuckCoreShimPlugin();
1074
- const themeEngineRuntimeAliasPlugin = createThemeEngineRuntimeAliasPlugin(root);
1075
- const themeEngineIconAliasPlugin = createThemeEngineIconAliasPlugin(root);
1165
+ const rejectEditorRuntimeDependencyPlugin = createRejectEditorRuntimeDependencyPlugin();
1166
+ const hostThemeRuntimeShimPlugin = createHostThemeRuntimeShimPlugin();
1167
+ const hostThemeIconsShimPlugin = createHostThemeIconsShimPlugin();
1076
1168
  const themeDependencyResolverPlugin = createThemeDependencyResolverPlugin(root);
1077
1169
  await build({
1078
1170
  bundle: true,
1079
- entryPoints: [entryPoint],
1171
+ entryPoints: { "runtime.client": generatedEntry },
1080
1172
  external: ["react", "react/*", "react-dom", "react-dom/*"],
1081
1173
  format: "esm",
1082
1174
  jsx: "automatic",
1175
+ logOverride: { "import-is-undefined": "silent" },
1083
1176
  minify,
1084
- outfile: path2.join(root, "dist", "runtime.client.js"),
1177
+ outdir: path2.join(root, "dist"),
1085
1178
  platform: "browser",
1086
1179
  define: {
1087
1180
  __SUDA_THEME_VERSION__: JSON.stringify(themeVersion)
1088
1181
  },
1089
1182
  plugins: [
1090
1183
  hostReactShimPlugin,
1091
- hostPuckCoreShimPlugin,
1092
- themeEngineRuntimeAliasPlugin,
1093
- themeEngineIconAliasPlugin,
1184
+ rejectEditorRuntimeDependencyPlugin,
1185
+ hostThemeRuntimeShimPlugin,
1186
+ hostThemeIconsShimPlugin,
1094
1187
  themeDependencyResolverPlugin
1095
1188
  ],
1096
1189
  sourcemap: false,
1190
+ splitting: true,
1191
+ chunkNames: "chunks/[name]-[hash]",
1097
1192
  target: "es2022",
1098
1193
  treeShaking: true
1099
1194
  });
@@ -1284,6 +1379,9 @@ var __testUtils = {
1284
1379
  validateThemeName,
1285
1380
  validateThemeTailwindContract,
1286
1381
  validateThemeLocales,
1382
+ validateThemePreviewLocales,
1383
+ resolveThemePreviewLocale,
1384
+ withSerializedVitePreviewLocaleLoad,
1287
1385
  toViteModuleUrl,
1288
1386
  updateRemotePageDraft
1289
1387
  };
@@ -1381,7 +1479,15 @@ function createSudaPreviewVitePlugin(root) {
1381
1479
  return;
1382
1480
  }
1383
1481
  try {
1384
- const theme = await loadViteDevTheme(root, server);
1482
+ const acceptLanguageHeader = request.headers["accept-language"];
1483
+ const acceptLanguage = Array.isArray(acceptLanguageHeader) ? acceptLanguageHeader.join(",") : acceptLanguageHeader;
1484
+ const explicitLang = url.searchParams.get("lang");
1485
+ const { theme, locale } = await loadLocalizedViteDevTheme(
1486
+ root,
1487
+ server,
1488
+ explicitLang,
1489
+ acceptLanguage
1490
+ );
1385
1491
  const renderer = await loadThemeScopedRenderer(root);
1386
1492
  if (slug === null) {
1387
1493
  const homeSlug = pickStarterSlug(theme);
@@ -1398,7 +1504,13 @@ function createSudaPreviewVitePlugin(root) {
1398
1504
  }
1399
1505
  const html2 = await server.transformIndexHtml(
1400
1506
  url.pathname,
1401
- renderDevStarterPageHtml(theme, starter2, renderer)
1507
+ renderDevStarterPageHtml(
1508
+ theme,
1509
+ starter2,
1510
+ renderer,
1511
+ locale,
1512
+ canonicalLocale(explicitLang) !== null
1513
+ )
1402
1514
  );
1403
1515
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1404
1516
  response.end(html2);
@@ -1412,7 +1524,13 @@ function createSudaPreviewVitePlugin(root) {
1412
1524
  }
1413
1525
  const html = await server.transformIndexHtml(
1414
1526
  url.pathname,
1415
- renderDevStarterPageHtml(theme, starter, renderer)
1527
+ renderDevStarterPageHtml(
1528
+ theme,
1529
+ starter,
1530
+ renderer,
1531
+ locale,
1532
+ canonicalLocale(explicitLang) !== null
1533
+ )
1416
1534
  );
1417
1535
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1418
1536
  response.end(html);
@@ -1425,6 +1543,48 @@ function createSudaPreviewVitePlugin(root) {
1425
1543
  }
1426
1544
  };
1427
1545
  }
1546
+ async function loadLocalizedViteDevTheme(root, server, explicitLang, acceptLanguage) {
1547
+ return withSerializedVitePreviewLocaleLoad(server, async () => {
1548
+ const baseTheme = await loadViteDevTheme(root, server);
1549
+ const localeParams = {
1550
+ previewLocales: baseTheme.module.manifest.previewLocales,
1551
+ explicitLang
1552
+ };
1553
+ if (acceptLanguage !== void 0) {
1554
+ localeParams.acceptLanguage = acceptLanguage;
1555
+ }
1556
+ const locale = resolveThemePreviewLocale(localeParams);
1557
+ if (!baseTheme.module.manifest.previewLocales) {
1558
+ return { theme: baseTheme, locale };
1559
+ }
1560
+ const messages = await readDevPreviewLocaleMessages(root, locale);
1561
+ server.moduleGraph.invalidateAll();
1562
+ const theme = await runWithThemePreviewLocale(
1563
+ messages,
1564
+ () => loadViteDevTheme(root, server)
1565
+ );
1566
+ return { theme, locale };
1567
+ });
1568
+ }
1569
+ var vitePreviewLocaleLoadTails = /* @__PURE__ */ new WeakMap();
1570
+ async function withSerializedVitePreviewLocaleLoad(server, load) {
1571
+ const previous = vitePreviewLocaleLoadTails.get(server) ?? Promise.resolve();
1572
+ let release;
1573
+ const gate = new Promise((resolve) => {
1574
+ release = resolve;
1575
+ });
1576
+ const tail = previous.then(() => gate);
1577
+ vitePreviewLocaleLoadTails.set(server, tail);
1578
+ await previous;
1579
+ try {
1580
+ return await load();
1581
+ } finally {
1582
+ release();
1583
+ if (vitePreviewLocaleLoadTails.get(server) === tail) {
1584
+ vitePreviewLocaleLoadTails.delete(server);
1585
+ }
1586
+ }
1587
+ }
1428
1588
  async function loadViteDevTheme(root, server) {
1429
1589
  const serverEntryPath = await findServerEntry(root);
1430
1590
  const imported = await server.ssrLoadModule(toViteModuleUrl(root, serverEntryPath));
@@ -1461,6 +1621,73 @@ function resolveDevStarterSlug(pathname) {
1461
1621
  }
1462
1622
  return decodeURIComponent(normalized);
1463
1623
  }
1624
+ function canonicalLocale(value) {
1625
+ const candidate = value?.trim();
1626
+ if (!candidate || candidate === "*") {
1627
+ return null;
1628
+ }
1629
+ try {
1630
+ return Intl.getCanonicalLocales(candidate)[0] ?? null;
1631
+ } catch {
1632
+ return null;
1633
+ }
1634
+ }
1635
+ function acceptLanguageLocales(value) {
1636
+ if (!value) {
1637
+ return [];
1638
+ }
1639
+ return value.split(",").map((part, index) => {
1640
+ const [rawLocale, ...parameters] = part.trim().split(";");
1641
+ const locale = canonicalLocale(rawLocale);
1642
+ const qualityParameter = parameters.find((parameter) => parameter.trim().startsWith("q="));
1643
+ const parsedQuality = qualityParameter ? Number.parseFloat(qualityParameter.trim().slice(2)) : 1;
1644
+ const quality = Number.isFinite(parsedQuality) ? parsedQuality : 0;
1645
+ return { locale, quality, index };
1646
+ }).filter(
1647
+ (entry) => Boolean(entry.locale) && entry.quality > 0
1648
+ ).sort((left, right) => right.quality - left.quality || left.index - right.index).map((entry) => entry.locale);
1649
+ }
1650
+ function localeLookupCandidates(locale) {
1651
+ const candidates = [];
1652
+ let current = locale;
1653
+ while (current) {
1654
+ candidates.push(current);
1655
+ const separator = current.lastIndexOf("-");
1656
+ if (separator < 0) {
1657
+ break;
1658
+ }
1659
+ current = current.slice(0, separator);
1660
+ }
1661
+ return candidates;
1662
+ }
1663
+ function resolveThemePreviewLocale(params) {
1664
+ const supported = params.previewLocales ?? ["en"];
1665
+ const supportedByNormalized = new Map(
1666
+ supported.map((locale) => [locale.toLowerCase(), locale])
1667
+ );
1668
+ const explicitLocale = canonicalLocale(params.explicitLang);
1669
+ const requestedLocales = explicitLocale ? [explicitLocale] : acceptLanguageLocales(params.acceptLanguage);
1670
+ for (const requested of requestedLocales) {
1671
+ for (const candidate of localeLookupCandidates(requested)) {
1672
+ const supportedLocale = supportedByNormalized.get(candidate.toLowerCase());
1673
+ if (supportedLocale) {
1674
+ return supportedLocale;
1675
+ }
1676
+ }
1677
+ if (explicitLocale) {
1678
+ break;
1679
+ }
1680
+ }
1681
+ return supportedByNormalized.get("en") ?? "en";
1682
+ }
1683
+ async function readDevPreviewLocaleMessages(root, locale) {
1684
+ const filePath = path2.join(root, "src", "locales", `${locale}.content.json`);
1685
+ const messages = await readLocaleJson(filePath);
1686
+ if (!isRecord(messages)) {
1687
+ throw new Error(`Theme preview locale ${locale} must be a JSON object.`);
1688
+ }
1689
+ return messages;
1690
+ }
1464
1691
  function escapeHtml(value) {
1465
1692
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1466
1693
  }
@@ -1494,7 +1721,7 @@ async function loadThemeScopedRenderer(themeRoot) {
1494
1721
  renderToString: reactDomServerModule.renderToString
1495
1722
  };
1496
1723
  }
1497
- function renderDevStarterPageHtml(theme, page, renderer) {
1724
+ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserveLang = false) {
1498
1725
  const chrome = renderer.extractLayoutChrome(theme.module.defaultLayout);
1499
1726
  const metadata = {
1500
1727
  resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath),
@@ -1513,7 +1740,7 @@ function renderDevStarterPageHtml(theme, page, renderer) {
1513
1740
  const customBody = chrome.customBodyCode ?? "";
1514
1741
  return [
1515
1742
  "<!doctype html>",
1516
- '<html lang="en">',
1743
+ `<html lang="${escapeHtml(locale)}">`,
1517
1744
  "<head>",
1518
1745
  '<meta charset="utf-8" />',
1519
1746
  '<meta name="viewport" content="width=device-width, initial-scale=1" />',
@@ -1524,6 +1751,9 @@ function renderDevStarterPageHtml(theme, page, renderer) {
1524
1751
  `<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
1525
1752
  body,
1526
1753
  customBody,
1754
+ ...preserveLang ? [
1755
+ '<script>for(const a of document.querySelectorAll("a[href]")){try{const u=new URL(a.href,location.href);if(u.origin===location.origin){u.searchParams.set("lang",new URL(location.href).searchParams.get("lang"));a.href=u.pathname+u.search+u.hash}}catch{}}</script>'
1756
+ ] : [],
1527
1757
  "</body>",
1528
1758
  "</html>"
1529
1759
  ].join("");