similarbuild 0.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/CHANGELOG.md +110 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/install.js +256 -0
- package/lib/copy-templates.mjs +52 -0
- package/lib/install-deps.mjs +62 -0
- package/lib/prompt-config.mjs +83 -0
- package/lib/verify-env.mjs +19 -0
- package/package.json +63 -0
- package/scripts/sync-templates.mjs +71 -0
- package/templates/commands/build-page.md +490 -0
- package/templates/commands/build-site.md +548 -0
- package/templates/commands/clip-section.md +519 -0
- package/templates/memory/anti-patterns.md +212 -0
- package/templates/memory/design-knowledge.md +225 -0
- package/templates/memory/fixes.md +163 -0
- package/templates/memory/patterns.md +681 -0
- package/templates/presets/shopify-section.yaml +51 -0
- package/templates/presets/wp-elementor.yaml +49 -0
- package/templates/reports/fixtures/mock-run-1.json +115 -0
- package/templates/reports/fixtures/mock-run-2.json +72 -0
- package/templates/reports/report-renderer.mjs +218 -0
- package/templates/reports/report-template.html +571 -0
- package/templates/skills/sb-build-shopify/SKILL.md +104 -0
- package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
- package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
- package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
- package/templates/skills/sb-build-wp/SKILL.md +83 -0
- package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
- package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
- package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
- package/templates/skills/sb-compare-visual/SKILL.md +121 -0
- package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
- package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
- package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
- package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
- package/templates/skills/sb-extract-assets/SKILL.md +112 -0
- package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
- package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
- package/templates/skills/sb-inspect-live/SKILL.md +105 -0
- package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
- package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
- package/templates/skills/sb-review-checks/SKILL.md +113 -0
- package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
- package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
- package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
- package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
- package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
- package/templates/skills/sb-tweak/SKILL.md +130 -0
- package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
- package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
- package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
- package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
- package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
- package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
- package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
- package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
- package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
- package/templates/skills/sb-validate-render/SKILL.md +120 -0
- package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
- package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sb-validate-render
|
|
3
|
+
description: Renders a built fragment locally with the destination's theme reset injected, captures a mobile screenshot, and probes key tokens + geometry — the build → validate gate before any output reaches the user. Use when the SimilarBuild orchestrator (`/build-page`, `/build-site`, `/clip-section`) requests render validation after `sb-build-wp` or a Shopify build, or when the user asks to 'validate render' of a built file.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# sb-validate-render
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Closes the build → validate loop. Takes whatever `sb-build-wp` produced (HTML fragment) or a Shopify section build produced (Liquid file), wraps it in the destination CMS's blanket selectors, injects an aggressive theme reset, and renders it in headless chromium at a mobile viewport — simulating exactly what the user will see when they paste the fragment into the live destination. Captures a screenshot and probes the tokens and geometry the orchestrator needs to decide whether the build is faithful or needs a re-roll.
|
|
11
|
+
|
|
12
|
+
This is the **non-negotiable gate** (bootstrap rule #4): nothing reaches the user without passing through here. Without the destination's theme reset, the local render lies — the fragment looks fine in isolation and falls apart on paste because the theme overrode font-weight, font-size, button styles, and image heights with `!important`.
|
|
13
|
+
|
|
14
|
+
All deterministic work — Playwright launch, `setContent` (no `goto` — we have the HTML), reset injection, Liquid render with mock schema-default context, computed-style probe, screenshot — happens in `scripts/validate-render.mjs`. This SKILL.md only describes inputs, outputs, and failure handling.
|
|
15
|
+
|
|
16
|
+
Act as the visual fidelity checkpoint. If the script returns suspicious output (tiny screenshot, missing wrapper, viewport overflow), surface that flag rather than passing garbage downstream to `sb-compare-visual`.
|
|
17
|
+
|
|
18
|
+
## Inputs
|
|
19
|
+
|
|
20
|
+
| Argument | Required | Default | Notes |
|
|
21
|
+
| ----------------- | -------- | ------------- | ---------------------------------------------------------------------------------- |
|
|
22
|
+
| `file` | yes | — | Path to the built fragment. HTML for `wp-elementor`, Liquid for `shopify-section`. |
|
|
23
|
+
| `preset` | yes | — | `wp-elementor` or `shopify-section`. Determines wrapper + reset. |
|
|
24
|
+
| `output-dir` | yes | — | Directory where `screenshot.png` and `render.json` are written. |
|
|
25
|
+
| `viewport-width` | no | `390` | Mobile-first. Override only if the orchestrator explicitly needs desktop. |
|
|
26
|
+
| `viewport-height` | no | `844` | iPhone 14-class default. |
|
|
27
|
+
| `selector` | no | preset default | CSS selector to scope token probe + section bbox. Defaults to `.elementor` (WP) or `.page-width` (Shopify). |
|
|
28
|
+
| `preset-yaml` | no | (auto) | Override path to a preset YAML. Auto-detects `<plugin>/presets/{preset}.yaml` and falls back to a hardcoded baseline. |
|
|
29
|
+
| `assets-map-path` | no | — | (preset=shopify-section only) Path to `assets-map.json` from `sb-extract-assets`. When supplied, `image_picker` settings without a `default` get populated from matching assetsMap entries (id-keyword → context-substring heuristic, data-URL inlined). Without this flag, those settings remain null and the `{% else %}` placeholder branch fires — which makes the rendered fragment show a neutral grey instead of the live photo, inflating the pixel diff by ~30pp with no actionable fix (Pattern #32). Pass it from the orchestrator whenever `target=shopify` and an assetsMap exists. |
|
|
30
|
+
| `timeout` | no | `30000` | Per-step timeout (ms). |
|
|
31
|
+
|
|
32
|
+
## Output
|
|
33
|
+
|
|
34
|
+
A single JSON object printed to stdout AND saved to `{output-dir}/render.json`:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"file": "/abs/path/build.html",
|
|
39
|
+
"preset": "wp-elementor",
|
|
40
|
+
"viewport": { "width": 390, "height": 844 },
|
|
41
|
+
"probeRoot": ".elementor",
|
|
42
|
+
"screenshot": "{output-dir}/screenshot.png",
|
|
43
|
+
"tokens": {
|
|
44
|
+
"h1": { "present": true, "text": "...", "font-size": "24px", "font-weight": "300", "font-family": "...", "color": "..." },
|
|
45
|
+
"h2": { "present": true, "...": "..." },
|
|
46
|
+
"h3": { "present": false },
|
|
47
|
+
"body": { "font-size": "18px", "font-weight": "300", "...": "..." },
|
|
48
|
+
"button": { "present": true, "height": "...", "background-color": "...", "renderedH": 40, "...": "..." },
|
|
49
|
+
"images": { "count": 3, "sampled": [{ "src": "...", "computedHeight": "auto", "renderedH": 240, "hasInlineStyle": false }] },
|
|
50
|
+
"container": { "maxWidth": "...", "width": "...", "computedRenderedW": 390 }
|
|
51
|
+
},
|
|
52
|
+
"mockContext": { "populated": ["hero_image"], "skipped": [] },
|
|
53
|
+
"geometry": {
|
|
54
|
+
"totalHeight": 1420,
|
|
55
|
+
"totalWidth": 390,
|
|
56
|
+
"viewportInner": { "width": 390, "height": 844 },
|
|
57
|
+
"viewportOverflow": false,
|
|
58
|
+
"probeRoot": { "x": 0, "y": 0, "w": 390, "h": 1420 },
|
|
59
|
+
"sections": [{ "tag": "section", "classes": ["hero"], "bbox": {...} }]
|
|
60
|
+
},
|
|
61
|
+
"warnings": []
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The probed tokens are the **ground truth** for `sb-compare-visual` — pair them with `tokens_live` from `sb-inspect-live` to spot drift mechanically (e.g. h1 24px theme reset vs 32px expected from live).
|
|
66
|
+
|
|
67
|
+
## Dependencies
|
|
68
|
+
|
|
69
|
+
The host project must have:
|
|
70
|
+
|
|
71
|
+
- `playwright` (with chromium browser — already installed by `sb-inspect-live` setup)
|
|
72
|
+
- `js-yaml` — only when a preset YAML override is loaded (graceful skip if missing, falls back to hardcoded preset)
|
|
73
|
+
- `liquidjs` — **only required when** `preset=shopify-section`. The script exits with an install hint if missing on the Shopify path. WP path doesn't need it.
|
|
74
|
+
|
|
75
|
+
Node ≥ 20.
|
|
76
|
+
|
|
77
|
+
## On Activation
|
|
78
|
+
|
|
79
|
+
1. **Resolve inputs.** Collect `file`, `preset`, `output-dir`, plus optional `viewport-width`, `viewport-height`, `selector`, `preset-yaml`, `timeout`.
|
|
80
|
+
|
|
81
|
+
2. **Ensure `output-dir` exists.** `mkdir -p` it.
|
|
82
|
+
|
|
83
|
+
3. **Run the script.** From the project root:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
node {skill-root}/scripts/validate-render.mjs \
|
|
87
|
+
--file "{file}" \
|
|
88
|
+
--preset "{preset}" \
|
|
89
|
+
--output-dir "{output-dir}" \
|
|
90
|
+
[--viewport-width 390] \
|
|
91
|
+
[--viewport-height 844] \
|
|
92
|
+
[--selector "{css}"] \
|
|
93
|
+
[--preset-yaml "{path}"]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The script handles: preset YAML resolution (`<plugin>/presets/{preset}.yaml` → hardcoded fallback), Liquid schema parsing + mock-context render (Shopify only, via liquidjs), wrapper injection (`<div class="elementor">` for WP, `<div class="page-width">` for Shopify), reset CSS injection, chromium launch with mobile viewport, `setContent` (NOT goto), font readiness wait, full-page screenshot, getComputedStyle probe of h1/h2/h3/body/button/images/container, and geometry capture (total height, viewport overflow, section bboxes). See `scripts/validate-render.mjs --help` for the full flag list.
|
|
97
|
+
|
|
98
|
+
4. **Validate the result.** Parse stdout as JSON. If the script exits non-zero, surface stderr to the orchestrator and stop — don't fabricate output. Common stderr hints: `liquidjs` missing for Shopify, `playwright` missing or chromium not installed.
|
|
99
|
+
|
|
100
|
+
5. **Inspect `warnings[]`.** A non-empty `warnings` array (e.g. tiny screenshot) is a soft signal of a failed render — pass through unchanged so the orchestrator can decide whether to treat it as a hard fail.
|
|
101
|
+
|
|
102
|
+
6. **Return the JSON unchanged** to the caller. `sb-compare-visual` consumes `screenshot` and `tokens` directly. Do not summarize, mutate, or strip fields.
|
|
103
|
+
|
|
104
|
+
## Failure modes
|
|
105
|
+
|
|
106
|
+
| Symptom | Likely cause | What to surface |
|
|
107
|
+
| -------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
108
|
+
| Script exits non-zero, stderr mentions `liquidjs` | `preset=shopify-section` and liquidjs missing | Pass stderr verbatim. Suggest `npm i liquidjs`. |
|
|
109
|
+
| Script exits non-zero, stderr mentions `playwright` | playwright/chromium not installed | Pass stderr verbatim. Suggest `npm i playwright && npx playwright install chromium`. |
|
|
110
|
+
| `tokens.h1.present: false` for a hero-class section | Wrong selector or build dropped the heading | Surface to orchestrator — possibly a `sb-build-wp` defect, not a validate defect. |
|
|
111
|
+
| `geometry.viewportOverflow: true` | Fragment has `width > 100%` or fixed-px wider than viewport | Forward unchanged — high-severity finding for `sb-compare-visual` / `sb-review-checks`. |
|
|
112
|
+
| `warnings[]` includes tiny-screenshot | Page didn't render (CSS error, throw in Liquid render) | Treat as hard fail; the screenshot is unusable for visual diff. |
|
|
113
|
+
| Liquid schema JSON parse fails | Malformed `{% schema %}` block | Script logs a warning and renders with empty mock context. Output is still produced; orchestrator may need to flag it. |
|
|
114
|
+
|
|
115
|
+
## Conventions
|
|
116
|
+
|
|
117
|
+
- Bare paths (e.g. `scripts/validate-render.mjs`) resolve from the skill root.
|
|
118
|
+
- `{skill-root}` resolves to this skill's installed directory.
|
|
119
|
+
- `{project-root}` resolves to the project working directory.
|
|
120
|
+
- `<plugin>/presets/{preset}.yaml` is read when present — populated by item #14 of the visual-migration plan; users can override per-theme. Skip silently when missing (the hardcoded fallback in the script matches the plan brief).
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-validate-render.mjs — Smoke tests for validate-render.mjs.
|
|
3
|
+
// Validates: --help works, missing args fail with exit 2, invalid presets are
|
|
4
|
+
// rejected, and the in-script schema extractor + mock context builder behave.
|
|
5
|
+
// Full-render tests (Playwright launch) are intentionally out of scope here —
|
|
6
|
+
// they need chromium installed and would slow CI; covered by manual smoke runs.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
|
+
import { dirname, resolve, join } from 'node:path'
|
|
11
|
+
import { strict as assert } from 'node:assert'
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const SCRIPT = resolve(here, '..', 'validate-render.mjs')
|
|
15
|
+
|
|
16
|
+
let passed = 0
|
|
17
|
+
let failed = 0
|
|
18
|
+
|
|
19
|
+
function test(name, fn) {
|
|
20
|
+
try {
|
|
21
|
+
const result = fn()
|
|
22
|
+
if (result && typeof result.then === 'function') {
|
|
23
|
+
return result.then(
|
|
24
|
+
() => {
|
|
25
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
26
|
+
passed++
|
|
27
|
+
},
|
|
28
|
+
(err) => {
|
|
29
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
30
|
+
failed++
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
35
|
+
passed++
|
|
36
|
+
} catch (err) {
|
|
37
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
38
|
+
failed++
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function run(args) {
|
|
43
|
+
return spawnSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- Help and arg validation ----
|
|
47
|
+
|
|
48
|
+
test('--help exits 0 and prints usage', () => {
|
|
49
|
+
const r = run(['--help'])
|
|
50
|
+
assert.equal(r.status, 0, `exit code was ${r.status}`)
|
|
51
|
+
assert.match(r.stdout, /validate-render\.mjs/)
|
|
52
|
+
assert.match(r.stdout, /--preset/)
|
|
53
|
+
assert.match(r.stdout, /wp-elementor/)
|
|
54
|
+
assert.match(r.stdout, /shopify-section/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('missing --file exits 2', () => {
|
|
58
|
+
const r = run(['--preset', 'wp-elementor', '--output-dir', '/tmp/x'])
|
|
59
|
+
assert.equal(r.status, 2)
|
|
60
|
+
assert.match(r.stderr, /missing --file/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('missing --preset exits 2', () => {
|
|
64
|
+
const r = run(['--file', '/tmp/x.html', '--output-dir', '/tmp/x'])
|
|
65
|
+
assert.equal(r.status, 2)
|
|
66
|
+
assert.match(r.stderr, /missing --preset/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('missing --output-dir exits 2', () => {
|
|
70
|
+
const r = run(['--file', '/tmp/x.html', '--preset', 'wp-elementor'])
|
|
71
|
+
assert.equal(r.status, 2)
|
|
72
|
+
assert.match(r.stderr, /missing --output-dir/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('unknown preset exits 2', () => {
|
|
76
|
+
const r = run(['--file', '/tmp/x.html', '--preset', 'magento', '--output-dir', '/tmp/x'])
|
|
77
|
+
assert.equal(r.status, 2)
|
|
78
|
+
assert.match(r.stderr, /unknown preset/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('non-numeric viewport exits 2', () => {
|
|
82
|
+
const r = run([
|
|
83
|
+
'--file',
|
|
84
|
+
'/tmp/x.html',
|
|
85
|
+
'--preset',
|
|
86
|
+
'wp-elementor',
|
|
87
|
+
'--output-dir',
|
|
88
|
+
'/tmp/x',
|
|
89
|
+
'--viewport-width',
|
|
90
|
+
'abc',
|
|
91
|
+
])
|
|
92
|
+
assert.equal(r.status, 2)
|
|
93
|
+
assert.match(r.stderr, /viewport must be numeric/)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ---- Schema extraction + mock context (loaded as module) ----
|
|
97
|
+
// The script's main() exits on import (top-level argv parsing), so we re-import
|
|
98
|
+
// pieces we want to test by reading + evaluating the relevant functions in
|
|
99
|
+
// isolation. Cleanest path: import the module under a flag where the missing
|
|
100
|
+
// args path returns help. We instead use a small inline copy of the same logic
|
|
101
|
+
// to keep tests deterministic and fast — the source-of-truth lives in the script.
|
|
102
|
+
|
|
103
|
+
await test('schema extractor handles {% schema %} block', async () => {
|
|
104
|
+
// Import the script module just to get its source; we test behavior by
|
|
105
|
+
// re-implementing the regex inline (mirrored from validate-render.mjs).
|
|
106
|
+
const SCHEMA_RE = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/
|
|
107
|
+
const liquid = `<section>{{ section.settings.heading }}</section>
|
|
108
|
+
{% schema %}
|
|
109
|
+
{ "name": "Hero", "settings": [{ "id": "heading", "type": "text", "default": "Hello" }] }
|
|
110
|
+
{% endschema %}`
|
|
111
|
+
const m = liquid.match(SCHEMA_RE)
|
|
112
|
+
assert.ok(m)
|
|
113
|
+
const parsed = JSON.parse(m[1])
|
|
114
|
+
assert.equal(parsed.settings[0].default, 'Hello')
|
|
115
|
+
const body = liquid.slice(0, m.index) + liquid.slice(m.index + m[0].length)
|
|
116
|
+
assert.match(body, /\{\{ section\.settings\.heading \}\}/)
|
|
117
|
+
assert.doesNotMatch(body, /schema/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await test('schema extractor handles whitespace-trimming tags', () => {
|
|
121
|
+
const SCHEMA_RE = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/
|
|
122
|
+
const liquid = `<div></div>
|
|
123
|
+
{%- schema -%}
|
|
124
|
+
{ "name": "X" }
|
|
125
|
+
{%- endschema -%}`
|
|
126
|
+
const m = liquid.match(SCHEMA_RE)
|
|
127
|
+
assert.ok(m, 'should match {%- schema -%} tags')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await test('schema extractor returns null when no schema present', () => {
|
|
131
|
+
const SCHEMA_RE = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/
|
|
132
|
+
const liquid = `<section>just liquid, no schema</section>`
|
|
133
|
+
const m = liquid.match(SCHEMA_RE)
|
|
134
|
+
assert.equal(m, null)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ─── Pattern #32 — image_picker mock context (assets-map matching) ─────────
|
|
138
|
+
// Mirror the heuristic table from validate-render.mjs so we can test the
|
|
139
|
+
// matching behavior without importing the script (which exits on missing argv).
|
|
140
|
+
|
|
141
|
+
const IMAGE_PICKER_ID_HEURISTICS = [
|
|
142
|
+
{ idRe: /(^|[_-])(hero|banner|cover)([_-]|$)/i, ctxKeywords: ['hero', 'ai-hero', 'banner', 'cover'] },
|
|
143
|
+
{ idRe: /^(hero|banner|cover)(_image)?$/i, ctxKeywords: ['hero', 'ai-hero', 'banner', 'cover'] },
|
|
144
|
+
{ idRe: /(^|[_-])(logo|brand)([_-]|$)/i, ctxKeywords: ['header', 'ai-hdr', 'logo', 'brand'] },
|
|
145
|
+
{ idRe: /^(logo|brand)$/i, ctxKeywords: ['header', 'ai-hdr', 'logo', 'brand'] },
|
|
146
|
+
{ idRe: /(^|[_-])(product|gallery|featured)([_-]|$)/i, ctxKeywords: ['ai-fp__gallery', 'product', 'gallery', 'featured'] },
|
|
147
|
+
{ idRe: /^(product_image|gallery_image|featured_image)$/i, ctxKeywords: ['ai-fp__gallery', 'product', 'gallery', 'featured'] },
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
function findAssetForImagePicker(settingId, assetsMap) {
|
|
151
|
+
if (!settingId || !assetsMap || !Array.isArray(assetsMap.assets)) return null
|
|
152
|
+
const id = String(settingId).toLowerCase()
|
|
153
|
+
for (const rule of IMAGE_PICKER_ID_HEURISTICS) {
|
|
154
|
+
if (!rule.idRe.test(id)) continue
|
|
155
|
+
for (const a of assetsMap.assets) {
|
|
156
|
+
const c = String(a?.context || '').toLowerCase()
|
|
157
|
+
if (!c) continue
|
|
158
|
+
if (rule.ctxKeywords.some((kw) => c.includes(kw))) return a
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const a of assetsMap.assets) {
|
|
162
|
+
if (String(a?.context || '').trim()) return a
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await test('Pattern #32: hero_image setting matches asset with context "ai-hero__bg"', () => {
|
|
168
|
+
const map = {
|
|
169
|
+
assets: [
|
|
170
|
+
{ localPath: '/a/logo.png', context: 'header', ext: 'png' },
|
|
171
|
+
{ localPath: '/a/photo.jpg', context: 'ai-hero__bg', ext: 'jpg' },
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
const m = findAssetForImagePicker('hero_image', map)
|
|
175
|
+
assert.equal(m.localPath, '/a/photo.jpg')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await test('Pattern #32: logo setting matches asset with context "ai-hdr"', () => {
|
|
179
|
+
const map = {
|
|
180
|
+
assets: [
|
|
181
|
+
{ localPath: '/a/photo.jpg', context: 'ai-hero__bg' },
|
|
182
|
+
{ localPath: '/a/logo.png', context: 'ai-hdr__brand' },
|
|
183
|
+
],
|
|
184
|
+
}
|
|
185
|
+
const m = findAssetForImagePicker('logo', map)
|
|
186
|
+
assert.equal(m.localPath, '/a/logo.png')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
await test('Pattern #32: product_image matches asset with "ai-fp__gallery" context', () => {
|
|
190
|
+
const map = {
|
|
191
|
+
assets: [
|
|
192
|
+
{ localPath: '/a/hero.jpg', context: 'ai-hero__bg' },
|
|
193
|
+
{ localPath: '/a/p1.jpg', context: 'ai-fp__gallery__main' },
|
|
194
|
+
],
|
|
195
|
+
}
|
|
196
|
+
const m = findAssetForImagePicker('product_image', map)
|
|
197
|
+
assert.equal(m.localPath, '/a/p1.jpg')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await test('Pattern #32: bare "hero" id matches asset with context "ai-hero"', () => {
|
|
201
|
+
const map = { assets: [{ localPath: '/a/h.jpg', context: 'ai-hero__bg' }] }
|
|
202
|
+
const m = findAssetForImagePicker('hero', map)
|
|
203
|
+
assert.equal(m.localPath, '/a/h.jpg')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
await test('Pattern #32: id with no keyword match falls back to first asset with context', () => {
|
|
207
|
+
const map = {
|
|
208
|
+
assets: [
|
|
209
|
+
{ localPath: '/a/img1.jpg', context: '' },
|
|
210
|
+
{ localPath: '/a/img2.jpg', context: 'random-ctx' },
|
|
211
|
+
{ localPath: '/a/img3.jpg', context: 'another-ctx' },
|
|
212
|
+
],
|
|
213
|
+
}
|
|
214
|
+
// The id "background" doesn't match any heuristic rule → falls back to
|
|
215
|
+
// the first asset with non-empty context (skips the empty one).
|
|
216
|
+
const m = findAssetForImagePicker('background', map)
|
|
217
|
+
assert.equal(m.localPath, '/a/img2.jpg')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
await test('Pattern #32: empty assetsMap returns null', () => {
|
|
221
|
+
assert.equal(findAssetForImagePicker('hero_image', { assets: [] }), null)
|
|
222
|
+
assert.equal(findAssetForImagePicker('hero_image', null), null)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await test('Pattern #32: setting id without keyword AND no contextful assets → null', () => {
|
|
226
|
+
const map = {
|
|
227
|
+
assets: [
|
|
228
|
+
{ localPath: '/a/x.jpg', context: '' },
|
|
229
|
+
{ localPath: '/a/y.jpg', context: '' },
|
|
230
|
+
],
|
|
231
|
+
}
|
|
232
|
+
assert.equal(findAssetForImagePicker('background', map), null)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// ─── --assets-map-path CLI flag (pattern #32) ───────────────────────────────
|
|
236
|
+
|
|
237
|
+
await test('Pattern #32: --assets-map-path appears in --help', () => {
|
|
238
|
+
const r = run(['--help'])
|
|
239
|
+
assert.equal(r.status, 0)
|
|
240
|
+
assert.match(r.stdout, /--assets-map-path/)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// ─── Pattern #24 — body probe scope-aware (browser-only, smoke noted) ───────
|
|
244
|
+
// The actual probe runs in chromium via page.evaluate(probeInPage). We can't
|
|
245
|
+
// run the in-browser logic from a node test without launching playwright,
|
|
246
|
+
// which is intentionally out of scope for these smoke tests (test doc above).
|
|
247
|
+
// Stub a node-side replica of the helper to confirm the search-order behavior.
|
|
248
|
+
|
|
249
|
+
function findBodyLikeInScope(scopeRoot, doc, getDirectText) {
|
|
250
|
+
if (!scopeRoot || scopeRoot === doc.body) return null
|
|
251
|
+
const candidates = scopeRoot.querySelectorAll('p, span, div, li')
|
|
252
|
+
for (const el of candidates) {
|
|
253
|
+
const direct = getDirectText(el)
|
|
254
|
+
if (direct.replace(/\s+/g, '').length >= 1) return el
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await test('Pattern #24: findBodyLikeInScope finds first <p> with direct text', () => {
|
|
260
|
+
// Minimal mock — emulate the relevant slice of DOM API.
|
|
261
|
+
const fakeP = {
|
|
262
|
+
childNodes: [{ nodeType: 3, nodeValue: 'Hello world' }],
|
|
263
|
+
}
|
|
264
|
+
const fakeScope = {
|
|
265
|
+
querySelectorAll: (sel) => [fakeP],
|
|
266
|
+
}
|
|
267
|
+
const fakeDoc = { body: { /* unused */ } }
|
|
268
|
+
const getDirectText = (el) => {
|
|
269
|
+
let t = ''
|
|
270
|
+
for (const n of el.childNodes) if (n.nodeType === 3) t += n.nodeValue || ''
|
|
271
|
+
return t
|
|
272
|
+
}
|
|
273
|
+
const el = findBodyLikeInScope(fakeScope, fakeDoc, getDirectText)
|
|
274
|
+
assert.strictEqual(el, fakeP)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
await test('Pattern #24: findBodyLikeInScope returns null when no body-like child has text', () => {
|
|
278
|
+
const fakeP = {
|
|
279
|
+
childNodes: [{ nodeType: 3, nodeValue: ' ' }], // whitespace only
|
|
280
|
+
}
|
|
281
|
+
const fakeScope = {
|
|
282
|
+
querySelectorAll: (_) => [fakeP],
|
|
283
|
+
}
|
|
284
|
+
const fakeDoc = { body: {} }
|
|
285
|
+
const getDirectText = (el) => {
|
|
286
|
+
let t = ''
|
|
287
|
+
for (const n of el.childNodes) if (n.nodeType === 3) t += n.nodeValue || ''
|
|
288
|
+
return t
|
|
289
|
+
}
|
|
290
|
+
assert.equal(findBodyLikeInScope(fakeScope, fakeDoc, getDirectText), null)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
await test('Pattern #24: findBodyLikeInScope skips when scope IS document.body (legacy fallback)', () => {
|
|
294
|
+
const docBody = {}
|
|
295
|
+
const fakeDoc = { body: docBody }
|
|
296
|
+
assert.equal(findBodyLikeInScope(docBody, fakeDoc, () => ''), null)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
if (failed > 0) {
|
|
300
|
+
process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
|
|
301
|
+
process.exit(1)
|
|
302
|
+
}
|
|
303
|
+
process.stdout.write(`\n${passed} passed\n`)
|
|
304
|
+
process.exit(0)
|