create-website-build-kit 0.1.5 → 0.1.7
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/package.json +1 -1
- package/template/package.json +2 -2
- package/template/scripts/build.mjs +114 -0
- package/template/scripts/check-sitemap.mjs +8 -2
- package/template/scripts/lastmod.mjs +24 -4
- package/template/scripts/lib/routes.mjs +8 -2
- package/template/scripts/og-cards.mjs +13 -2
- package/template/scripts/tells.mjs +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-website-build-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
package/template/package.json
CHANGED
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"redirects": "node scripts/redirects.mjs",
|
|
30
30
|
"handover": "node scripts/md-to-pdf.mjs docs/handover.md docs/handover.pdf",
|
|
31
31
|
"build": "astro build",
|
|
32
|
-
"build:staging": "
|
|
33
|
-
"build:production": "node scripts/
|
|
32
|
+
"build:staging": "node scripts/build.mjs staging",
|
|
33
|
+
"build:production": "node scripts/build.mjs production",
|
|
34
34
|
"preview": "wrangler dev",
|
|
35
35
|
"deploy:staging": "npm run build:staging && wrangler deploy && node scripts/check-secrets.mjs",
|
|
36
36
|
"deploy:production": "npm run build:production && wrangler deploy && node scripts/check-secrets.mjs",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a build for one environment.
|
|
3
|
+
*
|
|
4
|
+
* node scripts/build.mjs staging
|
|
5
|
+
* node scripts/build.mjs production
|
|
6
|
+
*
|
|
7
|
+
* `npm run build:staging` and `npm run build:production` are one-line aliases
|
|
8
|
+
* for these.
|
|
9
|
+
*
|
|
10
|
+
* ── WHY A SCRIPT AND NOT INLINE ENV IN package.json ────────────────────────
|
|
11
|
+
* ⚠ `PUBLIC_SITE_ENV=staging astro build` IS POSIX SHELL SYNTAX, AND npm ON
|
|
12
|
+
* WINDOWS RUNS SCRIPTS THROUGH cmd.exe. There it is not an assignment, it is
|
|
13
|
+
* a command name, and the build dies immediately with
|
|
14
|
+
*
|
|
15
|
+
* 'PUBLIC_SITE_ENV' is not recognized as an internal or external command
|
|
16
|
+
*
|
|
17
|
+
* So the two most important commands in the kit did not work at all on a
|
|
18
|
+
* platform the kit says it supports. Nothing caught it because every CI job
|
|
19
|
+
* ran on ubuntu.
|
|
20
|
+
*
|
|
21
|
+
* ── AND WHY IT IS SAFER EVEN WHERE THE SHELL SYNTAX WORKS ──────────────────
|
|
22
|
+
* The production line repeated `PUBLIC_SITE_ENV=production` four times, once
|
|
23
|
+
* per command. Miss one and that step runs as `development` while the others
|
|
24
|
+
* do not: `astro check` types a different environment than the one that gets
|
|
25
|
+
* built, or `check-env` validates an environment nobody deployed.
|
|
26
|
+
*
|
|
27
|
+
* A mixed-environment build is precisely what `check-env.mjs` exists to catch,
|
|
28
|
+
* and it is the sort of thing that survives review because every line looks
|
|
29
|
+
* right on its own. Setting it once removes the class.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
34
|
+
|
|
35
|
+
const RESET = '\x1b[0m';
|
|
36
|
+
const RED = '\x1b[31m';
|
|
37
|
+
const DIM = '\x1b[2m';
|
|
38
|
+
|
|
39
|
+
const env = process.argv[2];
|
|
40
|
+
|
|
41
|
+
if (env !== 'staging' && env !== 'production') {
|
|
42
|
+
console.error(
|
|
43
|
+
`\n${RED}✗ usage: node scripts/build.mjs <staging|production>${RESET}\n\n` +
|
|
44
|
+
' The environment is not optional. A build that does not declare one\n' +
|
|
45
|
+
' emits localhost canonical URLs — cleanly, and wrong.\n',
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* The site URL per environment. Read from package.json rather than restated
|
|
52
|
+
* here, so there is one place a project sets its hostnames.
|
|
53
|
+
*
|
|
54
|
+
* ⚠ Kept OUT of src/data/site.ts on purpose: astro.config.mjs needs the value
|
|
55
|
+
* before any TypeScript is loaded, and site.ts imports `import.meta.env`.
|
|
56
|
+
*/
|
|
57
|
+
const SITE_URLS = {
|
|
58
|
+
staging: 'https://new.example.com',
|
|
59
|
+
production: 'https://example.com',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run one step with the environment already set, and stop the build if it
|
|
64
|
+
* fails. `shell: true` on Windows because `npx`-style shims are .cmd files
|
|
65
|
+
* that execFile cannot resolve — the same reason the a11y scripts need it.
|
|
66
|
+
*/
|
|
67
|
+
function step(command, args) {
|
|
68
|
+
const run = spawnSync(command, args, {
|
|
69
|
+
stdio: 'inherit',
|
|
70
|
+
env: { ...process.env, PUBLIC_SITE_ENV: env, PUBLIC_SITE_URL: SITE_URLS[env] },
|
|
71
|
+
shell: process.platform === 'win32',
|
|
72
|
+
});
|
|
73
|
+
if (run.status !== 0) {
|
|
74
|
+
console.error(`\n${RED}✗ build failed at:${RESET} ${command} ${args.join(' ')}\n`);
|
|
75
|
+
process.exit(run.status ?? 1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* astro is a local binary; go through the package's own bin rather than a
|
|
80
|
+
global `astro`, which may not exist and would be the wrong version if it did. */
|
|
81
|
+
const astro = ['node_modules/astro/astro.js'];
|
|
82
|
+
const hasLocalAstro = existsSync('node_modules/astro/astro.js');
|
|
83
|
+
|
|
84
|
+
const run = (args) =>
|
|
85
|
+
hasLocalAstro
|
|
86
|
+
? step(process.execPath, [...astro, ...args])
|
|
87
|
+
: step('npx', ['--no-install', 'astro', ...args]);
|
|
88
|
+
|
|
89
|
+
console.log(`${DIM}building ${env} → ${SITE_URLS[env]}${RESET}\n`);
|
|
90
|
+
|
|
91
|
+
if (env === 'production') {
|
|
92
|
+
/* Refuse a production build of a template that has not been designed yet.
|
|
93
|
+
Before anything else, because it is the cheapest check and the most
|
|
94
|
+
embarrassing thing to deploy. */
|
|
95
|
+
step(process.execPath, ['scripts/tells.mjs', '--undecided-only']);
|
|
96
|
+
run(['check']);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
run(['build']);
|
|
100
|
+
|
|
101
|
+
if (env === 'staging') {
|
|
102
|
+
step(process.execPath, ['scripts/staging-headers.mjs']);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
step(process.execPath, ['scripts/check-env.mjs']);
|
|
106
|
+
|
|
107
|
+
if (env === 'production') {
|
|
108
|
+
step(process.execPath, ['scripts/check-sitemap.mjs']);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* A sanity line, so the log says which environment actually ran rather than
|
|
112
|
+
which one was asked for. They are the same now; they were not always. */
|
|
113
|
+
const pkg = existsSync('package.json') ? JSON.parse(readFileSync('package.json', 'utf8')) : {};
|
|
114
|
+
console.log(`\n${DIM}${pkg.name ?? 'site'} built as ${env}${RESET}`);
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
31
|
-
import { join, relative } from 'node:path';
|
|
31
|
+
import { join, relative, sep } from 'node:path';
|
|
32
32
|
|
|
33
33
|
const RESET = '[0m';
|
|
34
34
|
const RED = '[31m';
|
|
@@ -78,7 +78,13 @@ const ROBOTS_META = /<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*\bnoind
|
|
|
78
78
|
const noindexed = walk(root)
|
|
79
79
|
.filter((f) => f.endsWith('.html'))
|
|
80
80
|
.filter((f) => ROBOTS_META.test(readFileSync(f, 'utf8')))
|
|
81
|
-
|
|
81
|
+
/* ⚠ SEPARATORS NORMALISED — `relative()` RETURNS BACKSLASHES ON WINDOWS.
|
|
82
|
+
A URL path is always `/`. Without this the map produced `/about\\` for
|
|
83
|
+
`about\\index.html`, which matched nothing: check-sitemap's contradiction
|
|
84
|
+
check silently passed a site that listed a noindexed URL in its sitemap, and
|
|
85
|
+
Search Console reports that as an error against the whole submission. */
|
|
86
|
+
.map((f) => '/' + relative(root, f).split(sep).join('/')
|
|
87
|
+
.replace(/index\.html$/, '').replace(/\.html$/, '/'))
|
|
82
88
|
.map((p) => (p.endsWith('/') ? p : `${p}/`));
|
|
83
89
|
|
|
84
90
|
const contradictions = noindexed.filter((p) => listed.has(p));
|
|
@@ -31,12 +31,15 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { execFileSync } from 'node:child_process';
|
|
34
|
-
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
35
35
|
|
|
36
36
|
const OUT = 'src/data/lastmod.json';
|
|
37
37
|
|
|
38
|
-
const
|
|
39
|
-
execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
38
|
+
const gitRaw = (args) =>
|
|
39
|
+
execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
40
|
+
|
|
41
|
+
/** Trimmed — correct for a single scalar like `--format=%cI` or a rev-parse answer. */
|
|
42
|
+
const git = (args) => gitRaw(args).trim();
|
|
40
43
|
|
|
41
44
|
if (git(['rev-parse', '--is-shallow-repository']) === 'true') {
|
|
42
45
|
console.error(
|
|
@@ -47,8 +50,22 @@ if (git(['rev-parse', '--is-shallow-repository']) === 'true') {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/** Files with uncommitted changes. Their real "last modified" is now, not their last commit. */
|
|
53
|
+
/*
|
|
54
|
+
* ⚠ gitRaw, NOT git — `--porcelain` LINES BEGIN WITH A SIGNIFICANT SPACE.
|
|
55
|
+
*
|
|
56
|
+
* The status format is two columns then a space: ` M path` for an unstaged
|
|
57
|
+
* modification. Trimming the whole output eats the leading space of the FIRST
|
|
58
|
+
* line only, so `slice(3)` then cut one character too far and produced
|
|
59
|
+
* "rc/pages/about.astro". That path matched nothing, so the first uncommitted
|
|
60
|
+
* file was never treated as dirty and kept its old commit date — while the
|
|
61
|
+
* script still printed "1 uncommitted file(s) dated today".
|
|
62
|
+
*
|
|
63
|
+
* Only the first entry, and only when it starts with a space, which is the
|
|
64
|
+
* ordinary case of having edited a page without committing it: the page most
|
|
65
|
+
* worth recrawling is the one that silently keeps a stale date.
|
|
66
|
+
*/
|
|
50
67
|
const dirty = new Set(
|
|
51
|
-
|
|
68
|
+
gitRaw(['status', '--porcelain'])
|
|
52
69
|
.split('\n')
|
|
53
70
|
.filter(Boolean)
|
|
54
71
|
.map((l) => l.slice(3).trim()),
|
|
@@ -139,6 +156,9 @@ for (const [route, files] of [...sources].sort()) {
|
|
|
139
156
|
if (date) out[route] = date;
|
|
140
157
|
}
|
|
141
158
|
|
|
159
|
+
/* src/data/ exists in the template, but not in a bare checkout or a fixture —
|
|
160
|
+
and an ENOENT stack is not a diagnosis. */
|
|
161
|
+
mkdirSync(OUT.slice(0, OUT.lastIndexOf('/')), { recursive: true });
|
|
142
162
|
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n');
|
|
143
163
|
|
|
144
164
|
const spread = Object.values(out).reduce((m, d) => m.set(d, (m.get(d) ?? 0) + 1), new Map());
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
-
import { join, relative } from 'node:path';
|
|
14
|
+
import { join, relative, sep } from 'node:path';
|
|
15
15
|
|
|
16
16
|
/** Routes this build emitted, as absolute paths. 404 is excluded — it is not a route. */
|
|
17
17
|
export function routesFromDist() {
|
|
@@ -26,7 +26,13 @@ export function routesFromDist() {
|
|
|
26
26
|
|
|
27
27
|
return walk(root)
|
|
28
28
|
.filter((f) => f.endsWith('.html') && !f.endsWith('404.html'))
|
|
29
|
-
|
|
29
|
+
/* ⚠ SEPARATORS NORMALISED — `relative()` RETURNS BACKSLASHES ON WINDOWS.
|
|
30
|
+
A URL path is always `/`. Without this the map produced `/about\\` for
|
|
31
|
+
`about\\index.html`, which matched nothing: check-sitemap's contradiction
|
|
32
|
+
check silently passed a site that listed a noindexed URL in its sitemap, and
|
|
33
|
+
Search Console reports that as an error against the whole submission. */
|
|
34
|
+
.map((f) => '/' + relative(root, f).split(sep).join('/')
|
|
35
|
+
.replace(/index\.html$/, '').replace(/\.html$/, '/'))
|
|
30
36
|
.map((p) => (p.endsWith('/') ? p : `${p}/`))
|
|
31
37
|
.sort();
|
|
32
38
|
}
|
|
@@ -185,7 +185,18 @@ function worstContrast(image, fgHex, [x, y, w, h]) {
|
|
|
185
185
|
|
|
186
186
|
/* ── Drawing ───────────────────────────────────────────────────────────── */
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
/*
|
|
189
|
+
* ⚠ LAZY, BECAUSE A TOP-LEVEL READ RUNS BEFORE preflight().
|
|
190
|
+
*
|
|
191
|
+
* As a `const` at module scope this executed at import time — before main()
|
|
192
|
+
* called preflight() — so a project without an image manifest died on a raw
|
|
193
|
+
* ENOENT stack instead of being told its config was still the stub. The
|
|
194
|
+
* preflight exists precisely to name what is missing, and it was unreachable
|
|
195
|
+
* for the most common way to be missing something.
|
|
196
|
+
*/
|
|
197
|
+
let manifestCache = null;
|
|
198
|
+
const manifest = () =>
|
|
199
|
+
(manifestCache ??= JSON.parse(readFileSync('src/data/image-manifest.json', 'utf8')));
|
|
189
200
|
|
|
190
201
|
/**
|
|
191
202
|
* Render one text run as its own transparent layer.
|
|
@@ -225,7 +236,7 @@ function buildCard(card, fonts, mark) {
|
|
|
225
236
|
|
|
226
237
|
/* 1. Background. */
|
|
227
238
|
if (card.photo) {
|
|
228
|
-
const entry = manifest[card.photo];
|
|
239
|
+
const entry = manifest()[card.photo];
|
|
229
240
|
if (!entry) throw new Error(`${card.route ?? slug}: no manifest entry for "${card.photo}"`);
|
|
230
241
|
const file = join('public', entry.src);
|
|
231
242
|
if (!existsSync(file)) throw new Error(`${card.route ?? slug}: missing file ${file}`);
|
|
@@ -238,6 +238,81 @@ tell(
|
|
|
238
238
|
);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/*
|
|
242
|
+
* ── THE TELLS OF A GENERATED SITE, NOT A TEMPLATED ONE ─────────────────────
|
|
243
|
+
*
|
|
244
|
+
* Everything above catches the 2015 agency template: three equal cards, body
|
|
245
|
+
* text at container width, a headline at 96px. This block catches a newer and
|
|
246
|
+
* closer failure — the house style of the thing writing the code.
|
|
247
|
+
*
|
|
248
|
+
* These are counts with generous thresholds, deliberately. One frosted header
|
|
249
|
+
* is a decision; three glass surfaces is an aesthetic nobody chose. A gate that
|
|
250
|
+
* fires on a single legitimate use is one people learn to switch off, which is
|
|
251
|
+
* how the first version of `check:refs` shipped with seven false positives on a
|
|
252
|
+
* clean tree.
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
/*
|
|
256
|
+
* "glass everywhere" — backdrop-filter as a look rather than a decision.
|
|
257
|
+
*
|
|
258
|
+
* ⚠ THE CHARACTER CLASS EXCLUDES `}` AS WELL AS `;`, AND NONE OF THESE THREE
|
|
259
|
+
* REQUIRE A TRAILING SEMICOLON. The last declaration in a block may legally
|
|
260
|
+
* omit it. Written as `[^;]+;` these matched nothing there; written greedily
|
|
261
|
+
* as `[^;]*` one match ran across two whole declarations and counted them as
|
|
262
|
+
* one. Both were caught by fixtures asserting the check FIRES, never by the
|
|
263
|
+
* clean template, where all three read as passing.
|
|
264
|
+
*/
|
|
265
|
+
{
|
|
266
|
+
const glass = [...sourceCss.matchAll(/backdrop-filter:[^;}]*blur/g)].length;
|
|
267
|
+
tell(
|
|
268
|
+
'frosted glass on more than one surface',
|
|
269
|
+
glass > 1,
|
|
270
|
+
`${glass} backdrop-filter blurs. One translucent header is a choice; a page of them is the default look of generated UI, and each one costs a paint.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/*
|
|
275
|
+
* "giant border radii" — 24px and up.
|
|
276
|
+
*
|
|
277
|
+
* ⚠ A pill and a circle are NOT this. `9999px`, `50%` and `100%` are how you
|
|
278
|
+
* write "fully round" for a badge or an avatar, and flagging those would make
|
|
279
|
+
* the check useless on any correct design.
|
|
280
|
+
*/
|
|
281
|
+
{
|
|
282
|
+
const radii = [...sourceCss.matchAll(/border-radius:\s*([^;}]+)/g)]
|
|
283
|
+
.flatMap((m) => [...m[1].matchAll(/([\d.]+)(px|rem)/g)])
|
|
284
|
+
.map((m) => (m[2] === 'rem' ? Number(m[1]) * 16 : Number(m[1])))
|
|
285
|
+
.filter((px) => px >= 24 && px < 200);
|
|
286
|
+
tell(
|
|
287
|
+
'border radii of 24px and up, repeatedly',
|
|
288
|
+
radii.length > 2,
|
|
289
|
+
`${radii.length} radii at 24px or more (${[...new Set(radii)].slice(0, 4).join(', ')}px). Softness at that scale reads as a default rather than a decision. Pills and circles are excluded.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
* "glow" — a shadow with no offset and a real blur. `0 0 40px <colour>` is
|
|
295
|
+
* decoration; nothing in the physical world lights up from behind.
|
|
296
|
+
*
|
|
297
|
+
* ⚠ A focus ring is `0 0 0 3px` — zero blur. Requiring blur ≥ 16px is what
|
|
298
|
+
* keeps this from flagging the one shadow every accessible site needs.
|
|
299
|
+
*/
|
|
300
|
+
{
|
|
301
|
+
const glows = [...sourceCss.matchAll(/box-shadow:\s*([^;}]+)/g)]
|
|
302
|
+
.flatMap((m) => m[1].split(','))
|
|
303
|
+
.filter((sh) => /(^|\s)0\s+0\s+([\d.]+)(px|rem)/.test(sh))
|
|
304
|
+
.filter((sh) => {
|
|
305
|
+
const m = /(^|\s)0\s+0\s+([\d.]+)(px|rem)/.exec(sh);
|
|
306
|
+
const blur = m[3] === 'rem' ? Number(m[2]) * 16 : Number(m[2]);
|
|
307
|
+
return blur >= 16;
|
|
308
|
+
});
|
|
309
|
+
tell(
|
|
310
|
+
'glow shadows',
|
|
311
|
+
glows.length > 0,
|
|
312
|
+
`${glows.length} zero-offset shadow(s) with a large blur. A glow is decoration with no physical referent; a shadow with offset reads as light. Focus rings (0 0 0 3px) are excluded.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
241
316
|
// "the 404, the empty state or the form's invalid state was never designed"
|
|
242
317
|
tell(
|
|
243
318
|
'no invalid / busy form state',
|