create-website-build-kit 0.1.9 → 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.9",
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);