@sudajs/cli 0.13.4 → 0.14.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';
@@ -839,6 +839,7 @@ async function validateTheme(root) {
839
839
  validateThemeModule(module);
840
840
  await validateThemeTailwindContract(root);
841
841
  await validateThemeLocales(root);
842
+ await validateThemePreviewLocales(root, module.manifest.previewLocales);
842
843
  const result = {
843
844
  root,
844
845
  module,
@@ -885,7 +886,16 @@ async function listLocaleFiles(localesDir) {
885
886
  return [];
886
887
  }
887
888
  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));
889
+ return entries.filter(
890
+ (entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith(".content.json")
891
+ ).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
892
+ }
893
+ async function listPreviewLocaleFiles(localesDir) {
894
+ if (!await pathExists(localesDir)) {
895
+ return [];
896
+ }
897
+ const entries = await readdir(localesDir, { withFileTypes: true });
898
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".content.json")).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
889
899
  }
890
900
  async function validateThemeLocales(root) {
891
901
  const localesDir = path2.join(root, "dist", "locales");
@@ -921,6 +931,47 @@ ${details}`
921
931
  }
922
932
  }
923
933
  }
934
+ async function validateThemePreviewLocales(root, previewLocales) {
935
+ const localesDir = path2.join(root, "dist", "locales");
936
+ const files = await listPreviewLocaleFiles(localesDir);
937
+ const declared = previewLocales ?? [];
938
+ const expectedFiles = new Set(declared.map((locale) => `${locale}.content.json`));
939
+ const undeclared = files.filter((file) => !expectedFiles.has(file));
940
+ const missing = [...expectedFiles].filter((file) => !files.includes(file));
941
+ if (missing.length > 0 || undeclared.length > 0) {
942
+ const details = [
943
+ ...missing.map((file) => ` - missing locales/${file}`),
944
+ ...undeclared.map((file) => ` - undeclared locales/${file}`)
945
+ ].join("\n");
946
+ throw new Error(`Theme preview locale files do not match manifest.previewLocales:
947
+ ${details}`);
948
+ }
949
+ if (declared.length === 0) {
950
+ return;
951
+ }
952
+ const referenceFile = "en.content.json";
953
+ const referenceMessages = await readLocaleJson(path2.join(localesDir, referenceFile));
954
+ const referenceKeys = new Set(collectLocaleKeys(referenceMessages).sort());
955
+ for (const file of files) {
956
+ if (file === referenceFile) {
957
+ continue;
958
+ }
959
+ const messages = await readLocaleJson(path2.join(localesDir, file));
960
+ const keys = new Set(collectLocaleKeys(messages).sort());
961
+ const missingKeys = [...referenceKeys].filter((key) => !keys.has(key));
962
+ const extraKeys = [...keys].filter((key) => !referenceKeys.has(key));
963
+ if (missingKeys.length > 0 || extraKeys.length > 0) {
964
+ const details = [
965
+ ...missingKeys.map((key) => ` - missing ${key}`),
966
+ ...extraKeys.map((key) => ` - extra ${key}`)
967
+ ].join("\n");
968
+ throw new Error(
969
+ `Theme preview locale ${file} does not match ${referenceFile} key set:
970
+ ${details}`
971
+ );
972
+ }
973
+ }
974
+ }
924
975
  var VITE_CONFIG_FILES = [
925
976
  "vite.config.ts",
926
977
  "vite.config.mts",
@@ -1284,6 +1335,9 @@ var __testUtils = {
1284
1335
  validateThemeName,
1285
1336
  validateThemeTailwindContract,
1286
1337
  validateThemeLocales,
1338
+ validateThemePreviewLocales,
1339
+ resolveThemePreviewLocale,
1340
+ withSerializedVitePreviewLocaleLoad,
1287
1341
  toViteModuleUrl,
1288
1342
  updateRemotePageDraft
1289
1343
  };
@@ -1381,7 +1435,15 @@ function createSudaPreviewVitePlugin(root) {
1381
1435
  return;
1382
1436
  }
1383
1437
  try {
1384
- const theme = await loadViteDevTheme(root, server);
1438
+ const acceptLanguageHeader = request.headers["accept-language"];
1439
+ const acceptLanguage = Array.isArray(acceptLanguageHeader) ? acceptLanguageHeader.join(",") : acceptLanguageHeader;
1440
+ const explicitLang = url.searchParams.get("lang");
1441
+ const { theme, locale } = await loadLocalizedViteDevTheme(
1442
+ root,
1443
+ server,
1444
+ explicitLang,
1445
+ acceptLanguage
1446
+ );
1385
1447
  const renderer = await loadThemeScopedRenderer(root);
1386
1448
  if (slug === null) {
1387
1449
  const homeSlug = pickStarterSlug(theme);
@@ -1398,7 +1460,13 @@ function createSudaPreviewVitePlugin(root) {
1398
1460
  }
1399
1461
  const html2 = await server.transformIndexHtml(
1400
1462
  url.pathname,
1401
- renderDevStarterPageHtml(theme, starter2, renderer)
1463
+ renderDevStarterPageHtml(
1464
+ theme,
1465
+ starter2,
1466
+ renderer,
1467
+ locale,
1468
+ canonicalLocale(explicitLang) !== null
1469
+ )
1402
1470
  );
1403
1471
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1404
1472
  response.end(html2);
@@ -1412,7 +1480,13 @@ function createSudaPreviewVitePlugin(root) {
1412
1480
  }
1413
1481
  const html = await server.transformIndexHtml(
1414
1482
  url.pathname,
1415
- renderDevStarterPageHtml(theme, starter, renderer)
1483
+ renderDevStarterPageHtml(
1484
+ theme,
1485
+ starter,
1486
+ renderer,
1487
+ locale,
1488
+ canonicalLocale(explicitLang) !== null
1489
+ )
1416
1490
  );
1417
1491
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1418
1492
  response.end(html);
@@ -1425,6 +1499,48 @@ function createSudaPreviewVitePlugin(root) {
1425
1499
  }
1426
1500
  };
1427
1501
  }
1502
+ async function loadLocalizedViteDevTheme(root, server, explicitLang, acceptLanguage) {
1503
+ return withSerializedVitePreviewLocaleLoad(server, async () => {
1504
+ const baseTheme = await loadViteDevTheme(root, server);
1505
+ const localeParams = {
1506
+ previewLocales: baseTheme.module.manifest.previewLocales,
1507
+ explicitLang
1508
+ };
1509
+ if (acceptLanguage !== void 0) {
1510
+ localeParams.acceptLanguage = acceptLanguage;
1511
+ }
1512
+ const locale = resolveThemePreviewLocale(localeParams);
1513
+ if (!baseTheme.module.manifest.previewLocales) {
1514
+ return { theme: baseTheme, locale };
1515
+ }
1516
+ const messages = await readDevPreviewLocaleMessages(root, locale);
1517
+ server.moduleGraph.invalidateAll();
1518
+ const theme = await runWithThemePreviewLocale(
1519
+ messages,
1520
+ () => loadViteDevTheme(root, server)
1521
+ );
1522
+ return { theme, locale };
1523
+ });
1524
+ }
1525
+ var vitePreviewLocaleLoadTails = /* @__PURE__ */ new WeakMap();
1526
+ async function withSerializedVitePreviewLocaleLoad(server, load) {
1527
+ const previous = vitePreviewLocaleLoadTails.get(server) ?? Promise.resolve();
1528
+ let release;
1529
+ const gate = new Promise((resolve) => {
1530
+ release = resolve;
1531
+ });
1532
+ const tail = previous.then(() => gate);
1533
+ vitePreviewLocaleLoadTails.set(server, tail);
1534
+ await previous;
1535
+ try {
1536
+ return await load();
1537
+ } finally {
1538
+ release();
1539
+ if (vitePreviewLocaleLoadTails.get(server) === tail) {
1540
+ vitePreviewLocaleLoadTails.delete(server);
1541
+ }
1542
+ }
1543
+ }
1428
1544
  async function loadViteDevTheme(root, server) {
1429
1545
  const serverEntryPath = await findServerEntry(root);
1430
1546
  const imported = await server.ssrLoadModule(toViteModuleUrl(root, serverEntryPath));
@@ -1461,6 +1577,73 @@ function resolveDevStarterSlug(pathname) {
1461
1577
  }
1462
1578
  return decodeURIComponent(normalized);
1463
1579
  }
1580
+ function canonicalLocale(value) {
1581
+ const candidate = value?.trim();
1582
+ if (!candidate || candidate === "*") {
1583
+ return null;
1584
+ }
1585
+ try {
1586
+ return Intl.getCanonicalLocales(candidate)[0] ?? null;
1587
+ } catch {
1588
+ return null;
1589
+ }
1590
+ }
1591
+ function acceptLanguageLocales(value) {
1592
+ if (!value) {
1593
+ return [];
1594
+ }
1595
+ return value.split(",").map((part, index) => {
1596
+ const [rawLocale, ...parameters] = part.trim().split(";");
1597
+ const locale = canonicalLocale(rawLocale);
1598
+ const qualityParameter = parameters.find((parameter) => parameter.trim().startsWith("q="));
1599
+ const parsedQuality = qualityParameter ? Number.parseFloat(qualityParameter.trim().slice(2)) : 1;
1600
+ const quality = Number.isFinite(parsedQuality) ? parsedQuality : 0;
1601
+ return { locale, quality, index };
1602
+ }).filter(
1603
+ (entry) => Boolean(entry.locale) && entry.quality > 0
1604
+ ).sort((left, right) => right.quality - left.quality || left.index - right.index).map((entry) => entry.locale);
1605
+ }
1606
+ function localeLookupCandidates(locale) {
1607
+ const candidates = [];
1608
+ let current = locale;
1609
+ while (current) {
1610
+ candidates.push(current);
1611
+ const separator = current.lastIndexOf("-");
1612
+ if (separator < 0) {
1613
+ break;
1614
+ }
1615
+ current = current.slice(0, separator);
1616
+ }
1617
+ return candidates;
1618
+ }
1619
+ function resolveThemePreviewLocale(params) {
1620
+ const supported = params.previewLocales ?? ["en"];
1621
+ const supportedByNormalized = new Map(
1622
+ supported.map((locale) => [locale.toLowerCase(), locale])
1623
+ );
1624
+ const explicitLocale = canonicalLocale(params.explicitLang);
1625
+ const requestedLocales = explicitLocale ? [explicitLocale] : acceptLanguageLocales(params.acceptLanguage);
1626
+ for (const requested of requestedLocales) {
1627
+ for (const candidate of localeLookupCandidates(requested)) {
1628
+ const supportedLocale = supportedByNormalized.get(candidate.toLowerCase());
1629
+ if (supportedLocale) {
1630
+ return supportedLocale;
1631
+ }
1632
+ }
1633
+ if (explicitLocale) {
1634
+ break;
1635
+ }
1636
+ }
1637
+ return supportedByNormalized.get("en") ?? "en";
1638
+ }
1639
+ async function readDevPreviewLocaleMessages(root, locale) {
1640
+ const filePath = path2.join(root, "src", "locales", `${locale}.content.json`);
1641
+ const messages = await readLocaleJson(filePath);
1642
+ if (!isRecord(messages)) {
1643
+ throw new Error(`Theme preview locale ${locale} must be a JSON object.`);
1644
+ }
1645
+ return messages;
1646
+ }
1464
1647
  function escapeHtml(value) {
1465
1648
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1466
1649
  }
@@ -1494,7 +1677,7 @@ async function loadThemeScopedRenderer(themeRoot) {
1494
1677
  renderToString: reactDomServerModule.renderToString
1495
1678
  };
1496
1679
  }
1497
- function renderDevStarterPageHtml(theme, page, renderer) {
1680
+ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserveLang = false) {
1498
1681
  const chrome = renderer.extractLayoutChrome(theme.module.defaultLayout);
1499
1682
  const metadata = {
1500
1683
  resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath),
@@ -1513,7 +1696,7 @@ function renderDevStarterPageHtml(theme, page, renderer) {
1513
1696
  const customBody = chrome.customBodyCode ?? "";
1514
1697
  return [
1515
1698
  "<!doctype html>",
1516
- '<html lang="en">',
1699
+ `<html lang="${escapeHtml(locale)}">`,
1517
1700
  "<head>",
1518
1701
  '<meta charset="utf-8" />',
1519
1702
  '<meta name="viewport" content="width=device-width, initial-scale=1" />',
@@ -1524,6 +1707,9 @@ function renderDevStarterPageHtml(theme, page, renderer) {
1524
1707
  `<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
1525
1708
  body,
1526
1709
  customBody,
1710
+ ...preserveLang ? [
1711
+ '<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>'
1712
+ ] : [],
1527
1713
  "</body>",
1528
1714
  "</html>"
1529
1715
  ].join("");