create-website-build-kit 0.1.4 → 0.1.6
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/docs/runbook.md +5 -0
- package/template/package.json +4 -3
- package/template/scripts/check-secrets.mjs +152 -0
- package/template/scripts/lastmod.mjs +24 -4
- package/template/scripts/og-cards.mjs +13 -2
- package/template/scripts/tells.mjs +75 -0
- package/template/scripts/verify.mjs +1 -1
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.6",
|
|
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/docs/runbook.md
CHANGED
|
@@ -505,7 +505,12 @@ In this order. Steps 1–3 happen days ahead, not on launch day.
|
|
|
505
505
|
```bash
|
|
506
506
|
npm run verify -- "https://$PROD"
|
|
507
507
|
npm run check:sitemap # after a production build: nothing listed AND noindex
|
|
508
|
+
npm run check:secrets # the production worker holds every declared secret
|
|
508
509
|
```
|
|
510
|
+
|
|
511
|
+
`check:secrets` already ran as part of `deploy:production`. Run it again here because
|
|
512
|
+
go-live is when it is most likely to fail: a secret set on the staging worker is not
|
|
513
|
+
automatically on this one, and the failure is silent — leads store, nothing emails.
|
|
509
514
|
9. **Submit the sitemap** in [Search Console](https://search.google.com/search-console) and
|
|
510
515
|
[Bing Webmaster Tools](https://www.bing.com/webmasters). If you kept the old filename, the
|
|
511
516
|
existing entry keeps working and there is nothing to resubmit.
|
package/template/package.json
CHANGED
|
@@ -25,15 +25,16 @@
|
|
|
25
25
|
"recon": "node scripts/recon.mjs",
|
|
26
26
|
"extract": "node scripts/extract.mjs",
|
|
27
27
|
"dns": "node scripts/dns-snapshot.mjs",
|
|
28
|
-
"seo": "npx --yes
|
|
28
|
+
"seo": "npx --yes @nurkamol/seo-audit@1",
|
|
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
32
|
"build:staging": "PUBLIC_SITE_ENV=staging PUBLIC_SITE_URL=https://new.example.com astro build && PUBLIC_SITE_ENV=staging node scripts/staging-headers.mjs && PUBLIC_SITE_ENV=staging node scripts/check-env.mjs",
|
|
33
33
|
"build:production": "node scripts/tells.mjs --undecided-only && PUBLIC_SITE_ENV=production astro check && PUBLIC_SITE_ENV=production PUBLIC_SITE_URL=https://example.com astro build && PUBLIC_SITE_ENV=production node scripts/check-env.mjs && node scripts/check-sitemap.mjs",
|
|
34
34
|
"preview": "wrangler dev",
|
|
35
|
-
"deploy:staging": "npm run build:staging && wrangler deploy",
|
|
36
|
-
"deploy:production": "npm run build:production && wrangler deploy"
|
|
35
|
+
"deploy:staging": "npm run build:staging && wrangler deploy && node scripts/check-secrets.mjs",
|
|
36
|
+
"deploy:production": "npm run build:production && wrangler deploy && node scripts/check-secrets.mjs",
|
|
37
|
+
"check:secrets": "node scripts/check-secrets.mjs"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"@astrojs/cloudflare": "^14.1.7",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse to call a deploy finished when the worker is missing a secret.
|
|
3
|
+
*
|
|
4
|
+
* npm run check:secrets
|
|
5
|
+
*
|
|
6
|
+
* Runs at the end of `deploy:staging` and `deploy:production`, after the deploy
|
|
7
|
+
* rather than before it — a worker that does not exist yet cannot be missing
|
|
8
|
+
* anything, and the first deploy is exactly when a secret has never been set.
|
|
9
|
+
*
|
|
10
|
+
* ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
|
|
11
|
+
* `secret()` in src/lib/runtime.ts returns `undefined` for a binding that was
|
|
12
|
+
* never set. Nothing throws. The contact form still validates, still stores the
|
|
13
|
+
* lead in KV, still returns 200, and still shows the visitor a thank-you.
|
|
14
|
+
*
|
|
15
|
+
* The API says `{"stored":true,"emailed":false}` and nobody reads API responses.
|
|
16
|
+
*
|
|
17
|
+
* So the site captures enquiries and notifies no one. There is no error, no
|
|
18
|
+
* failed request, no console warning, and nothing in the deploy log. It is found
|
|
19
|
+
* weeks later, by someone asking why the phone stopped ringing — and the leads
|
|
20
|
+
* are all still sitting in KV, which is the only reason this is recoverable.
|
|
21
|
+
*
|
|
22
|
+
* ⚠ THIS IS NOT HYPOTHETICAL. It shipped that way on the ochome build: deployed,
|
|
23
|
+
* verified green by `npm run verify`, storing leads, emailing nothing.
|
|
24
|
+
* `verify` lists it under "what this cannot see", which is honest and did not
|
|
25
|
+
* help — a note in a report nobody re-reads is not a gate.
|
|
26
|
+
*
|
|
27
|
+
* ── WHAT IT COMPARES ───────────────────────────────────────────────────────
|
|
28
|
+
* `.dev.vars.example` is the declared contract: every secret the code expects,
|
|
29
|
+
* committed, with placeholder values. `wrangler secret list` is what the
|
|
30
|
+
* deployed worker actually holds. The two disagreeing is the bug.
|
|
31
|
+
*
|
|
32
|
+
* Using the example file rather than a list hardcoded here means adding a
|
|
33
|
+
* secret to the code and to the example — which you must do anyway, or local
|
|
34
|
+
* `wrangler dev` breaks — extends this check for free. A hardcoded list would
|
|
35
|
+
* go stale silently, the way `check-env.mjs` did.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync } from 'node:fs';
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
|
|
41
|
+
const RESET = '\x1b[0m';
|
|
42
|
+
const RED = '\x1b[31m';
|
|
43
|
+
const GREEN = '\x1b[32m';
|
|
44
|
+
const DIM = '\x1b[2m';
|
|
45
|
+
const YELLOW = '\x1b[33m';
|
|
46
|
+
|
|
47
|
+
/* `npx` is `npx.cmd` on Windows and execFileSync cannot resolve it without a
|
|
48
|
+
shell. Windows is a supported target and this is the second script to need
|
|
49
|
+
the line. */
|
|
50
|
+
const WIN = process.platform === 'win32';
|
|
51
|
+
|
|
52
|
+
/** Every NAME= in .dev.vars.example, which is the list the code expects. */
|
|
53
|
+
function declaredSecrets() {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync('.dev.vars.example', 'utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
console.error(
|
|
59
|
+
`\n${RED}✗ .dev.vars.example not found${RESET}\n\n` +
|
|
60
|
+
' It is the declared list of secrets this site needs, and this check\n' +
|
|
61
|
+
' has nothing to compare against without it. Run from the project root.\n',
|
|
62
|
+
);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
return [...raw.matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*=/gm)].map((m) => m[1]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Secrets on the deployed worker.
|
|
70
|
+
*
|
|
71
|
+
* Returns null — not an empty list — when the worker does not exist yet, so a
|
|
72
|
+
* pre-deploy state is never reported as "every secret is missing".
|
|
73
|
+
*/
|
|
74
|
+
function deployedSecrets() {
|
|
75
|
+
let out;
|
|
76
|
+
try {
|
|
77
|
+
out = execFileSync(WIN ? 'npx.cmd' : 'npx', ['wrangler', 'secret', 'list'], {
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
shell: WIN,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const text = `${err.stdout ?? ''}${err.stderr ?? ''}`;
|
|
84
|
+
/* A worker that has never been deployed, versus a real problem — being
|
|
85
|
+
logged out, or offline. Only the first is not a failure. */
|
|
86
|
+
if (/script_not_found|workers\.api\.error\.script_not_found|10007|not found/i.test(text)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
console.error(
|
|
90
|
+
`\n${RED}✗ could not read the worker's secrets${RESET}\n\n` +
|
|
91
|
+
`${text.trim().split('\n').slice(-6).map((l) => ` ${l}`).join('\n')}\n\n` +
|
|
92
|
+
' Usually `wrangler login`. This check cannot pass without an answer —\n' +
|
|
93
|
+
' it refuses rather than assume the secrets are fine.\n',
|
|
94
|
+
);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
/* wrangler prints a banner before the JSON. Take the array, not the noise. */
|
|
98
|
+
const match = out.match(/\[[\s\S]*\]/);
|
|
99
|
+
if (!match) return [];
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(match[0]).map((s) => s.name);
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const declared = declaredSecrets();
|
|
108
|
+
if (!declared.length) {
|
|
109
|
+
console.log(`${DIM}· .dev.vars.example declares no secrets — nothing to check${RESET}`);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const deployed = deployedSecrets();
|
|
114
|
+
|
|
115
|
+
if (deployed === null) {
|
|
116
|
+
console.log(
|
|
117
|
+
`${DIM}· worker not deployed yet — nothing to check${RESET}\n` +
|
|
118
|
+
`${DIM} ${declared.length} secret(s) will be required once it is: ${declared.join(', ')}${RESET}`,
|
|
119
|
+
);
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const missing = declared.filter((name) => !deployed.includes(name));
|
|
124
|
+
const extra = deployed.filter((name) => !declared.includes(name));
|
|
125
|
+
|
|
126
|
+
if (missing.length) {
|
|
127
|
+
console.error(`\n${RED}✗ the deployed worker is missing ${missing.length} secret(s)${RESET}\n`);
|
|
128
|
+
for (const name of missing) {
|
|
129
|
+
console.error(` ${name}`);
|
|
130
|
+
}
|
|
131
|
+
console.error(
|
|
132
|
+
`\n The deploy succeeded. The site is live and INCOMPLETE — whatever reads\n` +
|
|
133
|
+
` these gets \`undefined\` and carries on silently. If BREVO_API_KEY is in\n` +
|
|
134
|
+
` the list, the form is storing leads and emailing nobody.\n\n` +
|
|
135
|
+
` Set them, then deploy again:\n\n` +
|
|
136
|
+
missing.map((n) => ` npx wrangler secret put ${n}`).join('\n') +
|
|
137
|
+
'\n',
|
|
138
|
+
);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (extra.length) {
|
|
143
|
+
/* Not a failure — a secret the code no longer reads is dead weight, not a
|
|
144
|
+
broken site. Worth saying once, because it is usually a rename that only
|
|
145
|
+
got done on one side. */
|
|
146
|
+
console.log(
|
|
147
|
+
`${YELLOW}!${RESET} on the worker but not in .dev.vars.example: ${extra.join(', ')}\n` +
|
|
148
|
+
`${DIM} either the code stopped reading it, or the example was never updated${RESET}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log(`${GREEN}✓${RESET} every declared secret is set: ${declared.join(', ')}`);
|
|
@@ -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());
|
|
@@ -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',
|
|
@@ -1051,7 +1051,7 @@ const warned = results.filter((r) => !r.ok && r.warn);
|
|
|
1051
1051
|
console.log(`\n${BOLD}── What this cannot see ${'─'.repeat(36)}${RESET}`);
|
|
1052
1052
|
for (const line of [
|
|
1053
1053
|
'One pageview per visit — a double-count is only visible in Realtime',
|
|
1054
|
-
'That a valid submission stores AND emails — send one by hand, once',
|
|
1054
|
+
'That a valid submission stores AND emails — send one by hand, once (`npm run check:secrets` covers whether the key is even set)',
|
|
1055
1055
|
'Whether the analytics container is the client\'s own — fetch it and read it',
|
|
1056
1056
|
'Whether a redirect target is the RIGHT page, only that it resolves',
|
|
1057
1057
|
'How fast it FEELS — weight and blocking counts are the inputs, never a timing. ' +
|