@zohofinance/commerce 1.0.0 → 1.1.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 (127) hide show
  1. package/README.md +59 -0
  2. package/package.json +2 -2
  3. package/ts-dist/mocks/init-theme/.claude/CLAUDE.md +6 -0
  4. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/SKILL.md +242 -0
  5. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/inputs/figma.md +81 -0
  6. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/inputs/screenshot.md +54 -0
  7. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/inputs/url.md +88 -0
  8. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/spec-table.md +102 -0
  9. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/accessibility.md +110 -0
  10. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/performance.md +127 -0
  11. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/tokens.md +87 -0
  12. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/visual-diff.md +123 -0
  13. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/SKILL.md +605 -0
  14. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/design-tokens.md +105 -0
  15. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/page-json-contract.md +58 -0
  16. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/qa-accessibility.md +35 -0
  17. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/reference-lookup.md +94 -0
  18. package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/section-anatomy.md +109 -0
  19. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/SKILL.md +313 -0
  20. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/agent-hierarchy.md +142 -0
  21. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/ask-developer.md +113 -0
  22. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/bootstrap.md +260 -0
  23. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/build-metrics.md +89 -0
  24. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/code-quality.md +186 -0
  25. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/common-pages-agent.md +97 -0
  26. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/dev-server-and-urls.md +136 -0
  27. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/exemplars.md +79 -0
  28. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/figma-discovery.md +138 -0
  29. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/figma-extraction.md +132 -0
  30. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/image-sources.md +84 -0
  31. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/inputs/guidelines-frame.md +178 -0
  32. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/page-builder.md +322 -0
  33. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/page-orchestrator.md +199 -0
  34. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/parallel-fanout.md +152 -0
  35. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/a11y.mjs +129 -0
  36. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/lint-section.mjs +502 -0
  37. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/perf.mjs +134 -0
  38. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/pixel-diff.mjs +206 -0
  39. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/record-build-metrics.mjs +118 -0
  40. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/section-builder.md +131 -0
  41. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/subagent-escalation.md +75 -0
  42. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/transcription-rules.md +98 -0
  43. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/verification.md +227 -0
  44. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/visual-verify-loop.md +117 -0
  45. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/_index.md +4 -0
  46. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/assets.md +105 -0
  47. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/best-practices.md +212 -0
  48. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/custom-scripts.md +294 -0
  49. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/i18n.md +155 -0
  50. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/introduction.md +91 -0
  51. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/layouts.md +158 -0
  52. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/pages.md +156 -0
  53. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/_index.md +4 -0
  54. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/filters.md +419 -0
  55. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/global-objects.md +415 -0
  56. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/schema-types.md +926 -0
  57. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/tags.md +259 -0
  58. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/sections.md +203 -0
  59. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/setup-guide.md +424 -0
  60. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/snippets.md +70 -0
  61. package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/theme-structure.md +252 -0
  62. package/ts-dist/mocks/init-theme/.cursor/PLAYBOOK.md +144 -0
  63. package/ts-dist/mocks/init-theme/.cursor/README.md +6 -0
  64. package/ts-dist/mocks/init-theme/.cursor/agents/figma-desktop-mobile-parallel.md +48 -0
  65. package/ts-dist/mocks/init-theme/.cursor/agents/visual-compare.md +221 -0
  66. package/ts-dist/mocks/init-theme/.cursor/commands/add-field.md +83 -0
  67. package/ts-dist/mocks/init-theme/.cursor/commands/add-theme-setting.md +200 -0
  68. package/ts-dist/mocks/init-theme/.cursor/commands/assemble-page.md +72 -0
  69. package/ts-dist/mocks/init-theme/.cursor/commands/audit-page.md +57 -0
  70. package/ts-dist/mocks/init-theme/.cursor/commands/build-page-sections.md +110 -0
  71. package/ts-dist/mocks/init-theme/.cursor/commands/build-section.md +418 -0
  72. package/ts-dist/mocks/init-theme/.cursor/commands/build-theme.md +307 -0
  73. package/ts-dist/mocks/init-theme/.cursor/commands/compare-section.md +358 -0
  74. package/ts-dist/mocks/init-theme/.cursor/commands/create-theme.md +77 -0
  75. package/ts-dist/mocks/init-theme/.cursor/commands/extract-tokens.md +43 -0
  76. package/ts-dist/mocks/init-theme/.cursor/commands/fix-section.md +92 -0
  77. package/ts-dist/mocks/init-theme/.cursor/commands/impact.md +50 -0
  78. package/ts-dist/mocks/init-theme/.cursor/commands/qa-a11y.md +66 -0
  79. package/ts-dist/mocks/init-theme/.cursor/commands/qa-i18n.md +110 -0
  80. package/ts-dist/mocks/init-theme/.cursor/commands/qa-layout.md +127 -0
  81. package/ts-dist/mocks/init-theme/.cursor/commands/qa-performance.md +72 -0
  82. package/ts-dist/mocks/init-theme/.cursor/commands/qa-section.md +84 -0
  83. package/ts-dist/mocks/init-theme/.cursor/commands/qa-sections.md +48 -0
  84. package/ts-dist/mocks/init-theme/.cursor/commands/qa-theme.md +85 -0
  85. package/ts-dist/mocks/init-theme/.cursor/commands/redesign-section.md +177 -0
  86. package/ts-dist/mocks/init-theme/.cursor/commands/refactor-section.md +185 -0
  87. package/ts-dist/mocks/init-theme/.cursor/commands/sync-theme-settings.md +132 -0
  88. package/ts-dist/mocks/init-theme/.cursor/commands/validate-page.md +60 -0
  89. package/ts-dist/mocks/init-theme/.cursor/commands/validate-section-group.md +88 -0
  90. package/ts-dist/mocks/init-theme/.cursor/commands/validate-theme-settings.md +119 -0
  91. package/ts-dist/mocks/init-theme/.cursor/examples/design-instructions-template.md +151 -0
  92. package/ts-dist/mocks/init-theme/.cursor/mcp.json +18 -0
  93. package/ts-dist/mocks/init-theme/.cursor/rules/README.md +160 -0
  94. package/ts-dist/mocks/init-theme/.cursor/rules/accessibility.mdc +24 -0
  95. package/ts-dist/mocks/init-theme/.cursor/rules/add-field.mdc +222 -0
  96. package/ts-dist/mocks/init-theme/.cursor/rules/add-theme-setting.mdc +39 -0
  97. package/ts-dist/mocks/init-theme/.cursor/rules/assemble-page.mdc +130 -0
  98. package/ts-dist/mocks/init-theme/.cursor/rules/audit-page.mdc +92 -0
  99. package/ts-dist/mocks/init-theme/.cursor/rules/build-section.mdc +178 -0
  100. package/ts-dist/mocks/init-theme/.cursor/rules/build-theme.mdc +47 -0
  101. package/ts-dist/mocks/init-theme/.cursor/rules/compare-section.mdc +94 -0
  102. package/ts-dist/mocks/init-theme/.cursor/rules/create-theme.mdc +78 -0
  103. package/ts-dist/mocks/init-theme/.cursor/rules/css.mdc +137 -0
  104. package/ts-dist/mocks/init-theme/.cursor/rules/design-spec-extraction.mdc +255 -0
  105. package/ts-dist/mocks/init-theme/.cursor/rules/extract-tokens.mdc +43 -0
  106. package/ts-dist/mocks/init-theme/.cursor/rules/figma-mcp-resolution.mdc +40 -0
  107. package/ts-dist/mocks/init-theme/.cursor/rules/fix-section.mdc +154 -0
  108. package/ts-dist/mocks/init-theme/.cursor/rules/general.mdc +30 -0
  109. package/ts-dist/mocks/init-theme/.cursor/rules/i18n.mdc +85 -0
  110. package/ts-dist/mocks/init-theme/.cursor/rules/javascript.mdc +85 -0
  111. package/ts-dist/mocks/init-theme/.cursor/rules/liquid-no-hardcoded-strings.mdc +42 -0
  112. package/ts-dist/mocks/init-theme/.cursor/rules/liquid.mdc +171 -0
  113. package/ts-dist/mocks/init-theme/.cursor/rules/mobile-desktop-setting-meta.mdc +34 -0
  114. package/ts-dist/mocks/init-theme/.cursor/rules/naming-convention.mdc +64 -0
  115. package/ts-dist/mocks/init-theme/.cursor/rules/pages.mdc +120 -0
  116. package/ts-dist/mocks/init-theme/.cursor/rules/performance.mdc +51 -0
  117. package/ts-dist/mocks/init-theme/.cursor/rules/qa-section.mdc +134 -0
  118. package/ts-dist/mocks/init-theme/.cursor/rules/redesign-section.mdc +147 -0
  119. package/ts-dist/mocks/init-theme/.cursor/rules/schema-sync.mdc +110 -0
  120. package/ts-dist/mocks/init-theme/.cursor/rules/schema.mdc +360 -0
  121. package/ts-dist/mocks/init-theme/.cursor/rules/share-urls.mdc +68 -0
  122. package/ts-dist/mocks/init-theme/.cursor/skills/browser-verify.md +128 -0
  123. package/ts-dist/mocks/init-theme/.cursor/skills/design-tokens.md +105 -0
  124. package/ts-dist/mocks/init-theme/.cursor/skills/page-json-contract.md +87 -0
  125. package/ts-dist/mocks/init-theme/.cursor/skills/qa-accessibility.md +35 -0
  126. package/ts-dist/mocks/init-theme/.cursor/skills/reference-lookup.md +88 -0
  127. package/ts-dist/mocks/init-theme/.cursor/skills/section-anatomy.md +173 -0
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-section.mjs — write-time lint for ZCommerce Liquid section files.
4
+ *
5
+ * Enforces code-quality budgets defined in code-quality.md.
6
+ *
7
+ * Usage:
8
+ * node lint-section.mjs <path/to/section.liquid>
9
+ *
10
+ * Output: JSON report on stdout. Exits 0 on PASS, 1 on FAIL.
11
+ *
12
+ * Checks:
13
+ * lines_under_250 file ≤ 250 lines
14
+ * max_classes_per_el no element with > 12 utility classes
15
+ * max_dom_depth HTML nesting ≤ 6 (excluding Liquid blocks)
16
+ * empty_wrappers 0 wrappers whose only child is a single element with no layout role
17
+ * if_nesting_depth {% if %} nesting ≤ 2
18
+ * single_liquid_block all top-level {% assign %} consolidated into one {% liquid %} block
19
+ * inline_event_handlers 0 onclick=/onload=/etc.
20
+ * hardcoded_hex 0 hardcoded #rrggbb in inline style= or in {% style %} block
21
+ * hardcoded_inline_px 0 px values in inline style= for font-size, padding, margin
22
+ * schema_fields_wired every schema setting id is referenced in markup
23
+ * render_not_include 0 {% include %} (must use {% render %})
24
+ * escape_user_output warning if {{ var }} from settings is used in attribute context without | escape
25
+ *
26
+ * No external dependencies — pure Node.
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ const BUDGET = {
33
+ lines: 250,
34
+ classesPerElement: 12,
35
+ domDepth: 6,
36
+ ifDepth: 2,
37
+ };
38
+
39
+ // Args: <file> [--i18n <path-to-en.json>] [--i18n-schema <path-to-en.schema.json>]
40
+ // If --i18n is omitted, the linter tries to auto-locate i18n/en.json by walking up
41
+ // from the section file path. Provide the flags explicitly for snippets that live
42
+ // outside a theme tree.
43
+ const argv = process.argv.slice(2);
44
+ const file = argv[0];
45
+ if (!file || file.startsWith('--')) {
46
+ console.error('Usage: node lint-section.mjs <file> [--i18n <en.json>] [--i18n-schema <en.schema.json>]');
47
+ process.exit(2);
48
+ }
49
+
50
+ function flagValue(name) {
51
+ const i = argv.indexOf(name);
52
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
53
+ }
54
+
55
+ function autoLocateI18n(sectionPath) {
56
+ // Walk up from the section file looking for a sibling i18n/ folder.
57
+ // Typical layout: templates/<theme>/sections/foo.liquid → templates/<theme>/i18n/en.json
58
+ let dir = path.dirname(path.resolve(sectionPath));
59
+ for (let i = 0; i < 6; i++) {
60
+ const candidate = path.join(dir, 'i18n', 'en.json');
61
+ if (fs.existsSync(candidate)) return candidate;
62
+ const parent = path.dirname(dir);
63
+ if (parent === dir) break;
64
+ dir = parent;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ const i18nPath = flagValue('--i18n') || autoLocateI18n(file);
70
+ const i18nSchemaPath = flagValue('--i18n-schema')
71
+ || (i18nPath ? path.join(path.dirname(i18nPath), 'en.schema.json') : null);
72
+
73
+ const src = fs.readFileSync(file, 'utf-8');
74
+
75
+ const report = { file, lines: src.split('\n').length, checks: {} };
76
+
77
+ report.checks.lines_under_250 = {
78
+ pass: report.lines <= BUDGET.lines,
79
+ actual: report.lines,
80
+ budget: BUDGET.lines,
81
+ };
82
+
83
+ // Strip {% schema %}…{% endschema %} from the linted body to scope checks
84
+ const bodyOnly = src.replace(/\{%-?\s*schema\s*-?%\}[\s\S]*?\{%-?\s*endschema\s*-?%\}/g, '');
85
+ const schemaBlock = (src.match(/\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/) || [, ''])[1];
86
+
87
+ // ── Tailwind class count per element ─────────────────────────────────────────
88
+ {
89
+ const re = /class\s*=\s*"([^"]+)"/g;
90
+ let m, max = 0, worstSnippet = '';
91
+ while ((m = re.exec(bodyOnly))) {
92
+ const classes = m[1].trim().split(/\s+/).filter(Boolean);
93
+ // Skip dynamic Liquid-only class strings ({{ ... }}) — count what's there literally
94
+ const staticCount = classes.filter((c) => !c.includes('{{') && !c.includes('{%')).length;
95
+ if (staticCount > max) { max = staticCount; worstSnippet = m[0].slice(0, 200); }
96
+ }
97
+ report.checks.max_classes_per_el = {
98
+ pass: max <= BUDGET.classesPerElement,
99
+ actual: max,
100
+ budget: BUDGET.classesPerElement,
101
+ elementHint: worstSnippet || null,
102
+ };
103
+ }
104
+
105
+ // ── DOM depth (rough — counts < ... > opening tags, decrements on </) ─────────
106
+ {
107
+ const tokens = bodyOnly.match(/<\/?[a-zA-Z][^<>]*>/g) || [];
108
+ let depth = 0, max = 0;
109
+ for (const t of tokens) {
110
+ if (/^<\//.test(t)) {
111
+ depth = Math.max(0, depth - 1);
112
+ } else if (!/\/>$/.test(t) && !/^<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)\b/i.test(t)) {
113
+ depth += 1;
114
+ if (depth > max) max = depth;
115
+ }
116
+ }
117
+ report.checks.max_dom_depth = { pass: max <= BUDGET.domDepth, actual: max, budget: BUDGET.domDepth };
118
+ }
119
+
120
+ // ── Empty wrappers ────────────────────────────────────────────────────────────
121
+ //
122
+ // True empty wrapper: <div> with NO class/style/attrs containing a single element.
123
+ // Allow:
124
+ // - wrapper has a class with at least one non-trivial utility (it has a layout role)
125
+ // - wrapper has any other attribute (data-*, role, aria-*)
126
+ // - inner element is an <img> / <picture> / <video> / <iframe> (media often needs a wrapper)
127
+ // - Liquid blocks inside (a wrapper containing {% for %} or {% if %} has a logical role)
128
+ {
129
+ const re = /<div\b([^>]*)>([\s\S]*?)<\/div>/g;
130
+ let m, count = 0, samples = [];
131
+ while ((m = re.exec(bodyOnly))) {
132
+ const attrs = m[1].trim();
133
+ const inner = m[2];
134
+
135
+ // Skip if wrapper has any class with a non-empty value, OR has any non-class attr
136
+ const hasClass = /\bclass\s*=\s*"[^"]*\S[^"]*"/.test(attrs);
137
+ const hasOtherAttr = /\b(?:id|role|aria-|data-|style|hidden|tabindex)/.test(attrs);
138
+ if (hasClass || hasOtherAttr) continue;
139
+
140
+ // Skip if inner contains liquid logic blocks
141
+ if (/\{%-?\s*(?:for|if|case|unless|liquid|render)\b/.test(inner)) continue;
142
+
143
+ // Check inner for single-element child
144
+ const trimmed = inner.trim();
145
+ const childOpenTags = trimmed.match(/<[a-zA-Z]/g) || [];
146
+ if (childOpenTags.length !== 1) continue;
147
+
148
+ // Inner element is media → allow (legitimate wrapping)
149
+ if (/^<(img|picture|video|iframe|svg)\b/.test(trimmed)) continue;
150
+
151
+ count++;
152
+ if (samples.length < 3) samples.push(m[0].slice(0, 120));
153
+ }
154
+ report.checks.empty_wrappers = { pass: count === 0, count, samples };
155
+ }
156
+
157
+ // ── if nesting depth ──────────────────────────────────────────────────────────
158
+ {
159
+ const ifOpens = [...bodyOnly.matchAll(/\{%-?\s*if\b/g)].map((m) => m.index);
160
+ const ifEnds = [...bodyOnly.matchAll(/\{%-?\s*endif\b/g)].map((m) => m.index);
161
+ // Compute max nesting by walking the events in order
162
+ const events = [
163
+ ...ifOpens.map((i) => ({ i, t: 'open' })),
164
+ ...ifEnds.map((i) => ({ i, t: 'close' })),
165
+ ].sort((a, b) => a.i - b.i);
166
+ let depth = 0, max = 0;
167
+ for (const e of events) {
168
+ if (e.t === 'open') { depth++; if (depth > max) max = depth; } else { depth--; }
169
+ }
170
+ report.checks.if_nesting_depth = { pass: max <= BUDGET.ifDepth, actual: max, budget: BUDGET.ifDepth };
171
+ }
172
+
173
+ // ── single liquid block: count top-level {% assign %} outside any {% liquid %} block ──
174
+ {
175
+ // Remove {% liquid %}…{% endliquid %} blocks (so assigns inside are not counted)
176
+ const withoutLiquidBlocks = bodyOnly.replace(/\{%-?\s*liquid\s*[\s\S]*?-?%\}/g, '');
177
+ const looseAssigns = (withoutLiquidBlocks.match(/\{%-?\s*assign\b/g) || []).length;
178
+ report.checks.single_liquid_block = {
179
+ pass: looseAssigns === 0,
180
+ loose_assigns_outside_liquid_block: looseAssigns,
181
+ };
182
+ }
183
+
184
+ // ── inline event handlers ─────────────────────────────────────────────────────
185
+ {
186
+ const re = /\bon[a-z]+\s*=\s*"/g;
187
+ const count = (bodyOnly.match(re) || []).length;
188
+ report.checks.inline_event_handlers = { pass: count === 0, count };
189
+ }
190
+
191
+ // ── hardcoded hex colors (in inline style= or {% style %} block) ──────────────
192
+ {
193
+ // In inline style attrs
194
+ const styleAttrs = [...bodyOnly.matchAll(/\bstyle\s*=\s*"([^"]+)"/g)].map((m) => m[1]).join(' ');
195
+ // In {% style %} blocks
196
+ const styleBlocks = [...bodyOnly.matchAll(/\{%-?\s*style\s*-?%\}([\s\S]*?)\{%-?\s*endstyle\s*-?%\}/g)].map((m) => m[1]).join(' ');
197
+ const haystack = styleAttrs + ' ' + styleBlocks;
198
+ const hexes = haystack.match(/#[0-9a-fA-F]{6}\b/g) || [];
199
+ report.checks.hardcoded_hex = { pass: hexes.length === 0, count: hexes.length, samples: hexes.slice(0, 5) };
200
+ }
201
+
202
+ // ── hardcoded inline px ───────────────────────────────────────────────────────
203
+ {
204
+ const styleAttrs = [...bodyOnly.matchAll(/\bstyle\s*=\s*"([^"]+)"/g)].map((m) => m[1]).join(' ');
205
+ // Look for font-size: NNpx, padding: NNpx, margin: NNpx outside {{...}} contexts
206
+ const px = styleAttrs.match(/(?:font-size|padding|margin)[^;]*?\b\d+px/gi) || [];
207
+ report.checks.hardcoded_inline_px = { pass: px.length === 0, count: px.length, samples: px.slice(0, 5) };
208
+ }
209
+
210
+ // ── schema fields wired ───────────────────────────────────────────────────────
211
+ {
212
+ // Pull section.settings ids
213
+ const sectionIds = [...schemaBlock.matchAll(/"id"\s*:\s*"([^"]+)"/g)].map((m) => m[1]);
214
+ const unique = [...new Set(sectionIds)];
215
+
216
+ // Detect alias variables: `assign foo = section.settings` or `assign foo = block.settings`.
217
+ // Captures the alias name (e.g. "foo") so we can match `foo.<id>` as wiring too.
218
+ const aliasNames = new Set();
219
+ const aliasRe = /\bassign\s+(\w+)\s*=\s*(?:section|block)\.settings\b/g;
220
+ for (const m of bodyOnly.matchAll(aliasRe)) aliasNames.add(m[1]);
221
+ // Also handle the `{% capture %}`-style and `{% liquid \n assign x = section.settings %}` blocks
222
+ const liquidBlocks = [...bodyOnly.matchAll(/\{%-?\s*liquid([\s\S]*?)-?%\}/g)].map((m) => m[1]).join('\n');
223
+ for (const m of liquidBlocks.matchAll(aliasRe)) aliasNames.add(m[1]);
224
+
225
+ const wireableRegexes = (id) => {
226
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
227
+ const patterns = [
228
+ `\\bsection\\.settings\\.${escaped}\\b`,
229
+ `\\bblock\\.settings\\.${escaped}\\b`,
230
+ `\\bsettings\\.${escaped}\\b`,
231
+ ];
232
+ for (const alias of aliasNames) {
233
+ patterns.push(`\\b${alias}\\.${escaped}\\b`);
234
+ }
235
+ return new RegExp(patterns.join('|'));
236
+ };
237
+
238
+ const haystack = bodyOnly + '\n' + liquidBlocks;
239
+ const unused = unique.filter((id) => !wireableRegexes(id).test(haystack));
240
+
241
+ report.checks.schema_fields_wired = {
242
+ pass: unused.length === 0,
243
+ wired: unique.length - unused.length,
244
+ total: unique.length,
245
+ aliases_detected: [...aliasNames],
246
+ unused,
247
+ };
248
+ }
249
+
250
+ // ── render not include ────────────────────────────────────────────────────────
251
+ {
252
+ const count = (bodyOnly.match(/\{%-?\s*include\b/g) || []).length;
253
+ report.checks.render_not_include = { pass: count === 0, count_include: count };
254
+ }
255
+
256
+ // ── Liquid filter validity ────────────────────────────────────────────────────
257
+ //
258
+ // Source of truth: zcommerce-docs/reference/filters.md. ZCommerce supports a
259
+ // specific set of filters; Shopify-only filters (money, money_with_currency,
260
+ // img_url, weight_with_unit, etc.) DO NOT WORK and silently produce wrong output
261
+ // or crash the engine. Agents have a habit of using Shopify filters from training
262
+ // data — this catches it.
263
+ {
264
+ const VALID_FILTERS = new Set([
265
+ // ZCommerce-specific
266
+ 'asset_url', 'image_url', 'color_component', 't', 'translate', 'json', 'default',
267
+ // String
268
+ 'append', 'capitalize', 'downcase', 'upcase',
269
+ 'strip', 'lstrip', 'rstrip', 'strip_html', 'strip_newlines',
270
+ 'escape', 'url_encode', 'url_decode',
271
+ 'truncate', 'truncatewords',
272
+ 'replace', 'replace_first',
273
+ 'remove', 'remove_first',
274
+ 'prepend', 'newline_to_br', 'split', 'slice',
275
+ // Math
276
+ 'plus', 'minus', 'times', 'divided_by', 'modulo',
277
+ 'round', 'ceil', 'floor', 'abs', 'at_most', 'at_least',
278
+ // Array
279
+ 'map', 'where', 'sort', 'sort_natural',
280
+ 'join', 'first', 'last', 'size', 'reverse', 'uniq', 'compact', 'concat',
281
+ // Date
282
+ 'date',
283
+ ]);
284
+ // Find filter applications: `{{ x | filter_name }}` or `{{ x | filter_name: arg }}`
285
+ // Also catch chained: `{{ x | a | b | c }}`
286
+ const filterRe = /\|\s*([a-z_][a-z0-9_]*)/gi;
287
+ const seen = new Map();
288
+ for (const m of src.matchAll(filterRe)) {
289
+ const name = m[1];
290
+ seen.set(name, (seen.get(name) ?? 0) + 1);
291
+ }
292
+ const invalid = [];
293
+ for (const [name, count] of seen) {
294
+ if (!VALID_FILTERS.has(name)) invalid.push({ filter: name, count });
295
+ }
296
+ report.checks.liquid_filters_valid = {
297
+ pass: invalid.length === 0,
298
+ invalid_filters: invalid.slice(0, 10),
299
+ invalid_count: invalid.length,
300
+ hint: invalid.length > 0
301
+ ? `Invalid Liquid filter(s): ${invalid.map(i => i.filter).join(', ')}. Check zcommerce-docs/reference/filters.md for the supported list. Shopify-only filters (money, money_with_currency, img_url, weight_with_unit, etc.) do not work in ZCommerce.`
302
+ : null,
303
+ };
304
+ }
305
+
306
+ // ── Liquid tag validity ───────────────────────────────────────────────────────
307
+ //
308
+ // ZCommerce's template engine (LiquidJS) only knows: LiquidJS built-in tags
309
+ // + the custom tags registered by the Commerce CLI (schema, style, sections).
310
+ // (schema, style, sections).
311
+ //
312
+ // A common failure mode: the agent emits Shopify-only tags like `paginate`,
313
+ // `form`, `javascript`, `stylesheet` — these throw "tag X not found" at render
314
+ // time. The crash that shipped in real-world use was `{% paginate %}` in
315
+ // product-carousel-section.liquid.
316
+ {
317
+ // Source of truth: zcommerce-docs/reference/tags.md.
318
+ // Anything NOT in this list will crash the local dev server. The Shopify-only
319
+ // tags (paginate, form, javascript, stylesheet, etc.) are notably absent.
320
+ const ZCOMMERCE_DOCUMENTED = new Set([
321
+ // control flow
322
+ 'if', 'elsif', 'else', 'endif',
323
+ 'unless', 'endunless',
324
+ 'case', 'when', 'endcase',
325
+ // iteration
326
+ 'for', 'endfor', 'break', 'continue',
327
+ // variables
328
+ 'liquid',
329
+ 'assign',
330
+ 'capture', 'endcapture',
331
+ 'increment', 'decrement',
332
+ // composition
333
+ 'render',
334
+ 'sections',
335
+ 'schema', 'endschema',
336
+ // utility
337
+ 'comment', 'endcomment',
338
+ 'raw', 'endraw',
339
+ // ZCommerce custom (registered in CLI)
340
+ 'style', 'endstyle',
341
+ ]);
342
+ const VALID_TAGS = ZCOMMERCE_DOCUMENTED;
343
+
344
+ // Match `{% tagname` or `{%- tagname` — capture the first word after the opener
345
+ const tagRe = /\{%-?\s*(\w+)/g;
346
+ const seen = new Map();
347
+ for (const m of src.matchAll(tagRe)) {
348
+ const tag = m[1];
349
+ seen.set(tag, (seen.get(tag) ?? 0) + 1);
350
+ }
351
+ const invalid = [];
352
+ for (const [tag, count] of seen) {
353
+ if (!VALID_TAGS.has(tag)) invalid.push({ tag, count });
354
+ }
355
+ report.checks.liquid_tags_valid = {
356
+ pass: invalid.length === 0,
357
+ invalid_tags: invalid.slice(0, 10),
358
+ invalid_count: invalid.length,
359
+ hint: invalid.length > 0
360
+ ? `Invalid Liquid tag(s): ${invalid.map(i => i.tag).join(', ')}. ZCommerce only supports LiquidJS built-ins + custom: schema, style, sections. Shopify-only tags (paginate, form, javascript, stylesheet) DO NOT WORK and crash at render time.`
361
+ : null,
362
+ };
363
+ }
364
+
365
+ // ── i18n keys resolve ─────────────────────────────────────────────────────────
366
+ //
367
+ // Every `'a.b.c' | t` (or | translate) key referenced in the section must exist
368
+ // in en.json (content) or en.schema.json (schema labels). This is the bug that
369
+ // crashed category/product/blog pages in real-world use — schema declared keys
370
+ // like `sections.foo.settings.bar.label` were referenced in markup but never
371
+ // added to en.json.
372
+ //
373
+ // Resolution rules:
374
+ // - Keys starting with "sections.*.settings.*.label" / ".default" usually live in en.schema.json
375
+ // - Other keys (e.g. "category.sort_by") usually live in en.json
376
+ // - We check BOTH files and accept the key if it resolves in either
377
+ // - `{{ section.settings.X | t }}` and `{{ block.settings.X | t }}` are runtime
378
+ // translations of a value, not literal key references — those are skipped
379
+ {
380
+ if (!i18nPath || !fs.existsSync(i18nPath)) {
381
+ report.checks.i18n_keys_resolve = {
382
+ pass: true,
383
+ skipped: true,
384
+ reason: 'No i18n/en.json found — pass --i18n explicitly to enforce',
385
+ };
386
+ } else {
387
+ const enJson = JSON.parse(fs.readFileSync(i18nPath, 'utf-8'));
388
+ const enSchema = (i18nSchemaPath && fs.existsSync(i18nSchemaPath))
389
+ ? JSON.parse(fs.readFileSync(i18nSchemaPath, 'utf-8'))
390
+ : {};
391
+
392
+ const resolve = (obj, parts) => {
393
+ let cur = obj;
394
+ for (const p of parts) {
395
+ if (cur == null || typeof cur !== 'object') return undefined;
396
+ cur = cur[p];
397
+ }
398
+ return cur;
399
+ };
400
+
401
+ // RUNTIME translation keys: `'a.b.c' | t` / `"a.b.c" | t` / `| translate`
402
+ // These are resolved at render time and MUST exist in en.json (NOT en.schema.json,
403
+ // which is for the admin theme editor only). This is exactly the bug that crashed
404
+ // toy_story_test's category page: keys were written to en.schema.json but the
405
+ // section used `| t` at runtime → undefined → property-access crash.
406
+ const litRe = /['"]([a-zA-Z][a-zA-Z0-9_.]*)['"]\s*\|\s*(?:t|translate)\b/g;
407
+ const literals = [...bodyOnly.matchAll(litRe)].map((m) => m[1]);
408
+
409
+ // SCHEMA-LABEL keys: `"label": "t:sections.foo.label"` inside the {% schema %} block.
410
+ // These are resolved by the theme editor and MUST exist in en.schema.json.
411
+ const schemaLabelRe = /"t:([a-zA-Z][a-zA-Z0-9_.]*)"/g;
412
+ const schemaLabels = [...schemaBlock.matchAll(schemaLabelRe)].map((m) => m[1]);
413
+
414
+ // Check runtime keys against en.json (strict)
415
+ const runtimeMisplaced = []; // present in schema, missing from en.json — the bug
416
+ const runtimeMissing = []; // missing from BOTH
417
+ for (const key of [...new Set(literals)]) {
418
+ const parts = key.split('.');
419
+ const inEn = resolve(enJson, parts) !== undefined;
420
+ if (inEn) continue;
421
+ const inSchema = resolve(enSchema, parts) !== undefined;
422
+ if (inSchema) runtimeMisplaced.push(key);
423
+ else runtimeMissing.push(key);
424
+ }
425
+
426
+ // Check schema-label keys against en.schema.json (strict)
427
+ const schemaLabelMissing = [];
428
+ for (const key of [...new Set(schemaLabels)]) {
429
+ const parts = key.split('.');
430
+ const inSchema = resolve(enSchema, parts) !== undefined;
431
+ if (!inSchema) schemaLabelMissing.push(key);
432
+ }
433
+
434
+ const runtimeTotalFail = runtimeMisplaced.length + runtimeMissing.length;
435
+ report.checks.i18n_keys_resolve = {
436
+ pass: runtimeTotalFail === 0 && schemaLabelMissing.length === 0,
437
+ runtime_keys_referenced: literals.length,
438
+ runtime_unique: new Set(literals).size,
439
+ runtime_misplaced_in_schema: runtimeMisplaced.slice(0, 10),
440
+ runtime_misplaced_count: runtimeMisplaced.length,
441
+ runtime_missing_entirely: runtimeMissing.slice(0, 10),
442
+ runtime_missing_count: runtimeMissing.length,
443
+ schema_label_missing: schemaLabelMissing.slice(0, 10),
444
+ schema_label_missing_count: schemaLabelMissing.length,
445
+ i18n_file: i18nPath,
446
+ i18n_schema_file: i18nSchemaPath || null,
447
+ hint: runtimeMisplaced.length > 0
448
+ ? "Runtime `| t` keys exist in en.schema.json but not en.json. Move them — `| t` only resolves against en.json at render time. en.schema.json is for the admin theme editor only."
449
+ : null,
450
+ };
451
+ }
452
+ }
453
+
454
+ // ── snippet scope sanity ──────────────────────────────────────────────────────
455
+ //
456
+ // `settings.X` (without `section.` or `block.` prefix) is almost always a snippet
457
+ // scope bug. `settings` isn't a global. Real causes:
458
+ // (1) Author confused snippet variable scope with section scope.
459
+ // (2) Snippet expected a `settings` parameter via render: `{% render 'foo', settings: section.settings %}`
460
+ // and used `settings.X` correctly — this case is fine if the parameter is documented.
461
+ //
462
+ // We can't tell (1) from (2) statically, so this check is a WARNING by default,
463
+ // promoted to a FAIL if the file path indicates a section (sections/*.liquid).
464
+ // Snippets get a warning only.
465
+ {
466
+ const isSection = /\/sections\/[^/]+\.liquid$/.test(file);
467
+ // Match bare `settings.X` not preceded by `section.` or `block.`
468
+ // Use a negative lookbehind via a manual exclusion scan.
469
+ const allMatches = [...bodyOnly.matchAll(/\bsettings\.([a-zA-Z_]\w*)/g)];
470
+ const bareSettings = [];
471
+ for (const m of allMatches) {
472
+ const before = bodyOnly.slice(Math.max(0, m.index - 8), m.index);
473
+ if (!/section\.$/.test(before) && !/block\.$/.test(before)) {
474
+ bareSettings.push({ key: m[1], context: bodyOnly.slice(m.index - 20, m.index + 30).replace(/\s+/g, ' ') });
475
+ }
476
+ }
477
+ report.checks.snippet_scope_sanity = {
478
+ pass: isSection ? bareSettings.length === 0 : true,
479
+ is_section: isSection,
480
+ bare_settings_refs: bareSettings.slice(0, 5),
481
+ bare_settings_count: bareSettings.length,
482
+ note: bareSettings.length && !isSection
483
+ ? 'Warning only — `settings.X` is acceptable in snippets if `settings` is passed via render parameter.'
484
+ : null,
485
+ };
486
+ }
487
+
488
+ // ── escape user output (warning only) ─────────────────────────────────────────
489
+ {
490
+ // Look for {{ section.settings.X }} or {{ block.settings.X }} inside attribute context without | escape
491
+ const attrCtx = [...bodyOnly.matchAll(/\b\w+\s*=\s*"[^"]*\{\{([^}]+)\}\}[^"]*"/g)].map((m) => m[1].trim());
492
+ const warnings = attrCtx.filter((s) => !s.includes('| escape') && !s.includes('|escape') && /settings\./.test(s));
493
+ report.checks.escape_user_output = { pass: true, warnings: warnings.slice(0, 5), count: warnings.length };
494
+ }
495
+
496
+ // ── Final verdict ─────────────────────────────────────────────────────────────
497
+ const failed = Object.entries(report.checks).filter(([, v]) => v.pass === false);
498
+ report.verdict = failed.length === 0 ? 'PASS' : 'FAIL';
499
+ report.failed_checks = failed.map(([k]) => k);
500
+
501
+ console.log(JSON.stringify(report, null, 2));
502
+ process.exit(failed.length === 0 ? 0 : 1);
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * perf.mjs — run Lighthouse against a URL via headless Chrome.
4
+ *
5
+ * Usage:
6
+ * node perf.mjs \
7
+ * --url http://localhost:3000/home \
8
+ * --form-factor mobile | desktop (default: mobile)
9
+ * --out /path/to/lighthouse.json
10
+ *
11
+ * Output JSON shape:
12
+ * {
13
+ * "url": "...",
14
+ * "form_factor": "mobile",
15
+ * "scores": {
16
+ * "performance": 92, "accessibility": 95, "best-practices": 100, "seo": 98
17
+ * },
18
+ * "metrics": {
19
+ * "lcp_ms": 1850, "fcp_ms": 1100, "cls": 0.02, "tbt_ms": 110, "speed_index_ms": 2400
20
+ * },
21
+ * "opportunities": [...], // top 5 by potential savings
22
+ * "diagnostics": [...], // top 5 actionable
23
+ * "verdict": "PASS" | "FAIL"
24
+ * }
25
+ *
26
+ * Pass thresholds (mobile, throttled):
27
+ * performance >= 90
28
+ * lcp_ms < 2500
29
+ * cls < 0.1
30
+ * tbt_ms < 200
31
+ *
32
+ * Dependencies: puppeteer, lighthouse
33
+ * npm install --no-save puppeteer lighthouse
34
+ * (reuse this theme's node_modules if lighthouse is already installed.)
35
+ */
36
+
37
+ import puppeteer from 'puppeteer';
38
+ import lighthouse from 'lighthouse';
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { URL } from 'node:url';
42
+
43
+ const args = parseArgs(process.argv.slice(2));
44
+ const url = required(args, 'url');
45
+ const formFactor = (args['form-factor'] || 'mobile').toLowerCase();
46
+ const outPath = required(args, 'out');
47
+
48
+ const PASS = { performance: 90, lcp_ms: 2500, cls: 0.1, tbt_ms: 200 };
49
+
50
+ (async () => {
51
+ const browser = await puppeteer.launch({
52
+ headless: 'new',
53
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--remote-debugging-port=0'],
54
+ });
55
+ try {
56
+ const wsEndpoint = browser.wsEndpoint();
57
+ const port = new URL(wsEndpoint).port;
58
+
59
+ const config = {
60
+ extends: 'lighthouse:default',
61
+ settings: {
62
+ onlyCategories: ['performance'],
63
+ formFactor,
64
+ screenEmulation: formFactor === 'mobile'
65
+ ? { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false }
66
+ : { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
67
+ throttlingMethod: 'simulate',
68
+ },
69
+ };
70
+
71
+ const runnerResult = await lighthouse(
72
+ url,
73
+ { port: +port, output: 'json', logLevel: 'error' },
74
+ config
75
+ );
76
+
77
+ const lhr = runnerResult.lhr;
78
+ const audits = lhr.audits;
79
+
80
+ const scores = {
81
+ performance: Math.round((lhr.categories.performance?.score ?? 0) * 100),
82
+ };
83
+ const metrics = {
84
+ lcp_ms: Math.round(audits['largest-contentful-paint']?.numericValue ?? 0),
85
+ fcp_ms: Math.round(audits['first-contentful-paint']?.numericValue ?? 0),
86
+ cls: +(audits['cumulative-layout-shift']?.numericValue ?? 0).toFixed(3),
87
+ tbt_ms: Math.round(audits['total-blocking-time']?.numericValue ?? 0),
88
+ speed_index_ms: Math.round(audits['speed-index']?.numericValue ?? 0),
89
+ };
90
+
91
+ // Top opportunities by potential ms savings
92
+ const opportunities = Object.values(audits)
93
+ .filter((a) => a.details?.type === 'opportunity' && a.numericValue > 50)
94
+ .sort((a, b) => (b.numericValue ?? 0) - (a.numericValue ?? 0))
95
+ .slice(0, 5)
96
+ .map((a) => ({ id: a.id, title: a.title, potential_savings_ms: Math.round(a.numericValue) }));
97
+
98
+ // Diagnostics
99
+ const diagnostics = Object.values(audits)
100
+ .filter((a) => a.details?.type === 'table' && a.score !== null && a.score < 0.9)
101
+ .slice(0, 5)
102
+ .map((a) => ({ id: a.id, title: a.title, score: a.score }));
103
+
104
+ const verdict = (
105
+ scores.performance >= PASS.performance &&
106
+ metrics.lcp_ms < PASS.lcp_ms &&
107
+ metrics.cls < PASS.cls &&
108
+ metrics.tbt_ms < PASS.tbt_ms
109
+ ) ? 'PASS' : 'FAIL';
110
+
111
+ const report = {
112
+ url, form_factor: formFactor, scores, metrics, opportunities, diagnostics, verdict,
113
+ };
114
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
115
+ fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
116
+ console.log(JSON.stringify(report, null, 2));
117
+ } finally {
118
+ await browser.close();
119
+ }
120
+ })().catch((e) => fail(e.message ?? String(e)));
121
+
122
+ function parseArgs(argv) {
123
+ const out = {};
124
+ for (let i = 0; i < argv.length; i++) {
125
+ if (argv[i].startsWith('--')) {
126
+ const key = argv[i].slice(2);
127
+ const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
128
+ out[key] = val;
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+ function required(args, k) { if (!args[k]) fail(`Missing --${k}`); return args[k]; }
134
+ function fail(msg) { console.error('perf error:', msg); process.exit(2); }