@numueg/theme-cli 0.1.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 +1508 -265
  2. package/package.json +8 -2
package/dist/index.js CHANGED
@@ -591,13 +591,293 @@ var init_manifest_required_fields = __esm({
591
591
  }
592
592
  });
593
593
 
594
+ // src/lint/rules/contrast-hint.ts
595
+ var contrast_hint_exports = {};
596
+ __export(contrast_hint_exports, {
597
+ default: () => contrast_hint_default
598
+ });
599
+ function hexToRgb(hex) {
600
+ let h = hex.replace("#", "").trim();
601
+ if (h.length === 3) {
602
+ h = h.split("").map((c) => c + c).join("");
603
+ }
604
+ if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
605
+ const n = parseInt(h, 16);
606
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
607
+ }
608
+ function relativeLuminance([r, g, b]) {
609
+ const a = [r, g, b].map((v) => {
610
+ const s = v / 255;
611
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
612
+ });
613
+ return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
614
+ }
615
+ function contrastRatio(c1, c2) {
616
+ const l1 = relativeLuminance(c1);
617
+ const l2 = relativeLuminance(c2);
618
+ const hi = Math.max(l1, l2);
619
+ const lo = Math.min(l1, l2);
620
+ return (hi + 0.05) / (lo + 0.05);
621
+ }
622
+ var rule11, contrast_hint_default;
623
+ var init_contrast_hint = __esm({
624
+ "src/lint/rules/contrast-hint.ts"() {
625
+ "use strict";
626
+ rule11 = {
627
+ id: "contrast-hint",
628
+ description: "Co-located color/background hex pairs should meet WCAG 4.5:1 contrast",
629
+ check(ctx) {
630
+ const issues = [];
631
+ const css = ctx.styles;
632
+ if (!css) return issues;
633
+ const blockRe = /([^{}]*)\{([^{}]*)\}/g;
634
+ let m;
635
+ while ((m = blockRe.exec(css)) !== null) {
636
+ const rawSelector = m[1].trim().split("\n").pop()?.trim() || m[1].trim();
637
+ const body = m[2];
638
+ const colorM = body.match(/(?:^|[;{\s])color:\s*(#[0-9a-fA-F]{3,6})\b/);
639
+ const bgM = body.match(
640
+ /background(?:-color)?:\s*(#[0-9a-fA-F]{3,6})\b/
641
+ );
642
+ if (!colorM || !bgM) continue;
643
+ const fg = hexToRgb(colorM[1]);
644
+ const bg = hexToRgb(bgM[1]);
645
+ if (!fg || !bg) continue;
646
+ const ratio = contrastRatio(fg, bg);
647
+ if (ratio < 4.5) {
648
+ const line = css.slice(0, m.index).split("\n").length;
649
+ issues.push({
650
+ rule: rule11.id,
651
+ severity: "warning",
652
+ file: "styles.css",
653
+ line,
654
+ message: `Low contrast ${ratio.toFixed(2)}:1 for "${rawSelector}" (${colorM[1]} on ${bgM[1]}).`,
655
+ suggestion: ratio >= 3 ? `Meets 3:1 (OK for large text \u226524px/19px-bold) but not the 4.5:1 body-text minimum.` : `Below WCAG 4.5:1 \u2014 darken/lighten one color, or drive both from a color_scheme so merchants control contrast.`
656
+ });
657
+ }
658
+ }
659
+ return issues;
660
+ }
661
+ };
662
+ contrast_hint_default = rule11;
663
+ }
664
+ });
665
+
666
+ // src/lint/rules/touch-target.ts
667
+ var touch_target_exports = {};
668
+ __export(touch_target_exports, {
669
+ default: () => touch_target_default
670
+ });
671
+ var INTERACTIVE, DIM_PROPS, rule12, touch_target_default;
672
+ var init_touch_target = __esm({
673
+ "src/lint/rules/touch-target.ts"() {
674
+ "use strict";
675
+ INTERACTIVE = /(^|[\s,>+~])(button|a|\.btn\b|\[role=["']?button["']?\]|input\[type=["']?(?:button|submit|reset)["']?\])/i;
676
+ DIM_PROPS = ["width", "height", "min-width", "min-height"];
677
+ rule12 = {
678
+ id: "touch-target",
679
+ description: "Interactive targets should be \u2265 24\xD724px (WCAG 2.5.8)",
680
+ check(ctx) {
681
+ const issues = [];
682
+ const css = ctx.styles;
683
+ if (!css) return issues;
684
+ const blockRe = /([^{}]*)\{([^{}]*)\}/g;
685
+ let m;
686
+ while ((m = blockRe.exec(css)) !== null) {
687
+ const rawSelector = m[1].trim().split("\n").pop()?.trim() || m[1].trim();
688
+ if (rawSelector.includes("::")) continue;
689
+ if (!INTERACTIVE.test(rawSelector)) continue;
690
+ const body = m[2];
691
+ for (const prop of DIM_PROPS) {
692
+ const pm = body.match(
693
+ new RegExp(`(?:^|[;{\\s])${prop}:\\s*([0-9.]+)px\\b`)
694
+ );
695
+ if (!pm) continue;
696
+ const px = parseFloat(pm[1]);
697
+ if (px > 0 && px < 24) {
698
+ const line = css.slice(0, m.index).split("\n").length;
699
+ issues.push({
700
+ rule: rule12.id,
701
+ severity: "warning",
702
+ file: "styles.css",
703
+ line,
704
+ message: `Interactive "${rawSelector}" sets ${prop}: ${px}px (< 24px touch target).`,
705
+ suggestion: `Give clickable elements a \u226524\xD724px hit area (min-width/min-height: 24px or adequate padding).`
706
+ });
707
+ break;
708
+ }
709
+ }
710
+ }
711
+ return issues;
712
+ }
713
+ };
714
+ touch_target_default = rule12;
715
+ }
716
+ });
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
+
594
857
  // src/index.ts
595
- var import_commander16 = require("commander");
858
+ var import_commander18 = require("commander");
596
859
 
597
860
  // src/commands/init.ts
598
861
  var import_commander = require("commander");
862
+ var import_child_process = require("child_process");
599
863
  var fs = __toESM(require("fs"));
600
864
  var path = __toESM(require("path"));
865
+ function detectAuthor() {
866
+ const git = (args) => {
867
+ try {
868
+ return (0, import_child_process.execSync)(`git config ${args}`, {
869
+ stdio: ["ignore", "pipe", "ignore"]
870
+ }).toString().trim();
871
+ } catch {
872
+ return "";
873
+ }
874
+ };
875
+ const name = git("user.name");
876
+ const email = git("user.email");
877
+ if (name && email) return `${name} <${email}>`;
878
+ if (name) return name;
879
+ return "Theme Author";
880
+ }
601
881
  var initCommand = new import_commander.Command("init").description("Scaffold a new NUMU theme project").argument("<name>", "Theme name").option("--template <template>", "Starter template", "basic").action(async (name, _options) => {
602
882
  const dir = path.resolve(process.cwd(), name);
603
883
  if (fs.existsSync(dir)) {
@@ -620,7 +900,7 @@ var initCommand = new import_commander.Command("init").description("Scaffold a n
620
900
  {
621
901
  id: name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
622
902
  name,
623
- author: "",
903
+ author: detectAuthor(),
624
904
  version: "0.1.0",
625
905
  layout: "single-column",
626
906
  description: `${name} NUMU theme`,
@@ -771,36 +1051,23 @@ export default function Hero({ settings }: SectionProps) {
771
1051
  );
772
1052
  fs.writeFileSync(
773
1053
  path.join(dir, "src/main.tsx"),
774
- `import { createRoot, type Root } from "react-dom/client";
775
- import type {
776
- ThemeSettingsV3,
777
- Page,
778
- Product,
779
- Collection,
780
- Store,
781
- } from "@numueg/theme-sdk";
782
- import {
783
- usePage,
784
- PageContext,
785
- ProductProvider,
786
- CollectionProvider,
787
- NuMuProvider,
788
- } 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";
789
1057
  import Hero from "./sections/Hero";
790
1058
 
1059
+ const SECTION_REGISTRY: Record<string, ComponentType<any>> = {
1060
+ hero: Hero,
1061
+ };
1062
+
791
1063
  interface ThemeProps {
792
1064
  themeSettings: ThemeSettingsV3;
1065
+ currentTemplate: string;
793
1066
  }
794
1067
 
795
- const SECTION_REGISTRY: Record<string, React.ComponentType<any>> = {
796
- hero: Hero,
797
- };
798
-
799
- export default function Theme({ themeSettings }: ThemeProps) {
800
- const page = usePage();
801
- const pageType = page?.type || "home";
1068
+ export default function Theme({ themeSettings, currentTemplate }: ThemeProps) {
802
1069
  const template =
803
- themeSettings.templates?.[pageType] || themeSettings.templates?.home;
1070
+ themeSettings.templates?.[currentTemplate] || themeSettings.templates?.home;
804
1071
 
805
1072
  return (
806
1073
  <main>
@@ -822,106 +1089,15 @@ export default function Theme({ themeSettings }: ThemeProps) {
822
1089
  );
823
1090
  }
824
1091
 
825
- // \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
826
- // Required by Next.js storefront's <ByotThemeBoundary>. Owns the React
827
- // render cycle for the bundle's subtree so hooks work.
828
-
829
- interface MountProps {
830
- themeSettings: ThemeSettingsV3;
831
- storeData?: Store;
832
- page?: Page & { data?: Record<string, unknown> };
833
- }
834
-
835
- // Fallback Store used only when host didn't pass storeData. NuMuProvider
836
- // hard-requires Store.currency for Intl.NumberFormat \u2014 synthesize one
837
- // instead of crashing.
838
- const FALLBACK_STORE: Store = {
839
- id: "",
840
- name: "",
841
- slug: "",
842
- currency: "USD",
843
- default_language: "en",
844
- use_nextjs_storefront: true,
845
- };
846
-
847
- function ThemeWithContext({ themeSettings, storeData, page }: MountProps) {
848
- // Storefront returns snake_case \`default_currency\` / \`default_language\`
849
- // but SDK Store expects \`currency\`. Map both shapes so NuMuProvider's
850
- // Intl.NumberFormat doesn't blow up on an empty currency code.
851
- const raw = (storeData ?? FALLBACK_STORE) as Record<string, unknown> & Store;
852
- const store: Store = {
853
- ...raw,
854
- currency:
855
- (raw.currency as string) ||
856
- (raw.default_currency as string) ||
857
- FALLBACK_STORE.currency,
858
- default_language:
859
- (raw.default_language as string) || FALLBACK_STORE.default_language,
860
- };
861
-
862
- const product = page?.data?.product as Product | undefined;
863
- const collection = page?.data?.collection as Collection | undefined;
864
- const pageValue: Page = page
865
- ? {
866
- type: page.type,
867
- title: page.title || "",
868
- handle: page.handle,
869
- data: page.data,
870
- }
871
- : { type: "home", title: "" };
872
-
873
- let tree = <Theme themeSettings={themeSettings} />;
874
- if (collection)
875
- tree = <CollectionProvider collection={collection}>{tree}</CollectionProvider>;
876
- if (product)
877
- tree = <ProductProvider product={product}>{tree}</ProductProvider>;
878
-
879
- return (
880
- <NuMuProvider store={store} themeSettings={themeSettings} locale={store.default_language}>
881
- <PageContext.Provider value={pageValue}>{tree}</PageContext.Provider>
882
- </NuMuProvider>
883
- );
884
- }
885
-
886
- // The host (\`ByotThemeBoundary\`) prefers the object-shape return:
887
- // { unmount, update }
888
- // When \`update\` is present, the customizer forwards prop-only changes
889
- // (themeSettings / storeData / page) into the SAME React tree without
890
- // re-importing the bundle. Without \`update\`, every settings tweak
891
- // would trigger a full remount \u2014 fine, but visibly slower.
892
- export interface MountHandle {
893
- unmount: () => void;
894
- update: (next: MountProps) => void;
895
- }
896
-
897
- export function mount(el: HTMLElement, props: MountProps): MountHandle {
898
- const root: Root = createRoot(el);
899
- let current: MountProps = props;
900
- root.render(<ThemeWithContext {...current} />);
901
-
902
- // Live preview: the storefront's PreviewBridge forwards customizer edits
903
- // as \`numu:theme-update\` window events. Re-render with the new payload.
904
- // Also covered by the \`update\` method below \u2014 both paths funnel into
905
- // the same root.render() so they can't drift.
906
- function handleUpdate(e: Event) {
907
- const detail = (e as CustomEvent<ThemeSettingsV3>).detail;
908
- if (!detail || typeof detail !== "object") return;
909
- current = { ...current, themeSettings: detail };
910
- root.render(<ThemeWithContext {...current} />);
911
- }
912
- 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
+ ));
913
1098
 
914
- return {
915
- unmount: () => {
916
- window.removeEventListener("numu:theme-update", handleUpdate);
917
- root.unmount();
918
- },
919
- update: (next: MountProps) => {
920
- current = next;
921
- root.render(<ThemeWithContext {...current} />);
922
- },
923
- };
924
- }
1099
+ export const mount = entry.mount;
1100
+ export const createApp = entry.createApp;
925
1101
  `
926
1102
  );
927
1103
  fs.writeFileSync(
@@ -961,7 +1137,10 @@ const placeholder = {
961
1137
  };
962
1138
 
963
1139
  const root = document.getElementById("root");
964
- 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
+ );
965
1144
  `
966
1145
  );
967
1146
  fs.writeFileSync(
@@ -1020,10 +1199,10 @@ export default defineConfig({
1020
1199
  build: "numu-theme build",
1021
1200
  check: "numu-theme check"
1022
1201
  },
1023
- dependencies: { "@numueg/theme-sdk": "^0.1.0" },
1202
+ dependencies: { "@numueg/theme-sdk": "^0.3.0" },
1024
1203
  devDependencies: {
1025
- "@numueg/theme-cli": "^0.1.0",
1026
- "@numueg/theme-plugin": "^0.1.0",
1204
+ "@numueg/theme-cli": "^0.3.0",
1205
+ "@numueg/theme-plugin": "^0.3.0",
1027
1206
  "@vitejs/plugin-react": "^4.3.0",
1028
1207
  vite: "^6.0.0",
1029
1208
  typescript: "^5.8.0",
@@ -1056,20 +1235,27 @@ Next steps:
1056
1235
 
1057
1236
  // src/commands/dev.ts
1058
1237
  var import_commander2 = require("commander");
1059
- var import_child_process = require("child_process");
1238
+ var import_child_process2 = require("child_process");
1060
1239
 
1061
1240
  // src/utils/config.ts
1062
1241
  var fs2 = __toESM(require("fs"));
1063
1242
  var path2 = __toESM(require("path"));
1064
1243
  var os = __toESM(require("os"));
1065
- var RC_FILE = path2.join(os.homedir(), ".numurc");
1244
+ function configHome() {
1245
+ const override = process.env.NUMU_HOME;
1246
+ if (override && override.length > 0) return override;
1247
+ return os.homedir();
1248
+ }
1249
+ function rcFile() {
1250
+ return path2.join(configHome(), ".numurc");
1251
+ }
1066
1252
  function loadConfig() {
1067
1253
  const config = {
1068
1254
  api_url: process.env.NUMU_API_URL || "https://api.numu.io/api/v1"
1069
1255
  };
1070
- if (fs2.existsSync(RC_FILE)) {
1256
+ if (fs2.existsSync(rcFile())) {
1071
1257
  try {
1072
- const rc = JSON.parse(fs2.readFileSync(RC_FILE, "utf-8"));
1258
+ const rc = JSON.parse(fs2.readFileSync(rcFile(), "utf-8"));
1073
1259
  if (rc.token) config.token = rc.token;
1074
1260
  if (rc.api_url) config.api_url = rc.api_url;
1075
1261
  if (rc.store_id) config.store_id = rc.store_id;
@@ -1082,16 +1268,16 @@ function loadConfig() {
1082
1268
  }
1083
1269
  function saveConfig(updates) {
1084
1270
  let existing = {};
1085
- if (fs2.existsSync(RC_FILE)) {
1271
+ if (fs2.existsSync(rcFile())) {
1086
1272
  try {
1087
- existing = JSON.parse(fs2.readFileSync(RC_FILE, "utf-8"));
1273
+ existing = JSON.parse(fs2.readFileSync(rcFile(), "utf-8"));
1088
1274
  } catch {
1089
1275
  }
1090
1276
  }
1091
1277
  const merged = { ...existing, ...updates };
1092
- fs2.writeFileSync(RC_FILE, JSON.stringify(merged, null, 2), { mode: 384 });
1278
+ fs2.writeFileSync(rcFile(), JSON.stringify(merged, null, 2), { mode: 384 });
1093
1279
  try {
1094
- fs2.chmodSync(RC_FILE, 384);
1280
+ fs2.chmodSync(rcFile(), 384);
1095
1281
  } catch {
1096
1282
  }
1097
1283
  }
@@ -1126,12 +1312,12 @@ function assertHttpsOrLocalhost(urlStr) {
1126
1312
  }
1127
1313
  throw new Error(`Unsupported protocol: ${url.protocol}`);
1128
1314
  }
1129
- async function apiRequest(method, path14, body) {
1315
+ async function apiRequest(method, path16, body) {
1130
1316
  const config = loadConfig();
1131
- const url = assertHttpsOrLocalhost(`${config.api_url}${path14}`);
1317
+ const url = assertHttpsOrLocalhost(`${config.api_url}${path16}`);
1132
1318
  const isHttps = url.protocol === "https:";
1133
1319
  const transport = isHttps ? https : http;
1134
- return new Promise((resolve7, reject) => {
1320
+ return new Promise((resolve10, reject) => {
1135
1321
  const headers = {};
1136
1322
  if (config.token) headers["Authorization"] = `Bearer ${config.token}`;
1137
1323
  let postData;
@@ -1153,7 +1339,7 @@ async function apiRequest(method, path14, body) {
1153
1339
  res.on("data", (chunk) => data += chunk);
1154
1340
  res.on("end", () => {
1155
1341
  const { unwrapped, raw } = parseBody(data);
1156
- resolve7({
1342
+ resolve10({
1157
1343
  status: res.statusCode ?? 0,
1158
1344
  data: unwrapped,
1159
1345
  raw
@@ -1186,7 +1372,7 @@ Content-Type: application/zip\r
1186
1372
  const fullBody = Buffer.concat([head, fileBuffer, tail]);
1187
1373
  const isHttps = url.protocol === "https:";
1188
1374
  const transport = isHttps ? https : http;
1189
- return new Promise((resolve7, reject) => {
1375
+ return new Promise((resolve10, reject) => {
1190
1376
  const headers = {
1191
1377
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
1192
1378
  "Content-Length": String(fullBody.byteLength)
@@ -1209,7 +1395,7 @@ Content-Type: application/zip\r
1209
1395
  res.on("data", (chunk) => data += chunk);
1210
1396
  res.on("end", () => {
1211
1397
  const { unwrapped, raw } = parseBody(data);
1212
- resolve7({
1398
+ resolve10({
1213
1399
  status: res.statusCode ?? 0,
1214
1400
  data: unwrapped,
1215
1401
  raw
@@ -1265,11 +1451,11 @@ var devCommand = new import_commander2.Command("dev").description("Start local d
1265
1451
  console.log(
1266
1452
  `Starting Vite on ${options.expose ? "0.0.0.0" : "localhost"}:${options.port}...`
1267
1453
  );
1268
- const child = (0, import_child_process.spawn)("npx", args, { stdio: "inherit", shell: true });
1454
+ const child = (0, import_child_process2.spawn)("npx", args, { stdio: "inherit", shell: true });
1269
1455
  let watcher = null;
1270
1456
  if (options.watch) {
1271
1457
  console.log("Starting bundle watcher (vite build --watch)...");
1272
- watcher = (0, import_child_process.spawn)(
1458
+ watcher = (0, import_child_process2.spawn)(
1273
1459
  "npx",
1274
1460
  ["vite", "build", "--watch", "--mode", "development"],
1275
1461
  { stdio: "inherit", shell: true }
@@ -1295,6 +1481,7 @@ var devCommand = new import_commander2.Command("dev").description("Start local d
1295
1481
 
1296
1482
  // src/commands/check.ts
1297
1483
  var import_commander3 = require("commander");
1484
+ var import_child_process3 = require("child_process");
1298
1485
 
1299
1486
  // src/utils/validator.ts
1300
1487
  var fs4 = __toESM(require("fs"));
@@ -1383,27 +1570,134 @@ function validateTheme(themeDir) {
1383
1570
  if (!themeJson.presets || Object.keys(themeJson.presets).length === 0) {
1384
1571
  warnings.push("theme.json has no presets \u2014 merchants will start with an empty page");
1385
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
+ }
1386
1615
  return { valid: errors.length === 0, errors, warnings };
1387
1616
  }
1388
1617
 
1389
1618
  // src/commands/check.ts
1390
- var checkCommand = new import_commander3.Command("check").description("Validate theme schemas and structure").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
1391
- console.log("Validating theme...");
1392
- const result = validateTheme(options.dir);
1393
- if (result.warnings.length > 0) {
1394
- console.log("\nWarnings:");
1395
- result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1396
- }
1397
- if (result.errors.length > 0) {
1398
- console.log("\nErrors:");
1399
- result.errors.forEach((e) => console.log(` \u2717 ${e}`));
1400
- console.log(`
1619
+ var checkCommand = new import_commander3.Command("check").description("Validate theme schemas and structure").option("-d, --dir <directory>", "Theme directory", ".").option(
1620
+ "--lighthouse",
1621
+ "Run a Lighthouse audit (performance + accessibility) \u2014 needs --url"
1622
+ ).option(
1623
+ "--url <url>",
1624
+ "URL of the built theme on a seeded store to audit (e.g. http://localhost:3100/<store>)"
1625
+ ).option("--perf <score>", "Minimum Lighthouse performance score (0-100)", "60").option(
1626
+ "--a11y <score>",
1627
+ "Minimum Lighthouse accessibility score (0-100)",
1628
+ "90"
1629
+ ).option(
1630
+ "--strict",
1631
+ "Exit non-zero when below thresholds (default: soft gate \u2014 report only)"
1632
+ ).action(
1633
+ async (options) => {
1634
+ console.log("Validating theme...");
1635
+ const result = validateTheme(options.dir);
1636
+ if (result.warnings.length > 0) {
1637
+ console.log("\nWarnings:");
1638
+ result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1639
+ }
1640
+ if (result.errors.length > 0) {
1641
+ console.log("\nErrors:");
1642
+ result.errors.forEach((e) => console.log(` \u2717 ${e}`));
1643
+ console.log(`
1401
1644
  Validation failed with ${result.errors.length} error(s)`);
1645
+ process.exit(1);
1646
+ }
1647
+ console.log(`
1648
+ \u2713 Theme is valid (${result.warnings.length} warning(s))`);
1649
+ if (options.lighthouse) {
1650
+ runLighthouse(options);
1651
+ }
1652
+ }
1653
+ );
1654
+ function runLighthouse(options) {
1655
+ if (!options.url) {
1656
+ console.error(
1657
+ "\n\u2718 --lighthouse requires --url <url> (a built theme on a seeded store)."
1658
+ );
1402
1659
  process.exit(1);
1403
1660
  }
1661
+ const perfMin = parseFloat(options.perf);
1662
+ const a11yMin = parseFloat(options.a11y);
1404
1663
  console.log(`
1405
- \u2713 Theme is valid (${result.warnings.length} warning(s))`);
1406
- });
1664
+ Running Lighthouse against ${options.url} ...`);
1665
+ let raw;
1666
+ try {
1667
+ raw = (0, import_child_process3.execSync)(
1668
+ `npx --yes lighthouse "${options.url}" --output=json --output-path=stdout --only-categories=performance,accessibility --chrome-flags="--headless=new --no-sandbox --disable-gpu" --quiet --no-enable-error-reporting`,
1669
+ { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }
1670
+ );
1671
+ } catch (e) {
1672
+ console.warn(
1673
+ "\n\u26A0 Could not run Lighthouse (install it: `npm i -g lighthouse`, and ensure Chrome is available). Soft-skipping."
1674
+ );
1675
+ console.warn(` ${String(e.message).split("\n")[0]}`);
1676
+ return;
1677
+ }
1678
+ let report;
1679
+ try {
1680
+ report = JSON.parse(raw);
1681
+ } catch {
1682
+ console.warn("\n\u26A0 Lighthouse output was not parseable JSON; skipping gate.");
1683
+ return;
1684
+ }
1685
+ const perf = Math.round((report.categories?.performance?.score ?? 0) * 100);
1686
+ const a11y = Math.round((report.categories?.accessibility?.score ?? 0) * 100);
1687
+ console.log(` Performance: ${perf} (min ${perfMin})`);
1688
+ console.log(` Accessibility: ${a11y} (min ${a11yMin})`);
1689
+ const failed = perf < perfMin || a11y < a11yMin;
1690
+ if (!failed) {
1691
+ console.log("\u2713 Lighthouse thresholds met.");
1692
+ return;
1693
+ }
1694
+ const msg = `Lighthouse below threshold (Perf ${perf}/${perfMin}, A11y ${a11y}/${a11yMin}).`;
1695
+ if (options.strict) {
1696
+ console.error(`\u2718 ${msg}`);
1697
+ process.exit(1);
1698
+ }
1699
+ console.warn(`\u26A0 ${msg} (soft gate \u2014 pass --strict to enforce.)`);
1700
+ }
1407
1701
 
1408
1702
  // src/commands/lint.ts
1409
1703
  var import_commander4 = require("commander");
@@ -1425,7 +1719,11 @@ async function runAllRules(themeDir, options) {
1425
1719
  () => Promise.resolve().then(() => (init_inline_color_literal(), inline_color_literal_exports)),
1426
1720
  () => Promise.resolve().then(() => (init_forbidden_script_tag(), forbidden_script_tag_exports)),
1427
1721
  () => Promise.resolve().then(() => (init_use_app_no_availability_check(), use_app_no_availability_check_exports)),
1428
- () => Promise.resolve().then(() => (init_manifest_required_fields(), manifest_required_fields_exports))
1722
+ () => Promise.resolve().then(() => (init_manifest_required_fields(), manifest_required_fields_exports)),
1723
+ () => Promise.resolve().then(() => (init_contrast_hint(), contrast_hint_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))
1429
1727
  ];
1430
1728
  const issues = [];
1431
1729
  for (const load of ruleLoaders) {
@@ -1440,14 +1738,14 @@ async function runAllRules(themeDir, options) {
1440
1738
  });
1441
1739
  continue;
1442
1740
  }
1443
- const rule11 = mod.default;
1444
- if (options.enabledRules && !options.enabledRules.has(rule11.id)) continue;
1741
+ const rule15 = mod.default;
1742
+ if (options.enabledRules && !options.enabledRules.has(rule15.id)) continue;
1445
1743
  try {
1446
- const ruleIssues = await rule11.check(ctx);
1744
+ const ruleIssues = await rule15.check(ctx);
1447
1745
  for (const issue of ruleIssues) issues.push(issue);
1448
1746
  } catch (e) {
1449
1747
  issues.push({
1450
- rule: rule11.id,
1748
+ rule: rule15.id,
1451
1749
  severity: "warning",
1452
1750
  message: `Rule crashed: ${e.message}`
1453
1751
  });
@@ -1467,6 +1765,7 @@ function buildContext(themeDir) {
1467
1765
  const blockSchemas = readSchemaDir(path4.join(themeDir, "schemas", "blocks"));
1468
1766
  const locales = readLocales(path4.join(themeDir, "locales"));
1469
1767
  const sources = readSources(themeDir);
1768
+ const styles = readText(path4.join(themeDir, "styles.css"));
1470
1769
  return {
1471
1770
  themeDir,
1472
1771
  manifest,
@@ -1474,7 +1773,8 @@ function buildContext(themeDir) {
1474
1773
  sectionSchemas,
1475
1774
  blockSchemas,
1476
1775
  locales,
1477
- sources
1776
+ sources,
1777
+ styles
1478
1778
  };
1479
1779
  }
1480
1780
  function readJson(filePath, fallback) {
@@ -1485,6 +1785,14 @@ function readJson(filePath, fallback) {
1485
1785
  return fallback;
1486
1786
  }
1487
1787
  }
1788
+ function readText(filePath) {
1789
+ if (!fs5.existsSync(filePath)) return "";
1790
+ try {
1791
+ return fs5.readFileSync(filePath, "utf-8");
1792
+ } catch {
1793
+ return "";
1794
+ }
1795
+ }
1488
1796
  function readSchemaDir(dir) {
1489
1797
  const out = {};
1490
1798
  if (!fs5.existsSync(dir)) return out;
@@ -1592,45 +1900,171 @@ function groupByFile(issues) {
1592
1900
 
1593
1901
  // src/commands/build.ts
1594
1902
  var import_commander5 = require("commander");
1595
- var import_child_process2 = require("child_process");
1903
+ var import_child_process4 = require("child_process");
1596
1904
  var fs7 = __toESM(require("fs"));
1597
1905
  var path6 = __toESM(require("path"));
1598
- var buildCommand = new import_commander5.Command("build").description("Build the theme for production").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
1599
- console.log("Validating before build...");
1600
- const result = validateTheme(options.dir);
1601
- if (!result.valid) {
1602
- console.error("Validation failed. Fix errors before building.");
1603
- result.errors.forEach((e) => console.error(` \u2717 ${e}`));
1604
- process.exit(1);
1605
- }
1606
- console.log("Building theme...");
1607
- try {
1608
- (0, import_child_process2.execSync)("npx vite build", { cwd: options.dir, stdio: "inherit" });
1609
- } catch {
1610
- console.error("Build failed");
1611
- process.exit(1);
1612
- }
1613
- const distDir = path6.join(options.dir, "dist");
1614
- if (fs7.existsSync(distDir)) {
1615
- let totalSize = 0;
1616
- const files = fs7.readdirSync(distDir, { recursive: true });
1617
- for (const file of files) {
1618
- const filePath = path6.join(distDir, file);
1619
- if (fs7.statSync(filePath).isFile()) totalSize += fs7.statSync(filePath).size;
1906
+ var buildCommand = new import_commander5.Command("build").description("Build the theme for production").option("-d, --dir <directory>", "Theme directory", ".").option(
1907
+ "--strict",
1908
+ "Treat lint warnings as errors (Shopify-style a11y/quality gate)"
1909
+ ).option("--no-lint", "Skip the lint gate (not recommended)").action(
1910
+ async (options) => {
1911
+ console.log("Validating before build...");
1912
+ const result = validateTheme(options.dir);
1913
+ if (!result.valid) {
1914
+ console.error("Validation failed. Fix errors before building.");
1915
+ result.errors.forEach((e) => console.error(` \u2717 ${e}`));
1916
+ process.exit(1);
1620
1917
  }
1621
- const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
1622
- console.log(`
1918
+ if (options.lint !== false) {
1919
+ const issues = await runAllRules(
1920
+ path6.resolve(process.cwd(), options.dir),
1921
+ { enabledRules: null }
1922
+ );
1923
+ const errors = issues.filter((i) => i.severity === "error");
1924
+ const warnings = issues.filter((i) => i.severity === "warning");
1925
+ if (issues.length > 0) {
1926
+ console.log("\nLint:");
1927
+ for (const i of issues) {
1928
+ const marker = i.severity === "error" ? "\u2718" : "\u26A0";
1929
+ const loc = i.file ? ` ${i.file}${i.line ? ":" + i.line : ""}` : "";
1930
+ console.log(` ${marker} ${i.rule}${loc} \u2014 ${i.message}`);
1931
+ }
1932
+ }
1933
+ const blocked = errors.length > 0 || options.strict && warnings.length > 0;
1934
+ if (blocked) {
1935
+ console.error(
1936
+ `
1937
+ Lint gate failed: ${errors.length} error(s), ${warnings.length} warning(s)${options.strict ? " (--strict treats warnings as errors)" : ""}.`
1938
+ );
1939
+ process.exit(1);
1940
+ }
1941
+ if (warnings.length > 0) {
1942
+ console.log(
1943
+ `
1944
+ \u26A0 ${warnings.length} lint warning(s) \u2014 run \`numu-theme lint\` for detail, or \`--strict\` to enforce.`
1945
+ );
1946
+ }
1947
+ }
1948
+ console.log("Building theme...");
1949
+ try {
1950
+ (0, import_child_process4.execSync)("npx vite build", { cwd: options.dir, stdio: "inherit" });
1951
+ } catch {
1952
+ console.error("Build failed");
1953
+ process.exit(1);
1954
+ }
1955
+ const distDir = path6.join(options.dir, "dist");
1956
+ if (fs7.existsSync(distDir)) {
1957
+ let totalSize = 0;
1958
+ const files = fs7.readdirSync(distDir, { recursive: true });
1959
+ for (const file of files) {
1960
+ const filePath = path6.join(distDir, file);
1961
+ if (fs7.statSync(filePath).isFile())
1962
+ totalSize += fs7.statSync(filePath).size;
1963
+ }
1964
+ const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
1965
+ console.log(`
1623
1966
  Bundle size: ${sizeMB} MB`);
1624
- if (totalSize > 5 * 1024 * 1024) {
1625
- console.warn("\u26A0 Bundle exceeds 5MB limit \u2014 optimize before submitting to marketplace");
1967
+ if (totalSize > 5 * 1024 * 1024) {
1968
+ console.warn(
1969
+ "\u26A0 Bundle exceeds 5MB limit \u2014 optimize before submitting to marketplace"
1970
+ );
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
+ }
1626
1981
  }
1982
+ console.log("\n\u2713 Build complete");
1627
1983
  }
1628
- console.log("\n\u2713 Build complete");
1629
- });
1984
+ );
1630
1985
 
1631
- // src/commands/push.ts
1986
+ // src/commands/verify.ts
1632
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");
1633
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"));
1634
2068
  var os2 = __toESM(require("os"));
1635
2069
 
1636
2070
  // src/utils/zipper.ts
@@ -1638,10 +2072,10 @@ var fs8 = __toESM(require("fs"));
1638
2072
  var import_archiver = __toESM(require("archiver"));
1639
2073
  async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
1640
2074
  const opts = Array.isArray(optsOrLegacyExcludes) ? { excludePatterns: optsOrLegacyExcludes } : optsOrLegacyExcludes;
1641
- return new Promise((resolve7, reject) => {
2075
+ return new Promise((resolve10, reject) => {
1642
2076
  const output = fs8.createWriteStream(outputPath);
1643
2077
  const archive = (0, import_archiver.default)("zip", { zlib: { level: 9 } });
1644
- output.on("close", () => resolve7(outputPath));
2078
+ output.on("close", () => resolve10(outputPath));
1645
2079
  archive.on("error", reject);
1646
2080
  archive.pipe(output);
1647
2081
  const defaultExcludes = [
@@ -1663,7 +2097,7 @@ async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
1663
2097
  }
1664
2098
 
1665
2099
  // src/commands/push.ts
1666
- 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) => {
1667
2101
  const config = loadConfig();
1668
2102
  if (!config.token) {
1669
2103
  console.error("Not logged in. Run: numu-theme login");
@@ -1676,7 +2110,7 @@ var pushCommand = new import_commander6.Command("push").description("Upload buil
1676
2110
  process.exit(1);
1677
2111
  }
1678
2112
  console.log("Packaging theme...");
1679
- const zipPath = path7.join(os2.tmpdir(), `numu-theme-${Date.now()}.zip`);
2113
+ const zipPath = path8.join(os2.tmpdir(), `numu-theme-${Date.now()}.zip`);
1680
2114
  await zipDirectory(options.dir, zipPath);
1681
2115
  console.log("Uploading...");
1682
2116
  const res = await uploadFile("/themes/upload", zipPath);
@@ -1697,11 +2131,11 @@ Push failed (${res.status}): ${JSON.stringify(res.data)}`);
1697
2131
  });
1698
2132
 
1699
2133
  // src/commands/submit.ts
1700
- var import_commander7 = require("commander");
1701
- var path8 = __toESM(require("path"));
2134
+ var import_commander8 = require("commander");
2135
+ var path9 = __toESM(require("path"));
1702
2136
  var os3 = __toESM(require("os"));
1703
2137
  var fs9 = __toESM(require("fs"));
1704
- 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(
1705
2139
  "-t, --theme-id <theme_id>",
1706
2140
  "Marketplace theme listing UUID (create via dashboard first)"
1707
2141
  ).option("-n, --notes <notes>", "Release notes for this version").action(
@@ -1724,7 +2158,7 @@ var submitCommand = new import_commander7.Command("submit").description("Submit
1724
2158
  console.log("\nWarnings:");
1725
2159
  result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1726
2160
  }
1727
- const themeJsonPath = path8.join(options.dir, "theme.json");
2161
+ const themeJsonPath = path9.join(options.dir, "theme.json");
1728
2162
  let version;
1729
2163
  try {
1730
2164
  const tj = JSON.parse(fs9.readFileSync(themeJsonPath, "utf-8"));
@@ -1738,7 +2172,7 @@ var submitCommand = new import_commander7.Command("submit").description("Submit
1738
2172
  process.exit(1);
1739
2173
  }
1740
2174
  console.log("\nPackaging theme source...");
1741
- const zipPath = path8.join(
2175
+ const zipPath = path9.join(
1742
2176
  os3.tmpdir(),
1743
2177
  `numu-theme-submit-${Date.now()}.zip`
1744
2178
  );
@@ -1791,12 +2225,12 @@ Submission failed (${submitRes.status}): ${JSON.stringify(submitRes.data)}`
1791
2225
  );
1792
2226
 
1793
2227
  // src/commands/install.ts
1794
- var import_commander8 = require("commander");
1795
- var import_child_process3 = require("child_process");
1796
- 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"));
1797
2231
  var os4 = __toESM(require("os"));
1798
2232
  var fs10 = __toESM(require("fs"));
1799
- var installCommand = new import_commander8.Command("install").description(
2233
+ var installCommand = new import_commander9.Command("install").description(
1800
2234
  "Build + upload + install a theme directly on a store you own (bypasses marketplace review)"
1801
2235
  ).option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
1802
2236
  "-t, --theme-id <theme_id>",
@@ -1834,7 +2268,7 @@ var installCommand = new import_commander8.Command("install").description(
1834
2268
  process.exit(1);
1835
2269
  }
1836
2270
  result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1837
- const themeJsonPath = path9.join(options.dir, "theme.json");
2271
+ const themeJsonPath = path10.join(options.dir, "theme.json");
1838
2272
  let baseVersion;
1839
2273
  try {
1840
2274
  const tj = JSON.parse(fs10.readFileSync(themeJsonPath, "utf-8"));
@@ -1853,7 +2287,7 @@ var installCommand = new import_commander8.Command("install").description(
1853
2287
  console.log(` install tag: ${version}`);
1854
2288
  console.log("Building locally (so worker can skip npm install)...");
1855
2289
  try {
1856
- (0, import_child_process3.execSync)("npm run build", {
2290
+ (0, import_child_process6.execSync)("npm run build", {
1857
2291
  cwd: options.dir,
1858
2292
  stdio: "inherit",
1859
2293
  env: { ...process.env, NODE_ENV: "production" }
@@ -1864,7 +2298,7 @@ var installCommand = new import_commander8.Command("install").description(
1864
2298
  );
1865
2299
  process.exit(1);
1866
2300
  }
1867
- const distEntry = path9.join(options.dir, "dist", "theme.js");
2301
+ const distEntry = path10.join(options.dir, "dist", "theme.js");
1868
2302
  if (!fs10.existsSync(distEntry)) {
1869
2303
  console.error(
1870
2304
  "\nBuild completed but dist/theme.js is missing. Check vite.config.ts entry / output filename."
@@ -1872,7 +2306,7 @@ var installCommand = new import_commander8.Command("install").description(
1872
2306
  process.exit(1);
1873
2307
  }
1874
2308
  console.log("Packaging source + dist...");
1875
- const zipPath = path9.join(
2309
+ const zipPath = path10.join(
1876
2310
  os4.tmpdir(),
1877
2311
  `numu-theme-install-${Date.now()}.zip`
1878
2312
  );
@@ -1986,9 +2420,9 @@ Timed out waiting for build (${options.pollTimeout}s). Run \`numu-theme status -
1986
2420
  );
1987
2421
 
1988
2422
  // src/commands/login.ts
1989
- var import_commander9 = require("commander");
2423
+ var import_commander10 = require("commander");
1990
2424
  var import_inquirer = __toESM(require("inquirer"));
1991
- 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) => {
1992
2426
  if (options.apiUrl) {
1993
2427
  saveConfig({ api_url: options.apiUrl });
1994
2428
  console.log(`API URL set to: ${options.apiUrl}`);
@@ -2035,8 +2469,8 @@ Login failed: ${err.message}`);
2035
2469
  });
2036
2470
 
2037
2471
  // src/commands/status.ts
2038
- var import_commander10 = require("commander");
2039
- 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(
2040
2474
  "--version <version_id>",
2041
2475
  "Marketplace version ID returned by `numu-theme submit`"
2042
2476
  ).option("-w, --watch", "Poll until the build reaches a terminal state").option(
@@ -2163,13 +2597,13 @@ function formatBytes(n) {
2163
2597
  }
2164
2598
 
2165
2599
  // src/commands/doctor.ts
2166
- var import_commander11 = require("commander");
2600
+ var import_commander12 = require("commander");
2167
2601
  var fs11 = __toESM(require("fs"));
2168
- var path10 = __toESM(require("path"));
2169
- 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) => {
2170
2604
  let issues = 0;
2171
2605
  let warnings = 0;
2172
- const themeDir = path10.resolve(options.dir);
2606
+ const themeDir = path11.resolve(options.dir);
2173
2607
  function ok(line) {
2174
2608
  console.log(` \x1B[32m\u2713\x1B[0m ${line}`);
2175
2609
  }
@@ -2182,8 +2616,8 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2182
2616
  console.log(` \x1B[31m\u2717\x1B[0m ${line}`);
2183
2617
  }
2184
2618
  console.log("\nProject");
2185
- const themeJsonPath = path10.join(themeDir, "theme.json");
2186
- 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");
2187
2621
  if (!fs11.existsSync(themeJsonPath)) {
2188
2622
  fail(`No theme.json found in ${themeDir}`);
2189
2623
  console.log(
@@ -2209,21 +2643,53 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2209
2643
  "src/index.ts"
2210
2644
  ];
2211
2645
  const entry = entryCandidates.find(
2212
- (p) => fs11.existsSync(path10.join(themeDir, p))
2646
+ (p) => fs11.existsSync(path11.join(themeDir, p))
2213
2647
  );
2214
2648
  if (!entry) {
2215
2649
  fail(`No entry point found (expected one of: ${entryCandidates.join(", ")})`);
2216
2650
  } else {
2217
- const src = fs11.readFileSync(path10.join(themeDir, entry), "utf-8");
2218
- 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);
2219
2653
  if (exportsMount) ok(`${entry} exports mount(el, props)`);
2220
2654
  else
2221
2655
  fail(
2222
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\`.`
2223
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");
2224
2690
  }
2225
2691
  console.log("\nLocales");
2226
- const localesDir = path10.join(themeDir, "locales");
2692
+ const localesDir = path11.join(themeDir, "locales");
2227
2693
  if (!fs11.existsSync(localesDir)) {
2228
2694
  warn(
2229
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)."
@@ -2240,14 +2706,14 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2240
2706
  }
2241
2707
  for (const f of localeFiles) {
2242
2708
  try {
2243
- JSON.parse(fs11.readFileSync(path10.join(localesDir, f), "utf-8"));
2709
+ JSON.parse(fs11.readFileSync(path11.join(localesDir, f), "utf-8"));
2244
2710
  } catch (err) {
2245
2711
  fail(`locales/${f} is not valid JSON: ${err.message}`);
2246
2712
  }
2247
2713
  }
2248
2714
  }
2249
2715
  console.log("\nAssets");
2250
- const assetsDir = path10.join(themeDir, "assets");
2716
+ const assetsDir = path11.join(themeDir, "assets");
2251
2717
  if (!fs11.existsSync(assetsDir)) {
2252
2718
  warn(
2253
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."
@@ -2268,13 +2734,13 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2268
2734
  fs11.readFileSync(themeJsonPath, "utf-8")
2269
2735
  );
2270
2736
  const presets = themeJson.presets ?? {};
2271
- const sectionsDir = path10.join(themeDir, "src", "sections");
2272
- const schemaDir = path10.join(themeDir, "schemas", "sections");
2737
+ const sectionsDir = path11.join(themeDir, "src", "sections");
2738
+ const schemaDir = path11.join(themeDir, "schemas", "sections");
2273
2739
  const componentNames = fs11.existsSync(sectionsDir) ? new Set(
2274
- 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())
2275
2741
  ) : /* @__PURE__ */ new Set();
2276
2742
  const schemaNames = fs11.existsSync(schemaDir) ? new Set(
2277
- 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())
2278
2744
  ) : /* @__PURE__ */ new Set();
2279
2745
  let missingRefs = 0;
2280
2746
  for (const [presetName, presetVal] of Object.entries(presets)) {
@@ -2305,8 +2771,8 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2305
2771
  warn(`Could not parse theme.json presets: ${err.message}`);
2306
2772
  }
2307
2773
  console.log("\nBuild");
2308
- const distJs = path10.join(themeDir, "dist", "theme.js");
2309
- 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");
2310
2776
  if (!fs11.existsSync(distJs)) {
2311
2777
  warn("dist/theme.js not found \u2014 run `numu-theme build` first");
2312
2778
  } else {
@@ -2322,6 +2788,16 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2322
2788
  } else {
2323
2789
  ok("dist/theme.css present");
2324
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
+ }
2325
2801
  console.log("\nAuth");
2326
2802
  const config = loadConfig();
2327
2803
  if (!config.token) {
@@ -2389,9 +2865,9 @@ var doctorCommand = new import_commander11.Command("doctor").description("Diagno
2389
2865
  });
2390
2866
 
2391
2867
  // src/commands/add-section.ts
2392
- var import_commander12 = require("commander");
2868
+ var import_commander13 = require("commander");
2393
2869
  var fs12 = __toESM(require("fs"));
2394
- var path11 = __toESM(require("path"));
2870
+ var path12 = __toESM(require("path"));
2395
2871
 
2396
2872
  // src/section-library/entries/hero-with-cta.ts
2397
2873
  var heroWithCta = {
@@ -3436,7 +3912,7 @@ function findEntry(slug) {
3436
3912
  }
3437
3913
 
3438
3914
  // src/commands/add-section.ts
3439
- 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(
3440
3916
  "--from-library <slug>",
3441
3917
  "Copy from the built-in section library (run with --list to see options)"
3442
3918
  ).option("--list", "List the built-in section library and exit").option("-d, --dir <directory>", "Theme directory", ".").action(
@@ -3457,8 +3933,8 @@ var addSectionCommand = new import_commander12.Command("add-section").descriptio
3457
3933
  );
3458
3934
  process.exit(1);
3459
3935
  }
3460
- const themeDir = path11.resolve(process.cwd(), options.dir);
3461
- 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"))) {
3462
3938
  console.error(
3463
3939
  "No theme.json in this directory. Run from a theme project root."
3464
3940
  );
@@ -3494,14 +3970,14 @@ var addSectionCommand = new import_commander12.Command("add-section").descriptio
3494
3970
  ]
3495
3971
  };
3496
3972
  }
3497
- const componentPath = path11.join(themeDir, "src/sections", `${pascal}.tsx`);
3973
+ const componentPath = path12.join(themeDir, "src/sections", `${pascal}.tsx`);
3498
3974
  ensureDirOf(componentPath);
3499
3975
  if (fs12.existsSync(componentPath)) {
3500
3976
  console.error(`Already exists: ${componentPath}`);
3501
3977
  process.exit(1);
3502
3978
  }
3503
3979
  fs12.writeFileSync(componentPath, componentSource);
3504
- const schemaPath = path11.join(themeDir, "schemas/sections", `${slug}.json`);
3980
+ const schemaPath = path12.join(themeDir, "schemas/sections", `${slug}.json`);
3505
3981
  ensureDirOf(schemaPath);
3506
3982
  fs12.writeFileSync(schemaPath, JSON.stringify(schemaJson, null, 2));
3507
3983
  tryWireMain(themeDir, slug, pascal);
@@ -3525,7 +4001,7 @@ function humanize(slug) {
3525
4001
  return slug.split("-").filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join(" ");
3526
4002
  }
3527
4003
  function ensureDirOf(p) {
3528
- fs12.mkdirSync(path11.dirname(p), { recursive: true });
4004
+ fs12.mkdirSync(path12.dirname(p), { recursive: true });
3529
4005
  }
3530
4006
  function emptySectionStub(pascal) {
3531
4007
  return `import type { SectionProps } from "@numueg/theme-sdk";
@@ -3543,7 +4019,7 @@ export default function ${pascal}({ settings }: SectionProps) {
3543
4019
  `;
3544
4020
  }
3545
4021
  function tryWireMain(themeDir, slug, pascal) {
3546
- const mainPath = path11.join(themeDir, "src/main.tsx");
4022
+ const mainPath = path12.join(themeDir, "src/main.tsx");
3547
4023
  if (!fs12.existsSync(mainPath)) return;
3548
4024
  const src = fs12.readFileSync(mainPath, "utf-8");
3549
4025
  if (src.includes(`./sections/${pascal}`)) return;
@@ -3584,7 +4060,7 @@ function tryWireMain(themeDir, slug, pascal) {
3584
4060
  fs12.writeFileSync(mainPath, lines.join("\n"));
3585
4061
  }
3586
4062
  function tryAddToHomePreset(themeDir, slug) {
3587
- const themeJsonPath = path11.join(themeDir, "theme.json");
4063
+ const themeJsonPath = path12.join(themeDir, "theme.json");
3588
4064
  if (!fs12.existsSync(themeJsonPath)) return;
3589
4065
  let parsed;
3590
4066
  try {
@@ -3611,19 +4087,19 @@ function tryAddToHomePreset(themeDir, slug) {
3611
4087
  }
3612
4088
 
3613
4089
  // src/commands/add-block.ts
3614
- var import_commander13 = require("commander");
4090
+ var import_commander14 = require("commander");
3615
4091
  var fs13 = __toESM(require("fs"));
3616
- var path12 = __toESM(require("path"));
3617
- 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) => {
3618
- const themeDir = path12.resolve(options.dir);
3619
- 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"))) {
3620
4096
  console.error(`No theme.json in ${themeDir}.`);
3621
4097
  process.exit(1);
3622
4098
  }
3623
4099
  const sectionSnake = toSnakeCase(section);
3624
4100
  const blockSnake = toSnakeCase(name);
3625
4101
  const blockHuman = humanize2(toPascalCase(name));
3626
- const schemaPath = path12.join(
4102
+ const schemaPath = path13.join(
3627
4103
  themeDir,
3628
4104
  "schemas",
3629
4105
  "sections",
@@ -3666,7 +4142,7 @@ var addBlockCommand = new import_commander13.Command("add-block").description("S
3666
4142
  `
3667
4143
  \x1B[32m\u2713\x1B[0m Added block "${blockSnake}" to section "${sectionSnake}":`,
3668
4144
  `
3669
- - ${path12.relative(themeDir, schemaPath)}`,
4145
+ - ${path13.relative(themeDir, schemaPath)}`,
3670
4146
  `
3671
4147
 
3672
4148
  \x1B[33m\u26A0\x1B[0m The section component should iterate \`blockOrder\` and render each block by type.`,
@@ -3698,17 +4174,17 @@ function humanize2(name) {
3698
4174
  }
3699
4175
 
3700
4176
  // src/commands/pull.ts
3701
- var import_commander14 = require("commander");
4177
+ var import_commander15 = require("commander");
3702
4178
  var fs14 = __toESM(require("fs"));
3703
- var path13 = __toESM(require("path"));
4179
+ var path14 = __toESM(require("path"));
3704
4180
  var https2 = __toESM(require("https"));
3705
4181
  var http2 = __toESM(require("http"));
3706
4182
  async function downloadToFile(url, dest) {
3707
- return new Promise((resolve7, reject) => {
4183
+ return new Promise((resolve10, reject) => {
3708
4184
  const client = url.startsWith("https:") ? https2 : http2;
3709
4185
  const req = client.get(url, (res) => {
3710
4186
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
3711
- downloadToFile(res.headers.location, dest).then(resolve7, reject);
4187
+ downloadToFile(res.headers.location, dest).then(resolve10, reject);
3712
4188
  return;
3713
4189
  }
3714
4190
  if (res.statusCode !== 200) {
@@ -3717,7 +4193,7 @@ async function downloadToFile(url, dest) {
3717
4193
  }
3718
4194
  const out = fs14.createWriteStream(dest);
3719
4195
  res.pipe(out);
3720
- out.on("finish", () => out.close(() => resolve7()));
4196
+ out.on("finish", () => out.close(() => resolve10()));
3721
4197
  out.on("error", reject);
3722
4198
  });
3723
4199
  req.on("error", reject);
@@ -3736,7 +4212,7 @@ async function unzip(zipPath, targetDir) {
3736
4212
  });
3737
4213
  }
3738
4214
  }
3739
- 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(
3740
4216
  "-d, --dir <directory>",
3741
4217
  "Target directory (default: ./<theme-id>)"
3742
4218
  ).option(
@@ -3744,7 +4220,7 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
3744
4220
  "Specific version_string (default: latest)"
3745
4221
  ).option("--force", "Overwrite the target directory if it exists").action(
3746
4222
  async (themeId, options) => {
3747
- const targetDir = path13.resolve(options.dir || themeId);
4223
+ const targetDir = path14.resolve(options.dir || themeId);
3748
4224
  if (fs14.existsSync(targetDir) && !options.force) {
3749
4225
  const isEmpty = fs14.readdirSync(targetDir).length === 0;
3750
4226
  if (!isEmpty) {
@@ -3791,7 +4267,7 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
3791
4267
  process.exit(1);
3792
4268
  }
3793
4269
  console.log(`Downloading ${theme.name || themeId} ${target.version_string}\u2026`);
3794
- const tmpZip = path13.join(
4270
+ const tmpZip = path14.join(
3795
4271
  require("os").tmpdir(),
3796
4272
  `numu-pull-${process.pid}-${Date.now()}.zip`
3797
4273
  );
@@ -3808,28 +4284,28 @@ var pullCommand = new import_commander14.Command("pull").description("Download a
3808
4284
  console.log(`\u2714 Pulled ${theme.slug || themeId}@${target.version_string} into ${targetDir}`);
3809
4285
  console.log("");
3810
4286
  console.log("Next steps:");
3811
- console.log(` cd ${path13.relative(process.cwd(), targetDir) || "."}`);
4287
+ console.log(` cd ${path14.relative(process.cwd(), targetDir) || "."}`);
3812
4288
  console.log(" npm install");
3813
4289
  console.log(" numu-theme dev");
3814
4290
  }
3815
4291
  );
3816
4292
 
3817
4293
  // src/commands/delete.ts
3818
- var import_commander15 = require("commander");
4294
+ var import_commander16 = require("commander");
3819
4295
  var readline = __toESM(require("readline"));
3820
4296
  async function confirm(prompt) {
3821
4297
  const rl = readline.createInterface({
3822
4298
  input: process.stdin,
3823
4299
  output: process.stdout
3824
4300
  });
3825
- return new Promise((resolve7) => {
4301
+ return new Promise((resolve10) => {
3826
4302
  rl.question(`${prompt} [y/N] `, (answer) => {
3827
4303
  rl.close();
3828
- resolve7(/^y(es)?$/i.test(answer.trim()));
4304
+ resolve10(/^y(es)?$/i.test(answer.trim()));
3829
4305
  });
3830
4306
  });
3831
4307
  }
3832
- 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(
3833
4309
  async (themeId, options) => {
3834
4310
  const isVersion = Boolean(options.version);
3835
4311
  const scope = isVersion ? `version ${options.version} of ${themeId}` : `the entire theme ${themeId}`;
@@ -3842,8 +4318,8 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
3842
4318
  return;
3843
4319
  }
3844
4320
  }
3845
- const path14 = isVersion ? `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions/${encodeURIComponent(options.version)}` : `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`;
3846
- const res = await apiRequest("DELETE", path14);
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);
3847
4323
  if (res.status === 200 || res.status === 204) {
3848
4324
  console.log(`\u2714 Deleted ${scope}.`);
3849
4325
  return;
@@ -3861,14 +4337,780 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
3861
4337
  }
3862
4338
  );
3863
4339
 
4340
+ // src/commands/migrate.ts
4341
+ var import_commander17 = require("commander");
4342
+ var fs15 = __toESM(require("fs"));
4343
+ var path15 = __toESM(require("path"));
4344
+ var import_chalk = __toESM(require("chalk"));
4345
+ var V2_HOOK_PATTERNS = [
4346
+ {
4347
+ pattern: /from\s+["']@\/contexts\/StoreContext["']/,
4348
+ v2: "useStore (bazaar)",
4349
+ v3: "useShop() from @numueg/theme-sdk",
4350
+ note: "Returns Store directly; bazaar's useStore returns { store, themeSettings, ... } \u2014 destructure differently."
4351
+ },
4352
+ {
4353
+ pattern: /useProductsContext|from\s+["']@\/contexts\/ProductsContext["']/,
4354
+ v2: "useProductsContext (bazaar)",
4355
+ v3: "useProducts() from @numueg/theme-sdk",
4356
+ note: "V3 hook is paginated + has loading state. Shape: { items, loading, error, hasMore, loadMore }. V2 returned flat array."
4357
+ },
4358
+ {
4359
+ pattern: /useCart\s*\(\s*\)/,
4360
+ v2: "useCart (bazaar)",
4361
+ v3: "useCart() from @numueg/theme-sdk",
4362
+ note: "Same name but different shape \u2014 SDK returns { cart, addItem, removeItem, ... } with async actions. Bazaar's was sync."
4363
+ },
4364
+ {
4365
+ pattern: /useAuth\s*\(\s*\)/,
4366
+ v2: "useAuth (bazaar)",
4367
+ v3: "useCustomer() + useCustomerActions() from @numueg/theme-sdk",
4368
+ note: "Customer state is split from action handlers in V3 to avoid render-on-every-action."
4369
+ },
4370
+ {
4371
+ pattern: /useTheme\s*\(\s*\)|from\s+["']@\/contexts\/ThemeContext["']/,
4372
+ v2: "useTheme (bazaar)",
4373
+ v3: "useThemeSettings() from @numueg/theme-sdk",
4374
+ note: "Returns ThemeSettingsV3; access settings via .global_settings or .templates[<page>].sections[<id>].settings."
4375
+ },
4376
+ {
4377
+ pattern: /useLanguage\s*\(/,
4378
+ v2: "useLanguage (bazaar)",
4379
+ v3: "useLocalization() / useDirection() from @numueg/theme-sdk",
4380
+ note: "Returns { locale, direction, translations, ... }. Use useTranslation() for resolving message keys."
4381
+ },
4382
+ {
4383
+ pattern: /editable\.section\s*\(/,
4384
+ v2: "editable.section() helper",
4385
+ v3: "<EditableText> / <EditableImage> from @numueg/theme-sdk",
4386
+ note: "V3 marks editable nodes via dedicated components instead of spread props."
4387
+ }
4388
+ ];
4389
+ function migrationNotesFor(source) {
4390
+ const notes = [];
4391
+ const lines = source.split("\n");
4392
+ for (const { pattern, v2, v3, note } of V2_HOOK_PATTERNS) {
4393
+ for (let i = 0; i < lines.length; i++) {
4394
+ if (pattern.test(lines[i])) {
4395
+ notes.push({ v2, v3, note, lineHint: i + 1 });
4396
+ break;
4397
+ }
4398
+ }
4399
+ }
4400
+ return notes;
4401
+ }
4402
+ function adapterCommentBlock(notes, v2RelativePath) {
4403
+ const lines = [
4404
+ "/**",
4405
+ " * \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501",
4406
+ " * V3 PORT \u2014 ADAPTER NOTES",
4407
+ " * \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501",
4408
+ " *",
4409
+ ` * Source: ${v2RelativePath}`,
4410
+ " *",
4411
+ " * Generated by `numu-theme migrate`. Review and rewrite using V3",
4412
+ " * SDK hooks before publishing. The list below was inferred from",
4413
+ " * static analysis \u2014 there may be other V2 idioms not detected.",
4414
+ " *"
4415
+ ];
4416
+ if (notes.length === 0) {
4417
+ lines.push(" * No V2-specific hooks detected. You may only need to:");
4418
+ lines.push(" * 1. Replace any bazaar-specific imports with @numueg/theme-sdk equivalents.");
4419
+ lines.push(" * 2. Wrap your section's root element with <Section id={...} type={...}> from the SDK.");
4420
+ lines.push(" * 3. Read settings from `props.instance.settings` (V3 SectionInstance shape).");
4421
+ } else {
4422
+ lines.push(" * Required swaps:");
4423
+ lines.push(" *");
4424
+ for (const n of notes) {
4425
+ const where = n.lineHint ? ` (line ${n.lineHint})` : "";
4426
+ lines.push(` * \u2022 ${n.v2}${where}`);
4427
+ lines.push(` * \u2192 ${n.v3}`);
4428
+ lines.push(` * ${n.note}`);
4429
+ lines.push(" *");
4430
+ }
4431
+ }
4432
+ lines.push(" * When done, remove this block.");
4433
+ lines.push(" * \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
4434
+ lines.push(" */");
4435
+ return lines.join("\n");
4436
+ }
4437
+ function findSectionFiles(dir) {
4438
+ if (!fs15.existsSync(dir)) return [];
4439
+ const out = [];
4440
+ for (const entry of fs15.readdirSync(dir, { withFileTypes: true })) {
4441
+ const full = path15.join(dir, entry.name);
4442
+ if (entry.isDirectory()) out.push(...findSectionFiles(full));
4443
+ else if (entry.isFile() && /\.(tsx|ts)$/.test(entry.name)) out.push(full);
4444
+ }
4445
+ return out;
4446
+ }
4447
+ function sectionTypeFromFilename(filename) {
4448
+ const base = path15.basename(filename).replace(/\.(tsx|ts)$/, "");
4449
+ return base.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9-]+/g, "-").toLowerCase();
4450
+ }
4451
+ function makeV2Bridge() {
4452
+ return `/**
4453
+ * v2-bridge \u2014 per-theme compat shim for V2 sections ported via
4454
+ * \`numu-theme migrate\`. DELETE this file once each section has been
4455
+ * rewritten to use idiomatic V3 SDK hooks + components.
4456
+ */
4457
+
4458
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
4459
+ import {
4460
+ useV2Products,
4461
+ useV2Categories,
4462
+ useV2Auth,
4463
+ useV2Language,
4464
+ useV2Theme,
4465
+ } from "@numueg/theme-sdk/v2-compat";
4466
+ import { ProductCard, useThemeSettings } from "@numueg/theme-sdk";
4467
+ import type { SectionInstance } from "@numueg/theme-sdk";
4468
+
4469
+ // \u2500\u2500 Re-shaped V3 hooks under V2 names \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
4470
+
4471
+ /** V2's \`useProducts\` returned \`{ products, loading }\`. Same shape. */
4472
+ export function useProducts() {
4473
+ return useV2Products();
4474
+ }
4475
+
4476
+ /** V2's \`useCategories\` returned \`{ categories, loading }\`. Same shape. */
4477
+ export function useCategories() {
4478
+ return useV2Categories();
4479
+ }
4480
+
4481
+ /** V2's \`useAuth\` returned \`{ user, isAuthenticated }\`. */
4482
+ export function useAuth() {
4483
+ return useV2Auth();
4484
+ }
4485
+
4486
+ /** V2's \`useLanguage\` returned \`{ language, direction, setLanguage, t }\`. */
4487
+ export function useLanguage() {
4488
+ return useV2Language();
4489
+ }
4490
+
4491
+ /** V2's \`useStore\` returned the full store config; here we expose
4492
+ * only the parts ported sections use (themeSettings nested). */
4493
+ export function useStore() {
4494
+ return useV2Theme();
4495
+ }
4496
+
4497
+ // \u2500\u2500 V2 type re-shaping \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
4498
+
4499
+ /**
4500
+ * V2 sections expected this prop shape:
4501
+ *
4502
+ * interface SectionComponentProps {
4503
+ * section: { id: string; type: string; settings: Record<string, any>; ... };
4504
+ * ...
4505
+ * }
4506
+ *
4507
+ * V3 mount passes \`{ instance: SectionInstance }\`. We re-export a
4508
+ * compatible interface so existing destructure patterns like
4509
+ * \`const { section } = props\` keep compiling \u2014 the wrapper below
4510
+ * coerces \`instance\` \u2192 \`section\` at render time.
4511
+ */
4512
+ export interface SectionComponentProps {
4513
+ section: SectionInstance & { id?: string };
4514
+ }
4515
+
4516
+ /**
4517
+ * Section adapter. Migrate-generated sections still expect to be
4518
+ * called with \`{ section }\`; the V3 \`SECTION_REGISTRY\` calls them
4519
+ * with \`{ instance }\`. Wrap each export:
4520
+ *
4521
+ * export default v2Section(YourComponent);
4522
+ */
4523
+ export function v2Section<P extends SectionComponentProps>(
4524
+ Component: (props: P) => ReactNode,
4525
+ ): (props: { instance: SectionInstance }) => ReactNode {
4526
+ return function V2SectionAdapter({ instance }) {
4527
+ const props = { section: instance } as unknown as P;
4528
+ return Component(props);
4529
+ };
4530
+ }
4531
+
4532
+ // \u2500\u2500 editable.section() compat \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
4533
+
4534
+ /**
4535
+ * V2's editable helper spread DOM props on individual nodes so the
4536
+ * old V2 customizer could click-select them. V3 uses \`<EditableText>\`
4537
+ * / \`<EditableImage>\` for the same job. Returning an empty object
4538
+ * here means the spread is a no-op \u2014 the section still renders, just
4539
+ * without the inline click-to-edit affordance. Replace with the V3
4540
+ * components when polishing the port.
4541
+ */
4542
+ export const editable = {
4543
+ section: (_sectionId: string, _key: string) => ({}),
4544
+ block: (_blockId: string, _key: string) => ({}),
4545
+ };
4546
+
4547
+ // \u2500\u2500 Misc re-exports the V2 sections often imported \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4548
+
4549
+ export { ProductCard, useThemeSettings };
4550
+
4551
+ /**
4552
+ * V2's \`<Link to="/...">\` came from react-router-dom. The V3 storefront
4553
+ * is Next.js, where \`<a href="/...">\` works (Next intercepts internal
4554
+ * navigation). We expose a polyfill so existing JSX renders without
4555
+ * react-router-dom installed. Pass-through any extra props.
4556
+ */
4557
+ export function Link({
4558
+ to,
4559
+ children,
4560
+ ...rest
4561
+ }: { to: string; children: ReactNode } & Omit<
4562
+ ComponentPropsWithoutRef<"a">,
4563
+ "href"
4564
+ >) {
4565
+ return (
4566
+ <a href={to} {...rest}>
4567
+ {children}
4568
+ </a>
4569
+ );
4570
+ }
4571
+
4572
+ /** V2's image placeholder constant. */
4573
+ export const PLACEHOLDER_HERO =
4574
+ "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1200&q=60";
4575
+ `;
4576
+ }
4577
+ function rewriteV2Imports(source) {
4578
+ const PATH_REWRITES = [
4579
+ // The most-common bazaar context imports.
4580
+ [/@\/contexts\/ProductsContext/g, "../v2-bridge"],
4581
+ [/@\/contexts\/AuthContext/g, "../v2-bridge"],
4582
+ [/@\/contexts\/LanguageContext/g, "../v2-bridge"],
4583
+ [/@\/contexts\/StoreContext/g, "../v2-bridge"],
4584
+ [/@\/contexts\/ThemeContext/g, "../v2-bridge"],
4585
+ // V2 engine types + editable helper.
4586
+ [/@\/themes\/engine\/types/g, "../v2-bridge"],
4587
+ [/@\/themes\/engine\/editable/g, "../v2-bridge"],
4588
+ // bazaar's shared ProductCard component.
4589
+ [/@\/components\/store\/ProductCard/g, "../v2-bridge"],
4590
+ // The image placeholder constant.
4591
+ [/@\/lib\/imagePlaceholders/g, "../v2-bridge"],
4592
+ // react-router-dom → bridge (Link polyfill). Only the named
4593
+ // imports we know about; the bridge exports `Link`. Sections that
4594
+ // need other react-router-dom APIs will fail at build time, which
4595
+ // is the right outcome — they need manual conversion.
4596
+ [/(['"])react-router-dom\1/g, '"../v2-bridge"']
4597
+ ];
4598
+ let out = source;
4599
+ for (const [pattern, replacement] of PATH_REWRITES) {
4600
+ out = out.replace(pattern, replacement);
4601
+ }
4602
+ return out;
4603
+ }
4604
+ function makeThemeJson(themeId, displayName, sectionTypes) {
4605
+ return {
4606
+ id: themeId,
4607
+ name: displayName,
4608
+ author: "",
4609
+ version: "0.1.0",
4610
+ layout: "single-column",
4611
+ description: `${displayName} \u2014 migrated from V2.`,
4612
+ error_template: "templates/error.html",
4613
+ loading_template: "templates/loading.html",
4614
+ presets: {
4615
+ templates: {
4616
+ home: {
4617
+ name: "Home",
4618
+ sections: sectionTypes.map((type) => ({
4619
+ type,
4620
+ settings: {}
4621
+ }))
4622
+ }
4623
+ }
4624
+ }
4625
+ };
4626
+ }
4627
+ function makeSettingsSchema() {
4628
+ return [
4629
+ {
4630
+ name: "Brand",
4631
+ settings: [
4632
+ {
4633
+ type: "color",
4634
+ id: "primary_color",
4635
+ label: "Primary color",
4636
+ default: "#111111"
4637
+ },
4638
+ {
4639
+ type: "color",
4640
+ id: "accent_color",
4641
+ label: "Accent color",
4642
+ default: "#d4af37"
4643
+ }
4644
+ ]
4645
+ }
4646
+ ];
4647
+ }
4648
+ function makeMainTsx(themeId, displayName, sectionTypes) {
4649
+ const registryEntries = sectionTypes.map((type) => ` "${type}": lazy(() => import("./sections/${type}")),`).join("\n");
4650
+ return `/**
4651
+ * ${displayName} (V3) \u2014 entry point.
4652
+ *
4653
+ * Generated by \`numu-theme migrate\`. Section components live in
4654
+ * src/sections/<type>.tsx and are lazy-loaded so only sections the
4655
+ * merchant actually uses pay the bundle cost.
4656
+ */
4657
+
4658
+ import {
4659
+ StrictMode,
4660
+ lazy,
4661
+ Suspense,
4662
+ useImperativeHandle,
4663
+ useRef,
4664
+ useState,
4665
+ forwardRef,
4666
+ } from "react";
4667
+ import { createRoot, type Root } from "react-dom/client";
4668
+ import {
4669
+ NuMuProvider,
4670
+ Section,
4671
+ useThemeSettings,
4672
+ type ThemeSettingsV3,
4673
+ type Store,
4674
+ type Cart,
4675
+ type Customer,
4676
+ type SectionInstance,
4677
+ type MountResult,
4678
+ } from "@numueg/theme-sdk";
4679
+ import themeManifest from "../theme.json";
4680
+
4681
+ const SECTION_REGISTRY: Record<string, ReturnType<typeof lazy>> = {
4682
+ ${registryEntries}
4683
+ };
4684
+
4685
+ function UnknownSection({ type }: { type: string }) {
4686
+ return (
4687
+ <section style={{ padding: "1rem", border: "1px dashed #fb923c" }}>
4688
+ Unknown section: <strong>{type}</strong>
4689
+ </section>
4690
+ );
4691
+ }
4692
+
4693
+ interface ResolvedSection {
4694
+ id: string;
4695
+ instance: SectionInstance;
4696
+ }
4697
+
4698
+ interface MaybeOrdered {
4699
+ sections?: Record<string, SectionInstance> | SectionInstance[];
4700
+ order?: string[];
4701
+ }
4702
+
4703
+ function resolveSections(group: MaybeOrdered | undefined): ResolvedSection[] {
4704
+ if (!group) return [];
4705
+ if (Array.isArray(group.sections)) {
4706
+ return group.sections.map((instance, idx) => ({
4707
+ id: \`\${instance.type}-\${idx}\`,
4708
+ instance,
4709
+ }));
4710
+ }
4711
+ const map = (group.sections ?? {}) as Record<string, SectionInstance>;
4712
+ const order = group.order ?? Object.keys(map);
4713
+ return order
4714
+ .map((id): ResolvedSection | null => {
4715
+ const instance = map[id];
4716
+ if (!instance) return null;
4717
+ return { id, instance };
4718
+ })
4719
+ .filter((x): x is ResolvedSection => Boolean(x));
4720
+ }
4721
+
4722
+ const BUILTIN_HOME = (themeManifest as unknown as { presets?: { templates?: { home?: MaybeOrdered } } })
4723
+ .presets?.templates?.home;
4724
+
4725
+ function RenderSection({
4726
+ instance,
4727
+ sectionId,
4728
+ groupId,
4729
+ }: {
4730
+ instance: SectionInstance;
4731
+ sectionId: string;
4732
+ groupId?: string;
4733
+ }) {
4734
+ const Component = SECTION_REGISTRY[instance.type];
4735
+ if (!Component) {
4736
+ return (
4737
+ <Section id={sectionId} type={instance.type} groupId={groupId}>
4738
+ <UnknownSection type={instance.type} />
4739
+ </Section>
4740
+ );
4741
+ }
4742
+ return (
4743
+ <Section id={sectionId} type={instance.type} groupId={groupId}>
4744
+ <Suspense fallback={<div style={{ minHeight: "20vh" }} />}>
4745
+ <Component instance={instance} />
4746
+ </Suspense>
4747
+ </Section>
4748
+ );
4749
+ }
4750
+
4751
+ function ThemeApp() {
4752
+ const settings = useThemeSettings();
4753
+ const hostHome = settings.templates?.home as MaybeOrdered | undefined;
4754
+ const sections = resolveSections(hostHome ?? BUILTIN_HOME);
4755
+ return (
4756
+ <div data-${themeId}-app>
4757
+ {sections.map(({ id, instance }) => (
4758
+ <RenderSection key={id} sectionId={id} instance={instance} />
4759
+ ))}
4760
+ </div>
4761
+ );
4762
+ }
4763
+
4764
+ // \u2500\u2500 Host contract: mount(el, ctx) returns a MountResult \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4765
+
4766
+ export interface MountContext {
4767
+ store: Store;
4768
+ themeSettings: ThemeSettingsV3;
4769
+ initialCart?: Cart;
4770
+ customer?: Customer | null;
4771
+ locale?: string;
4772
+ translations?: Record<string, string>;
4773
+ [extra: string]: unknown;
4774
+ }
4775
+
4776
+ interface DraftHandle {
4777
+ applyDraft: (next: ThemeSettingsV3) => void;
4778
+ }
4779
+
4780
+ const ThemeSettingsBridge = forwardRef<DraftHandle, { ctx: MountContext }>(
4781
+ function ThemeSettingsBridge({ ctx }, ref) {
4782
+ const [themeSettings, setThemeSettings] = useState<ThemeSettingsV3>(
4783
+ ctx.themeSettings,
4784
+ );
4785
+ useImperativeHandle(
4786
+ ref,
4787
+ () => ({
4788
+ applyDraft: (next) => setThemeSettings((prev) => (prev === next ? prev : next)),
4789
+ }),
4790
+ [],
4791
+ );
4792
+ return (
4793
+ <NuMuProvider
4794
+ store={ctx.store}
4795
+ themeSettings={themeSettings}
4796
+ initialCart={ctx.initialCart}
4797
+ customer={ctx.customer}
4798
+ locale={ctx.locale}
4799
+ translations={ctx.translations}
4800
+ >
4801
+ <ThemeApp />
4802
+ </NuMuProvider>
4803
+ );
4804
+ },
4805
+ );
4806
+
4807
+ let currentRoot: Root | null = null;
4808
+
4809
+ export function mount(el: HTMLElement, ctx: MountContext): MountResult {
4810
+ if (currentRoot) {
4811
+ currentRoot.unmount();
4812
+ currentRoot = null;
4813
+ }
4814
+ const root = createRoot(el);
4815
+ currentRoot = root;
4816
+ const handleRef = { current: null as DraftHandle | null };
4817
+ root.render(
4818
+ <StrictMode>
4819
+ <ThemeSettingsBridge
4820
+ ctx={ctx}
4821
+ ref={(h) => {
4822
+ handleRef.current = h;
4823
+ }}
4824
+ />
4825
+ </StrictMode>,
4826
+ );
4827
+ return {
4828
+ applyDraft: (next) => handleRef.current?.applyDraft(next),
4829
+ cleanup: () => {
4830
+ root.unmount();
4831
+ if (currentRoot === root) currentRoot = null;
4832
+ handleRef.current = null;
4833
+ },
4834
+ };
4835
+ }
4836
+
4837
+ const v3Handle = {
4838
+ kind: "v3-mount" as const,
4839
+ numu_theme_version: 3 as const,
4840
+ mount_returns: "MountResult" as const,
4841
+ manifest: { id: "${themeId}", name: "${displayName}", version: "0.1.0" },
4842
+ mount,
4843
+ };
4844
+ export default v3Handle;
4845
+ `;
4846
+ }
4847
+ function makeViteConfig() {
4848
+ return `import { defineConfig, type PluginOption } from "vite";
4849
+ import react from "@vitejs/plugin-react";
4850
+ import { numuTheme } from "@numueg/theme-plugin";
4851
+
4852
+ export default defineConfig({
4853
+ plugins: [react(), numuTheme({ federate: false }) as unknown as PluginOption],
4854
+ server: { port: 5173 },
4855
+ });
4856
+ `;
4857
+ }
4858
+ function makePackageJson(themeId, displayName) {
4859
+ return {
4860
+ name: themeId,
4861
+ version: "0.1.0",
4862
+ private: true,
4863
+ description: `${displayName} (V3 port)`,
4864
+ type: "module",
4865
+ scripts: {
4866
+ dev: "vite",
4867
+ build: "vite build",
4868
+ preview: "vite preview --port 5173"
4869
+ },
4870
+ dependencies: {
4871
+ react: "^18.3.1",
4872
+ "react-dom": "^18.3.1",
4873
+ "@numueg/theme-sdk": "^0.1.0",
4874
+ // V2 sections commonly import these — including them in deps so
4875
+ // `npm install` works without manual edits. The bundle externalises
4876
+ // react/react-dom/@numueg/theme-sdk via federation; framer-motion
4877
+ // and lucide-react ship as part of the theme bundle (~30-40 KB
4878
+ // gzip combined). Themes that prove they don't need them can
4879
+ // remove these after polishing.
4880
+ "framer-motion": "^11.11.0",
4881
+ "lucide-react": "^0.454.0"
4882
+ },
4883
+ devDependencies: {
4884
+ "@numueg/theme-plugin": "^0.1.0",
4885
+ "@types/react": "^18.3.0",
4886
+ "@types/react-dom": "^18.3.0",
4887
+ "@vitejs/plugin-react": "^4.3.0",
4888
+ typescript: "^5.5.0",
4889
+ vite: "^6.0.0"
4890
+ }
4891
+ };
4892
+ }
4893
+ function makeTsConfig() {
4894
+ return {
4895
+ compilerOptions: {
4896
+ target: "ES2022",
4897
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
4898
+ module: "ESNext",
4899
+ moduleResolution: "Bundler",
4900
+ jsx: "react-jsx",
4901
+ strict: true,
4902
+ noImplicitAny: true,
4903
+ strictNullChecks: true,
4904
+ esModuleInterop: true,
4905
+ resolveJsonModule: true,
4906
+ skipLibCheck: true,
4907
+ isolatedModules: true,
4908
+ allowSyntheticDefaultImports: true,
4909
+ forceConsistentCasingInFileNames: true
4910
+ },
4911
+ include: ["src", "theme.json"]
4912
+ };
4913
+ }
4914
+ function makeIndexHtml(displayName) {
4915
+ return `<!doctype html>
4916
+ <html lang="en">
4917
+ <head>
4918
+ <meta charset="UTF-8" />
4919
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4920
+ <title>${displayName} \u2014 dev preview</title>
4921
+ <link rel="stylesheet" href="/styles.css" />
4922
+ </head>
4923
+ <body>
4924
+ <div id="root"></div>
4925
+ <script type="module" src="/src/main.tsx"></script>
4926
+ </body>
4927
+ </html>
4928
+ `;
4929
+ }
4930
+ function makeTemplates() {
4931
+ return {
4932
+ error: `<!-- Static BYOT error template -->
4933
+ <main role="alert" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui">
4934
+ <div style="text-align:center;max-width:28rem">
4935
+ <h1 style="font-size:1.5rem;font-weight:700;color:#b91c1c">Something went wrong</h1>
4936
+ <p style="color:#374151;margin-top:.5rem">Please try again in a moment.</p>
4937
+ <button data-numu-reset type="button" style="margin-top:1.5rem;padding:.5rem 1rem;background:#1d4ed8;color:white;border-radius:.375rem;border:0;cursor:pointer">Try again</button>
4938
+ </div>
4939
+ </main>
4940
+ `,
4941
+ loading: `<!-- Static BYOT loading skeleton -->
4942
+ <div role="status" aria-live="polite" aria-label="Loading" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui">
4943
+ <span style="font-size:.875rem;font-weight:500;color:#4b5563">Loading\u2026</span>
4944
+ </div>
4945
+ `
4946
+ };
4947
+ }
4948
+ var migrateCommand = new import_commander17.Command("migrate").description("Scaffold a V3 theme project from a V2 theme directory").argument(
4949
+ "<v2-path>",
4950
+ "Path to the V2 theme directory (e.g. ../numu-egyptian-bazaar/src/themes/empire)"
4951
+ ).option(
4952
+ "--out <dir>",
4953
+ "Output directory (default: ./<v2-id>-engine-V3)"
4954
+ ).option(
4955
+ "--name <displayName>",
4956
+ "Override the display name (default: title-cased v2 id)"
4957
+ ).action(
4958
+ async (v2Path, options) => {
4959
+ const absV2Path = path15.resolve(process.cwd(), v2Path);
4960
+ if (!fs15.existsSync(absV2Path) || !fs15.statSync(absV2Path).isDirectory()) {
4961
+ console.error(import_chalk.default.red(`V2 path does not exist or is not a directory: ${absV2Path}`));
4962
+ process.exit(1);
4963
+ }
4964
+ const v2Id = path15.basename(absV2Path).toLowerCase().replace(/[^a-z0-9-]/g, "-");
4965
+ const themeId = `${v2Id}-v3`;
4966
+ const displayName = options.name ?? v2Id.charAt(0).toUpperCase() + v2Id.slice(1).replace(/-/g, " ") + " (V3)";
4967
+ const outDir = path15.resolve(
4968
+ process.cwd(),
4969
+ options.out ?? `${v2Id}-engine-V3`
4970
+ );
4971
+ if (fs15.existsSync(outDir)) {
4972
+ console.error(import_chalk.default.red(`Output directory already exists: ${outDir}`));
4973
+ console.error(import_chalk.default.dim("Remove it first, or pass --out to a fresh location."));
4974
+ process.exit(1);
4975
+ }
4976
+ console.log(import_chalk.default.bold(`
4977
+ Migrating V2 \u2192 V3:`));
4978
+ console.log(` ${import_chalk.default.dim("from")} ${absV2Path}`);
4979
+ console.log(` ${import_chalk.default.dim(" to")} ${outDir}
4980
+ `);
4981
+ for (const d of [
4982
+ "src/sections",
4983
+ "src/v2-bridge",
4984
+ "schemas/sections",
4985
+ "schemas/blocks",
4986
+ "templates"
4987
+ ]) {
4988
+ fs15.mkdirSync(path15.join(outDir, d), { recursive: true });
4989
+ }
4990
+ fs15.writeFileSync(
4991
+ path15.join(outDir, "src/v2-bridge", "index.tsx"),
4992
+ makeV2Bridge()
4993
+ );
4994
+ const v2Styles = path15.join(absV2Path, "styles.css");
4995
+ if (fs15.existsSync(v2Styles)) {
4996
+ fs15.copyFileSync(v2Styles, path15.join(outDir, "styles.css"));
4997
+ console.log(import_chalk.default.green(" \u2713 Copied styles.css"));
4998
+ } else {
4999
+ fs15.writeFileSync(
5000
+ path15.join(outDir, "styles.css"),
5001
+ `/* ${displayName} styles */
5002
+ `
5003
+ );
5004
+ console.log(import_chalk.default.yellow(" \u26A0 No styles.css found \u2014 created an empty one"));
5005
+ }
5006
+ const v2SectionsDir = path15.join(absV2Path, "sections");
5007
+ const sectionFiles = fs15.existsSync(v2SectionsDir) ? findSectionFiles(v2SectionsDir) : [];
5008
+ const sectionTypes = [];
5009
+ const allNotes = [];
5010
+ for (const file of sectionFiles) {
5011
+ if (!file.endsWith(".tsx")) continue;
5012
+ const type = sectionTypeFromFilename(file);
5013
+ sectionTypes.push(type);
5014
+ const v2Source = fs15.readFileSync(file, "utf-8");
5015
+ const notes = migrationNotesFor(v2Source);
5016
+ allNotes.push({ file, type, notes });
5017
+ const relativeV2 = path15.relative(process.cwd(), file);
5018
+ const header = adapterCommentBlock(notes, relativeV2);
5019
+ const rewritten = rewriteV2Imports(v2Source);
5020
+ const ported = `${header}
5021
+
5022
+ ${rewritten}`;
5023
+ fs15.writeFileSync(
5024
+ path15.join(outDir, "src/sections", `${type}.tsx`),
5025
+ ported
5026
+ );
5027
+ fs15.writeFileSync(
5028
+ path15.join(outDir, "schemas/sections", `${type}.json`),
5029
+ JSON.stringify(
5030
+ {
5031
+ type,
5032
+ name: type.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" "),
5033
+ settings: []
5034
+ },
5035
+ null,
5036
+ 2
5037
+ )
5038
+ );
5039
+ }
5040
+ console.log(import_chalk.default.green(` \u2713 Ported ${sectionTypes.length} section file(s)`));
5041
+ fs15.writeFileSync(
5042
+ path15.join(outDir, "theme.json"),
5043
+ JSON.stringify(makeThemeJson(themeId, displayName, sectionTypes), null, 2)
5044
+ );
5045
+ fs15.writeFileSync(
5046
+ path15.join(outDir, "settings_schema.json"),
5047
+ JSON.stringify(makeSettingsSchema(), null, 2)
5048
+ );
5049
+ fs15.writeFileSync(
5050
+ path15.join(outDir, "src/main.tsx"),
5051
+ makeMainTsx(themeId, displayName, sectionTypes)
5052
+ );
5053
+ fs15.writeFileSync(
5054
+ path15.join(outDir, "vite.config.ts"),
5055
+ makeViteConfig()
5056
+ );
5057
+ fs15.writeFileSync(
5058
+ path15.join(outDir, "package.json"),
5059
+ JSON.stringify(makePackageJson(themeId, displayName), null, 2)
5060
+ );
5061
+ fs15.writeFileSync(
5062
+ path15.join(outDir, "tsconfig.json"),
5063
+ JSON.stringify(makeTsConfig(), null, 2)
5064
+ );
5065
+ fs15.writeFileSync(
5066
+ path15.join(outDir, "index.html"),
5067
+ makeIndexHtml(displayName)
5068
+ );
5069
+ const tpl = makeTemplates();
5070
+ fs15.writeFileSync(path15.join(outDir, "templates/error.html"), tpl.error);
5071
+ fs15.writeFileSync(path15.join(outDir, "templates/loading.html"), tpl.loading);
5072
+ fs15.writeFileSync(
5073
+ path15.join(outDir, ".gitignore"),
5074
+ "node_modules/\ndist/\n.DS_Store\n"
5075
+ );
5076
+ console.log(import_chalk.default.green(" \u2713 Wrote scaffold files\n"));
5077
+ const filesWithNotes = allNotes.filter((n) => n.notes.length > 0);
5078
+ if (filesWithNotes.length > 0) {
5079
+ console.log(import_chalk.default.bold.yellow("Sections that need manual review:"));
5080
+ for (const { type, notes } of filesWithNotes) {
5081
+ console.log(
5082
+ `
5083
+ ${import_chalk.default.cyan(type)} ${import_chalk.default.dim(`(${notes.length} V2 hooks to swap)`)}`
5084
+ );
5085
+ for (const n of notes) {
5086
+ console.log(
5087
+ ` \u2022 ${import_chalk.default.dim(`L${n.lineHint ?? "?"}`)} ${n.v2} \u2192 ${import_chalk.default.green(n.v3)}`
5088
+ );
5089
+ }
5090
+ }
5091
+ console.log();
5092
+ }
5093
+ console.log(import_chalk.default.bold("\nNext steps:"));
5094
+ console.log(` ${import_chalk.default.cyan("cd")} ${path15.relative(process.cwd(), outDir)}`);
5095
+ console.log(` ${import_chalk.default.cyan("npm install")}`);
5096
+ console.log(` ${import_chalk.default.cyan("npm run dev")} ${import_chalk.default.dim("# dev preview on :5173")}`);
5097
+ console.log(` ${import_chalk.default.cyan("npx numu-theme build")} ${import_chalk.default.dim("# validate + build")}`);
5098
+ console.log(
5099
+ `
5100
+ ${import_chalk.default.dim("Open each src/sections/*.tsx and follow the ADAPTER NOTES at the top.")}`
5101
+ );
5102
+ }
5103
+ );
5104
+
3864
5105
  // src/index.ts
3865
- var program = new import_commander16.Command();
5106
+ var program = new import_commander18.Command();
3866
5107
  program.name("numu-theme").description("CLI for developing, validating, building, and publishing NUMU themes").version("0.1.0");
3867
5108
  program.addCommand(initCommand);
3868
5109
  program.addCommand(devCommand);
3869
5110
  program.addCommand(checkCommand);
3870
5111
  program.addCommand(lintCommand);
3871
5112
  program.addCommand(buildCommand);
5113
+ program.addCommand(verifyCommand);
3872
5114
  program.addCommand(pushCommand);
3873
5115
  program.addCommand(submitCommand);
3874
5116
  program.addCommand(installCommand);
@@ -3879,4 +5121,5 @@ program.addCommand(addSectionCommand);
3879
5121
  program.addCommand(addBlockCommand);
3880
5122
  program.addCommand(pullCommand);
3881
5123
  program.addCommand(deleteCommand);
5124
+ program.addCommand(migrateCommand);
3882
5125
  program.parse();