@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.
- package/dist/index.js +1508 -265
- 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
|
|
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 {
|
|
775
|
-
import
|
|
776
|
-
|
|
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
|
-
|
|
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?.[
|
|
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
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
915
|
-
|
|
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)
|
|
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.
|
|
1202
|
+
dependencies: { "@numueg/theme-sdk": "^0.3.0" },
|
|
1024
1203
|
devDependencies: {
|
|
1025
|
-
"@numueg/theme-cli": "^0.
|
|
1026
|
-
"@numueg/theme-plugin": "^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
|
|
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
|
-
|
|
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(
|
|
1256
|
+
if (fs2.existsSync(rcFile())) {
|
|
1071
1257
|
try {
|
|
1072
|
-
const rc = JSON.parse(fs2.readFileSync(
|
|
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(
|
|
1271
|
+
if (fs2.existsSync(rcFile())) {
|
|
1086
1272
|
try {
|
|
1087
|
-
existing = JSON.parse(fs2.readFileSync(
|
|
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(
|
|
1278
|
+
fs2.writeFileSync(rcFile(), JSON.stringify(merged, null, 2), { mode: 384 });
|
|
1093
1279
|
try {
|
|
1094
|
-
fs2.chmodSync(
|
|
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,
|
|
1315
|
+
async function apiRequest(method, path16, body) {
|
|
1130
1316
|
const config = loadConfig();
|
|
1131
|
-
const url = assertHttpsOrLocalhost(`${config.api_url}${
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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", ".").
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
-
|
|
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
|
|
1444
|
-
if (options.enabledRules && !options.enabledRules.has(
|
|
1741
|
+
const rule15 = mod.default;
|
|
1742
|
+
if (options.enabledRules && !options.enabledRules.has(rule15.id)) continue;
|
|
1445
1743
|
try {
|
|
1446
|
-
const ruleIssues = await
|
|
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:
|
|
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
|
|
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", ".").
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
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
|
-
|
|
1625
|
-
|
|
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
|
-
|
|
1629
|
-
});
|
|
1984
|
+
);
|
|
1630
1985
|
|
|
1631
|
-
// src/commands/
|
|
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((
|
|
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", () =>
|
|
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
|
|
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 =
|
|
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
|
|
1701
|
-
var
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
1795
|
-
var
|
|
1796
|
-
var
|
|
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
|
|
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 =
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
|
2423
|
+
var import_commander10 = require("commander");
|
|
1990
2424
|
var import_inquirer = __toESM(require("inquirer"));
|
|
1991
|
-
var loginCommand = new
|
|
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
|
|
2039
|
-
var statusCommand = new
|
|
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
|
|
2600
|
+
var import_commander12 = require("commander");
|
|
2167
2601
|
var fs11 = __toESM(require("fs"));
|
|
2168
|
-
var
|
|
2169
|
-
var doctorCommand = new
|
|
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 =
|
|
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 =
|
|
2186
|
-
const settingsPath =
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
2272
|
-
const schemaDir =
|
|
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) =>
|
|
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) =>
|
|
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 =
|
|
2309
|
-
const distCss =
|
|
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
|
|
2868
|
+
var import_commander13 = require("commander");
|
|
2393
2869
|
var fs12 = __toESM(require("fs"));
|
|
2394
|
-
var
|
|
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
|
|
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 =
|
|
3461
|
-
if (!fs12.existsSync(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
4090
|
+
var import_commander14 = require("commander");
|
|
3615
4091
|
var fs13 = __toESM(require("fs"));
|
|
3616
|
-
var
|
|
3617
|
-
var addBlockCommand = new
|
|
3618
|
-
const themeDir =
|
|
3619
|
-
if (!fs13.existsSync(
|
|
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 =
|
|
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
|
-
- ${
|
|
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
|
|
4177
|
+
var import_commander15 = require("commander");
|
|
3702
4178
|
var fs14 = __toESM(require("fs"));
|
|
3703
|
-
var
|
|
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((
|
|
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(
|
|
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(() =>
|
|
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
|
|
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 =
|
|
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 =
|
|
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 ${
|
|
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
|
|
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((
|
|
4301
|
+
return new Promise((resolve10) => {
|
|
3826
4302
|
rl.question(`${prompt} [y/N] `, (answer) => {
|
|
3827
4303
|
rl.close();
|
|
3828
|
-
|
|
4304
|
+
resolve10(/^y(es)?$/i.test(answer.trim()));
|
|
3829
4305
|
});
|
|
3830
4306
|
});
|
|
3831
4307
|
}
|
|
3832
|
-
var deleteCommand = new
|
|
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
|
|
3846
|
-
const res = await apiRequest("DELETE",
|
|
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
|
|
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();
|