@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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- 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
|
+
});
|