@jtakeit/astro 0.1.0

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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/jtk.mjs +41 -0
  4. package/docs/booking.md +164 -0
  5. package/docs/catalogue.md +459 -0
  6. package/docs/collections.md +249 -0
  7. package/docs/css.md +86 -0
  8. package/docs/gallery.md +127 -0
  9. package/docs/hero-motion.md +189 -0
  10. package/docs/kit.md +454 -0
  11. package/docs/languages.md +182 -0
  12. package/docs/lead-form.md +109 -0
  13. package/docs/pages.md +193 -0
  14. package/docs/photos.md +314 -0
  15. package/docs/scaffold.md +75 -0
  16. package/docs/shapes.md +140 -0
  17. package/docs/surface.md +187 -0
  18. package/lib/catalogue.mjs +1678 -0
  19. package/lib/codes.mjs +171 -0
  20. package/lib/create.mjs +282 -0
  21. package/package.json +16 -0
  22. package/template/astro.config.mjs +84 -0
  23. package/template/figures.mjs +122 -0
  24. package/template/gitignore +16 -0
  25. package/template/jtakeit-meta.mjs +112 -0
  26. package/template/jtk/content/index.json +38 -0
  27. package/template/jtk/design.json +24 -0
  28. package/template/markdown.mjs +36 -0
  29. package/template/package-lock.json +5320 -0
  30. package/template/package.json +26 -0
  31. package/template/specimens.mjs +46 -0
  32. package/template/src/components/Blocks.astro +151 -0
  33. package/template/src/components/BookingForm.astro +506 -0
  34. package/template/src/components/Clip.astro +155 -0
  35. package/template/src/components/Hero.astro +66 -0
  36. package/template/src/components/LeadForm.astro +347 -0
  37. package/template/src/components/OpeningHours.astro +69 -0
  38. package/template/src/components/Pile.astro +185 -0
  39. package/template/src/components/Shot.astro +472 -0
  40. package/template/src/components/gallery/Gallery.astro +381 -0
  41. package/template/src/components/gallery/galleries.ts +139 -0
  42. package/template/src/components/motion/HeroField.astro +520 -0
  43. package/template/src/components/motion/fields.ts +430 -0
  44. package/template/src/components/surface/Pattern.astro +278 -0
  45. package/template/src/components/surface/patterns.ts +187 -0
  46. package/template/src/content/blocks.ts +758 -0
  47. package/template/src/content.config.ts +19 -0
  48. package/template/src/copy/LOCALE.ts +324 -0
  49. package/template/src/data/site.ts +137 -0
  50. package/template/src/layouts/Layout.astro +282 -0
  51. package/template/src/lib/alive.ts +49 -0
  52. package/template/src/lib/entries.ts +106 -0
  53. package/template/src/lib/entryLoader.ts +315 -0
  54. package/template/src/lib/noise.ts +26 -0
  55. package/template/src/lib/page.ts +287 -0
  56. package/template/src/lib/photos.ts +168 -0
  57. package/template/src/lib/under.ts +32 -0
  58. package/template/src/lib/uploads.ts +85 -0
  59. package/template/src/pages/[...entry].astro +207 -0
  60. package/template/src/pages/[...feed].xml.ts +64 -0
  61. package/template/src/pages/index.astro +90 -0
  62. package/template/src/pages/llms.txt.ts +50 -0
  63. package/template/src/pages/privacy.astro +59 -0
  64. package/template/src/pages/robots.txt.ts +21 -0
  65. package/template/src/pages/sitemap.xml.ts +50 -0
  66. package/template/src/styles/global.css +411 -0
  67. package/template/src/styles/surface.css +375 -0
  68. package/template/tsconfig.json +5 -0
