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,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
|
+
"<": "<", ">": ">", "&": "&", "\"": """,
|
|
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)))
|