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