@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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/jtk.mjs +41 -0
- package/docs/booking.md +164 -0
- package/docs/catalogue.md +459 -0
- package/docs/collections.md +249 -0
- package/docs/css.md +86 -0
- package/docs/gallery.md +127 -0
- package/docs/hero-motion.md +189 -0
- package/docs/kit.md +454 -0
- package/docs/languages.md +182 -0
- package/docs/lead-form.md +109 -0
- package/docs/pages.md +193 -0
- package/docs/photos.md +314 -0
- package/docs/scaffold.md +75 -0
- package/docs/shapes.md +140 -0
- package/docs/surface.md +187 -0
- package/lib/catalogue.mjs +1678 -0
- package/lib/codes.mjs +171 -0
- package/lib/create.mjs +282 -0
- package/package.json +16 -0
- package/template/astro.config.mjs +84 -0
- package/template/figures.mjs +122 -0
- package/template/gitignore +16 -0
- package/template/jtakeit-meta.mjs +112 -0
- package/template/jtk/content/index.json +38 -0
- package/template/jtk/design.json +24 -0
- package/template/markdown.mjs +36 -0
- package/template/package-lock.json +5320 -0
- package/template/package.json +26 -0
- package/template/specimens.mjs +46 -0
- package/template/src/components/Blocks.astro +151 -0
- package/template/src/components/BookingForm.astro +506 -0
- package/template/src/components/Clip.astro +155 -0
- package/template/src/components/Hero.astro +66 -0
- package/template/src/components/LeadForm.astro +347 -0
- package/template/src/components/OpeningHours.astro +69 -0
- package/template/src/components/Pile.astro +185 -0
- package/template/src/components/Shot.astro +472 -0
- package/template/src/components/gallery/Gallery.astro +381 -0
- package/template/src/components/gallery/galleries.ts +139 -0
- package/template/src/components/motion/HeroField.astro +520 -0
- package/template/src/components/motion/fields.ts +430 -0
- package/template/src/components/surface/Pattern.astro +278 -0
- package/template/src/components/surface/patterns.ts +187 -0
- package/template/src/content/blocks.ts +758 -0
- package/template/src/content.config.ts +19 -0
- package/template/src/copy/LOCALE.ts +324 -0
- package/template/src/data/site.ts +137 -0
- package/template/src/layouts/Layout.astro +282 -0
- package/template/src/lib/alive.ts +49 -0
- package/template/src/lib/entries.ts +106 -0
- package/template/src/lib/entryLoader.ts +315 -0
- package/template/src/lib/noise.ts +26 -0
- package/template/src/lib/page.ts +287 -0
- package/template/src/lib/photos.ts +168 -0
- package/template/src/lib/under.ts +32 -0
- package/template/src/lib/uploads.ts +85 -0
- package/template/src/pages/[...entry].astro +207 -0
- package/template/src/pages/[...feed].xml.ts +64 -0
- package/template/src/pages/index.astro +90 -0
- package/template/src/pages/llms.txt.ts +50 -0
- package/template/src/pages/privacy.astro +59 -0
- package/template/src/pages/robots.txt.ts +21 -0
- package/template/src/pages/sitemap.xml.ts +50 -0
- package/template/src/styles/global.css +411 -0
- package/template/src/styles/surface.css +375 -0
- 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');
|