@hollware/core 0.0.2 → 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/dist/index.mjs CHANGED
@@ -1,4 +1,3309 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import * as z from "zod";
3
+ //#region src/config/define.ts
4
+ /**
5
+ * The `define*` helpers only give the config its types: the frontmatter type of a collection
6
+ * flows from its schema into its contracts. Validating the config is the job of the config
7
+ * module, when the Worker starts.
8
+ */
9
+ /** Declares the config of a deployment: what `hollware.config.ts` exports. */
10
+ function defineConfig(config) {
11
+ return config;
12
+ }
13
+ /** Declares a collection. Its contracts see the frontmatter that its schema returns. */
14
+ function defineCollection(collection) {
15
+ return collection;
16
+ }
17
+ /** Declares a component that entries may use in their body. */
18
+ function defineComponent(component) {
19
+ return component;
20
+ }
21
+ /**
22
+ * Declares a contract shared by several collections, with the frontmatter type it reads given
23
+ * explicitly: `defineContract<{ title: string }>({ … })`. Inside `defineCollection`, write a
24
+ * contract as a plain object instead: its `check` gets the frontmatter type of the schema there,
25
+ * which a generic call inside the array would lose.
26
+ */
27
+ function defineContract(contract) {
28
+ return contract;
29
+ }
30
+ //#endregion
31
+ //#region src/config/config-error.ts
32
+ /**
33
+ * The error `resolveConfig` throws: every problem of the config at once, so that a broken
34
+ * `hollware.config.ts` stops the Worker at startup with a message that says what to change. The
35
+ * message has one line per problem, `<path>: <message>. <fix>`, and `problems` holds them as data.
36
+ */
37
+ var ConfigError = class extends Error {
38
+ name = "ConfigError";
39
+ problems;
40
+ constructor(problems) {
41
+ super(problems.map((problem) => `${problem.path}: ${problem.message}. ${problem.fix}`).join("\n"));
42
+ this.problems = problems;
43
+ }
44
+ };
45
+ /** The problems found so far, in the order they were found. */
46
+ var Problems = class {
47
+ list = [];
48
+ add(path, message, fix) {
49
+ this.list.push({
50
+ path,
51
+ message,
52
+ fix
53
+ });
54
+ }
55
+ };
56
+ //#endregion
57
+ //#region src/config/constants.ts
58
+ /** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
59
+ const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
60
+ /**
61
+ * The largest `maxMarkdownBytes` a collection may set, 768 KiB. A revision row holds the Markdown,
62
+ * the frontmatter and the Comark document, which is about 1.3 times the Markdown, so a row is
63
+ * about 2.3 times the file, and the store refuses rows over 1.9 MB: a file over about 800 KB could
64
+ * never be saved. 768 KiB keeps a margin.
65
+ */
66
+ const MAX_MARKDOWN_BYTES_CEILING = 786432;
67
+ /**
68
+ * A collection name or a component name: lowercase letters, digits and hyphens, starting with a
69
+ * letter, at most 63 characters. A component name is what Markdown writes after the colons
70
+ * (`::callout`). A site key has its own rule (`site-key.ts`).
71
+ */
72
+ const NAME = /^[a-z][a-z0-9-]{0,62}$/;
73
+ /** A language tag: a language, then an optional script and region: `es`, `pt-BR`, `zh-Hant-TW`. */
74
+ const LOCALE_TAG = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
75
+ /** The id of a contract: lowercase words joined by single hyphens. */
76
+ const CONTRACT_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
77
+ /** The fix of a name that breaks `NAME`, with an example of the right kind. */
78
+ function nameFix(example) {
79
+ return `Use lowercase letters, digits and hyphens, starting with a letter, at most 63 characters, such as "${example}".`;
80
+ }
81
+ //#endregion
82
+ //#region src/contracts/registry.ts
83
+ /**
84
+ * The contracts that Hollware makes itself, as opposed to the ones a site writes. The runner
85
+ * needs to know one thing about them that a `Contract` does not say: whether the rule reads the
86
+ * frontmatter, because a rule that does only runs once the schema has accepted it.
87
+ */
88
+ const BUILT_INS = /* @__PURE__ */ new WeakMap();
89
+ /** Marks a contract as one of Hollware's own and returns it. */
90
+ function registerBuiltIn(contract, options) {
91
+ BUILT_INS.set(contract, options);
92
+ return contract;
93
+ }
94
+ /**
95
+ * Whether a contract was made by Hollware: the always-on ones and what the factories of
96
+ * `contracts` return. A site's own contract with the id of a built-in one is not.
97
+ */
98
+ function isBuiltInContract(contract) {
99
+ return BUILT_INS.has(contract);
100
+ }
101
+ /** Whether a contract only runs once the schema has accepted the frontmatter. */
102
+ function readsFrontmatter(contract) {
103
+ return BUILT_INS.get(contract)?.readsFrontmatter ?? true;
104
+ }
105
+ //#endregion
106
+ //#region src/contracts/runtime.ts
107
+ const RUNTIMES = /* @__PURE__ */ new WeakMap();
108
+ /** Records the runtime of the context a run hands to its contracts. */
109
+ function bindRuntime(context, runtime) {
110
+ RUNTIMES.set(context, runtime);
111
+ }
112
+ /** The runtime of a context. A built-in contract called by hand, with a context of its own, throws. */
113
+ function runtimeOf(contractId, context) {
114
+ const runtime = RUNTIMES.get(context);
115
+ if (runtime === void 0) throw new Error(`The built-in contract ${contractId} runs only through runContracts.`);
116
+ return runtime;
117
+ }
118
+ //#endregion
119
+ //#region src/contracts/text.ts
120
+ /**
121
+ * Helpers for the texts of violations. A message can repeat something the entry holds, such as a
122
+ * URL or a tag name. A verdict is stored with every revision and sent to the agent within an
123
+ * output cap, so whatever a text repeats from the entry is clipped and escaped.
124
+ */
125
+ /** The most characters of the entry's own text that a message repeats. */
126
+ const MAX_ECHO_LENGTH = 80;
127
+ function isRecord$2(value) {
128
+ return typeof value === "object" && value !== null && !Array.isArray(value);
129
+ }
130
+ /** Orders two texts by UTF-16 code units: the same in every runtime, unlike `localeCompare`. */
131
+ function compareText(left, right) {
132
+ return left < right ? -1 : left > right ? 1 : 0;
133
+ }
134
+ /** `text` cut to `max` characters, never inside a surrogate pair, with an ellipsis. */
135
+ function clip(text, max = MAX_ECHO_LENGTH) {
136
+ if (text.length <= max) return text;
137
+ const head = text.slice(0, max);
138
+ const last = head.charCodeAt(max - 1);
139
+ return `${last >= 55296 && last <= 56319 ? head.slice(0, -1) : head}…`;
140
+ }
141
+ /** Text from the entry as a message shows it: clipped, in JSON quotes, control characters escaped. */
142
+ function quote$1(text) {
143
+ return JSON.stringify(clip(text));
144
+ }
145
+ /** A JSON Pointer (RFC 6901) from its segments: `['tags', '0']` is `/tags/0`, none is the root. */
146
+ function pointer(segments) {
147
+ return segments.map((segment) => `/${segment.replaceAll("~", "~0").replaceAll("/", "~1")}`).join("");
148
+ }
149
+ /** `1 character`, `2 characters`. */
150
+ function count(amount, noun) {
151
+ return `${amount} ${noun}${amount === 1 ? "" : "s"}`;
152
+ }
153
+ //#endregion
154
+ //#region src/contracts/schema-fix.ts
155
+ /**
156
+ * The fix of a schema violation. A Standard Schema issue says where (`path`) and what (`message`),
157
+ * and nothing else is the same across libraries, so the fix comes from the JSON Schema of the same
158
+ * schema: it names the field and what the schema accepts there ("a string of 1 to 160
159
+ * characters", "one of "info", "tip""), which is what an agent needs to write the next attempt.
160
+ * Where the JSON Schema cannot say, the fix falls back to the library's own message.
161
+ */
162
+ /** The most values of an enum that a fix lists. */
163
+ const MAX_LISTED_VALUES = 20;
164
+ /** The path of an issue as plain strings; a number or a symbol becomes its text. */
165
+ function issueSegments(issue) {
166
+ return (issue.path ?? []).map((segment) => String(typeof segment === "object" ? segment.key : segment));
167
+ }
168
+ /** A path as a person writes it: `author.links[2]`. */
169
+ function label(segments) {
170
+ return segments.reduce((text, segment) => {
171
+ if (/^\d+$/.test(segment)) return `${text}[${segment}]`;
172
+ return text === "" ? segment : `${text}.${segment}`;
173
+ }, "");
174
+ }
175
+ /** The part of a JSON Schema that describes the value at `segments`, when the schema says. */
176
+ function fragmentAt(schema, segments) {
177
+ let node = schema;
178
+ for (const segment of segments) {
179
+ const properties = node.properties;
180
+ if (isRecord$2(properties) && Object.hasOwn(properties, segment)) {
181
+ const next = properties[segment];
182
+ if (!isRecord$2(next)) return void 0;
183
+ node = next;
184
+ } else if (/^\d+$/.test(segment) && isRecord$2(node.items)) node = node.items;
185
+ else return;
186
+ }
187
+ return node;
188
+ }
189
+ function valueAt(input, segments) {
190
+ let node = input;
191
+ for (const segment of segments) if (Array.isArray(node) && /^\d+$/.test(segment)) node = node[Number(segment)];
192
+ else if (isRecord$2(node) && Object.hasOwn(node, segment)) node = node[segment];
193
+ else return void 0;
194
+ return node;
195
+ }
196
+ function listValues(values) {
197
+ const shown = values.slice(0, MAX_LISTED_VALUES).map((value) => JSON.stringify(value)).join(", ");
198
+ const more = values.length - MAX_LISTED_VALUES;
199
+ return more > 0 ? `${shown} and ${more} more` : shown;
200
+ }
201
+ /** `1 to 160 characters`, `at least 1 item`, `at most 3 items`, or nothing. */
202
+ function range(min, max, noun) {
203
+ const low = typeof min === "number" ? min : void 0;
204
+ const high = typeof max === "number" ? max : void 0;
205
+ if (low !== void 0 && high !== void 0) return low === high ? `exactly ${count(low, noun)}` : `${low} to ${count(high, noun)}`;
206
+ if (low !== void 0) return `at least ${count(low, noun)}`;
207
+ if (high !== void 0) return `at most ${count(high, noun)}`;
208
+ }
209
+ function stringExpectation(schema) {
210
+ const length = range(schema.minLength, schema.maxLength, "character");
211
+ let shape = "";
212
+ if (typeof schema.format === "string") shape = ` in the format ${JSON.stringify(schema.format)}`;
213
+ else if (typeof schema.pattern === "string") shape = ` matching the pattern ${schema.pattern}`;
214
+ return `a string${length === void 0 ? "" : ` of ${length}`}${shape}`;
215
+ }
216
+ function numberExpectation(type, schema) {
217
+ const bounds = [
218
+ typeof schema.minimum === "number" ? `at least ${schema.minimum}` : void 0,
219
+ typeof schema.exclusiveMinimum === "number" ? `greater than ${schema.exclusiveMinimum}` : void 0,
220
+ typeof schema.maximum === "number" ? `at most ${schema.maximum}` : void 0,
221
+ typeof schema.exclusiveMaximum === "number" ? `less than ${schema.exclusiveMaximum}` : void 0
222
+ ].filter((bound) => bound !== void 0);
223
+ const base = type === "integer" ? "an integer" : "a number";
224
+ return bounds.length === 0 ? base : `${base} that is ${bounds.join(" and ")}`;
225
+ }
226
+ function arrayExpectation(schema) {
227
+ const size = range(schema.minItems, schema.maxItems, "item");
228
+ const items = isRecord$2(schema.items) ? expectation(schema.items) : void 0;
229
+ return `an array${size === void 0 ? "" : ` of ${size}`}${items === void 0 ? "" : `, each ${items}`}`;
230
+ }
231
+ function objectExpectation(schema) {
232
+ const keys = isRecord$2(schema.properties) ? Object.keys(schema.properties) : [];
233
+ return keys.length === 0 ? "an object" : `an object with the keys ${keys.join(", ")}`;
234
+ }
235
+ function typeExpectation(type, schema) {
236
+ switch (type) {
237
+ case "string": return stringExpectation(schema);
238
+ case "integer":
239
+ case "number": return numberExpectation(type, schema);
240
+ case "boolean": return "true or false";
241
+ case "null": return "null";
242
+ case "array": return arrayExpectation(schema);
243
+ case "object": return objectExpectation(schema);
244
+ default: return;
245
+ }
246
+ }
247
+ /**
248
+ * What a JSON Schema accepts, as the end of a sentence ("a string of at most 160 characters"), or
249
+ * `undefined` when it uses something this does not put into words, such as `$ref` or `allOf`.
250
+ */
251
+ function expectation(schema) {
252
+ if ("const" in schema) return `exactly ${JSON.stringify(schema.const)}`;
253
+ if (Array.isArray(schema.enum)) return `one of ${listValues(schema.enum)}`;
254
+ const alternatives = schema.anyOf ?? schema.oneOf;
255
+ const parts = Array.isArray(alternatives) ? alternatives.map((option) => isRecord$2(option) ? expectation(option) : void 0) : (Array.isArray(schema.type) ? schema.type : [schema.type]).map((type) => typeExpectation(type, schema));
256
+ return parts.every((part) => part !== void 0) ? parts.join(" or ") : void 0;
257
+ }
258
+ /** The keys of `value` that an object schema closed to other keys does not list. */
259
+ function unknownKeys(fragment, value) {
260
+ const { additionalProperties, properties } = fragment;
261
+ if (additionalProperties !== false || !isRecord$2(properties) || !isRecord$2(value)) return [];
262
+ return Object.keys(value).filter((key) => !Object.hasOwn(properties, key));
263
+ }
264
+ /** The fix for one issue: imperative, naming the field and what the schema accepts there. */
265
+ function fixForIssue({ subject, schema, input, segments, message }) {
266
+ const target = segments.length === 0 ? subject.container : `the ${subject.noun} ${quote$1(label(segments))}`;
267
+ const fragment = schema === null ? void 0 : fragmentAt(schema, segments);
268
+ if (fragment !== void 0) {
269
+ const unknown = unknownKeys(fragment, valueAt(input, segments));
270
+ if (unknown.length > 0) {
271
+ const allowed = isRecord$2(fragment.properties) ? Object.keys(fragment.properties) : [];
272
+ const nouns = unknown.length === 1 ? subject.noun : `${subject.noun}s`;
273
+ const where = allowed.length === 0 ? `no ${subject.noun}s are allowed there` : `the ${subject.noun}s allowed there are: ${allowed.join(", ")}`;
274
+ return `Remove the ${nouns} ${unknown.map((key) => quote$1(key)).join(", ")} from ${target}; ${where}.`;
275
+ }
276
+ }
277
+ const expected = fragment === void 0 ? void 0 : expectation(fragment);
278
+ return expected === void 0 ? `Change ${target} to a value that the schema of ${subject.container} accepts: ${message}` : `Set ${target} to ${expected}.`;
279
+ }
280
+ //#endregion
281
+ //#region src/contracts/schema.ts
282
+ const SNAPSHOTS = /* @__PURE__ */ new WeakMap();
283
+ function toJsonSchema$1(schema) {
284
+ try {
285
+ return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" });
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+ function schemaSnapshot(collection) {
291
+ let snapshot = SNAPSHOTS.get(collection);
292
+ if (snapshot === void 0) {
293
+ snapshot = {
294
+ frontmatter: toJsonSchema$1(collection.schema),
295
+ components: new Map([...collection.components].map(([name, component]) => [name, toJsonSchema$1(component.props)]))
296
+ };
297
+ SNAPSHOTS.set(collection, snapshot);
298
+ }
299
+ return snapshot;
300
+ }
301
+ const FRONTMATTER = {
302
+ container: "the frontmatter",
303
+ noun: "field"
304
+ };
305
+ /**
306
+ * Validates the frontmatter with the collection's schema. When it passes, `value` is what the
307
+ * schema returned, which is what the collection's own contracts get. Each issue becomes a finding
308
+ * at its JSON Pointer under `/frontmatter`. It throws when the schema does.
309
+ */
310
+ async function checkFrontmatter(collection, frontmatter) {
311
+ const result = await collection.schema["~standard"].validate(frontmatter);
312
+ if (!result.issues) return {
313
+ passed: true,
314
+ value: result.value
315
+ };
316
+ const json = schemaSnapshot(collection).frontmatter;
317
+ return {
318
+ passed: false,
319
+ findings: result.issues.map((issue) => {
320
+ const segments = issueSegments(issue);
321
+ return {
322
+ path: `/frontmatter${pointer(segments)}`,
323
+ message: issue.message,
324
+ fix: fixForIssue({
325
+ subject: FRONTMATTER,
326
+ schema: json,
327
+ input: frontmatter,
328
+ segments,
329
+ message: issue.message
330
+ })
331
+ };
332
+ })
333
+ };
334
+ }
335
+ //#endregion
336
+ //#region src/contracts/components.ts
337
+ /**
338
+ * `components`: the props of every component the body uses, checked with the schema of that
339
+ * component. A component the collection does not allow is `unknown-component`'s business: the
340
+ * parser leaves a marker for it instead of a use.
341
+ */
342
+ const COMPONENTS = registerBuiltIn({
343
+ id: "components",
344
+ severity: "error",
345
+ description: "The props of every component in the body match the schema of that component.",
346
+ async check(context) {
347
+ const { collection } = runtimeOf("components", context);
348
+ const schemas = schemaSnapshot(collection).components;
349
+ const findings = [];
350
+ for (const use of context.markdown.extracted.components) {
351
+ const definition = collection.components.get(use.name);
352
+ if (definition === void 0) continue;
353
+ const result = await definition.props["~standard"].validate(use.props);
354
+ if (!result.issues) continue;
355
+ const subject = {
356
+ container: `the component ${quote$1(use.name)}`,
357
+ noun: "prop"
358
+ };
359
+ for (const issue of result.issues) {
360
+ const segments = issueSegments(issue);
361
+ const which = segments.length === 0 ? "The props" : `The prop ${quote$1(label(segments))}`;
362
+ findings.push({
363
+ path: "/body",
364
+ message: `${which} of the component ${quote$1(use.name)} ${segments.length === 0 ? "are" : "is"} invalid: ${issue.message}`,
365
+ fix: fixForIssue({
366
+ subject,
367
+ schema: schemas.get(use.name) ?? null,
368
+ input: use.props,
369
+ segments,
370
+ message: issue.message
371
+ }),
372
+ line: use.line
373
+ });
374
+ }
375
+ }
376
+ return findings;
377
+ }
378
+ }, { readsFrontmatter: false });
379
+ //#endregion
380
+ //#region src/contracts/description.ts
381
+ /** The phases, in the order they happen. */
382
+ const PHASES = ["write", "publish"];
383
+ /**
384
+ * The phases a contract is declared for: both when it names none, in their natural order and
385
+ * without repeats, so that the same rule always reads the same.
386
+ */
387
+ function contractPhases(contract) {
388
+ const declared = contract.phases ?? PHASES;
389
+ return PHASES.filter((phase) => declared.includes(phase));
390
+ }
391
+ /** A contract as `describe_site` shows it to agents. */
392
+ function describeContract(contract) {
393
+ return {
394
+ id: contract.id,
395
+ severity: contract.severity,
396
+ phases: contractPhases(contract),
397
+ description: contract.description
398
+ };
399
+ }
400
+ //#endregion
401
+ //#region src/contracts/safety.ts
402
+ /** The components a collection allows, sorted: what a fix offers instead. */
403
+ function allowedComponents(collection) {
404
+ return [...collection.components.keys()].toSorted();
405
+ }
406
+ function markerContract(kind, description, wording) {
407
+ return registerBuiltIn({
408
+ id: kind,
409
+ severity: "error",
410
+ description,
411
+ check(context) {
412
+ const { collection } = runtimeOf(kind, context);
413
+ return context.markdown.extracted.disallowed.filter((marker) => marker.kind === kind).map((marker) => ({
414
+ path: "/body",
415
+ ...wording(marker, collection),
416
+ line: marker.line
417
+ }));
418
+ }
419
+ }, { readsFrontmatter: false });
420
+ }
421
+ const RAW_HTML = markerContract("raw-html", "The body holds Markdown and the components of the collection, never raw HTML.", (marker, collection) => {
422
+ const components = allowedComponents(collection);
423
+ return {
424
+ message: `Raw HTML is not allowed: the body has a <${clip(marker.name)}> tag.`,
425
+ fix: `Remove the HTML and use Markdown${components.length === 0 ? "" : ` or one of the allowed components: ${components.join(", ")}`}.`
426
+ };
427
+ });
428
+ const UNKNOWN_COMPONENT = markerContract("unknown-component", "The body uses only the components that the collection declares.", (marker, collection) => {
429
+ const components = allowedComponents(collection);
430
+ return {
431
+ message: `The component ${quote$1(marker.name)} does not exist in this collection.`,
432
+ fix: components.length === 0 ? "This collection allows no components: remove it and use Markdown." : `Use one of the allowed components: ${components.join(", ")}.`
433
+ };
434
+ });
435
+ const DISALLOWED_ATTRIBUTE = markerContract("disallowed-attribute", "Links, images and code carry only the attributes Markdown produces, and a component only the props its schema declares.", (marker) => ({
436
+ message: `The attribute ${quote$1(marker.name)} is not allowed.`,
437
+ fix: `Remove the attribute ${quote$1(marker.name)}. Links keep href and title, images keep src, alt, title, width and height, and a component takes only the props its schema declares.`
438
+ }));
439
+ const UNSAFE_URL = markerContract("unsafe-url", "Links and images use an https:, http: or mailto: URL, a path of the site or an anchor.", (marker) => ({
440
+ message: `The URL ${quote$1(marker.name)} is not allowed.`,
441
+ fix: "Use an https: or http: URL, a mailto: address, a path of this site that starts with /, or an anchor that starts with #."
442
+ }));
443
+ //#endregion
444
+ //#region src/contracts/builtin.ts
445
+ /**
446
+ * Every id Hollware's own contracts use. The config module reserves them: a contract of a site
447
+ * cannot take one, except through `contracts.*`, which makes the opt-in ones.
448
+ *
449
+ * - Always on, run by `runContracts`: `schema`, `components` and the four content-safety
450
+ * contracts, `raw-html`, `unknown-component`, `disallowed-attribute` and `unsafe-url`.
451
+ * - Opt-in, listed in a collection's `contracts`: `translation-pair`, `internal-links-resolve`
452
+ * and `images-have-alt`.
453
+ * - Always on, found before the contracts run: `size-limit` (made by the pipeline for
454
+ * `maxMarkdownBytes` and by the store for the row, with `sizeLimitViolation`),
455
+ * `frontmatter-syntax` and `comark-parse` (made by the pipeline), and `unique-slug` and
456
+ * `unique-translation` (made when the store finds the slug or the translation group taken). A
457
+ * write with one of them is `refused`, so no verdict is made for it: they are
458
+ * `REFUSAL_CONTRACT_IDS`.
459
+ */
460
+ const BUILT_IN_CONTRACT_IDS = [
461
+ "schema",
462
+ "components",
463
+ "raw-html",
464
+ "unknown-component",
465
+ "disallowed-attribute",
466
+ "unsafe-url",
467
+ "translation-pair",
468
+ "internal-links-resolve",
469
+ "images-have-alt",
470
+ "size-limit",
471
+ "frontmatter-syntax",
472
+ "comark-parse",
473
+ "unique-slug",
474
+ "unique-translation"
475
+ ];
476
+ /**
477
+ * The ids of the violations that make a write `refused` instead of saving it: a file that cannot
478
+ * be stored, because it is over a size limit, its frontmatter is not YAML or its Markdown does not
479
+ * parse, or whose slug or translation group another entry holds. Every other violation is saved with the draft and only blocks publishing (ADR-004,
480
+ * ADR-020). Plain strings, so that a violation's `contract` can be looked up in the list.
481
+ */
482
+ const REFUSAL_CONTRACT_IDS = [
483
+ "size-limit",
484
+ "frontmatter-syntax",
485
+ "comark-parse",
486
+ "unique-slug",
487
+ "unique-translation"
488
+ ];
489
+ /** The built-in contract that the schema of the collection stands for. It runs before the others. */
490
+ const SCHEMA_CONTRACT = {
491
+ id: "schema",
492
+ severity: "error",
493
+ description: "The frontmatter matches the schema of the collection, which describe_site shows as JSON Schema."
494
+ };
495
+ /** The always-on contracts after `schema`, in the order they run. */
496
+ const ALWAYS_ON_CONTRACTS = [
497
+ COMPONENTS,
498
+ RAW_HTML,
499
+ UNKNOWN_COMPONENT,
500
+ DISALLOWED_ATTRIBUTE,
501
+ UNSAFE_URL
502
+ ];
503
+ /** The always-on contracts that are checked before any of these runs. */
504
+ const PIPELINE_CONTRACTS = [
505
+ {
506
+ id: "size-limit",
507
+ severity: "error",
508
+ description: "The whole Markdown file stays within the size limit of the collection."
509
+ },
510
+ {
511
+ id: "frontmatter-syntax",
512
+ severity: "error",
513
+ description: "The frontmatter is valid YAML between two --- lines at the top of the file."
514
+ },
515
+ {
516
+ id: "comark-parse",
517
+ severity: "error",
518
+ description: "The Markdown parses."
519
+ },
520
+ {
521
+ id: "unique-slug",
522
+ severity: "error",
523
+ description: "No other entry of the collection and locale has the slug."
524
+ },
525
+ {
526
+ id: "unique-translation",
527
+ severity: "error",
528
+ description: "No other entry of the collection and locale belongs to the translation group."
529
+ }
530
+ ];
531
+ /**
532
+ * The always-on contracts as `describe_site` shows them, in `BUILT_IN_CONTRACT_IDS` order. The
533
+ * opt-in ones are not here: a collection that uses one lists it in its own `contracts`.
534
+ */
535
+ function describeBuiltInContracts() {
536
+ return [
537
+ SCHEMA_CONTRACT,
538
+ ...ALWAYS_ON_CONTRACTS,
539
+ ...PIPELINE_CONTRACTS
540
+ ].map((contract) => describeContract(contract));
541
+ }
542
+ //#endregion
543
+ //#region src/contracts/opt-in.ts
544
+ /**
545
+ * Every locale of the site has an entry in the translation group of this one. It reads the group
546
+ * from the frontmatter field the collection names in `translations.groupBy`, and counts an entry
547
+ * in any status, so the pair can be published one after the other. Checked when publishing, and
548
+ * skipped when the collection has no translations.
549
+ */
550
+ function translationPair(options = {}) {
551
+ return registerBuiltIn({
552
+ id: "translation-pair",
553
+ severity: options.severity ?? "error",
554
+ phases: ["publish"],
555
+ description: "Every locale of the site has an entry in the translation group of this entry. Checked when publishing.",
556
+ async check(context) {
557
+ const { site, collection } = runtimeOf("translation-pair", context);
558
+ const field = collection.translationGroupField;
559
+ if (field === null) return [];
560
+ const path = `/frontmatter${pointer([field])}`;
561
+ const group = isRecord$2(context.frontmatter) ? context.frontmatter[field] : void 0;
562
+ if (typeof group !== "string" || group === "") return [{
563
+ path,
564
+ message: `The entry has no translation group: its frontmatter field ${field} is empty.`,
565
+ fix: `Set ${field} in the frontmatter to the key that all the translations of this entry share, then create the entries for the locales of the site: ${site.locales.join(", ")}.`
566
+ }];
567
+ const found = await context.entries.translations(collection.name, group);
568
+ const present = /* @__PURE__ */ new Set([context.locale, ...found.map((entry) => entry.locale)]);
569
+ return site.locales.filter((locale) => !present.has(locale)).map((locale) => ({
570
+ path,
571
+ message: `The ${locale} version of the translation group ${quote$1(group)} does not exist.`,
572
+ fix: `Create the entry in ${locale} with ${field}: ${quote$1(group)} in its frontmatter before publishing.`
573
+ }));
574
+ }
575
+ }, { readsFrontmatter: true });
576
+ }
577
+ /**
578
+ * Every link to another entry of the site points at an entry that is published, or at the entry
579
+ * itself. All the targets are looked up in one call to `lookupMany`, and none when the body has no
580
+ * link to another entry. Checked when publishing.
581
+ */
582
+ function internalLinksResolve(options = {}) {
583
+ return registerBuiltIn({
584
+ id: "internal-links-resolve",
585
+ severity: options.severity ?? "error",
586
+ phases: ["publish"],
587
+ description: "Every link to another entry of the site points at a published entry. Checked when publishing.",
588
+ async check(context) {
589
+ const links = context.markdown.extracted.links.flatMap((link) => link.kind === "internal" && link.target !== void 0 && !isThisEntry(link.target, context) ? [{
590
+ link,
591
+ target: link.target
592
+ }] : []);
593
+ if (links.length === 0) return [];
594
+ const positions = /* @__PURE__ */ new Map();
595
+ const refs = [];
596
+ for (const { target } of links) {
597
+ const key = keyOf(target);
598
+ if (positions.has(key)) continue;
599
+ positions.set(key, refs.length);
600
+ refs.push({
601
+ collection: target.collection,
602
+ locale: target.locale,
603
+ slug: target.slug
604
+ });
605
+ }
606
+ const facts = await context.entries.lookupMany(refs);
607
+ const findings = [];
608
+ for (const { link, target } of links) {
609
+ const position = positions.get(keyOf(target));
610
+ const found = (position === void 0 ? void 0 : facts[position]) ?? null;
611
+ if (found?.status === "published") continue;
612
+ const entry = `the entry ${clip(`${target.collection}/${target.locale}/${target.slug}`)}`;
613
+ findings.push({
614
+ path: "/body",
615
+ message: found === null ? `The link ${quote$1(link.href)} points at ${entry}, which does not exist.` : `The link ${quote$1(link.href)} points at ${entry}, which is not published yet (${found.status}).`,
616
+ fix: found === null ? `Create and publish ${entry}, point the link at an entry that is published, or remove the link.` : `Publish ${entry} first, point the link at an entry that is published, or remove the link.`,
617
+ line: link.line
618
+ });
619
+ }
620
+ return findings;
621
+ }
622
+ }, { readsFrontmatter: false });
623
+ }
624
+ /** A key that tells two targets apart, whatever characters their names hold. */
625
+ function keyOf(target) {
626
+ return JSON.stringify([
627
+ target.collection,
628
+ target.locale,
629
+ target.slug
630
+ ]);
631
+ }
632
+ function isThisEntry(target, context) {
633
+ return target.collection === context.collection && target.locale === context.locale && target.slug === context.slug;
634
+ }
635
+ /** Every image has alt text. An alt of only spaces counts as none. Checked on every write. */
636
+ function imagesHaveAlt(options = {}) {
637
+ return registerBuiltIn({
638
+ id: "images-have-alt",
639
+ severity: options.severity ?? "error",
640
+ phases: ["write", "publish"],
641
+ description: "Every image has alt text that describes it.",
642
+ check: (context) => context.markdown.extracted.images.filter((image) => image.alt.trim() === "").map((image) => ({
643
+ path: "/body",
644
+ message: `The image ${quote$1(image.src)} has no alt text.`,
645
+ fix: `Add alt text that describes the image, as in ![a short description](${clip(image.src)}).`,
646
+ line: image.line
647
+ }))
648
+ }, { readsFrontmatter: false });
649
+ }
650
+ /**
651
+ * A contract of a site made of a yes or no question and the words to say when the answer is no.
652
+ * One finding at most: a rule that reports several problems, or the line they are on, is a plain
653
+ * `Contract` object, which can return as many findings as it wants.
654
+ *
655
+ * Inside `defineCollection`, TypeScript cannot infer the frontmatter type of a call like this one
656
+ * (it can for a plain object): give it, as in `contracts.custom<{ title: string }>({ … })`.
657
+ */
658
+ function custom(options) {
659
+ const text = (value, context) => typeof value === "function" ? value(context) : value;
660
+ return {
661
+ id: options.id,
662
+ severity: options.severity,
663
+ ...options.phases === void 0 ? {} : { phases: options.phases },
664
+ description: options.description,
665
+ async check(context) {
666
+ if (await options.check(context)) return [];
667
+ return [{
668
+ path: options.path ?? "",
669
+ message: text(options.message, context),
670
+ fix: text(options.fix, context)
671
+ }];
672
+ }
673
+ };
674
+ }
675
+ const contracts = {
676
+ custom,
677
+ imagesHaveAlt,
678
+ internalLinksResolve,
679
+ translationPair
680
+ };
681
+ //#endregion
682
+ //#region src/contracts/hash.ts
683
+ /**
684
+ * `contractsHash` (ADR-004): what a revision was checked against, in a short string. A stored
685
+ * verdict says which rules it answered to, and a changed rule shows as another hash.
686
+ */
687
+ /**
688
+ * JSON with the keys of every object in order and no spaces, so that equal values always give
689
+ * the same text. Arrays keep their order, which means something there. An `undefined` member is
690
+ * left out, as `JSON.stringify` does.
691
+ */
692
+ function canonicalJson(value) {
693
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
694
+ if (typeof value === "object" && value !== null) return `{${Object.entries(value).filter(([, member]) => member !== void 0).toSorted(([left], [right]) => compareText(left, right)).map(([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`).join(",")}}`;
695
+ return JSON.stringify(value) ?? "null";
696
+ }
697
+ /** The SHA-256 of a text, in lowercase hex. */
698
+ async function sha256Hex(text) {
699
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
700
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
701
+ }
702
+ /**
703
+ * The hash of the rules of a run: the input JSON Schema of the frontmatter and of each
704
+ * component's props (`null` for one that does not convert), and the id, severity and phases of
705
+ * every contract that was to run. Contracts go by id, so the order of the config does not matter,
706
+ * and their descriptions are left out, because wording is no rule.
707
+ */
708
+ function hashContracts(snapshot, contracts) {
709
+ return sha256Hex(canonicalJson({
710
+ components: Object.fromEntries(snapshot.components),
711
+ contracts: contracts.toSorted((left, right) => compareText(left.id, right.id)).map(({ id, phases, severity }) => ({
712
+ id,
713
+ phases,
714
+ severity
715
+ })),
716
+ frontmatter: snapshot.frontmatter
717
+ }));
718
+ }
719
+ //#endregion
720
+ //#region src/contracts/run.ts
721
+ /** The contracts after `schema` that a run has to run: the always-on ones, then the collection's own. */
722
+ function contractsFor(collection, phase) {
723
+ const all = [...ALWAYS_ON_CONTRACTS, ...collection.contracts];
724
+ return phase === "publish" ? all : all.filter((contract) => contractPhases(contract).includes("write"));
725
+ }
726
+ /** Findings by line, then by path; one without a line comes first. The sort is stable. */
727
+ function compareFindings(left, right) {
728
+ return (left.line ?? 0) - (right.line ?? 0) || compareText(left.path, right.path);
729
+ }
730
+ /**
731
+ * The findings of one contract as violations of it, in order. A finding whose fix is empty, which
732
+ * the type cannot rule out, gets the description of the rule instead, so that no violation ever
733
+ * reaches an agent without something to do.
734
+ */
735
+ function toViolations(contract, findings) {
736
+ return findings.toSorted(compareFindings).map((finding) => ({
737
+ contract: contract.id,
738
+ severity: contract.severity,
739
+ path: finding.path,
740
+ message: finding.message,
741
+ fix: finding.fix.trim() === "" ? `Change the entry so that the rule ${quote$1(contract.id)} passes: ${contract.description}` : finding.fix,
742
+ ...finding.line === void 0 ? {} : { line: finding.line }
743
+ }));
744
+ }
745
+ function describeError(error) {
746
+ try {
747
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
748
+ } catch {
749
+ return "an error that cannot be described";
750
+ }
751
+ }
752
+ /** What a contract that throws becomes: an error of that contract, and a fix for the maintainer. */
753
+ function thrown(contract, error) {
754
+ return {
755
+ contract: contract.id,
756
+ severity: "error",
757
+ path: "",
758
+ message: `The contract ${quote$1(contract.id)} failed with an error while it checked this entry: ${clip(describeError(error), 200)}`,
759
+ fix: `This is a problem in the config of the site, not in the entry: report it to whoever maintains the config, naming the contract ${quote$1(contract.id)}.`
760
+ };
761
+ }
762
+ async function settle(contract, context) {
763
+ try {
764
+ return toViolations(contract, await contract.check(context));
765
+ } catch (error) {
766
+ return [thrown(contract, error)];
767
+ }
768
+ }
769
+ /** The schema's verdict on the frontmatter: whether it passed, what it returned and what it found. */
770
+ async function validateFrontmatter(collection, markdown) {
771
+ try {
772
+ const check = await checkFrontmatter(collection, markdown.frontmatter);
773
+ return check.passed ? {
774
+ passed: true,
775
+ value: check.value,
776
+ violations: []
777
+ } : {
778
+ passed: false,
779
+ value: void 0,
780
+ violations: toViolations(SCHEMA_CONTRACT, check.findings)
781
+ };
782
+ } catch (error) {
783
+ return {
784
+ passed: false,
785
+ value: void 0,
786
+ violations: [thrown(SCHEMA_CONTRACT, error)]
787
+ };
788
+ }
789
+ }
790
+ /**
791
+ * Runs the contracts of a collection on one revision (ADR-004).
792
+ *
793
+ * The `schema` contract goes first. The collection's own contracts, which read the frontmatter,
794
+ * run only when it passed, and get what the schema returned; the built-in ones that do not need
795
+ * it run anyway, so that one verdict lists as much as it can; the context of those has no
796
+ * frontmatter (`undefined`) when the schema refused it. A contract that throws does not break
797
+ * the run: it becomes an error of that contract.
798
+ *
799
+ * The violations come in a fixed order: the built-in contracts in their own order, then the
800
+ * collection's in the order of its config, and the findings of each one by line and then by path.
801
+ */
802
+ async function runContracts(input) {
803
+ const { site, collection, phase, markdown } = input;
804
+ const contracts = contractsFor(collection, phase);
805
+ const frontmatter = await validateFrontmatter(collection, markdown);
806
+ const context = Object.freeze({
807
+ site: site.key,
808
+ collection: collection.name,
809
+ locale: input.locale,
810
+ slug: input.slug,
811
+ entryId: input.entryId,
812
+ phase,
813
+ frontmatter: frontmatter.value,
814
+ markdown,
815
+ entries: input.entries
816
+ });
817
+ bindRuntime(context, {
818
+ site,
819
+ collection
820
+ });
821
+ const violations = [...frontmatter.violations];
822
+ for (const contract of contracts) {
823
+ if (!frontmatter.passed && readsFrontmatter(contract)) continue;
824
+ violations.push(...await settle(contract, context));
825
+ }
826
+ const errors = violations.filter((violation) => violation.severity === "error");
827
+ const warnings = violations.filter((violation) => violation.severity === "warning");
828
+ return {
829
+ ok: errors.length === 0,
830
+ phase,
831
+ errors,
832
+ warnings,
833
+ contractsHash: await hashContracts(schemaSnapshot(collection), [SCHEMA_CONTRACT, ...contracts].map((contract) => describeContract(contract)))
834
+ };
835
+ }
836
+ //#endregion
837
+ //#region src/contracts/size.ts
838
+ /**
839
+ * The `size-limit` violation of the row that D1 holds, which the store checks against
840
+ * `MAX_ROW_BYTES`. It points at the entry as a whole (the empty JSON Pointer), because what
841
+ * counts is everything stored with it. The Markdown pipeline words its own `size-limit` for
842
+ * `maxMarkdownBytes`, which names the file and the collection's limit.
843
+ *
844
+ * `bytes` is the size in UTF-8 bytes and `limit` the largest size accepted. Call it when `bytes`
845
+ * is over `limit`.
846
+ */
847
+ function sizeLimitViolation(bytes, limit) {
848
+ return {
849
+ contract: "size-limit",
850
+ severity: "error",
851
+ path: "",
852
+ message: `The entry takes ${bytes} bytes and the limit is ${limit} bytes.`,
853
+ fix: `Cut at least ${bytes - limit} bytes: shorten the text, drop what is not essential or split it into several entries.`
854
+ };
855
+ }
856
+ //#endregion
857
+ //#region src/contracts/unique.ts
858
+ /**
859
+ * The violations of a write that the store refuses because another entry of the same collection
860
+ * and locale holds what has to be unique there: the slug, or the translation group. `holder` is
861
+ * the id of that entry. Like `sizeLimitViolation`, they make the write `refused`, and every such
862
+ * refusal reads the same.
863
+ */
864
+ /** Another entry of the collection and locale has the slug. The slug is no field of the file. */
865
+ function uniqueSlugViolation(holder) {
866
+ return {
867
+ contract: "unique-slug",
868
+ severity: "error",
869
+ path: "",
870
+ message: `Another entry of this collection and locale, ${holder}, already has this slug.`,
871
+ fix: `Choose another slug, or change the entry ${holder} with update_draft.`
872
+ };
873
+ }
874
+ /** Another entry of the collection and locale belongs to the translation group in `field`. */
875
+ function uniqueTranslationViolation(holder, field) {
876
+ return {
877
+ contract: "unique-translation",
878
+ severity: "error",
879
+ path: `/frontmatter${pointer([field])}`,
880
+ message: `Another entry of this collection and locale, ${holder}, already belongs to this translation group.`,
881
+ fix: `Give ${quote$1(field)} another value, or change the entry ${holder} with update_draft: a translation group holds one entry per locale.`
882
+ };
883
+ }
884
+ //#endregion
885
+ //#region src/config/values.ts
886
+ /** A value with keys: not null, not an array. */
887
+ function isRecord$1(value) {
888
+ return typeof value === "object" && value !== null && !Array.isArray(value);
889
+ }
890
+ /** A string as the messages show it: quoted, with what could break a line escaped. */
891
+ function quote(text) {
892
+ return JSON.stringify(text);
893
+ }
894
+ /** Text of an error that someone else wrote, on one line, so that a problem stays one line. */
895
+ function oneLine(text) {
896
+ return text.replace(/\s+/g, " ").trim();
897
+ }
898
+ /** What a value is, for a message: `"es"`, `42`, `null`, `an array`. */
899
+ function found(value) {
900
+ if (value === null) return "null";
901
+ switch (typeof value) {
902
+ case "string": return quote(value);
903
+ case "number":
904
+ case "boolean":
905
+ case "bigint": return String(value);
906
+ case "function": return "a function";
907
+ case "symbol": return "a symbol";
908
+ default: return Array.isArray(value) ? "an array" : "an object";
909
+ }
910
+ }
911
+ /** `is 42; it must be a string`, or `is missing; …` when the value is not there. */
912
+ function wrongType(value, expected) {
913
+ return `${value === void 0 ? "is missing" : `is ${found(value)}`}; it must be ${expected}`;
914
+ }
915
+ /** An optional string: `null` when it is absent, and a problem when it is anything else. */
916
+ function readOptionalString(path, value, fix, problems) {
917
+ if (value === void 0) return null;
918
+ if (typeof value === "string") return value;
919
+ problems.add(path, wrongType(value, "a string"), fix);
920
+ return null;
921
+ }
922
+ const PLAIN_KEY = /^[A-Za-z0-9_-]+$/;
923
+ /** The path of a key inside `parent`: `sites.demo`, or `sites["my site"]` for an odd key. */
924
+ function keyPath(parent, key) {
925
+ return PLAIN_KEY.test(key) ? `${parent}.${key}` : `${parent}[${quote(key)}]`;
926
+ }
927
+ /** The path of an item of an array: `sites.demo.locales[1]`. */
928
+ function itemPath(parent, index) {
929
+ return `${parent}[${index}]`;
930
+ }
931
+ //#endregion
932
+ //#region src/config/json-schema.ts
933
+ /**
934
+ * JSON Schema as the config uses it: what agents see of a schema, and what the checks read of it
935
+ * (which fields exist, of what type, whether an object rejects unknown keys).
936
+ */
937
+ /** The dialect agents read. */
938
+ const TARGET = "draft-2020-12";
939
+ /**
940
+ * The JSON Schema that a schema accepts as input, as plain JSON data. It throws what the library
941
+ * throws when the schema has no JSON Schema (Zod: `z.date()`, `z.custom()`).
942
+ */
943
+ function toJsonSchema(schema) {
944
+ const converted = schema["~standard"].jsonSchema.input({ target: TARGET });
945
+ if (!isRecord$1(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
946
+ return JSON.parse(JSON.stringify(converted));
947
+ }
948
+ /** Reference chains longer than this are cycles. */
949
+ const MAX_REF_HOPS = 16;
950
+ /** What a local reference (`#` or `#/$defs/Name`) points to inside `root`, or `undefined`. */
951
+ function pointTo(root, ref) {
952
+ if (ref === "#") return root;
953
+ if (!ref.startsWith("#/")) return void 0;
954
+ let node = root;
955
+ for (const part of ref.slice(2).split("/")) {
956
+ const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
957
+ if (!isRecord$1(node) || !Object.hasOwn(node, key)) return void 0;
958
+ node = node[key];
959
+ }
960
+ return node;
961
+ }
962
+ /**
963
+ * The schema itself, or the one that its local `$ref`s lead to (Zod writes `$ref` for a schema
964
+ * with an `id`, and for a recursive one); `null` when there is none.
965
+ */
966
+ function resolveSchema(root, schema) {
967
+ let node = schema;
968
+ for (let hop = 0; hop <= MAX_REF_HOPS; hop += 1) {
969
+ if (!isRecord$1(node)) return null;
970
+ const ref = node["$ref"];
971
+ if (typeof ref !== "string") return node;
972
+ node = pointTo(root, ref);
973
+ }
974
+ return null;
975
+ }
976
+ function typeLabel(node) {
977
+ const type = node["type"];
978
+ if (typeof type === "string") return type;
979
+ if (Array.isArray(type)) {
980
+ const names = type.filter((name) => typeof name === "string");
981
+ if (names.length > 0) return names.join(" or ");
982
+ }
983
+ return Array.isArray(node["anyOf"]) || Array.isArray(node["oneOf"]) ? "a union" : "any value";
984
+ }
985
+ /** The type of a schema in words, for a message: `string`, `string or null`, `an array of number`. */
986
+ function describeShape(root, schema) {
987
+ const node = resolveSchema(root, schema);
988
+ if (node === null) return "an unresolvable reference";
989
+ if (node["type"] !== "array") return typeLabel(node);
990
+ const items = resolveSchema(root, node["items"]);
991
+ return `an array of ${items === null ? "any value" : typeLabel(items)}`;
992
+ }
993
+ /** A string: the value of the field is text. */
994
+ function isStringSchema(root, schema) {
995
+ return resolveSchema(root, schema)?.["type"] === "string";
996
+ }
997
+ /** An array whose items are strings. */
998
+ function isStringArraySchema(root, schema) {
999
+ const node = resolveSchema(root, schema);
1000
+ return node?.["type"] === "array" && isStringSchema(root, node["items"]);
1001
+ }
1002
+ /**
1003
+ * A string with the format `date` or `date-time`, which is what proves that it is ISO 8601 and
1004
+ * sorts by time (Zod: `z.iso.date()` and `z.iso.datetime()`). A string that only has a pattern
1005
+ * does not count.
1006
+ */
1007
+ function isDateSchema(root, schema) {
1008
+ const node = resolveSchema(root, schema);
1009
+ return node?.["type"] === "string" && (node["format"] === "date" || node["format"] === "date-time");
1010
+ }
1011
+ //#endregion
1012
+ //#region src/config/describe.ts
1013
+ /** What a call without built-in contracts memoizes under. */
1014
+ const NO_BUILT_IN = [];
1015
+ function describeCollection(collection, builtIn) {
1016
+ return {
1017
+ name: collection.name,
1018
+ schema: toJsonSchema(collection.schema),
1019
+ routes: { ...collection.routes },
1020
+ translationGroupField: collection.translationGroupField,
1021
+ tagsField: collection.tagsField,
1022
+ sortByField: collection.sortByField,
1023
+ components: [...collection.components].map(([name, component]) => ({
1024
+ name,
1025
+ description: component.description ?? null,
1026
+ example: component.example ?? null,
1027
+ props: toJsonSchema(component.props)
1028
+ })),
1029
+ contracts: [...builtIn, ...collection.contracts].map((contract) => describeContract(contract)),
1030
+ maxMarkdownBytes: collection.maxMarkdownBytes,
1031
+ styleGuide: collection.styleGuide
1032
+ };
1033
+ }
1034
+ /** Freezes the value and everything inside it, so that a caller cannot change what is shared. */
1035
+ function deepFreeze(value) {
1036
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
1037
+ Object.freeze(value);
1038
+ for (const inner of Object.values(value)) deepFreeze(inner);
1039
+ }
1040
+ return value;
1041
+ }
1042
+ /** Descriptions by site, then by the array of built-in contracts they were made with. */
1043
+ const descriptions = /* @__PURE__ */ new WeakMap();
1044
+ /**
1045
+ * What an agent reads before it writes: for each collection, the JSON Schema of the frontmatter
1046
+ * (draft 2020-12, as the schema accepts it) and of each component's props, the routes, the
1047
+ * fields (the translation group, the tags and the sorting field), the contracts and the style
1048
+ * guides. `builtInContracts` are described before the collection's own ones.
1049
+ *
1050
+ * Converting the schemas costs about 1.4 ms for a realistic site, so the description is made once
1051
+ * per site and per array of built-in contracts (pass the same array every time), and every call
1052
+ * after that returns the same object, which is frozen because it is shared by every request of
1053
+ * the isolate. Collections, components and contracts come in the order of the config.
1054
+ */
1055
+ function describeResolvedSite(site, options = {}) {
1056
+ const builtIn = options.builtInContracts ?? NO_BUILT_IN;
1057
+ let bySite = descriptions.get(site);
1058
+ if (bySite === void 0) {
1059
+ bySite = /* @__PURE__ */ new WeakMap();
1060
+ descriptions.set(site, bySite);
1061
+ }
1062
+ let description = bySite.get(builtIn);
1063
+ if (description === void 0) {
1064
+ description = deepFreeze({
1065
+ key: site.key,
1066
+ locales: [...site.locales],
1067
+ defaultLocale: site.defaultLocale,
1068
+ styleGuide: site.styleGuide,
1069
+ collections: [...site.collections.values()].map((collection) => describeCollection(collection, builtIn))
1070
+ });
1071
+ bySite.set(builtIn, description);
1072
+ }
1073
+ return description;
1074
+ }
1075
+ //#endregion
1076
+ //#region src/config/collection.ts
1077
+ const RESERVED = new Set(BUILT_IN_CONTRACT_IDS);
1078
+ const SCHEMA_FIX = "Use a schema from a library that implements Standard Schema and Standard JSON Schema, such as zod 4.2 or later.";
1079
+ function standardProps(value) {
1080
+ return typeof value === "object" && value !== null || typeof value === "function" ? Reflect.get(value, "~standard") : void 0;
1081
+ }
1082
+ /** A Standard Schema that also converts to JSON Schema, which is what a collection needs. */
1083
+ function isDescribedSchema(value) {
1084
+ const standard = standardProps(value);
1085
+ if (!isRecord$1(standard) || typeof standard["validate"] !== "function") return false;
1086
+ const converter = standard["jsonSchema"];
1087
+ return isRecord$1(converter) && typeof converter["input"] === "function";
1088
+ }
1089
+ /** What `isDescribedSchema` found missing, for the message. */
1090
+ function whyNotDescribed(value) {
1091
+ const standard = standardProps(value);
1092
+ if (!isRecord$1(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
1093
+ if (typeof standard["validate"] !== "function") return "has no ~standard.validate function, so it is no Standard Schema";
1094
+ return "has no ~standard.jsonSchema.input function, so describe_site could not show it to agents";
1095
+ }
1096
+ function errorMessage(error) {
1097
+ return error instanceof Error ? error.message : String(error);
1098
+ }
1099
+ /**
1100
+ * The schema of a collection's frontmatter, or of a component's props, once it is known to
1101
+ * convert to the JSON Schema of an object. Props must also reject the keys they do not declare.
1102
+ */
1103
+ function readSchema(path, raw, kind, problems) {
1104
+ if (!isDescribedSchema(raw)) {
1105
+ problems.add(path, whyNotDescribed(raw), SCHEMA_FIX);
1106
+ return null;
1107
+ }
1108
+ let document;
1109
+ try {
1110
+ document = toJsonSchema(raw);
1111
+ } catch (error) {
1112
+ problems.add(path, `cannot be converted to JSON Schema (${oneLine(errorMessage(error))})`, "Remove what JSON Schema cannot express, such as z.date() (use z.iso.date() for dates), z.custom() or z.map(), so that agents can read the schema.");
1113
+ return null;
1114
+ }
1115
+ const object = resolveSchema(document, document);
1116
+ if (object?.["type"] !== "object") {
1117
+ problems.add(path, `describes ${describeShape(document, document)} instead of an object`, kind === "props" ? "Declare the props as an object, such as z.strictObject({ type: z.enum([\"info\", \"tip\"]) })." : "Declare the frontmatter as an object, such as z.object({ title: z.string() }).");
1118
+ return null;
1119
+ }
1120
+ if (kind === "props" && object["additionalProperties"] !== false) {
1121
+ problems.add(path, "accepts props it does not declare, because its JSON Schema does not set additionalProperties to false", "Declare the props with z.strictObject({ … }) instead of z.object({ … }), or with the strict object of your validation library, so that a prop the component does not declare is an error.");
1122
+ return null;
1123
+ }
1124
+ return {
1125
+ schema: raw,
1126
+ document,
1127
+ object
1128
+ };
1129
+ }
1130
+ const ROUTES_FIX = "Add one route per locale, such as routes: { es: \"/blog/:slug/\", en: \"/en/blog/:slug/\" }.";
1131
+ /**
1132
+ * Where the slug of the entry goes in a route; `:slugs` is another placeholder, not this one. It is
1133
+ * global because `matchAll` needs that, and `matchAll` works on a copy, so nothing is shared.
1134
+ */
1135
+ const SLUG_PLACEHOLDER = /:slug(?![A-Za-z0-9_])/g;
1136
+ /** Reports what is wrong with the shape of a route; true when nothing is. */
1137
+ function isRoutePattern(path, route, problems) {
1138
+ let valid = true;
1139
+ if (!route.startsWith("/")) {
1140
+ problems.add(path, `${quote(route)} does not start with "/"`, "Write the path from the root of the site, such as \"/blog/:slug/\".");
1141
+ valid = false;
1142
+ }
1143
+ const slugs = [...route.matchAll(SLUG_PLACEHOLDER)].length;
1144
+ if (slugs !== 1) {
1145
+ problems.add(path, slugs === 0 ? `${quote(route)} has no :slug` : `${quote(route)} has :slug ${slugs} times`, "Use :slug exactly once, where the slug of the entry goes, such as \"/blog/:slug/\".");
1146
+ valid = false;
1147
+ }
1148
+ return valid;
1149
+ }
1150
+ /** Takes a route for the site, or reports the route that already has the same pattern. */
1151
+ function claimRoute(route, path, site, problems) {
1152
+ const pattern = route.endsWith("/") ? route.slice(0, -1) : route;
1153
+ const owner = site.routes.get(pattern);
1154
+ if (owner === void 0) {
1155
+ site.routes.set(pattern, path);
1156
+ return;
1157
+ }
1158
+ problems.add(path, `${quote(route)} is the same pattern as the route at ${owner}`, "Give every route of the site its own path: internal links are matched against them.");
1159
+ }
1160
+ /** The route of each locale, in the order of the site's locales. */
1161
+ function readRoutes(path, raw, collection, site, problems) {
1162
+ if (!isRecord$1(raw)) {
1163
+ problems.add(path, wrongType(raw, "an object with one path per locale"), ROUTES_FIX);
1164
+ return {};
1165
+ }
1166
+ for (const locale of Object.keys(raw)) {
1167
+ const routePath = keyPath(path, locale);
1168
+ const route = raw[locale];
1169
+ const known = !site.localesOk || site.locales.includes(locale);
1170
+ if (!known) problems.add(routePath, `is a route for ${quote(locale)}, which is not a locale of the site (${site.locales.join(", ")})`, `Remove it, or add ${quote(locale)} to the locales of the site.`);
1171
+ if (typeof route !== "string") problems.add(routePath, wrongType(route, "a path such as \"/blog/:slug/\""), ROUTES_FIX);
1172
+ else if (isRoutePattern(routePath, route, problems) && known) claimRoute(route, routePath, site, problems);
1173
+ }
1174
+ if (site.localesOk) {
1175
+ for (const locale of site.locales) if (!Object.hasOwn(raw, locale)) problems.add(path, `has no route for the locale ${quote(locale)}`, `Add ${keyPath("routes", locale)}, a path such as ${quote(`/${locale}/${collection}/:slug/`)}.`);
1176
+ }
1177
+ const routes = {};
1178
+ for (const locale of site.locales) {
1179
+ const route = raw[locale];
1180
+ if (typeof route === "string") routes[locale] = route;
1181
+ }
1182
+ return routes;
1183
+ }
1184
+ const GROUP_BY = {
1185
+ mustBe: "a string",
1186
+ accepts: isStringSchema,
1187
+ fix: (field) => `Declare ${quote(field)} as z.string() in the schema, or name another field.`
1188
+ };
1189
+ const TAGS = {
1190
+ mustBe: "an array of strings",
1191
+ accepts: isStringArraySchema,
1192
+ fix: (field) => `Declare ${quote(field)} as z.array(z.string()) in the schema, or name another field.`
1193
+ };
1194
+ /**
1195
+ * Listings sort by this field as text, which is chronological only for ISO 8601 dates. A string
1196
+ * that is not declared with the `date` or `date-time` format may be anything, so it is refused.
1197
+ */
1198
+ const SORT_BY = {
1199
+ mustBe: "a string with the \"date\" or \"date-time\" format",
1200
+ accepts: isDateSchema,
1201
+ fix: (field) => `Declare ${quote(field)} as z.iso.date() or z.iso.datetime() in the schema, or name another field.`
1202
+ };
1203
+ /** The name of a field of the frontmatter, checked against the schema when there is one. */
1204
+ function readField(path, raw, schema, kind, problems) {
1205
+ if (typeof raw !== "string" || raw === "") {
1206
+ problems.add(path, wrongType(raw, "the name of a frontmatter field"), "Set it to the name of a field of the frontmatter schema, or remove it.");
1207
+ return null;
1208
+ }
1209
+ if (schema === null) return raw;
1210
+ const properties = isRecord$1(schema.object["properties"]) ? schema.object["properties"] : {};
1211
+ if (!Object.hasOwn(properties, raw)) {
1212
+ const names = Object.keys(properties);
1213
+ problems.add(path, `${quote(raw)} is not a property of the schema${names.length === 0 ? ", which declares none" : ` (its properties are ${names.map(quote).join(", ")})`}`, `Name a field that the schema declares, or add ${quote(raw)} to the schema.`);
1214
+ } else if (!kind.accepts(schema.document, properties[raw])) problems.add(path, `${quote(raw)} is declared as ${describeShape(schema.document, properties[raw])}, but it must be ${kind.mustBe}`, kind.fix(raw));
1215
+ return raw;
1216
+ }
1217
+ function readTranslations(path, raw, schema, problems) {
1218
+ if (raw === void 0) return null;
1219
+ if (!isRecord$1(raw)) {
1220
+ problems.add(path, wrongType(raw, "an object like { groupBy: \"translationGroup\" }"), "Set it to { groupBy: \"<field>\" }, the field of the frontmatter that holds the key the translations of an entry share, or remove it.");
1221
+ return null;
1222
+ }
1223
+ return readField(keyPath(path, "groupBy"), raw["groupBy"], schema, GROUP_BY, problems);
1224
+ }
1225
+ const COMPONENT_FIX = "Declare it with defineComponent({ props: z.strictObject({ … }) }).";
1226
+ function readComponent(path, name, raw, problems) {
1227
+ if (!NAME.test(name)) problems.add(path, `${quote(name)} is not a valid component name`, nameFix("callout"));
1228
+ if (!isRecord$1(raw)) {
1229
+ problems.add(path, wrongType(raw, "a component"), COMPONENT_FIX);
1230
+ return null;
1231
+ }
1232
+ const props = readSchema(keyPath(path, "props"), raw["props"], "props", problems);
1233
+ const textFix = "Write it as a string, or remove it.";
1234
+ const description = readOptionalString(keyPath(path, "description"), raw["description"], textFix, problems);
1235
+ const example = readOptionalString(keyPath(path, "example"), raw["example"], textFix, problems);
1236
+ if (props === null) return null;
1237
+ return {
1238
+ props: props.schema,
1239
+ ...description === null ? {} : { description },
1240
+ ...example === null ? {} : { example }
1241
+ };
1242
+ }
1243
+ function readComponents(path, raw, problems) {
1244
+ const components = /* @__PURE__ */ new Map();
1245
+ if (raw === void 0) return components;
1246
+ if (!isRecord$1(raw)) {
1247
+ problems.add(path, wrongType(raw, "an object that maps each component name to its definition"), COMPONENT_FIX);
1248
+ return components;
1249
+ }
1250
+ for (const name of Object.keys(raw)) {
1251
+ const component = readComponent(keyPath(path, name), name, raw[name], problems);
1252
+ if (component !== null) components.set(name, component);
1253
+ }
1254
+ return components;
1255
+ }
1256
+ function isSeverity(value) {
1257
+ return value === "error" || value === "warning";
1258
+ }
1259
+ function isPhase(value) {
1260
+ return value === "write" || value === "publish";
1261
+ }
1262
+ const PHASES_FIX = "Use \"write\" (create_draft and update_draft), \"publish\" (validate and publish) or both, or remove phases to run in both.";
1263
+ function checkPhases(path, raw, problems) {
1264
+ if (raw === void 0) return;
1265
+ if (!Array.isArray(raw)) {
1266
+ problems.add(path, wrongType(raw, "an array of \"write\" and \"publish\""), PHASES_FIX);
1267
+ return;
1268
+ }
1269
+ if (raw.length === 0) {
1270
+ problems.add(path, "is empty, so the contract would never run", PHASES_FIX);
1271
+ return;
1272
+ }
1273
+ const seen = /* @__PURE__ */ new Set();
1274
+ const phases = raw;
1275
+ for (let index = 0; index < phases.length; index += 1) {
1276
+ const phase = phases[index];
1277
+ if (!isPhase(phase)) problems.add(itemPath(path, index), wrongType(phase, "\"write\" or \"publish\""), PHASES_FIX);
1278
+ else if (seen.has(phase)) problems.add(itemPath(path, index), `repeats ${quote(phase)}`, PHASES_FIX);
1279
+ seen.add(phase);
1280
+ }
1281
+ }
1282
+ /**
1283
+ * Reports every problem with the shape of one contract and says whether it has none, which is
1284
+ * what makes it a `Contract`. Whether it may have its id is a question for `checkIdentity`.
1285
+ */
1286
+ function isContract(item, path, problems) {
1287
+ if (!isRecord$1(item)) {
1288
+ problems.add(path, wrongType(item, "a contract"), "Write it as { id, severity, description, check }.");
1289
+ return false;
1290
+ }
1291
+ const before = problems.list.length;
1292
+ const id = item["id"];
1293
+ if (typeof id !== "string" || !CONTRACT_ID.test(id)) problems.add(keyPath(path, "id"), wrongType(id, "lowercase words joined by hyphens, such as \"seo-title-length\""), "Name the rule with lowercase words joined by hyphens.");
1294
+ if (!isSeverity(item["severity"])) problems.add(keyPath(path, "severity"), wrongType(item["severity"], "\"error\" or \"warning\""), "Use \"error\" for a rule that blocks publishing and \"warning\" for one that publishing asks to acknowledge.");
1295
+ checkPhases(keyPath(path, "phases"), item["phases"], problems);
1296
+ const description = item["description"];
1297
+ if (typeof description !== "string" || description.trim() === "") problems.add(keyPath(path, "description"), wrongType(description, "a sentence that says what the rule asks for"), "Describe the rule for the agent that has to follow it.");
1298
+ if (typeof item["check"] !== "function") problems.add(keyPath(path, "check"), wrongType(item["check"], "a function that returns the problems it finds"), "Add check(context), which returns a list of problems, empty when the content passes.");
1299
+ return problems.list.length === before;
1300
+ }
1301
+ /**
1302
+ * Whether a contract may have its id: the id of a built-in contract only when Hollware made the
1303
+ * contract (`contracts.translationPair()`, which is `own`), and no id that another contract of the
1304
+ * collection has. `ids` holds the contracts seen so far, by id. A contract that Hollware did not
1305
+ * make, such as a plain object with the id `translation-pair`, would be taken for the real one in
1306
+ * a verdict.
1307
+ */
1308
+ function checkIdentity(id, own, path, ids, problems) {
1309
+ if (RESERVED.has(id) && !own) {
1310
+ problems.add(keyPath(path, "id"), `${quote(id)} is the id of a built-in contract`, `Choose another id: ${BUILT_IN_CONTRACT_IDS.join(", ")} belong to Hollware's built-in contracts, and the opt-in ones are listed as contracts.translationPair(), contracts.internalLinksResolve() and contracts.imagesHaveAlt().`);
1311
+ return;
1312
+ }
1313
+ const first = ids.get(id);
1314
+ if (first === void 0) {
1315
+ ids.set(id, path);
1316
+ return;
1317
+ }
1318
+ problems.add(keyPath(path, "id"), `${quote(id)} is already the id of the contract at ${first}`, "Give every contract of the collection its own id.");
1319
+ }
1320
+ function readContracts(path, raw, problems) {
1321
+ const contracts = [];
1322
+ if (raw === void 0) return contracts;
1323
+ if (!Array.isArray(raw)) {
1324
+ problems.add(path, wrongType(raw, "an array of contracts"), "Write it as [{ id, severity, description, check }, …].");
1325
+ return contracts;
1326
+ }
1327
+ const ids = /* @__PURE__ */ new Map();
1328
+ const items = raw;
1329
+ for (let index = 0; index < items.length; index += 1) {
1330
+ const item = items[index];
1331
+ const itemAt = itemPath(path, index);
1332
+ if (isContract(item, itemAt, problems)) {
1333
+ checkIdentity(item.id, isBuiltInContract(item), itemAt, ids, problems);
1334
+ contracts.push(item);
1335
+ } else if (isRecord$1(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
1336
+ }
1337
+ return contracts;
1338
+ }
1339
+ function readMaxMarkdownBytes(path, raw, problems) {
1340
+ if (raw === void 0) return DEFAULT_MAX_MARKDOWN_BYTES;
1341
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 786432) return raw;
1342
+ problems.add(path, wrongType(raw, `an integer from 1 to ${MAX_MARKDOWN_BYTES_CEILING}`), `Set a size in bytes from 1 to ${MAX_MARKDOWN_BYTES_CEILING} (768 KiB), or remove it for the default of ${DEFAULT_MAX_MARKDOWN_BYTES} (512 KiB): a revision row is about 2.3 times the file and the store refuses rows over 1.9 MB, so a larger file could never be saved.`);
1343
+ return DEFAULT_MAX_MARKDOWN_BYTES;
1344
+ }
1345
+ /**
1346
+ * Checks one collection and fills in its defaults. `null` when its schema is unusable, which is a
1347
+ * problem already: `resolveConfig` throws when there is any, so nothing else keeps track of them.
1348
+ */
1349
+ function readCollection(path, name, raw, site, problems) {
1350
+ if (!NAME.test(name)) problems.add(path, `${quote(name)} is not a valid collection name`, nameFix("blog"));
1351
+ if (!isRecord$1(raw)) {
1352
+ problems.add(path, wrongType(raw, "a collection"), "Declare it with defineCollection({ schema, routes, … }).");
1353
+ return null;
1354
+ }
1355
+ const schema = readSchema(keyPath(path, "schema"), raw["schema"], "frontmatter", problems);
1356
+ const routes = readRoutes(keyPath(path, "routes"), raw["routes"], name, site, problems);
1357
+ const translationGroupField = readTranslations(keyPath(path, "translations"), raw["translations"], schema, problems);
1358
+ const tagsField = raw["tags"] === void 0 ? null : readField(keyPath(path, "tags"), raw["tags"], schema, TAGS, problems);
1359
+ const sortByField = raw["sortBy"] === void 0 ? null : readField(keyPath(path, "sortBy"), raw["sortBy"], schema, SORT_BY, problems);
1360
+ const components = readComponents(keyPath(path, "components"), raw["components"], problems);
1361
+ const contracts = readContracts(keyPath(path, "contracts"), raw["contracts"], problems);
1362
+ const maxMarkdownBytes = readMaxMarkdownBytes(keyPath(path, "maxMarkdownBytes"), raw["maxMarkdownBytes"], problems);
1363
+ const styleGuide = readOptionalString(keyPath(path, "styleGuide"), raw["styleGuide"], "Write the guidance as a Markdown string, or remove it.", problems);
1364
+ if (schema === null) return null;
1365
+ return {
1366
+ name,
1367
+ schema: schema.schema,
1368
+ routes,
1369
+ translationGroupField,
1370
+ tagsField,
1371
+ sortByField,
1372
+ components,
1373
+ contracts,
1374
+ maxMarkdownBytes,
1375
+ styleGuide
1376
+ };
1377
+ }
1378
+ //#endregion
1379
+ //#region src/config/site-key.ts
1380
+ /**
1381
+ * The key of a site, which is the id of its row in the database: lowercase letters and digits
1382
+ * joined by single hyphens (`blog`, `docs-2`, `acme-eu`), from 1 to `SITE_KEY_MAX_LENGTH`
1383
+ * characters. It is the rule of the CLI, which can import it, so that a key the CLI writes is a
1384
+ * key the config accepts.
1385
+ */
1386
+ const SITE_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1387
+ /** The longest a site key can be. */
1388
+ const SITE_KEY_MAX_LENGTH = 64;
1389
+ /** Whether a value is a site key: a string that fits `SITE_KEY_PATTERN` and is not too long. */
1390
+ function isSiteKey(value) {
1391
+ return typeof value === "string" && value.length <= 64 && SITE_KEY_PATTERN.test(value);
1392
+ }
1393
+ //#endregion
1394
+ //#region src/config/resolve.ts
1395
+ const SITE_FIX = "Declare one site: sites: { \"my-site\": { locales: [\"en\"], defaultLocale: \"en\", collections: { … } } }.";
1396
+ const SITE_KEY_FIX = `Use lowercase letters and digits joined by single hyphens, from 1 to 64 characters, such as "my-site".`;
1397
+ const LOCALE_FIX = "Write a language, then a region if you need one: \"es\", \"en\" or \"pt-BR\" (lowercase language, uppercase region).";
1398
+ /** The locales of a site, and whether they are fit to check the routes against. */
1399
+ function readLocales(path, raw, problems) {
1400
+ if (!Array.isArray(raw)) {
1401
+ problems.add(path, wrongType(raw, "an array of language tags"), "List the languages of the site, such as [\"es\", \"en\"].");
1402
+ return {
1403
+ locales: [],
1404
+ ok: false
1405
+ };
1406
+ }
1407
+ if (raw.length === 0) {
1408
+ problems.add(path, "is empty", "List at least one language tag, such as [\"en\"].");
1409
+ return {
1410
+ locales: [],
1411
+ ok: false
1412
+ };
1413
+ }
1414
+ const locales = [];
1415
+ const firstAt = /* @__PURE__ */ new Map();
1416
+ const tags = raw;
1417
+ let ok = true;
1418
+ for (let index = 0; index < tags.length; index += 1) {
1419
+ const tag = tags[index];
1420
+ const tagPath = itemPath(path, index);
1421
+ if (typeof tag !== "string") {
1422
+ problems.add(tagPath, wrongType(tag, "a language tag"), LOCALE_FIX);
1423
+ ok = false;
1424
+ continue;
1425
+ }
1426
+ locales.push(tag);
1427
+ if (!LOCALE_TAG.test(tag)) {
1428
+ problems.add(tagPath, `${quote(tag)} is not a language tag`, LOCALE_FIX);
1429
+ ok = false;
1430
+ continue;
1431
+ }
1432
+ const first = firstAt.get(tag);
1433
+ if (first === void 0) firstAt.set(tag, index);
1434
+ else {
1435
+ problems.add(tagPath, `${quote(tag)} is already listed at ${itemPath(path, first)}`, "List each locale once.");
1436
+ ok = false;
1437
+ }
1438
+ }
1439
+ return {
1440
+ locales,
1441
+ ok
1442
+ };
1443
+ }
1444
+ function readDefaultLocale(path, raw, locales, problems) {
1445
+ if (typeof raw !== "string") {
1446
+ problems.add(path, wrongType(raw, "one of the locales"), "Set it to the main language of the site, one of its locales.");
1447
+ return "";
1448
+ }
1449
+ if (locales.length > 0 && !locales.includes(raw)) problems.add(path, `is ${quote(raw)}, which is not one of the locales (${locales.map(quote).join(", ")})`, `Set it to one of ${locales.map(quote).join(", ")}, or add ${quote(raw)} to locales.`);
1450
+ return raw;
1451
+ }
1452
+ const COLLECTIONS_FIX = "Declare at least one: collections: { blog: defineCollection({ schema, routes }) }.";
1453
+ function readCollections(path, raw, site, problems) {
1454
+ const collections = /* @__PURE__ */ new Map();
1455
+ if (!isRecord$1(raw)) {
1456
+ problems.add(path, wrongType(raw, "an object that maps each collection name to its collection"), COLLECTIONS_FIX);
1457
+ return collections;
1458
+ }
1459
+ const names = Object.keys(raw);
1460
+ if (names.length === 0) problems.add(path, "has no collection", COLLECTIONS_FIX);
1461
+ for (const name of names) {
1462
+ const collection = readCollection(keyPath(path, name), name, raw[name], site, problems);
1463
+ if (collection !== null) collections.set(name, collection);
1464
+ }
1465
+ return collections;
1466
+ }
1467
+ /** Checks one site; `null` when it is no object, which is a problem already. */
1468
+ function readSite(key, raw, problems) {
1469
+ const path = keyPath("sites", key);
1470
+ if (!isSiteKey(key)) problems.add(path, `${quote(key)} is not a valid site key`, SITE_KEY_FIX);
1471
+ if (!isRecord$1(raw)) {
1472
+ problems.add(path, wrongType(raw, "a site"), "Declare it as { locales: [\"en\"], defaultLocale: \"en\", collections: { … } }.");
1473
+ return null;
1474
+ }
1475
+ const { locales, ok } = readLocales(keyPath(path, "locales"), raw["locales"], problems);
1476
+ const defaultLocale = readDefaultLocale(keyPath(path, "defaultLocale"), raw["defaultLocale"], locales, problems);
1477
+ const collections = readCollections(keyPath(path, "collections"), raw["collections"], {
1478
+ path,
1479
+ locales,
1480
+ localesOk: ok,
1481
+ routes: /* @__PURE__ */ new Map()
1482
+ }, problems);
1483
+ return {
1484
+ key,
1485
+ locales,
1486
+ defaultLocale,
1487
+ styleGuide: readOptionalString(keyPath(path, "styleGuide"), raw["styleGuide"], "Write the guidance as a Markdown string, or remove it.", problems),
1488
+ collections
1489
+ };
1490
+ }
1491
+ function readSites(raw, problems) {
1492
+ const sites = /* @__PURE__ */ new Map();
1493
+ if (!isRecord$1(raw)) {
1494
+ problems.add("sites", wrongType(raw, "an object that maps the site key to the site"), SITE_FIX);
1495
+ return sites;
1496
+ }
1497
+ const keys = Object.keys(raw);
1498
+ if (keys.length === 0) problems.add("sites", "has no site, and v0.1 serves exactly one site per deployment", SITE_FIX);
1499
+ else if (keys.length > 1) problems.add("sites", `has ${keys.length} sites (${keys.map(quote).join(", ")}), and v0.1 serves exactly one site per deployment`, "Keep exactly one site in sites.");
1500
+ for (const key of keys) {
1501
+ const site = readSite(key, raw[key], problems);
1502
+ if (site !== null) sites.set(key, site);
1503
+ }
1504
+ return sites;
1505
+ }
1506
+ /**
1507
+ * Checks the config of the deployment and fills in its defaults, or throws a `ConfigError` with
1508
+ * every problem it found, so that a broken `hollware.config.ts` stops the Worker at startup with
1509
+ * a message that says what to change. v0.1 serves exactly one site per deployment.
1510
+ *
1511
+ * The parameter is `unknown` because the config comes from user code that may be plain
1512
+ * JavaScript; a `HollwareConfig` is accepted as is, so this is a `ResolveConfig`. Notes on the
1513
+ * rules:
1514
+ *
1515
+ * - A site key is lowercase letters and digits joined by single hyphens, from 1 to 64 characters
1516
+ * (`isSiteKey`, the rule of the CLI). A collection name and a component name are lowercase
1517
+ * letters, digits and hyphens, starting with a letter, at most 63 characters. A contract id is
1518
+ * lowercase words joined by single hyphens, and the id of a built-in contract
1519
+ * (`BUILT_IN_CONTRACT_IDS`) is taken only by a contract that Hollware made (`isBuiltInContract`:
1520
+ * `contracts.translationPair()` and the like), once.
1521
+ * - Each locale is a language tag with an optional script and region (`es`, `pt-BR`,
1522
+ * `zh-Hant-TW`). Routes are checked against the locales only when the locales are valid.
1523
+ * - A route has `:slug` once. Two routes of the site are the same pattern when they differ at
1524
+ * most by a trailing slash; patterns that overlap without being equal are not detected.
1525
+ * - `translations.groupBy`, `tags` and `sortBy` name properties of the frontmatter's JSON Schema
1526
+ * (as agents see it, so `$ref`s are followed). `groupBy` is a string, `tags` an array of
1527
+ * strings and `sortBy` a string with the `date` or `date-time` format: a plain string does not
1528
+ * prove that the listing sorts by time. None may be nullable, and each may be optional.
1529
+ * - Collections, components and contracts keep the order of the config; each collection's routes
1530
+ * come out in the order of the site's locales.
1531
+ */
1532
+ function resolveConfig(config) {
1533
+ const problems = new Problems();
1534
+ let sites = /* @__PURE__ */ new Map();
1535
+ if (isRecord$1(config)) sites = readSites(config["sites"], problems);
1536
+ else problems.add("config", wrongType(config, "an object"), "Export the result of defineConfig({ sites: { … } }) from hollware.config.ts.");
1537
+ if (problems.list.length > 0) throw new ConfigError(problems.list);
1538
+ return { sites };
1539
+ }
1540
+ //#endregion
1541
+ //#region src/errors.ts
1542
+ /**
1543
+ * The errors of an operation that are none of its outcomes: the caller asked for something it
1544
+ * cannot have, or the server could not answer. Conflicts, missing entries and blocked publishes
1545
+ * are outcomes, not errors (`operations/specs.ts`).
1546
+ */
1547
+ const ERROR_CODES = [
1548
+ "invalid_input",
1549
+ "unknown_collection",
1550
+ "unknown_locale",
1551
+ "insufficient_scope",
1552
+ "output_too_large",
1553
+ "timeout",
1554
+ "internal"
1555
+ ];
1556
+ /**
1557
+ * An error that the caller sees: a code, a message for the agent and a fix it can act on. The
1558
+ * cause, when there is one, is for the log only and never reaches the caller.
1559
+ */
1560
+ var HollwareError = class extends Error {
1561
+ name = "HollwareError";
1562
+ code;
1563
+ fix;
1564
+ constructor(code, message, fix, options) {
1565
+ super(message, options);
1566
+ this.code = code;
1567
+ this.fix = fix;
1568
+ }
1569
+ };
1570
+ //#endregion
1571
+ //#region src/ids.ts
1572
+ /**
1573
+ * Identifiers: a prefix that names the kind of thing, an underscore, and a ULID (48 bits of
1574
+ * time in milliseconds, then 80 random bits, in Crockford's base 32), so that ids of one kind
1575
+ * sort by creation time. For example `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`.
1576
+ */
1577
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1578
+ /** The kinds of ids and their prefixes. A new kind adds its prefix here. */
1579
+ const ID_PREFIXES = {
1580
+ entry: "ent",
1581
+ publication: "pub",
1582
+ revision: "rev",
1583
+ revisionTag: "tag",
1584
+ token: "tok",
1585
+ user: "usr"
1586
+ };
1587
+ const LARGEST_TIME = 2 ** 48 - 1;
1588
+ /**
1589
+ * A ULID for the given time and 10 random bytes. Both default to now and to fresh randomness
1590
+ * from `crypto.getRandomValues`; tests pass their own.
1591
+ */
1592
+ function ulid(time = Date.now(), random = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(10))) {
1593
+ if (!Number.isInteger(time) || time < 0 || time > LARGEST_TIME) throw new RangeError(`a ULID time is a whole number of milliseconds from 0 to 2^48 - 1, got ${time}`);
1594
+ if (random.length !== 10) throw new RangeError(`a ULID takes 10 random bytes, got ${random.length}`);
1595
+ let timePart = "";
1596
+ let rest = time;
1597
+ for (let i = 0; i < 10; i += 1) {
1598
+ timePart = CROCKFORD.charAt(rest % 32) + timePart;
1599
+ rest = Math.floor(rest / 32);
1600
+ }
1601
+ let randomPart = "";
1602
+ let buffer = 0;
1603
+ let bits = 0;
1604
+ for (const byte of random) {
1605
+ buffer = buffer << 8 | byte;
1606
+ bits += 8;
1607
+ while (bits >= 5) {
1608
+ bits -= 5;
1609
+ randomPart += CROCKFORD.charAt(buffer >>> bits & 31);
1610
+ }
1611
+ buffer &= (1 << bits) - 1;
1612
+ }
1613
+ return timePart + randomPart;
1614
+ }
1615
+ /** A new id of the given kind: `newId('ent')` → `ent_01JAB…`. */
1616
+ function newId(prefix) {
1617
+ return `${prefix}_${ulid()}`;
1618
+ }
1619
+ const ID_PATTERN = /^([a-z]{3})_[0-9A-HJKMNP-TV-Z]{26}$/;
1620
+ /** Whether a string is an id of the given kind. */
1621
+ function isId(prefix, value) {
1622
+ const match = ID_PATTERN.exec(value);
1623
+ return match !== null && match[1] === prefix;
1624
+ }
1625
+ //#endregion
1626
+ //#region src/markdown/policy.ts
1627
+ /**
1628
+ * What the pipeline lets through (ADR-020): the tags Markdown itself produces, the attributes each
1629
+ * of them may carry and the URLs that links and images may point to. The write path
1630
+ * (`analyze.ts`) applies these rules and reports whatever it removes; `renderHtml` applies the
1631
+ * ones that matter for safety once more to any document it is given.
1632
+ */
1633
+ /** The tag of the element that stands in the document for something the pipeline did not let through. */
1634
+ const MARKER_TAG = "hollware-marker";
1635
+ /**
1636
+ * The prefix of the tag the markdown-it rule gives raw HTML (`hollware-raw:script`). Component
1637
+ * syntax cannot write a colon in a tag name, so no author can forge one, and the analysis turns
1638
+ * every such element into a marker.
1639
+ */
1640
+ const RAW_HTML_PREFIX = "hollware-raw:";
1641
+ /**
1642
+ * The tag of the node the markdown-it rule adds at the end of the document, which lists the text
1643
+ * that Comark's syntax read as attributes or parameters and left out of the document: the
1644
+ * analysis reads it, reports what it holds and removes it. Like `hollware-raw:`, it has a colon,
1645
+ * which component syntax cannot write.
1646
+ */
1647
+ const LEFT_OUT_TAG = "hollware-left-out:";
1648
+ /**
1649
+ * The key of an attribute that names one `{...}` (`$p0`, `$p1`): it travels from the token to the
1650
+ * element that took the braces as its attributes, which tells the analysis that they were taken.
1651
+ */
1652
+ const PROPS_KEY = /^\$p(\d+)$/;
1653
+ /** The attribute that carries the line of a block from the markdown-it rule to the analysis, which removes it. */
1654
+ const LINE_KEY = "$line";
1655
+ /**
1656
+ * The same for a component, whose line is the one it opens on. A component with a single
1657
+ * paragraph is unwrapped by Comark, which merges the paragraph's props into it: the paragraph's
1658
+ * `$line` then says where the content starts, and `$open` where the component does.
1659
+ */
1660
+ const OPEN_KEY = "$open";
1661
+ /** The tags Markdown itself produces, plus the slot wrapper `template` and the task list checkbox `input`. */
1662
+ const NATIVE_TAGS = /* @__PURE__ */ new Set([
1663
+ "h1",
1664
+ "h2",
1665
+ "h3",
1666
+ "h4",
1667
+ "h5",
1668
+ "h6",
1669
+ "p",
1670
+ "a",
1671
+ "img",
1672
+ "ul",
1673
+ "ol",
1674
+ "li",
1675
+ "pre",
1676
+ "code",
1677
+ "table",
1678
+ "thead",
1679
+ "tbody",
1680
+ "tr",
1681
+ "th",
1682
+ "td",
1683
+ "blockquote",
1684
+ "hr",
1685
+ "br",
1686
+ "strong",
1687
+ "em",
1688
+ "del",
1689
+ "span",
1690
+ "input",
1691
+ "template"
1692
+ ]);
1693
+ /** The tags that sit inside a line of text: they do not end a word, which a block does. */
1694
+ const INLINE_TAGS = /* @__PURE__ */ new Set([
1695
+ "a",
1696
+ "img",
1697
+ "code",
1698
+ "strong",
1699
+ "em",
1700
+ "del",
1701
+ "span",
1702
+ "input"
1703
+ ]);
1704
+ /** The kinds of alert a blockquote can be (`> [!NOTE]`). */
1705
+ const ALERT_TYPES = [
1706
+ "note",
1707
+ "tip",
1708
+ "important",
1709
+ "warning",
1710
+ "caution"
1711
+ ];
1712
+ const text = (value) => typeof value === "string";
1713
+ const exactly = (...allowed) => (value) => typeof value === "string" && allowed.includes(value);
1714
+ const matching = (pattern) => (value) => typeof value === "string" && pattern.test(value);
1715
+ /** Only `language-*` classes, which is what a fenced code block carries. */
1716
+ const languageClasses = (value) => {
1717
+ if (typeof value !== "string") return false;
1718
+ const names = value.split(/\s+/).filter((name) => name !== "");
1719
+ return names.length > 0 && names.every((name) => /^language-[\w+#.-]+$/.test(name));
1720
+ };
1721
+ const lineNumbers = (value) => Array.isArray(value) && value.every((line) => Number.isSafeInteger(line));
1722
+ const alignment = /* @__PURE__ */ new Map([["style", exactly("text-align:left", "text-align:right", "text-align:center")]]);
1723
+ /**
1724
+ * The attributes each native tag may carry, with the values it may have. The headings carry none:
1725
+ * their ids are Hollware's own. The classes, the alert and the table alignment are what Comark
1726
+ * and markdown-it add themselves, so a document the parser writes passes its own rules.
1727
+ */
1728
+ const ATTRIBUTE_RULES = /* @__PURE__ */ new Map([
1729
+ ["a", /* @__PURE__ */ new Map([["href", text], ["title", text]])],
1730
+ ["img", /* @__PURE__ */ new Map([
1731
+ ["src", text],
1732
+ ["alt", text],
1733
+ ["title", text],
1734
+ ["width", text],
1735
+ ["height", text]
1736
+ ])],
1737
+ ["code", /* @__PURE__ */ new Map([["class", languageClasses]])],
1738
+ ["pre", /* @__PURE__ */ new Map([
1739
+ ["language", text],
1740
+ ["filename", text],
1741
+ ["highlights", lineNumbers],
1742
+ ["meta", text]
1743
+ ])],
1744
+ ["ol", /* @__PURE__ */ new Map([["start", matching(/^\d{1,9}$/)]])],
1745
+ ["ul", /* @__PURE__ */ new Map([["class", exactly("contains-task-list")]])],
1746
+ ["li", /* @__PURE__ */ new Map([["class", exactly("task-list-item")]])],
1747
+ ["blockquote", /* @__PURE__ */ new Map([["as", exactly(...ALERT_TYPES)]])],
1748
+ ["th", alignment],
1749
+ ["td", alignment],
1750
+ ["input", /* @__PURE__ */ new Map([
1751
+ ["class", exactly("task-list-item-checkbox")],
1752
+ ["type", exactly("checkbox")],
1753
+ [":disabled", exactly("true")],
1754
+ [":checked", exactly("true")]
1755
+ ])],
1756
+ ["template", /* @__PURE__ */ new Map([["name", matching(/^[\w-]+$/)]])]
1757
+ ]);
1758
+ /** Whether a native tag may carry the attribute with that value. */
1759
+ function isAllowedAttribute(tag, key, value) {
1760
+ const rule = ATTRIBUTE_RULES.get(tag)?.get(key);
1761
+ return rule !== void 0 && rule(value);
1762
+ }
1763
+ /** The attribute as an author wrote it: Comark stores `{open}` as `:open`. */
1764
+ function displayAttribute(key) {
1765
+ return key.startsWith(":") ? key.slice(1) : key;
1766
+ }
1767
+ /**
1768
+ * The name of an attribute as a browser or a framework will see it: Comark keeps `:name` for
1769
+ * bindings and Vue spells a handler `@click` or `v-on:click`, so those prefixes go before the name
1770
+ * is judged.
1771
+ */
1772
+ function attributeName(key) {
1773
+ return key.toLowerCase().replace(/^(?::|v-bind:)/, "").replace(/^(?:@|v-on:)/, "on").replaceAll(":", "");
1774
+ }
1775
+ const SINK_NAMES = /* @__PURE__ */ new Set([
1776
+ "srcdoc",
1777
+ "formaction",
1778
+ "innerhtml",
1779
+ "dangerouslysetinnerhtml",
1780
+ "textcontent"
1781
+ ]);
1782
+ /**
1783
+ * An attribute no element may carry: an event handler (every name that starts with `on`, which
1784
+ * is also what Vue reads as a listener) or one that writes HTML.
1785
+ */
1786
+ function isForbiddenAttribute(name) {
1787
+ return name.startsWith("on") || SINK_NAMES.has(name);
1788
+ }
1789
+ const NAMED_ENTITY = /* @__PURE__ */ new Map([
1790
+ ["colon", ":"],
1791
+ ["Tab", " "],
1792
+ ["NewLine", "\n"]
1793
+ ]);
1794
+ /** The character references a URL can hide its scheme behind, decoded the way an HTML renderer that does not escape them would leave them for a browser. */
1795
+ function decodeEntities(value) {
1796
+ let current = value;
1797
+ for (let pass = 0; pass < 3; pass++) {
1798
+ const next = current.replace(/&(?:#[xX]([0-9a-fA-F]+)|#(\d+)|(colon|Tab|NewLine));?/g, (match, hex, decimal, name) => {
1799
+ if (name !== void 0) return NAMED_ENTITY.get(name) ?? match;
1800
+ const code = hex !== void 0 ? Number.parseInt(hex, 16) : Number.parseInt(decimal ?? "", 10);
1801
+ return Number.isFinite(code) && code <= 1114111 ? String.fromCodePoint(code) : match;
1802
+ });
1803
+ if (next === current) break;
1804
+ current = next;
1805
+ }
1806
+ return current;
1807
+ }
1808
+ /** Drops the control characters and spaces at the start of a URL, which a browser ignores. */
1809
+ function trimLeadingControls(value) {
1810
+ let at = 0;
1811
+ while (at < value.length && value.charCodeAt(at) <= 32) at++;
1812
+ return value.slice(at);
1813
+ }
1814
+ /**
1815
+ * The scheme of a URL as a browser reads it, in lowercase, or `undefined` for a relative URL.
1816
+ * A browser drops the control characters and spaces at the start, and every tab and line break,
1817
+ * before it reads the scheme, and it reads a character reference in an attribute: `jav&#9;ascript:`
1818
+ * is `javascript:`.
1819
+ */
1820
+ function urlScheme(value) {
1821
+ const decoded = value.includes("&") ? decodeEntities(value) : value;
1822
+ const colon = decoded.indexOf(":");
1823
+ if (colon === -1) return void 0;
1824
+ const head = trimLeadingControls(decoded.slice(0, colon).replace(/[\t\n\r]/g, ""));
1825
+ return /^[A-Za-z][A-Za-z0-9+.-]*$/.test(head) ? head.toLowerCase() : void 0;
1826
+ }
1827
+ const LINK_SCHEMES = /* @__PURE__ */ new Set([
1828
+ "http",
1829
+ "https",
1830
+ "mailto"
1831
+ ]);
1832
+ const IMAGE_SCHEMES = /* @__PURE__ */ new Set(["http", "https"]);
1833
+ /**
1834
+ * Whether a link or an image may point there. A relative URL (a path, `#anchor`, `?query` or
1835
+ * `//host/path`) is always safe: it keeps the scheme of the page. Of the schemes, links may use
1836
+ * `http`, `https` and `mailto`, and images `http` and `https`.
1837
+ */
1838
+ function isSafeUrl(value, kind) {
1839
+ const scheme = urlScheme(value);
1840
+ return scheme === void 0 || (kind === "link" ? LINK_SCHEMES : IMAGE_SCHEMES).has(scheme);
1841
+ }
1842
+ const NAME_LENGTH = 100;
1843
+ /** A tag, attribute or URL as a marker names it: long values, such as a `data:` URL, are cut. */
1844
+ function shorten(value) {
1845
+ return value.length > NAME_LENGTH ? `${value.slice(0, 97)}...` : value;
1846
+ }
1847
+ /** Defines an own property, so that a prop called `__proto__` is a prop and not a prototype. */
1848
+ function setOwn(target, key, value) {
1849
+ Object.defineProperty(target, key, {
1850
+ value,
1851
+ enumerable: true,
1852
+ writable: true,
1853
+ configurable: true
1854
+ });
1855
+ }
1856
+ /** Reads a string as JSON, or gives it back when it is not JSON (or is a number that JSON cannot hold). */
1857
+ function parseJson(value) {
1858
+ try {
1859
+ const parsed = JSON.parse(value);
1860
+ return typeof parsed === "number" && !Number.isFinite(parsed) ? value : parsed;
1861
+ } catch {
1862
+ return value;
1863
+ }
1864
+ }
1865
+ /** Comark stores a prop that holds data, such as `{open}` or `{:count="3"}`, under `:name`. */
1866
+ function isBinding(key) {
1867
+ return key.startsWith(":") || key.startsWith("v-bind:");
1868
+ }
1869
+ /** The URL an attribute holds, or `undefined` when it holds a value that cannot be one. */
1870
+ function urlValue(key, value) {
1871
+ if (typeof value !== "string") return void 0;
1872
+ if (!isBinding(key)) return value;
1873
+ const parsed = parseJson(value);
1874
+ return typeof parsed === "string" ? parsed : value;
1875
+ }
1876
+ //#endregion
1877
+ //#region src/markdown/json.ts
1878
+ function isPlainObject(value) {
1879
+ const prototype = Object.getPrototypeOf(value);
1880
+ return prototype === Object.prototype || prototype === null;
1881
+ }
1882
+ /**
1883
+ * Checks that a value YAML or JSON gave can be stored as JSON and read back as it is. YAML anchors
1884
+ * and aliases build a value in which one array or object is reachable twice, so a few lines of
1885
+ * them are a tree of billions of nodes once they are written out (and `&a [*a]` is a cycle).
1886
+ * The walk is iterative, so no depth can overflow the stack, and it never visits a container
1887
+ * twice, so it takes time in proportion to what was parsed.
1888
+ */
1889
+ function jsonProblem(root) {
1890
+ const seen = /* @__PURE__ */ new Set();
1891
+ const stack = [{
1892
+ value: root,
1893
+ depth: 1
1894
+ }];
1895
+ while (stack.length > 0) {
1896
+ const item = stack.pop();
1897
+ if (item === void 0) break;
1898
+ const { value, depth } = item;
1899
+ if (value === null || typeof value === "string" || typeof value === "boolean") continue;
1900
+ if (typeof value === "number") {
1901
+ if (!Number.isFinite(value)) return "number";
1902
+ continue;
1903
+ }
1904
+ if (typeof value !== "object") return "type";
1905
+ if (seen.has(value)) return "shared";
1906
+ seen.add(value);
1907
+ if (depth > 100) return "depth";
1908
+ if (Array.isArray(value)) for (const child of value) stack.push({
1909
+ value: child,
1910
+ depth: depth + 1
1911
+ });
1912
+ else if (isPlainObject(value)) for (const key of Object.keys(value)) stack.push({
1913
+ value: Reflect.get(value, key),
1914
+ depth: depth + 1
1915
+ });
1916
+ else return "type";
1917
+ }
1918
+ }
1919
+ //#endregion
1920
+ //#region src/markdown/routes.ts
1921
+ const SLUG = ":slug";
1922
+ /** The segments of a path: `/en/blog/x/` and `/en/blog/x` are both `en`, `blog`, `x`. */
1923
+ function segmentsOf(path) {
1924
+ const segments = path.slice(1).split("/");
1925
+ if (segments[segments.length - 1] === "") segments.pop();
1926
+ return segments;
1927
+ }
1928
+ /**
1929
+ * Compiles the routes of the site's collections, which hold `:slug` once. They are sorted by
1930
+ * collection and locale, so that the order of the config never changes which entry a link
1931
+ * names when two routes could match the same path.
1932
+ */
1933
+ function compileRoutes(routes) {
1934
+ const compiled = [];
1935
+ for (const collection of Object.keys(routes).sort()) {
1936
+ const byLocale = routes[collection] ?? {};
1937
+ for (const locale of Object.keys(byLocale).sort()) {
1938
+ const pattern = byLocale[locale] ?? "";
1939
+ if (!pattern.startsWith("/")) continue;
1940
+ const segments = segmentsOf(pattern);
1941
+ const slugAt = segments.indexOf(SLUG);
1942
+ if (slugAt !== -1) compiled.push({
1943
+ collection,
1944
+ locale,
1945
+ segments,
1946
+ slugAt
1947
+ });
1948
+ }
1949
+ }
1950
+ return compiled;
1951
+ }
1952
+ function safeDecode(segment) {
1953
+ try {
1954
+ return decodeURIComponent(segment);
1955
+ } catch {
1956
+ return segment;
1957
+ }
1958
+ }
1959
+ function matchRoute(path, routes) {
1960
+ const segments = segmentsOf(path);
1961
+ for (const route of routes) {
1962
+ if (route.segments.length !== segments.length) continue;
1963
+ let matches = true;
1964
+ for (let at = 0; at < segments.length && matches; at++) {
1965
+ const segment = segments[at] ?? "";
1966
+ matches = at === route.slugAt ? segment !== "" : segment === route.segments[at];
1967
+ }
1968
+ if (matches) return {
1969
+ collection: route.collection,
1970
+ locale: route.locale,
1971
+ slug: safeDecode(segments[route.slugAt] ?? "")
1972
+ };
1973
+ }
1974
+ }
1975
+ /**
1976
+ * Tells what a link points to. Links with a scheme (`http:`, `https:`, `mailto:`: the policy
1977
+ * has already removed any other) and `//host/path` are `external`, `#section` is an `anchor`, and
1978
+ * a path that starts with one `/` and matches a route is `internal`. The query and the fragment
1979
+ * do not count, and nor does a trailing slash, so `/en/blog/x?utm=1#top` names the entry that
1980
+ * `/en/blog/x/` does. Any other path, absolute or relative, is `site`.
1981
+ */
1982
+ function classifyLink(href, routes) {
1983
+ const value = trimLeadingControls(href);
1984
+ if (value.startsWith("#")) return { kind: "anchor" };
1985
+ if (urlScheme(value) !== void 0 || value.startsWith("//")) return { kind: "external" };
1986
+ if (value.startsWith("/")) {
1987
+ const end = value.search(/[?#]/);
1988
+ const target = matchRoute(end === -1 ? value : value.slice(0, end), routes);
1989
+ if (target !== void 0) return {
1990
+ kind: "internal",
1991
+ target
1992
+ };
1993
+ }
1994
+ return { kind: "site" };
1995
+ }
1996
+ //#endregion
1997
+ //#region src/markdown/slug.ts
1998
+ /**
1999
+ * Hollware's own heading ids (ADR-020): Comark's drop every letter outside ASCII, so `Diseño`
2000
+ * would become `diseo`. The id is the text in lowercase, with the letters, digits and marks of
2001
+ * every script kept, whitespace and hyphens collapsed into one hyphen and anything else removed.
2002
+ */
2003
+ function slugify(text) {
2004
+ const slug = text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}_\s-]+/gu, "").replace(/[\s-]+/g, "-").replace(/^-+|-+$/g, "");
2005
+ return slug === "" ? "section" : slug;
2006
+ }
2007
+ /** Hands out the ids of one document: the second `Setup` is `setup-1`, the third `setup-2`. */
2008
+ var HeadingIds = class {
2009
+ #used = /* @__PURE__ */ new Set();
2010
+ #next = /* @__PURE__ */ new Map();
2011
+ /** The id for a heading with this text, different from every id handed out before. */
2012
+ next(text) {
2013
+ const base = slugify(text);
2014
+ let count = this.#next.get(base) ?? 0;
2015
+ let id = count === 0 ? base : `${base}-${count}`;
2016
+ while (this.#used.has(id)) {
2017
+ count++;
2018
+ id = `${base}-${count}`;
2019
+ }
2020
+ this.#used.add(id);
2021
+ this.#next.set(base, count + 1);
2022
+ return id;
2023
+ }
2024
+ };
2025
+ //#endregion
2026
+ //#region src/markdown/analyze.ts
2027
+ const HEADING_LEVEL = /* @__PURE__ */ new Map([
2028
+ ["h1", 1],
2029
+ ["h2", 2],
2030
+ ["h3", 3],
2031
+ ["h4", 4],
2032
+ ["h5", 5],
2033
+ ["h6", 6]
2034
+ ]);
2035
+ function isProps$1(value) {
2036
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2037
+ }
2038
+ /** What `\s` matches, which is what separates the words of a text. */
2039
+ function isSpace(code) {
2040
+ return code === 32 || code >= 9 && code <= 13 || code === 160 || code === 5760 || code >= 8192 && code <= 8202 || code === 8232 || code === 8233 || code === 8239 || code === 8287 || code === 12288 || code === 65279;
2041
+ }
2042
+ function countNewlines(text) {
2043
+ let count = 0;
2044
+ let at = text.indexOf("\n");
2045
+ while (at !== -1) {
2046
+ count++;
2047
+ at = text.indexOf("\n", at + 1);
2048
+ }
2049
+ return count;
2050
+ }
2051
+ function addText(state, text) {
2052
+ for (let at = 0; at < text.length; at++) if (isSpace(text.charCodeAt(at))) state.inWord = false;
2053
+ else if (!state.inWord) {
2054
+ state.inWord = true;
2055
+ state.words++;
2056
+ }
2057
+ }
2058
+ /** The text of a node, without its marks. */
2059
+ function plainText(node, depth) {
2060
+ let text = "";
2061
+ for (let at = 2; at < node.length; at++) {
2062
+ const child = node[at];
2063
+ if (typeof child === "string") text += child;
2064
+ else if (Array.isArray(child) && depth < 100) text += plainText(child, depth + 1);
2065
+ }
2066
+ return text;
2067
+ }
2068
+ /** Takes the line the markdown-it rule left in the props under `key`, as a line of the file, or `undefined`. */
2069
+ function takeLine(props, key, lineOffset) {
2070
+ if (!Object.hasOwn(props, key)) return void 0;
2071
+ const value = props[key];
2072
+ delete props[key];
2073
+ const line = typeof value === "string" ? Number.parseInt(value, 10) : NaN;
2074
+ return Number.isSafeInteger(line) && line >= 0 ? line + 1 + lineOffset : void 0;
2075
+ }
2076
+ /** Reads and removes the node at the end of the document that lists what was left out. */
2077
+ function takeLeftOut(nodes, lineOffset) {
2078
+ const found = /* @__PURE__ */ new Map();
2079
+ const last = nodes[nodes.length - 1];
2080
+ if (!Array.isArray(last) || last[0] !== "hollware-left-out:") return found;
2081
+ nodes.pop();
2082
+ const props = last[1];
2083
+ if (!isProps$1(props)) return found;
2084
+ for (const key of Object.keys(props)) {
2085
+ const id = PROPS_KEY.exec(key)?.[1];
2086
+ const value = props[key];
2087
+ if (id === void 0 || typeof value !== "string") continue;
2088
+ const [line = "", tag = "", given = "", ...text] = value.split("\0");
2089
+ const at = Number.parseInt(line, 10);
2090
+ if (Number.isSafeInteger(at) && at >= 0) found.set(Number(id), {
2091
+ line: at + 1 + lineOffset,
2092
+ tag,
2093
+ given: given === "1",
2094
+ text: text.join("\0")
2095
+ });
2096
+ }
2097
+ return found;
2098
+ }
2099
+ /** Removes the attributes that name the `{...}` an element took, and remembers which. */
2100
+ function takeProps(state, props) {
2101
+ for (const key of Object.keys(props)) {
2102
+ const id = PROPS_KEY.exec(key)?.[1];
2103
+ if (id !== void 0) {
2104
+ delete props[key];
2105
+ state.taken.add(Number(id));
2106
+ }
2107
+ }
2108
+ }
2109
+ function record(state, kind, name, line) {
2110
+ const shortName = shorten(name);
2111
+ const key = `${kind}\u0000${shortName}\u0000${line}`;
2112
+ if (state.reported.has(key)) return;
2113
+ state.reported.add(key);
2114
+ state.disallowed.push({
2115
+ kind,
2116
+ name: shortName,
2117
+ line
2118
+ });
2119
+ }
2120
+ function markerNode(kind, name, line, children) {
2121
+ return [
2122
+ MARKER_TAG,
2123
+ {
2124
+ kind,
2125
+ name: shorten(name),
2126
+ line
2127
+ },
2128
+ ...children
2129
+ ];
2130
+ }
2131
+ /** The props of a component as its schema should see them: Comark stores `{open}` as `":open": "true"`. */
2132
+ function decodeProps(props) {
2133
+ const decoded = {};
2134
+ for (const key of Object.keys(props)) {
2135
+ const value = props[key];
2136
+ if (key.startsWith(":")) setOwn(decoded, key.slice(1), typeof value === "string" ? parseJson(value) : value);
2137
+ else setOwn(decoded, key, value);
2138
+ }
2139
+ return decoded;
2140
+ }
2141
+ /** What kind of URL an attribute holds, if it holds one. */
2142
+ function urlKind(tag, key, native) {
2143
+ if (native) {
2144
+ if (tag === "a" && key === "href") return "link";
2145
+ return tag === "img" && key === "src" ? "image" : void 0;
2146
+ }
2147
+ const name = attributeName(key);
2148
+ if (name === "href" || name === "xlinkhref") return "link";
2149
+ return name === "src" ? "image" : void 0;
2150
+ }
2151
+ /**
2152
+ * Takes off an element every attribute its tag may not carry and every URL it may not point
2153
+ * to. A native tag has an allowlist (`policy.ts`). A component takes the props its schema
2154
+ * declares, which only the contracts know, so here it loses only what is never safe: event
2155
+ * handlers, attributes that write HTML and unsafe `href` and `src`.
2156
+ */
2157
+ function cleanProps(state, tag, props, native, line) {
2158
+ for (const key of Object.keys(props)) {
2159
+ const value = props[key];
2160
+ if (native ? !isAllowedAttribute(tag, key, value) : isForbiddenAttribute(attributeName(key))) {
2161
+ delete props[key];
2162
+ record(state, "disallowed-attribute", `${tag}[${displayAttribute(key)}]`, line);
2163
+ continue;
2164
+ }
2165
+ const kind = urlKind(tag, key, native);
2166
+ if (kind !== void 0) {
2167
+ const url = urlValue(key, value);
2168
+ if (url === void 0) {
2169
+ delete props[key];
2170
+ record(state, "disallowed-attribute", `${tag}[${displayAttribute(key)}]`, line);
2171
+ } else if (!isSafeUrl(url, kind)) {
2172
+ delete props[key];
2173
+ record(state, "unsafe-url", url, line);
2174
+ }
2175
+ } else if (!native && typeof value === "object" && value !== null && jsonProblem(value) !== void 0) state.problem ??= "props";
2176
+ }
2177
+ }
2178
+ /**
2179
+ * Gives a span back the brackets Comark took off it. Comark reads `[text]` as a span, for
2180
+ * `[text]{.class}`, and prints the text without the brackets, so `array[0]` would read `array0`.
2181
+ * Hollware allows no attribute on a span, so a span is always text that had brackets.
2182
+ */
2183
+ function bracket(node) {
2184
+ const last = node.length - 1;
2185
+ const end = node[last];
2186
+ if (last >= 2 && typeof end === "string") node[last] = `${end}]`;
2187
+ else node.push("]");
2188
+ const first = node[2];
2189
+ if (typeof first === "string") node[2] = `[${first}`;
2190
+ else node.splice(2, 0, "[");
2191
+ }
2192
+ function collectLink(state, props, line) {
2193
+ const href = props.href;
2194
+ if (typeof href !== "string") return;
2195
+ const { kind, target } = classifyLink(href, state.options.routes);
2196
+ state.links.push(target === void 0 ? {
2197
+ href,
2198
+ kind,
2199
+ line
2200
+ } : {
2201
+ href,
2202
+ kind,
2203
+ target,
2204
+ line
2205
+ });
2206
+ }
2207
+ function collectImage(state, props, line) {
2208
+ const src = props.src;
2209
+ if (typeof src !== "string" || src === "") return;
2210
+ const alt = props.alt;
2211
+ state.images.push({
2212
+ src,
2213
+ alt: typeof alt === "string" ? alt : "",
2214
+ line
2215
+ });
2216
+ }
2217
+ /**
2218
+ * Judges the children of a node, in order, and returns the line the next sibling starts on.
2219
+ * A line is counted from the block that carries it: every line break in the text (a soft break)
2220
+ * and every hard break moves it down one.
2221
+ */
2222
+ function walk(state, list, start, firstLine, depth, inPre) {
2223
+ let cursor = firstLine;
2224
+ for (let at = start; at < list.length; at++) {
2225
+ const node = list[at];
2226
+ if (typeof node === "string") {
2227
+ if (!inPre) addText(state, node);
2228
+ cursor += countNewlines(node);
2229
+ } else if (Array.isArray(node)) cursor = visit(state, list, at, node, cursor, depth, inPre);
2230
+ }
2231
+ return cursor;
2232
+ }
2233
+ function visit(state, list, at, node, cursor, depth, inPre) {
2234
+ if (depth > 100) {
2235
+ state.problem ??= "nesting";
2236
+ return cursor;
2237
+ }
2238
+ const tag = node[0];
2239
+ let props;
2240
+ if (isProps$1(node[1])) props = node[1];
2241
+ else {
2242
+ props = {};
2243
+ node[1] = props;
2244
+ }
2245
+ const open = takeLine(props, OPEN_KEY, state.options.lineOffset);
2246
+ const content = takeLine(props, LINE_KEY, state.options.lineOffset);
2247
+ const line = open ?? content ?? cursor;
2248
+ if (state.leftOut.size > 0) takeProps(state, props);
2249
+ if (typeof tag !== "string") {
2250
+ list[at] = markerNode("raw-html", "!--", line, []);
2251
+ record(state, "raw-html", "!--", line);
2252
+ state.inWord = false;
2253
+ return line;
2254
+ }
2255
+ if (tag.startsWith("hollware-raw:")) {
2256
+ const name = tag.slice(13);
2257
+ list[at] = markerNode("raw-html", name, line, []);
2258
+ record(state, "raw-html", name, line);
2259
+ state.inWord = false;
2260
+ return line;
2261
+ }
2262
+ const native = NATIVE_TAGS.has(tag);
2263
+ if (!native && !state.options.components.has(tag)) {
2264
+ const marker = markerNode("unknown-component", tag, line, node.slice(2));
2265
+ list[at] = marker;
2266
+ record(state, "unknown-component", tag, line);
2267
+ state.inWord = false;
2268
+ const end = walk(state, marker, 2, content ?? line, depth + 1, inPre);
2269
+ state.inWord = false;
2270
+ return end;
2271
+ }
2272
+ cleanProps(state, tag, props, native, line);
2273
+ if (tag === "span") bracket(node);
2274
+ if (!native) state.components.push({
2275
+ name: tag,
2276
+ props: decodeProps(props),
2277
+ line
2278
+ });
2279
+ if (tag === "a") collectLink(state, props, line);
2280
+ else if (tag === "img") collectImage(state, props, line);
2281
+ const inline = INLINE_TAGS.has(tag);
2282
+ if (!inline) state.inWord = false;
2283
+ if (tag === "br") return line + 1;
2284
+ const end = walk(state, node, 2, content ?? line, depth + 1, inPre || tag === "pre");
2285
+ if (!inline) state.inWord = false;
2286
+ const level = HEADING_LEVEL.get(tag);
2287
+ if (level !== void 0) {
2288
+ const text = plainText(node, depth);
2289
+ const id = state.ids.next(text);
2290
+ props.id = id;
2291
+ state.headings.push({
2292
+ depth: level,
2293
+ text,
2294
+ id,
2295
+ line
2296
+ });
2297
+ }
2298
+ return end;
2299
+ }
2300
+ /**
2301
+ * Judges and extracts a document: see the comment at the top of this file. The `words` are
2302
+ * the whitespace-separated words of the text outside code blocks, with the headings, the list
2303
+ * items, the link text and the text inside components, and without URLs and the marks of Markdown.
2304
+ */
2305
+ function analyzeDocument(document, options) {
2306
+ const state = {
2307
+ options,
2308
+ leftOut: takeLeftOut(document.nodes, options.lineOffset),
2309
+ taken: /* @__PURE__ */ new Set(),
2310
+ headings: [],
2311
+ links: [],
2312
+ images: [],
2313
+ components: [],
2314
+ disallowed: [],
2315
+ reported: /* @__PURE__ */ new Set(),
2316
+ ids: new HeadingIds(),
2317
+ words: 0,
2318
+ inWord: false,
2319
+ problem: void 0
2320
+ };
2321
+ walk(state, document.nodes, 0, options.lineOffset + 1, 1, false);
2322
+ for (const [id, item] of state.leftOut) if (!item.given || !state.taken.has(id)) record(state, "disallowed-attribute", `${item.tag}[${item.text}]`, item.line);
2323
+ state.disallowed.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
2324
+ return {
2325
+ extracted: {
2326
+ headings: state.headings,
2327
+ links: state.links,
2328
+ images: state.images,
2329
+ components: state.components,
2330
+ words: state.words,
2331
+ disallowed: state.disallowed
2332
+ },
2333
+ problem: state.problem
2334
+ };
2335
+ }
2336
+ //#endregion
2337
+ //#region src/markdown/parser.ts
2338
+ /** The block tokens whose attributes reach the node, so that the line they start on can travel with them. */
2339
+ const LINE_TOKENS = /* @__PURE__ */ new Set([
2340
+ "paragraph_open",
2341
+ "heading_open",
2342
+ "blockquote_open",
2343
+ "bullet_list_open",
2344
+ "ordered_list_open",
2345
+ "list_item_open",
2346
+ "table_open",
2347
+ "thead_open",
2348
+ "tbody_open",
2349
+ "tr_open"
2350
+ ]);
2351
+ /** The tokens of a component, whose line is kept apart from the line of its content. */
2352
+ const OPEN_TOKENS = /* @__PURE__ */ new Set(["mdc_block_open", "mdc_block_shorthand"]);
2353
+ const TAG_NAME$1 = /^\s*<\/?([A-Za-z][A-Za-z0-9-]*)/;
2354
+ const DECLARATION = /^\s*<!([A-Za-z]+)/;
2355
+ /** What a piece of raw HTML is called in a marker: its tag, or `!--` for a comment, `?` for an instruction. */
2356
+ function rawHtmlName(content) {
2357
+ const tag = TAG_NAME$1.exec(content)?.[1];
2358
+ if (tag !== void 0) return tag.toLowerCase();
2359
+ const start = content.trimStart();
2360
+ if (start.startsWith("<!--")) return "!--";
2361
+ if (start.startsWith("<![CDATA[")) return "![cdata[";
2362
+ const declaration = DECLARATION.exec(start)?.[1];
2363
+ return declaration === void 0 ? "?" : `!${declaration.toLowerCase()}`;
2364
+ }
2365
+ function skipSpaces(text, from) {
2366
+ let at = from;
2367
+ while (text[at] === " " || text[at] === " ") at++;
2368
+ return at;
2369
+ }
2370
+ /** The `]` that closes the `[` at `open`, with Comark's rules: a backslash hides a character and brackets nest. */
2371
+ function closingBracket(text, open) {
2372
+ let depth = 0;
2373
+ for (let at = open + 1; at < text.length; at++) {
2374
+ const char = text[at];
2375
+ if (char === "\\") at++;
2376
+ else if (char === "[") depth++;
2377
+ else if (char === "]") {
2378
+ if (depth === 0) return at;
2379
+ depth--;
2380
+ }
2381
+ }
2382
+ return -1;
2383
+ }
2384
+ /** The `}` that closes the `{` at `open`: quotes hide their braces, and braces nest. */
2385
+ function closingBrace(text, open) {
2386
+ let depth = 0;
2387
+ for (let at = open + 1; at < text.length; at++) {
2388
+ const char = text[at];
2389
+ if (char === "\\") at++;
2390
+ else if (char === "\"" || char === "'" || char === "`") {
2391
+ let end = at + 1;
2392
+ while (end < text.length && text[end] !== char) end += text[end] === "\\" ? 2 : 1;
2393
+ if (end >= text.length) return -1;
2394
+ at = end;
2395
+ } else if (char === "{") depth++;
2396
+ else if (char === "}") {
2397
+ if (depth === 0) return at;
2398
+ depth--;
2399
+ }
2400
+ }
2401
+ return -1;
2402
+ }
2403
+ /**
2404
+ * The text that follows the name, the `[content]` and the `{props}` on the line that opens a
2405
+ * component (`::callout{type="tip"} and this`). Comark takes the line for the component and
2406
+ * leaves what follows out of the document. It is empty when there is nothing after them, and
2407
+ * when the line is too odd to tell where they end.
2408
+ */
2409
+ function openTail(line) {
2410
+ const fence = line.indexOf("::");
2411
+ if (fence === -1) return "";
2412
+ let at = fence;
2413
+ while (line[at] === ":") at++;
2414
+ at = skipSpaces(line, at);
2415
+ const name = /^[A-Za-z$][$\w.-]*/.exec(line.slice(at));
2416
+ if (name === null) return "";
2417
+ at = skipSpaces(line, at + name[0].length);
2418
+ if (line[at] === "[") {
2419
+ const close = closingBracket(line, at);
2420
+ if (close === -1) return "";
2421
+ at = skipSpaces(line, close + 1);
2422
+ }
2423
+ if (line[at] === "{") {
2424
+ const close = closingBrace(line, at);
2425
+ if (close === -1) return "";
2426
+ at = skipSpaces(line, close + 1);
2427
+ }
2428
+ return line.slice(at).trim();
2429
+ }
2430
+ /**
2431
+ * Runs once the block and inline rules have made their tokens. It leaves no token that the
2432
+ * analysis could not judge:
2433
+ * - Raw HTML becomes an element no author can write (`hollware-raw:script`), so the content of
2434
+ * the tag never reaches the document and the analysis can report it with its line.
2435
+ * - A block that Comark turns into a node gets the line it starts on, for the same reason.
2436
+ * - A link reference definition, which the links that use it have already read, leaves no token:
2437
+ * Comark would make an empty `component` node of it.
2438
+ * - Text that Comark's syntax takes and leaves out of the document is listed in a node at the
2439
+ * end of the document, for the analysis to report: every `{...}` (Comark reads it as
2440
+ * attributes, and drops it wherever no element takes them, which includes any `{` in prose that
2441
+ * is not closed), and whatever follows the parameters on the line that opens a component.
2442
+ */
2443
+ function rewriteTokens(state) {
2444
+ const { tokens } = state;
2445
+ const leftOut = [];
2446
+ let lines;
2447
+ let blockTag = "p";
2448
+ let blockLine = 0;
2449
+ let kept = 0;
2450
+ for (let at = 0; at < tokens.length; at++) {
2451
+ const token = tokens[at];
2452
+ if (token === void 0 || token.type === "reference") continue;
2453
+ if (token.map !== null) blockLine = token.map[0];
2454
+ if (token.nesting === 1 && token.tag !== "") blockTag = token.tag;
2455
+ if (token.type === "html_block") {
2456
+ const raw = new state.Token("hollware_raw_html", RAW_HTML_PREFIX + rawHtmlName(token.content), 0);
2457
+ if (token.map !== null) raw.attrs = [[LINE_KEY, String(token.map[0])]];
2458
+ tokens[kept++] = raw;
2459
+ continue;
2460
+ }
2461
+ if (token.type === "inline" && token.children !== null) {
2462
+ const { children } = token;
2463
+ let line = blockLine;
2464
+ for (let child = 0; child < children.length; child++) {
2465
+ const inner = children[child];
2466
+ if (inner === void 0) continue;
2467
+ if (inner.type === "html_inline") children[child] = new state.Token("mdc_inline_component", RAW_HTML_PREFIX + rawHtmlName(inner.content), 0);
2468
+ else if (inner.type === "mdc_inline_props") {
2469
+ const given = inner.attrs !== null && inner.attrs.length > 0;
2470
+ inner.attrs ??= [];
2471
+ inner.attrs.push([`$p${leftOut.length}`, "1"]);
2472
+ leftOut.push([
2473
+ String(line),
2474
+ blockTag,
2475
+ given ? "1" : "0",
2476
+ inner.content
2477
+ ]);
2478
+ } else if (inner.type === "softbreak" || inner.type === "hardbreak") line++;
2479
+ }
2480
+ } else if (token.map !== null && token.nesting >= 0) {
2481
+ const key = OPEN_TOKENS.has(token.type) ? OPEN_KEY : LINE_TOKENS.has(token.type) ? LINE_KEY : void 0;
2482
+ if (key !== void 0) {
2483
+ token.attrs ??= [];
2484
+ token.attrs.push([key, String(token.map[0])]);
2485
+ }
2486
+ if (token.type === "mdc_block_open") {
2487
+ lines ??= state.src.split("\n");
2488
+ const tail = openTail(lines[token.map[0]] ?? "");
2489
+ if (tail !== "") leftOut.push([
2490
+ String(token.map[0]),
2491
+ token.tag,
2492
+ "0",
2493
+ tail
2494
+ ]);
2495
+ }
2496
+ }
2497
+ tokens[kept++] = token;
2498
+ }
2499
+ tokens.length = kept;
2500
+ if (leftOut.length > 0) {
2501
+ const list = new state.Token("hollware_left_out", LEFT_OUT_TAG, 0);
2502
+ list.attrs = leftOut.map((entry, id) => [`$p${id}`, entry.join("\0")]);
2503
+ tokens.push(list);
2504
+ }
2505
+ }
2506
+ /**
2507
+ * Keeps the text of every `{...}` that Comark reads as attributes in the content of its token,
2508
+ * which Comark leaves empty, so that the analysis can say what was left out. The rule is
2509
+ * Comark's own, found by its name in the rules of markdown-it.
2510
+ */
2511
+ function keepPropsText(md) {
2512
+ const rules = Reflect.get(md.inline.ruler, "__rules__");
2513
+ const rule = Array.isArray(rules) ? rules.find((entry) => typeof entry === "object" && entry !== null && Reflect.get(entry, "name") === "comark_inline_props") : void 0;
2514
+ const original = typeof rule === "object" && rule !== null ? Reflect.get(rule, "fn") : void 0;
2515
+ if (typeof original !== "function") throw new Error("Comark no longer has the inline rule `comark_inline_props`");
2516
+ md.inline.ruler.at("comark_inline_props", (state, silent) => {
2517
+ const start = state.pos;
2518
+ const matched = Reflect.apply(original, void 0, [state, silent]) === true;
2519
+ if (matched && !silent) {
2520
+ const token = state.tokens[state.tokens.length - 1];
2521
+ if (token?.type === "mdc_inline_props") token.content = state.src.slice(start, state.pos);
2522
+ }
2523
+ return matched;
2524
+ });
2525
+ }
2526
+ /**
2527
+ * Hollware's settings for Comark's markdown-it:
2528
+ * - `html` is on only so that raw HTML is tokenized, never parsed: the rule above replaces it.
2529
+ * - Any destination is let through, so that a link or an image with an unsafe URL is a link
2530
+ * the analysis can report, not text that Markdown left behind without a word.
2531
+ * - A bare `README.md` is not a link to the host `readme.md`; `https://…` and e-mail addresses
2532
+ * still are.
2533
+ * - A component takes its props in `{...}`. The YAML block form (`---` under the component)
2534
+ * is off: its anchors and aliases can build a value of billions of nodes, and Comark writes
2535
+ * that value out as JSON while it parses: a file of 1 KB cost it 9.5 seconds of CPU with seven
2536
+ * levels of anchors and more than 40 with nine.
2537
+ */
2538
+ function hollwareMarkdownIt(md) {
2539
+ md.set({ html: true });
2540
+ md.validateLink = () => true;
2541
+ md.linkify.set({ fuzzyLink: false });
2542
+ md.block.ruler.disable("comark_block_yaml");
2543
+ keepPropsText(md);
2544
+ md.core.ruler.push("hollware", rewriteTokens);
2545
+ }
2546
+ const hollware = {
2547
+ name: "hollware",
2548
+ markdownItPlugins: [hollwareMarkdownIt]
2549
+ };
2550
+ async function createParser() {
2551
+ const [comark, components, attributes, taskList, alert] = await Promise.all([
2552
+ import("comark"),
2553
+ import("comark/plugins/components"),
2554
+ import("comark/plugins/attributes"),
2555
+ import("comark/plugins/task-list"),
2556
+ import("comark/plugins/alert")
2557
+ ]);
2558
+ const parse = comark.createMarkdownParser({
2559
+ autoClose: false,
2560
+ registerDefaultPlugins: false,
2561
+ headingIds: false,
2562
+ plugins: [
2563
+ components.default(),
2564
+ attributes.default(),
2565
+ taskList.default(),
2566
+ hollware,
2567
+ alert.default()
2568
+ ]
2569
+ });
2570
+ return {
2571
+ splitFrontmatter: (markdown) => comark.parseFrontmatter(markdown),
2572
+ parseBody: (markdown) => parse(markdown)
2573
+ };
2574
+ }
2575
+ let parser;
2576
+ /** The parser of this isolate, built the first time it is asked for. */
2577
+ function getParser() {
2578
+ parser ??= createParser().catch((error) => {
2579
+ parser = void 0;
2580
+ throw error;
2581
+ });
2582
+ return parser;
2583
+ }
2584
+ //#endregion
2585
+ //#region src/markdown/violations.ts
2586
+ function refusal(contract, path, message, fix, line) {
2587
+ const violation = {
2588
+ contract,
2589
+ severity: "error",
2590
+ path,
2591
+ message,
2592
+ fix
2593
+ };
2594
+ return line === void 0 ? violation : {
2595
+ ...violation,
2596
+ line
2597
+ };
2598
+ }
2599
+ function sizeLimit(bytes, limit) {
2600
+ return refusal("size-limit", "/body", `The Markdown file is ${bytes} bytes and this collection accepts at most ${limit}.`, `Shorten the file to ${limit} bytes or fewer, counting the frontmatter, or split the content into several entries.`);
2601
+ }
2602
+ const YAML_FIX = "Put a space after every colon, indent nested keys with spaces (never tabs), quote any value that contains a colon, a hash or a bracket, and give each key only once.";
2603
+ /** YAML that does not parse. `line` is the line of the file, when the parser says where. */
2604
+ function yamlSyntax(reason, line, column) {
2605
+ return refusal("frontmatter-syntax", "/frontmatter", `The frontmatter is not valid YAML: ${reason}${line === void 0 ? "" : ` (line ${line}${column === void 0 ? "" : `, column ${column}`})`}.`, `Fix the YAML between the two --- lines at the top of the file${line === void 0 ? "" : `, at line ${line}`}. ${YAML_FIX}`, line);
2606
+ }
2607
+ /** Frontmatter that parses to something other than a mapping, such as a list or a sentence. */
2608
+ function frontmatterNotMapping(found) {
2609
+ return refusal("frontmatter-syntax", "/frontmatter", `The frontmatter must be a YAML mapping of keys to values, but the file starts with ${found}.`, "Start the file with a line of three dashes, one \"key: value\" line for each field and a closing line of three dashes. If the dashes at the top were meant as a horizontal rule, remove them or write *** instead.", 1);
2610
+ }
2611
+ const JSON_PROBLEMS = {
2612
+ shared: "a YAML anchor or alias, which Hollware does not store",
2613
+ depth: `a value nested more than 100 levels deep`,
2614
+ number: "a number that is not finite",
2615
+ type: "a value that is not text, a number, a boolean, null, a list or a mapping"
2616
+ };
2617
+ /** Frontmatter that cannot be stored as JSON. */
2618
+ function frontmatterNotJson(problem) {
2619
+ return refusal("frontmatter-syntax", "/frontmatter", `The frontmatter holds ${JSON_PROBLEMS[problem]}.`, problem === "shared" ? "Remove the &anchor and *alias markers and write every value out in full." : "Replace the value with plain text, a number, a boolean, null, a list or a mapping of them.", 1);
2620
+ }
2621
+ const PARSE_FIX = "Simplify the Markdown: flatten deep nesting of emphasis, lists, quotes and components, and balance every bracket and brace. If it still fails, split the entry to find the part that breaks.";
2622
+ /** Anything the parser or the analysis could not get through. */
2623
+ function parseFailure(error) {
2624
+ return refusal("comark-parse", "/body", `Comark could not parse the Markdown (${(error instanceof Error ? `${error.name}: ${error.message.split("\n")[0] ?? ""}` : String(error)).slice(0, 200)}).`, PARSE_FIX);
2625
+ }
2626
+ function notAString() {
2627
+ return refusal("comark-parse", "/body", "The Markdown is not a string.", "Send the whole file, frontmatter and body, as one string.");
2628
+ }
2629
+ /** A document the parser accepted but the pipeline cannot store. */
2630
+ function unstorable(problem) {
2631
+ return refusal("comark-parse", "/body", problem === "nesting" ? `The document nests elements more than 100 levels deep.` : `A component prop holds a value that nests more than 100 levels deep or is not JSON.`, problem === "nesting" ? "Flatten the nesting of emphasis, lists, quotes and components." : "Give the component a smaller, flatter value, written as JSON.");
2632
+ }
2633
+ //#endregion
2634
+ //#region src/markdown/versions.ts
2635
+ /**
2636
+ * The version of Comark that parses the Markdown, which every revision stores as `comark_version`.
2637
+ * Comark exports no version at runtime, so this constant is the one place that names it, and
2638
+ * `tests/markdown/versions.test.ts` binds it to the package that is installed.
2639
+ */
2640
+ const COMARK_VERSION = "0.7.0";
2641
+ /**
2642
+ * The version of Hollware's own pipeline settings: the plugins, the allowlists, the heading ids,
2643
+ * the markers and the line numbers (ADR-020). Bump it whenever the same Markdown would give another
2644
+ * document or other extracted data, so that `reparse` knows which revisions to rebuild.
2645
+ * `tests/markdown/versions.test.ts` pins the output for the shared fixtures, so a change that
2646
+ * forgets the bump fails there.
2647
+ */
2648
+ const PIPELINE_VERSION = "1";
2649
+ //#endregion
2650
+ //#region src/markdown/process.ts
2651
+ const BYTE_ORDER_MARK = 65279;
2652
+ /** The size of a string in UTF-8, which is what D1 stores: a lone surrogate is written as U+FFFD, three bytes. */
2653
+ function utf8ByteLength(text) {
2654
+ let bytes = 0;
2655
+ for (let at = 0; at < text.length; at++) {
2656
+ const code = text.charCodeAt(at);
2657
+ if (code < 128) bytes += 1;
2658
+ else if (code < 2048) bytes += 2;
2659
+ else if (code >= 55296 && code <= 56319 && at + 1 < text.length) {
2660
+ const next = text.charCodeAt(at + 1);
2661
+ if (next >= 56320 && next <= 57343) {
2662
+ bytes += 4;
2663
+ at++;
2664
+ } else bytes += 3;
2665
+ } else bytes += 3;
2666
+ }
2667
+ return bytes;
2668
+ }
2669
+ function isRecord(value) {
2670
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2671
+ }
2672
+ function describe(value) {
2673
+ if (Array.isArray(value)) return "a list";
2674
+ if (typeof value === "string") return "text";
2675
+ if (typeof value === "number") return "a number";
2676
+ if (typeof value === "boolean") return "a boolean";
2677
+ return "something else";
2678
+ }
2679
+ function isYamlError(error) {
2680
+ return error instanceof Error && error.name === "YAMLException";
2681
+ }
2682
+ /**
2683
+ * How many lines of the file come before the body: the frontmatter and its two fences. The
2684
+ * first line of the body is then `lines + 1`. A carriage return that is followed by a line feed is
2685
+ * one break, even when Comark cut it in two, with the carriage return in the frontmatter and the
2686
+ * line feed in the body.
2687
+ */
2688
+ function linesBeforeBody(source, body) {
2689
+ if (!source.endsWith(body)) return 0;
2690
+ const end = source.length - body.length;
2691
+ let lines = 0;
2692
+ for (let at = 0; at < end; at++) {
2693
+ const code = source.charCodeAt(at);
2694
+ if (code === 10 || code === 13 && source.charCodeAt(at + 1) !== 10) lines++;
2695
+ }
2696
+ return lines;
2697
+ }
2698
+ /** The line of the file that a YAML error is on: the parser counts from the first line after the opening fence. */
2699
+ function yamlViolation(error, source) {
2700
+ const reason = Reflect.get(error, "reason");
2701
+ const mark = Reflect.get(error, "mark");
2702
+ const markLine = isRecord(mark) ? mark.line : void 0;
2703
+ const markColumn = isRecord(mark) ? mark.column : void 0;
2704
+ const firstLine = source.startsWith("---\r\n") ? 1 : 2;
2705
+ return yamlSyntax(typeof reason === "string" ? reason : error.message.split("\n")[0] ?? "it does not parse", typeof markLine === "number" ? markLine + firstLine : void 0, typeof markColumn === "number" ? markColumn + 1 : void 0);
2706
+ }
2707
+ async function run(markdown, options) {
2708
+ if (typeof markdown !== "string") return {
2709
+ ok: false,
2710
+ violations: [notAString()]
2711
+ };
2712
+ const bytes = utf8ByteLength(markdown);
2713
+ if (!(bytes <= options.maxMarkdownBytes)) return {
2714
+ ok: false,
2715
+ violations: [sizeLimit(bytes, options.maxMarkdownBytes)]
2716
+ };
2717
+ const source = markdown.charCodeAt(0) === BYTE_ORDER_MARK ? markdown.slice(1) : markdown;
2718
+ const parser = await getParser();
2719
+ let split;
2720
+ try {
2721
+ split = parser.splitFrontmatter(source);
2722
+ } catch (error) {
2723
+ return {
2724
+ ok: false,
2725
+ violations: [isYamlError(error) ? yamlViolation(error, source) : parseFailure(error)]
2726
+ };
2727
+ }
2728
+ const frontmatter = split.data;
2729
+ if (!isRecord(frontmatter)) return {
2730
+ ok: false,
2731
+ violations: [frontmatterNotMapping(describe(frontmatter))]
2732
+ };
2733
+ const problem = jsonProblem(frontmatter);
2734
+ if (problem !== void 0) return {
2735
+ ok: false,
2736
+ violations: [frontmatterNotJson(problem)]
2737
+ };
2738
+ const document = await parser.parseBody(split.content);
2739
+ document.frontmatter = frontmatter;
2740
+ const analysis = analyzeDocument(document, {
2741
+ components: new Set(options.components),
2742
+ routes: compileRoutes(options.routes),
2743
+ lineOffset: linesBeforeBody(source, split.content)
2744
+ });
2745
+ if (analysis.problem !== void 0) return {
2746
+ ok: false,
2747
+ violations: [unstorable(analysis.problem)]
2748
+ };
2749
+ return {
2750
+ ok: true,
2751
+ value: {
2752
+ frontmatter,
2753
+ document,
2754
+ extracted: analysis.extracted,
2755
+ comarkVersion: COMARK_VERSION,
2756
+ pipelineVersion: "1"
2757
+ }
2758
+ };
2759
+ }
2760
+ /**
2761
+ * Processes a Markdown file for a collection (ADR-001, ADR-020): the frontmatter and the body
2762
+ * are parsed once and the contracts get what they need from the result. It answers for any
2763
+ * string and never throws: a file it cannot store comes back as violations.
2764
+ */
2765
+ const processMarkdown = async (markdown, options) => {
2766
+ try {
2767
+ return await run(markdown, options);
2768
+ } catch (error) {
2769
+ return {
2770
+ ok: false,
2771
+ violations: [parseFailure(error)]
2772
+ };
2773
+ }
2774
+ };
2775
+ //#endregion
2776
+ //#region src/markdown/render.ts
2777
+ /** HTML elements that load, run or restyle something, or change how the HTML after them is read. */
2778
+ const DANGEROUS_TAGS = /* @__PURE__ */ new Set([
2779
+ "script",
2780
+ "style",
2781
+ "iframe",
2782
+ "frame",
2783
+ "frameset",
2784
+ "object",
2785
+ "embed",
2786
+ "applet",
2787
+ "base",
2788
+ "link",
2789
+ "meta",
2790
+ "form",
2791
+ "svg",
2792
+ "math",
2793
+ "noscript",
2794
+ "noembed",
2795
+ "noframes",
2796
+ "xmp",
2797
+ "plaintext",
2798
+ "listing",
2799
+ "textarea",
2800
+ "title",
2801
+ "select",
2802
+ "option",
2803
+ "button",
2804
+ "audio",
2805
+ "video",
2806
+ "source",
2807
+ "track",
2808
+ "picture",
2809
+ "canvas",
2810
+ "portal",
2811
+ "dialog",
2812
+ "slot",
2813
+ "html",
2814
+ "head",
2815
+ "body",
2816
+ "param",
2817
+ "bgsound",
2818
+ "image"
2819
+ ]);
2820
+ /** A tag name that is safe to print: letters, digits and a few separators, starting with a letter. */
2821
+ const TAG_NAME = /^[A-Za-z][\w.-]{0,63}$/;
2822
+ /** The attributes that hold a URL, by the name `attributeName` gives them. */
2823
+ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
2824
+ "href",
2825
+ "xlinkhref",
2826
+ "src",
2827
+ "action",
2828
+ "poster",
2829
+ "background",
2830
+ "cite",
2831
+ "ping"
2832
+ ]);
2833
+ function isProps(value) {
2834
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2835
+ }
2836
+ function sanitizeProps(raw) {
2837
+ const props = {};
2838
+ if (!isProps(raw)) return props;
2839
+ for (const key of Object.keys(raw)) {
2840
+ if (key.startsWith("$")) continue;
2841
+ const name = attributeName(key);
2842
+ if (isForbiddenAttribute(name)) continue;
2843
+ const value = raw[key];
2844
+ if (URL_ATTRIBUTES.has(name)) {
2845
+ const url = urlValue(key, value);
2846
+ if (url === void 0 || !isSafeUrl(url, name === "src" ? "image" : "link")) continue;
2847
+ }
2848
+ setOwn(props, key, typeof value === "string" && !isBinding(key) ? value.replaceAll("&", "&amp;") : value);
2849
+ }
2850
+ return props;
2851
+ }
2852
+ function marker(name) {
2853
+ return [MARKER_TAG, {
2854
+ kind: "raw-html",
2855
+ name: TAG_NAME.test(name) ? shorten(name) : "?"
2856
+ }];
2857
+ }
2858
+ function sanitizeElement(node, depth) {
2859
+ const tag = node[0];
2860
+ if (typeof tag !== "string") return void 0;
2861
+ if (!NATIVE_TAGS.has(tag) && tag !== "hollware-marker") {
2862
+ if (!TAG_NAME.test(tag) || DANGEROUS_TAGS.has(tag.toLowerCase())) return marker(tag);
2863
+ }
2864
+ const element = [tag, sanitizeProps(node[1])];
2865
+ if (depth >= 100) return element;
2866
+ for (let at = 2; at < node.length; at++) {
2867
+ const child = node[at];
2868
+ if (typeof child === "string") element.push(child);
2869
+ else if (Array.isArray(child)) {
2870
+ const clean = sanitizeElement(child, depth + 1);
2871
+ if (clean !== void 0) element.push(clean);
2872
+ }
2873
+ }
2874
+ return element;
2875
+ }
2876
+ /** Copies a document through the rules of this file. */
2877
+ function sanitizeDocument(document) {
2878
+ const nodes = [];
2879
+ if (Array.isArray(document.nodes)) {
2880
+ for (const node of document.nodes) if (typeof node === "string") nodes.push(node);
2881
+ else if (Array.isArray(node)) {
2882
+ const clean = sanitizeElement(node, 1);
2883
+ if (clean !== void 0) nodes.push(clean);
2884
+ }
2885
+ }
2886
+ return {
2887
+ frontmatter: document.frontmatter,
2888
+ meta: document.meta,
2889
+ nodes
2890
+ };
2891
+ }
2892
+ /**
2893
+ * Renders a stored document to HTML with `@comark/html`. `@comark/html` is imported when the
2894
+ * first document is rendered, so that an isolate that never renders never loads it.
2895
+ */
2896
+ const renderHtml = async (document) => {
2897
+ const { renderHtmlFromDocument } = await import("@comark/html");
2898
+ return renderHtmlFromDocument(sanitizeDocument(document));
2899
+ };
2900
+ //#endregion
2901
+ //#region src/operations/schemas.ts
2902
+ var schemas_exports = /* @__PURE__ */ __exportAll({
2903
+ acknowledgedWarning: () => acknowledgedWarning,
2904
+ collectionDescription: () => collectionDescription,
2905
+ collectionName: () => collectionName,
2906
+ componentDescription: () => componentDescription,
2907
+ conflict: () => conflict,
2908
+ contractDescription: () => contractDescription,
2909
+ document: () => document,
2910
+ entry: () => entry,
2911
+ entryId: () => entryId,
2912
+ entryStatus: () => entryStatus,
2913
+ entrySummary: () => entrySummary,
2914
+ format: () => format,
2915
+ locale: () => locale,
2916
+ markdown: () => markdown,
2917
+ phase: () => phase,
2918
+ publication: () => publication,
2919
+ revisionId: () => revisionId,
2920
+ severity: () => severity,
2921
+ siteDescription: () => siteDescription,
2922
+ slug: () => slug,
2923
+ verdict: () => verdict,
2924
+ version: () => version,
2925
+ violation: () => violation
2926
+ });
2927
+ /**
2928
+ * The pieces the operation schemas share. Their descriptions become the JSON Schema that agents
2929
+ * read over MCP, so they are written for the agent.
2930
+ */
2931
+ const collectionName = z.string().min(1).describe("A collection of the site, as describe_site lists it.");
2932
+ const locale = z.string().min(1).describe("A locale of the site, as describe_site lists it.");
2933
+ const slug = z.string().max(200).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).describe("The entry's path segment: lowercase letters and digits joined by hyphens, such as edge-caching-guide.");
2934
+ const entryId = z.string().regex(/^ent_[0-9A-HJKMNP-TV-Z]{26}$/).describe("The id of an entry (ent_…), as the other operations return it.");
2935
+ const revisionId = z.string().regex(/^rev_[0-9A-HJKMNP-TV-Z]{26}$/).describe("The id of a revision (rev_…).");
2936
+ const version = z.number().int().min(1).describe("The version of the entry you read. A change against an older version answers conflict.");
2937
+ const markdown = z.string().describe("The whole Markdown file: the YAML frontmatter between two --- lines, then the body. The collection schema in describe_site says what the frontmatter needs.");
2938
+ const entryStatus = z.enum([
2939
+ "draft",
2940
+ "scheduled",
2941
+ "published"
2942
+ ]);
2943
+ const severity = z.enum(["error", "warning"]);
2944
+ const phase = z.enum(["write", "publish"]);
2945
+ const violation = z.object({
2946
+ contract: z.string().describe("The id of the contract."),
2947
+ severity,
2948
+ path: z.string().describe("A JSON Pointer into { frontmatter, body }: /frontmatter/title, or /body with a line."),
2949
+ message: z.string(),
2950
+ fix: z.string().describe("What to change so that the problem goes away."),
2951
+ line: z.number().int().min(1).optional()
2952
+ });
2953
+ const verdict = z.object({
2954
+ ok: z.boolean().describe("Whether there are no errors. Warnings do not count, but publish asks about them."),
2955
+ phase,
2956
+ errors: z.array(violation),
2957
+ warnings: z.array(violation),
2958
+ contractsHash: z.string().describe("A hash of the rules the content was checked against.")
2959
+ });
2960
+ const acknowledgedWarning = z.object({
2961
+ contract: z.string(),
2962
+ path: z.string()
2963
+ });
2964
+ const entrySummary = z.object({
2965
+ id: entryId,
2966
+ collection: z.string(),
2967
+ locale: z.string(),
2968
+ slug: z.string(),
2969
+ translationGroup: z.string().nullable(),
2970
+ status: entryStatus,
2971
+ version: z.number().int().min(1),
2972
+ frontmatter: z.record(z.string(), z.unknown()),
2973
+ tags: z.array(z.string()),
2974
+ hasUnpublishedChanges: z.boolean().describe("Whether the latest revision differs from the published one."),
2975
+ publishedAt: z.string().nullable().describe("When the entry was first published, or null."),
2976
+ updatedAt: z.string()
2977
+ });
2978
+ const publication = z.object({
2979
+ revision: revisionId,
2980
+ verdict: verdict.describe("The verdict of the publish phase."),
2981
+ acknowledged: z.array(acknowledgedWarning).describe("The warnings acknowledged to publish."),
2982
+ at: z.string()
2983
+ });
2984
+ /** The Comark document of a revision: what a site's renderer takes. */
2985
+ const document = z.object({
2986
+ nodes: z.array(z.unknown()),
2987
+ frontmatter: z.record(z.string(), z.unknown()),
2988
+ meta: z.record(z.string(), z.unknown())
2989
+ });
2990
+ const entry = entrySummary.extend({
2991
+ revision: revisionId.describe("The revision this view shows."),
2992
+ verdict: verdict.describe("The verdict of the contracts when this revision was written."),
2993
+ lastPublication: publication.nullable(),
2994
+ markdown: z.string().optional(),
2995
+ document: document.optional(),
2996
+ html: z.string().optional()
2997
+ });
2998
+ const conflict = z.object({
2999
+ currentVersion: z.number().int().min(1),
3000
+ headRevision: revisionId
3001
+ });
3002
+ const format = z.enum([
3003
+ "markdown",
3004
+ "document",
3005
+ "html"
3006
+ ]);
3007
+ const componentDescription = z.object({
3008
+ name: z.string(),
3009
+ description: z.string().nullable(),
3010
+ example: z.string().nullable(),
3011
+ props: z.record(z.string(), z.unknown()).describe("The JSON Schema of the props.")
3012
+ });
3013
+ const contractDescription = z.object({
3014
+ id: z.string(),
3015
+ severity,
3016
+ phases: z.array(phase),
3017
+ description: z.string()
3018
+ });
3019
+ const collectionDescription = z.object({
3020
+ name: z.string(),
3021
+ schema: z.record(z.string(), z.unknown()).describe("The JSON Schema of the frontmatter."),
3022
+ routes: z.record(z.string(), z.string()).describe("The public path of an entry, per locale."),
3023
+ translationGroupField: z.string().nullable().describe("The frontmatter field that the translations of an entry share, or null."),
3024
+ tagsField: z.string().nullable().describe("The frontmatter field with the tags, or null."),
3025
+ sortByField: z.string().nullable().describe("The frontmatter field, a date, that orders published listings, newest first; null when they follow the date of first publication."),
3026
+ components: z.array(componentDescription),
3027
+ contracts: z.array(contractDescription),
3028
+ maxMarkdownBytes: z.number().int(),
3029
+ styleGuide: z.string().nullable()
3030
+ });
3031
+ const siteDescription = z.object({
3032
+ key: z.string(),
3033
+ locales: z.array(z.string()),
3034
+ defaultLocale: z.string(),
3035
+ styleGuide: z.string().nullable(),
3036
+ collections: z.array(collectionDescription)
3037
+ });
3038
+ //#endregion
3039
+ //#region src/operations/types.ts
3040
+ /** Declares an operation spec, keeping the literal types of its name and schemas. */
3041
+ function defineOperation(spec) {
3042
+ return spec;
3043
+ }
3044
+ //#endregion
3045
+ //#region src/operations/specs.ts
3046
+ /**
3047
+ * The operations of v0.1, specified once (ADR-017). The outcomes and the effect of each one are
3048
+ * the table of the v0.1 plan; `tests/operations.test.ts` holds it literally. Inputs are strict
3049
+ * objects, so a misspelled field is an error instead of being ignored.
3050
+ */
3051
+ const describeSite = defineOperation({
3052
+ name: "describe_site",
3053
+ title: "Describe the site",
3054
+ description: "Describes the site: its locales, and for each collection the JSON Schema of the frontmatter, the components the body may use, the contracts that writes and publishes are checked against, and the style guides. Read it before writing.",
3055
+ input: z.strictObject({}),
3056
+ output: z.object({
3057
+ outcome: z.enum(["ok"]),
3058
+ site: siteDescription
3059
+ }),
3060
+ scopes: ["content:read"],
3061
+ effect: "read",
3062
+ outcomes: { ok: { isError: false } }
3063
+ });
3064
+ const listEntries = defineOperation({
3065
+ name: "list_entries",
3066
+ title: "List entries",
3067
+ description: "Lists the entries of a collection, newest first, filtered by locale, tag or status. Pass the nextCursor of an answer as cursor to get the next page.",
3068
+ input: z.strictObject({
3069
+ collection: collectionName,
3070
+ locale: locale.optional(),
3071
+ tag: z.string().min(1).optional(),
3072
+ status: entryStatus.optional(),
3073
+ limit: z.number().int().min(1).max(100).default(20),
3074
+ cursor: z.string().min(1).optional()
3075
+ }),
3076
+ output: z.object({
3077
+ outcome: z.enum(["ok"]),
3078
+ entries: z.array(entrySummary),
3079
+ nextCursor: z.string().nullable().describe("The cursor of the next page, or null.")
3080
+ }),
3081
+ scopes: ["content:read"],
3082
+ effect: "read",
3083
+ outcomes: { ok: { isError: false } }
3084
+ });
3085
+ const getEntry = defineOperation({
3086
+ name: "get_entry",
3087
+ title: "Get an entry",
3088
+ description: "Gets an entry by collection, locale and slug, with its latest revision: the Markdown, the frontmatter, the verdict of its contracts and its last publication.",
3089
+ input: z.strictObject({
3090
+ collection: collectionName,
3091
+ locale,
3092
+ slug,
3093
+ formats: z.array(format).min(1).default(["markdown"]).describe("What to return of the revision: markdown, the Comark document, or html.")
3094
+ }),
3095
+ output: z.object({
3096
+ outcome: z.enum(["ok", "not_found"]),
3097
+ entry: entry.optional()
3098
+ }),
3099
+ scopes: ["content:read"],
3100
+ effect: "read",
3101
+ outcomes: {
3102
+ ok: { isError: false },
3103
+ not_found: { isError: true }
3104
+ }
3105
+ });
3106
+ const getTranslations = defineOperation({
3107
+ name: "get_translations",
3108
+ title: "Get the translations of an entry",
3109
+ description: "Gets the entries of a translation group of a collection, one per locale. The group is the value of the field that describe_site names as translationGroupField.",
3110
+ input: z.strictObject({
3111
+ collection: collectionName,
3112
+ translationGroup: z.string().min(1)
3113
+ }),
3114
+ output: z.object({
3115
+ outcome: z.enum(["ok", "not_found"]),
3116
+ entries: z.array(entrySummary).optional()
3117
+ }),
3118
+ scopes: ["content:read"],
3119
+ effect: "read",
3120
+ outcomes: {
3121
+ ok: { isError: false },
3122
+ not_found: { isError: true }
3123
+ }
3124
+ });
3125
+ const createDraft = defineOperation({
3126
+ name: "create_draft",
3127
+ title: "Create a draft",
3128
+ description: "Creates an entry from a Markdown file and saves it as a draft at version 1. Contract errors do not stop the draft: it is saved, and the verdict says what to fix before publishing. It is refused only when it cannot be stored: the file is too large, its frontmatter or syntax does not parse, or the slug or translation is taken.",
3129
+ input: z.strictObject({
3130
+ collection: collectionName,
3131
+ locale,
3132
+ slug,
3133
+ markdown
3134
+ }),
3135
+ output: z.object({
3136
+ outcome: z.enum(["saved", "refused"]),
3137
+ entry: entrySummary.optional(),
3138
+ verdict: verdict.optional()
3139
+ }),
3140
+ scopes: ["content:write"],
3141
+ effect: "write",
3142
+ outcomes: {
3143
+ saved: { isError: false },
3144
+ refused: { isError: true }
3145
+ }
3146
+ });
3147
+ const updateDraft = defineOperation({
3148
+ name: "update_draft",
3149
+ title: "Update a draft",
3150
+ description: "Saves a new revision of an entry from the whole Markdown file, if the entry is still at the version you read; otherwise it answers conflict with the current version, and nothing is saved. A file identical to the latest revision answers unchanged. Contract errors do not stop the save; the verdict says what to fix.",
3151
+ input: z.strictObject({
3152
+ id: entryId,
3153
+ version,
3154
+ markdown
3155
+ }),
3156
+ output: z.object({
3157
+ outcome: z.enum([
3158
+ "saved",
3159
+ "unchanged",
3160
+ "conflict",
3161
+ "not_found",
3162
+ "refused"
3163
+ ]),
3164
+ entry: entrySummary.optional(),
3165
+ verdict: verdict.optional(),
3166
+ conflict: conflict.optional()
3167
+ }),
3168
+ scopes: ["content:write"],
3169
+ effect: "edit",
3170
+ outcomes: {
3171
+ saved: { isError: false },
3172
+ unchanged: { isError: false },
3173
+ conflict: { isError: true },
3174
+ not_found: { isError: true },
3175
+ refused: { isError: true }
3176
+ }
3177
+ });
3178
+ const validate = defineOperation({
3179
+ name: "validate",
3180
+ title: "Validate a Markdown file",
3181
+ description: "Checks a Markdown file against every contract of the collection, as publish would, without saving anything: the verdict lists each problem with its fix.",
3182
+ input: z.strictObject({
3183
+ collection: collectionName,
3184
+ locale,
3185
+ slug,
3186
+ markdown
3187
+ }),
3188
+ output: z.object({
3189
+ outcome: z.enum(["ok"]),
3190
+ verdict
3191
+ }),
3192
+ scopes: ["content:read"],
3193
+ effect: "read",
3194
+ outcomes: { ok: { isError: false } }
3195
+ });
3196
+ const publish = defineOperation({
3197
+ name: "publish",
3198
+ title: "Publish an entry",
3199
+ description: "Publishes the latest revision of an entry, if the entry is still at the version you read. It is blocked while the contracts report errors. Warnings have to be acknowledged: an answer of needs_acknowledgement lists them, and publish again with them in acknowledgeWarnings.",
3200
+ input: z.strictObject({
3201
+ id: entryId,
3202
+ version,
3203
+ acknowledgeWarnings: z.array(acknowledgedWarning).default([]).describe("The warnings you accept, as needs_acknowledgement listed them.")
3204
+ }),
3205
+ output: z.object({
3206
+ outcome: z.enum([
3207
+ "published",
3208
+ "blocked",
3209
+ "needs_acknowledgement",
3210
+ "conflict",
3211
+ "not_found"
3212
+ ]),
3213
+ entry: entrySummary.optional(),
3214
+ verdict: verdict.optional(),
3215
+ conflict: conflict.optional()
3216
+ }),
3217
+ scopes: ["content:publish"],
3218
+ effect: "write",
3219
+ outcomes: {
3220
+ published: { isError: false },
3221
+ blocked: { isError: true },
3222
+ needs_acknowledgement: { isError: false },
3223
+ conflict: { isError: true },
3224
+ not_found: { isError: true }
3225
+ }
3226
+ });
3227
+ const unpublish = defineOperation({
3228
+ name: "unpublish",
3229
+ title: "Unpublish an entry",
3230
+ description: "Takes an entry off the public site and keeps it, with its revisions, as a draft. An entry that is not published stays as it is.",
3231
+ input: z.strictObject({ id: entryId }),
3232
+ output: z.object({
3233
+ outcome: z.enum(["unpublished", "not_found"]),
3234
+ entry: entrySummary.optional()
3235
+ }),
3236
+ scopes: ["content:publish"],
3237
+ effect: "destructive",
3238
+ outcomes: {
3239
+ unpublished: { isError: false },
3240
+ not_found: { isError: true }
3241
+ }
3242
+ });
3243
+ /** Every operation of v0.1, sorted by name: the order of `tools/list`. */
3244
+ const OPERATIONS = [
3245
+ createDraft,
3246
+ describeSite,
3247
+ getEntry,
3248
+ getTranslations,
3249
+ listEntries,
3250
+ publish,
3251
+ unpublish,
3252
+ updateDraft,
3253
+ validate
3254
+ ];
3255
+ //#endregion
3256
+ //#region src/scopes.ts
3257
+ /**
3258
+ * The scopes a token carries. An operation asks for scopes, never for a role: what a token may
3259
+ * do is its own scopes intersected with those of its user's role (ADR-019).
3260
+ */
3261
+ const SCOPES = [
3262
+ "content:read",
3263
+ "content:write",
3264
+ "content:publish",
3265
+ "schema:admin"
3266
+ ];
3267
+ /** Whether a string is one of the scopes. */
3268
+ function isScope(value) {
3269
+ return SCOPES.some((scope) => scope === value);
3270
+ }
3271
+ //#endregion
3272
+ //#region src/tokens.ts
3273
+ /**
3274
+ * Personal tokens (ADR-019): `hw_pat_` and 52 characters of lowercase Crockford base 32, which
3275
+ * carry 260 random bits. The CLI makes a token and prints it once. The server never stores it:
3276
+ * it keeps the SHA-256 of the whole token, in hex, and finds a presented token by that hash. The
3277
+ * row of a token has an id of its own (`tok_…`, see `ids.ts`), its scopes and its expiry.
3278
+ */
3279
+ const PERSONAL_TOKEN_PREFIX = "hw_pat_";
3280
+ /** Days until a new personal token expires, unless whoever creates it asks for another expiry. */
3281
+ const PERSONAL_TOKEN_TTL_DAYS = 90;
3282
+ const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
3283
+ const SECRET_LENGTH = 52;
3284
+ const TOKEN_PATTERN = /^hw_pat_[0-9a-hjkmnp-tv-z]{52}$/;
3285
+ /**
3286
+ * A new personal token. Each of the 52 random bytes gives one character (its low 5 bits, which
3287
+ * are uniform because 256 is a multiple of 32). `random` defaults to `crypto.getRandomValues`;
3288
+ * tests pass their own bytes.
3289
+ */
3290
+ function generatePersonalToken(random = crypto.getRandomValues(new Uint8Array(SECRET_LENGTH))) {
3291
+ if (random.length !== SECRET_LENGTH) throw new RangeError(`a personal token takes ${SECRET_LENGTH} random bytes, got ${random.length}`);
3292
+ let secret = "";
3293
+ for (const byte of random) secret += ALPHABET.charAt(byte & 31);
3294
+ return PERSONAL_TOKEN_PREFIX + secret;
3295
+ }
3296
+ /** Whether a string has the shape of a personal token. It says nothing about whether one exists. */
3297
+ function isPersonalToken(value) {
3298
+ return TOKEN_PATTERN.test(value);
3299
+ }
3300
+ /** The SHA-256 of a token, in lowercase hex: what the `tokens` table stores and looks up. */
3301
+ async function hashPersonalToken(token) {
3302
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
3303
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
3304
+ }
3305
+ //#endregion
1
3306
  //#region src/index.ts
2
3307
  const name = "@hollware/core";
3
3308
  //#endregion
4
- export { name };
3309
+ export { BUILT_IN_CONTRACT_IDS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, REFUSAL_CONTRACT_IDS, SCOPES, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, describeBuiltInContracts, describeContract, describeResolvedSite, describeSite, generatePersonalToken, getEntry, getTranslations, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isScope, isSiteKey, listEntries, name, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, schemas_exports as schemas, sizeLimitViolation, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate };