@numueg/theme-cli 0.2.0 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.js +452 -239
  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(
@@ -1161,10 +1199,10 @@ export default defineConfig({
1161
1199
  build: "numu-theme build",
1162
1200
  check: "numu-theme check"
1163
1201
  },
1164
- dependencies: { "@numueg/theme-sdk": "^0.2.0" },
1202
+ dependencies: { "@numueg/theme-sdk": "^0.3.0" },
1165
1203
  devDependencies: {
1166
- "@numueg/theme-cli": "^0.2.0",
1167
- "@numueg/theme-plugin": "^0.2.0",
1204
+ "@numueg/theme-cli": "^0.3.0",
1205
+ "@numueg/theme-plugin": "^0.3.0",
1168
1206
  "@vitejs/plugin-react": "^4.3.0",
1169
1207
  vite: "^6.0.0",
1170
1208
  typescript: "^5.8.0",
@@ -1274,12 +1312,12 @@ function assertHttpsOrLocalhost(urlStr) {
1274
1312
  }
1275
1313
  throw new Error(`Unsupported protocol: ${url.protocol}`);
1276
1314
  }
1277
- async function apiRequest(method, path15, body) {
1315
+ async function apiRequest(method, path16, body) {
1278
1316
  const config = loadConfig();
1279
- const url = assertHttpsOrLocalhost(`${config.api_url}${path15}`);
1317
+ const url = assertHttpsOrLocalhost(`${config.api_url}${path16}`);
1280
1318
  const isHttps = url.protocol === "https:";
1281
1319
  const transport = isHttps ? https : http;
1282
- return new Promise((resolve9, reject) => {
1320
+ return new Promise((resolve10, reject) => {
1283
1321
  const headers = {};
1284
1322
  if (config.token) headers["Authorization"] = `Bearer ${config.token}`;
1285
1323
  let postData;
@@ -1301,7 +1339,7 @@ async function apiRequest(method, path15, body) {
1301
1339
  res.on("data", (chunk) => data += chunk);
1302
1340
  res.on("end", () => {
1303
1341
  const { unwrapped, raw } = parseBody(data);
1304
- resolve9({
1342
+ resolve10({
1305
1343
  status: res.statusCode ?? 0,
1306
1344
  data: unwrapped,
1307
1345
  raw
@@ -1334,7 +1372,7 @@ Content-Type: application/zip\r
1334
1372
  const fullBody = Buffer.concat([head, fileBuffer, tail]);
1335
1373
  const isHttps = url.protocol === "https:";
1336
1374
  const transport = isHttps ? https : http;
1337
- return new Promise((resolve9, reject) => {
1375
+ return new Promise((resolve10, reject) => {
1338
1376
  const headers = {
1339
1377
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
1340
1378
  "Content-Length": String(fullBody.byteLength)
@@ -1357,7 +1395,7 @@ Content-Type: application/zip\r
1357
1395
  res.on("data", (chunk) => data += chunk);
1358
1396
  res.on("end", () => {
1359
1397
  const { unwrapped, raw } = parseBody(data);
1360
- resolve9({
1398
+ resolve10({
1361
1399
  status: res.statusCode ?? 0,
1362
1400
  data: unwrapped,
1363
1401
  raw
@@ -1532,6 +1570,48 @@ function validateTheme(themeDir) {
1532
1570
  if (!themeJson.presets || Object.keys(themeJson.presets).length === 0) {
1533
1571
  warnings.push("theme.json has no presets \u2014 merchants will start with an empty page");
1534
1572
  }
1573
+ const REQUIRED_TEMPLATES = [
1574
+ "home",
1575
+ "product",
1576
+ "collection",
1577
+ "cart",
1578
+ "page",
1579
+ "search",
1580
+ "404"
1581
+ ];
1582
+ if (themeJson.presets && typeof themeJson.presets === "object") {
1583
+ const referenced = /* @__PURE__ */ new Set();
1584
+ for (const bucket of [
1585
+ themeJson.presets.templates,
1586
+ themeJson.presets.section_groups
1587
+ ]) {
1588
+ if (!bucket || typeof bucket !== "object") continue;
1589
+ for (const entry of Object.values(bucket)) {
1590
+ const sections = entry?.sections;
1591
+ const instances = Array.isArray(sections) ? sections : sections && typeof sections === "object" ? Object.values(sections) : [];
1592
+ for (const inst of instances) {
1593
+ if (inst && typeof inst.type === "string") {
1594
+ referenced.add(inst.type.toLowerCase());
1595
+ }
1596
+ }
1597
+ }
1598
+ }
1599
+ for (const type of referenced) {
1600
+ if (!schemaNames.has(type)) {
1601
+ errors.push(
1602
+ `theme.json preset references section type "${type}" but there is no schemas/sections/${type}.json \u2014 the storefront drops unknown sections at render.`
1603
+ );
1604
+ }
1605
+ }
1606
+ const templates = themeJson.presets.templates && typeof themeJson.presets.templates === "object" ? themeJson.presets.templates : {};
1607
+ for (const tpl of REQUIRED_TEMPLATES) {
1608
+ if (!(tpl in templates)) {
1609
+ warnings.push(
1610
+ `theme.json has no preset for the "${tpl}" template \u2014 the storefront will use its built-in fallback.`
1611
+ );
1612
+ }
1613
+ }
1614
+ }
1535
1615
  return { valid: errors.length === 0, errors, warnings };
1536
1616
  }
1537
1617
 
@@ -1641,7 +1721,9 @@ async function runAllRules(themeDir, options) {
1641
1721
  () => Promise.resolve().then(() => (init_use_app_no_availability_check(), use_app_no_availability_check_exports)),
1642
1722
  () => Promise.resolve().then(() => (init_manifest_required_fields(), manifest_required_fields_exports)),
1643
1723
  () => Promise.resolve().then(() => (init_contrast_hint(), contrast_hint_exports)),
1644
- () => Promise.resolve().then(() => (init_touch_target(), touch_target_exports))
1724
+ () => Promise.resolve().then(() => (init_touch_target(), touch_target_exports)),
1725
+ () => Promise.resolve().then(() => (init_ssr_unsafe_globals(), ssr_unsafe_globals_exports)),
1726
+ () => Promise.resolve().then(() => (init_ssr_nondeterministic_render(), ssr_nondeterministic_render_exports))
1645
1727
  ];
1646
1728
  const issues = [];
1647
1729
  for (const load of ruleLoaders) {
@@ -1656,14 +1738,14 @@ async function runAllRules(themeDir, options) {
1656
1738
  });
1657
1739
  continue;
1658
1740
  }
1659
- const rule13 = mod.default;
1660
- if (options.enabledRules && !options.enabledRules.has(rule13.id)) continue;
1741
+ const rule15 = mod.default;
1742
+ if (options.enabledRules && !options.enabledRules.has(rule15.id)) continue;
1661
1743
  try {
1662
- const ruleIssues = await rule13.check(ctx);
1744
+ const ruleIssues = await rule15.check(ctx);
1663
1745
  for (const issue of ruleIssues) issues.push(issue);
1664
1746
  } catch (e) {
1665
1747
  issues.push({
1666
- rule: rule13.id,
1748
+ rule: rule15.id,
1667
1749
  severity: "warning",
1668
1750
  message: `Rule crashed: ${e.message}`
1669
1751
  });
@@ -1887,14 +1969,102 @@ Bundle size: ${sizeMB} MB`);
1887
1969
  "\u26A0 Bundle exceeds 5MB limit \u2014 optimize before submitting to marketplace"
1888
1970
  );
1889
1971
  }
1972
+ const serverBundle = path6.join(distDir, "theme.server.js");
1973
+ if (fs7.existsSync(serverBundle)) {
1974
+ const kb = (fs7.statSync(serverBundle).size / 1024).toFixed(1);
1975
+ console.log(`SSR bundle: theme.server.js (${kb} KB) \u2014 hosts will server-render this theme`);
1976
+ } else {
1977
+ console.log(
1978
+ "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."
1979
+ );
1980
+ }
1890
1981
  }
1891
1982
  console.log("\n\u2713 Build complete");
1892
1983
  }
1893
1984
  );
1894
1985
 
1895
- // src/commands/push.ts
1986
+ // src/commands/verify.ts
1896
1987
  var import_commander6 = require("commander");
1988
+ var import_child_process5 = require("child_process");
1989
+ var import_fs = require("fs");
1990
+ var import_module = require("module");
1897
1991
  var path7 = __toESM(require("path"));
1992
+ var import_url = require("url");
1993
+ var dynamicImport = new Function("u", "return import(u)");
1994
+ var verifyCommand = new import_commander6.Command("verify").description(
1995
+ "Server-render every template against fixtures to catch runtime crashes"
1996
+ ).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(
1997
+ async (options) => {
1998
+ const dir = path7.resolve(process.cwd(), options.dir);
1999
+ if (options.build !== false) {
2000
+ console.log("Building theme (SSR bundle)\u2026");
2001
+ try {
2002
+ (0, import_child_process5.execSync)("npx vite build", { cwd: dir, stdio: "inherit" });
2003
+ } catch {
2004
+ console.error("Build failed");
2005
+ process.exit(1);
2006
+ }
2007
+ }
2008
+ const serverBundle = path7.join(dir, "dist", "theme.server.js");
2009
+ if (!(0, import_fs.existsSync)(serverBundle)) {
2010
+ console.error(
2011
+ "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)."
2012
+ );
2013
+ process.exit(1);
2014
+ }
2015
+ const requireFromTheme = (0, import_module.createRequire)(path7.join(dir, "package.json"));
2016
+ let harness;
2017
+ try {
2018
+ const verifyEntry = requireFromTheme.resolve("@numueg/theme-sdk/verify");
2019
+ harness = await dynamicImport(
2020
+ (0, import_url.pathToFileURL)(verifyEntry).href
2021
+ );
2022
+ } catch {
2023
+ console.error(
2024
+ "This theme's @numueg/theme-sdk has no `/verify` entry \u2014 upgrade @numueg/theme-sdk to a version that ships the render harness."
2025
+ );
2026
+ process.exit(1);
2027
+ }
2028
+ let serverModule;
2029
+ try {
2030
+ serverModule = await dynamicImport((0, import_url.pathToFileURL)(serverBundle).href);
2031
+ } catch (e) {
2032
+ console.error(
2033
+ "Failed to load dist/theme.server.js:",
2034
+ e instanceof Error ? e.message : String(e)
2035
+ );
2036
+ process.exit(1);
2037
+ }
2038
+ const def = serverModule.default;
2039
+ const mod = {
2040
+ createApp: serverModule.createApp ?? def?.createApp
2041
+ };
2042
+ const result = await harness.verifyThemeRender(mod, {
2043
+ locale: options.locale
2044
+ });
2045
+ console.log("\nRender verification:");
2046
+ for (const r of result.results) {
2047
+ const mark = r.ok ? "\u2713" : "\u2718";
2048
+ console.log(
2049
+ ` ${mark} ${r.template}${r.ok ? ` (${r.htmlLength} chars)` : ""}`
2050
+ );
2051
+ if (!r.ok && r.error) {
2052
+ console.log(` ${String(r.error).split("\n")[0]}`);
2053
+ }
2054
+ }
2055
+ if (!result.ok) {
2056
+ console.error(
2057
+ "\n\u2718 Render verification failed \u2014 fix the crashing templates above."
2058
+ );
2059
+ process.exit(1);
2060
+ }
2061
+ console.log("\n\u2713 All templates render against fixture data.");
2062
+ }
2063
+ );
2064
+
2065
+ // src/commands/push.ts
2066
+ var import_commander7 = require("commander");
2067
+ var path8 = __toESM(require("path"));
1898
2068
  var os2 = __toESM(require("os"));
1899
2069
 
1900
2070
  // src/utils/zipper.ts
@@ -1902,10 +2072,10 @@ var fs8 = __toESM(require("fs"));
1902
2072
  var import_archiver = __toESM(require("archiver"));
1903
2073
  async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
1904
2074
  const opts = Array.isArray(optsOrLegacyExcludes) ? { excludePatterns: optsOrLegacyExcludes } : optsOrLegacyExcludes;
1905
- return new Promise((resolve9, reject) => {
2075
+ return new Promise((resolve10, reject) => {
1906
2076
  const output = fs8.createWriteStream(outputPath);
1907
2077
  const archive = (0, import_archiver.default)("zip", { zlib: { level: 9 } });
1908
- output.on("close", () => resolve9(outputPath));
2078
+ output.on("close", () => resolve10(outputPath));
1909
2079
  archive.on("error", reject);
1910
2080
  archive.pipe(output);
1911
2081
  const defaultExcludes = [
@@ -1927,7 +2097,7 @@ async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
1927
2097
  }
1928
2098
 
1929
2099
  // 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) => {
2100
+ 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
2101
  const config = loadConfig();
1932
2102
  if (!config.token) {
1933
2103
  console.error("Not logged in. Run: numu-theme login");
@@ -1940,7 +2110,7 @@ var pushCommand = new import_commander6.Command("push").description("Upload buil
1940
2110
  process.exit(1);
1941
2111
  }
1942
2112
  console.log("Packaging theme...");
1943
- const zipPath = path7.join(os2.tmpdir(), `numu-theme-${Date.now()}.zip`);
2113
+ const zipPath = path8.join(os2.tmpdir(), `numu-theme-${Date.now()}.zip`);
1944
2114
  await zipDirectory(options.dir, zipPath);
1945
2115
  console.log("Uploading...");
1946
2116
  const res = await uploadFile("/themes/upload", zipPath);
@@ -1961,11 +2131,11 @@ Push failed (${res.status}): ${JSON.stringify(res.data)}`);
1961
2131
  });
1962
2132
 
1963
2133
  // src/commands/submit.ts
1964
- var import_commander7 = require("commander");
1965
- var path8 = __toESM(require("path"));
2134
+ var import_commander8 = require("commander");
2135
+ var path9 = __toESM(require("path"));
1966
2136
  var os3 = __toESM(require("os"));
1967
2137
  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(
2138
+ var submitCommand = new import_commander8.Command("submit").description("Submit theme to the NUMU Marketplace for review").option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
1969
2139
  "-t, --theme-id <theme_id>",
1970
2140
  "Marketplace theme listing UUID (create via dashboard first)"
1971
2141
  ).option("-n, --notes <notes>", "Release notes for this version").action(
@@ -1988,7 +2158,7 @@ var submitCommand = new import_commander7.Command("submit").description("Submit
1988
2158
  console.log("\nWarnings:");
1989
2159
  result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1990
2160
  }
1991
- const themeJsonPath = path8.join(options.dir, "theme.json");
2161
+ const themeJsonPath = path9.join(options.dir, "theme.json");
1992
2162
  let version;
1993
2163
  try {
1994
2164
  const tj = JSON.parse(fs9.readFileSync(themeJsonPath, "utf-8"));
@@ -2002,7 +2172,7 @@ var submitCommand = new import_commander7.Command("submit").description("Submit
2002
2172
  process.exit(1);
2003
2173
  }
2004
2174
  console.log("\nPackaging theme source...");
2005
- const zipPath = path8.join(
2175
+ const zipPath = path9.join(
2006
2176
  os3.tmpdir(),
2007
2177
  `numu-theme-submit-${Date.now()}.zip`
2008
2178
  );
@@ -2055,12 +2225,12 @@ Submission failed (${submitRes.status}): ${JSON.stringify(submitRes.data)}`
2055
2225
  );
2056
2226
 
2057
2227
  // src/commands/install.ts
2058
- var import_commander8 = require("commander");
2059
- var import_child_process5 = require("child_process");
2060
- var path9 = __toESM(require("path"));
2228
+ var import_commander9 = require("commander");
2229
+ var import_child_process6 = require("child_process");
2230
+ var path10 = __toESM(require("path"));
2061
2231
  var os4 = __toESM(require("os"));
2062
2232
  var fs10 = __toESM(require("fs"));
2063
- var installCommand = new import_commander8.Command("install").description(
2233
+ var installCommand = new import_commander9.Command("install").description(
2064
2234
  "Build + upload + install a theme directly on a store you own (bypasses marketplace review)"
2065
2235
  ).option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
2066
2236
  "-t, --theme-id <theme_id>",
@@ -2098,7 +2268,7 @@ var installCommand = new import_commander8.Command("install").description(
2098
2268
  process.exit(1);
2099
2269
  }
2100
2270
  result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
2101
- const themeJsonPath = path9.join(options.dir, "theme.json");
2271
+ const themeJsonPath = path10.join(options.dir, "theme.json");
2102
2272
  let baseVersion;
2103
2273
  try {
2104
2274
  const tj = JSON.parse(fs10.readFileSync(themeJsonPath, "utf-8"));
@@ -2117,7 +2287,7 @@ var installCommand = new import_commander8.Command("install").description(
2117
2287
  console.log(` install tag: ${version}`);
2118
2288
  console.log("Building locally (so worker can skip npm install)...");
2119
2289
  try {
2120
- (0, import_child_process5.execSync)("npm run build", {
2290
+ (0, import_child_process6.execSync)("npm run build", {
2121
2291
  cwd: options.dir,
2122
2292
  stdio: "inherit",
2123
2293
  env: { ...process.env, NODE_ENV: "production" }
@@ -2128,7 +2298,7 @@ var installCommand = new import_commander8.Command("install").description(
2128
2298
  );
2129
2299
  process.exit(1);
2130
2300
  }
2131
- const distEntry = path9.join(options.dir, "dist", "theme.js");
2301
+ const distEntry = path10.join(options.dir, "dist", "theme.js");
2132
2302
  if (!fs10.existsSync(distEntry)) {
2133
2303
  console.error(
2134
2304
  "\nBuild completed but dist/theme.js is missing. Check vite.config.ts entry / output filename."
@@ -2136,7 +2306,7 @@ var installCommand = new import_commander8.Command("install").description(
2136
2306
  process.exit(1);
2137
2307
  }
2138
2308
  console.log("Packaging source + dist...");
2139
- const zipPath = path9.join(
2309
+ const zipPath = path10.join(
2140
2310
  os4.tmpdir(),
2141
2311
  `numu-theme-install-${Date.now()}.zip`
2142
2312
  );
@@ -2250,9 +2420,9 @@ Timed out waiting for build (${options.pollTimeout}s). Run \`numu-theme status -
2250
2420
  );
2251
2421
 
2252
2422
  // src/commands/login.ts
2253
- var import_commander9 = require("commander");
2423
+ var import_commander10 = require("commander");
2254
2424
  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) => {
2425
+ 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
2426
  if (options.apiUrl) {
2257
2427
  saveConfig({ api_url: options.apiUrl });
2258
2428
  console.log(`API URL set to: ${options.apiUrl}`);
@@ -2299,8 +2469,8 @@ Login failed: ${err.message}`);
2299
2469
  });
2300
2470
 
2301
2471
  // 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(
2472
+ var import_commander11 = require("commander");
2473
+ 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
2474
  "--version <version_id>",
2305
2475
  "Marketplace version ID returned by `numu-theme submit`"
2306
2476
  ).option("-w, --watch", "Poll until the build reaches a terminal state").option(
@@ -2427,13 +2597,13 @@ function formatBytes(n) {
2427
2597
  }
2428
2598
 
2429
2599
  // src/commands/doctor.ts
2430
- var import_commander11 = require("commander");
2600
+ var import_commander12 = require("commander");
2431
2601
  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) => {
2602
+ var path11 = __toESM(require("path"));
2603
+ 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
2604
  let issues = 0;
2435
2605
  let warnings = 0;
2436
- const themeDir = path10.resolve(options.dir);
2606
+ const themeDir = path11.resolve(options.dir);
2437
2607
  function ok(line) {
2438
2608
  console.log(` \x1B[32m\u2713\x1B[0m ${line}`);
2439
2609
  }
@@ -2446,8 +2616,8 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2446
2616
  console.log(` \x1B[31m\u2717\x1B[0m ${line}`);
2447
2617
  }
2448
2618
  console.log("\nProject");
2449
- const themeJsonPath = path10.join(themeDir, "theme.json");
2450
- const settingsPath = path10.join(themeDir, "settings_schema.json");
2619
+ const themeJsonPath = path11.join(themeDir, "theme.json");
2620
+ const settingsPath = path11.join(themeDir, "settings_schema.json");
2451
2621
  if (!fs11.existsSync(themeJsonPath)) {
2452
2622
  fail(`No theme.json found in ${themeDir}`);
2453
2623
  console.log(
@@ -2473,21 +2643,53 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2473
2643
  "src/index.ts"
2474
2644
  ];
2475
2645
  const entry = entryCandidates.find(
2476
- (p) => fs11.existsSync(path10.join(themeDir, p))
2646
+ (p) => fs11.existsSync(path11.join(themeDir, p))
2477
2647
  );
2478
2648
  if (!entry) {
2479
2649
  fail(`No entry point found (expected one of: ${entryCandidates.join(", ")})`);
2480
2650
  } 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);
2651
+ const src = fs11.readFileSync(path11.join(themeDir, entry), "utf-8");
2652
+ 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
2653
  if (exportsMount) ok(`${entry} exports mount(el, props)`);
2484
2654
  else
2485
2655
  fail(
2486
2656
  `${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
2657
  );
2658
+ 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);
2659
+ if (exportsCreateApp) ok(`${entry} exports createApp(ctx) \u2014 SSR-capable`);
2660
+ else
2661
+ warn(
2662
+ `${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.`
2663
+ );
2664
+ }
2665
+ console.log("\nSSR toolchain");
2666
+ try {
2667
+ const pkg = JSON.parse(
2668
+ fs11.readFileSync(path11.join(themeDir, "package.json"), "utf-8")
2669
+ );
2670
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
2671
+ const checkMinor = (name, minMinor) => {
2672
+ const range = allDeps[name];
2673
+ if (!range) {
2674
+ warn(`${name} not in package.json \u2014 npm install it`);
2675
+ return;
2676
+ }
2677
+ const m = /(\d+)\.(\d+)\./.exec(range);
2678
+ if (m && Number(m[1]) === 0 && Number(m[2]) < minMinor) {
2679
+ warn(
2680
+ `${name}@${range} predates the SSR contract \u2014 bump to ^0.${minMinor}.0 for server rendering`
2681
+ );
2682
+ } else {
2683
+ ok(`${name}@${range}`);
2684
+ }
2685
+ };
2686
+ checkMinor("@numueg/theme-sdk", 3);
2687
+ checkMinor("@numueg/theme-plugin", 3);
2688
+ } catch {
2689
+ warn("Could not read package.json to verify SDK/plugin versions");
2488
2690
  }
2489
2691
  console.log("\nLocales");
2490
- const localesDir = path10.join(themeDir, "locales");
2692
+ const localesDir = path11.join(themeDir, "locales");
2491
2693
  if (!fs11.existsSync(localesDir)) {
2492
2694
  warn(
2493
2695
  "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 +2706,14 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2504
2706
  }
2505
2707
  for (const f of localeFiles) {
2506
2708
  try {
2507
- JSON.parse(fs11.readFileSync(path10.join(localesDir, f), "utf-8"));
2709
+ JSON.parse(fs11.readFileSync(path11.join(localesDir, f), "utf-8"));
2508
2710
  } catch (err) {
2509
2711
  fail(`locales/${f} is not valid JSON: ${err.message}`);
2510
2712
  }
2511
2713
  }
2512
2714
  }
2513
2715
  console.log("\nAssets");
2514
- const assetsDir = path10.join(themeDir, "assets");
2716
+ const assetsDir = path11.join(themeDir, "assets");
2515
2717
  if (!fs11.existsSync(assetsDir)) {
2516
2718
  warn(
2517
2719
  "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 +2734,13 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2532
2734
  fs11.readFileSync(themeJsonPath, "utf-8")
2533
2735
  );
2534
2736
  const presets = themeJson.presets ?? {};
2535
- const sectionsDir = path10.join(themeDir, "src", "sections");
2536
- const schemaDir = path10.join(themeDir, "schemas", "sections");
2737
+ const sectionsDir = path11.join(themeDir, "src", "sections");
2738
+ const schemaDir = path11.join(themeDir, "schemas", "sections");
2537
2739
  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())
2740
+ fs11.readdirSync(sectionsDir).filter((f) => /\.(tsx|ts|jsx|js)$/.test(f)).map((f) => path11.basename(f, path11.extname(f)).toLowerCase())
2539
2741
  ) : /* @__PURE__ */ new Set();
2540
2742
  const schemaNames = fs11.existsSync(schemaDir) ? new Set(
2541
- fs11.readdirSync(schemaDir).filter((f) => f.endsWith(".json")).map((f) => path10.basename(f, ".json").toLowerCase())
2743
+ fs11.readdirSync(schemaDir).filter((f) => f.endsWith(".json")).map((f) => path11.basename(f, ".json").toLowerCase())
2542
2744
  ) : /* @__PURE__ */ new Set();
2543
2745
  let missingRefs = 0;
2544
2746
  for (const [presetName, presetVal] of Object.entries(presets)) {
@@ -2569,8 +2771,8 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2569
2771
  warn(`Could not parse theme.json presets: ${err.message}`);
2570
2772
  }
2571
2773
  console.log("\nBuild");
2572
- const distJs = path10.join(themeDir, "dist", "theme.js");
2573
- const distCss = path10.join(themeDir, "dist", "theme.css");
2774
+ const distJs = path11.join(themeDir, "dist", "theme.js");
2775
+ const distCss = path11.join(themeDir, "dist", "theme.css");
2574
2776
  if (!fs11.existsSync(distJs)) {
2575
2777
  warn("dist/theme.js not found \u2014 run `numu-theme build` first");
2576
2778
  } else {
@@ -2586,6 +2788,16 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2586
2788
  } else {
2587
2789
  ok("dist/theme.css present");
2588
2790
  }
2791
+ const distServer = path11.join(themeDir, "dist", "theme.server.js");
2792
+ if (fs11.existsSync(distServer)) {
2793
+ ok(
2794
+ `dist/theme.server.js (${(fs11.statSync(distServer).size / 1024).toFixed(1)} KB) \u2014 SSR artifact present`
2795
+ );
2796
+ } else if (fs11.existsSync(distJs)) {
2797
+ warn(
2798
+ "dist/theme.server.js not found \u2014 last build produced a client-only theme (plugin < 0.3, federate:false, or SSR pass failed)"
2799
+ );
2800
+ }
2589
2801
  console.log("\nAuth");
2590
2802
  const config = loadConfig();
2591
2803
  if (!config.token) {
@@ -2653,9 +2865,9 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2653
2865
  });
2654
2866
 
2655
2867
  // src/commands/add-section.ts
2656
- var import_commander12 = require("commander");
2868
+ var import_commander13 = require("commander");
2657
2869
  var fs12 = __toESM(require("fs"));
2658
- var path11 = __toESM(require("path"));
2870
+ var path12 = __toESM(require("path"));
2659
2871
 
2660
2872
  // src/section-library/entries/hero-with-cta.ts
2661
2873
  var heroWithCta = {
@@ -3700,7 +3912,7 @@ function findEntry(slug) {
3700
3912
  }
3701
3913
 
3702
3914
  // 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(
3915
+ 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
3916
  "--from-library <slug>",
3705
3917
  "Copy from the built-in section library (run with --list to see options)"
3706
3918
  ).option("--list", "List the built-in section library and exit").option("-d, --dir <directory>", "Theme directory", ".").action(
@@ -3721,8 +3933,8 @@ var addSectionCommand = new import_commander12.Command("add-section").descriptio
3721
3933
  );
3722
3934
  process.exit(1);
3723
3935
  }
3724
- const themeDir = path11.resolve(process.cwd(), options.dir);
3725
- if (!fs12.existsSync(path11.join(themeDir, "theme.json"))) {
3936
+ const themeDir = path12.resolve(process.cwd(), options.dir);
3937
+ if (!fs12.existsSync(path12.join(themeDir, "theme.json"))) {
3726
3938
  console.error(
3727
3939
  "No theme.json in this directory. Run from a theme project root."
3728
3940
  );
@@ -3758,14 +3970,14 @@ var addSectionCommand = new import_commander12.Command("add-section").descriptio
3758
3970
  ]
3759
3971
  };
3760
3972
  }
3761
- const componentPath = path11.join(themeDir, "src/sections", `${pascal}.tsx`);
3973
+ const componentPath = path12.join(themeDir, "src/sections", `${pascal}.tsx`);
3762
3974
  ensureDirOf(componentPath);
3763
3975
  if (fs12.existsSync(componentPath)) {
3764
3976
  console.error(`Already exists: ${componentPath}`);
3765
3977
  process.exit(1);
3766
3978
  }
3767
3979
  fs12.writeFileSync(componentPath, componentSource);
3768
- const schemaPath = path11.join(themeDir, "schemas/sections", `${slug}.json`);
3980
+ const schemaPath = path12.join(themeDir, "schemas/sections", `${slug}.json`);
3769
3981
  ensureDirOf(schemaPath);
3770
3982
  fs12.writeFileSync(schemaPath, JSON.stringify(schemaJson, null, 2));
3771
3983
  tryWireMain(themeDir, slug, pascal);
@@ -3789,7 +4001,7 @@ function humanize(slug) {
3789
4001
  return slug.split("-").filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join(" ");
3790
4002
  }
3791
4003
  function ensureDirOf(p) {
3792
- fs12.mkdirSync(path11.dirname(p), { recursive: true });
4004
+ fs12.mkdirSync(path12.dirname(p), { recursive: true });
3793
4005
  }
3794
4006
  function emptySectionStub(pascal) {
3795
4007
  return `import type { SectionProps } from "@numueg/theme-sdk";
@@ -3807,7 +4019,7 @@ export default function ${pascal}({ settings }: SectionProps) {
3807
4019
  `;
3808
4020
  }
3809
4021
  function tryWireMain(themeDir, slug, pascal) {
3810
- const mainPath = path11.join(themeDir, "src/main.tsx");
4022
+ const mainPath = path12.join(themeDir, "src/main.tsx");
3811
4023
  if (!fs12.existsSync(mainPath)) return;
3812
4024
  const src = fs12.readFileSync(mainPath, "utf-8");
3813
4025
  if (src.includes(`./sections/${pascal}`)) return;
@@ -3848,7 +4060,7 @@ function tryWireMain(themeDir, slug, pascal) {
3848
4060
  fs12.writeFileSync(mainPath, lines.join("\n"));
3849
4061
  }
3850
4062
  function tryAddToHomePreset(themeDir, slug) {
3851
- const themeJsonPath = path11.join(themeDir, "theme.json");
4063
+ const themeJsonPath = path12.join(themeDir, "theme.json");
3852
4064
  if (!fs12.existsSync(themeJsonPath)) return;
3853
4065
  let parsed;
3854
4066
  try {
@@ -3875,19 +4087,19 @@ function tryAddToHomePreset(themeDir, slug) {
3875
4087
  }
3876
4088
 
3877
4089
  // src/commands/add-block.ts
3878
- var import_commander13 = require("commander");
4090
+ var import_commander14 = require("commander");
3879
4091
  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"))) {
4092
+ var path13 = __toESM(require("path"));
4093
+ 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) => {
4094
+ const themeDir = path13.resolve(options.dir);
4095
+ if (!fs13.existsSync(path13.join(themeDir, "theme.json"))) {
3884
4096
  console.error(`No theme.json in ${themeDir}.`);
3885
4097
  process.exit(1);
3886
4098
  }
3887
4099
  const sectionSnake = toSnakeCase(section);
3888
4100
  const blockSnake = toSnakeCase(name);
3889
4101
  const blockHuman = humanize2(toPascalCase(name));
3890
- const schemaPath = path12.join(
4102
+ const schemaPath = path13.join(
3891
4103
  themeDir,
3892
4104
  "schemas",
3893
4105
  "sections",
@@ -3930,7 +4142,7 @@ var addBlockCommand = new import_commander13.Command("add-block").description("S
3930
4142
  `
3931
4143
  \x1B[32m\u2713\x1B[0m Added block "${blockSnake}" to section "${sectionSnake}":`,
3932
4144
  `
3933
- - ${path12.relative(themeDir, schemaPath)}`,
4145
+ - ${path13.relative(themeDir, schemaPath)}`,
3934
4146
  `
3935
4147
 
3936
4148
  \x1B[33m\u26A0\x1B[0m The section component should iterate \`blockOrder\` and render each block by type.`,
@@ -3962,17 +4174,17 @@ function humanize2(name) {
3962
4174
  }
3963
4175
 
3964
4176
  // src/commands/pull.ts
3965
- var import_commander14 = require("commander");
4177
+ var import_commander15 = require("commander");
3966
4178
  var fs14 = __toESM(require("fs"));
3967
- var path13 = __toESM(require("path"));
4179
+ var path14 = __toESM(require("path"));
3968
4180
  var https2 = __toESM(require("https"));
3969
4181
  var http2 = __toESM(require("http"));
3970
4182
  async function downloadToFile(url, dest) {
3971
- return new Promise((resolve9, reject) => {
4183
+ return new Promise((resolve10, reject) => {
3972
4184
  const client = url.startsWith("https:") ? https2 : http2;
3973
4185
  const req = client.get(url, (res) => {
3974
4186
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
3975
- downloadToFile(res.headers.location, dest).then(resolve9, reject);
4187
+ downloadToFile(res.headers.location, dest).then(resolve10, reject);
3976
4188
  return;
3977
4189
  }
3978
4190
  if (res.statusCode !== 200) {
@@ -3981,7 +4193,7 @@ async function downloadToFile(url, dest) {
3981
4193
  }
3982
4194
  const out = fs14.createWriteStream(dest);
3983
4195
  res.pipe(out);
3984
- out.on("finish", () => out.close(() => resolve9()));
4196
+ out.on("finish", () => out.close(() => resolve10()));
3985
4197
  out.on("error", reject);
3986
4198
  });
3987
4199
  req.on("error", reject);
@@ -4000,7 +4212,7 @@ async function unzip(zipPath, targetDir) {
4000
4212
  });
4001
4213
  }
4002
4214
  }
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(
4215
+ 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
4216
  "-d, --dir <directory>",
4005
4217
  "Target directory (default: ./<theme-id>)"
4006
4218
  ).option(
@@ -4008,7 +4220,7 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
4008
4220
  "Specific version_string (default: latest)"
4009
4221
  ).option("--force", "Overwrite the target directory if it exists").action(
4010
4222
  async (themeId, options) => {
4011
- const targetDir = path13.resolve(options.dir || themeId);
4223
+ const targetDir = path14.resolve(options.dir || themeId);
4012
4224
  if (fs14.existsSync(targetDir) && !options.force) {
4013
4225
  const isEmpty = fs14.readdirSync(targetDir).length === 0;
4014
4226
  if (!isEmpty) {
@@ -4055,7 +4267,7 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
4055
4267
  process.exit(1);
4056
4268
  }
4057
4269
  console.log(`Downloading ${theme.name || themeId} ${target.version_string}\u2026`);
4058
- const tmpZip = path13.join(
4270
+ const tmpZip = path14.join(
4059
4271
  require("os").tmpdir(),
4060
4272
  `numu-pull-${process.pid}-${Date.now()}.zip`
4061
4273
  );
@@ -4072,28 +4284,28 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
4072
4284
  console.log(`\u2714 Pulled ${theme.slug || themeId}@${target.version_string} into ${targetDir}`);
4073
4285
  console.log("");
4074
4286
  console.log("Next steps:");
4075
- console.log(` cd ${path13.relative(process.cwd(), targetDir) || "."}`);
4287
+ console.log(` cd ${path14.relative(process.cwd(), targetDir) || "."}`);
4076
4288
  console.log(" npm install");
4077
4289
  console.log(" numu-theme dev");
4078
4290
  }
4079
4291
  );
4080
4292
 
4081
4293
  // src/commands/delete.ts
4082
- var import_commander15 = require("commander");
4294
+ var import_commander16 = require("commander");
4083
4295
  var readline = __toESM(require("readline"));
4084
4296
  async function confirm(prompt) {
4085
4297
  const rl = readline.createInterface({
4086
4298
  input: process.stdin,
4087
4299
  output: process.stdout
4088
4300
  });
4089
- return new Promise((resolve9) => {
4301
+ return new Promise((resolve10) => {
4090
4302
  rl.question(`${prompt} [y/N] `, (answer) => {
4091
4303
  rl.close();
4092
- resolve9(/^y(es)?$/i.test(answer.trim()));
4304
+ resolve10(/^y(es)?$/i.test(answer.trim()));
4093
4305
  });
4094
4306
  });
4095
4307
  }
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(
4308
+ 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
4309
  async (themeId, options) => {
4098
4310
  const isVersion = Boolean(options.version);
4099
4311
  const scope = isVersion ? `version ${options.version} of ${themeId}` : `the entire theme ${themeId}`;
@@ -4106,8 +4318,8 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
4106
4318
  return;
4107
4319
  }
4108
4320
  }
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);
4321
+ const path16 = isVersion ? `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions/${encodeURIComponent(options.version)}` : `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`;
4322
+ const res = await apiRequest("DELETE", path16);
4111
4323
  if (res.status === 200 || res.status === 204) {
4112
4324
  console.log(`\u2714 Deleted ${scope}.`);
4113
4325
  return;
@@ -4126,9 +4338,9 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
4126
4338
  );
4127
4339
 
4128
4340
  // src/commands/migrate.ts
4129
- var import_commander16 = require("commander");
4341
+ var import_commander17 = require("commander");
4130
4342
  var fs15 = __toESM(require("fs"));
4131
- var path14 = __toESM(require("path"));
4343
+ var path15 = __toESM(require("path"));
4132
4344
  var import_chalk = __toESM(require("chalk"));
4133
4345
  var V2_HOOK_PATTERNS = [
4134
4346
  {
@@ -4226,14 +4438,14 @@ function findSectionFiles(dir) {
4226
4438
  if (!fs15.existsSync(dir)) return [];
4227
4439
  const out = [];
4228
4440
  for (const entry of fs15.readdirSync(dir, { withFileTypes: true })) {
4229
- const full = path14.join(dir, entry.name);
4441
+ const full = path15.join(dir, entry.name);
4230
4442
  if (entry.isDirectory()) out.push(...findSectionFiles(full));
4231
4443
  else if (entry.isFile() && /\.(tsx|ts)$/.test(entry.name)) out.push(full);
4232
4444
  }
4233
4445
  return out;
4234
4446
  }
4235
4447
  function sectionTypeFromFilename(filename) {
4236
- const base = path14.basename(filename).replace(/\.(tsx|ts)$/, "");
4448
+ const base = path15.basename(filename).replace(/\.(tsx|ts)$/, "");
4237
4449
  return base.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9-]+/g, "-").toLowerCase();
4238
4450
  }
4239
4451
  function makeV2Bridge() {
@@ -4733,7 +4945,7 @@ function makeTemplates() {
4733
4945
  `
4734
4946
  };
4735
4947
  }
4736
- var migrateCommand = new import_commander16.Command("migrate").description("Scaffold a V3 theme project from a V2 theme directory").argument(
4948
+ var migrateCommand = new import_commander17.Command("migrate").description("Scaffold a V3 theme project from a V2 theme directory").argument(
4737
4949
  "<v2-path>",
4738
4950
  "Path to the V2 theme directory (e.g. ../numu-egyptian-bazaar/src/themes/empire)"
4739
4951
  ).option(
@@ -4744,15 +4956,15 @@ var migrateCommand = new import_commander16.Command("migrate").description("Scaf
4744
4956
  "Override the display name (default: title-cased v2 id)"
4745
4957
  ).action(
4746
4958
  async (v2Path, options) => {
4747
- const absV2Path = path14.resolve(process.cwd(), v2Path);
4959
+ const absV2Path = path15.resolve(process.cwd(), v2Path);
4748
4960
  if (!fs15.existsSync(absV2Path) || !fs15.statSync(absV2Path).isDirectory()) {
4749
4961
  console.error(import_chalk.default.red(`V2 path does not exist or is not a directory: ${absV2Path}`));
4750
4962
  process.exit(1);
4751
4963
  }
4752
- const v2Id = path14.basename(absV2Path).toLowerCase().replace(/[^a-z0-9-]/g, "-");
4964
+ const v2Id = path15.basename(absV2Path).toLowerCase().replace(/[^a-z0-9-]/g, "-");
4753
4965
  const themeId = `${v2Id}-v3`;
4754
4966
  const displayName = options.name ?? v2Id.charAt(0).toUpperCase() + v2Id.slice(1).replace(/-/g, " ") + " (V3)";
4755
- const outDir = path14.resolve(
4967
+ const outDir = path15.resolve(
4756
4968
  process.cwd(),
4757
4969
  options.out ?? `${v2Id}-engine-V3`
4758
4970
  );
@@ -4773,25 +4985,25 @@ Migrating V2 \u2192 V3:`));
4773
4985
  "schemas/blocks",
4774
4986
  "templates"
4775
4987
  ]) {
4776
- fs15.mkdirSync(path14.join(outDir, d), { recursive: true });
4988
+ fs15.mkdirSync(path15.join(outDir, d), { recursive: true });
4777
4989
  }
4778
4990
  fs15.writeFileSync(
4779
- path14.join(outDir, "src/v2-bridge", "index.tsx"),
4991
+ path15.join(outDir, "src/v2-bridge", "index.tsx"),
4780
4992
  makeV2Bridge()
4781
4993
  );
4782
- const v2Styles = path14.join(absV2Path, "styles.css");
4994
+ const v2Styles = path15.join(absV2Path, "styles.css");
4783
4995
  if (fs15.existsSync(v2Styles)) {
4784
- fs15.copyFileSync(v2Styles, path14.join(outDir, "styles.css"));
4996
+ fs15.copyFileSync(v2Styles, path15.join(outDir, "styles.css"));
4785
4997
  console.log(import_chalk.default.green(" \u2713 Copied styles.css"));
4786
4998
  } else {
4787
4999
  fs15.writeFileSync(
4788
- path14.join(outDir, "styles.css"),
5000
+ path15.join(outDir, "styles.css"),
4789
5001
  `/* ${displayName} styles */
4790
5002
  `
4791
5003
  );
4792
5004
  console.log(import_chalk.default.yellow(" \u26A0 No styles.css found \u2014 created an empty one"));
4793
5005
  }
4794
- const v2SectionsDir = path14.join(absV2Path, "sections");
5006
+ const v2SectionsDir = path15.join(absV2Path, "sections");
4795
5007
  const sectionFiles = fs15.existsSync(v2SectionsDir) ? findSectionFiles(v2SectionsDir) : [];
4796
5008
  const sectionTypes = [];
4797
5009
  const allNotes = [];
@@ -4802,18 +5014,18 @@ Migrating V2 \u2192 V3:`));
4802
5014
  const v2Source = fs15.readFileSync(file, "utf-8");
4803
5015
  const notes = migrationNotesFor(v2Source);
4804
5016
  allNotes.push({ file, type, notes });
4805
- const relativeV2 = path14.relative(process.cwd(), file);
5017
+ const relativeV2 = path15.relative(process.cwd(), file);
4806
5018
  const header = adapterCommentBlock(notes, relativeV2);
4807
5019
  const rewritten = rewriteV2Imports(v2Source);
4808
5020
  const ported = `${header}
4809
5021
 
4810
5022
  ${rewritten}`;
4811
5023
  fs15.writeFileSync(
4812
- path14.join(outDir, "src/sections", `${type}.tsx`),
5024
+ path15.join(outDir, "src/sections", `${type}.tsx`),
4813
5025
  ported
4814
5026
  );
4815
5027
  fs15.writeFileSync(
4816
- path14.join(outDir, "schemas/sections", `${type}.json`),
5028
+ path15.join(outDir, "schemas/sections", `${type}.json`),
4817
5029
  JSON.stringify(
4818
5030
  {
4819
5031
  type,
@@ -4827,38 +5039,38 @@ ${rewritten}`;
4827
5039
  }
4828
5040
  console.log(import_chalk.default.green(` \u2713 Ported ${sectionTypes.length} section file(s)`));
4829
5041
  fs15.writeFileSync(
4830
- path14.join(outDir, "theme.json"),
5042
+ path15.join(outDir, "theme.json"),
4831
5043
  JSON.stringify(makeThemeJson(themeId, displayName, sectionTypes), null, 2)
4832
5044
  );
4833
5045
  fs15.writeFileSync(
4834
- path14.join(outDir, "settings_schema.json"),
5046
+ path15.join(outDir, "settings_schema.json"),
4835
5047
  JSON.stringify(makeSettingsSchema(), null, 2)
4836
5048
  );
4837
5049
  fs15.writeFileSync(
4838
- path14.join(outDir, "src/main.tsx"),
5050
+ path15.join(outDir, "src/main.tsx"),
4839
5051
  makeMainTsx(themeId, displayName, sectionTypes)
4840
5052
  );
4841
5053
  fs15.writeFileSync(
4842
- path14.join(outDir, "vite.config.ts"),
5054
+ path15.join(outDir, "vite.config.ts"),
4843
5055
  makeViteConfig()
4844
5056
  );
4845
5057
  fs15.writeFileSync(
4846
- path14.join(outDir, "package.json"),
5058
+ path15.join(outDir, "package.json"),
4847
5059
  JSON.stringify(makePackageJson(themeId, displayName), null, 2)
4848
5060
  );
4849
5061
  fs15.writeFileSync(
4850
- path14.join(outDir, "tsconfig.json"),
5062
+ path15.join(outDir, "tsconfig.json"),
4851
5063
  JSON.stringify(makeTsConfig(), null, 2)
4852
5064
  );
4853
5065
  fs15.writeFileSync(
4854
- path14.join(outDir, "index.html"),
5066
+ path15.join(outDir, "index.html"),
4855
5067
  makeIndexHtml(displayName)
4856
5068
  );
4857
5069
  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);
5070
+ fs15.writeFileSync(path15.join(outDir, "templates/error.html"), tpl.error);
5071
+ fs15.writeFileSync(path15.join(outDir, "templates/loading.html"), tpl.loading);
4860
5072
  fs15.writeFileSync(
4861
- path14.join(outDir, ".gitignore"),
5073
+ path15.join(outDir, ".gitignore"),
4862
5074
  "node_modules/\ndist/\n.DS_Store\n"
4863
5075
  );
4864
5076
  console.log(import_chalk.default.green(" \u2713 Wrote scaffold files\n"));
@@ -4879,7 +5091,7 @@ ${rewritten}`;
4879
5091
  console.log();
4880
5092
  }
4881
5093
  console.log(import_chalk.default.bold("\nNext steps:"));
4882
- console.log(` ${import_chalk.default.cyan("cd")} ${path14.relative(process.cwd(), outDir)}`);
5094
+ console.log(` ${import_chalk.default.cyan("cd")} ${path15.relative(process.cwd(), outDir)}`);
4883
5095
  console.log(` ${import_chalk.default.cyan("npm install")}`);
4884
5096
  console.log(` ${import_chalk.default.cyan("npm run dev")} ${import_chalk.default.dim("# dev preview on :5173")}`);
4885
5097
  console.log(` ${import_chalk.default.cyan("npx numu-theme build")} ${import_chalk.default.dim("# validate + build")}`);
@@ -4891,13 +5103,14 @@ ${import_chalk.default.dim("Open each src/sections/*.tsx and follow the ADAPTER
4891
5103
  );
4892
5104
 
4893
5105
  // src/index.ts
4894
- var program = new import_commander17.Command();
5106
+ var program = new import_commander18.Command();
4895
5107
  program.name("numu-theme").description("CLI for developing, validating, building, and publishing NUMU themes").version("0.1.0");
4896
5108
  program.addCommand(initCommand);
4897
5109
  program.addCommand(devCommand);
4898
5110
  program.addCommand(checkCommand);
4899
5111
  program.addCommand(lintCommand);
4900
5112
  program.addCommand(buildCommand);
5113
+ program.addCommand(verifyCommand);
4901
5114
  program.addCommand(pushCommand);
4902
5115
  program.addCommand(submitCommand);
4903
5116
  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.0",
4
4
  "description": "CLI for developing, validating, building, linting, and publishing NUMU storefront themes",
5
5
  "keywords": [
6
6
  "numu",