@cparkerwebm/webmonterey 1.0.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 (133) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +104 -0
  4. package/agents/.gitkeep +0 -0
  5. package/dist/webm.mjs +2381 -0
  6. package/hooks/.gitkeep +0 -0
  7. package/package.json +101 -0
  8. package/schema/design.json +118 -0
  9. package/skills/launch/SKILL.md +183 -0
  10. package/skills/new-component/SKILL.md +85 -0
  11. package/skills/start/SKILL.md +117 -0
  12. package/skills/traps/SKILL.md +333 -0
  13. package/skills/upgrade/SKILL.md +42 -0
  14. package/src/actions/index.ts +348 -0
  15. package/src/cli/checks.test.ts +711 -0
  16. package/src/cli/checks.ts +822 -0
  17. package/src/cli/codemods.test.ts +42 -0
  18. package/src/cli/codemods.ts +51 -0
  19. package/src/cli/compare.test.ts +144 -0
  20. package/src/cli/compare.ts +222 -0
  21. package/src/cli/design-extract.test.ts +96 -0
  22. package/src/cli/design-extract.ts +229 -0
  23. package/src/cli/doctor.ts +187 -0
  24. package/src/cli/mcp.test.ts +57 -0
  25. package/src/cli/mcp.ts +110 -0
  26. package/src/cli/new.ts +148 -0
  27. package/src/cli/package-root.ts +58 -0
  28. package/src/cli/scaffold.test.ts +230 -0
  29. package/src/cli/scaffold.ts +424 -0
  30. package/src/cli/seed.ts +133 -0
  31. package/src/cli/slug.test.ts +70 -0
  32. package/src/cli/slug.ts +109 -0
  33. package/src/cli/sync.test.ts +137 -0
  34. package/src/cli/sync.ts +266 -0
  35. package/src/cli/upgrade.ts +93 -0
  36. package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
  37. package/src/design/brand.ts +49 -0
  38. package/src/design/compile.test.ts +98 -0
  39. package/src/design/compile.ts +155 -0
  40. package/src/design/defaults.ts +315 -0
  41. package/src/design/index.ts +18 -0
  42. package/src/design/resolve.test.ts +80 -0
  43. package/src/design/resolve.ts +108 -0
  44. package/src/design/types.ts +96 -0
  45. package/src/emails/autoresponse.test.ts +82 -0
  46. package/src/emails/autoresponse.ts +83 -0
  47. package/src/emails/footer.test.ts +102 -0
  48. package/src/emails/footer.ts +91 -0
  49. package/src/emails/index.ts +24 -0
  50. package/src/emails/subject.test.ts +66 -0
  51. package/src/emails/subject.ts +80 -0
  52. package/src/emails/submission-notification.ts +76 -0
  53. package/src/env.d.ts +12 -0
  54. package/src/includes/cloudflare/d1/client.ts +86 -0
  55. package/src/includes/cloudflare/r2/README.md +68 -0
  56. package/src/includes/cloudflare/r2/media.ts +41 -0
  57. package/src/includes/cloudflare/r2/url.test.ts +44 -0
  58. package/src/includes/cloudflare/r2/url.ts +34 -0
  59. package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
  60. package/src/includes/cloudflare/turnstile/verify.ts +157 -0
  61. package/src/includes/cloudflare/workers/env.ts +56 -0
  62. package/src/includes/google/tag-manager/TagManager.astro +73 -0
  63. package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
  64. package/src/includes/sinch/mailgun/redirect.ts +96 -0
  65. package/src/includes/sinch/mailgun/send.ts +186 -0
  66. package/src/includes/webmonterey/animations/animations.css +347 -0
  67. package/src/includes/webmonterey/animations/observe.test.ts +88 -0
  68. package/src/includes/webmonterey/animations/observe.ts +209 -0
  69. package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
  70. package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
  71. package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
  72. package/src/includes/webmonterey/compliance/consent.ts +142 -0
  73. package/src/includes/webmonterey/config.test.ts +94 -0
  74. package/src/includes/webmonterey/config.ts +346 -0
  75. package/src/includes/webmonterey/copy-defaults.ts +148 -0
  76. package/src/includes/webmonterey/copy.ts +13 -0
  77. package/src/includes/webmonterey/credits/Credit.astro +80 -0
  78. package/src/includes/webmonterey/credits/credit.test.ts +111 -0
  79. package/src/includes/webmonterey/credits/credit.ts +59 -0
  80. package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
  81. package/src/includes/webmonterey/forms/honeypot.ts +66 -0
  82. package/src/includes/webmonterey/prose/inline.test.ts +94 -0
  83. package/src/includes/webmonterey/prose/inline.ts +71 -0
  84. package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
  85. package/src/includes/webmonterey/site.ts +136 -0
  86. package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
  87. package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
  88. package/src/integration/adapter.ts +53 -0
  89. package/src/integration/app-middleware.ts +43 -0
  90. package/src/integration/config.ts +96 -0
  91. package/src/integration/content.ts +66 -0
  92. package/src/integration/image-size.test.ts +83 -0
  93. package/src/integration/image-size.ts +100 -0
  94. package/src/integration/index.ts +386 -0
  95. package/src/integration/virtual.d.ts +101 -0
  96. package/src/layouts/base.astro +402 -0
  97. package/src/package.test.ts +121 -0
  98. package/src/pages/404.astro +33 -0
  99. package/src/pages/[...slug].astro +126 -0
  100. package/src/pages/robots.txt.ts +41 -0
  101. package/src/pages/webm.astro +23 -0
  102. package/src/scripts/.gitkeep +0 -0
  103. package/src/styles/base.css +185 -0
  104. package/src/styles/global.css +47 -0
  105. package/src/styles/layers.test.ts +32 -0
  106. package/src/styles/layers.ts +21 -0
  107. package/src/styles/layout.css +235 -0
  108. package/src/styles/reset.css +135 -0
  109. package/src/styles/utilities.css +90 -0
  110. package/src/worker.ts +76 -0
  111. package/template/assets/logo.svg +14 -0
  112. package/template/assets/open-graph.png +0 -0
  113. package/template/migrations/0001_create_submissions.sql +40 -0
  114. package/template/migrations/README.md +41 -0
  115. package/template/public/android-chrome-192x192.png +0 -0
  116. package/template/public/android-chrome-512x512.png +0 -0
  117. package/template/public/apple-touch-icon.png +0 -0
  118. package/template/public/favicon-16x16.png +0 -0
  119. package/template/public/favicon-32x32.png +0 -0
  120. package/template/public/favicon.ico +0 -0
  121. package/template/public/favicon.svg +4 -0
  122. package/template/public/open-graph.png +0 -0
  123. package/template/scripts/check-node.mjs +72 -0
  124. package/template/scripts/test-hooks.mjs +96 -0
  125. package/template/site/.editorconfig +22 -0
  126. package/template/site/.prettierignore +15 -0
  127. package/template/site/.prettierrc.json +17 -0
  128. package/template/site/CLAUDE.md +245 -0
  129. package/template/site/CONTENT.md +135 -0
  130. package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
  131. package/template/site/public/_headers +70 -0
  132. package/template/site/src/forms/contact.json +45 -0
  133. package/template/workflows/ci.yml +52 -0
