@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.
- package/README.md +59 -0
- package/package.json +2 -2
- package/ts-dist/mocks/init-theme/.claude/CLAUDE.md +6 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/SKILL.md +242 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/inputs/figma.md +81 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/inputs/screenshot.md +54 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/inputs/url.md +88 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/spec-table.md +102 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/accessibility.md +110 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/performance.md +127 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/tokens.md +87 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-section-from-design.20260528/verification/visual-diff.md +123 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/SKILL.md +605 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/design-tokens.md +105 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/page-json-contract.md +58 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/qa-accessibility.md +35 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/reference-lookup.md +94 -0
- package/ts-dist/mocks/init-theme/.claude/skills/.archive/zcommerce-theme-builder.20260528/section-anatomy.md +109 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/SKILL.md +313 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/agent-hierarchy.md +142 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/ask-developer.md +113 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/bootstrap.md +260 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/build-metrics.md +89 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/code-quality.md +186 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/common-pages-agent.md +97 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/dev-server-and-urls.md +136 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/exemplars.md +79 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/figma-discovery.md +138 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/figma-extraction.md +132 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/image-sources.md +84 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/inputs/guidelines-frame.md +178 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/page-builder.md +322 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/page-orchestrator.md +199 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/parallel-fanout.md +152 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/a11y.mjs +129 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/lint-section.mjs +502 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/perf.mjs +134 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/pixel-diff.mjs +206 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/scripts/record-build-metrics.mjs +118 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/section-builder.md +131 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/subagent-escalation.md +75 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/transcription-rules.md +98 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/verification.md +227 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/visual-verify-loop.md +117 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/_index.md +4 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/assets.md +105 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/best-practices.md +212 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/custom-scripts.md +294 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/i18n.md +155 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/introduction.md +91 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/layouts.md +158 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/pages.md +156 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/_index.md +4 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/filters.md +419 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/global-objects.md +415 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/schema-types.md +926 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/reference/tags.md +259 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/sections.md +203 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/setup-guide.md +424 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/snippets.md +70 -0
- package/ts-dist/mocks/init-theme/.claude/skills/theme-from-figma/zcommerce-docs/theme-structure.md +252 -0
- package/ts-dist/mocks/init-theme/.cursor/PLAYBOOK.md +144 -0
- package/ts-dist/mocks/init-theme/.cursor/README.md +6 -0
- package/ts-dist/mocks/init-theme/.cursor/agents/figma-desktop-mobile-parallel.md +48 -0
- package/ts-dist/mocks/init-theme/.cursor/agents/visual-compare.md +221 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/add-field.md +83 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/add-theme-setting.md +200 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/assemble-page.md +72 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/audit-page.md +57 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/build-page-sections.md +110 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/build-section.md +418 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/build-theme.md +307 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/compare-section.md +358 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/create-theme.md +77 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/extract-tokens.md +43 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/fix-section.md +92 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/impact.md +50 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/qa-a11y.md +66 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/qa-i18n.md +110 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/qa-layout.md +127 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/qa-performance.md +72 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/qa-section.md +84 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/qa-sections.md +48 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/qa-theme.md +85 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/redesign-section.md +177 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/refactor-section.md +185 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/sync-theme-settings.md +132 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/validate-page.md +60 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/validate-section-group.md +88 -0
- package/ts-dist/mocks/init-theme/.cursor/commands/validate-theme-settings.md +119 -0
- package/ts-dist/mocks/init-theme/.cursor/examples/design-instructions-template.md +151 -0
- package/ts-dist/mocks/init-theme/.cursor/mcp.json +18 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/README.md +160 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/accessibility.mdc +24 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/add-field.mdc +222 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/add-theme-setting.mdc +39 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/assemble-page.mdc +130 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/audit-page.mdc +92 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/build-section.mdc +178 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/build-theme.mdc +47 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/compare-section.mdc +94 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/create-theme.mdc +78 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/css.mdc +137 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/design-spec-extraction.mdc +255 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/extract-tokens.mdc +43 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/figma-mcp-resolution.mdc +40 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/fix-section.mdc +154 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/general.mdc +30 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/i18n.mdc +85 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/javascript.mdc +85 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/liquid-no-hardcoded-strings.mdc +42 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/liquid.mdc +171 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/mobile-desktop-setting-meta.mdc +34 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/naming-convention.mdc +64 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/pages.mdc +120 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/performance.mdc +51 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/qa-section.mdc +134 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/redesign-section.mdc +147 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/schema-sync.mdc +110 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/schema.mdc +360 -0
- package/ts-dist/mocks/init-theme/.cursor/rules/share-urls.mdc +68 -0
- package/ts-dist/mocks/init-theme/.cursor/skills/browser-verify.md +128 -0
- package/ts-dist/mocks/init-theme/.cursor/skills/design-tokens.md +105 -0
- package/ts-dist/mocks/init-theme/.cursor/skills/page-json-contract.md +87 -0
- package/ts-dist/mocks/init-theme/.cursor/skills/qa-accessibility.md +35 -0
- package/ts-dist/mocks/init-theme/.cursor/skills/reference-lookup.md +88 -0
- 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); }
|