@numueg/theme-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1111 -81
  2. package/package.json +8 -2
package/dist/index.js CHANGED
@@ -591,13 +591,154 @@ 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
+
594
718
  // src/index.ts
595
- var import_commander16 = require("commander");
719
+ var import_commander17 = require("commander");
596
720
 
597
721
  // src/commands/init.ts
598
722
  var import_commander = require("commander");
723
+ var import_child_process = require("child_process");
599
724
  var fs = __toESM(require("fs"));
600
725
  var path = __toESM(require("path"));
726
+ function detectAuthor() {
727
+ const git = (args) => {
728
+ try {
729
+ return (0, import_child_process.execSync)(`git config ${args}`, {
730
+ stdio: ["ignore", "pipe", "ignore"]
731
+ }).toString().trim();
732
+ } catch {
733
+ return "";
734
+ }
735
+ };
736
+ const name = git("user.name");
737
+ const email = git("user.email");
738
+ if (name && email) return `${name} <${email}>`;
739
+ if (name) return name;
740
+ return "Theme Author";
741
+ }
601
742
  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
743
  const dir = path.resolve(process.cwd(), name);
603
744
  if (fs.existsSync(dir)) {
@@ -620,7 +761,7 @@ var initCommand = new import_commander.Command("init").description("Scaffold a n
620
761
  {
621
762
  id: name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
622
763
  name,
623
- author: "",
764
+ author: detectAuthor(),
624
765
  version: "0.1.0",
625
766
  layout: "single-column",
626
767
  description: `${name} NUMU theme`,
@@ -1020,10 +1161,10 @@ export default defineConfig({
1020
1161
  build: "numu-theme build",
1021
1162
  check: "numu-theme check"
1022
1163
  },
1023
- dependencies: { "@numueg/theme-sdk": "^0.1.0" },
1164
+ dependencies: { "@numueg/theme-sdk": "^0.2.0" },
1024
1165
  devDependencies: {
1025
- "@numueg/theme-cli": "^0.1.0",
1026
- "@numueg/theme-plugin": "^0.1.0",
1166
+ "@numueg/theme-cli": "^0.2.0",
1167
+ "@numueg/theme-plugin": "^0.2.0",
1027
1168
  "@vitejs/plugin-react": "^4.3.0",
1028
1169
  vite: "^6.0.0",
1029
1170
  typescript: "^5.8.0",
@@ -1056,20 +1197,27 @@ Next steps:
1056
1197
 
1057
1198
  // src/commands/dev.ts
1058
1199
  var import_commander2 = require("commander");
1059
- var import_child_process = require("child_process");
1200
+ var import_child_process2 = require("child_process");
1060
1201
 
1061
1202
  // src/utils/config.ts
1062
1203
  var fs2 = __toESM(require("fs"));
1063
1204
  var path2 = __toESM(require("path"));
1064
1205
  var os = __toESM(require("os"));
1065
- var RC_FILE = path2.join(os.homedir(), ".numurc");
1206
+ function configHome() {
1207
+ const override = process.env.NUMU_HOME;
1208
+ if (override && override.length > 0) return override;
1209
+ return os.homedir();
1210
+ }
1211
+ function rcFile() {
1212
+ return path2.join(configHome(), ".numurc");
1213
+ }
1066
1214
  function loadConfig() {
1067
1215
  const config = {
1068
1216
  api_url: process.env.NUMU_API_URL || "https://api.numu.io/api/v1"
1069
1217
  };
1070
- if (fs2.existsSync(RC_FILE)) {
1218
+ if (fs2.existsSync(rcFile())) {
1071
1219
  try {
1072
- const rc = JSON.parse(fs2.readFileSync(RC_FILE, "utf-8"));
1220
+ const rc = JSON.parse(fs2.readFileSync(rcFile(), "utf-8"));
1073
1221
  if (rc.token) config.token = rc.token;
1074
1222
  if (rc.api_url) config.api_url = rc.api_url;
1075
1223
  if (rc.store_id) config.store_id = rc.store_id;
@@ -1082,16 +1230,16 @@ function loadConfig() {
1082
1230
  }
1083
1231
  function saveConfig(updates) {
1084
1232
  let existing = {};
1085
- if (fs2.existsSync(RC_FILE)) {
1233
+ if (fs2.existsSync(rcFile())) {
1086
1234
  try {
1087
- existing = JSON.parse(fs2.readFileSync(RC_FILE, "utf-8"));
1235
+ existing = JSON.parse(fs2.readFileSync(rcFile(), "utf-8"));
1088
1236
  } catch {
1089
1237
  }
1090
1238
  }
1091
1239
  const merged = { ...existing, ...updates };
1092
- fs2.writeFileSync(RC_FILE, JSON.stringify(merged, null, 2), { mode: 384 });
1240
+ fs2.writeFileSync(rcFile(), JSON.stringify(merged, null, 2), { mode: 384 });
1093
1241
  try {
1094
- fs2.chmodSync(RC_FILE, 384);
1242
+ fs2.chmodSync(rcFile(), 384);
1095
1243
  } catch {
1096
1244
  }
1097
1245
  }
@@ -1126,12 +1274,12 @@ function assertHttpsOrLocalhost(urlStr) {
1126
1274
  }
1127
1275
  throw new Error(`Unsupported protocol: ${url.protocol}`);
1128
1276
  }
1129
- async function apiRequest(method, path14, body) {
1277
+ async function apiRequest(method, path15, body) {
1130
1278
  const config = loadConfig();
1131
- const url = assertHttpsOrLocalhost(`${config.api_url}${path14}`);
1279
+ const url = assertHttpsOrLocalhost(`${config.api_url}${path15}`);
1132
1280
  const isHttps = url.protocol === "https:";
1133
1281
  const transport = isHttps ? https : http;
1134
- return new Promise((resolve7, reject) => {
1282
+ return new Promise((resolve9, reject) => {
1135
1283
  const headers = {};
1136
1284
  if (config.token) headers["Authorization"] = `Bearer ${config.token}`;
1137
1285
  let postData;
@@ -1153,7 +1301,7 @@ async function apiRequest(method, path14, body) {
1153
1301
  res.on("data", (chunk) => data += chunk);
1154
1302
  res.on("end", () => {
1155
1303
  const { unwrapped, raw } = parseBody(data);
1156
- resolve7({
1304
+ resolve9({
1157
1305
  status: res.statusCode ?? 0,
1158
1306
  data: unwrapped,
1159
1307
  raw
@@ -1186,7 +1334,7 @@ Content-Type: application/zip\r
1186
1334
  const fullBody = Buffer.concat([head, fileBuffer, tail]);
1187
1335
  const isHttps = url.protocol === "https:";
1188
1336
  const transport = isHttps ? https : http;
1189
- return new Promise((resolve7, reject) => {
1337
+ return new Promise((resolve9, reject) => {
1190
1338
  const headers = {
1191
1339
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
1192
1340
  "Content-Length": String(fullBody.byteLength)
@@ -1209,7 +1357,7 @@ Content-Type: application/zip\r
1209
1357
  res.on("data", (chunk) => data += chunk);
1210
1358
  res.on("end", () => {
1211
1359
  const { unwrapped, raw } = parseBody(data);
1212
- resolve7({
1360
+ resolve9({
1213
1361
  status: res.statusCode ?? 0,
1214
1362
  data: unwrapped,
1215
1363
  raw
@@ -1265,11 +1413,11 @@ var devCommand = new import_commander2.Command("dev").description("Start local d
1265
1413
  console.log(
1266
1414
  `Starting Vite on ${options.expose ? "0.0.0.0" : "localhost"}:${options.port}...`
1267
1415
  );
1268
- const child = (0, import_child_process.spawn)("npx", args, { stdio: "inherit", shell: true });
1416
+ const child = (0, import_child_process2.spawn)("npx", args, { stdio: "inherit", shell: true });
1269
1417
  let watcher = null;
1270
1418
  if (options.watch) {
1271
1419
  console.log("Starting bundle watcher (vite build --watch)...");
1272
- watcher = (0, import_child_process.spawn)(
1420
+ watcher = (0, import_child_process2.spawn)(
1273
1421
  "npx",
1274
1422
  ["vite", "build", "--watch", "--mode", "development"],
1275
1423
  { stdio: "inherit", shell: true }
@@ -1295,6 +1443,7 @@ var devCommand = new import_commander2.Command("dev").description("Start local d
1295
1443
 
1296
1444
  // src/commands/check.ts
1297
1445
  var import_commander3 = require("commander");
1446
+ var import_child_process3 = require("child_process");
1298
1447
 
1299
1448
  // src/utils/validator.ts
1300
1449
  var fs4 = __toESM(require("fs"));
@@ -1387,23 +1536,88 @@ function validateTheme(themeDir) {
1387
1536
  }
1388
1537
 
1389
1538
  // src/commands/check.ts
1390
- var checkCommand = new import_commander3.Command("check").description("Validate theme schemas and structure").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
1391
- console.log("Validating theme...");
1392
- const result = validateTheme(options.dir);
1393
- if (result.warnings.length > 0) {
1394
- console.log("\nWarnings:");
1395
- result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1396
- }
1397
- if (result.errors.length > 0) {
1398
- console.log("\nErrors:");
1399
- result.errors.forEach((e) => console.log(` \u2717 ${e}`));
1400
- console.log(`
1539
+ var checkCommand = new import_commander3.Command("check").description("Validate theme schemas and structure").option("-d, --dir <directory>", "Theme directory", ".").option(
1540
+ "--lighthouse",
1541
+ "Run a Lighthouse audit (performance + accessibility) \u2014 needs --url"
1542
+ ).option(
1543
+ "--url <url>",
1544
+ "URL of the built theme on a seeded store to audit (e.g. http://localhost:3100/<store>)"
1545
+ ).option("--perf <score>", "Minimum Lighthouse performance score (0-100)", "60").option(
1546
+ "--a11y <score>",
1547
+ "Minimum Lighthouse accessibility score (0-100)",
1548
+ "90"
1549
+ ).option(
1550
+ "--strict",
1551
+ "Exit non-zero when below thresholds (default: soft gate \u2014 report only)"
1552
+ ).action(
1553
+ async (options) => {
1554
+ console.log("Validating theme...");
1555
+ const result = validateTheme(options.dir);
1556
+ if (result.warnings.length > 0) {
1557
+ console.log("\nWarnings:");
1558
+ result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
1559
+ }
1560
+ if (result.errors.length > 0) {
1561
+ console.log("\nErrors:");
1562
+ result.errors.forEach((e) => console.log(` \u2717 ${e}`));
1563
+ console.log(`
1401
1564
  Validation failed with ${result.errors.length} error(s)`);
1565
+ process.exit(1);
1566
+ }
1567
+ console.log(`
1568
+ \u2713 Theme is valid (${result.warnings.length} warning(s))`);
1569
+ if (options.lighthouse) {
1570
+ runLighthouse(options);
1571
+ }
1572
+ }
1573
+ );
1574
+ function runLighthouse(options) {
1575
+ if (!options.url) {
1576
+ console.error(
1577
+ "\n\u2718 --lighthouse requires --url <url> (a built theme on a seeded store)."
1578
+ );
1402
1579
  process.exit(1);
1403
1580
  }
1581
+ const perfMin = parseFloat(options.perf);
1582
+ const a11yMin = parseFloat(options.a11y);
1404
1583
  console.log(`
1405
- \u2713 Theme is valid (${result.warnings.length} warning(s))`);
1406
- });
1584
+ Running Lighthouse against ${options.url} ...`);
1585
+ let raw;
1586
+ try {
1587
+ raw = (0, import_child_process3.execSync)(
1588
+ `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`,
1589
+ { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }
1590
+ );
1591
+ } catch (e) {
1592
+ console.warn(
1593
+ "\n\u26A0 Could not run Lighthouse (install it: `npm i -g lighthouse`, and ensure Chrome is available). Soft-skipping."
1594
+ );
1595
+ console.warn(` ${String(e.message).split("\n")[0]}`);
1596
+ return;
1597
+ }
1598
+ let report;
1599
+ try {
1600
+ report = JSON.parse(raw);
1601
+ } catch {
1602
+ console.warn("\n\u26A0 Lighthouse output was not parseable JSON; skipping gate.");
1603
+ return;
1604
+ }
1605
+ const perf = Math.round((report.categories?.performance?.score ?? 0) * 100);
1606
+ const a11y = Math.round((report.categories?.accessibility?.score ?? 0) * 100);
1607
+ console.log(` Performance: ${perf} (min ${perfMin})`);
1608
+ console.log(` Accessibility: ${a11y} (min ${a11yMin})`);
1609
+ const failed = perf < perfMin || a11y < a11yMin;
1610
+ if (!failed) {
1611
+ console.log("\u2713 Lighthouse thresholds met.");
1612
+ return;
1613
+ }
1614
+ const msg = `Lighthouse below threshold (Perf ${perf}/${perfMin}, A11y ${a11y}/${a11yMin}).`;
1615
+ if (options.strict) {
1616
+ console.error(`\u2718 ${msg}`);
1617
+ process.exit(1);
1618
+ }
1619
+ console.warn(`\u26A0 ${msg} (soft gate \u2014 pass --strict to enforce.)`);
1620
+ }
1407
1621
 
1408
1622
  // src/commands/lint.ts
1409
1623
  var import_commander4 = require("commander");
@@ -1425,7 +1639,9 @@ async function runAllRules(themeDir, options) {
1425
1639
  () => Promise.resolve().then(() => (init_inline_color_literal(), inline_color_literal_exports)),
1426
1640
  () => Promise.resolve().then(() => (init_forbidden_script_tag(), forbidden_script_tag_exports)),
1427
1641
  () => 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))
1642
+ () => Promise.resolve().then(() => (init_manifest_required_fields(), manifest_required_fields_exports)),
1643
+ () => Promise.resolve().then(() => (init_contrast_hint(), contrast_hint_exports)),
1644
+ () => Promise.resolve().then(() => (init_touch_target(), touch_target_exports))
1429
1645
  ];
1430
1646
  const issues = [];
1431
1647
  for (const load of ruleLoaders) {
@@ -1440,14 +1656,14 @@ async function runAllRules(themeDir, options) {
1440
1656
  });
1441
1657
  continue;
1442
1658
  }
1443
- const rule11 = mod.default;
1444
- if (options.enabledRules && !options.enabledRules.has(rule11.id)) continue;
1659
+ const rule13 = mod.default;
1660
+ if (options.enabledRules && !options.enabledRules.has(rule13.id)) continue;
1445
1661
  try {
1446
- const ruleIssues = await rule11.check(ctx);
1662
+ const ruleIssues = await rule13.check(ctx);
1447
1663
  for (const issue of ruleIssues) issues.push(issue);
1448
1664
  } catch (e) {
1449
1665
  issues.push({
1450
- rule: rule11.id,
1666
+ rule: rule13.id,
1451
1667
  severity: "warning",
1452
1668
  message: `Rule crashed: ${e.message}`
1453
1669
  });
@@ -1467,6 +1683,7 @@ function buildContext(themeDir) {
1467
1683
  const blockSchemas = readSchemaDir(path4.join(themeDir, "schemas", "blocks"));
1468
1684
  const locales = readLocales(path4.join(themeDir, "locales"));
1469
1685
  const sources = readSources(themeDir);
1686
+ const styles = readText(path4.join(themeDir, "styles.css"));
1470
1687
  return {
1471
1688
  themeDir,
1472
1689
  manifest,
@@ -1474,7 +1691,8 @@ function buildContext(themeDir) {
1474
1691
  sectionSchemas,
1475
1692
  blockSchemas,
1476
1693
  locales,
1477
- sources
1694
+ sources,
1695
+ styles
1478
1696
  };
1479
1697
  }
1480
1698
  function readJson(filePath, fallback) {
@@ -1485,6 +1703,14 @@ function readJson(filePath, fallback) {
1485
1703
  return fallback;
1486
1704
  }
1487
1705
  }
1706
+ function readText(filePath) {
1707
+ if (!fs5.existsSync(filePath)) return "";
1708
+ try {
1709
+ return fs5.readFileSync(filePath, "utf-8");
1710
+ } catch {
1711
+ return "";
1712
+ }
1713
+ }
1488
1714
  function readSchemaDir(dir) {
1489
1715
  const out = {};
1490
1716
  if (!fs5.existsSync(dir)) return out;
@@ -1592,41 +1818,79 @@ function groupByFile(issues) {
1592
1818
 
1593
1819
  // src/commands/build.ts
1594
1820
  var import_commander5 = require("commander");
1595
- var import_child_process2 = require("child_process");
1821
+ var import_child_process4 = require("child_process");
1596
1822
  var fs7 = __toESM(require("fs"));
1597
1823
  var path6 = __toESM(require("path"));
1598
- var buildCommand = new import_commander5.Command("build").description("Build the theme for production").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
1599
- console.log("Validating before build...");
1600
- const result = validateTheme(options.dir);
1601
- if (!result.valid) {
1602
- console.error("Validation failed. Fix errors before building.");
1603
- result.errors.forEach((e) => console.error(` \u2717 ${e}`));
1604
- process.exit(1);
1605
- }
1606
- console.log("Building theme...");
1607
- try {
1608
- (0, import_child_process2.execSync)("npx vite build", { cwd: options.dir, stdio: "inherit" });
1609
- } catch {
1610
- console.error("Build failed");
1611
- process.exit(1);
1612
- }
1613
- const distDir = path6.join(options.dir, "dist");
1614
- if (fs7.existsSync(distDir)) {
1615
- let totalSize = 0;
1616
- const files = fs7.readdirSync(distDir, { recursive: true });
1617
- for (const file of files) {
1618
- const filePath = path6.join(distDir, file);
1619
- if (fs7.statSync(filePath).isFile()) totalSize += fs7.statSync(filePath).size;
1824
+ var buildCommand = new import_commander5.Command("build").description("Build the theme for production").option("-d, --dir <directory>", "Theme directory", ".").option(
1825
+ "--strict",
1826
+ "Treat lint warnings as errors (Shopify-style a11y/quality gate)"
1827
+ ).option("--no-lint", "Skip the lint gate (not recommended)").action(
1828
+ async (options) => {
1829
+ console.log("Validating before build...");
1830
+ const result = validateTheme(options.dir);
1831
+ if (!result.valid) {
1832
+ console.error("Validation failed. Fix errors before building.");
1833
+ result.errors.forEach((e) => console.error(` \u2717 ${e}`));
1834
+ process.exit(1);
1620
1835
  }
1621
- const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
1622
- console.log(`
1836
+ if (options.lint !== false) {
1837
+ const issues = await runAllRules(
1838
+ path6.resolve(process.cwd(), options.dir),
1839
+ { enabledRules: null }
1840
+ );
1841
+ const errors = issues.filter((i) => i.severity === "error");
1842
+ const warnings = issues.filter((i) => i.severity === "warning");
1843
+ if (issues.length > 0) {
1844
+ console.log("\nLint:");
1845
+ for (const i of issues) {
1846
+ const marker = i.severity === "error" ? "\u2718" : "\u26A0";
1847
+ const loc = i.file ? ` ${i.file}${i.line ? ":" + i.line : ""}` : "";
1848
+ console.log(` ${marker} ${i.rule}${loc} \u2014 ${i.message}`);
1849
+ }
1850
+ }
1851
+ const blocked = errors.length > 0 || options.strict && warnings.length > 0;
1852
+ if (blocked) {
1853
+ console.error(
1854
+ `
1855
+ Lint gate failed: ${errors.length} error(s), ${warnings.length} warning(s)${options.strict ? " (--strict treats warnings as errors)" : ""}.`
1856
+ );
1857
+ process.exit(1);
1858
+ }
1859
+ if (warnings.length > 0) {
1860
+ console.log(
1861
+ `
1862
+ \u26A0 ${warnings.length} lint warning(s) \u2014 run \`numu-theme lint\` for detail, or \`--strict\` to enforce.`
1863
+ );
1864
+ }
1865
+ }
1866
+ console.log("Building theme...");
1867
+ try {
1868
+ (0, import_child_process4.execSync)("npx vite build", { cwd: options.dir, stdio: "inherit" });
1869
+ } catch {
1870
+ console.error("Build failed");
1871
+ process.exit(1);
1872
+ }
1873
+ const distDir = path6.join(options.dir, "dist");
1874
+ if (fs7.existsSync(distDir)) {
1875
+ let totalSize = 0;
1876
+ const files = fs7.readdirSync(distDir, { recursive: true });
1877
+ for (const file of files) {
1878
+ const filePath = path6.join(distDir, file);
1879
+ if (fs7.statSync(filePath).isFile())
1880
+ totalSize += fs7.statSync(filePath).size;
1881
+ }
1882
+ const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
1883
+ console.log(`
1623
1884
  Bundle size: ${sizeMB} MB`);
1624
- if (totalSize > 5 * 1024 * 1024) {
1625
- console.warn("\u26A0 Bundle exceeds 5MB limit \u2014 optimize before submitting to marketplace");
1885
+ if (totalSize > 5 * 1024 * 1024) {
1886
+ console.warn(
1887
+ "\u26A0 Bundle exceeds 5MB limit \u2014 optimize before submitting to marketplace"
1888
+ );
1889
+ }
1626
1890
  }
1891
+ console.log("\n\u2713 Build complete");
1627
1892
  }
1628
- console.log("\n\u2713 Build complete");
1629
- });
1893
+ );
1630
1894
 
1631
1895
  // src/commands/push.ts
1632
1896
  var import_commander6 = require("commander");
@@ -1638,10 +1902,10 @@ var fs8 = __toESM(require("fs"));
1638
1902
  var import_archiver = __toESM(require("archiver"));
1639
1903
  async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
1640
1904
  const opts = Array.isArray(optsOrLegacyExcludes) ? { excludePatterns: optsOrLegacyExcludes } : optsOrLegacyExcludes;
1641
- return new Promise((resolve7, reject) => {
1905
+ return new Promise((resolve9, reject) => {
1642
1906
  const output = fs8.createWriteStream(outputPath);
1643
1907
  const archive = (0, import_archiver.default)("zip", { zlib: { level: 9 } });
1644
- output.on("close", () => resolve7(outputPath));
1908
+ output.on("close", () => resolve9(outputPath));
1645
1909
  archive.on("error", reject);
1646
1910
  archive.pipe(output);
1647
1911
  const defaultExcludes = [
@@ -1792,7 +2056,7 @@ Submission failed (${submitRes.status}): ${JSON.stringify(submitRes.data)}`
1792
2056
 
1793
2057
  // src/commands/install.ts
1794
2058
  var import_commander8 = require("commander");
1795
- var import_child_process3 = require("child_process");
2059
+ var import_child_process5 = require("child_process");
1796
2060
  var path9 = __toESM(require("path"));
1797
2061
  var os4 = __toESM(require("os"));
1798
2062
  var fs10 = __toESM(require("fs"));
@@ -1853,7 +2117,7 @@ var installCommand = new import_commander8.Command("install").description(
1853
2117
  console.log(` install tag: ${version}`);
1854
2118
  console.log("Building locally (so worker can skip npm install)...");
1855
2119
  try {
1856
- (0, import_child_process3.execSync)("npm run build", {
2120
+ (0, import_child_process5.execSync)("npm run build", {
1857
2121
  cwd: options.dir,
1858
2122
  stdio: "inherit",
1859
2123
  env: { ...process.env, NODE_ENV: "production" }
@@ -3704,11 +3968,11 @@ var path13 = __toESM(require("path"));
3704
3968
  var https2 = __toESM(require("https"));
3705
3969
  var http2 = __toESM(require("http"));
3706
3970
  async function downloadToFile(url, dest) {
3707
- return new Promise((resolve7, reject) => {
3971
+ return new Promise((resolve9, reject) => {
3708
3972
  const client = url.startsWith("https:") ? https2 : http2;
3709
3973
  const req = client.get(url, (res) => {
3710
3974
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
3711
- downloadToFile(res.headers.location, dest).then(resolve7, reject);
3975
+ downloadToFile(res.headers.location, dest).then(resolve9, reject);
3712
3976
  return;
3713
3977
  }
3714
3978
  if (res.statusCode !== 200) {
@@ -3717,7 +3981,7 @@ async function downloadToFile(url, dest) {
3717
3981
  }
3718
3982
  const out = fs14.createWriteStream(dest);
3719
3983
  res.pipe(out);
3720
- out.on("finish", () => out.close(() => resolve7()));
3984
+ out.on("finish", () => out.close(() => resolve9()));
3721
3985
  out.on("error", reject);
3722
3986
  });
3723
3987
  req.on("error", reject);
@@ -3822,10 +4086,10 @@ async function confirm(prompt) {
3822
4086
  input: process.stdin,
3823
4087
  output: process.stdout
3824
4088
  });
3825
- return new Promise((resolve7) => {
4089
+ return new Promise((resolve9) => {
3826
4090
  rl.question(`${prompt} [y/N] `, (answer) => {
3827
4091
  rl.close();
3828
- resolve7(/^y(es)?$/i.test(answer.trim()));
4092
+ resolve9(/^y(es)?$/i.test(answer.trim()));
3829
4093
  });
3830
4094
  });
3831
4095
  }
@@ -3842,8 +4106,8 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
3842
4106
  return;
3843
4107
  }
3844
4108
  }
3845
- const path14 = isVersion ? `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions/${encodeURIComponent(options.version)}` : `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`;
3846
- const res = await apiRequest("DELETE", path14);
4109
+ const path15 = isVersion ? `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions/${encodeURIComponent(options.version)}` : `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`;
4110
+ const res = await apiRequest("DELETE", path15);
3847
4111
  if (res.status === 200 || res.status === 204) {
3848
4112
  console.log(`\u2714 Deleted ${scope}.`);
3849
4113
  return;
@@ -3861,8 +4125,773 @@ var deleteCommand = new import_commander15.Command("delete").description("Delete
3861
4125
  }
3862
4126
  );
3863
4127
 
4128
+ // src/commands/migrate.ts
4129
+ var import_commander16 = require("commander");
4130
+ var fs15 = __toESM(require("fs"));
4131
+ var path14 = __toESM(require("path"));
4132
+ var import_chalk = __toESM(require("chalk"));
4133
+ var V2_HOOK_PATTERNS = [
4134
+ {
4135
+ pattern: /from\s+["']@\/contexts\/StoreContext["']/,
4136
+ v2: "useStore (bazaar)",
4137
+ v3: "useShop() from @numueg/theme-sdk",
4138
+ note: "Returns Store directly; bazaar's useStore returns { store, themeSettings, ... } \u2014 destructure differently."
4139
+ },
4140
+ {
4141
+ pattern: /useProductsContext|from\s+["']@\/contexts\/ProductsContext["']/,
4142
+ v2: "useProductsContext (bazaar)",
4143
+ v3: "useProducts() from @numueg/theme-sdk",
4144
+ note: "V3 hook is paginated + has loading state. Shape: { items, loading, error, hasMore, loadMore }. V2 returned flat array."
4145
+ },
4146
+ {
4147
+ pattern: /useCart\s*\(\s*\)/,
4148
+ v2: "useCart (bazaar)",
4149
+ v3: "useCart() from @numueg/theme-sdk",
4150
+ note: "Same name but different shape \u2014 SDK returns { cart, addItem, removeItem, ... } with async actions. Bazaar's was sync."
4151
+ },
4152
+ {
4153
+ pattern: /useAuth\s*\(\s*\)/,
4154
+ v2: "useAuth (bazaar)",
4155
+ v3: "useCustomer() + useCustomerActions() from @numueg/theme-sdk",
4156
+ note: "Customer state is split from action handlers in V3 to avoid render-on-every-action."
4157
+ },
4158
+ {
4159
+ pattern: /useTheme\s*\(\s*\)|from\s+["']@\/contexts\/ThemeContext["']/,
4160
+ v2: "useTheme (bazaar)",
4161
+ v3: "useThemeSettings() from @numueg/theme-sdk",
4162
+ note: "Returns ThemeSettingsV3; access settings via .global_settings or .templates[<page>].sections[<id>].settings."
4163
+ },
4164
+ {
4165
+ pattern: /useLanguage\s*\(/,
4166
+ v2: "useLanguage (bazaar)",
4167
+ v3: "useLocalization() / useDirection() from @numueg/theme-sdk",
4168
+ note: "Returns { locale, direction, translations, ... }. Use useTranslation() for resolving message keys."
4169
+ },
4170
+ {
4171
+ pattern: /editable\.section\s*\(/,
4172
+ v2: "editable.section() helper",
4173
+ v3: "<EditableText> / <EditableImage> from @numueg/theme-sdk",
4174
+ note: "V3 marks editable nodes via dedicated components instead of spread props."
4175
+ }
4176
+ ];
4177
+ function migrationNotesFor(source) {
4178
+ const notes = [];
4179
+ const lines = source.split("\n");
4180
+ for (const { pattern, v2, v3, note } of V2_HOOK_PATTERNS) {
4181
+ for (let i = 0; i < lines.length; i++) {
4182
+ if (pattern.test(lines[i])) {
4183
+ notes.push({ v2, v3, note, lineHint: i + 1 });
4184
+ break;
4185
+ }
4186
+ }
4187
+ }
4188
+ return notes;
4189
+ }
4190
+ function adapterCommentBlock(notes, v2RelativePath) {
4191
+ const lines = [
4192
+ "/**",
4193
+ " * \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",
4194
+ " * V3 PORT \u2014 ADAPTER NOTES",
4195
+ " * \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",
4196
+ " *",
4197
+ ` * Source: ${v2RelativePath}`,
4198
+ " *",
4199
+ " * Generated by `numu-theme migrate`. Review and rewrite using V3",
4200
+ " * SDK hooks before publishing. The list below was inferred from",
4201
+ " * static analysis \u2014 there may be other V2 idioms not detected.",
4202
+ " *"
4203
+ ];
4204
+ if (notes.length === 0) {
4205
+ lines.push(" * No V2-specific hooks detected. You may only need to:");
4206
+ lines.push(" * 1. Replace any bazaar-specific imports with @numueg/theme-sdk equivalents.");
4207
+ lines.push(" * 2. Wrap your section's root element with <Section id={...} type={...}> from the SDK.");
4208
+ lines.push(" * 3. Read settings from `props.instance.settings` (V3 SectionInstance shape).");
4209
+ } else {
4210
+ lines.push(" * Required swaps:");
4211
+ lines.push(" *");
4212
+ for (const n of notes) {
4213
+ const where = n.lineHint ? ` (line ${n.lineHint})` : "";
4214
+ lines.push(` * \u2022 ${n.v2}${where}`);
4215
+ lines.push(` * \u2192 ${n.v3}`);
4216
+ lines.push(` * ${n.note}`);
4217
+ lines.push(" *");
4218
+ }
4219
+ }
4220
+ lines.push(" * When done, remove this block.");
4221
+ 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");
4222
+ lines.push(" */");
4223
+ return lines.join("\n");
4224
+ }
4225
+ function findSectionFiles(dir) {
4226
+ if (!fs15.existsSync(dir)) return [];
4227
+ const out = [];
4228
+ for (const entry of fs15.readdirSync(dir, { withFileTypes: true })) {
4229
+ const full = path14.join(dir, entry.name);
4230
+ if (entry.isDirectory()) out.push(...findSectionFiles(full));
4231
+ else if (entry.isFile() && /\.(tsx|ts)$/.test(entry.name)) out.push(full);
4232
+ }
4233
+ return out;
4234
+ }
4235
+ function sectionTypeFromFilename(filename) {
4236
+ const base = path14.basename(filename).replace(/\.(tsx|ts)$/, "");
4237
+ return base.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9-]+/g, "-").toLowerCase();
4238
+ }
4239
+ function makeV2Bridge() {
4240
+ return `/**
4241
+ * v2-bridge \u2014 per-theme compat shim for V2 sections ported via
4242
+ * \`numu-theme migrate\`. DELETE this file once each section has been
4243
+ * rewritten to use idiomatic V3 SDK hooks + components.
4244
+ */
4245
+
4246
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
4247
+ import {
4248
+ useV2Products,
4249
+ useV2Categories,
4250
+ useV2Auth,
4251
+ useV2Language,
4252
+ useV2Theme,
4253
+ } from "@numueg/theme-sdk/v2-compat";
4254
+ import { ProductCard, useThemeSettings } from "@numueg/theme-sdk";
4255
+ import type { SectionInstance } from "@numueg/theme-sdk";
4256
+
4257
+ // \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
4258
+
4259
+ /** V2's \`useProducts\` returned \`{ products, loading }\`. Same shape. */
4260
+ export function useProducts() {
4261
+ return useV2Products();
4262
+ }
4263
+
4264
+ /** V2's \`useCategories\` returned \`{ categories, loading }\`. Same shape. */
4265
+ export function useCategories() {
4266
+ return useV2Categories();
4267
+ }
4268
+
4269
+ /** V2's \`useAuth\` returned \`{ user, isAuthenticated }\`. */
4270
+ export function useAuth() {
4271
+ return useV2Auth();
4272
+ }
4273
+
4274
+ /** V2's \`useLanguage\` returned \`{ language, direction, setLanguage, t }\`. */
4275
+ export function useLanguage() {
4276
+ return useV2Language();
4277
+ }
4278
+
4279
+ /** V2's \`useStore\` returned the full store config; here we expose
4280
+ * only the parts ported sections use (themeSettings nested). */
4281
+ export function useStore() {
4282
+ return useV2Theme();
4283
+ }
4284
+
4285
+ // \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
4286
+
4287
+ /**
4288
+ * V2 sections expected this prop shape:
4289
+ *
4290
+ * interface SectionComponentProps {
4291
+ * section: { id: string; type: string; settings: Record<string, any>; ... };
4292
+ * ...
4293
+ * }
4294
+ *
4295
+ * V3 mount passes \`{ instance: SectionInstance }\`. We re-export a
4296
+ * compatible interface so existing destructure patterns like
4297
+ * \`const { section } = props\` keep compiling \u2014 the wrapper below
4298
+ * coerces \`instance\` \u2192 \`section\` at render time.
4299
+ */
4300
+ export interface SectionComponentProps {
4301
+ section: SectionInstance & { id?: string };
4302
+ }
4303
+
4304
+ /**
4305
+ * Section adapter. Migrate-generated sections still expect to be
4306
+ * called with \`{ section }\`; the V3 \`SECTION_REGISTRY\` calls them
4307
+ * with \`{ instance }\`. Wrap each export:
4308
+ *
4309
+ * export default v2Section(YourComponent);
4310
+ */
4311
+ export function v2Section<P extends SectionComponentProps>(
4312
+ Component: (props: P) => ReactNode,
4313
+ ): (props: { instance: SectionInstance }) => ReactNode {
4314
+ return function V2SectionAdapter({ instance }) {
4315
+ const props = { section: instance } as unknown as P;
4316
+ return Component(props);
4317
+ };
4318
+ }
4319
+
4320
+ // \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
4321
+
4322
+ /**
4323
+ * V2's editable helper spread DOM props on individual nodes so the
4324
+ * old V2 customizer could click-select them. V3 uses \`<EditableText>\`
4325
+ * / \`<EditableImage>\` for the same job. Returning an empty object
4326
+ * here means the spread is a no-op \u2014 the section still renders, just
4327
+ * without the inline click-to-edit affordance. Replace with the V3
4328
+ * components when polishing the port.
4329
+ */
4330
+ export const editable = {
4331
+ section: (_sectionId: string, _key: string) => ({}),
4332
+ block: (_blockId: string, _key: string) => ({}),
4333
+ };
4334
+
4335
+ // \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
4336
+
4337
+ export { ProductCard, useThemeSettings };
4338
+
4339
+ /**
4340
+ * V2's \`<Link to="/...">\` came from react-router-dom. The V3 storefront
4341
+ * is Next.js, where \`<a href="/...">\` works (Next intercepts internal
4342
+ * navigation). We expose a polyfill so existing JSX renders without
4343
+ * react-router-dom installed. Pass-through any extra props.
4344
+ */
4345
+ export function Link({
4346
+ to,
4347
+ children,
4348
+ ...rest
4349
+ }: { to: string; children: ReactNode } & Omit<
4350
+ ComponentPropsWithoutRef<"a">,
4351
+ "href"
4352
+ >) {
4353
+ return (
4354
+ <a href={to} {...rest}>
4355
+ {children}
4356
+ </a>
4357
+ );
4358
+ }
4359
+
4360
+ /** V2's image placeholder constant. */
4361
+ export const PLACEHOLDER_HERO =
4362
+ "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1200&q=60";
4363
+ `;
4364
+ }
4365
+ function rewriteV2Imports(source) {
4366
+ const PATH_REWRITES = [
4367
+ // The most-common bazaar context imports.
4368
+ [/@\/contexts\/ProductsContext/g, "../v2-bridge"],
4369
+ [/@\/contexts\/AuthContext/g, "../v2-bridge"],
4370
+ [/@\/contexts\/LanguageContext/g, "../v2-bridge"],
4371
+ [/@\/contexts\/StoreContext/g, "../v2-bridge"],
4372
+ [/@\/contexts\/ThemeContext/g, "../v2-bridge"],
4373
+ // V2 engine types + editable helper.
4374
+ [/@\/themes\/engine\/types/g, "../v2-bridge"],
4375
+ [/@\/themes\/engine\/editable/g, "../v2-bridge"],
4376
+ // bazaar's shared ProductCard component.
4377
+ [/@\/components\/store\/ProductCard/g, "../v2-bridge"],
4378
+ // The image placeholder constant.
4379
+ [/@\/lib\/imagePlaceholders/g, "../v2-bridge"],
4380
+ // react-router-dom → bridge (Link polyfill). Only the named
4381
+ // imports we know about; the bridge exports `Link`. Sections that
4382
+ // need other react-router-dom APIs will fail at build time, which
4383
+ // is the right outcome — they need manual conversion.
4384
+ [/(['"])react-router-dom\1/g, '"../v2-bridge"']
4385
+ ];
4386
+ let out = source;
4387
+ for (const [pattern, replacement] of PATH_REWRITES) {
4388
+ out = out.replace(pattern, replacement);
4389
+ }
4390
+ return out;
4391
+ }
4392
+ function makeThemeJson(themeId, displayName, sectionTypes) {
4393
+ return {
4394
+ id: themeId,
4395
+ name: displayName,
4396
+ author: "",
4397
+ version: "0.1.0",
4398
+ layout: "single-column",
4399
+ description: `${displayName} \u2014 migrated from V2.`,
4400
+ error_template: "templates/error.html",
4401
+ loading_template: "templates/loading.html",
4402
+ presets: {
4403
+ templates: {
4404
+ home: {
4405
+ name: "Home",
4406
+ sections: sectionTypes.map((type) => ({
4407
+ type,
4408
+ settings: {}
4409
+ }))
4410
+ }
4411
+ }
4412
+ }
4413
+ };
4414
+ }
4415
+ function makeSettingsSchema() {
4416
+ return [
4417
+ {
4418
+ name: "Brand",
4419
+ settings: [
4420
+ {
4421
+ type: "color",
4422
+ id: "primary_color",
4423
+ label: "Primary color",
4424
+ default: "#111111"
4425
+ },
4426
+ {
4427
+ type: "color",
4428
+ id: "accent_color",
4429
+ label: "Accent color",
4430
+ default: "#d4af37"
4431
+ }
4432
+ ]
4433
+ }
4434
+ ];
4435
+ }
4436
+ function makeMainTsx(themeId, displayName, sectionTypes) {
4437
+ const registryEntries = sectionTypes.map((type) => ` "${type}": lazy(() => import("./sections/${type}")),`).join("\n");
4438
+ return `/**
4439
+ * ${displayName} (V3) \u2014 entry point.
4440
+ *
4441
+ * Generated by \`numu-theme migrate\`. Section components live in
4442
+ * src/sections/<type>.tsx and are lazy-loaded so only sections the
4443
+ * merchant actually uses pay the bundle cost.
4444
+ */
4445
+
4446
+ import {
4447
+ StrictMode,
4448
+ lazy,
4449
+ Suspense,
4450
+ useImperativeHandle,
4451
+ useRef,
4452
+ useState,
4453
+ forwardRef,
4454
+ } from "react";
4455
+ import { createRoot, type Root } from "react-dom/client";
4456
+ import {
4457
+ NuMuProvider,
4458
+ Section,
4459
+ useThemeSettings,
4460
+ type ThemeSettingsV3,
4461
+ type Store,
4462
+ type Cart,
4463
+ type Customer,
4464
+ type SectionInstance,
4465
+ type MountResult,
4466
+ } from "@numueg/theme-sdk";
4467
+ import themeManifest from "../theme.json";
4468
+
4469
+ const SECTION_REGISTRY: Record<string, ReturnType<typeof lazy>> = {
4470
+ ${registryEntries}
4471
+ };
4472
+
4473
+ function UnknownSection({ type }: { type: string }) {
4474
+ return (
4475
+ <section style={{ padding: "1rem", border: "1px dashed #fb923c" }}>
4476
+ Unknown section: <strong>{type}</strong>
4477
+ </section>
4478
+ );
4479
+ }
4480
+
4481
+ interface ResolvedSection {
4482
+ id: string;
4483
+ instance: SectionInstance;
4484
+ }
4485
+
4486
+ interface MaybeOrdered {
4487
+ sections?: Record<string, SectionInstance> | SectionInstance[];
4488
+ order?: string[];
4489
+ }
4490
+
4491
+ function resolveSections(group: MaybeOrdered | undefined): ResolvedSection[] {
4492
+ if (!group) return [];
4493
+ if (Array.isArray(group.sections)) {
4494
+ return group.sections.map((instance, idx) => ({
4495
+ id: \`\${instance.type}-\${idx}\`,
4496
+ instance,
4497
+ }));
4498
+ }
4499
+ const map = (group.sections ?? {}) as Record<string, SectionInstance>;
4500
+ const order = group.order ?? Object.keys(map);
4501
+ return order
4502
+ .map((id): ResolvedSection | null => {
4503
+ const instance = map[id];
4504
+ if (!instance) return null;
4505
+ return { id, instance };
4506
+ })
4507
+ .filter((x): x is ResolvedSection => Boolean(x));
4508
+ }
4509
+
4510
+ const BUILTIN_HOME = (themeManifest as unknown as { presets?: { templates?: { home?: MaybeOrdered } } })
4511
+ .presets?.templates?.home;
4512
+
4513
+ function RenderSection({
4514
+ instance,
4515
+ sectionId,
4516
+ groupId,
4517
+ }: {
4518
+ instance: SectionInstance;
4519
+ sectionId: string;
4520
+ groupId?: string;
4521
+ }) {
4522
+ const Component = SECTION_REGISTRY[instance.type];
4523
+ if (!Component) {
4524
+ return (
4525
+ <Section id={sectionId} type={instance.type} groupId={groupId}>
4526
+ <UnknownSection type={instance.type} />
4527
+ </Section>
4528
+ );
4529
+ }
4530
+ return (
4531
+ <Section id={sectionId} type={instance.type} groupId={groupId}>
4532
+ <Suspense fallback={<div style={{ minHeight: "20vh" }} />}>
4533
+ <Component instance={instance} />
4534
+ </Suspense>
4535
+ </Section>
4536
+ );
4537
+ }
4538
+
4539
+ function ThemeApp() {
4540
+ const settings = useThemeSettings();
4541
+ const hostHome = settings.templates?.home as MaybeOrdered | undefined;
4542
+ const sections = resolveSections(hostHome ?? BUILTIN_HOME);
4543
+ return (
4544
+ <div data-${themeId}-app>
4545
+ {sections.map(({ id, instance }) => (
4546
+ <RenderSection key={id} sectionId={id} instance={instance} />
4547
+ ))}
4548
+ </div>
4549
+ );
4550
+ }
4551
+
4552
+ // \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
4553
+
4554
+ export interface MountContext {
4555
+ store: Store;
4556
+ themeSettings: ThemeSettingsV3;
4557
+ initialCart?: Cart;
4558
+ customer?: Customer | null;
4559
+ locale?: string;
4560
+ translations?: Record<string, string>;
4561
+ [extra: string]: unknown;
4562
+ }
4563
+
4564
+ interface DraftHandle {
4565
+ applyDraft: (next: ThemeSettingsV3) => void;
4566
+ }
4567
+
4568
+ const ThemeSettingsBridge = forwardRef<DraftHandle, { ctx: MountContext }>(
4569
+ function ThemeSettingsBridge({ ctx }, ref) {
4570
+ const [themeSettings, setThemeSettings] = useState<ThemeSettingsV3>(
4571
+ ctx.themeSettings,
4572
+ );
4573
+ useImperativeHandle(
4574
+ ref,
4575
+ () => ({
4576
+ applyDraft: (next) => setThemeSettings((prev) => (prev === next ? prev : next)),
4577
+ }),
4578
+ [],
4579
+ );
4580
+ return (
4581
+ <NuMuProvider
4582
+ store={ctx.store}
4583
+ themeSettings={themeSettings}
4584
+ initialCart={ctx.initialCart}
4585
+ customer={ctx.customer}
4586
+ locale={ctx.locale}
4587
+ translations={ctx.translations}
4588
+ >
4589
+ <ThemeApp />
4590
+ </NuMuProvider>
4591
+ );
4592
+ },
4593
+ );
4594
+
4595
+ let currentRoot: Root | null = null;
4596
+
4597
+ export function mount(el: HTMLElement, ctx: MountContext): MountResult {
4598
+ if (currentRoot) {
4599
+ currentRoot.unmount();
4600
+ currentRoot = null;
4601
+ }
4602
+ const root = createRoot(el);
4603
+ currentRoot = root;
4604
+ const handleRef = { current: null as DraftHandle | null };
4605
+ root.render(
4606
+ <StrictMode>
4607
+ <ThemeSettingsBridge
4608
+ ctx={ctx}
4609
+ ref={(h) => {
4610
+ handleRef.current = h;
4611
+ }}
4612
+ />
4613
+ </StrictMode>,
4614
+ );
4615
+ return {
4616
+ applyDraft: (next) => handleRef.current?.applyDraft(next),
4617
+ cleanup: () => {
4618
+ root.unmount();
4619
+ if (currentRoot === root) currentRoot = null;
4620
+ handleRef.current = null;
4621
+ },
4622
+ };
4623
+ }
4624
+
4625
+ const v3Handle = {
4626
+ kind: "v3-mount" as const,
4627
+ numu_theme_version: 3 as const,
4628
+ mount_returns: "MountResult" as const,
4629
+ manifest: { id: "${themeId}", name: "${displayName}", version: "0.1.0" },
4630
+ mount,
4631
+ };
4632
+ export default v3Handle;
4633
+ `;
4634
+ }
4635
+ function makeViteConfig() {
4636
+ return `import { defineConfig, type PluginOption } from "vite";
4637
+ import react from "@vitejs/plugin-react";
4638
+ import { numuTheme } from "@numueg/theme-plugin";
4639
+
4640
+ export default defineConfig({
4641
+ plugins: [react(), numuTheme({ federate: false }) as unknown as PluginOption],
4642
+ server: { port: 5173 },
4643
+ });
4644
+ `;
4645
+ }
4646
+ function makePackageJson(themeId, displayName) {
4647
+ return {
4648
+ name: themeId,
4649
+ version: "0.1.0",
4650
+ private: true,
4651
+ description: `${displayName} (V3 port)`,
4652
+ type: "module",
4653
+ scripts: {
4654
+ dev: "vite",
4655
+ build: "vite build",
4656
+ preview: "vite preview --port 5173"
4657
+ },
4658
+ dependencies: {
4659
+ react: "^18.3.1",
4660
+ "react-dom": "^18.3.1",
4661
+ "@numueg/theme-sdk": "^0.1.0",
4662
+ // V2 sections commonly import these — including them in deps so
4663
+ // `npm install` works without manual edits. The bundle externalises
4664
+ // react/react-dom/@numueg/theme-sdk via federation; framer-motion
4665
+ // and lucide-react ship as part of the theme bundle (~30-40 KB
4666
+ // gzip combined). Themes that prove they don't need them can
4667
+ // remove these after polishing.
4668
+ "framer-motion": "^11.11.0",
4669
+ "lucide-react": "^0.454.0"
4670
+ },
4671
+ devDependencies: {
4672
+ "@numueg/theme-plugin": "^0.1.0",
4673
+ "@types/react": "^18.3.0",
4674
+ "@types/react-dom": "^18.3.0",
4675
+ "@vitejs/plugin-react": "^4.3.0",
4676
+ typescript: "^5.5.0",
4677
+ vite: "^6.0.0"
4678
+ }
4679
+ };
4680
+ }
4681
+ function makeTsConfig() {
4682
+ return {
4683
+ compilerOptions: {
4684
+ target: "ES2022",
4685
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
4686
+ module: "ESNext",
4687
+ moduleResolution: "Bundler",
4688
+ jsx: "react-jsx",
4689
+ strict: true,
4690
+ noImplicitAny: true,
4691
+ strictNullChecks: true,
4692
+ esModuleInterop: true,
4693
+ resolveJsonModule: true,
4694
+ skipLibCheck: true,
4695
+ isolatedModules: true,
4696
+ allowSyntheticDefaultImports: true,
4697
+ forceConsistentCasingInFileNames: true
4698
+ },
4699
+ include: ["src", "theme.json"]
4700
+ };
4701
+ }
4702
+ function makeIndexHtml(displayName) {
4703
+ return `<!doctype html>
4704
+ <html lang="en">
4705
+ <head>
4706
+ <meta charset="UTF-8" />
4707
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4708
+ <title>${displayName} \u2014 dev preview</title>
4709
+ <link rel="stylesheet" href="/styles.css" />
4710
+ </head>
4711
+ <body>
4712
+ <div id="root"></div>
4713
+ <script type="module" src="/src/main.tsx"></script>
4714
+ </body>
4715
+ </html>
4716
+ `;
4717
+ }
4718
+ function makeTemplates() {
4719
+ return {
4720
+ error: `<!-- Static BYOT error template -->
4721
+ <main role="alert" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui">
4722
+ <div style="text-align:center;max-width:28rem">
4723
+ <h1 style="font-size:1.5rem;font-weight:700;color:#b91c1c">Something went wrong</h1>
4724
+ <p style="color:#374151;margin-top:.5rem">Please try again in a moment.</p>
4725
+ <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>
4726
+ </div>
4727
+ </main>
4728
+ `,
4729
+ loading: `<!-- Static BYOT loading skeleton -->
4730
+ <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">
4731
+ <span style="font-size:.875rem;font-weight:500;color:#4b5563">Loading\u2026</span>
4732
+ </div>
4733
+ `
4734
+ };
4735
+ }
4736
+ var migrateCommand = new import_commander16.Command("migrate").description("Scaffold a V3 theme project from a V2 theme directory").argument(
4737
+ "<v2-path>",
4738
+ "Path to the V2 theme directory (e.g. ../numu-egyptian-bazaar/src/themes/empire)"
4739
+ ).option(
4740
+ "--out <dir>",
4741
+ "Output directory (default: ./<v2-id>-engine-V3)"
4742
+ ).option(
4743
+ "--name <displayName>",
4744
+ "Override the display name (default: title-cased v2 id)"
4745
+ ).action(
4746
+ async (v2Path, options) => {
4747
+ const absV2Path = path14.resolve(process.cwd(), v2Path);
4748
+ if (!fs15.existsSync(absV2Path) || !fs15.statSync(absV2Path).isDirectory()) {
4749
+ console.error(import_chalk.default.red(`V2 path does not exist or is not a directory: ${absV2Path}`));
4750
+ process.exit(1);
4751
+ }
4752
+ const v2Id = path14.basename(absV2Path).toLowerCase().replace(/[^a-z0-9-]/g, "-");
4753
+ const themeId = `${v2Id}-v3`;
4754
+ const displayName = options.name ?? v2Id.charAt(0).toUpperCase() + v2Id.slice(1).replace(/-/g, " ") + " (V3)";
4755
+ const outDir = path14.resolve(
4756
+ process.cwd(),
4757
+ options.out ?? `${v2Id}-engine-V3`
4758
+ );
4759
+ if (fs15.existsSync(outDir)) {
4760
+ console.error(import_chalk.default.red(`Output directory already exists: ${outDir}`));
4761
+ console.error(import_chalk.default.dim("Remove it first, or pass --out to a fresh location."));
4762
+ process.exit(1);
4763
+ }
4764
+ console.log(import_chalk.default.bold(`
4765
+ Migrating V2 \u2192 V3:`));
4766
+ console.log(` ${import_chalk.default.dim("from")} ${absV2Path}`);
4767
+ console.log(` ${import_chalk.default.dim(" to")} ${outDir}
4768
+ `);
4769
+ for (const d of [
4770
+ "src/sections",
4771
+ "src/v2-bridge",
4772
+ "schemas/sections",
4773
+ "schemas/blocks",
4774
+ "templates"
4775
+ ]) {
4776
+ fs15.mkdirSync(path14.join(outDir, d), { recursive: true });
4777
+ }
4778
+ fs15.writeFileSync(
4779
+ path14.join(outDir, "src/v2-bridge", "index.tsx"),
4780
+ makeV2Bridge()
4781
+ );
4782
+ const v2Styles = path14.join(absV2Path, "styles.css");
4783
+ if (fs15.existsSync(v2Styles)) {
4784
+ fs15.copyFileSync(v2Styles, path14.join(outDir, "styles.css"));
4785
+ console.log(import_chalk.default.green(" \u2713 Copied styles.css"));
4786
+ } else {
4787
+ fs15.writeFileSync(
4788
+ path14.join(outDir, "styles.css"),
4789
+ `/* ${displayName} styles */
4790
+ `
4791
+ );
4792
+ console.log(import_chalk.default.yellow(" \u26A0 No styles.css found \u2014 created an empty one"));
4793
+ }
4794
+ const v2SectionsDir = path14.join(absV2Path, "sections");
4795
+ const sectionFiles = fs15.existsSync(v2SectionsDir) ? findSectionFiles(v2SectionsDir) : [];
4796
+ const sectionTypes = [];
4797
+ const allNotes = [];
4798
+ for (const file of sectionFiles) {
4799
+ if (!file.endsWith(".tsx")) continue;
4800
+ const type = sectionTypeFromFilename(file);
4801
+ sectionTypes.push(type);
4802
+ const v2Source = fs15.readFileSync(file, "utf-8");
4803
+ const notes = migrationNotesFor(v2Source);
4804
+ allNotes.push({ file, type, notes });
4805
+ const relativeV2 = path14.relative(process.cwd(), file);
4806
+ const header = adapterCommentBlock(notes, relativeV2);
4807
+ const rewritten = rewriteV2Imports(v2Source);
4808
+ const ported = `${header}
4809
+
4810
+ ${rewritten}`;
4811
+ fs15.writeFileSync(
4812
+ path14.join(outDir, "src/sections", `${type}.tsx`),
4813
+ ported
4814
+ );
4815
+ fs15.writeFileSync(
4816
+ path14.join(outDir, "schemas/sections", `${type}.json`),
4817
+ JSON.stringify(
4818
+ {
4819
+ type,
4820
+ name: type.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" "),
4821
+ settings: []
4822
+ },
4823
+ null,
4824
+ 2
4825
+ )
4826
+ );
4827
+ }
4828
+ console.log(import_chalk.default.green(` \u2713 Ported ${sectionTypes.length} section file(s)`));
4829
+ fs15.writeFileSync(
4830
+ path14.join(outDir, "theme.json"),
4831
+ JSON.stringify(makeThemeJson(themeId, displayName, sectionTypes), null, 2)
4832
+ );
4833
+ fs15.writeFileSync(
4834
+ path14.join(outDir, "settings_schema.json"),
4835
+ JSON.stringify(makeSettingsSchema(), null, 2)
4836
+ );
4837
+ fs15.writeFileSync(
4838
+ path14.join(outDir, "src/main.tsx"),
4839
+ makeMainTsx(themeId, displayName, sectionTypes)
4840
+ );
4841
+ fs15.writeFileSync(
4842
+ path14.join(outDir, "vite.config.ts"),
4843
+ makeViteConfig()
4844
+ );
4845
+ fs15.writeFileSync(
4846
+ path14.join(outDir, "package.json"),
4847
+ JSON.stringify(makePackageJson(themeId, displayName), null, 2)
4848
+ );
4849
+ fs15.writeFileSync(
4850
+ path14.join(outDir, "tsconfig.json"),
4851
+ JSON.stringify(makeTsConfig(), null, 2)
4852
+ );
4853
+ fs15.writeFileSync(
4854
+ path14.join(outDir, "index.html"),
4855
+ makeIndexHtml(displayName)
4856
+ );
4857
+ const tpl = makeTemplates();
4858
+ fs15.writeFileSync(path14.join(outDir, "templates/error.html"), tpl.error);
4859
+ fs15.writeFileSync(path14.join(outDir, "templates/loading.html"), tpl.loading);
4860
+ fs15.writeFileSync(
4861
+ path14.join(outDir, ".gitignore"),
4862
+ "node_modules/\ndist/\n.DS_Store\n"
4863
+ );
4864
+ console.log(import_chalk.default.green(" \u2713 Wrote scaffold files\n"));
4865
+ const filesWithNotes = allNotes.filter((n) => n.notes.length > 0);
4866
+ if (filesWithNotes.length > 0) {
4867
+ console.log(import_chalk.default.bold.yellow("Sections that need manual review:"));
4868
+ for (const { type, notes } of filesWithNotes) {
4869
+ console.log(
4870
+ `
4871
+ ${import_chalk.default.cyan(type)} ${import_chalk.default.dim(`(${notes.length} V2 hooks to swap)`)}`
4872
+ );
4873
+ for (const n of notes) {
4874
+ console.log(
4875
+ ` \u2022 ${import_chalk.default.dim(`L${n.lineHint ?? "?"}`)} ${n.v2} \u2192 ${import_chalk.default.green(n.v3)}`
4876
+ );
4877
+ }
4878
+ }
4879
+ console.log();
4880
+ }
4881
+ console.log(import_chalk.default.bold("\nNext steps:"));
4882
+ console.log(` ${import_chalk.default.cyan("cd")} ${path14.relative(process.cwd(), outDir)}`);
4883
+ console.log(` ${import_chalk.default.cyan("npm install")}`);
4884
+ console.log(` ${import_chalk.default.cyan("npm run dev")} ${import_chalk.default.dim("# dev preview on :5173")}`);
4885
+ console.log(` ${import_chalk.default.cyan("npx numu-theme build")} ${import_chalk.default.dim("# validate + build")}`);
4886
+ console.log(
4887
+ `
4888
+ ${import_chalk.default.dim("Open each src/sections/*.tsx and follow the ADAPTER NOTES at the top.")}`
4889
+ );
4890
+ }
4891
+ );
4892
+
3864
4893
  // src/index.ts
3865
- var program = new import_commander16.Command();
4894
+ var program = new import_commander17.Command();
3866
4895
  program.name("numu-theme").description("CLI for developing, validating, building, and publishing NUMU themes").version("0.1.0");
3867
4896
  program.addCommand(initCommand);
3868
4897
  program.addCommand(devCommand);
@@ -3879,4 +4908,5 @@ program.addCommand(addSectionCommand);
3879
4908
  program.addCommand(addBlockCommand);
3880
4909
  program.addCommand(pullCommand);
3881
4910
  program.addCommand(deleteCommand);
4911
+ program.addCommand(migrateCommand);
3882
4912
  program.parse();