@numueg/theme-cli 0.2.0 → 0.4.1

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.
Files changed (2) hide show
  1. package/dist/index.js +455 -240
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -715,8 +715,147 @@ var init_touch_target = __esm({
715
715
  }
716
716
  });
717
717
 
718
+ // src/lint/rules/_render-path.ts
719
+ function computeBrowserOnlyRanges(source) {
720
+ const lines = source.split("\n");
721
+ const ranges = [];
722
+ for (let i = 0; i < lines.length; i++) {
723
+ if (!CALLBACK_OPENERS.test(lines[i])) continue;
724
+ let depth = 0;
725
+ let started = false;
726
+ let end = i;
727
+ for (let j = i; j < lines.length; j++) {
728
+ for (const ch of lines[j]) {
729
+ if (ch === "(" || ch === "{") {
730
+ depth++;
731
+ started = true;
732
+ } else if (ch === ")" || ch === "}") {
733
+ depth--;
734
+ }
735
+ }
736
+ if (started && depth <= 0) {
737
+ end = j;
738
+ break;
739
+ }
740
+ end = j;
741
+ }
742
+ ranges.push([i + 1, end + 1]);
743
+ }
744
+ return ranges;
745
+ }
746
+ function inRanges(line, ranges) {
747
+ return ranges.some(([start, end]) => line >= start && line <= end);
748
+ }
749
+ function isGuardedNearby(lines, idx) {
750
+ for (let i = Math.max(0, idx - 3); i <= idx; i++) {
751
+ if (/typeof\s+(window|document|navigator|localStorage|sessionStorage)\b/.test(lines[i])) {
752
+ return true;
753
+ }
754
+ }
755
+ return false;
756
+ }
757
+ function isCommentLine(line) {
758
+ const t = line.trim();
759
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
760
+ }
761
+ function isImportLine(line) {
762
+ return /^\s*import\b/.test(line);
763
+ }
764
+ var CALLBACK_OPENERS;
765
+ var init_render_path = __esm({
766
+ "src/lint/rules/_render-path.ts"() {
767
+ "use strict";
768
+ CALLBACK_OPENERS = /\b(useEffect|useLayoutEffect|useInsertionEffect|addEventListener|removeEventListener|setTimeout|setInterval|requestAnimationFrame|requestIdleCallback)\s*\(|\bon[A-Z]\w*\s*=\s*\{/;
769
+ }
770
+ });
771
+
772
+ // src/lint/rules/ssr-unsafe-globals.ts
773
+ var ssr_unsafe_globals_exports = {};
774
+ __export(ssr_unsafe_globals_exports, {
775
+ default: () => ssr_unsafe_globals_default
776
+ });
777
+ var BROWSER_GLOBAL, rule13, ssr_unsafe_globals_default;
778
+ var init_ssr_unsafe_globals = __esm({
779
+ "src/lint/rules/ssr-unsafe-globals.ts"() {
780
+ "use strict";
781
+ init_render_path();
782
+ BROWSER_GLOBAL = /\b(window|document|navigator)\s*\.|\b(localStorage|sessionStorage)\b/;
783
+ rule13 = {
784
+ id: "ssr-unsafe-globals",
785
+ description: "browser globals in the render path crash or de-SSR server rendering",
786
+ check(ctx) {
787
+ const issues = [];
788
+ for (const [file, source] of Object.entries(ctx.sources)) {
789
+ if (!BROWSER_GLOBAL.test(source)) continue;
790
+ const lines = source.split("\n");
791
+ const browserOnly = computeBrowserOnlyRanges(source);
792
+ for (let i = 0; i < lines.length; i++) {
793
+ const line = lines[i];
794
+ if (!BROWSER_GLOBAL.test(line)) continue;
795
+ if (isCommentLine(line) || isImportLine(line)) continue;
796
+ if (inRanges(i + 1, browserOnly)) continue;
797
+ if (isGuardedNearby(lines, i)) continue;
798
+ issues.push({
799
+ rule: rule13.id,
800
+ severity: "warning",
801
+ file,
802
+ line: i + 1,
803
+ message: "Runs during server render once this theme is SSR-capable \u2014 browser globals don't exist there.",
804
+ suggestion: 'Guard with `typeof window !== "undefined"`, or move the access into useEffect / an event handler.'
805
+ });
806
+ }
807
+ }
808
+ return issues;
809
+ }
810
+ };
811
+ ssr_unsafe_globals_default = rule13;
812
+ }
813
+ });
814
+
815
+ // src/lint/rules/ssr-nondeterministic-render.ts
816
+ var ssr_nondeterministic_render_exports = {};
817
+ __export(ssr_nondeterministic_render_exports, {
818
+ default: () => ssr_nondeterministic_render_default
819
+ });
820
+ var NONDETERMINISTIC, rule14, ssr_nondeterministic_render_default;
821
+ var init_ssr_nondeterministic_render = __esm({
822
+ "src/lint/rules/ssr-nondeterministic-render.ts"() {
823
+ "use strict";
824
+ init_render_path();
825
+ NONDETERMINISTIC = /\bDate\.now\s*\(|\bnew\s+Date\s*\(\s*\)|\bMath\.random\s*\(|\bcrypto\.randomUUID\s*\(/;
826
+ rule14 = {
827
+ id: "ssr-nondeterministic-render",
828
+ description: "time/random calls in the render path differ between server and client \u2192 hydration mismatch",
829
+ check(ctx) {
830
+ const issues = [];
831
+ for (const [file, source] of Object.entries(ctx.sources)) {
832
+ if (!NONDETERMINISTIC.test(source)) continue;
833
+ const lines = source.split("\n");
834
+ const browserOnly = computeBrowserOnlyRanges(source);
835
+ for (let i = 0; i < lines.length; i++) {
836
+ const line = lines[i];
837
+ if (!NONDETERMINISTIC.test(line)) continue;
838
+ if (isCommentLine(line) || isImportLine(line)) continue;
839
+ if (inRanges(i + 1, browserOnly)) continue;
840
+ issues.push({
841
+ rule: rule14.id,
842
+ severity: "warning",
843
+ file,
844
+ line: i + 1,
845
+ message: "Produces a different value on the server than on the client \u2192 hydration mismatch once this theme is SSR-capable.",
846
+ suggestion: "Read time/randomness inside useEffect and store it in state (initial render uses a deterministic placeholder)."
847
+ });
848
+ }
849
+ }
850
+ return issues;
851
+ }
852
+ };
853
+ ssr_nondeterministic_render_default = rule14;
854
+ }
855
+ });
856
+
718
857
  // src/index.ts
719
- var import_commander17 = require("commander");
858
+ var import_commander18 = require("commander");
720
859
 
721
860
  // src/commands/init.ts
722
861
  var import_commander = require("commander");
@@ -912,36 +1051,23 @@ export default function Hero({ settings }: SectionProps) {
912
1051
  );
913
1052
  fs.writeFileSync(
914
1053
  path.join(dir, "src/main.tsx"),
915
- `import { createRoot, type Root } from "react-dom/client";
916
- import type {
917
- ThemeSettingsV3,
918
- Page,
919
- Product,
920
- Collection,
921
- Store,
922
- } from "@numueg/theme-sdk";
923
- import {
924
- usePage,
925
- PageContext,
926
- ProductProvider,
927
- CollectionProvider,
928
- NuMuProvider,
929
- } from "@numueg/theme-sdk";
1054
+ `import type { ComponentType } from "react";
1055
+ import { defineThemeEntry } from "@numueg/theme-sdk";
1056
+ import type { ThemeSettingsV3 } from "@numueg/theme-sdk";
930
1057
  import Hero from "./sections/Hero";
931
1058
 
1059
+ const SECTION_REGISTRY: Record<string, ComponentType<any>> = {
1060
+ hero: Hero,
1061
+ };
1062
+
932
1063
  interface ThemeProps {
933
1064
  themeSettings: ThemeSettingsV3;
1065
+ currentTemplate: string;
934
1066
  }
935
1067
 
936
- const SECTION_REGISTRY: Record<string, React.ComponentType<any>> = {
937
- hero: Hero,
938
- };
939
-
940
- export default function Theme({ themeSettings }: ThemeProps) {
941
- const page = usePage();
942
- const pageType = page?.type || "home";
1068
+ export default function Theme({ themeSettings, currentTemplate }: ThemeProps) {
943
1069
  const template =
944
- themeSettings.templates?.[pageType] || themeSettings.templates?.home;
1070
+ themeSettings.templates?.[currentTemplate] || themeSettings.templates?.home;
945
1071
 
946
1072
  return (
947
1073
  <main>
@@ -963,106 +1089,15 @@ export default function Theme({ themeSettings }: ThemeProps) {
963
1089
  );
964
1090
  }
965
1091
 
966
- // \u2500\u2500 BYOT mount helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
967
- // Required by Next.js storefront's <ByotThemeBoundary>. Owns the React
968
- // render cycle for the bundle's subtree so hooks work.
969
-
970
- interface MountProps {
971
- themeSettings: ThemeSettingsV3;
972
- storeData?: Store;
973
- page?: Page & { data?: Record<string, unknown> };
974
- }
975
-
976
- // Fallback Store used only when host didn't pass storeData. NuMuProvider
977
- // hard-requires Store.currency for Intl.NumberFormat \u2014 synthesize one
978
- // instead of crashing.
979
- const FALLBACK_STORE: Store = {
980
- id: "",
981
- name: "",
982
- slug: "",
983
- currency: "USD",
984
- default_language: "en",
985
- use_nextjs_storefront: true,
986
- };
987
-
988
- function ThemeWithContext({ themeSettings, storeData, page }: MountProps) {
989
- // Storefront returns snake_case \`default_currency\` / \`default_language\`
990
- // but SDK Store expects \`currency\`. Map both shapes so NuMuProvider's
991
- // Intl.NumberFormat doesn't blow up on an empty currency code.
992
- const raw = (storeData ?? FALLBACK_STORE) as Record<string, unknown> & Store;
993
- const store: Store = {
994
- ...raw,
995
- currency:
996
- (raw.currency as string) ||
997
- (raw.default_currency as string) ||
998
- FALLBACK_STORE.currency,
999
- default_language:
1000
- (raw.default_language as string) || FALLBACK_STORE.default_language,
1001
- };
1002
-
1003
- const product = page?.data?.product as Product | undefined;
1004
- const collection = page?.data?.collection as Collection | undefined;
1005
- const pageValue: Page = page
1006
- ? {
1007
- type: page.type,
1008
- title: page.title || "",
1009
- handle: page.handle,
1010
- data: page.data,
1011
- }
1012
- : { type: "home", title: "" };
1013
-
1014
- let tree = <Theme themeSettings={themeSettings} />;
1015
- if (collection)
1016
- tree = <CollectionProvider collection={collection}>{tree}</CollectionProvider>;
1017
- if (product)
1018
- tree = <ProductProvider product={product}>{tree}</ProductProvider>;
1019
-
1020
- return (
1021
- <NuMuProvider store={store} themeSettings={themeSettings} locale={store.default_language}>
1022
- <PageContext.Provider value={pageValue}>{tree}</PageContext.Provider>
1023
- </NuMuProvider>
1024
- );
1025
- }
1026
-
1027
- // The host (\`ByotThemeBoundary\`) prefers the object-shape return:
1028
- // { unmount, update }
1029
- // When \`update\` is present, the customizer forwards prop-only changes
1030
- // (themeSettings / storeData / page) into the SAME React tree without
1031
- // re-importing the bundle. Without \`update\`, every settings tweak
1032
- // would trigger a full remount \u2014 fine, but visibly slower.
1033
- export interface MountHandle {
1034
- unmount: () => void;
1035
- update: (next: MountProps) => void;
1036
- }
1037
-
1038
- export function mount(el: HTMLElement, props: MountProps): MountHandle {
1039
- const root: Root = createRoot(el);
1040
- let current: MountProps = props;
1041
- root.render(<ThemeWithContext {...current} />);
1042
-
1043
- // Live preview: the storefront's PreviewBridge forwards customizer edits
1044
- // as \`numu:theme-update\` window events. Re-render with the new payload.
1045
- // Also covered by the \`update\` method below \u2014 both paths funnel into
1046
- // the same root.render() so they can't drift.
1047
- function handleUpdate(e: Event) {
1048
- const detail = (e as CustomEvent<ThemeSettingsV3>).detail;
1049
- if (!detail || typeof detail !== "object") return;
1050
- current = { ...current, themeSettings: detail };
1051
- root.render(<ThemeWithContext {...current} />);
1052
- }
1053
- window.addEventListener("numu:theme-update", handleUpdate);
1092
+ // SSR rule of thumb: everything rendered above must be deterministic and
1093
+ // browser-free (no window/document/Date.now in the render path \u2014 put those
1094
+ // in useEffect). \`numu-theme lint\` checks this for you.
1095
+ const entry = defineThemeEntry(({ themeSettings, currentTemplate }) => (
1096
+ <Theme themeSettings={themeSettings} currentTemplate={currentTemplate} />
1097
+ ));
1054
1098
 
1055
- return {
1056
- unmount: () => {
1057
- window.removeEventListener("numu:theme-update", handleUpdate);
1058
- root.unmount();
1059
- },
1060
- update: (next: MountProps) => {
1061
- current = next;
1062
- root.render(<ThemeWithContext {...current} />);
1063
- },
1064
- };
1065
- }
1099
+ export const mount = entry.mount;
1100
+ export const createApp = entry.createApp;
1066
1101
  `
1067
1102
  );
1068
1103
  fs.writeFileSync(
@@ -1102,7 +1137,10 @@ const placeholder = {
1102
1137
  };
1103
1138
 
1104
1139
  const root = document.getElementById("root");
1105
- if (root) createRoot(root).render(<Theme themeSettings={placeholder as any} />);
1140
+ if (root)
1141
+ createRoot(root).render(
1142
+ <Theme themeSettings={placeholder as any} currentTemplate="home" />,
1143
+ );
1106
1144
  `
1107
1145
  );
1108
1146
  fs.writeFileSync(
@@ -1159,12 +1197,14 @@ export default defineConfig({
1159
1197
  scripts: {
1160
1198
  dev: "numu-theme dev",
1161
1199
  build: "numu-theme build",
1162
- check: "numu-theme check"
1200
+ check: "numu-theme check",
1201
+ // Render-verify every template against fixtures (Phase 2).
1202
+ verify: "numu-theme verify"
1163
1203
  },
1164
- dependencies: { "@numueg/theme-sdk": "^0.2.0" },
1204
+ dependencies: { "@numueg/theme-sdk": "^0.4.0" },
1165
1205
  devDependencies: {
1166
- "@numueg/theme-cli": "^0.2.0",
1167
- "@numueg/theme-plugin": "^0.2.0",
1206
+ "@numueg/theme-cli": "^0.4.0",
1207
+ "@numueg/theme-plugin": "^0.4.0",
1168
1208
  "@vitejs/plugin-react": "^4.3.0",
1169
1209
  vite: "^6.0.0",
1170
1210
  typescript: "^5.8.0",
@@ -1274,12 +1314,12 @@ function assertHttpsOrLocalhost(urlStr) {
1274
1314
  }
1275
1315
  throw new Error(`Unsupported protocol: ${url.protocol}`);
1276
1316
  }
1277
- async function apiRequest(method, path15, body) {
1317
+ async function apiRequest(method, path16, body) {
1278
1318
  const config = loadConfig();
1279
- const url = assertHttpsOrLocalhost(`${config.api_url}${path15}`);
1319
+ const url = assertHttpsOrLocalhost(`${config.api_url}${path16}`);
1280
1320
  const isHttps = url.protocol === "https:";
1281
1321
  const transport = isHttps ? https : http;
1282
- return new Promise((resolve9, reject) => {
1322
+ return new Promise((resolve10, reject) => {
1283
1323
  const headers = {};
1284
1324
  if (config.token) headers["Authorization"] = `Bearer ${config.token}`;
1285
1325
  let postData;
@@ -1301,7 +1341,7 @@ async function apiRequest(method, path15, body) {
1301
1341
  res.on("data", (chunk) => data += chunk);
1302
1342
  res.on("end", () => {
1303
1343
  const { unwrapped, raw } = parseBody(data);
1304
- resolve9({
1344
+ resolve10({
1305
1345
  status: res.statusCode ?? 0,
1306
1346
  data: unwrapped,
1307
1347
  raw
@@ -1334,7 +1374,7 @@ Content-Type: application/zip\r
1334
1374
  const fullBody = Buffer.concat([head, fileBuffer, tail]);
1335
1375
  const isHttps = url.protocol === "https:";
1336
1376
  const transport = isHttps ? https : http;
1337
- return new Promise((resolve9, reject) => {
1377
+ return new Promise((resolve10, reject) => {
1338
1378
  const headers = {
1339
1379
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
1340
1380
  "Content-Length": String(fullBody.byteLength)
@@ -1357,7 +1397,7 @@ Content-Type: application/zip\r
1357
1397
  res.on("data", (chunk) => data += chunk);
1358
1398
  res.on("end", () => {
1359
1399
  const { unwrapped, raw } = parseBody(data);
1360
- resolve9({
1400
+ resolve10({
1361
1401
  status: res.statusCode ?? 0,
1362
1402
  data: unwrapped,
1363
1403
  raw
@@ -1532,6 +1572,48 @@ function validateTheme(themeDir) {
1532
1572
  if (!themeJson.presets || Object.keys(themeJson.presets).length === 0) {
1533
1573
  warnings.push("theme.json has no presets \u2014 merchants will start with an empty page");
1534
1574
  }
1575
+ const REQUIRED_TEMPLATES = [
1576
+ "home",
1577
+ "product",
1578
+ "collection",
1579
+ "cart",
1580
+ "page",
1581
+ "search",
1582
+ "404"
1583
+ ];
1584
+ if (themeJson.presets && typeof themeJson.presets === "object") {
1585
+ const referenced = /* @__PURE__ */ new Set();
1586
+ for (const bucket of [
1587
+ themeJson.presets.templates,
1588
+ themeJson.presets.section_groups
1589
+ ]) {
1590
+ if (!bucket || typeof bucket !== "object") continue;
1591
+ for (const entry of Object.values(bucket)) {
1592
+ const sections = entry?.sections;
1593
+ const instances = Array.isArray(sections) ? sections : sections && typeof sections === "object" ? Object.values(sections) : [];
1594
+ for (const inst of instances) {
1595
+ if (inst && typeof inst.type === "string") {
1596
+ referenced.add(inst.type.toLowerCase());
1597
+ }
1598
+ }
1599
+ }
1600
+ }
1601
+ for (const type of referenced) {
1602
+ if (!schemaNames.has(type)) {
1603
+ errors.push(
1604
+ `theme.json preset references section type "${type}" but there is no schemas/sections/${type}.json \u2014 the storefront drops unknown sections at render.`
1605
+ );
1606
+ }
1607
+ }
1608
+ const templates = themeJson.presets.templates && typeof themeJson.presets.templates === "object" ? themeJson.presets.templates : {};
1609
+ for (const tpl of REQUIRED_TEMPLATES) {
1610
+ if (!(tpl in templates)) {
1611
+ warnings.push(
1612
+ `theme.json has no preset for the "${tpl}" template \u2014 the storefront will use its built-in fallback.`
1613
+ );
1614
+ }
1615
+ }
1616
+ }
1535
1617
  return { valid: errors.length === 0, errors, warnings };
1536
1618
  }
1537
1619
 
@@ -1641,7 +1723,9 @@ async function runAllRules(themeDir, options) {
1641
1723
  () => Promise.resolve().then(() => (init_use_app_no_availability_check(), use_app_no_availability_check_exports)),
1642
1724
  () => Promise.resolve().then(() => (init_manifest_required_fields(), manifest_required_fields_exports)),
1643
1725
  () => Promise.resolve().then(() => (init_contrast_hint(), contrast_hint_exports)),
1644
- () => Promise.resolve().then(() => (init_touch_target(), touch_target_exports))
1726
+ () => Promise.resolve().then(() => (init_touch_target(), touch_target_exports)),
1727
+ () => Promise.resolve().then(() => (init_ssr_unsafe_globals(), ssr_unsafe_globals_exports)),
1728
+ () => Promise.resolve().then(() => (init_ssr_nondeterministic_render(), ssr_nondeterministic_render_exports))
1645
1729
  ];
1646
1730
  const issues = [];
1647
1731
  for (const load of ruleLoaders) {
@@ -1656,14 +1740,14 @@ async function runAllRules(themeDir, options) {
1656
1740
  });
1657
1741
  continue;
1658
1742
  }
1659
- const rule13 = mod.default;
1660
- if (options.enabledRules && !options.enabledRules.has(rule13.id)) continue;
1743
+ const rule15 = mod.default;
1744
+ if (options.enabledRules && !options.enabledRules.has(rule15.id)) continue;
1661
1745
  try {
1662
- const ruleIssues = await rule13.check(ctx);
1746
+ const ruleIssues = await rule15.check(ctx);
1663
1747
  for (const issue of ruleIssues) issues.push(issue);
1664
1748
  } catch (e) {
1665
1749
  issues.push({
1666
- rule: rule13.id,
1750
+ rule: rule15.id,
1667
1751
  severity: "warning",
1668
1752
  message: `Rule crashed: ${e.message}`
1669
1753
  });
@@ -1887,14 +1971,102 @@ Bundle size: ${sizeMB} MB`);
1887
1971
  "\u26A0 Bundle exceeds 5MB limit \u2014 optimize before submitting to marketplace"
1888
1972
  );
1889
1973
  }
1974
+ const serverBundle = path6.join(distDir, "theme.server.js");
1975
+ if (fs7.existsSync(serverBundle)) {
1976
+ const kb = (fs7.statSync(serverBundle).size / 1024).toFixed(1);
1977
+ console.log(`SSR bundle: theme.server.js (${kb} KB) \u2014 hosts will server-render this theme`);
1978
+ } else {
1979
+ console.log(
1980
+ "SSR bundle: not emitted \u2014 theme ships client-only. Export `createApp` via defineThemeEntry (SDK \u2265 0.3) and build with federate:true to enable server rendering."
1981
+ );
1982
+ }
1890
1983
  }
1891
1984
  console.log("\n\u2713 Build complete");
1892
1985
  }
1893
1986
  );
1894
1987
 
1895
- // src/commands/push.ts
1988
+ // src/commands/verify.ts
1896
1989
  var import_commander6 = require("commander");
1990
+ var import_child_process5 = require("child_process");
1991
+ var import_fs = require("fs");
1992
+ var import_module = require("module");
1897
1993
  var path7 = __toESM(require("path"));
1994
+ var import_url = require("url");
1995
+ var dynamicImport = new Function("u", "return import(u)");
1996
+ var verifyCommand = new import_commander6.Command("verify").description(
1997
+ "Server-render every template against fixtures to catch runtime crashes"
1998
+ ).option("-d, --dir <directory>", "Theme directory", ".").option("--locale <code>", "Render under a locale (e.g. `ar` for RTL)").option("--no-build", "Verify the existing dist/ without rebuilding").action(
1999
+ async (options) => {
2000
+ const dir = path7.resolve(process.cwd(), options.dir);
2001
+ if (options.build !== false) {
2002
+ console.log("Building theme (SSR bundle)\u2026");
2003
+ try {
2004
+ (0, import_child_process5.execSync)("npx vite build", { cwd: dir, stdio: "inherit" });
2005
+ } catch {
2006
+ console.error("Build failed");
2007
+ process.exit(1);
2008
+ }
2009
+ }
2010
+ const serverBundle = path7.join(dir, "dist", "theme.server.js");
2011
+ if (!(0, import_fs.existsSync)(serverBundle)) {
2012
+ console.error(
2013
+ "No dist/theme.server.js \u2014 this theme is client-only and can't be render-verified. Export `createApp` via defineThemeEntry() and build with federate:true (SDK >= 0.3)."
2014
+ );
2015
+ process.exit(1);
2016
+ }
2017
+ const requireFromTheme = (0, import_module.createRequire)(path7.join(dir, "package.json"));
2018
+ let harness;
2019
+ try {
2020
+ const verifyEntry = requireFromTheme.resolve("@numueg/theme-sdk/verify");
2021
+ harness = await dynamicImport(
2022
+ (0, import_url.pathToFileURL)(verifyEntry).href
2023
+ );
2024
+ } catch {
2025
+ console.error(
2026
+ "This theme's @numueg/theme-sdk has no `/verify` entry \u2014 upgrade @numueg/theme-sdk to a version that ships the render harness."
2027
+ );
2028
+ process.exit(1);
2029
+ }
2030
+ let serverModule;
2031
+ try {
2032
+ serverModule = await dynamicImport((0, import_url.pathToFileURL)(serverBundle).href);
2033
+ } catch (e) {
2034
+ console.error(
2035
+ "Failed to load dist/theme.server.js:",
2036
+ e instanceof Error ? e.message : String(e)
2037
+ );
2038
+ process.exit(1);
2039
+ }
2040
+ const def = serverModule.default;
2041
+ const mod = {
2042
+ createApp: serverModule.createApp ?? def?.createApp
2043
+ };
2044
+ const result = await harness.verifyThemeRender(mod, {
2045
+ locale: options.locale
2046
+ });
2047
+ console.log("\nRender verification:");
2048
+ for (const r of result.results) {
2049
+ const mark = r.ok ? "\u2713" : "\u2718";
2050
+ console.log(
2051
+ ` ${mark} ${r.template}${r.ok ? ` (${r.htmlLength} chars)` : ""}`
2052
+ );
2053
+ if (!r.ok && r.error) {
2054
+ console.log(` ${String(r.error).split("\n")[0]}`);
2055
+ }
2056
+ }
2057
+ if (!result.ok) {
2058
+ console.error(
2059
+ "\n\u2718 Render verification failed \u2014 fix the crashing templates above."
2060
+ );
2061
+ process.exit(1);
2062
+ }
2063
+ console.log("\n\u2713 All templates render against fixture data.");
2064
+ }
2065
+ );
2066
+
2067
+ // src/commands/push.ts
2068
+ var import_commander7 = require("commander");
2069
+ var path8 = __toESM(require("path"));
1898
2070
  var os2 = __toESM(require("os"));
1899
2071
 
1900
2072
  // src/utils/zipper.ts
@@ -1902,10 +2074,10 @@ var fs8 = __toESM(require("fs"));
1902
2074
  var import_archiver = __toESM(require("archiver"));
1903
2075
  async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
1904
2076
  const opts = Array.isArray(optsOrLegacyExcludes) ? { excludePatterns: optsOrLegacyExcludes } : optsOrLegacyExcludes;
1905
- return new Promise((resolve9, reject) => {
2077
+ return new Promise((resolve10, reject) => {
1906
2078
  const output = fs8.createWriteStream(outputPath);
1907
2079
  const archive = (0, import_archiver.default)("zip", { zlib: { level: 9 } });
1908
- output.on("close", () => resolve9(outputPath));
2080
+ output.on("close", () => resolve10(outputPath));
1909
2081
  archive.on("error", reject);
1910
2082
  archive.pipe(output);
1911
2083
  const defaultExcludes = [
@@ -1927,7 +2099,7 @@ async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
1927
2099
  }
1928
2100
 
1929
2101
  // src/commands/push.ts
1930
- var pushCommand = new import_commander6.Command("push").description("Upload built theme to your developer account for testing").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
2102
+ var pushCommand = new import_commander7.Command("push").description("Upload built theme to your developer account for testing").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
1931
2103
  const config = loadConfig();
1932
2104
  if (!config.token) {
1933
2105
  console.error("Not logged in. Run: numu-theme login");
@@ -1940,7 +2112,7 @@ var pushCommand = new import_commander6.Command("push").description("Upload buil
1940
2112
  process.exit(1);
1941
2113
  }
1942
2114
  console.log("Packaging theme...");
1943
- const zipPath = path7.join(os2.tmpdir(), `numu-theme-${Date.now()}.zip`);
2115
+ const zipPath = path8.join(os2.tmpdir(), `numu-theme-${Date.now()}.zip`);
1944
2116
  await zipDirectory(options.dir, zipPath);
1945
2117
  console.log("Uploading...");
1946
2118
  const res = await uploadFile("/themes/upload", zipPath);
@@ -1961,11 +2133,11 @@ Push failed (${res.status}): ${JSON.stringify(res.data)}`);
1961
2133
  });
1962
2134
 
1963
2135
  // src/commands/submit.ts
1964
- var import_commander7 = require("commander");
1965
- var path8 = __toESM(require("path"));
2136
+ var import_commander8 = require("commander");
2137
+ var path9 = __toESM(require("path"));
1966
2138
  var os3 = __toESM(require("os"));
1967
2139
  var fs9 = __toESM(require("fs"));
1968
- var submitCommand = new import_commander7.Command("submit").description("Submit theme to the NUMU Marketplace for review").option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
2140
+ var submitCommand = new import_commander8.Command("submit").description("Submit theme to the NUMU Marketplace for review").option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
1969
2141
  "-t, --theme-id <theme_id>",
1970
2142
  "Marketplace theme listing UUID (create via dashboard first)"
1971
2143
  ).option("-n, --notes <notes>", "Release notes for this version").action(
@@ -1988,7 +2160,7 @@ var submitCommand = new import_commander7.Command("submit").description("Submit
1988
2160
  console.log("\nWarnings:");
1989
2161
  result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1990
2162
  }
1991
- const themeJsonPath = path8.join(options.dir, "theme.json");
2163
+ const themeJsonPath = path9.join(options.dir, "theme.json");
1992
2164
  let version;
1993
2165
  try {
1994
2166
  const tj = JSON.parse(fs9.readFileSync(themeJsonPath, "utf-8"));
@@ -2002,7 +2174,7 @@ var submitCommand = new import_commander7.Command("submit").description("Submit
2002
2174
  process.exit(1);
2003
2175
  }
2004
2176
  console.log("\nPackaging theme source...");
2005
- const zipPath = path8.join(
2177
+ const zipPath = path9.join(
2006
2178
  os3.tmpdir(),
2007
2179
  `numu-theme-submit-${Date.now()}.zip`
2008
2180
  );
@@ -2055,12 +2227,12 @@ Submission failed (${submitRes.status}): ${JSON.stringify(submitRes.data)}`
2055
2227
  );
2056
2228
 
2057
2229
  // src/commands/install.ts
2058
- var import_commander8 = require("commander");
2059
- var import_child_process5 = require("child_process");
2060
- var path9 = __toESM(require("path"));
2230
+ var import_commander9 = require("commander");
2231
+ var import_child_process6 = require("child_process");
2232
+ var path10 = __toESM(require("path"));
2061
2233
  var os4 = __toESM(require("os"));
2062
2234
  var fs10 = __toESM(require("fs"));
2063
- var installCommand = new import_commander8.Command("install").description(
2235
+ var installCommand = new import_commander9.Command("install").description(
2064
2236
  "Build + upload + install a theme directly on a store you own (bypasses marketplace review)"
2065
2237
  ).option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
2066
2238
  "-t, --theme-id <theme_id>",
@@ -2098,7 +2270,7 @@ var installCommand = new import_commander8.Command("install").description(
2098
2270
  process.exit(1);
2099
2271
  }
2100
2272
  result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
2101
- const themeJsonPath = path9.join(options.dir, "theme.json");
2273
+ const themeJsonPath = path10.join(options.dir, "theme.json");
2102
2274
  let baseVersion;
2103
2275
  try {
2104
2276
  const tj = JSON.parse(fs10.readFileSync(themeJsonPath, "utf-8"));
@@ -2117,7 +2289,7 @@ var installCommand = new import_commander8.Command("install").description(
2117
2289
  console.log(` install tag: ${version}`);
2118
2290
  console.log("Building locally (so worker can skip npm install)...");
2119
2291
  try {
2120
- (0, import_child_process5.execSync)("npm run build", {
2292
+ (0, import_child_process6.execSync)("npm run build", {
2121
2293
  cwd: options.dir,
2122
2294
  stdio: "inherit",
2123
2295
  env: { ...process.env, NODE_ENV: "production" }
@@ -2128,7 +2300,7 @@ var installCommand = new import_commander8.Command("install").description(
2128
2300
  );
2129
2301
  process.exit(1);
2130
2302
  }
2131
- const distEntry = path9.join(options.dir, "dist", "theme.js");
2303
+ const distEntry = path10.join(options.dir, "dist", "theme.js");
2132
2304
  if (!fs10.existsSync(distEntry)) {
2133
2305
  console.error(
2134
2306
  "\nBuild completed but dist/theme.js is missing. Check vite.config.ts entry / output filename."
@@ -2136,7 +2308,7 @@ var installCommand = new import_commander8.Command("install").description(
2136
2308
  process.exit(1);
2137
2309
  }
2138
2310
  console.log("Packaging source + dist...");
2139
- const zipPath = path9.join(
2311
+ const zipPath = path10.join(
2140
2312
  os4.tmpdir(),
2141
2313
  `numu-theme-install-${Date.now()}.zip`
2142
2314
  );
@@ -2250,9 +2422,9 @@ Timed out waiting for build (${options.pollTimeout}s). Run \`numu-theme status -
2250
2422
  );
2251
2423
 
2252
2424
  // src/commands/login.ts
2253
- var import_commander9 = require("commander");
2425
+ var import_commander10 = require("commander");
2254
2426
  var import_inquirer = __toESM(require("inquirer"));
2255
- var loginCommand = new import_commander9.Command("login").description("Authenticate with the NUMU API").option("--token <token>", "API token (skip interactive login)").option("--api-url <url>", "Custom API URL").action(async (options) => {
2427
+ var loginCommand = new import_commander10.Command("login").description("Authenticate with the NUMU API").option("--token <token>", "API token (skip interactive login)").option("--api-url <url>", "Custom API URL").action(async (options) => {
2256
2428
  if (options.apiUrl) {
2257
2429
  saveConfig({ api_url: options.apiUrl });
2258
2430
  console.log(`API URL set to: ${options.apiUrl}`);
@@ -2299,8 +2471,8 @@ Login failed: ${err.message}`);
2299
2471
  });
2300
2472
 
2301
2473
  // src/commands/status.ts
2302
- var import_commander10 = require("commander");
2303
- var statusCommand = new import_commander10.Command("status").description("Poll the status of a theme build or marketplace version").option("--build <build_id>", "Build ID returned by `numu-theme push`").option(
2474
+ var import_commander11 = require("commander");
2475
+ var statusCommand = new import_commander11.Command("status").description("Poll the status of a theme build or marketplace version").option("--build <build_id>", "Build ID returned by `numu-theme push`").option(
2304
2476
  "--version <version_id>",
2305
2477
  "Marketplace version ID returned by `numu-theme submit`"
2306
2478
  ).option("-w, --watch", "Poll until the build reaches a terminal state").option(
@@ -2427,13 +2599,13 @@ function formatBytes(n) {
2427
2599
  }
2428
2600
 
2429
2601
  // src/commands/doctor.ts
2430
- var import_commander11 = require("commander");
2602
+ var import_commander12 = require("commander");
2431
2603
  var fs11 = __toESM(require("fs"));
2432
- var path10 = __toESM(require("path"));
2433
- var doctorCommand = new import_commander11.Command("doctor").description("Diagnose common dev-loop problems (run inside a theme directory)").option("-d, --dir <directory>", "Theme directory", ".").option("-p, --port <port>", "Expected dev server port", "5173").action(async (options) => {
2604
+ var path11 = __toESM(require("path"));
2605
+ var doctorCommand = new import_commander12.Command("doctor").description("Diagnose common dev-loop problems (run inside a theme directory)").option("-d, --dir <directory>", "Theme directory", ".").option("-p, --port <port>", "Expected dev server port", "5173").action(async (options) => {
2434
2606
  let issues = 0;
2435
2607
  let warnings = 0;
2436
- const themeDir = path10.resolve(options.dir);
2608
+ const themeDir = path11.resolve(options.dir);
2437
2609
  function ok(line) {
2438
2610
  console.log(` \x1B[32m\u2713\x1B[0m ${line}`);
2439
2611
  }
@@ -2446,8 +2618,8 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2446
2618
  console.log(` \x1B[31m\u2717\x1B[0m ${line}`);
2447
2619
  }
2448
2620
  console.log("\nProject");
2449
- const themeJsonPath = path10.join(themeDir, "theme.json");
2450
- const settingsPath = path10.join(themeDir, "settings_schema.json");
2621
+ const themeJsonPath = path11.join(themeDir, "theme.json");
2622
+ const settingsPath = path11.join(themeDir, "settings_schema.json");
2451
2623
  if (!fs11.existsSync(themeJsonPath)) {
2452
2624
  fail(`No theme.json found in ${themeDir}`);
2453
2625
  console.log(
@@ -2473,21 +2645,53 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2473
2645
  "src/index.ts"
2474
2646
  ];
2475
2647
  const entry = entryCandidates.find(
2476
- (p) => fs11.existsSync(path10.join(themeDir, p))
2648
+ (p) => fs11.existsSync(path11.join(themeDir, p))
2477
2649
  );
2478
2650
  if (!entry) {
2479
2651
  fail(`No entry point found (expected one of: ${entryCandidates.join(", ")})`);
2480
2652
  } else {
2481
- const src = fs11.readFileSync(path10.join(themeDir, entry), "utf-8");
2482
- const exportsMount = /\bexport\s+(?:async\s+)?function\s+mount\b/.test(src) || /\bexport\s+(?:const|let|var)\s+mount\b/.test(src) || /\bexport\s*\{[^}]*\bmount\b[^}]*\}/.test(src);
2653
+ const src = fs11.readFileSync(path11.join(themeDir, entry), "utf-8");
2654
+ const exportsMount = /\bexport\s+(?:async\s+)?function\s+mount\b/.test(src) || /\bexport\s+(?:const|let|var)\s+mount\b/.test(src) || /\bexport\s+(?:const|let|var)\s*\{[^}]*\bmount\b[^}]*\}/.test(src) || /\bexport\s*\{[^}]*\bmount\b[^}]*\}/.test(src);
2483
2655
  if (exportsMount) ok(`${entry} exports mount(el, props)`);
2484
2656
  else
2485
2657
  fail(
2486
2658
  `${entry} does NOT export mount(el, props) \u2014 BYOT host can't render this theme. See THEME_AUTHORING.md for the contract, or scaffold a fresh theme with \`numu-theme init\`.`
2487
2659
  );
2660
+ const exportsCreateApp = /\bexport\s+(?:async\s+)?function\s+createApp\b/.test(src) || /\bexport\s+(?:const|let|var)\s+createApp\b/.test(src) || /\bexport\s+(?:const|let|var)\s*\{[^}]*\bcreateApp\b[^}]*\}/.test(src) || /\bexport\s*\{[^}]*\bcreateApp\b[^}]*\}/.test(src);
2661
+ if (exportsCreateApp) ok(`${entry} exports createApp(ctx) \u2014 SSR-capable`);
2662
+ else
2663
+ warn(
2664
+ `${entry} does not export createApp \u2014 theme renders client-only (no server-rendered first paint). Use defineThemeEntry from @numueg/theme-sdk \u2265 0.3 to export mount + createApp from one component.`
2665
+ );
2666
+ }
2667
+ console.log("\nSSR toolchain");
2668
+ try {
2669
+ const pkg = JSON.parse(
2670
+ fs11.readFileSync(path11.join(themeDir, "package.json"), "utf-8")
2671
+ );
2672
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
2673
+ const checkMinor = (name, minMinor) => {
2674
+ const range = allDeps[name];
2675
+ if (!range) {
2676
+ warn(`${name} not in package.json \u2014 npm install it`);
2677
+ return;
2678
+ }
2679
+ const m = /(\d+)\.(\d+)\./.exec(range);
2680
+ if (m && Number(m[1]) === 0 && Number(m[2]) < minMinor) {
2681
+ warn(
2682
+ `${name}@${range} predates the SSR contract \u2014 bump to ^0.${minMinor}.0 for server rendering`
2683
+ );
2684
+ } else {
2685
+ ok(`${name}@${range}`);
2686
+ }
2687
+ };
2688
+ checkMinor("@numueg/theme-sdk", 3);
2689
+ checkMinor("@numueg/theme-plugin", 3);
2690
+ } catch {
2691
+ warn("Could not read package.json to verify SDK/plugin versions");
2488
2692
  }
2489
2693
  console.log("\nLocales");
2490
- const localesDir = path10.join(themeDir, "locales");
2694
+ const localesDir = path11.join(themeDir, "locales");
2491
2695
  if (!fs11.existsSync(localesDir)) {
2492
2696
  warn(
2493
2697
  "locales/ directory not found \u2014 `useTranslation` will fall through to keys. Add locales/en.default.json (required) and locales/ar.json (recommended for MENA stores)."
@@ -2504,14 +2708,14 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2504
2708
  }
2505
2709
  for (const f of localeFiles) {
2506
2710
  try {
2507
- JSON.parse(fs11.readFileSync(path10.join(localesDir, f), "utf-8"));
2711
+ JSON.parse(fs11.readFileSync(path11.join(localesDir, f), "utf-8"));
2508
2712
  } catch (err) {
2509
2713
  fail(`locales/${f} is not valid JSON: ${err.message}`);
2510
2714
  }
2511
2715
  }
2512
2716
  }
2513
2717
  console.log("\nAssets");
2514
- const assetsDir = path10.join(themeDir, "assets");
2718
+ const assetsDir = path11.join(themeDir, "assets");
2515
2719
  if (!fs11.existsSync(assetsDir)) {
2516
2720
  warn(
2517
2721
  "assets/ directory not found \u2014 assetUrl() helpers will fall back to bare paths under /assets/. Create assets/ and put images, fonts, and JSON fixtures there for the plugin to copy with content-hashed filenames."
@@ -2532,13 +2736,13 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2532
2736
  fs11.readFileSync(themeJsonPath, "utf-8")
2533
2737
  );
2534
2738
  const presets = themeJson.presets ?? {};
2535
- const sectionsDir = path10.join(themeDir, "src", "sections");
2536
- const schemaDir = path10.join(themeDir, "schemas", "sections");
2739
+ const sectionsDir = path11.join(themeDir, "src", "sections");
2740
+ const schemaDir = path11.join(themeDir, "schemas", "sections");
2537
2741
  const componentNames = fs11.existsSync(sectionsDir) ? new Set(
2538
- fs11.readdirSync(sectionsDir).filter((f) => /\.(tsx|ts|jsx|js)$/.test(f)).map((f) => path10.basename(f, path10.extname(f)).toLowerCase())
2742
+ fs11.readdirSync(sectionsDir).filter((f) => /\.(tsx|ts|jsx|js)$/.test(f)).map((f) => path11.basename(f, path11.extname(f)).toLowerCase())
2539
2743
  ) : /* @__PURE__ */ new Set();
2540
2744
  const schemaNames = fs11.existsSync(schemaDir) ? new Set(
2541
- fs11.readdirSync(schemaDir).filter((f) => f.endsWith(".json")).map((f) => path10.basename(f, ".json").toLowerCase())
2745
+ fs11.readdirSync(schemaDir).filter((f) => f.endsWith(".json")).map((f) => path11.basename(f, ".json").toLowerCase())
2542
2746
  ) : /* @__PURE__ */ new Set();
2543
2747
  let missingRefs = 0;
2544
2748
  for (const [presetName, presetVal] of Object.entries(presets)) {
@@ -2569,8 +2773,8 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2569
2773
  warn(`Could not parse theme.json presets: ${err.message}`);
2570
2774
  }
2571
2775
  console.log("\nBuild");
2572
- const distJs = path10.join(themeDir, "dist", "theme.js");
2573
- const distCss = path10.join(themeDir, "dist", "theme.css");
2776
+ const distJs = path11.join(themeDir, "dist", "theme.js");
2777
+ const distCss = path11.join(themeDir, "dist", "theme.css");
2574
2778
  if (!fs11.existsSync(distJs)) {
2575
2779
  warn("dist/theme.js not found \u2014 run `numu-theme build` first");
2576
2780
  } else {
@@ -2586,6 +2790,16 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2586
2790
  } else {
2587
2791
  ok("dist/theme.css present");
2588
2792
  }
2793
+ const distServer = path11.join(themeDir, "dist", "theme.server.js");
2794
+ if (fs11.existsSync(distServer)) {
2795
+ ok(
2796
+ `dist/theme.server.js (${(fs11.statSync(distServer).size / 1024).toFixed(1)} KB) \u2014 SSR artifact present`
2797
+ );
2798
+ } else if (fs11.existsSync(distJs)) {
2799
+ warn(
2800
+ "dist/theme.server.js not found \u2014 last build produced a client-only theme (plugin < 0.3, federate:false, or SSR pass failed)"
2801
+ );
2802
+ }
2589
2803
  console.log("\nAuth");
2590
2804
  const config = loadConfig();
2591
2805
  if (!config.token) {
@@ -2653,9 +2867,9 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2653
2867
  });
2654
2868
 
2655
2869
  // src/commands/add-section.ts
2656
- var import_commander12 = require("commander");
2870
+ var import_commander13 = require("commander");
2657
2871
  var fs12 = __toESM(require("fs"));
2658
- var path11 = __toESM(require("path"));
2872
+ var path12 = __toESM(require("path"));
2659
2873
 
2660
2874
  // src/section-library/entries/hero-with-cta.ts
2661
2875
  var heroWithCta = {
@@ -3700,7 +3914,7 @@ function findEntry(slug) {
3700
3914
  }
3701
3915
 
3702
3916
  // src/commands/add-section.ts
3703
- var addSectionCommand = new import_commander12.Command("add-section").description("Scaffold a new section (optionally from the built-in library)").argument("[name]", "Section slug (kebab-case, e.g. 'hero-banner')").option(
3917
+ var addSectionCommand = new import_commander13.Command("add-section").description("Scaffold a new section (optionally from the built-in library)").argument("[name]", "Section slug (kebab-case, e.g. 'hero-banner')").option(
3704
3918
  "--from-library <slug>",
3705
3919
  "Copy from the built-in section library (run with --list to see options)"
3706
3920
  ).option("--list", "List the built-in section library and exit").option("-d, --dir <directory>", "Theme directory", ".").action(
@@ -3721,8 +3935,8 @@ var addSectionCommand = new import_commander12.Command("add-section").descriptio
3721
3935
  );
3722
3936
  process.exit(1);
3723
3937
  }
3724
- const themeDir = path11.resolve(process.cwd(), options.dir);
3725
- if (!fs12.existsSync(path11.join(themeDir, "theme.json"))) {
3938
+ const themeDir = path12.resolve(process.cwd(), options.dir);
3939
+ if (!fs12.existsSync(path12.join(themeDir, "theme.json"))) {
3726
3940
  console.error(
3727
3941
  "No theme.json in this directory. Run from a theme project root."
3728
3942
  );
@@ -3758,14 +3972,14 @@ var addSectionCommand = new import_commander12.Command("add-section").descriptio
3758
3972
  ]
3759
3973
  };
3760
3974
  }
3761
- const componentPath = path11.join(themeDir, "src/sections", `${pascal}.tsx`);
3975
+ const componentPath = path12.join(themeDir, "src/sections", `${pascal}.tsx`);
3762
3976
  ensureDirOf(componentPath);
3763
3977
  if (fs12.existsSync(componentPath)) {
3764
3978
  console.error(`Already exists: ${componentPath}`);
3765
3979
  process.exit(1);
3766
3980
  }
3767
3981
  fs12.writeFileSync(componentPath, componentSource);
3768
- const schemaPath = path11.join(themeDir, "schemas/sections", `${slug}.json`);
3982
+ const schemaPath = path12.join(themeDir, "schemas/sections", `${slug}.json`);
3769
3983
  ensureDirOf(schemaPath);
3770
3984
  fs12.writeFileSync(schemaPath, JSON.stringify(schemaJson, null, 2));
3771
3985
  tryWireMain(themeDir, slug, pascal);
@@ -3789,7 +4003,7 @@ function humanize(slug) {
3789
4003
  return slug.split("-").filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join(" ");
3790
4004
  }
3791
4005
  function ensureDirOf(p) {
3792
- fs12.mkdirSync(path11.dirname(p), { recursive: true });
4006
+ fs12.mkdirSync(path12.dirname(p), { recursive: true });
3793
4007
  }
3794
4008
  function emptySectionStub(pascal) {
3795
4009
  return `import type { SectionProps } from "@numueg/theme-sdk";
@@ -3807,7 +4021,7 @@ export default function ${pascal}({ settings }: SectionProps) {
3807
4021
  `;
3808
4022
  }
3809
4023
  function tryWireMain(themeDir, slug, pascal) {
3810
- const mainPath = path11.join(themeDir, "src/main.tsx");
4024
+ const mainPath = path12.join(themeDir, "src/main.tsx");
3811
4025
  if (!fs12.existsSync(mainPath)) return;
3812
4026
  const src = fs12.readFileSync(mainPath, "utf-8");
3813
4027
  if (src.includes(`./sections/${pascal}`)) return;
@@ -3848,7 +4062,7 @@ function tryWireMain(themeDir, slug, pascal) {
3848
4062
  fs12.writeFileSync(mainPath, lines.join("\n"));
3849
4063
  }
3850
4064
  function tryAddToHomePreset(themeDir, slug) {
3851
- const themeJsonPath = path11.join(themeDir, "theme.json");
4065
+ const themeJsonPath = path12.join(themeDir, "theme.json");
3852
4066
  if (!fs12.existsSync(themeJsonPath)) return;
3853
4067
  let parsed;
3854
4068
  try {
@@ -3875,19 +4089,19 @@ function tryAddToHomePreset(themeDir, slug) {
3875
4089
  }
3876
4090
 
3877
4091
  // src/commands/add-block.ts
3878
- var import_commander13 = require("commander");
4092
+ var import_commander14 = require("commander");
3879
4093
  var fs13 = __toESM(require("fs"));
3880
- var path12 = __toESM(require("path"));
3881
- var addBlockCommand = new import_commander13.Command("add-block").description("Scaffold a new block inside an existing section schema").argument("<section>", "Section type, e.g. 'product_grid'").argument("<name>", "Block type, e.g. 'feature'").option("-d, --dir <directory>", "Theme directory", ".").action((section, name, options) => {
3882
- const themeDir = path12.resolve(options.dir);
3883
- if (!fs13.existsSync(path12.join(themeDir, "theme.json"))) {
4094
+ var path13 = __toESM(require("path"));
4095
+ var addBlockCommand = new import_commander14.Command("add-block").description("Scaffold a new block inside an existing section schema").argument("<section>", "Section type, e.g. 'product_grid'").argument("<name>", "Block type, e.g. 'feature'").option("-d, --dir <directory>", "Theme directory", ".").action((section, name, options) => {
4096
+ const themeDir = path13.resolve(options.dir);
4097
+ if (!fs13.existsSync(path13.join(themeDir, "theme.json"))) {
3884
4098
  console.error(`No theme.json in ${themeDir}.`);
3885
4099
  process.exit(1);
3886
4100
  }
3887
4101
  const sectionSnake = toSnakeCase(section);
3888
4102
  const blockSnake = toSnakeCase(name);
3889
4103
  const blockHuman = humanize2(toPascalCase(name));
3890
- const schemaPath = path12.join(
4104
+ const schemaPath = path13.join(
3891
4105
  themeDir,
3892
4106
  "schemas",
3893
4107
  "sections",
@@ -3930,7 +4144,7 @@ var addBlockCommand = new import_commander13.Command("add-block").description("S
3930
4144
  `
3931
4145
  \x1B[32m\u2713\x1B[0m Added block "${blockSnake}" to section "${sectionSnake}":`,
3932
4146
  `
3933
- - ${path12.relative(themeDir, schemaPath)}`,
4147
+ - ${path13.relative(themeDir, schemaPath)}`,
3934
4148
  `
3935
4149
 
3936
4150
  \x1B[33m\u26A0\x1B[0m The section component should iterate \`blockOrder\` and render each block by type.`,
@@ -3962,17 +4176,17 @@ function humanize2(name) {
3962
4176
  }
3963
4177
 
3964
4178
  // src/commands/pull.ts
3965
- var import_commander14 = require("commander");
4179
+ var import_commander15 = require("commander");
3966
4180
  var fs14 = __toESM(require("fs"));
3967
- var path13 = __toESM(require("path"));
4181
+ var path14 = __toESM(require("path"));
3968
4182
  var https2 = __toESM(require("https"));
3969
4183
  var http2 = __toESM(require("http"));
3970
4184
  async function downloadToFile(url, dest) {
3971
- return new Promise((resolve9, reject) => {
4185
+ return new Promise((resolve10, reject) => {
3972
4186
  const client = url.startsWith("https:") ? https2 : http2;
3973
4187
  const req = client.get(url, (res) => {
3974
4188
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
3975
- downloadToFile(res.headers.location, dest).then(resolve9, reject);
4189
+ downloadToFile(res.headers.location, dest).then(resolve10, reject);
3976
4190
  return;
3977
4191
  }
3978
4192
  if (res.statusCode !== 200) {
@@ -3981,7 +4195,7 @@ async function downloadToFile(url, dest) {
3981
4195
  }
3982
4196
  const out = fs14.createWriteStream(dest);
3983
4197
  res.pipe(out);
3984
- out.on("finish", () => out.close(() => resolve9()));
4198
+ out.on("finish", () => out.close(() => resolve10()));
3985
4199
  out.on("error", reject);
3986
4200
  });
3987
4201
  req.on("error", reject);
@@ -4000,7 +4214,7 @@ async function unzip(zipPath, targetDir) {
4000
4214
  });
4001
4215
  }
4002
4216
  }
4003
- var pullCommand = new import_commander14.Command("pull").description("Download a published theme's source to a local directory").argument("<theme-id>", "Theme slug or UUID").option(
4217
+ var pullCommand = new import_commander15.Command("pull").description("Download a published theme's source to a local directory").argument("<theme-id>", "Theme slug or UUID").option(
4004
4218
  "-d, --dir <directory>",
4005
4219
  "Target directory (default: ./<theme-id>)"
4006
4220
  ).option(
@@ -4008,7 +4222,7 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
4008
4222
  "Specific version_string (default: latest)"
4009
4223
  ).option("--force", "Overwrite the target directory if it exists").action(
4010
4224
  async (themeId, options) => {
4011
- const targetDir = path13.resolve(options.dir || themeId);
4225
+ const targetDir = path14.resolve(options.dir || themeId);
4012
4226
  if (fs14.existsSync(targetDir) && !options.force) {
4013
4227
  const isEmpty = fs14.readdirSync(targetDir).length === 0;
4014
4228
  if (!isEmpty) {
@@ -4055,7 +4269,7 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
4055
4269
  process.exit(1);
4056
4270
  }
4057
4271
  console.log(`Downloading ${theme.name || themeId} ${target.version_string}\u2026`);
4058
- const tmpZip = path13.join(
4272
+ const tmpZip = path14.join(
4059
4273
  require("os").tmpdir(),
4060
4274
  `numu-pull-${process.pid}-${Date.now()}.zip`
4061
4275
  );
@@ -4072,28 +4286,28 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
4072
4286
  console.log(`\u2714 Pulled ${theme.slug || themeId}@${target.version_string} into ${targetDir}`);
4073
4287
  console.log("");
4074
4288
  console.log("Next steps:");
4075
- console.log(` cd ${path13.relative(process.cwd(), targetDir) || "."}`);
4289
+ console.log(` cd ${path14.relative(process.cwd(), targetDir) || "."}`);
4076
4290
  console.log(" npm install");
4077
4291
  console.log(" numu-theme dev");
4078
4292
  }
4079
4293
  );
4080
4294
 
4081
4295
  // src/commands/delete.ts
4082
- var import_commander15 = require("commander");
4296
+ var import_commander16 = require("commander");
4083
4297
  var readline = __toESM(require("readline"));
4084
4298
  async function confirm(prompt) {
4085
4299
  const rl = readline.createInterface({
4086
4300
  input: process.stdin,
4087
4301
  output: process.stdout
4088
4302
  });
4089
- return new Promise((resolve9) => {
4303
+ return new Promise((resolve10) => {
4090
4304
  rl.question(`${prompt} [y/N] `, (answer) => {
4091
4305
  rl.close();
4092
- resolve9(/^y(es)?$/i.test(answer.trim()));
4306
+ resolve10(/^y(es)?$/i.test(answer.trim()));
4093
4307
  });
4094
4308
  });
4095
4309
  }
4096
- var deleteCommand = new import_commander15.Command("delete").description("Delete a theme or a specific version from the marketplace").argument("<theme-id>", "Theme slug or UUID").option("-v, --version <version>", "Delete this version only (default: whole theme)").option("-y, --yes", "Skip the confirmation prompt").action(
4310
+ var deleteCommand = new import_commander16.Command("delete").description("Delete a theme or a specific version from the marketplace").argument("<theme-id>", "Theme slug or UUID").option("-v, --version <version>", "Delete this version only (default: whole theme)").option("-y, --yes", "Skip the confirmation prompt").action(
4097
4311
  async (themeId, options) => {
4098
4312
  const isVersion = Boolean(options.version);
4099
4313
  const scope = isVersion ? `version ${options.version} of ${themeId}` : `the entire theme ${themeId}`;
@@ -4106,8 +4320,8 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
4106
4320
  return;
4107
4321
  }
4108
4322
  }
4109
- const path15 = isVersion ? `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions/${encodeURIComponent(options.version)}` : `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`;
4110
- const res = await apiRequest("DELETE", path15);
4323
+ const path16 = isVersion ? `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions/${encodeURIComponent(options.version)}` : `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`;
4324
+ const res = await apiRequest("DELETE", path16);
4111
4325
  if (res.status === 200 || res.status === 204) {
4112
4326
  console.log(`\u2714 Deleted ${scope}.`);
4113
4327
  return;
@@ -4126,9 +4340,9 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
4126
4340
  );
4127
4341
 
4128
4342
  // src/commands/migrate.ts
4129
- var import_commander16 = require("commander");
4343
+ var import_commander17 = require("commander");
4130
4344
  var fs15 = __toESM(require("fs"));
4131
- var path14 = __toESM(require("path"));
4345
+ var path15 = __toESM(require("path"));
4132
4346
  var import_chalk = __toESM(require("chalk"));
4133
4347
  var V2_HOOK_PATTERNS = [
4134
4348
  {
@@ -4226,14 +4440,14 @@ function findSectionFiles(dir) {
4226
4440
  if (!fs15.existsSync(dir)) return [];
4227
4441
  const out = [];
4228
4442
  for (const entry of fs15.readdirSync(dir, { withFileTypes: true })) {
4229
- const full = path14.join(dir, entry.name);
4443
+ const full = path15.join(dir, entry.name);
4230
4444
  if (entry.isDirectory()) out.push(...findSectionFiles(full));
4231
4445
  else if (entry.isFile() && /\.(tsx|ts)$/.test(entry.name)) out.push(full);
4232
4446
  }
4233
4447
  return out;
4234
4448
  }
4235
4449
  function sectionTypeFromFilename(filename) {
4236
- const base = path14.basename(filename).replace(/\.(tsx|ts)$/, "");
4450
+ const base = path15.basename(filename).replace(/\.(tsx|ts)$/, "");
4237
4451
  return base.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9-]+/g, "-").toLowerCase();
4238
4452
  }
4239
4453
  function makeV2Bridge() {
@@ -4733,7 +4947,7 @@ function makeTemplates() {
4733
4947
  `
4734
4948
  };
4735
4949
  }
4736
- var migrateCommand = new import_commander16.Command("migrate").description("Scaffold a V3 theme project from a V2 theme directory").argument(
4950
+ var migrateCommand = new import_commander17.Command("migrate").description("Scaffold a V3 theme project from a V2 theme directory").argument(
4737
4951
  "<v2-path>",
4738
4952
  "Path to the V2 theme directory (e.g. ../numu-egyptian-bazaar/src/themes/empire)"
4739
4953
  ).option(
@@ -4744,15 +4958,15 @@ var migrateCommand = new import_commander16.Command("migrate").description("Scaf
4744
4958
  "Override the display name (default: title-cased v2 id)"
4745
4959
  ).action(
4746
4960
  async (v2Path, options) => {
4747
- const absV2Path = path14.resolve(process.cwd(), v2Path);
4961
+ const absV2Path = path15.resolve(process.cwd(), v2Path);
4748
4962
  if (!fs15.existsSync(absV2Path) || !fs15.statSync(absV2Path).isDirectory()) {
4749
4963
  console.error(import_chalk.default.red(`V2 path does not exist or is not a directory: ${absV2Path}`));
4750
4964
  process.exit(1);
4751
4965
  }
4752
- const v2Id = path14.basename(absV2Path).toLowerCase().replace(/[^a-z0-9-]/g, "-");
4966
+ const v2Id = path15.basename(absV2Path).toLowerCase().replace(/[^a-z0-9-]/g, "-");
4753
4967
  const themeId = `${v2Id}-v3`;
4754
4968
  const displayName = options.name ?? v2Id.charAt(0).toUpperCase() + v2Id.slice(1).replace(/-/g, " ") + " (V3)";
4755
- const outDir = path14.resolve(
4969
+ const outDir = path15.resolve(
4756
4970
  process.cwd(),
4757
4971
  options.out ?? `${v2Id}-engine-V3`
4758
4972
  );
@@ -4773,25 +4987,25 @@ Migrating V2 \u2192 V3:`));
4773
4987
  "schemas/blocks",
4774
4988
  "templates"
4775
4989
  ]) {
4776
- fs15.mkdirSync(path14.join(outDir, d), { recursive: true });
4990
+ fs15.mkdirSync(path15.join(outDir, d), { recursive: true });
4777
4991
  }
4778
4992
  fs15.writeFileSync(
4779
- path14.join(outDir, "src/v2-bridge", "index.tsx"),
4993
+ path15.join(outDir, "src/v2-bridge", "index.tsx"),
4780
4994
  makeV2Bridge()
4781
4995
  );
4782
- const v2Styles = path14.join(absV2Path, "styles.css");
4996
+ const v2Styles = path15.join(absV2Path, "styles.css");
4783
4997
  if (fs15.existsSync(v2Styles)) {
4784
- fs15.copyFileSync(v2Styles, path14.join(outDir, "styles.css"));
4998
+ fs15.copyFileSync(v2Styles, path15.join(outDir, "styles.css"));
4785
4999
  console.log(import_chalk.default.green(" \u2713 Copied styles.css"));
4786
5000
  } else {
4787
5001
  fs15.writeFileSync(
4788
- path14.join(outDir, "styles.css"),
5002
+ path15.join(outDir, "styles.css"),
4789
5003
  `/* ${displayName} styles */
4790
5004
  `
4791
5005
  );
4792
5006
  console.log(import_chalk.default.yellow(" \u26A0 No styles.css found \u2014 created an empty one"));
4793
5007
  }
4794
- const v2SectionsDir = path14.join(absV2Path, "sections");
5008
+ const v2SectionsDir = path15.join(absV2Path, "sections");
4795
5009
  const sectionFiles = fs15.existsSync(v2SectionsDir) ? findSectionFiles(v2SectionsDir) : [];
4796
5010
  const sectionTypes = [];
4797
5011
  const allNotes = [];
@@ -4802,18 +5016,18 @@ Migrating V2 \u2192 V3:`));
4802
5016
  const v2Source = fs15.readFileSync(file, "utf-8");
4803
5017
  const notes = migrationNotesFor(v2Source);
4804
5018
  allNotes.push({ file, type, notes });
4805
- const relativeV2 = path14.relative(process.cwd(), file);
5019
+ const relativeV2 = path15.relative(process.cwd(), file);
4806
5020
  const header = adapterCommentBlock(notes, relativeV2);
4807
5021
  const rewritten = rewriteV2Imports(v2Source);
4808
5022
  const ported = `${header}
4809
5023
 
4810
5024
  ${rewritten}`;
4811
5025
  fs15.writeFileSync(
4812
- path14.join(outDir, "src/sections", `${type}.tsx`),
5026
+ path15.join(outDir, "src/sections", `${type}.tsx`),
4813
5027
  ported
4814
5028
  );
4815
5029
  fs15.writeFileSync(
4816
- path14.join(outDir, "schemas/sections", `${type}.json`),
5030
+ path15.join(outDir, "schemas/sections", `${type}.json`),
4817
5031
  JSON.stringify(
4818
5032
  {
4819
5033
  type,
@@ -4827,38 +5041,38 @@ ${rewritten}`;
4827
5041
  }
4828
5042
  console.log(import_chalk.default.green(` \u2713 Ported ${sectionTypes.length} section file(s)`));
4829
5043
  fs15.writeFileSync(
4830
- path14.join(outDir, "theme.json"),
5044
+ path15.join(outDir, "theme.json"),
4831
5045
  JSON.stringify(makeThemeJson(themeId, displayName, sectionTypes), null, 2)
4832
5046
  );
4833
5047
  fs15.writeFileSync(
4834
- path14.join(outDir, "settings_schema.json"),
5048
+ path15.join(outDir, "settings_schema.json"),
4835
5049
  JSON.stringify(makeSettingsSchema(), null, 2)
4836
5050
  );
4837
5051
  fs15.writeFileSync(
4838
- path14.join(outDir, "src/main.tsx"),
5052
+ path15.join(outDir, "src/main.tsx"),
4839
5053
  makeMainTsx(themeId, displayName, sectionTypes)
4840
5054
  );
4841
5055
  fs15.writeFileSync(
4842
- path14.join(outDir, "vite.config.ts"),
5056
+ path15.join(outDir, "vite.config.ts"),
4843
5057
  makeViteConfig()
4844
5058
  );
4845
5059
  fs15.writeFileSync(
4846
- path14.join(outDir, "package.json"),
5060
+ path15.join(outDir, "package.json"),
4847
5061
  JSON.stringify(makePackageJson(themeId, displayName), null, 2)
4848
5062
  );
4849
5063
  fs15.writeFileSync(
4850
- path14.join(outDir, "tsconfig.json"),
5064
+ path15.join(outDir, "tsconfig.json"),
4851
5065
  JSON.stringify(makeTsConfig(), null, 2)
4852
5066
  );
4853
5067
  fs15.writeFileSync(
4854
- path14.join(outDir, "index.html"),
5068
+ path15.join(outDir, "index.html"),
4855
5069
  makeIndexHtml(displayName)
4856
5070
  );
4857
5071
  const tpl = makeTemplates();
4858
- fs15.writeFileSync(path14.join(outDir, "templates/error.html"), tpl.error);
4859
- fs15.writeFileSync(path14.join(outDir, "templates/loading.html"), tpl.loading);
5072
+ fs15.writeFileSync(path15.join(outDir, "templates/error.html"), tpl.error);
5073
+ fs15.writeFileSync(path15.join(outDir, "templates/loading.html"), tpl.loading);
4860
5074
  fs15.writeFileSync(
4861
- path14.join(outDir, ".gitignore"),
5075
+ path15.join(outDir, ".gitignore"),
4862
5076
  "node_modules/\ndist/\n.DS_Store\n"
4863
5077
  );
4864
5078
  console.log(import_chalk.default.green(" \u2713 Wrote scaffold files\n"));
@@ -4879,7 +5093,7 @@ ${rewritten}`;
4879
5093
  console.log();
4880
5094
  }
4881
5095
  console.log(import_chalk.default.bold("\nNext steps:"));
4882
- console.log(` ${import_chalk.default.cyan("cd")} ${path14.relative(process.cwd(), outDir)}`);
5096
+ console.log(` ${import_chalk.default.cyan("cd")} ${path15.relative(process.cwd(), outDir)}`);
4883
5097
  console.log(` ${import_chalk.default.cyan("npm install")}`);
4884
5098
  console.log(` ${import_chalk.default.cyan("npm run dev")} ${import_chalk.default.dim("# dev preview on :5173")}`);
4885
5099
  console.log(` ${import_chalk.default.cyan("npx numu-theme build")} ${import_chalk.default.dim("# validate + build")}`);
@@ -4891,13 +5105,14 @@ ${import_chalk.default.dim("Open each src/sections/*.tsx and follow the ADAPTER
4891
5105
  );
4892
5106
 
4893
5107
  // src/index.ts
4894
- var program = new import_commander17.Command();
5108
+ var program = new import_commander18.Command();
4895
5109
  program.name("numu-theme").description("CLI for developing, validating, building, and publishing NUMU themes").version("0.1.0");
4896
5110
  program.addCommand(initCommand);
4897
5111
  program.addCommand(devCommand);
4898
5112
  program.addCommand(checkCommand);
4899
5113
  program.addCommand(lintCommand);
4900
5114
  program.addCommand(buildCommand);
5115
+ program.addCommand(verifyCommand);
4901
5116
  program.addCommand(pushCommand);
4902
5117
  program.addCommand(submitCommand);
4903
5118
  program.addCommand(installCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@numueg/theme-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.1",
4
4
  "description": "CLI for developing, validating, building, linting, and publishing NUMU storefront themes",
5
5
  "keywords": [
6
6
  "numu",