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.
Files changed (76) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +301 -0
  4. package/bin/install.js +256 -0
  5. package/lib/copy-templates.mjs +52 -0
  6. package/lib/install-deps.mjs +62 -0
  7. package/lib/prompt-config.mjs +83 -0
  8. package/lib/verify-env.mjs +19 -0
  9. package/package.json +63 -0
  10. package/scripts/sync-templates.mjs +71 -0
  11. package/templates/commands/build-page.md +490 -0
  12. package/templates/commands/build-site.md +548 -0
  13. package/templates/commands/clip-section.md +519 -0
  14. package/templates/memory/anti-patterns.md +212 -0
  15. package/templates/memory/design-knowledge.md +225 -0
  16. package/templates/memory/fixes.md +163 -0
  17. package/templates/memory/patterns.md +681 -0
  18. package/templates/presets/shopify-section.yaml +51 -0
  19. package/templates/presets/wp-elementor.yaml +49 -0
  20. package/templates/reports/fixtures/mock-run-1.json +115 -0
  21. package/templates/reports/fixtures/mock-run-2.json +72 -0
  22. package/templates/reports/report-renderer.mjs +218 -0
  23. package/templates/reports/report-template.html +571 -0
  24. package/templates/skills/sb-build-shopify/SKILL.md +104 -0
  25. package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
  26. package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
  27. package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
  28. package/templates/skills/sb-build-wp/SKILL.md +83 -0
  29. package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
  30. package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
  31. package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
  32. package/templates/skills/sb-compare-visual/SKILL.md +121 -0
  33. package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
  34. package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
  35. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
  36. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
  37. package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
  38. package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
  39. package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
  40. package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
  41. package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
  42. package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
  43. package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
  44. package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
  45. package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
  46. package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
  47. package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
  48. package/templates/skills/sb-extract-assets/SKILL.md +112 -0
  49. package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
  50. package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
  51. package/templates/skills/sb-inspect-live/SKILL.md +105 -0
  52. package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
  53. package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
  54. package/templates/skills/sb-review-checks/SKILL.md +113 -0
  55. package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
  56. package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
  57. package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
  58. package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
  59. package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
  60. package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
  61. package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
  62. package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
  63. package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
  64. package/templates/skills/sb-tweak/SKILL.md +130 -0
  65. package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
  66. package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
  67. package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
  68. package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
  69. package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
  70. package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
  71. package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
  72. package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
  73. package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
  74. package/templates/skills/sb-validate-render/SKILL.md +120 -0
  75. package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
  76. package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