@@ -0,0 +1,1678 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * jtk catalogue — write `jtk/catalogue.json`, and prove it matches the page.
4
+ *
5
+ * The admin edits a site by tapping the text on it, and it can do that for a
6
+ * site built any way at all because it reads a catalogue rather than knowing
7
+ * anything about the components. Which means the catalogue is the one file
8
+ * standing between "a site that looks like nothing else" and "a site the owner
9
+ * can edit" — and a catalogue kept by hand is a catalogue that drifts.
10
+ *
11
+ * So it is derived. `src/content/blocks.ts` is the declaration, in TypeScript,
12
+ * beside the components; this emits it and then checks it against what the site
13
+ * actually rendered:
14
+ *
15
+ * every declared field → appears in the HTML carrying its data-jtk-path
16
+ * every annotated path → is declared here
17
+ *
18
+ * Both directions matter and they fail differently. A field declared and never
19
+ * rendered is a control in the admin that edits nothing. A field rendered and
20
+ * never declared is text the owner can see and cannot touch — and it is the
21
+ * silent one, which is why `annotation-lint.mjs` exists at the other end of the
22
+ * pipeline to catch it at build time too.
23
+ *
24
+ * jtk catalogue # emit, then check against dist/
25
+ * jtk catalogue --emit-only # emit, do not build or check
26
+ * jtk catalogue --dist ./dist # check against a build made elsewhere
27
+ * jtk catalogue --judge # and ask the real validator whether we agree
28
+ *
29
+ * Exit code is the number of disagreements. Zero means the admin can be handed
30
+ * this repository.
31
+ *
32
+ * ── the checks below are a copy, and --judge is how it stays a good one ──────
33
+ *
34
+ * The authoritative catalogue validator is Go, in the admin's `registry`
35
+ * package, reachable at `POST /v1/registry/validate`. The checks in this file
36
+ * are a second implementation of the same contract, kept because this one has to
37
+ * work offline, before a deploy and before there is a site to attach to — the
38
+ * answer "that is not a usable catalogue" is worth having at the moment somebody
39
+ * writes it, with a line number, rather than in the admin days later with none.
40
+ *
41
+ * Two hand-written validators of one contract disagree silently, so this one
42
+ * sits the other's exam (wiki/35 §5): every finding carries the same `JTK_E_*`
43
+ * code the server would give it, and `--judge` posts the emitted catalogue to
44
+ * the real validator and reports every place the two answers differ. Every
45
+ * client project the studio runs through it is one sitting.
46
+ */
47
+
48
+ import { execFileSync } from 'node:child_process';
49
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
50
+ import { dirname, join, relative, resolve, sep } from 'node:path';
51
+ import { pathToFileURL } from 'node:url';
52
+
53
+ import { E, isAdvice, isPlatformCode, LOCAL } from './codes.mjs';
54
+
55
+ // What this tool calls itself in a catalogue it writes. Bumped when the shape
56
+ // it emits changes; the `contract` beside it moves only when the format does.
57
+ const TOOL_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
58
+
59
+ const argv = process.argv.slice(2);
60
+ const has = (name) => argv.includes(`--${name}`);
61
+ const flag = (name, fallback) => {
62
+ const at = argv.indexOf(`--${name}`);
63
+ return at === -1 ? fallback : argv[at + 1];
64
+ };
65
+
66
+ const root = resolve(flag('root', process.cwd()));
67
+ const declaration = join(root, 'src', 'catalogue-declaration');
68
+
69
+ function die(message) {
70
+ console.error(`jtk catalogue: ${message}`);
71
+ process.exit(1);
72
+ }
73
+
74
+ // --- the declaration --------------------------------------------------------
75
+ //
76
+ // Imported rather than parsed. Node runs TypeScript by stripping the types,
77
+ // which is why the declaration may hold interfaces and `satisfies` and still be
78
+ // readable here with no build step and no dependency — and why it is a module
79
+ // rather than a JSON file a person edits with no type checking at all.
80
+
81
+ const source = join(root, 'src', 'content', 'blocks.ts');
82
+ if (!existsSync(source)) {
83
+ die(`no src/content/blocks.ts — that file is what says which fields the admin may edit.
84
+ A site without one cannot be attached to the admin; copy the scaffold's and cut it down.`);
85
+ }
86
+
87
+ let declared;
88
+ try {
89
+ declared = await import(pathToFileURL(source).href);
90
+ } catch (why) {
91
+ die(`could not read src/content/blocks.ts: ${why.message}
92
+ Node runs TypeScript by stripping types, so the file may hold no enums and no namespaces.`);
93
+ }
94
+
95
+ const catalogue = {
96
+ /*
97
+ * The version of the catalogue FORMAT, and the tool that wrote this file.
98
+ *
99
+ * A whole number because the question the service asks is binary: can this
100
+ * parser read this file. Additive changes are compatible by the rule in
101
+ * wiki/05, so a minor could never change what the validator does — it would
102
+ * be documentation living in a load-bearing field. Semver belongs to this
103
+ * toolkit instead, and its major names the contract it speaks.
104
+ *
105
+ * The generator is provenance and nothing branches on it: the first question
106
+ * about a build that failed on somebody else's repository is which version of
107
+ * the tooling produced the catalogue, and a lock file answers that about
108
+ * dependencies for exactly the same reason.
109
+ */
110
+ contract: 2,
111
+ generator: `@jtakeit/astro@${TOOL_VERSION}`,
112
+ blocks: declared.BLOCKS ?? [],
113
+ page_seo: declared.PAGE_SEO ?? [],
114
+ business_facts: declared.BUSINESS_FACTS ?? [],
115
+ };
116
+
117
+ // Only when there are any, so that a site with no collections emits the file it
118
+ // has always emitted — every catalogue already in a repository stays byte for
119
+ // byte what it was.
120
+ const collections = declared.COLLECTIONS ?? [];
121
+ if (collections.length > 0) catalogue.collections = collections;
122
+
123
+ // The languages this site has besides its own, and the same rule about not
124
+ // emitting what is not there: a site in one language produces the file it
125
+ // always produced.
126
+ /**
127
+ * A language tag, as BCP 47 writes one: a lowercase language, and where there
128
+ * is a region, an uppercase region — `de`, `de-CH`, `en-GB`.
129
+ *
130
+ * One constant, because there used to be two in this file and they disagreed:
131
+ * this one, and a second at the LOCALE check that spelt the region `[a-z]{2}`.
132
+ * A site scaffolded as `de-CH` therefore had its labels accepted and its own
133
+ * language refused, with the message "not a language — two letters, or two and
134
+ * a region" printed against a tag that is exactly that. Every regional locale
135
+ * fl-init can produce hit it.
136
+ *
137
+ * The region is *read* in either case and *written* in one. There are sites in
138
+ * repositories spelt `de-ch` — the platform asked for that once — and a checker
139
+ * that refuses them is a checker somebody edits the site to get past, which is
140
+ * how a language ends up spelt to suit a script. So this accepts both, and
141
+ * `canonicalLocale` decides which one goes in the catalogue.
142
+ */
143
+ const localeKeyRe = /^[a-z]{2}(-[A-Za-z]{2})?$/;
144
+
145
+ /** The one spelling, given either. Returns `null` for what is not a language. */
146
+ function canonicalLocale(tag) {
147
+ if (!localeKeyRe.test(tag)) return null;
148
+ const [language, region] = tag.split('-');
149
+ return region === undefined ? language : `${language}-${region.toUpperCase()}`;
150
+ }
151
+
152
+ /*
153
+ * A site declares every language it has, in one place.
154
+ *
155
+ * `LOCALE` is the one it is written in and `LOCALES` are the rest. The first
156
+ * used to live only in the admin's record of the site — set when the site was
157
+ * created, defaulting to the studio's own language, changeable by nobody — so a
158
+ * site written in English carried a record saying Ukrainian, and every screen
159
+ * that named a language named the wrong one. The import takes it from here now.
160
+ */
161
+ const ownLocale = declared.LOCALE ?? '';
162
+ if (ownLocale !== '') catalogue.locale = canonicalLocale(ownLocale) ?? ownLocale;
163
+
164
+ const locales = declared.LOCALES ?? [];
165
+ if (locales.length > 0) catalogue.locales = locales.map((one) => canonicalLocale(one) ?? one);
166
+
167
+ // --- the shape the admin will refuse ----------------------------------------
168
+ //
169
+ // Checked here rather than discovered at import, because the answer "that is
170
+ // not a usable catalogue" arrives in the admin with no line number and by then
171
+ // the developer has moved on.
172
+
173
+ const keyRe = /^[a-z][a-z0-9_]{0,39}$/;
174
+ /*
175
+ * The two sets checkAsks reads, beside the kinds: the keys the structure of
176
+ * an enquiry owns, and the kinds a visitor's form control can hold.
177
+ */
178
+ const ENQUIRY_KEYS = new Set(['name', 'contact', 'phone', 'email', 'message', 'website']);
179
+ const ASK_KINDS = new Set(['text', 'textarea', 'number', 'bool', 'select', 'tel', 'email', 'url', 'date', 'time_of_day']);
180
+
181
+ const kinds = new Set([
182
+ 'text', 'textarea', 'richtext_lite', 'number', 'tel', 'url',
183
+ 'email', 'date', 'select', 'bool', 'media', 'list', 'markdown',
184
+ // An amount in minor units (25000 is 250.00) and a length in whole minutes.
185
+ // Both are whole numbers carrying a unit, and both arrived with the domain
186
+ // modules: a service entry costs something and takes some time.
187
+ 'money', 'duration', 'time_of_day',
188
+ ]);
189
+
190
+ const problems = [];
191
+
192
+ /**
193
+ * One thing wrong with the catalogue, named the way the server names it.
194
+ *
195
+ * Three parts and each has one reader, which is the shape `registry.Fault`
196
+ * already has on the other side: `code` is looked up, `where` is navigated to,
197
+ * `says` is read. The code is the part that is new here — the sentence was
198
+ * always enough for a person and was never enough for anything else, because
199
+ * prose cannot be grepped, counted, or compared with what the judge said about
200
+ * the same file.
201
+ *
202
+ * `code` is either one of the platform's — in which case it is a promise that
203
+ * `docs/reference/errors.md` has a row for it, and `--judge` will hold this file
204
+ * to raising it exactly when the server does — or `LOCAL`, for the handful of
205
+ * checks that are the studio's own. See lib/codes.mjs for why those two must not
206
+ * be spelled alike.
207
+ */
208
+ function fault(code, where, says) {
209
+ problems.push({ code, where, says });
210
+ }
211
+
212
+ // A label is the words, written once or per language. The same rule the server
213
+ // applies in registry.validateText — here so the answer arrives with a line
214
+ // number rather than in the admin, hours later, with none.
215
+ const LONGEST_LABEL = 300;
216
+
217
+ function checkText(where, what, text) {
218
+ if (text === undefined || text === null) return;
219
+
220
+ if (typeof text === 'string') {
221
+ if (text.length > LONGEST_LABEL) {
222
+ fault(E.TEXT, where, `${what} runs past ${LONGEST_LABEL} characters`);
223
+ }
224
+ return;
225
+ }
226
+ if (typeof text !== 'object' || Array.isArray(text)) {
227
+ fault(
228
+ E.TEXT,
229
+ where,
230
+ `${what} is neither the words — 'Заголовок' — nor the words per ` +
231
+ `language — { uk: 'Заголовок', en: 'Heading' }`,
232
+ );
233
+ return;
234
+ }
235
+ const languages = Object.keys(text);
236
+ if (languages.length === 0) {
237
+ fault(E.TEXT, where, `${what} names no language at all`);
238
+ }
239
+ for (const locale of languages) {
240
+ if (canonicalLocale(locale) === null) {
241
+ fault(E.TEXT, where, `${what} names the language ${JSON.stringify(locale)}, which is not a language`);
242
+ }
243
+ const words = text[locale];
244
+ if (typeof words !== 'string' || words.trim() === '') {
245
+ fault(E.TEXT, where, `${what} is empty in ${JSON.stringify(locale)} — leave the language out instead`);
246
+ } else if (words.length > LONGEST_LABEL) {
247
+ fault(E.TEXT, where, `${what} in ${JSON.stringify(locale)} runs past ${LONGEST_LABEL} characters`);
248
+ }
249
+ }
250
+ }
251
+
252
+ function checkField(owner, field, nested) {
253
+ const where = `${owner}.${field.key ?? '(no key)'}`;
254
+
255
+ if (!keyRe.test(field.key ?? '')) {
256
+ fault(E.NAME, where, 'a key must be a–z, 0–9 and underscores, starting with a letter');
257
+ }
258
+ if (!kinds.has(field.kind)) {
259
+ fault(E.KIND, where, `${JSON.stringify(field.kind)} is not a kind the admin has a control for`);
260
+ }
261
+ if (!field.label) {
262
+ fault(E.LABEL_MISSING, where, 'no label — the owner sees this above the control, so it is the words themselves');
263
+ }
264
+ checkText(where, 'label', field.label);
265
+ checkText(where, 'hint', field.hint);
266
+ if (field.kind === 'select' && !(field.options ?? []).length) {
267
+ fault(E.OPTIONS, where, 'a select with no options');
268
+ }
269
+ /*
270
+ * `accept` is what tells the admin a slot holds a clip rather than a
271
+ * photograph — a different control, a different upload, and a poster frame
272
+ * to go with it. On anything but a media field it is a field that was copied
273
+ * from one above it.
274
+ */
275
+ if (field.accept && field.kind !== 'media') {
276
+ fault(E.MEDIA, where, `accept is for media fields only, and this one is a ${field.kind}`);
277
+ }
278
+ if (field.accept && !['image', 'video'].includes(field.accept)) {
279
+ fault(E.MEDIA, where, `accept is "image" or "video", not ${JSON.stringify(field.accept)}`);
280
+ }
281
+ if (field.multiple && field.kind !== 'media') {
282
+ fault(E.MEDIA, where, `multiple is for media fields only — a list of anything else is \`kind: 'list'\``);
283
+ }
284
+ /*
285
+ * A clip is stored with the still that a refused autoplay lands on, and a
286
+ * plain media field is one key with nowhere to keep one. So the item shape is
287
+ * the only shape a clip has, and one clip is `multiple` with `max: 1` — which
288
+ * reads oddly for a moment and keeps one value shape for every video on every
289
+ * site.
290
+ */
291
+ if (field.accept === 'video' && !field.multiple) {
292
+ fault(
293
+ E.MEDIA,
294
+ where,
295
+ `accept: 'video' without multiple — a clip is stored with its poster, so declare it multiple (max: 1 if there is only one)`,
296
+ );
297
+ }
298
+
299
+ /*
300
+ * The frame a picture is shown in, declared by the design.
301
+ *
302
+ * The other half of cropping — which part of the picture survives it — is
303
+ * stored beside the picture, so a framed field needs the item shape for the
304
+ * same reason a clip does.
305
+ */
306
+ if (field.ratio && field.kind !== 'media') {
307
+ fault(E.MEDIA, where, `ratio is the frame a picture is shown in, and this one is a ${field.kind}`);
308
+ }
309
+ if (field.ratio && !/^[1-9][0-9]{0,2}:[1-9][0-9]{0,2}$/.test(field.ratio)) {
310
+ fault(E.MEDIA, where, `ratio is two whole numbers, like '3:2' — not ${JSON.stringify(field.ratio)}`);
311
+ }
312
+ if (field.ratio && field.kind === 'media' && !field.multiple) {
313
+ fault(
314
+ E.MEDIA,
315
+ where,
316
+ `ratio without multiple — the focus of a cropped picture is stored beside it, so declare it multiple (max: 1 if there is only one)`,
317
+ );
318
+ }
319
+ if (field.kind === 'list') {
320
+ if (nested) {
321
+ fault(E.LIST, where, 'a list inside a list — the admin has no control for that');
322
+ return;
323
+ }
324
+ if (!(field.of ?? []).length) {
325
+ fault(E.LIST, where, 'a list with no fields in a row');
326
+ }
327
+ for (const sub of field.of ?? []) checkField(where, sub, true);
328
+ }
329
+ }
330
+
331
+ const seenTypes = new Set();
332
+ for (const block of catalogue.blocks) {
333
+ if (!keyRe.test(block.type ?? '')) {
334
+ fault(E.NAME, block.type, 'a block type must be a–z, 0–9 and underscores');
335
+ }
336
+ if (seenTypes.has(block.type)) fault(E.DUPLICATE, block.type, 'declared twice');
337
+ seenTypes.add(block.type);
338
+
339
+ if (!block.label) fault(E.LABEL_MISSING, block.type, 'no label');
340
+ checkText(block.type, 'label', block.label);
341
+ checkText(block.type, 'hint', block.hint);
342
+ if (!(block.v >= 1)) fault(E.BLOCK_VERSION, block.type, 'v must be 1 or more');
343
+
344
+ const seenKeys = new Set();
345
+ for (const field of block.fields ?? []) {
346
+ if (seenKeys.has(field.key)) fault(E.DUPLICATE, `${block.type}.${field.key}`, 'declared twice');
347
+ seenKeys.add(field.key);
348
+ checkField(block.type, field, false);
349
+ }
350
+
351
+ checkAsks(block);
352
+ checkViews(block);
353
+ }
354
+
355
+ /*
356
+ * What a form asks a visitor for, beyond the structure every enquiry has.
357
+ *
358
+ * A mirror of validateAsks in registry/catalogue.go: the asks are fields and
359
+ * are judged as fields, with two rules of their own — the structure's keys
360
+ * (name, contact, phone, email, message, website) are taken, and a kind a
361
+ * visitor cannot type into is refused. Only a block that collects may ask.
362
+ */
363
+ function checkAsks(block) {
364
+ const asks = block.asks ?? [];
365
+ if (asks.length === 0) return;
366
+ const owner = `${block.type}.asks`;
367
+ if (block.collects !== 'enquiry') {
368
+ fault(E.COLLECTS, owner, 'asks the visitor for fields and collects nothing — say collects: "enquiry"');
369
+ }
370
+ const seen = new Set();
371
+ for (const ask of asks) {
372
+ const where = `${owner}.${ask.key ?? '(no key)'}`;
373
+ if (ENQUIRY_KEYS.has(ask.key)) {
374
+ fault(E.NAME, where, "is the enquiry's own — the form's name, contact, message and honeypot have their places already");
375
+ continue;
376
+ }
377
+ if (kinds.has(ask.kind) && !ASK_KINDS.has(ask.kind)) {
378
+ fault(E.KIND, where, `${JSON.stringify(ask.kind)} is not something a visitor's form control can hold`);
379
+ }
380
+ if (seen.has(ask.key)) fault(E.DUPLICATE, where, 'declared twice');
381
+ seen.add(ask.key);
382
+ checkField(owner, ask, true);
383
+ }
384
+ }
385
+
386
+ /*
387
+ * The arrangements a block may be shown in, and the rules that come with each.
388
+ *
389
+ * A mirror of registry/catalogue.go for the reason the whole script is: the
390
+ * admin's refusal arrives with no line number, days later, to somebody who has
391
+ * moved on. Two rules carry the argument — one gallery, because an arrangement
392
+ * arranges one; and the limits in one place, because a field saying three and a
393
+ * view saying five is a rule whose answer depends on which the reader looked
394
+ * at.
395
+ */
396
+ function checkViews(block) {
397
+ const views = block.views ?? [];
398
+ if (views.length === 0) return;
399
+
400
+ const galleries = (block.fields ?? []).filter((f) => f.kind === 'media' && f.multiple === true);
401
+ if (galleries.length !== 1) {
402
+ fault(
403
+ E.VIEW,
404
+ block.type,
405
+ 'declares views, so it must hold exactly one gallery for them to arrange ' +
406
+ `(it holds ${galleries.length})`,
407
+ );
408
+ return;
409
+ }
410
+ const gallery = galleries[0];
411
+ if (gallery.min !== undefined || gallery.max !== undefined || gallery.ratio !== undefined) {
412
+ fault(
413
+ E.VIEW,
414
+ `${block.type}.${gallery.key}`,
415
+ 'the block declares views, so how many pictures and what shape ' +
416
+ 'they are cropped to belong to each view and not to the field',
417
+ );
418
+ }
419
+
420
+ const seenViews = new Set();
421
+ for (const view of views) {
422
+ const where = `${block.type}.views.${view.key ?? '(no key)'}`;
423
+ if (!keyRe.test(view.key ?? '')) fault(E.NAME, where, 'a view key must be a–z, 0–9 and underscores');
424
+ if (seenViews.has(view.key)) fault(E.DUPLICATE, where, 'declared twice');
425
+ seenViews.add(view.key);
426
+
427
+ if (!view.label) fault(E.LABEL_MISSING, where, 'no label — a menu of keys is not a menu');
428
+ checkText(where, 'label', view.label);
429
+ if (view.min !== undefined && view.max !== undefined && view.min > view.max) {
430
+ fault(E.VIEW, where, 'min above max');
431
+ }
432
+ if (view.ratio !== undefined && !/^[1-9][0-9]{0,2}:[1-9][0-9]{0,2}$/.test(view.ratio)) {
433
+ fault(E.VIEW, where, `${JSON.stringify(view.ratio)} is not a ratio`);
434
+ }
435
+ if (view.sample !== undefined && !/^\/[a-z0-9]+(-[a-z0-9]+)*(\/[a-z0-9]+(-[a-z0-9]+)*)*$/.test(view.sample)) {
436
+ fault(E.VIEW, where, 'sample must be a page of this site, like "/portfolio"');
437
+ }
438
+ if (view.needs_build !== undefined && typeof view.needs_build !== 'boolean') {
439
+ fault(E.VIEW, where, 'needs_build is true or absent');
440
+ }
441
+ }
442
+ }
443
+
444
+ for (const field of catalogue.page_seo) checkField('page_seo', field, false);
445
+ for (const field of catalogue.business_facts) checkField('business_facts', field, false);
446
+
447
+ /*
448
+ * The collections, checked against the same rules the admin will apply.
449
+ *
450
+ * A mirror of registry/collections.go, and it is here for the reason the whole
451
+ * script is: the admin's refusal arrives with no line number, days later, to
452
+ * somebody who has moved on. These are the rules that cannot be relaxed —
453
+ * every one of them is something that reads a reserved key by name: the
454
+ * listing, the feed, the sitemap, the card a messenger draws, and the admin's
455
+ * own list of entries.
456
+ */
457
+ const prefixRe = /^\/[a-z0-9]+(-[a-z0-9]+)*(\/[a-z0-9]+(-[a-z0-9]+)*)*$/;
458
+ const entryKinds = {
459
+ title: 'text',
460
+ date: 'date',
461
+ excerpt: 'textarea',
462
+ cover: 'media',
463
+ body: 'markdown',
464
+ };
465
+
466
+ if (ownLocale !== '' && !localeKeyRe.test(ownLocale)) {
467
+ fault(E.LOCALE, 'LOCALE', `${JSON.stringify(ownLocale)} is not a language — two letters, or two and a region`);
468
+ }
469
+
470
+ const seenLocales = new Set();
471
+ for (const locale of locales) {
472
+ if (!localeKeyRe.test(locale)) {
473
+ fault(E.LOCALE, 'LOCALES', `${JSON.stringify(locale)} is not a language — two letters, or two and a region`);
474
+ }
475
+ // Duplicate rather than locale, and it is the server's own division: a
476
+ // language declared twice is the same fault as a block type declared twice,
477
+ // and the remedy — rename or remove one — is what a code is for.
478
+ if (seenLocales.has(locale)) fault(E.DUPLICATE, 'LOCALES', `${locale} is declared twice`);
479
+ // The site's own language is the empty string on every page that carries one,
480
+ // so naming it again here is a site with two spellings of one language.
481
+ if (locale === ownLocale) {
482
+ fault(E.LOCALE, 'LOCALES', `${locale} is LOCALE — the site's own language does not go in LOCALES as well`);
483
+ }
484
+ seenLocales.add(locale);
485
+ }
486
+
487
+ const takenPrefixes = [];
488
+ const takenNames = new Set();
489
+
490
+ for (const collection of collections) {
491
+ const where = `collection ${collection.name ?? '(no name)'}`;
492
+
493
+ if (!keyRe.test(collection.name ?? '')) {
494
+ fault(E.NAME, where, 'a name must be a–z, 0–9 and underscores, starting with a letter');
495
+ }
496
+ if (takenNames.has(collection.name)) fault(E.DUPLICATE, where, 'declared twice');
497
+ takenNames.add(collection.name);
498
+
499
+ if (!collection.label) fault(E.LABEL_MISSING, where, 'no label — a menu of keys is not a menu');
500
+ checkText(where, 'label', collection.label);
501
+
502
+ if (!prefixRe.test(collection.prefix ?? '')) {
503
+ fault(E.COLLECTION, where, 'prefix must be a path like "/blog", with no trailing slash');
504
+ } else {
505
+ for (const other of takenPrefixes) {
506
+ if (`${other}/`.startsWith(`${collection.prefix}/`) || `${collection.prefix}/`.startsWith(`${other}/`)) {
507
+ fault(E.COLLECTION, where, `it and "${other}" would both own the same paths`);
508
+ }
509
+ }
510
+ takenPrefixes.push(collection.prefix);
511
+ }
512
+
513
+ // A mark for the sidebar is a Lucide name or path data and nothing else —
514
+ // the same two regexes the registry holds it to (collections.go).
515
+ if (collection.icon !== undefined) {
516
+ const said = String(collection.icon);
517
+ if (!/^lucide:[a-z0-9]+(-[a-z0-9]+)*$/.test(said) && !/^path:[MmZzLlHhVvCcSsQqTtAa0-9 .,-]{1,1000}$/.test(said)) {
518
+ fault(E.COLLECTION, where, 'icon is `lucide:<name>` — an icon of Lucide by its name, like lucide:table-2 — or `path:<svg path data>` on a 20×20 grid');
519
+ }
520
+ }
521
+
522
+ const entry = catalogue.blocks.find((block) => block.type === collection.type);
523
+ if (entry === undefined) {
524
+ // The collection's fault rather than the entry type's, which is the
525
+ // division the server draws too: ENTRY_TYPE is about a block that exists
526
+ // and cannot hold entries, and there is no block here at all.
527
+ fault(E.COLLECTION, where, `its entries are "${collection.type}", and BLOCKS has no such type`);
528
+ continue;
529
+ }
530
+
531
+ const field = (key) => entry.fields.find((one) => one.key === key);
532
+
533
+ const title = field('title');
534
+ if (title === undefined) {
535
+ fault(E.ENTRY_TYPE, where, `${collection.type} declares no "title" — an entry is listed, linked to and put in a search result by its title`);
536
+ } else if (title.required !== true) {
537
+ fault(E.ENTRY_TYPE, where, `${collection.type}.title must be required`);
538
+ }
539
+
540
+ for (const [key, kind] of Object.entries(entryKinds)) {
541
+ const declaredField = field(key);
542
+ if (declaredField !== undefined && declaredField.kind !== kind) {
543
+ fault(E.ENTRY_TYPE, where, `${collection.type}.${key} is "${declaredField.kind}" — on a collection's entries "${key}" is reserved for "${kind}"`);
544
+ }
545
+ }
546
+
547
+ const cover = field('cover');
548
+ if (cover !== undefined && cover.multiple === true) {
549
+ fault(E.ENTRY_TYPE, where, `${collection.type}.cover cannot be multiple — one entry, one picture that stands for it`);
550
+ }
551
+
552
+ const by = collection.order?.by;
553
+ if (by !== undefined && by !== '' && by !== 'manual') {
554
+ const ordering = field(by);
555
+ if (ordering === undefined) {
556
+ fault(E.ORDER, where, `ordered by "${by}", which ${collection.type} does not declare`);
557
+ } else if (ordering.required !== true) {
558
+ fault(E.ORDER, where, `ordered by ${collection.type}.${by}, so that field must be required`);
559
+ } else if (ordering.kind !== 'date' && ordering.kind !== 'number') {
560
+ fault(E.ORDER, where, `ordered by ${collection.type}.${by}, which is "${ordering.kind}" — order by a date or a number, or arrange them by hand ("manual")`);
561
+ }
562
+ }
563
+
564
+ const perPage = collection.per_page ?? 0;
565
+ if (!Number.isInteger(perPage) || perPage < 0 || perPage > 200) {
566
+ fault(E.COLLECTION, where, 'per_page must be between 1 and 200, or absent for one page');
567
+ }
568
+
569
+ /*
570
+ * What a post may hold, and the one type it must.
571
+ *
572
+ * A post is a sequence: its opening prose, then runs of prose and whatever
573
+ * else is named here, in the order the owner put them in. Without `text` in
574
+ * the list the writing cannot be broken by anything — a gallery could only
575
+ * ever go after the whole of it, which is the arrangement this replaced.
576
+ */
577
+ const body = collection.body ?? [];
578
+ if (body.length > 0 && !body.includes('text')) {
579
+ fault(
580
+ E.COLLECTION,
581
+ where,
582
+ 'body does not name "text", so a post could only ever hold a gallery after the whole ' +
583
+ 'of its writing — see COLLECTIONS[].body',
584
+ );
585
+ }
586
+ for (const kind of body) {
587
+ if (!seenTypes.has(kind)) {
588
+ fault(E.COLLECTION, where, `body names "${kind}", and BLOCKS has no such type`);
589
+ }
590
+ if (kind === collection.type) {
591
+ fault(E.COLLECTION, where, `body names "${kind}", which is what an entry *is* — a post cannot hold itself`);
592
+ }
593
+ }
594
+ }
595
+
596
+ /*
597
+ * ── can the platform serve this at all ─────────────────────────────────────
598
+ *
599
+ * A site is served two ways and the difference is a path. On its own host it is
600
+ * at the root, so `/preise/` and `/favicon.png` mean what they say. In the
601
+ * studio's preview the same build is served under
602
+ * `https://preview…/p/<slug>/`, where the root is not the site: every one of
603
+ * those addresses leaves it, and what the client is shown is a page with no
604
+ * stylesheet, no pictures and navigation that 404s.
605
+ *
606
+ * It reads as a broken build rather than as a wrong prefix, which is why it is
607
+ * checked here rather than written down anywhere: it has cost three separate
608
+ * afternoons, and the third one was a site the kit had already been used to
609
+ * build. Two things are needed and neither is visible in a browser at
610
+ * `localhost:4321`, where the base is `/` and everything works.
611
+ *
612
+ * ── and these three carry no JTK_E_ code, on purpose ────────────────────────
613
+ *
614
+ * The judge has no opinion about any of it, correctly. Where a build is served
615
+ * from is the studio's arrangement — our preview, our prefix — and not something
616
+ * the catalogue format says anything about; a stranger's repository could get
617
+ * every one of these "wrong" and still be a repository the platform can build.
618
+ * Giving them a `JTK_E_…` would put a name in the terminal that the platform's
619
+ * own reference has no row for, and `--judge` would then read the server's
620
+ * silence about them as a false refusal. They are marked LOCAL instead.
621
+ */
622
+
623
+ const configPath = join(root, 'astro.config.mjs');
624
+ if (existsSync(configPath)) {
625
+ const config = readFileSync(configPath, 'utf8');
626
+ if (!/\bbase\s*:/.test(config)) {
627
+ fault(
628
+ LOCAL,
629
+ 'astro.config.mjs',
630
+ 'sets no `base`, so this build can only be served at the root of a host — ' +
631
+ "the studio's preview serves it under /p/<slug>/, where every asset it emits is a 404. " +
632
+ 'Derive it from the site URL: `const BASE = new URL(SITE).pathname` and `base: BASE`',
633
+ );
634
+ } else if (!/new URL\(\s*SITE\s*\)\.pathname/.test(config) && !/BASE_URL/.test(config)) {
635
+ fault(
636
+ LOCAL,
637
+ 'astro.config.mjs',
638
+ 'sets `base` from something other than the site URL — the two are one ' +
639
+ 'decision, and setting one and forgetting the other is the failure this check exists for',
640
+ );
641
+ }
642
+ }
643
+
644
+ /*
645
+ * And the addresses a person typed.
646
+ *
647
+ * `base` fixes what Astro emits — the bundled CSS, the optimised images — and
648
+ * it cannot fix a string. `href="/preise/"` is a link out of the preview and
649
+ * `href="/favicon.png"` is a 404, in a build where everything Astro made is
650
+ * right. src/lib/under.ts is the one way to write these.
651
+ *
652
+ * `/api/…` is exempt: a form posts to the host it is served from, which is the
653
+ * real site in production and is not part of the build.
654
+ */
655
+ const absolute = /(?:href|src|poster|action|content)\s*=\s*"(\/(?!\/)[^"#]*)"/g;
656
+ const cssAbsolute = /url\(\s*['"]?(\/(?!\/)[^)'"]*)/g;
657
+
658
+ function sourceFiles(dir) {
659
+ const found = [];
660
+ if (!existsSync(dir)) return found;
661
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
662
+ const full = join(dir, entry.name);
663
+ if (entry.isDirectory()) {
664
+ found.push(...sourceFiles(full));
665
+ continue;
666
+ }
667
+ if (/\.(astro|ts|tsx|css)$/.test(entry.name)) found.push(full);
668
+ }
669
+ return found;
670
+ }
671
+
672
+ for (const file of sourceFiles(join(root, 'src'))) {
673
+ const text = readFileSync(file, 'utf8');
674
+ const where = relative(root, file);
675
+ const seen = new Set();
676
+
677
+ for (const [, address] of [...text.matchAll(absolute), ...text.matchAll(cssAbsolute)]) {
678
+ if (address.startsWith('/api/')) continue;
679
+ if (seen.has(address)) continue;
680
+ seen.add(address);
681
+ fault(
682
+ LOCAL,
683
+ where,
684
+ `"${address}" is written from the root, so it leaves the site wherever the site ` +
685
+ "is not at the root — which is the studio's preview, always. Write it as " +
686
+ `under('${address}') — see src/lib/under.ts`,
687
+ );
688
+ }
689
+ }
690
+
691
+ /*
692
+ * ── the exam ────────────────────────────────────────────────────────────────
693
+ *
694
+ * wiki/35 §5 settles which of the two validators is right: the Go one is the
695
+ * judge, and this one sits its exam. Everything below is that sitting — post the
696
+ * catalogue we just built to `POST /v1/registry/validate`, and compare the two
697
+ * verdicts.
698
+ *
699
+ * The comparison is over **codes**, not over findings. Both sides say where a
700
+ * fault is and they say it in different dialects — `hero.title` here against
701
+ * `blocks[0].fields[2]` there — so matching on paths would report a divergence
702
+ * every time the two agreed perfectly, which is the fastest way to teach
703
+ * somebody to ignore a check. What may not differ is the verdict: a code the
704
+ * judge raised and we did not means this file is behind, and a code we raised
705
+ * and it did not means this file refuses a catalogue the platform would accept.
706
+ * Both are gaps and both are ours to close.
707
+ */
708
+
709
+ /** The session, presented the way the admin API insists on finding it. */
710
+ function judgeRequest(text) {
711
+ const api = (process.env.JTK_API ?? '').trim().replace(/\/+$/, '');
712
+ return {
713
+ url: `${api}/v1/registry/validate`,
714
+ init: {
715
+ method: 'POST',
716
+ headers: {
717
+ // A cookie named `sid`, because that is the only credential the service
718
+ // takes: auth/authHandler.go reads one `http.Cookie` and says in as many
719
+ // words that there are no bearer tokens in the app. An Authorization
720
+ // header would be ignored in silence and every call would come back
721
+ // unauthenticated, which is the most confusing way to be wrong.
722
+ cookie: `sid=${(process.env.JTK_TOKEN ?? '').trim()}`,
723
+ accept: 'application/json',
724
+ 'content-type': 'application/json',
725
+ },
726
+ body: JSON.stringify({ catalogue: text }),
727
+ },
728
+ };
729
+ }
730
+
731
+ /**
732
+ * Ask the judge, and report where the two of us disagree.
733
+ *
734
+ * Returns the number of divergences, so the caller can make them an exit code.
735
+ * Not reaching the judge at all returns zero on purpose: "the network is down"
736
+ * and "your catalogue is wrong" are different facts, and a check that conflates
737
+ * them is a check somebody disables on a train.
738
+ */
739
+ async function sitTheExam(text, ours) {
740
+ if (!process.env.JTK_API || !process.env.JTK_TOKEN) {
741
+ // Loudly rather than quietly. A --judge that shrugs when it is not
742
+ // configured is a --judge that has been passing for a month in somebody's
743
+ // script while sitting no exam at all.
744
+ console.error(`
745
+ jtk catalogue: --judge needs somewhere to ask and something to ask with.
746
+
747
+ export JTK_API=https://… the admin API (http://localhost:4000 locally)
748
+ export JTK_TOKEN=… a session minted in the admin for a studio member
749
+
750
+ The token is the value of the \`sid\` cookie on a signed-in admin session; the
751
+ same two variables the toolkit's MCP server reads. \`fl-doctor\` says whether
752
+ this machine has them.`);
753
+ process.exit(1);
754
+ }
755
+
756
+ const { url, init } = judgeRequest(text);
757
+
758
+ let response;
759
+ try {
760
+ response = await fetch(url, init);
761
+ } catch (why) {
762
+ console.error(`\njtk catalogue: could not reach the judge at ${url} — ${why.message}`);
763
+ console.error('The catalogue was not examined. This is not a verdict on it.');
764
+ return 0;
765
+ }
766
+
767
+ if (!response.ok) {
768
+ const said = (await response.text()).trim().slice(0, 400);
769
+ console.error(`\njtk catalogue: could not reach the judge — ${url} answered ${response.status}`);
770
+ console.error(` ${said || '(nothing)'}`);
771
+ console.error(
772
+ response.status === 401 || response.status === 403
773
+ ? 'A session expires. Sign in to the admin again and re-read the `sid` cookie into JTK_TOKEN.'
774
+ : 'The catalogue was not examined. This is not a verdict on it.',
775
+ );
776
+ return 0;
777
+ }
778
+
779
+ let verdict;
780
+ try {
781
+ verdict = await response.json();
782
+ } catch (why) {
783
+ console.error(`\njtk catalogue: could not reach the judge — ${url} answered with something that is not JSON`);
784
+ console.error(` ${why.message}`);
785
+ return 0;
786
+ }
787
+
788
+ const findings = verdict.findings ?? [];
789
+ // Advice is the judge's alone — rules about the bookings module it runs,
790
+ // which this checker has no copy of — so it is printed and not examined.
791
+ const advice = [...(verdict.advice ?? []), ...findings.filter((one) => isAdvice(one.code))];
792
+ for (const one of advice) {
793
+ console.log(` ~ ${one.code} ${one.path || '(the file)'}: ${one.says}`);
794
+ }
795
+ if (advice.length > 0) {
796
+ console.log(
797
+ ` ${advice.length} piece(s) of advice from the judge — allowed, and unwise. Act on them before the first build.\n`,
798
+ );
799
+ }
800
+ const theirs = new Set(findings.filter((one) => !isAdvice(one.code)).map((one) => one.code));
801
+ // LOCAL findings are left out entirely: the server has no rule about the
802
+ // studio's preview, so its silence about them is the right answer rather than
803
+ // a gap. See lib/codes.mjs.
804
+ const mine = new Set(ours.filter((one) => isPlatformCode(one.code)).map((one) => one.code));
805
+
806
+ const missed = [...theirs].filter((code) => !mine.has(code)).sort();
807
+ const invented = [...mine].filter((code) => !theirs.has(code)).sort();
808
+
809
+ if (missed.length === 0 && invented.length === 0) {
810
+ console.log(
811
+ `judged by ${url} — contract ${verdict.contract}, ${findings.length} finding(s), ` +
812
+ 'and this checker said the same',
813
+ );
814
+ return 0;
815
+ }
816
+
817
+ console.error(`\njtk catalogue: the judge and this checker do not agree (contract ${verdict.contract})\n`);
818
+
819
+ for (const code of missed) {
820
+ console.error(` ← ${code} the judge raised this and we did not — this checker is behind`);
821
+ for (const one of findings.filter((f) => f.code === code)) {
822
+ console.error(` ${one.path || '(the file)'}: ${one.says}`);
823
+ }
824
+ }
825
+ for (const code of invented) {
826
+ console.error(` → ${code} we raised this and the judge did not — a catalogue refused for nothing`);
827
+ for (const one of ours.filter((f) => f.code === code)) {
828
+ console.error(` ${one.where}: ${one.says}`);
829
+ }
830
+ }
831
+
832
+ console.error(`
833
+ The Go validator in the admin's registry package is the judge (wiki/35 §5); the
834
+ checks in this file are a copy that has to work offline. A divergence is this
835
+ file's bug, not the server's — bring the check here into line, or add the one it
836
+ is missing, and note it in the platform's 12 · debt if the contract is what
837
+ moved.`);
838
+
839
+ return missed.length + invented.length;
840
+ }
841
+
842
+ // --- the report, and then the judge -----------------------------------------
843
+ //
844
+ // The catalogue is serialised before either, because `--judge` sends the text
845
+ // and has to be able to send a catalogue this file refuses: the interesting exam
846
+ // question is not only "what did the server catch that we missed" but "what did
847
+ // we refuse that the server was happy with", and there is no way to ask the
848
+ // second one about a file we declined to produce.
849
+ //
850
+ // Serialised, not written. What goes on disk still goes there only when the
851
+ // checks pass — a `jtk/catalogue.json` that this tool has said is unusable is a
852
+ // file somebody would otherwise commit.
853
+
854
+ const catalogueText = JSON.stringify(catalogue, null, 2) + '\n';
855
+
856
+ if (problems.length) {
857
+ console.error('jtk catalogue: this catalogue is not one the admin will accept\n');
858
+ for (const problem of problems) {
859
+ console.error(` ✗ ${problem.code} ${problem.where}: ${problem.says}`);
860
+ }
861
+ console.error(`
862
+ A JTK_E_… code is the platform's own name for that fault: the same one the admin
863
+ returns from POST /v1/registry/validate, and the anchor it has in the generated
864
+ docs/reference/errors.md. ${LOCAL} is this skill's own — the studio's preview
865
+ arrangement, which the catalogue format has nothing to say about.`);
866
+ }
867
+
868
+ const divergences = has('judge') ? await sitTheExam(catalogueText, problems) : 0;
869
+
870
+ if (problems.length) process.exit(Math.min(problems.length, 100));
871
+
872
+ // --- write it ---------------------------------------------------------------
873
+
874
+ const out = join(root, 'jtk', 'catalogue.json');
875
+ mkdirSync(join(root, 'jtk'), { recursive: true });
876
+ writeFileSync(out, catalogueText);
877
+
878
+ const fieldCount = catalogue.blocks.reduce((n, b) => n + (b.fields?.length ?? 0), 0);
879
+ console.log(`jtk/catalogue.json — ${catalogue.blocks.length} block type(s), ${fieldCount} field(s)`);
880
+
881
+ // A divergence is a failure of this script and not of the site, so the site
882
+ // keeps its catalogue: the file is written, and then the run goes red. Holding
883
+ // the emit back would make a bug in our copy of the rules look like a broken
884
+ // project, which is exactly the confusion the codes are here to end.
885
+ if (divergences > 0) process.exit(Math.min(divergences, 100));
886
+
887
+ if (has('emit-only')) process.exit(0);
888
+
889
+ // --- and prove it matches the page ------------------------------------------
890
+
891
+ let dist = flag('dist');
892
+ if (!dist) {
893
+ console.log('building, to check the catalogue against what the page actually renders…');
894
+ try {
895
+ execFileSync('npm', ['run', 'build'], { cwd: root, stdio: 'inherit' });
896
+ } catch {
897
+ /*
898
+ * The one build failure worth explaining, because the message Astro gives
899
+ * is true and useless.
900
+ *
901
+ * A picture inside an entry's body is markdown pointing at a media key, and
902
+ * Astro resolves a markdown image relative to the markdown file — so the
903
+ * build wants a real file beside the entry, and on a laptop there is none.
904
+ * The studio's build downloads them there before it builds; a clone has
905
+ * never downloaded anything.
906
+ *
907
+ * `ImageNotFound` on a path under jtk/content/ is always this, and it
908
+ * is a stop rather than a bug in the site.
909
+ */
910
+ // Not `contentDir`: that is declared further down and would be a
911
+ // ReferenceError from here. The function is hoisted; the const is not.
912
+ if (hasEntries(join(root, 'jtk', 'content'))) {
913
+ console.error(`
914
+ If that failed with ImageNotFound on a media/… path, this is why:
915
+ a picture in an entry's body is a real file beside the entry, and a clone
916
+ has not downloaded any. The studio's build fetches them before it builds.
917
+ Ask the studio for this site's media, or check the catalogue against a
918
+ build made there: jtk catalogue --dist ./dist`);
919
+ }
920
+ die('the site does not build, so there is nothing to check the catalogue against');
921
+ }
922
+ dist = join(root, 'dist');
923
+ }
924
+ dist = resolve(dist);
925
+
926
+ if (!existsSync(dist)) die(`no build at ${dist}`);
927
+
928
+ /**
929
+ * Every data-jtk-path in the build, **per page**.
930
+ *
931
+ * Per page, and that word is the whole fix. It used to be one set for the whole
932
+ * site, which works exactly as long as a site has one page: two pages both
933
+ * carrying `blocks[0].title` are one string in a set, so the first one to be
934
+ * accounted for deleted it and the second was reported as declared and not
935
+ * rendered. A multi-page site could not pass this check, and a site with a
936
+ * collection is a multi-page site by definition.
937
+ */
938
+ function annotated(dir) {
939
+ const byPage = new Map();
940
+
941
+ const walk = (at) => {
942
+ for (const name of readdirSync(at)) {
943
+ const full = join(at, name);
944
+ if (statSync(full).isDirectory()) {
945
+ walk(full);
946
+ continue;
947
+ }
948
+ if (!full.endsWith('.html')) continue;
949
+
950
+ const html = readFileSync(full, 'utf8');
951
+ const found = new Set();
952
+ for (const match of html.matchAll(/data-jtk-path="([^"]+)"/g)) found.add(match[1]);
953
+ byPage.set(pageOfFile(full), found);
954
+ }
955
+ };
956
+
957
+ walk(dir);
958
+ return byPage;
959
+ }
960
+
961
+ /** dist/blog/doglyad/index.html is the page at /blog/doglyad. */
962
+ function pageOfFile(file) {
963
+ const at = '/' + relative(dist, file).split(sep).join('/');
964
+ return at.replace(/\/index\.html$/, '').replace(/\.html$/, '') || '/';
965
+ }
966
+
967
+ const annotationsByPage = annotated(dist);
968
+
969
+ /**
970
+ * The same, before anything is taken out of it.
971
+ *
972
+ * The check below consumes those sets: a field matched against the catalogue is
973
+ * deleted, and whatever is left over is text nobody can edit. That makes them
974
+ * useless afterwards for the one question a cross-document path asks — *does
975
+ * the page this claims to belong to actually offer this field* — so a copy is
976
+ * kept. Without it that check reads every set as empty and passes everything,
977
+ * which is a guard that only looks like one.
978
+ */
979
+ const offeredByPage = new Map([...annotationsByPage].map(([at, found]) => [at, new Set(found)]));
980
+
981
+ // What the content document says is on the page, so a declared field can be
982
+ // looked for at the index it actually occupies.
983
+ //
984
+ // At any depth. An entry of a collection is a page like any other and lives
985
+ // under the collection's own directory — reading only the top level, which is
986
+ // what this did once, meant every entry's annotations came out the far end as
987
+ // "on the page and in nobody's catalogue", which is the report for text an
988
+ // owner cannot touch. They were the opposite: perfectly editable, and unread.
989
+ const contentDir = join(root, 'jtk', 'content');
990
+
991
+ /** A media key as the admin writes one: the site, then the file's own hash. */
992
+ const MEDIA_KEY = /^\.?\/?media\/[0-9a-fA-F-]{36}\/[0-9a-f]{8,64}\.[a-z0-9]{2,5}$/;
993
+
994
+ /** Every picture in every markdown field of a document, with where it is. */
995
+ function* bodyPictures(document) {
996
+ const blocks = document.blocks ?? [];
997
+
998
+ for (const [index, block] of blocks.entries()) {
999
+ for (const [key, value] of Object.entries(block ?? {})) {
1000
+ if (typeof value !== 'string') continue;
1001
+ for (const found of value.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)) {
1002
+ yield [`blocks[${index}].${key}`, found[1]];
1003
+ }
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ /** Whether any block of this document holds markdown with a picture in it. */
1009
+ function hasBodyPicture(document) {
1010
+ return (document.blocks ?? []).some((block) =>
1011
+ Object.values(block ?? {}).some((value) => typeof value === 'string' && /!\[[^\]]*\]\(/.test(value)),
1012
+ );
1013
+ }
1014
+
1015
+ /**
1016
+ * Whether this site has any entries at all, for the one message that mentions
1017
+ * them.
1018
+ *
1019
+ * An entry is a page now — a `.json` document of blocks (the platform's
1020
+ * wiki/30) — so what makes one an entry is the `collection` inside it, not the
1021
+ * extension. Which also means this check no longer needs the site's own Astro
1022
+ * installed to read a body: it used to parse frontmatter with it, and that is
1023
+ * gone with the format.
1024
+ */
1025
+ function hasEntries(dir) {
1026
+ if (!existsSync(dir)) return false;
1027
+
1028
+ for (const name of readdirSync(dir)) {
1029
+ const full = join(dir, name);
1030
+ if (statSync(full).isDirectory()) {
1031
+ if (name !== 'media' && hasEntries(full)) return true;
1032
+ continue;
1033
+ }
1034
+ if (!name.endsWith('.json')) continue;
1035
+ try {
1036
+ if (typeof JSON.parse(readFileSync(full, 'utf8')).collection === 'string') return true;
1037
+ } catch {
1038
+ // Unreadable JSON is somebody else's error to report, and it is reported
1039
+ // where the file is actually read.
1040
+ }
1041
+ }
1042
+ return false;
1043
+ }
1044
+
1045
+ /**
1046
+ * Every content document under a collection or beside one, at any depth.
1047
+ *
1048
+ * One shape to read: an entry is a page now — a `.json` document of blocks —
1049
+ * so the branch that parsed markdown frontmatter is gone with the format.
1050
+ */
1051
+ function contentPages(dir) {
1052
+ if (!existsSync(dir)) return [];
1053
+
1054
+ const out = [];
1055
+ for (const name of readdirSync(dir)) {
1056
+ const full = join(dir, name);
1057
+ if (statSync(full).isDirectory()) {
1058
+ // A collection's own pictures land here at build time. They are not
1059
+ // content and reading them as such would be a stack trace.
1060
+ if (name !== 'media') out.push(...contentPages(full));
1061
+ continue;
1062
+ }
1063
+ if (!name.endsWith('.json')) continue;
1064
+ const document = JSON.parse(readFileSync(full, 'utf8'));
1065
+ // Where it came from, so a picture in a body can be resolved the way the
1066
+ // build resolves it: relative to the file that holds the markdown.
1067
+ Object.defineProperty(document, 'file', { value: full, enumerable: false });
1068
+ out.push(document);
1069
+ }
1070
+ return out;
1071
+ }
1072
+
1073
+ /** Every stylesheet under src, as one string. Crude on purpose: the question is
1074
+ * only whether a name appears at all. */
1075
+ function readStyles(dir) {
1076
+ if (!existsSync(dir)) return '';
1077
+
1078
+ let found = '';
1079
+ for (const name of readdirSync(dir)) {
1080
+ const full = join(dir, name);
1081
+ if (statSync(full).isDirectory()) {
1082
+ found += readStyles(full);
1083
+ continue;
1084
+ }
1085
+ // Astro components carry their styles inside them, which is where a site is
1086
+ // as likely to have put these as in a stylesheet.
1087
+ if (/\.(css|astro)$/.test(name)) found += readFileSync(full, 'utf8');
1088
+ }
1089
+ return found;
1090
+ }
1091
+
1092
+ const pages = contentPages(contentDir);
1093
+
1094
+ /*
1095
+ * Every localised document lives at its own address with the language in front.
1096
+ *
1097
+ * ── the check that replaced a declaration ───────────────────────────────────
1098
+ *
1099
+ * Pages used to pair by a `group` key both documents carried, which let each
1100
+ * language have its own words. That is a second source of truth for something
1101
+ * the addresses already say, and one nobody can check: `group: "work"` on one
1102
+ * page against `group: "works"` on the other is two unrelated pages, no error
1103
+ * anywhere, and a site that has quietly lost its hreflang.
1104
+ *
1105
+ * The rule now is the address itself, and this is where a repository that has
1106
+ * not caught up is told so — before the push, rather than by the import.
1107
+ */
1108
+ /*
1109
+ * ── the content's own problems, reported separately and on purpose ──────────
1110
+ *
1111
+ * `problems` above is about the catalogue, and it is reported and exited at the
1112
+ * point the catalogue is finished — long before the content is read. Two checks
1113
+ * were pushed into it from down here and were therefore never printed at all:
1114
+ * dead code that looked like a guard. This is the second list, reported where
1115
+ * the content it is about has actually been read.
1116
+ */
1117
+ const contentProblems = [];
1118
+
1119
+ /*
1120
+ * A body with pictures in it needs the two class names that draw them.
1121
+ *
1122
+ * `figures.mjs` turns a paragraph of images into `.fl-figure` and `.fl-row`,
1123
+ * and the kit ships styles for both in `src/styles/global.css`. A site that
1124
+ * deleted them gets pictures at their natural size in document order, which
1125
+ * looks like a broken build and is really a missing stylesheet — so it is said
1126
+ * here rather than found in a screenshot.
1127
+ */
1128
+ if (pages.some(hasBodyPicture)) {
1129
+ const styled = readStyles(join(root, 'src'));
1130
+ for (const name of ['fl-figure', 'fl-row']) {
1131
+ if (!styled.includes(name)) {
1132
+ contentProblems.push(
1133
+ `a post has pictures in its body and nothing styles .${name}. ` +
1134
+ `figures.mjs makes them out of plain markdown; the kit ships both in src/styles/global.css. ` +
1135
+ `Restyle them — do not invent a third.`,
1136
+ );
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ /*
1142
+ * A view that says where it can be seen points at a page that exists.
1143
+ *
1144
+ * The admin frames that page at the block and shows it in the menu, so an
1145
+ * arrangement whose sample is a typo is an arrangement offered as a blank
1146
+ * rectangle — and the person choosing has no way to know the difference between
1147
+ * "this looks like nothing" and "this could not be found".
1148
+ */
1149
+ const addresses = new Set(pages.map((page) => page.path));
1150
+ for (const block of catalogue.blocks) {
1151
+ for (const view of block.views ?? []) {
1152
+ if (view.sample !== undefined && !addresses.has(view.sample)) {
1153
+ contentProblems.push(
1154
+ `${block.type}.views.${view.key}: sample is ${JSON.stringify(view.sample)}, and this site has no ` +
1155
+ 'such page. The admin shows that page in the menu, so a sample nobody can fetch is a blank rectangle.',
1156
+ );
1157
+ }
1158
+ }
1159
+ }
1160
+
1161
+ for (const document of pages) {
1162
+ const where = document.path ?? '(a document with no path)';
1163
+
1164
+ if ((document.group ?? '') !== '') {
1165
+ contentProblems.push(
1166
+ `${where}: \`group\` is no longer read. A page's address is the same in every language, ` +
1167
+ `with the language in front of it — /prices and /${locales[0] ?? 'de'}/prices — so the pairing ` +
1168
+ `is the address. Remove the key, and make the addresses match if they differ by more than the language.`,
1169
+ );
1170
+ }
1171
+
1172
+ const locale = document.locale ?? '';
1173
+ if (locale === '') continue;
1174
+
1175
+ // Compared by language rather than by spelling: a page written `de-ch` and a
1176
+ // LOCALES entry of `de-CH` are one language, and telling somebody their site
1177
+ // does not list a language it plainly lists is the unhelpful half of being
1178
+ // strict. The address below is still checked against what the page itself
1179
+ // says, because a URL is not a language tag and is not ours to re-case.
1180
+ const canonical = canonicalLocale(locale);
1181
+ if (!locales.some((one) => canonicalLocale(one) === canonical)) {
1182
+ contentProblems.push(`${where}: written in ${JSON.stringify(locale)}, which LOCALES does not list`);
1183
+ continue;
1184
+ }
1185
+ if (document.path !== `/${locale}` && !String(document.path ?? '').startsWith(`/${locale}/`)) {
1186
+ contentProblems.push(
1187
+ `${where}: written in ${locale} and not under /${locale}. A page's address is the same in ` +
1188
+ `every language, with the language in front of it.`,
1189
+ );
1190
+ }
1191
+ }
1192
+
1193
+ /*
1194
+ * Every picture a body points at is a picture that exists.
1195
+ *
1196
+ * ── the failure this replaces ───────────────────────────────────────────────
1197
+ *
1198
+ * A body is markdown and its images are resolved by the build the way markdown
1199
+ * images always are: relative to the file. A path to nothing fails inside
1200
+ * Vite's resolver with `ImageNotFound` and a stack trace through four
1201
+ * packages, after a clone, an install and a content sync — which is a long way
1202
+ * to travel to be told a filename is wrong.
1203
+ *
1204
+ * Two kinds are legitimate and this knows both:
1205
+ *
1206
+ * ./media/<site>/<hash>.jpg an upload; the build downloads it here
1207
+ * ../../../src/assets/healed.jpg a photograph this repository ships
1208
+ *
1209
+ * An upload is not on disk in a clone and never can be, so it is taken on
1210
+ * trust — the download either finds it or says so in its own step. Anything
1211
+ * else has to be a file, now.
1212
+ */
1213
+ for (const document of pages) {
1214
+ for (const [where, src] of bodyPictures(document)) {
1215
+ if (MEDIA_KEY.test(src)) continue;
1216
+
1217
+ const from = document.file ? dirname(document.file) : contentDir;
1218
+ if (!existsSync(resolve(from, src))) {
1219
+ contentProblems.push(
1220
+ `${document.path ?? where}: ${where} points at ${JSON.stringify(src)} and there is no such file. ` +
1221
+ `A picture in a body is either an upload (./media/<site>/<hash>.jpg, which the build downloads ` +
1222
+ `beside the entry) or a path relative to this file — ../../../src/assets/<name>.jpg for a ` +
1223
+ `photograph the repository ships.`,
1224
+ );
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ if (contentProblems.length) {
1230
+ console.error('\njtk catalogue: the content does not follow the rules the admin reads it by\n');
1231
+ for (const problem of contentProblems) console.error(` ✗ ${problem}`);
1232
+ process.exit(Math.min(contentProblems.length, 100));
1233
+ }
1234
+
1235
+ /*
1236
+ * The text that is on every page.
1237
+ *
1238
+ * Beside the pages and not among them: it has no address, so there is no built
1239
+ * file to check it against — its fields are annotated on whichever pages render
1240
+ * them, with the `shared:` prefix that says which document they mean.
1241
+ */
1242
+ const sharedFile = join(root, 'jtk', 'shared.json');
1243
+ const sharedBlocks = existsSync(sharedFile)
1244
+ ? (JSON.parse(readFileSync(sharedFile, 'utf8')).blocks ?? [])
1245
+ : [];
1246
+
1247
+ const byType = new Map(catalogue.blocks.map((block) => [block.type, block]));
1248
+ const disagreements = [];
1249
+
1250
+ for (const page of pages) {
1251
+ const onThePage = annotationsByPage.get(page.path);
1252
+ if (onThePage === undefined) {
1253
+ disagreements.push(`${page.path} is in the content and the build did not produce it`);
1254
+ continue;
1255
+ }
1256
+
1257
+ (page.blocks ?? []).forEach((block, at) => {
1258
+ const type = byType.get(block.type);
1259
+ if (!type) {
1260
+ disagreements.push(`${block.type} is in the content and not in the catalogue`);
1261
+ return;
1262
+ }
1263
+
1264
+ for (const field of type.fields ?? []) {
1265
+ // Only a field this page actually filled in: an empty one renders
1266
+ // nothing, correctly, and is not evidence of anything.
1267
+ const value = block[field.key];
1268
+ const empty = value === undefined || value === null || value === '' ||
1269
+ (Array.isArray(value) && value.length === 0);
1270
+ if (empty || field.no_tap_target || field.kind === 'bool') continue;
1271
+
1272
+ const path = `blocks[${at}].${field.key}`;
1273
+
1274
+ /*
1275
+ * A gallery: one field, many pictures, and each picture is its own tap
1276
+ * target at `blocks[3].work[7].src`. The container is not one — tapping a
1277
+ * wall of thirty-seven and being asked to edit "the wall" is not an edit
1278
+ * anybody means to make — and neither are the words beside a picture,
1279
+ * which are edited in the panel and never appear as an element.
1280
+ *
1281
+ * `src`, `alt` and `poster` are the item's whole vocabulary. They are not
1282
+ * declared per site: the admin synthesises them from this field, so a
1283
+ * site naming them differently would be a site the editor cannot follow.
1284
+ */
1285
+ if (field.kind === 'media' && field.multiple) {
1286
+ const items = Array.isArray(value) ? value : [];
1287
+ items.forEach((item, i) => {
1288
+ if (item === null || typeof item !== 'object') return;
1289
+ const itemPath = `blocks[${at}].${field.key}[${i}]`;
1290
+
1291
+ if (item.src && !field.no_tap_target) {
1292
+ if (!onThePage.has(`${itemPath}.src`)) {
1293
+ disagreements.push(`${itemPath}.src is declared and the page does not render it`);
1294
+ }
1295
+ }
1296
+ // Annotating them is allowed and not asked for, so they are accounted
1297
+ // for either way rather than reported as text nobody can touch.
1298
+ for (const key of ['src', 'alt', 'poster']) onThePage.delete(`${itemPath}.${key}`);
1299
+ });
1300
+ continue;
1301
+ }
1302
+
1303
+ if (field.kind === 'list') {
1304
+ const rows = Array.isArray(value) ? value : [];
1305
+ rows.forEach((row, i) => {
1306
+ for (const sub of field.of ?? []) {
1307
+ if (sub.no_tap_target || !row[sub.key]) continue;
1308
+ const rowPath = `blocks[${at}].${field.key}[${i}].${sub.key}`;
1309
+ if (!onThePage.has(rowPath)) {
1310
+ disagreements.push(`${rowPath} is declared and the page does not render it`);
1311
+ }
1312
+ onThePage.delete(rowPath);
1313
+ }
1314
+ });
1315
+ continue;
1316
+ }
1317
+
1318
+ if (!onThePage.has(path)) {
1319
+ disagreements.push(`${path} is declared and the page does not render it`);
1320
+ }
1321
+ onThePage.delete(path);
1322
+ }
1323
+ });
1324
+
1325
+ // Whatever is left on this page is in nobody's catalogue: text an owner can
1326
+ // see and cannot touch, which is the failure that hides.
1327
+ //
1328
+ // Except the shared ones — they belong to the site's document, are checked
1329
+ // above, and are correct on every page that renders them.
1330
+ for (const path of onThePage) {
1331
+ if (path.startsWith('shared:')) continue;
1332
+ if (path.startsWith('page:')) continue;
1333
+ disagreements.push(`${page.path}: ${path} is on the page and not in the catalogue`);
1334
+ }
1335
+ }
1336
+
1337
+ /*
1338
+ * A tile that names a page nobody has, or a field that page has not got.
1339
+ *
1340
+ * ── the check a cross-document path needs, and the others do not ────────────
1341
+ *
1342
+ * Every other annotation is checked from the document outwards: the field is
1343
+ * declared, so the page rendering it must say so. A `page:` path goes the other
1344
+ * way — a listing says "this heading belongs to /blog/healing" — and what can
1345
+ * be wrong is the claim. A mistyped address is a tile the owner taps and
1346
+ * nothing opens, which is this whole file's subject arriving through the one
1347
+ * door it did not watch.
1348
+ */
1349
+ for (const [where, found] of annotationsByPage) {
1350
+ for (const path of found) {
1351
+ if (!path.startsWith('page:')) continue;
1352
+
1353
+ const rest = path.slice('page:'.length);
1354
+ const ends = rest.indexOf(':');
1355
+ const address = ends < 0 ? '' : rest.slice(0, ends);
1356
+ const inner = ends < 0 ? '' : rest.slice(ends + 1);
1357
+
1358
+ const there = offeredByPage.get(address);
1359
+ if (there === undefined) {
1360
+ disagreements.push(`${where}: annotates page:${address}: and this site built no such page`);
1361
+ continue;
1362
+ }
1363
+
1364
+ /*
1365
+ * Checked against what that page itself annotates rather than against a
1366
+ * second walk of its document. The two would eventually disagree, and the
1367
+ * page's own annotations are already held to the catalogue a few lines up
1368
+ * — so this asks a question that cannot drift: does the page whose field
1369
+ * this claims to be actually offer that field?
1370
+ */
1371
+ if (!there.has(inner)) {
1372
+ disagreements.push(
1373
+ `${where}: annotates ${inner} on ${address}, which that page does not offer`,
1374
+ );
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ /*
1380
+ * Every shared field has to be rendered somewhere, and every `shared:` path on
1381
+ * the page has to be a field.
1382
+ *
1383
+ * The rule is not the pages' rule and cannot be: a shared field is on many
1384
+ * pages and in none of their documents. What can be checked is the thing that
1385
+ * actually breaks — a field the whole site annotates nowhere is a control that
1386
+ * edits nothing, and an annotation for a field the shared document does not
1387
+ * have is text the owner cannot touch.
1388
+ */
1389
+ const sharedOnThePage = new Set();
1390
+ for (const found of annotationsByPage.values()) {
1391
+ for (const path of found) {
1392
+ if (path.startsWith('shared:')) sharedOnThePage.add(path);
1393
+ }
1394
+ }
1395
+
1396
+ sharedBlocks.forEach((block, at) => {
1397
+ const type = byType.get(block.type);
1398
+ if (!type) {
1399
+ disagreements.push(`shared: ${block.type} is in the shared document and not in the catalogue`);
1400
+ return;
1401
+ }
1402
+
1403
+ for (const field of type.fields ?? []) {
1404
+ const value = block[field.key];
1405
+ const empty = value === undefined || value === null || value === '' ||
1406
+ (Array.isArray(value) && value.length === 0);
1407
+ if (empty || field.no_tap_target || field.kind === 'bool') continue;
1408
+
1409
+ const path = `shared:blocks[${at}].${field.key}`;
1410
+ if (!sharedOnThePage.has(path)) {
1411
+ disagreements.push(`${path} is declared and no page renders it`);
1412
+ }
1413
+ sharedOnThePage.delete(path);
1414
+ }
1415
+ });
1416
+
1417
+ for (const path of sharedOnThePage) {
1418
+ disagreements.push(`${path} is on the page and the shared document does not have it`);
1419
+ }
1420
+
1421
+ // A built page with no content document of its own is skipped rather than
1422
+ // reported: a listing is rendered from other pages' entries and has no document
1423
+ // to check against. What it must not carry is an annotation — a tap there would
1424
+ // send a path the admin resolves against the wrong document — and that is what
1425
+ // the loop above would have said if the page had one.
1426
+ for (const [at, found] of annotationsByPage) {
1427
+ if (pages.some((page) => page.path === at)) continue;
1428
+ /*
1429
+ * A specimen is annotated on purpose and has no document by design.
1430
+ *
1431
+ * `entryLoader.ts` builds one page per arrangement so the admin can lift this
1432
+ * site's own markup for a block that has just been added — annotations and
1433
+ * all, because the paths are half of what is lifted. Nothing is behind it and
1434
+ * nothing is meant to be: it is generated from the declaration at build time.
1435
+ * Named by `specimenId`, which is the one format the two sides agree on.
1436
+ */
1437
+ if (at.split('/').some((part) => part.startsWith('_fl-'))) continue;
1438
+ // A shared field is correct here: it says which document it belongs to, and
1439
+ // it is not this one.
1440
+ const ownPage = [...found].filter((path) => !path.startsWith('shared:'));
1441
+ if (ownPage.length === 0) continue;
1442
+ disagreements.push(`${at}: has annotations and no content document — ${ownPage.sort()[0]} would open the wrong page`);
1443
+ }
1444
+
1445
+ /*
1446
+ * ── and prove it survives being served under a prefix ──────────────────────
1447
+ *
1448
+ * The source check above catches an address somebody typed. It cannot catch one
1449
+ * that arrives as data — a nav list of `{ label, href }`, rendered with
1450
+ * `href={item.href}` — and that is how the failure actually shipped: a site with
1451
+ * every link in one file, none of them under the base, and a preview where the
1452
+ * front page is right and every other page leaves the site.
1453
+ *
1454
+ * So the build is done a second time with a path in SITE_URL, into a directory
1455
+ * that is thrown away, and every address it emitted is held to that path. It is
1456
+ * the only proof that does not depend on how the site is written: whatever the
1457
+ * link went through, this is what came out. About five seconds on a whole site.
1458
+ */
1459
+ if (!flag('dist')) {
1460
+ const prefix = '/p/fl-check/';
1461
+ const out = join(root, '.fl-prefix-check');
1462
+
1463
+ try {
1464
+ execFileSync('npx', ['astro', 'build', '--outDir', out], {
1465
+ cwd: root,
1466
+ stdio: 'pipe',
1467
+ env: { ...process.env, SITE_URL: `https://fl-check.invalid${prefix}`, PUBLIC_INDEXABLE: 'false' },
1468
+ });
1469
+
1470
+ const leaks = [];
1471
+ const emitted = /(?:href|src|poster|action|content)="(\/(?!\/)[^"#]*)"/g;
1472
+
1473
+ for (const file of builtPages(out)) {
1474
+ const page = relative(out, file);
1475
+ for (const [, address] of readFileSync(file, 'utf8').matchAll(emitted)) {
1476
+ if (address.startsWith(prefix) || address.startsWith('/api/')) continue;
1477
+ if (leaks.some((one) => one.address === address)) continue;
1478
+ leaks.push({ page, address });
1479
+ }
1480
+ }
1481
+
1482
+ if (leaks.length > 0) {
1483
+ console.error(`\njtk catalogue: built under ${prefix}, and ${leaks.length} address(es) still point at the root\n`);
1484
+ for (const leak of leaks.slice(0, 20)) console.error(` ✗ ${leak.page}: ${leak.address}`);
1485
+ if (leaks.length > 20) console.error(` … and ${leaks.length - 20} more`);
1486
+ console.error(`
1487
+ The studio's preview serves this build under /p/<slug>/. Every address above
1488
+ leaves the site there: a stylesheet that 404s, a link back to somebody else's
1489
+ home page. Write them through src/lib/under.ts — including the ones that arrive
1490
+ as data, which is what this second build is here to catch.`);
1491
+ rmSync(out, { recursive: true, force: true });
1492
+ process.exit(Math.min(leaks.length, 100));
1493
+ }
1494
+
1495
+ console.log(`built again under ${prefix} — every address it emits stays inside the site`);
1496
+ } catch (err) {
1497
+ // A build that fails only with a prefix is worth saying out loud; a build
1498
+ // that fails outright has already been reported above, and this one is
1499
+ // running against a directory nobody will deploy.
1500
+ if (err?.status !== undefined) {
1501
+ console.error('\njtk catalogue: this site does not build when it is served under a path');
1502
+ console.error(String(err.stderr ?? '').slice(-2000));
1503
+ rmSync(out, { recursive: true, force: true });
1504
+ process.exit(1);
1505
+ }
1506
+ }
1507
+
1508
+ rmSync(out, { recursive: true, force: true });
1509
+ }
1510
+
1511
+ if (disagreements.length) {
1512
+ console.error('\njtk catalogue: the catalogue and the page disagree\n');
1513
+ for (const one of disagreements.sort()) console.error(` ✗ ${one}`);
1514
+ console.error('\nA field declared and not rendered is a control that edits nothing.');
1515
+ console.error('A field rendered and not declared is text the owner cannot touch.');
1516
+ process.exit(Math.min(disagreements.length, 100));
1517
+ }
1518
+
1519
+ console.log(`checked against ${dist} — the catalogue and the page agree`);
1520
+
1521
+ // --- and what is on the page that nobody can edit --------------------------
1522
+ //
1523
+ // The checks above compare the catalogue with the annotations. Neither of them
1524
+ // can see the failure that is actually most common: a sentence written straight
1525
+ // into a component, which carries no annotation, is in nobody's catalogue and is
1526
+ // therefore invisible to both directions of the check.
1527
+ //
1528
+ // It is found by tapping, one string at a time, by whoever is looking at the
1529
+ // site — "I cannot edit the header", "nor the footer", "nor the labels on the
1530
+ // form", "nor the word above each section" — and every one of those is a
1531
+ // message and a deploy.
1532
+ //
1533
+ // **This used to be a report and is now a gate.** A report made the decision
1534
+ // optional, and optional is how twenty-six strings on a real site ended up
1535
+ // belonging to nobody: the navigation, the footer and the labels on the form,
1536
+ // with a note in the handover saying the line had been drawn deliberately. It
1537
+ // had not been drawn at all — nothing was written down, so nothing could be
1538
+ // changed, and the owner found out by wanting to change one.
1539
+ //
1540
+ // So every visible sentence must be one of two things, and both are a decision
1541
+ // somebody made **in the repository**:
1542
+ //
1543
+ // the owner's → declared in the catalogue and annotated: `data-jtk-path`
1544
+ // (text on every page goes in the shared document — one
1545
+ // copy, annotated everywhere it is drawn)
1546
+ // the design's → marked `data-jtk-fixed`, which says "these words are part
1547
+ // of the composition and not the business's to change"
1548
+ //
1549
+ // `data-jtk-fixed` is what a skip link, a honeypot's label and a decorative
1550
+ // caption carry. It silences a subtree, so one attribute on a wrapper covers a
1551
+ // whole ornament. The edge takes it off the public copy the same way it takes
1552
+ // `data-jtk-path` off.
1553
+
1554
+ /** Text on the page with no annotated element above it. */
1555
+ function unowned(html) {
1556
+ const found = [];
1557
+ const stack = [];
1558
+ let skipping = null;
1559
+ let at = 0;
1560
+
1561
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
1562
+ 'link', 'meta', 'param', 'source', 'track', 'wbr']);
1563
+ const OPAQUE = new Set(['script', 'style', 'svg', 'head', 'template', 'noscript']);
1564
+
1565
+ while (at < html.length) {
1566
+ const open = html.indexOf('<', at);
1567
+ if (open === -1) break;
1568
+
1569
+ const text = html.slice(at, open);
1570
+ if (skipping === null && stack.length > 0 && !stack.some((frame) => frame.annotated)) {
1571
+ const words = text.replace(/\s+/g, ' ').trim();
1572
+ /*
1573
+ * Anything with letters in it, and that bar is low on purpose.
1574
+ *
1575
+ * The first version asked for a dozen characters and two words, and it
1576
+ * missed exactly the ones somebody was complaining about: `Method`,
1577
+ * `Evidence`, `One piece` — the word above each section, six letters
1578
+ * each, and as much the business's own as the heading under it. A bullet,
1579
+ * a dash and a page number have no letters and fall out anyway.
1580
+ */
1581
+ if (/\p{L}/u.test(words) && words.length >= 3) {
1582
+ found.push({ where: stack[stack.length - 1].tag, words });
1583
+ }
1584
+ }
1585
+
1586
+ const close = html.indexOf('>', open);
1587
+ if (close === -1) break;
1588
+ const tag = html.slice(open + 1, close);
1589
+ at = close + 1;
1590
+
1591
+ if (tag.startsWith('!') || tag.startsWith('?')) continue;
1592
+
1593
+ if (tag.startsWith('/')) {
1594
+ const name = tag.slice(1).trim().toLowerCase();
1595
+ if (skipping === name) skipping = null;
1596
+ for (let i = stack.length - 1; i >= 0; i--) {
1597
+ if (stack[i].tag === name) {
1598
+ stack.length = i;
1599
+ break;
1600
+ }
1601
+ }
1602
+ continue;
1603
+ }
1604
+
1605
+ const name = tag.split(/[\s/>]/)[0].toLowerCase();
1606
+ if (skipping !== null) continue;
1607
+ if (OPAQUE.has(name)) {
1608
+ skipping = name;
1609
+ continue;
1610
+ }
1611
+ if (VOID.has(name) || tag.endsWith('/')) continue;
1612
+
1613
+ stack.push({
1614
+ tag: name,
1615
+ /*
1616
+ * Either the owner's or the design's — both are decisions, and what is
1617
+ * left is the failure: a sentence nobody has claimed.
1618
+ *
1619
+ * The `|$` is not a flourish. `tag` is held here with its brackets already
1620
+ * stripped, so a boolean attribute written last — which is where a person
1621
+ * naturally writes it, `<a class="skip" href="#main" data-jtk-fixed>` — has
1622
+ * nothing after it to match. Without the alternative the mark was silently
1623
+ * ignored and the sentence reported as belonging to nobody. It passed on
1624
+ * the two sites it was tried on only because Astro appends its own
1625
+ * `data-astro-cid-…` to elements in a component that has styles; the first
1626
+ * element without one failed, and was worked around locally rather than
1627
+ * reported as a bug for a day.
1628
+ *
1629
+ * The lookahead itself stays, so `data-jtk-fixedly` is still not a mark.
1630
+ */
1631
+ annotated: /\sdata-jtk-path=/.test(tag) || /\sdata-jtk-fixed(?=[\s/>=]|$)/.test(tag),
1632
+ });
1633
+ }
1634
+
1635
+ return found;
1636
+ }
1637
+
1638
+ /** Every built page, as files. `pages` above is the content, not the build. */
1639
+ function builtPages(dir) {
1640
+ const out = [];
1641
+ for (const name of readdirSync(dir)) {
1642
+ const full = join(dir, name);
1643
+ if (statSync(full).isDirectory()) out.push(...builtPages(full));
1644
+ else if (full.endsWith('.html')) out.push(full);
1645
+ }
1646
+ return out;
1647
+ }
1648
+
1649
+ const loose = [];
1650
+ for (const file of builtPages(dist)) {
1651
+ for (const one of unowned(readFileSync(file, 'utf8'))) {
1652
+ if (!loose.some((seen) => seen.words === one.words)) loose.push(one);
1653
+ }
1654
+ }
1655
+
1656
+ if (loose.length > 0) {
1657
+ console.error(`\njtk catalogue: ${loose.length} sentence(s) on the page belong to nobody\n`);
1658
+ for (const one of loose.slice(0, 30)) {
1659
+ const words = one.words.length > 70 ? one.words.slice(0, 67) + '…' : one.words;
1660
+ console.error(` ✗ <${one.where}> ${words}`);
1661
+ }
1662
+ if (loose.length > 30) console.error(` … and ${loose.length - 30} more`);
1663
+
1664
+ console.error(`
1665
+ Each one is either the owner's or the design's, and the repository has to say
1666
+ which. A wordmark, a navigation label, a footer note, the labels on a form and
1667
+ the word above a section are the owner's: declare them — in the shared document
1668
+ where they are on every page — and annotate them with data-jtk-path. A skip link,
1669
+ a honeypot's label and a caption that is part of the composition are not: mark
1670
+ them data-jtk-fixed, which silences everything inside the element it is on.
1671
+
1672
+ Undeclared is not a third answer. It is what "I cannot edit the header" is made
1673
+ of, and it arrives as a message and a deploy weeks later. See
1674
+ references/catalogue.md.`);
1675
+ process.exit(Math.min(loose.length, 100));
1676
+ }
1677
+
1678
+ console.log('every sentence on the page belongs to somebody');