create-website-build-kit 0.1.8 → 0.1.10
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/CLAUDE.md +6 -0
- package/template/package.json +2 -1
- package/template/scripts/build.mjs +5 -0
- package/template/scripts/check-form.mjs +126 -0
- package/template/src/components/Header.astro +16 -1
- package/template/src/components/PageHero.astro +26 -1
- package/template/src/styles/global.css +10 -0
- package/template/src/styles/tokens.css +5 -0
- package/template/wrangler.jsonc +15 -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.10",
|
|
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/CLAUDE.md
CHANGED
|
@@ -94,6 +94,12 @@ The rules whose failure looks like success — double-counted pageviews, a conve
|
|
|
94
94
|
two pipes, a trigger on `sent=1` that catches almost nothing — are in `docs/analytics.md`.
|
|
95
95
|
Read it before adding any tag.
|
|
96
96
|
|
|
97
|
+
⚠ **THE HONEYPOT IS CALLED `company`.** `api/contact.ts` discards any submission that fills it in,
|
|
98
|
+
silently and with a 200, so a bot learns nothing. Add a real "Company" field to that form — an
|
|
99
|
+
ordinary client request — and every enquiry from a company that types its name is thrown away, with
|
|
100
|
+
a thank-you page and nothing stored. **Name the real field `companyName`** and leave the trap alone;
|
|
101
|
+
`npm run check:form` fails the build if two controls share a name.
|
|
102
|
+
|
|
97
103
|
**Notes to yourself never ship.** `check:copy` reads the text a browser would show — not the
|
|
98
104
|
source, not comments, not `<script>` — and looks for the markers people actually leave: `TODO`,
|
|
99
105
|
`FIXME`, `⚠ CONFIRM:`, `Lorem ipsum`, an unrendered `{{ placeholder }}`. It warns on staging and
|
package/template/package.json
CHANGED
|
@@ -35,7 +35,8 @@
|
|
|
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",
|
|
37
37
|
"check:secrets": "node scripts/check-secrets.mjs",
|
|
38
|
-
"check:copy": "node scripts/check-copy.mjs"
|
|
38
|
+
"check:copy": "node scripts/check-copy.mjs",
|
|
39
|
+
"check:form": "node scripts/check-form.mjs"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"@astrojs/cloudflare": "^14.1.7",
|
|
@@ -96,6 +96,11 @@ if (env === 'production') {
|
|
|
96
96
|
run(['check']);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/* Before the build, not after: a duplicate field name is a source bug, and
|
|
100
|
+
there is no reason to spend a build discovering it. Fails in BOTH
|
|
101
|
+
environments — nobody ever meant two controls to share a name. */
|
|
102
|
+
step(process.execPath, ['scripts/check-form.mjs']);
|
|
103
|
+
|
|
99
104
|
run(['build']);
|
|
100
105
|
|
|
101
106
|
if (env === 'staging') {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse a build where two form controls share a `name`.
|
|
3
|
+
*
|
|
4
|
+
* npm run check:form
|
|
5
|
+
*
|
|
6
|
+
* Runs inside `build:staging` and `build:production`. This is a correctness
|
|
7
|
+
* bug, not a note, so it fails in both — a duplicate `name` is never something
|
|
8
|
+
* anyone meant.
|
|
9
|
+
*
|
|
10
|
+
* ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
|
|
11
|
+
* ⚠ THE HONEYPOT IS CALLED `company`, AND THAT IS THE FIELD A B2B SITE ADDS.
|
|
12
|
+
*
|
|
13
|
+
* `ContactForm.astro` hides a trap field named `company`, and `api/contact.ts`
|
|
14
|
+
* discards any submission that fills it in — **silently and with a 200**, so a
|
|
15
|
+
* bot learns nothing:
|
|
16
|
+
*
|
|
17
|
+
* if (input.company) return seeOther(FORM_PAGE) // or { ok: true }
|
|
18
|
+
*
|
|
19
|
+
* Add a real "Company" field to that form, as any business site eventually
|
|
20
|
+
* does, and every enquiry from a company that types its name is thrown away.
|
|
21
|
+
* The form returns 200, the thank-you page renders, nothing is stored, nothing
|
|
22
|
+
* is logged. It is `check-secrets` all over again — leads vanishing while the
|
|
23
|
+
* site looks like it is working — except this one arrives as an ordinary client
|
|
24
|
+
* request rather than a mistake.
|
|
25
|
+
*
|
|
26
|
+
* It has already happened on a shipped build. The fix there was to name the
|
|
27
|
+
* real field `companyName`, which is right and is what this check tells you.
|
|
28
|
+
*
|
|
29
|
+
* ── WHY THE SOURCE AND NOT dist/ ───────────────────────────────────────────
|
|
30
|
+
* The contact route is `prerender = false`, so the form is not in the build
|
|
31
|
+
* output to inspect. Reading the component catches it before a deploy rather
|
|
32
|
+
* than after, which for a lead-loss bug is the difference that matters.
|
|
33
|
+
*
|
|
34
|
+
* ── WHY NOT JUST RENAME THE HONEYPOT ───────────────────────────────────────
|
|
35
|
+
* It has to look plausible to a bot, and every plausible name — `company`,
|
|
36
|
+
* `website`, `fax`, `url` — is a field some real form wants. Moving the trap
|
|
37
|
+
* moves the landmine. A check is the answer that does not depend on guessing
|
|
38
|
+
* which name nobody will need.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
42
|
+
import { join, relative, sep } from 'node:path';
|
|
43
|
+
|
|
44
|
+
const RESET = '\x1b[0m';
|
|
45
|
+
const RED = '\x1b[31m';
|
|
46
|
+
const GREEN = '\x1b[32m';
|
|
47
|
+
const DIM = '\x1b[2m';
|
|
48
|
+
|
|
49
|
+
const SRC = 'src';
|
|
50
|
+
if (!existsSync(SRC)) {
|
|
51
|
+
console.error('check-form: no src/ — run from the project root.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const walk = (dir) =>
|
|
56
|
+
readdirSync(dir).flatMap((e) => {
|
|
57
|
+
const full = join(dir, e);
|
|
58
|
+
return statSync(full).isDirectory() ? walk(full) : [full];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* A control is an input, select or textarea. `name` on anything else — a
|
|
63
|
+
* <meta>, an <a>, a slot — is not a form field and must not be counted.
|
|
64
|
+
*
|
|
65
|
+
* Only QUOTED values are captured, which is also how an expression-built name
|
|
66
|
+
* is skipped: Astro writes those unquoted — name={`f-${i}`} — so they never
|
|
67
|
+
* match, and there is nothing to compare anyway.
|
|
68
|
+
*
|
|
69
|
+
* ⚠ There was a guard here testing the captured name for `{` and `$`. It could
|
|
70
|
+
* never fire, because a captured name is quoted by definition — and it would
|
|
71
|
+
* have wrongly skipped a real literal like name="f-{i}". Found by mutation:
|
|
72
|
+
* deleting it changed no test, which is what an unreachable line looks like.
|
|
73
|
+
*/
|
|
74
|
+
const CONTROL = /<(input|select|textarea)\b[^>]*?\bname=["']([^"']+)["'][^>]*>/gis;
|
|
75
|
+
|
|
76
|
+
/** The trap is marked by its wrapper class, so it can be named in the message. */
|
|
77
|
+
const isHoneypot = (html, index) => html.lastIndexOf('form__trap', index) > html.lastIndexOf('</div>', index);
|
|
78
|
+
|
|
79
|
+
const problems = [];
|
|
80
|
+
|
|
81
|
+
for (const file of walk(SRC).filter((f) => f.endsWith('.astro'))) {
|
|
82
|
+
const html = readFileSync(file, 'utf8');
|
|
83
|
+
if (!/<form\b/i.test(html) && !/form__trap/.test(html)) continue;
|
|
84
|
+
|
|
85
|
+
const seen = new Map();
|
|
86
|
+
for (const m of html.matchAll(CONTROL)) {
|
|
87
|
+
const name = m[2];
|
|
88
|
+
const at = seen.get(name);
|
|
89
|
+
if (at === undefined) {
|
|
90
|
+
seen.set(name, m.index);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
problems.push({
|
|
94
|
+
file: relative(process.cwd(), file).split(sep).join('/'),
|
|
95
|
+
name,
|
|
96
|
+
honeypot: isHoneypot(html, at) || isHoneypot(html, m.index),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!problems.length) {
|
|
102
|
+
console.log(`${GREEN}✓${RESET} no duplicate form field names`);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.error(`\n${RED}✗ ${problems.length} duplicate form field name(s)${RESET}\n`);
|
|
107
|
+
for (const p of problems) {
|
|
108
|
+
console.error(` ${p.file} → name="${p.name}"`);
|
|
109
|
+
if (p.honeypot) {
|
|
110
|
+
console.error(
|
|
111
|
+
` ${DIM}This is the HONEYPOT name. api/contact.ts discards any submission\n` +
|
|
112
|
+
` that fills it in, silently and with a 200 — so every enquiry from a\n` +
|
|
113
|
+
` company that types its name would be thrown away, with a thank-you\n` +
|
|
114
|
+
` page and nothing stored.\n\n` +
|
|
115
|
+
` Rename the REAL field — companyName, organisation — and leave the\n` +
|
|
116
|
+
` trap alone.${RESET}`,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
console.error(
|
|
120
|
+
` ${DIM}Two controls posting the same key: the second overwrites the first\n` +
|
|
121
|
+
` in formData, so one of them is silently discarded.${RESET}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.error('');
|
|
126
|
+
process.exit(1);
|
|
@@ -29,7 +29,22 @@ const isCurrent = (href: string) => (href === '/' ? path === '/' : path.startsWi
|
|
|
29
29
|
{/* Text until there is a logo. A brand mark referenced before the file
|
|
30
30
|
exists renders as a broken image on every page, which is worse than
|
|
31
31
|
plain type — swap this for <img> once the artwork is in place, and
|
|
32
|
-
look at it on both a light and a dark background before shipping.
|
|
32
|
+
look at it on both a light and a dark background before shipping.
|
|
33
|
+
|
|
34
|
+
⚠ SIZE IT BY HEIGHT, NOT WIDTH, AND KEEP IT UNDER --header-h.
|
|
35
|
+
`inline-size: 12rem; block-size: auto` on a wide mark computes to
|
|
36
|
+
whatever its aspect ratio says — on a real build a 520×227 logo came
|
|
37
|
+
out 87px tall in an 88px bar, so the LOGO was setting the header's
|
|
38
|
+
height instead of the token.
|
|
39
|
+
|
|
40
|
+
That is not cosmetic. FOUR offsets are computed from --header-h:
|
|
41
|
+
this header's min-block-size, `.under-header`'s reserve, and
|
|
42
|
+
scroll-padding-top / scroll-margin-top, which are what stop an
|
|
43
|
+
anchor target landing underneath the fixed nav. A header taller than
|
|
44
|
+
its token leaves all four short by the same amount, and none of them
|
|
45
|
+
errors.
|
|
46
|
+
|
|
47
|
+
`block-size: clamp(2.25rem, 3.4vw, 2.75rem); inline-size: auto` */}
|
|
33
48
|
{business.name}
|
|
34
49
|
</a>
|
|
35
50
|
|
|
@@ -29,7 +29,7 @@ const { title, lede, breadcrumbs, class: className = '' } = Astro.props;
|
|
|
29
29
|
const crumbs = breadcrumbs ?? [];
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
-
<section class:list={['hero', 'under-header',
|
|
32
|
+
<section class:list={['hero', 'under-header', className]}>
|
|
33
33
|
<div class="container">
|
|
34
34
|
{
|
|
35
35
|
crumbs.length > 0 && (
|
|
@@ -59,6 +59,31 @@ const crumbs = breadcrumbs ?? [];
|
|
|
59
59
|
</section>
|
|
60
60
|
|
|
61
61
|
<style>
|
|
62
|
+
/*
|
|
63
|
+
* ⚠ THE RHYTHM BELONGS TO THE NEXT SECTION, NOT TO THIS ONE.
|
|
64
|
+
*
|
|
65
|
+
* This carried `.section--tight`, which is a SHORTHAND — it sets padding-block
|
|
66
|
+
* at both ends. Every page opens its following section with a rhythm class of
|
|
67
|
+
* its own, so two stacked: a measured 160px hole on the template's own
|
|
68
|
+
* /contact/, 176px on all four PageHero pages of a real build, and up to 232px
|
|
69
|
+
* where the next section is `.section` rather than `.section--tight`.
|
|
70
|
+
*
|
|
71
|
+
* Nothing could see it. Build green, types green, axe green, tells green — a
|
|
72
|
+
* hole is only visible by looking at the page.
|
|
73
|
+
*
|
|
74
|
+
* ⚠ AND THE SHORTHAND CLOBBERED THE HEADER OFFSET. `.under-header` reserves
|
|
75
|
+
* the fixed header's height on padding-block-START, and global.css warns in
|
|
76
|
+
* as many words that "a scoped component style would out-specify" it. A
|
|
77
|
+
* shorthand from this component is exactly such a style: on a real build it
|
|
78
|
+
* discarded the reserve and put the hero behind the nav.
|
|
79
|
+
*
|
|
80
|
+
* padding-block-END only, therefore. The offset stays with `.under-header`,
|
|
81
|
+
* the rhythm stays with whatever section comes next.
|
|
82
|
+
*/
|
|
83
|
+
.hero {
|
|
84
|
+
padding-block-end: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
.hero__crumbs ol {
|
|
63
88
|
display: flex;
|
|
64
89
|
flex-wrap: wrap;
|
|
@@ -241,6 +241,16 @@ b {
|
|
|
241
241
|
* height or the heading sits behind the nav. Applied by each opening section
|
|
242
242
|
* rather than a blanket `main > :first-child` rule, which a scoped component
|
|
243
243
|
* style would out-specify.
|
|
244
|
+
*
|
|
245
|
+
* ⚠ AND ONE DID. A component wrote `padding-block: <a> <b>` on the same element
|
|
246
|
+
* — a SHORTHAND, so it set padding-block-start too, out-specified this, and
|
|
247
|
+
* the hero sat behind the nav on a real build. On the element that carries
|
|
248
|
+
* `.under-header`, set `padding-block-end` and never the shorthand.
|
|
249
|
+
*
|
|
250
|
+
* ⚠ NOT MOVED TO `main`, WHICH WOULD BE UN-OVERRIDABLE. The header's background
|
|
251
|
+
* is opaque `var(--bg)`, so padding on `main` would leave a strip of body
|
|
252
|
+
* colour behind it wherever a first section has a background of its own. The
|
|
253
|
+
* per-section reserve is deliberate; the shorthand is the bug.
|
|
244
254
|
*/
|
|
245
255
|
.under-header {
|
|
246
256
|
padding-block-start: calc(var(--header-h) + var(--space-2xl));
|
|
@@ -197,6 +197,11 @@
|
|
|
197
197
|
appeared should spring. */
|
|
198
198
|
|
|
199
199
|
/* ── Chrome ────────────────────────────────────────────────────────────── */
|
|
200
|
+
/* ⚠ FOUR THINGS READ THIS: the header's min-block-size, `.under-header`'s
|
|
201
|
+
reserve, and scroll-padding-top / scroll-margin-top, which keep an anchor
|
|
202
|
+
target clear of the fixed nav. If the rendered header is taller than this
|
|
203
|
+
— a logo sized by width is the usual cause — all four are short by the same
|
|
204
|
+
amount and nothing reports it. Measure the bar, do not assume it. */
|
|
200
205
|
--header-h: 4.5rem;
|
|
201
206
|
--z-header: 100;
|
|
202
207
|
--z-menu: 110;
|
package/template/wrangler.jsonc
CHANGED
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
// the Astro adapter resolves its environment at build time and silently
|
|
8
8
|
// ignores `deploy --env`, which is how a staging deploy lands on production.
|
|
9
9
|
//
|
|
10
|
+
// ⚠ AND IT IGNORES `--config` THE SAME WAY, which is the next flag anyone
|
|
11
|
+
// reaches for. @astrojs/cloudflare generates dist/server/wrangler.json —
|
|
12
|
+
// the config actually deployed, the one carrying `main` and the real asset
|
|
13
|
+
// directory — and builds it from the DEFAULT config path only. So
|
|
14
|
+
// `wrangler deploy --config wrangler.production.jsonc` either fails with
|
|
15
|
+
// "Cannot use assets with a binding in an assets-only Worker" (the
|
|
16
|
+
// hand-written file has no `main`), or, if you work around that by
|
|
17
|
+
// deploying the generated file instead, ships with whatever name and routes
|
|
18
|
+
// THIS file held, whatever was built.
|
|
19
|
+
//
|
|
20
|
+
// One config is the reason that cannot happen here. A project that grows a
|
|
21
|
+
// second one has to rewrite dist/server/wrangler.json between build and
|
|
22
|
+
// deploy, because there is no adapter option for it. Found on a shipped
|
|
23
|
+
// build that needed two.
|
|
24
|
+
//
|
|
10
25
|
// npm run deploy:staging → builds staging, deploys this worker
|
|
11
26
|
// npm run deploy:production → builds production, deploys this worker
|
|
12
27
|
//
|