@@ -0,0 +1,42 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { codemodsBetween, CODEMODS, type Codemod } from './codemods.ts';
4
+
5
+ const stub = (version: string): Codemod => ({ version, title: version, run: () => [] });
6
+ const ALL = [stub('3.0.0'), stub('1.1.0'), stub('2.1.0'), stub('2.0.0')];
7
+ const versions = (from: string, to: string) => codemodsBetween(from, to, ALL).map((c) => c.version);
8
+
9
+ test('the shipped codemods are exactly the versions this release claims', () => {
10
+ // A registry assertion rather than nothing: adding a codemod is a deliberate act somebody
11
+ // reviews, and this line is where the review happens.
12
+ assert.deepEqual(
13
+ CODEMODS.map((c) => c.version),
14
+ [],
15
+ );
16
+ });
17
+
18
+ test('the range is exclusive at the bottom and inclusive at the top', () => {
19
+ // 1.1.0's codemod already ran when the site landed on 1.1.0.
20
+ assert.deepEqual(versions('1.1.0', '2.0.0'), ['2.0.0']);
21
+ assert.deepEqual(versions('1.0.0', '2.0.0'), ['1.1.0', '2.0.0']);
22
+ });
23
+
24
+ test('results are ordered by version, whatever order they were registered in', () => {
25
+ assert.deepEqual(versions('1.0.0', '3.0.0'), ['1.1.0', '2.0.0', '2.1.0', '3.0.0']);
26
+ });
27
+
28
+ test('an upgrade that skips majors runs every codemod in between', () => {
29
+ assert.deepEqual(versions('1.0.0', '2.1.0'), ['1.1.0', '2.0.0', '2.1.0']);
30
+ });
31
+
32
+ test('no movement runs nothing', () => {
33
+ assert.deepEqual(versions('2.0.0', '2.0.0'), []);
34
+ });
35
+
36
+ test('a fresh install runs everything up to the installed version', () => {
37
+ assert.deepEqual(versions('0.0.0', '2.0.0'), ['1.1.0', '2.0.0']);
38
+ });
39
+
40
+ test('a two-part version is treated as x.y.0', () => {
41
+ assert.deepEqual(versions('2.0', '2.1.0'), ['2.1.0']);
42
+ });
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Codemods, keyed by the version that introduces the break.
3
+ *
4
+ * A MAJOR WITHOUT A CODEMOD IS A MAJOR THAT WILL NOT GET ADOPTED. Generation 1 is the evidence:
5
+ * it shipped a framework that six sites could have used and one did, because moving forward meant
6
+ * hand-editing every consumer.
7
+ *
8
+ * Each entry runs against a site root and reports what it changed. They must be IDEMPOTENT - a
9
+ * half-finished upgrade gets re-run, and a codemod that doubles an import on the second pass is
10
+ * worse than no codemod.
11
+ */
12
+ export interface Codemod {
13
+ /** The version whose changes this handles, e.g. '2.0.0'. */
14
+ version: string;
15
+ title: string;
16
+ /** Returns a description of every change made. Empty means nothing needed doing. */
17
+ run(siteRoot: string): string[];
18
+ }
19
+
20
+ /*
21
+ * EMPTY AT 1.0.0, and that is the registry's normal state. A codemod is added in the same change
22
+ * as the break it repairs, and a test asserts the list is exactly what the release claims - so
23
+ * adding one is a deliberate act somebody reviews rather than a surprise.
24
+ *
25
+ * The shape to copy when the first one arrives: read the site's file, MERGE the change in
26
+ * (never replace a file the site may have added to), and return [] when there is nothing left to
27
+ * do, so a second run is a no-op.
28
+ */
29
+ export const CODEMODS: Codemod[] = [];
30
+
31
+ /** Semver compare, on the three numeric parts only. Prerelease tags are not used here. */
32
+ function compareVersions(a: string, b: string): number {
33
+ const pa = a.split('.').map(Number);
34
+ const pb = b.split('.').map(Number);
35
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
36
+ return 0;
37
+ }
38
+
39
+ /**
40
+ * Codemods that apply when moving from `from` to `to`, in version order.
41
+ *
42
+ * Exclusive at the bottom, inclusive at the top: upgrading 1.1.0 -> 2.0.0 runs 2.0.0's codemod
43
+ * and not 1.1.0's, which already ran when the site landed on 1.1.0.
44
+ *
45
+ * `list` is injectable so the range logic is testable without registering a real codemod.
46
+ */
47
+ export function codemodsBetween(from: string, to: string, list: Codemod[] = CODEMODS): Codemod[] {
48
+ return list
49
+ .filter((c) => compareVersions(c.version, from) > 0 && compareVersions(c.version, to) <= 0)
50
+ .sort((a, b) => compareVersions(a.version, b.version));
51
+ }
@@ -0,0 +1,144 @@
1
+ /*
2
+ * The rebuild comparison. Each layer exists because the one before it missed something real.
3
+ */
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { visibleText, headTags, jsonLd, cssDeclarations, compare } from './compare.ts';
10
+
11
+ test('visible text ignores scripts, styles and comments', () => {
12
+ const html = `<html><head><style>.a{color:red}</style></head>
13
+ <body><!-- hidden --><script>var x=1</script><p>Hello there</p></body></html>`;
14
+ assert.equal(visibleText(html), 'Hello there');
15
+ });
16
+
17
+ test('text alone would have passed a rebuild that lost its typeface', () => {
18
+ /*
19
+ * THIS IS WHY THERE ARE FOUR LAYERS. On the webmonterey.com rebuild, comparing text reported
20
+ * "identical on every page" while the site had silently stopped loading its self-hosted font -
21
+ * every word present, in the wrong face. The words are the same here too.
22
+ */
23
+ const before = `<html><head><link rel="stylesheet" href="/a.css"></head><body><p>Same words</p></body></html>`;
24
+ const after = `<html><head></head><body><p>Same words</p></body></html>`;
25
+ assert.equal(visibleText(before), visibleText(after), 'text cannot see it');
26
+ assert.notDeepEqual(headTags(before), headTags(after), 'the head layer can');
27
+ });
28
+
29
+ test('head tags are order-independent, because head order is not meaningful', () => {
30
+ const a = `<head><meta name="a" content="1"><link rel="canonical" href="/x"></head>`;
31
+ const b = `<head><link rel="canonical" href="/x"><meta name="a" content="1"></head>`;
32
+ assert.deepEqual(headTags(a), headTags(b));
33
+ });
34
+
35
+ test('a changed canonical is caught', () => {
36
+ const a = `<head><link rel="canonical" href="/x"></head>`;
37
+ const b = `<head><link rel="canonical" href="/y"></head>`;
38
+ assert.notDeepEqual(headTags(a), headTags(b));
39
+ });
40
+
41
+ test('JSON-LD is compared as parsed data, not as text', () => {
42
+ // Key order and whitespace change between builds and mean nothing.
43
+ const a = `<script type="application/ld+json">{"a":1,"b":2}</script>`;
44
+ const b = `<script type="application/ld+json">{"a":1,"b":2}</script>`;
45
+ assert.equal(jsonLd(a), jsonLd(b));
46
+ const c = `<script type="application/ld+json">{"a":1}</script>`;
47
+ assert.notEqual(jsonLd(a), jsonLd(c));
48
+ });
49
+
50
+ test('malformed JSON-LD does not throw, it falls back to raw text', () => {
51
+ const bad = `<script type="application/ld+json">{not json</script>`;
52
+ assert.match(jsonLd(bad), /not json/);
53
+ });
54
+
55
+ test('bundler hashes in asset URLs are not a difference', () => {
56
+ /*
57
+ * The hash is content-derived and changes every build. Without stripping it, every stylesheet
58
+ * and script tag reads as both missing and added on a rebuild that changed nothing - and a
59
+ * comparison tool that cries wolf on every run is one nobody reads.
60
+ */
61
+ const a = `<head><link rel="stylesheet" href="/_astro/base.D8TeHbgD.css"></head>`;
62
+ const b = `<head><link rel="stylesheet" href="/_astro/base.B7aTneAO.css"></head>`;
63
+ assert.deepEqual(headTags(a), headTags(b));
64
+ });
65
+
66
+ test('a genuinely different stylesheet is still caught', () => {
67
+ const a = `<head><link rel="stylesheet" href="/_astro/base.AAAAAAAA.css"></head>`;
68
+ const b = `<head><link rel="stylesheet" href="/_astro/other.BBBBBBBB.css"></head>`;
69
+ assert.notDeepEqual(headTags(a), headTags(b), 'the NAME differs, not just the hash');
70
+ });
71
+
72
+ test('CSS is gathered from inline <style> as well as from files', () => {
73
+ /*
74
+ * Astro inlines a small stylesheet into the HTML instead of emitting a file, and which way it
75
+ * goes depends on size - so the same rule sits in _astro/*.css on one build and inside <style>
76
+ * on the next. Reading only the files reported a whole component stylesheet as missing when it
77
+ * had simply moved, on a rebuild that was correct.
78
+ */
79
+ const dir = mkdtempSync(join(tmpdir(), 'webm-cmp-'));
80
+ mkdirSync(join(dir, '_astro'), { recursive: true });
81
+ writeFileSync(join(dir, '_astro', 'a.css'), '.from-file{color:red}');
82
+ writeFileSync(
83
+ join(dir, 'index.html'),
84
+ '<html><head><style>.inlined{padding:1rem}</style></head></html>',
85
+ );
86
+
87
+ const decls = cssDeclarations(dir);
88
+ assert.ok(
89
+ [...decls].some((d) => d.includes('color:red')),
90
+ 'the file',
91
+ );
92
+ assert.ok(
93
+ [...decls].some((d) => d.includes('padding:1rem')),
94
+ 'and the inline block',
95
+ );
96
+ });
97
+
98
+ test('a build that only GAINS css declarations is not a regression', () => {
99
+ /*
100
+ * The case that nearly cost the tool its credibility. A package upgrade adds declarations and
101
+ * removes none, and the old summary line said "no differences in ... CSS" - which is false, and
102
+ * false in the direction that makes you doubt a working tool. Worse, it hides the evidence that
103
+ * an upgrade landed at all.
104
+ */
105
+ const dir = mkdtempSync(join(tmpdir(), 'cmp-'));
106
+ const oldDist = join(dir, 'old');
107
+ const newDist = join(dir, 'new');
108
+ for (const d of [oldDist, newDist]) mkdirSync(join(d, '_astro'), { recursive: true });
109
+
110
+ writeFileSync(join(oldDist, 'index.html'), '<html><body><p>Same words</p></body></html>');
111
+ writeFileSync(join(newDist, 'index.html'), '<html><body><p>Same words</p></body></html>');
112
+ writeFileSync(join(oldDist, '_astro', 'a.css'), 'a{color:red}');
113
+ writeFileSync(join(newDist, '_astro', 'a.css'), 'a{color:red}b{outline:1px solid blue}');
114
+
115
+ const diffs = compare(oldDist, newDist);
116
+ assert.equal(diffs.length, 1, 'the gain is reported');
117
+ assert.equal(diffs[0]!.info, true, 'but it is not a regression');
118
+ assert.deepEqual(diffs[0]!.missing, [], 'nothing was lost');
119
+ assert.ok(
120
+ diffs[0]!.added.some((d) => d.includes('outline:1px solid blue')),
121
+ 'and the new declaration is named, so you can see the upgrade landed',
122
+ );
123
+
124
+ rmSync(dir, { recursive: true, force: true });
125
+ });
126
+
127
+ test('a lost declaration is still a regression even when others are gained', () => {
128
+ const dir = mkdtempSync(join(tmpdir(), 'cmp-'));
129
+ const oldDist = join(dir, 'old');
130
+ const newDist = join(dir, 'new');
131
+ for (const d of [oldDist, newDist]) mkdirSync(join(d, '_astro'), { recursive: true });
132
+
133
+ writeFileSync(join(oldDist, 'index.html'), '<html><body><p>Same words</p></body></html>');
134
+ writeFileSync(join(newDist, 'index.html'), '<html><body><p>Same words</p></body></html>');
135
+ writeFileSync(join(oldDist, '_astro', 'a.css'), '@font-face{src:url(x.woff2)}');
136
+ writeFileSync(join(newDist, '_astro', 'a.css'), 'b{outline:1px solid blue}');
137
+
138
+ const diffs = compare(oldDist, newDist);
139
+ const css = diffs.find((d) => d.layer === 'css')!;
140
+ assert.notEqual(css.info, true, 'a loss is never downgraded to information');
141
+ assert.ok(css.missing.some((d) => d.includes('url(x.woff2)')));
142
+
143
+ rmSync(dir, { recursive: true, force: true });
144
+ });
@@ -0,0 +1,222 @@
1
+ /*
2
+ * `webm compare <old-dist> <new-dist>` - did the rebuild change anything a visitor can see?
3
+ *
4
+ * A rebuild moves every file in a site. The only honest way to know it is faithful is to build
5
+ * the old one, build the new one, and diff the OUTPUT - the source is expected to differ
6
+ * everywhere, so comparing source proves nothing.
7
+ *
8
+ * FOUR LAYERS, BECAUSE THE FIRST ONE ALONE LIES. Comparing rendered text on the webmonterey.com
9
+ * rebuild reported "identical on every page" while the site had silently stopped loading its
10
+ * self-hosted typeface: the words were all present, in the wrong font. Text, head, structured
11
+ * data and CSS each catch a different class of regression and none subsumes another.
12
+ *
13
+ * text visible words, tags stripped content loss, duplicated headings
14
+ * head meta and link tags canonical, robots, Open Graph, favicons
15
+ * jsonld the structured-data graph the SEO surface, which no page shows
16
+ * css every declaration in the bundle fonts, tokens, whole stylesheets not imported
17
+ *
18
+ * Astro's per-build scope hashes are normalized away, or every component rule reads as changed.
19
+ */
20
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
21
+ import { join, relative } from 'node:path';
22
+
23
+ export interface Diff {
24
+ layer: 'text' | 'head' | 'jsonld' | 'css';
25
+ /* Reported, but not a regression - it does not fail the run. Only CSS gains use this: a
26
+ * declaration the new build serves and the old did not is the expected shape of a package
27
+ * upgrade, and staying silent about it made the summary line claim there was no difference
28
+ * at all. Seeing the gain is how you confirm a fix actually reached the bundle. */
29
+ info?: boolean;
30
+ page: string;
31
+ missing: string[];
32
+ added: string[];
33
+ }
34
+
35
+ function walk(dir: string, match: (f: string) => boolean): string[] {
36
+ if (!existsSync(dir)) return [];
37
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
38
+ const full = join(dir, e.name);
39
+ return e.isDirectory() ? walk(full, match) : match(e.name) ? [full] : [];
40
+ });
41
+ }
42
+
43
+ /** Visible words, with script, style and comments removed. */
44
+ export function visibleText(html: string): string {
45
+ return html
46
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ')
47
+ .replace(/<!--[\s\S]*?-->/g, ' ')
48
+ .replace(/<[^>]+>/g, ' ')
49
+ .replace(/&[a-z]+;|&#\d+;/gi, ' ')
50
+ .replace(/\s+/g, ' ')
51
+ .trim();
52
+ }
53
+
54
+ /**
55
+ * Every meta and link tag, normalized and sorted - order in <head> is not meaningful.
56
+ *
57
+ * BUNDLER HASHES ARE STRIPPED. `/_astro/base.D8TeHbgD.css` becomes `/_astro/base.css`: the hash
58
+ * is content-derived and changes on every build, so without this every stylesheet and script tag
59
+ * reads as both missing and added on a rebuild that changed nothing. That noise is what makes a
60
+ * comparison tool get ignored.
61
+ */
62
+ export function headTags(html: string): string[] {
63
+ const head = html.slice(0, html.indexOf('</head>') + 1);
64
+ return [...head.matchAll(/<(meta|link)\b[^>]*>/gi)]
65
+ .map((m) =>
66
+ m[0]
67
+ .replace(/\s+/g, ' ')
68
+ .replace(/\.[A-Za-z0-9_-]{8,}\.(css|js|woff2?|png|jpg|webp|svg)/g, '.$1')
69
+ /*
70
+ * Server-island preloads carry the island's ENCRYPTED PROPS in the query string, and
71
+ * those are re-encrypted every build. Without this each one reads as both missing and
72
+ * added, on every page that defers anything.
73
+ */
74
+ .replace(/(\/_server-islands\/[A-Za-z0-9_-]+)\?[^"']*/g, '$1'),
75
+ )
76
+ .sort();
77
+ }
78
+
79
+ export function jsonLd(html: string): string {
80
+ const blocks = [
81
+ ...html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi),
82
+ ];
83
+ try {
84
+ return JSON.stringify(
85
+ blocks.map((b) => JSON.parse(b[1]!)),
86
+ null,
87
+ 1,
88
+ );
89
+ } catch {
90
+ return blocks.map((b) => b[1]).join('\n');
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Every CSS declaration in the bundle, as a set.
96
+ *
97
+ * A set rather than a diff of files: the bundler splits and names files differently between
98
+ * builds, so file-level comparison is noise. What matters is whether a declaration that used to
99
+ * be served still is.
100
+ */
101
+ export function cssDeclarations(dist: string): Set<string> {
102
+ /*
103
+ * BOTH SOURCES, and missing the second one makes this lie. Astro INLINES a small stylesheet
104
+ * into the HTML rather than emitting a file, and which way it goes depends on size - so the
105
+ * same rule can be in _astro/*.css on one build and inside <style> on the next. Reading only
106
+ * the files reported a component's entire stylesheet as missing when it had simply moved.
107
+ */
108
+ const inline = walk(dist, (f) => f.endsWith('.html'))
109
+ .flatMap((f) => [...readFileSync(f, 'utf8').matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)])
110
+ .map((m) => m[1]!);
111
+
112
+ const css = [
113
+ ...walk(join(dist, '_astro'), (f) => f.endsWith('.css')).map((f) => readFileSync(f, 'utf8')),
114
+ ...inline,
115
+ ]
116
+ .join('\n')
117
+ .replace(/\/\*[\s\S]*?\*\//g, '')
118
+ /* Astro's scope hash changes every build; without this every scoped rule reads as changed. */
119
+ .replace(/\[data-astro-cid-[a-z0-9]+\]/gi, '');
120
+
121
+ return new Set(
122
+ css
123
+ .split(/[{};]/)
124
+ .map((d) => d.trim())
125
+ .filter((d) => d.includes(':') && d.length < 300),
126
+ );
127
+ }
128
+
129
+ const setDiff = (a: Set<string>, b: Set<string>) => [...a].filter((x) => !b.has(x));
130
+ const words = (s: string) => new Set(s.split(' ').filter(Boolean));
131
+
132
+ export function compare(oldDist: string, newDist: string): Diff[] {
133
+ const diffs: Diff[] = [];
134
+ const pages = walk(oldDist, (f) => f.endsWith('.html')).map((f) => relative(oldDist, f));
135
+
136
+ for (const page of pages) {
137
+ const a = readFileSync(join(oldDist, page), 'utf8');
138
+ const bPath = join(newDist, page);
139
+ if (!existsSync(bPath)) {
140
+ diffs.push({ layer: 'text', page, missing: ['THE WHOLE PAGE'], added: [] });
141
+ continue;
142
+ }
143
+ const b = readFileSync(bPath, 'utf8');
144
+
145
+ const at = words(visibleText(a));
146
+ const bt = words(visibleText(b));
147
+ if (setDiff(at, bt).length || setDiff(bt, at).length) {
148
+ diffs.push({ layer: 'text', page, missing: setDiff(at, bt), added: setDiff(bt, at) });
149
+ }
150
+
151
+ const ah = new Set(headTags(a));
152
+ const bh = new Set(headTags(b));
153
+ if (setDiff(ah, bh).length || setDiff(bh, ah).length) {
154
+ diffs.push({ layer: 'head', page, missing: setDiff(ah, bh), added: setDiff(bh, ah) });
155
+ }
156
+
157
+ if (jsonLd(a) !== jsonLd(b)) {
158
+ diffs.push({ layer: 'jsonld', page, missing: ['graph differs'], added: [] });
159
+ }
160
+ }
161
+
162
+ const ac = cssDeclarations(oldDist);
163
+ const bc = cssDeclarations(newDist);
164
+ const lost = setDiff(ac, bc);
165
+ const gained = setDiff(bc, ac);
166
+ if (lost.length) {
167
+ /*
168
+ * Only LOST declarations fail. The new build legitimately gains rules - a package base rule
169
+ * that the old site had edited in place now sits alongside the override that supersedes it.
170
+ * A declaration that vanished is the regression.
171
+ */
172
+ diffs.push({ layer: 'css', page: '(bundle)', missing: lost, added: gained });
173
+ } else if (gained.length) {
174
+ diffs.push({ layer: 'css', page: '(bundle)', missing: [], added: gained, info: true });
175
+ }
176
+
177
+ return diffs;
178
+ }
179
+
180
+ export function run(argv: string[]): number {
181
+ const [oldDist, newDist] = argv;
182
+ if (!oldDist || !newDist) {
183
+ console.error('webm compare <old-dist/client> <new-dist/client>');
184
+ console.error('\n Build both sites first. Compares text, head tags, JSON-LD and CSS.');
185
+ return 1;
186
+ }
187
+ for (const d of [oldDist, newDist]) {
188
+ if (!existsSync(d) || !statSync(d).isDirectory()) {
189
+ console.error(`webm compare: no such directory: ${d}`);
190
+ return 1;
191
+ }
192
+ }
193
+
194
+ const diffs = compare(oldDist, newDist);
195
+ const pages = walk(oldDist, (f) => f.endsWith('.html')).length;
196
+ const regressions = diffs.filter((d) => !d.info);
197
+
198
+ if (!diffs.length) {
199
+ console.log(`webm compare: ${pages} pages, no differences in text, head, JSON-LD or CSS.`);
200
+ return 0;
201
+ }
202
+
203
+ for (const d of diffs) {
204
+ console.log(`\n${d.layer.toUpperCase()}${d.info ? ' (not a regression)' : ''} ${d.page}`);
205
+ for (const m of d.missing.slice(0, 25)) console.log(` - ${m.slice(0, 160)}`);
206
+ if (d.missing.length > 25) console.log(` … ${d.missing.length - 25} more missing`);
207
+ for (const a of d.added.slice(0, 10)) console.log(` + ${a.slice(0, 160)}`);
208
+ if (d.added.length > 10) console.log(` … ${d.added.length - 10} more added`);
209
+ }
210
+ if (!regressions.length) {
211
+ console.log(
212
+ `\nwebm compare: ${pages} pages, no regressions in text, head, JSON-LD or CSS.` +
213
+ ` The additions above are what the new build serves and the old did not.`,
214
+ );
215
+ return 0;
216
+ }
217
+
218
+ console.log(
219
+ `\n${regressions.length} differences across ${pages} pages. A "-" line is something the old site served and the new one does not.`,
220
+ );
221
+ return 1;
222
+ }
@@ -0,0 +1,96 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { extract, parseTokens } from './design-extract.ts';
4
+ import { compileToCss } from '../design/compile.ts';
5
+
6
+ test('a stylesheet identical to the defaults extracts to an empty design', () => {
7
+ const r = extract(compileToCss());
8
+ assert.equal(r.mapped.length, 0);
9
+ assert.equal(r.overridden.length, 0);
10
+ assert.equal(r.added.length, 0);
11
+ assert.deepEqual(r.design, { version: 1 });
12
+ });
13
+
14
+ test('a changed brand color maps to a structured field, not an override', () => {
15
+ const r = extract(compileToCss({ color: { action: { base: '#bfb23b' } } }));
16
+ assert.deepEqual(r.design.color?.action, { base: '#bfb23b' });
17
+ assert.equal(r.design.overrides, undefined);
18
+ assert.deepEqual(r.mapped, ['--webm-action']);
19
+ });
20
+
21
+ test('a changed token with no structured field falls back to overrides, and is reported', () => {
22
+ const r = extract(compileToCss({ overrides: { '--webm-space-md': '2rem' } }));
23
+ assert.deepEqual(r.design.overrides, { '--webm-space-md': '2rem' });
24
+ assert.deepEqual(r.overridden, ['--webm-space-md']);
25
+ assert.deepEqual(r.added, []);
26
+ });
27
+
28
+ test('a token the system does not define is reported separately as a site token', () => {
29
+ const r = extract(':root { --webm-hero-height: 80vh; }');
30
+ assert.deepEqual(r.added, ['--webm-hero-height']);
31
+ assert.deepEqual(r.design.overrides, { '--webm-hero-height': '80vh' });
32
+ });
33
+
34
+ test('extraction round-trips: extract then compile reproduces the input', () => {
35
+ const original = compileToCss({
36
+ color: { action: { base: '#bfb23b' }, base: { '700': '#2f3030' } },
37
+ font: { sans: '"DM Sans", system-ui, sans-serif' },
38
+ overrides: { '--webm-space-md': '2rem', '--webm-hero-height': '80vh' },
39
+ });
40
+ const rebuilt = compileToCss(extract(original).design);
41
+ assert.deepEqual(parseTokens(rebuilt), parseTokens(original));
42
+ });
43
+
44
+ test('comments are ignored, so a commented-out token is not extracted', () => {
45
+ const r = extract(':root { /* --webm-action: #ff0000; */ --webm-action: #00ff00; }');
46
+ assert.equal(r.design.color?.action?.base, '#00ff00');
47
+ });
48
+
49
+ test('a token overridden inside @media does not overwrite its base value', () => {
50
+ /*
51
+ * THE MIGRATION BUG. The old parser was a flat regex, last-write-wins, so the reduced-motion
52
+ * override became the site's real value. Converting webmonterey.com that way wrote
53
+ * --webm-duration-fast: 0ms as the base: every animation disabled for every visitor, from a
54
+ * command that printed success.
55
+ */
56
+ const css = `
57
+ @layer webm.tokens {
58
+ :root {
59
+ --webm-duration-fast: 140ms;
60
+ --webm-chrome-inset: var(--webm-space-16);
61
+ }
62
+ @media (prefers-reduced-motion: reduce) {
63
+ :root { --webm-duration-fast: 0ms; }
64
+ }
65
+ @media (width < 48rem) {
66
+ :root { --webm-chrome-inset: var(--webm-space-sm); }
67
+ }
68
+ }
69
+ `;
70
+ const { base, conditional } = parseTokens(css);
71
+
72
+ assert.equal(base.get('--webm-duration-fast'), '140ms', 'the REAL value, not the media override');
73
+ assert.equal(base.get('--webm-chrome-inset'), 'var(--webm-space-16)');
74
+ assert.ok(conditional.has('--webm-duration-fast'), 'the override is reported, not lost');
75
+ assert.match(conditional.get('--webm-duration-fast')!.join(' '), /prefers-reduced-motion/);
76
+ assert.match(conditional.get('--webm-chrome-inset')!.join(' '), /width < 48rem/);
77
+ });
78
+
79
+ test('a conditional-only token never becomes a base value', () => {
80
+ const css = `
81
+ :root { --webm-action: #006abe; }
82
+ @media print { :root { --webm-action: #000000; } }
83
+ `;
84
+ const { base, conditional } = parseTokens(css);
85
+ assert.equal(base.get('--webm-action'), '#006abe');
86
+ assert.deepEqual(conditional.get('--webm-action'), ['@media print']);
87
+ });
88
+
89
+ test('extract reports the conditional tokens it could not carry across', () => {
90
+ const result = extract(`
91
+ :root { --webm-duration-fast: 140ms; }
92
+ @media (prefers-reduced-motion: reduce) { :root { --webm-duration-fast: 0ms; } }
93
+ `);
94
+ assert.equal(result.design.overrides?.['--webm-duration-fast'], '140ms');
95
+ assert.ok(result.conditional.has('--webm-duration-fast'));
96
+ });