@@ -0,0 +1,51 @@
1
+ # shopify-section preset — theme reset CSS + wrapper for sb-validate-render's
2
+ # local rendering of a Shopify section .liquid file.
3
+ #
4
+ # Functionally equivalent to FALLBACK_PRESETS['shopify-section'] in
5
+ # .claude/skills/sb-validate-render/scripts/validate-render.mjs. Extracting it
6
+ # here turns the hardcoded baseline into a declarative, customizable artifact:
7
+ # the installer (item #16) will copy this file into <plugin>/presets/, where
8
+ # sb-validate-render auto-resolves it; users can edit it for their specific
9
+ # theme (Dawn, Sense, Studio, custom OS 2.0) without touching skill code.
10
+ #
11
+ # Resolve order in sb-validate-render:
12
+ # 1. --preset-yaml <path> CLI override
13
+ # 2. <plugin>/presets/shopify-section.yaml (auto-resolved)
14
+ # 3. baked-in FALLBACK_PRESETS (this file's mirror)
15
+ #
16
+ # When YAML is loaded, it is shallow-merged INTO the baked-in fallback so a
17
+ # partial override (e.g. only `reset_css`) still inherits the other keys.
18
+ #
19
+ # This baseline encodes what Dawn / typical OS 2.0 themes inject at the section
20
+ # level. It is informational simulation, NOT what your build emits — your
21
+ # build's CSS must DEFEAT this via chained-scope specificity. See
22
+ # sb-build-shopify/references/shopify-build-rules.md.
23
+
24
+ wrapper:
25
+ tag: div
26
+ class: page-width
27
+
28
+ # Aggressive reset injected around the rendered Liquid fragment at validate
29
+ # time. Mirrors Dawn's typography + spacing baseline. Note: `html { font-size:
30
+ # 62.5% }` is Dawn's idiom (1rem = 10px), which is why every rem value below
31
+ # matches Dawn's effective pixel size.
32
+ reset_css: |-
33
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.5; color: rgb(18, 18, 18); margin: 0; }
34
+ .page-width { max-width: 120rem; padding: 0 1.5rem; margin: 0 auto; box-sizing: border-box; }
35
+ h1 { font-size: 4rem; font-weight: 400; line-height: 1.1; letter-spacing: -0.05rem; margin: 0 0 1.5rem; }
36
+ h2 { font-size: 3rem; font-weight: 400; line-height: 1.15; letter-spacing: -0.04rem; margin: 0 0 1.25rem; }
37
+ h3 { font-size: 2.4rem; font-weight: 400; line-height: 1.2; letter-spacing: -0.03rem; margin: 0 0 1rem; }
38
+ button, .button { background: rgb(18, 18, 18); color: rgb(255, 255, 255); border: 0.1rem solid rgb(18, 18, 18); border-radius: 0; padding: 1.5rem 3rem; font-size: 1.5rem; font-weight: 500; cursor: pointer; }
39
+ img { max-width: 100%; height: auto; display: block; }
40
+ * { box-sizing: border-box; }
41
+ html { font-size: 62.5%; }
42
+
43
+ # Shopify owns <head>; the theme already loads its own fonts via `font_face` or
44
+ # theme settings. Leave empty unless customizing for a theme that needs an
45
+ # explicit font preload at validate time (rare — usually inheritance handles it
46
+ # and the build's typography wins via chained-scope specificity).
47
+ head_extras: ""
48
+
49
+ # Default selector for token probing + section bbox when --selector is not
50
+ # passed on the CLI. `.page-width` is Dawn's section wrapper class.
51
+ default_probe_root: .page-width
@@ -0,0 +1,49 @@
1
+ # wp-elementor preset — theme reset CSS + wrapper + Google Fonts injection
2
+ # for sb-validate-render's local rendering of an Elementor HTML widget output.
3
+ #
4
+ # Functionally equivalent to FALLBACK_PRESETS['wp-elementor'] in
5
+ # .claude/skills/sb-validate-render/scripts/validate-render.mjs. Extracting it
6
+ # here turns the hardcoded baseline into a declarative, customizable artifact:
7
+ # the installer (item #16) will copy this file into <plugin>/presets/, where
8
+ # sb-validate-render auto-resolves it; users can edit it for their specific
9
+ # theme (Astra, Hello, OceanWP, custom) without touching skill code.
10
+ #
11
+ # Resolve order in sb-validate-render:
12
+ # 1. --preset-yaml <path> CLI override
13
+ # 2. <plugin>/presets/wp-elementor.yaml (auto-resolved)
14
+ # 3. baked-in FALLBACK_PRESETS (this file's mirror)
15
+ #
16
+ # When YAML is loaded, it is shallow-merged INTO the baked-in fallback so a
17
+ # partial override (e.g. only `reset_css`) still inherits the other keys.
18
+ #
19
+ # This baseline encodes what Elementor + a typical paired theme (Hello, Astra)
20
+ # inject via blanket `.elementor *` rules. It is informational simulation, NOT
21
+ # what your build emits — your build's CSS must DEFEAT this via chained-scope
22
+ # specificity. See sb-build-wp/references/wp-build-rules.md.
23
+
24
+ wrapper:
25
+ tag: div
26
+ class: elementor
27
+
28
+ # Aggressive reset injected around the fragment at validate time. Mirrors what
29
+ # the Elementor + active-theme stack typically applies via `.elementor *` and
30
+ # `img { height: auto !important }` blanket rules. The build under test must
31
+ # win on specificity against every property listed here.
32
+ reset_css: |-
33
+ .elementor, .elementor * { font-weight: 300 !important; font-size: 18px !important; font-family: 'Roboto', sans-serif !important; }
34
+ .elementor h1, .elementor h2, .elementor h3 { font-weight: 300 !important; font-size: 24px !important; }
35
+ .elementor img, img { max-width: 100% !important; height: auto !important; border-radius: 0 !important; }
36
+ .elementor button, button { background: transparent !important; border: 0 !important; padding: 0 !important; }
37
+
38
+ # Injected BEFORE the reset so 'Roboto' is actually available when the reset
39
+ # forces font-family. Without this, the reset names a font the rendered page
40
+ # does not have, and the screenshot falls back to the system stack — masking
41
+ # real font-related diff in sb-compare-visual.
42
+ head_extras: |-
43
+ <link rel="preconnect" href="https://fonts.googleapis.com">
44
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
45
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap">
46
+
47
+ # Default selector for token probing + section bbox when --selector is not
48
+ # passed on the CLI. The wrapper class IS the boundary in Elementor fragments.
49
+ default_probe_root: .elementor
@@ -0,0 +1,115 @@
1
+ {
2
+ "projectSlug": "example-store",
3
+ "rootUrl": "https://example-store.com",
4
+ "target": "wp",
5
+ "currentRun": {
6
+ "timestamp": "2026-05-05T14:23:01Z",
7
+ "durationMs": 187340,
8
+ "totalIterations": 4,
9
+ "totals": { "ok": 3, "warn": 1, "fail": 1 },
10
+ "pageResults": [
11
+ {
12
+ "url": "https://example-store.com/",
13
+ "type": "home",
14
+ "slug": "home",
15
+ "status": "ok",
16
+ "diffPercent": 8.42,
17
+ "iterations": 1,
18
+ "outputPath": "clean/home/index.html",
19
+ "screenshotsLive": "validations/home-1/live-cropped.png",
20
+ "screenshotsBuild": "validations/home-1/build-cropped.png",
21
+ "diffMap": "validations/home-1/diff.png",
22
+ "violations": [],
23
+ "structuredDiffs": [
24
+ { "key": "h1.font-size", "live": "48px", "build": "44px", "severity": "med" },
25
+ { "key": "section.padding-top", "live": "96px", "build": "96px", "severity": "low" }
26
+ ],
27
+ "tokens": {
28
+ "live": { "h1.font-size": "48px", "h1.color": "rgb(20,20,20)", "button.bg": "rgb(255,86,0)" },
29
+ "build": { "h1.font-size": "44px", "h1.color": "rgb(20,20,20)", "button.bg": "rgb(255,86,0)" }
30
+ },
31
+ "candidateFixes": []
32
+ },
33
+ {
34
+ "url": "https://example-store.com/products/foo",
35
+ "type": "pdp",
36
+ "slug": "products-foo",
37
+ "status": "ok",
38
+ "diffPercent": 4.91,
39
+ "iterations": 0,
40
+ "outputPath": "clean/pdp/products-foo.html",
41
+ "screenshotsLive": "validations/pdp-foo-0/live-cropped.png",
42
+ "screenshotsBuild": "validations/pdp-foo-0/build-cropped.png",
43
+ "diffMap": "validations/pdp-foo-0/diff.png",
44
+ "violations": [],
45
+ "structuredDiffs": [],
46
+ "tokens": null,
47
+ "candidateFixes": []
48
+ },
49
+ {
50
+ "url": "https://example-store.com/pages/contact",
51
+ "type": "pages",
52
+ "slug": "pages-contact",
53
+ "status": "warn",
54
+ "diffPercent": 14.27,
55
+ "iterations": 2,
56
+ "outputPath": "clean/pages/pages-contact.html",
57
+ "screenshotsLive": "validations/pages-contact-2/live-cropped.png",
58
+ "screenshotsBuild": "validations/pages-contact-2/build-cropped.png",
59
+ "diffMap": "validations/pages-contact-2/diff.png",
60
+ "violations": [
61
+ { "id": "anti-pattern-#5", "severity": "medium", "message": "Defensive specificity missing on .scope button — chain class + !important required to escape Elementor reset." }
62
+ ],
63
+ "structuredDiffs": [
64
+ { "key": "form.gap", "live": "16px", "build": "12px", "severity": "low" }
65
+ ],
66
+ "candidateFixes": [
67
+ "Add .scope.scope__contact { } around the form to chain specificity (Pattern E).",
68
+ "!important on button { background: …; color: … } to defeat Elementor reset."
69
+ ]
70
+ },
71
+ {
72
+ "url": "https://example-store.com/blog/article",
73
+ "type": "pages",
74
+ "slug": "blog-article",
75
+ "status": "ok",
76
+ "diffPercent": 6.12,
77
+ "iterations": 0,
78
+ "outputPath": "clean/pages/blog-article.html",
79
+ "screenshotsLive": null,
80
+ "screenshotsBuild": null,
81
+ "diffMap": null,
82
+ "violations": [],
83
+ "structuredDiffs": [],
84
+ "candidateFixes": []
85
+ },
86
+ {
87
+ "url": "https://example-store.com/checkout",
88
+ "type": "pages",
89
+ "slug": "checkout",
90
+ "status": "fail",
91
+ "diffPercent": 75.30,
92
+ "iterations": 2,
93
+ "outputPath": "clean/pages/checkout.html",
94
+ "screenshotsLive": "validations/checkout-2/live-cropped.png",
95
+ "screenshotsBuild": "validations/checkout-2/build-cropped.png",
96
+ "diffMap": "validations/checkout-2/diff.png",
97
+ "violations": [
98
+ { "id": "widget-blocked", "severity": "high", "message": "Third-party checkout widget did not render in headless browser. URL adjacency probe failed; manual paste of rendered HTML required." }
99
+ ],
100
+ "structuredDiffs": [
101
+ { "key": "section.height", "live": "1840px", "build": "320px", "severity": "high" }
102
+ ],
103
+ "candidateFixes": [
104
+ "Re-run with --user-paste-html=checkout.html (Plan B for widget-blocked pages)."
105
+ ]
106
+ }
107
+ ],
108
+ "configSnapshot": {
109
+ "max_parallel_pages": 3,
110
+ "auto_correct_max_iterations": 2,
111
+ "diff_threshold_pct": 10,
112
+ "preset": "wp-elementor"
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "projectSlug": "example-store",
3
+ "rootUrl": "https://example-store.com",
4
+ "target": "wp",
5
+ "currentRun": {
6
+ "timestamp": "2026-05-05T18:10:55Z",
7
+ "durationMs": 142001,
8
+ "totalIterations": 1,
9
+ "totals": { "ok": 4, "warn": 1, "fail": 0 },
10
+ "pageResults": [
11
+ {
12
+ "url": "https://example-store.com/",
13
+ "type": "home",
14
+ "slug": "home",
15
+ "status": "ok",
16
+ "diffPercent": 5.20,
17
+ "iterations": 0,
18
+ "outputPath": "clean/home/index.html",
19
+ "violations": [],
20
+ "structuredDiffs": []
21
+ },
22
+ {
23
+ "url": "https://example-store.com/products/foo",
24
+ "type": "pdp",
25
+ "slug": "products-foo",
26
+ "status": "ok",
27
+ "diffPercent": 4.10,
28
+ "iterations": 0,
29
+ "outputPath": "clean/pdp/products-foo.html",
30
+ "violations": []
31
+ },
32
+ {
33
+ "url": "https://example-store.com/pages/contact",
34
+ "type": "pages",
35
+ "slug": "pages-contact",
36
+ "status": "ok",
37
+ "diffPercent": 7.80,
38
+ "iterations": 1,
39
+ "outputPath": "clean/pages/pages-contact.html",
40
+ "violations": []
41
+ },
42
+ {
43
+ "url": "https://example-store.com/blog/article",
44
+ "type": "pages",
45
+ "slug": "blog-article",
46
+ "status": "ok",
47
+ "diffPercent": 5.61,
48
+ "iterations": 0,
49
+ "outputPath": "clean/pages/blog-article.html",
50
+ "violations": []
51
+ },
52
+ {
53
+ "url": "https://example-store.com/checkout",
54
+ "type": "pages",
55
+ "slug": "checkout",
56
+ "status": "warn",
57
+ "diffPercent": 32.10,
58
+ "iterations": 0,
59
+ "outputPath": "clean/pages/checkout.html",
60
+ "violations": [
61
+ { "id": "widget-blocked", "severity": "medium", "message": "Widget pasted manually; visual still drifts but acceptable for review." }
62
+ ]
63
+ }
64
+ ],
65
+ "configSnapshot": {
66
+ "max_parallel_pages": 3,
67
+ "auto_correct_max_iterations": 2,
68
+ "diff_threshold_pct": 10,
69
+ "preset": "wp-elementor"
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // SimilarBuild aggregate report renderer.
3
+ //
4
+ // Reads a current-run JSON (stdin OR --input <path>), merges with prior runs
5
+ // preserved inside an existing target HTML (Pattern #38: cumulative reports),
6
+ // substitutes into report-template.html, writes the result.
7
+ //
8
+ // Usage:
9
+ // cat run.json | node report-renderer.mjs --output reports/index.html
10
+ // node report-renderer.mjs --input run.json --output reports/index.html
11
+ // node report-renderer.mjs --input run.json --output out.html --template path/to/template.html
12
+ // node report-renderer.mjs --input run.json --output out.html --max-previous-runs 10
13
+ //
14
+ // Stderr prefix: [report-renderer]
15
+
16
+ import { readFile, writeFile, stat } from "node:fs/promises"
17
+ import { fileURLToPath } from "node:url"
18
+ import { dirname, isAbsolute, resolve } from "node:path"
19
+
20
+ const SELF_DIR = dirname(fileURLToPath(import.meta.url))
21
+ const DEFAULT_TEMPLATE = resolve(SELF_DIR, "report-template.html")
22
+ const TEMPLATE_VERSION = "1"
23
+ const STDERR_PREFIX = "[report-renderer]"
24
+
25
+ function log(msg) { process.stderr.write(`${STDERR_PREFIX} ${msg}\n`) }
26
+ function fail(msg, code = 1) { log(`fatal: ${msg}`); process.exit(code) }
27
+
28
+ function parseArgs(argv) {
29
+ const out = { input: null, output: null, template: DEFAULT_TEMPLATE, maxPrevious: 20, projectTitle: null }
30
+ for (let i = 2; i < argv.length; i++) {
31
+ const a = argv[i]
32
+ const next = () => argv[++i]
33
+ switch (a) {
34
+ case "--input": out.input = next(); break
35
+ case "--output": out.output = next(); break
36
+ case "--template": out.template = next(); break
37
+ case "--max-previous-runs": out.maxPrevious = parseInt(next(), 10) || 20; break
38
+ case "--project-title": out.projectTitle = next(); break
39
+ case "-h": case "--help":
40
+ process.stdout.write(usage()); process.exit(0)
41
+ default:
42
+ fail(`unknown arg: ${a}. Try --help.`)
43
+ }
44
+ }
45
+ if (!out.output) fail("--output is required")
46
+ return out
47
+ }
48
+
49
+ function usage() {
50
+ return [
51
+ "report-renderer — emits SimilarBuild aggregate report.html",
52
+ "",
53
+ "Required: --output <path>",
54
+ "Input: stdin (default) or --input <run.json>",
55
+ "Optional: --template <path> --max-previous-runs <N> --project-title <str>",
56
+ "",
57
+ ].join("\n")
58
+ }
59
+
60
+ async function readStdin() {
61
+ return new Promise((resolveP, rejectP) => {
62
+ let buf = ""
63
+ if (process.stdin.isTTY) return resolveP("")
64
+ process.stdin.setEncoding("utf8")
65
+ process.stdin.on("data", (c) => { buf += c })
66
+ process.stdin.on("end", () => resolveP(buf))
67
+ process.stdin.on("error", rejectP)
68
+ })
69
+ }
70
+
71
+ async function loadRunData(args) {
72
+ let raw
73
+ if (args.input) {
74
+ log(`reading run JSON from ${args.input}`)
75
+ raw = await readFile(args.input, "utf8")
76
+ } else {
77
+ log(`reading run JSON from stdin`)
78
+ raw = await readStdin()
79
+ }
80
+ if (!raw || !raw.trim()) fail("no run data on stdin or --input")
81
+ let json
82
+ try { json = JSON.parse(raw) } catch (e) { fail(`run JSON parse error: ${e.message}`) }
83
+ return json
84
+ }
85
+
86
+ // Pattern #38: extract prior currentRun + previousRuns from an existing report.
87
+ // We embed all run state in <script type="application/json" id="sb-report-data">…</script>,
88
+ // so a regex pull is sufficient (no need to spin up a DOM parser).
89
+ async function extractPreviousState(outputPath) {
90
+ let exists = false
91
+ try { exists = (await stat(outputPath)).isFile() } catch { /* not there */ }
92
+ if (!exists) return null
93
+ let html
94
+ try { html = await readFile(outputPath, "utf8") } catch (e) {
95
+ log(`warn: could not read existing ${outputPath} for merge: ${e.message}`)
96
+ return null
97
+ }
98
+ const m = html.match(/<script type="application\/json" id="sb-report-data">([\s\S]*?)<\/script>/)
99
+ if (!m) {
100
+ log(`warn: existing ${outputPath} has no embedded data block — overwriting without merge`)
101
+ return null
102
+ }
103
+ try {
104
+ return JSON.parse(m[1])
105
+ } catch (e) {
106
+ log(`warn: existing data block unparseable (${e.message}) — overwriting without merge`)
107
+ return null
108
+ }
109
+ }
110
+
111
+ function normalizeRun(run) {
112
+ // Defensive — accept skinny inputs but guarantee the shape the template renders against.
113
+ const r = run && typeof run === "object" ? run : {}
114
+ const pages = Array.isArray(r.pageResults) ? r.pageResults : []
115
+ const totals = r.totals || pages.reduce((acc, p) => {
116
+ const s = String(p.status || "").toLowerCase()
117
+ if (s === "ok" || s === "pass" || s === "passed" || s === "✅") acc.ok++
118
+ else if (s === "fail" || s === "failed" || s === "❌") acc.fail++
119
+ else acc.warn++
120
+ return acc
121
+ }, { ok: 0, warn: 0, fail: 0 })
122
+ return {
123
+ timestamp: r.timestamp || new Date().toISOString(),
124
+ durationMs: r.durationMs || 0,
125
+ totalIterations: r.totalIterations != null ? r.totalIterations : pages.reduce((s, p) => s + (p.iterations || 0), 0),
126
+ totals,
127
+ pageResults: pages,
128
+ configSnapshot: r.configSnapshot || null,
129
+ }
130
+ }
131
+
132
+ function buildPayload(input, prior, args) {
133
+ const currentRun = normalizeRun(input.currentRun || input.run || input)
134
+ const projectSlug = input.projectSlug || (prior && prior.projectSlug) || "(unnamed)"
135
+ const rootUrl = input.rootUrl || (prior && prior.rootUrl) || ""
136
+ const target = input.target || (prior && prior.target) || ""
137
+
138
+ let previousRuns = []
139
+ if (prior) {
140
+ if (prior.currentRun) previousRuns.push(prior.currentRun)
141
+ if (Array.isArray(prior.previousRuns)) previousRuns = previousRuns.concat(prior.previousRuns)
142
+ }
143
+ if (Array.isArray(input.previousRuns)) {
144
+ // explicit override (e.g., user passed full state)
145
+ previousRuns = input.previousRuns
146
+ }
147
+ if (args.maxPrevious > 0 && previousRuns.length > args.maxPrevious) {
148
+ log(`trimming previousRuns ${previousRuns.length} → ${args.maxPrevious}`)
149
+ previousRuns = previousRuns.slice(0, args.maxPrevious)
150
+ }
151
+
152
+ return {
153
+ projectSlug,
154
+ rootUrl,
155
+ target,
156
+ currentRun,
157
+ previousRuns,
158
+ generatedAt: new Date().toISOString(),
159
+ templateVersion: TEMPLATE_VERSION,
160
+ }
161
+ }
162
+
163
+ // Safe-embed: prevent JSON breaking out of the host <script> block.
164
+ function embedSafe(jsonStr) {
165
+ return jsonStr
166
+ .replace(/</g, "\\u003c")
167
+ .replace(/>/g, "\\u003e")
168
+ .replace(/&/g, "\\u0026")
169
+ .replace(/\u2028/g, "\\u2028")
170
+ .replace(/\u2029/g, "\\u2029")
171
+ }
172
+
173
+ function substitute(template, payload, args) {
174
+ const titleSource =
175
+ args.projectTitle || `${payload.projectSlug}${payload.target ? " · " + payload.target : ""}`
176
+ const safeTitle = String(titleSource).replace(/[<>&"]/g, (c) => ({
177
+ "<": "&lt;", ">": "&gt;", "&": "&amp;", "\"": "&quot;",
178
+ }[c]))
179
+ const safeJson = embedSafe(JSON.stringify(payload))
180
+ return template
181
+ .replace("{{PROJECT_TITLE}}", safeTitle)
182
+ .replace("{{REPORT_DATA_JSON}}", safeJson)
183
+ }
184
+
185
+ async function main() {
186
+ const args = parseArgs(process.argv)
187
+ log(`template=${args.template}`)
188
+ log(`output=${args.output}`)
189
+
190
+ const [template, input] = await Promise.all([
191
+ readFile(args.template, "utf8").catch((e) => fail(`cannot read template: ${e.message}`)),
192
+ loadRunData(args),
193
+ ])
194
+
195
+ const outputAbs = isAbsolute(args.output) ? args.output : resolve(process.cwd(), args.output)
196
+ const prior = await extractPreviousState(outputAbs)
197
+ if (prior) log(`merged prior state (1 currentRun + ${(prior.previousRuns || []).length} previousRuns) from existing report`)
198
+
199
+ const payload = buildPayload(input, prior, args)
200
+ log(`payload: ${payload.currentRun.pageResults.length} pages this run, ${payload.previousRuns.length} previous runs preserved`)
201
+
202
+ const html = substitute(template, payload, args)
203
+ await writeFile(outputAbs, html, "utf8")
204
+ log(`wrote ${outputAbs} (${Buffer.byteLength(html, "utf8")} bytes)`)
205
+
206
+ // stdout: structured metadata for orchestrators that pipe us
207
+ process.stdout.write(JSON.stringify({
208
+ ok: true,
209
+ output: outputAbs,
210
+ bytes: Buffer.byteLength(html, "utf8"),
211
+ pagesThisRun: payload.currentRun.pageResults.length,
212
+ previousRunsPreserved: payload.previousRuns.length,
213
+ generatedAt: payload.generatedAt,
214
+ }))
215
+ process.stdout.write("\n")
216
+ }
217
+
218
+ main().catch((err) => fail(err && err.stack ? err.stack : String(err)))