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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-website-build-kit",
3
- "version": "0.1.8",
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",
@@ -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
@@ -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', 'section--tight', className]}>
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;
@@ -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
  //