@hollware/core 0.5.0 → 0.8.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,5 +1,6 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import * as z from "zod";
3
+ import { Validator } from "@cfworker/json-schema";
3
4
  //#region src/config/define.ts
4
5
  /**
5
6
  * The `define*` helpers only give the config its types: the frontmatter type of a collection
@@ -124,7 +125,7 @@ function runtimeOf(contractId, context) {
124
125
  */
125
126
  /** The most characters of the entry's own text that a message repeats. */
126
127
  const MAX_ECHO_LENGTH = 80;
127
- function isRecord$2(value) {
128
+ function isRecord$3(value) {
128
129
  return typeof value === "object" && value !== null && !Array.isArray(value);
129
130
  }
130
131
  /** Orders two texts by UTF-16 code units: the same in every runtime, unlike `localeCompare`. */
@@ -177,11 +178,11 @@ function fragmentAt(schema, segments) {
177
178
  let node = schema;
178
179
  for (const segment of segments) {
179
180
  const properties = node.properties;
180
- if (isRecord$2(properties) && Object.hasOwn(properties, segment)) {
181
+ if (isRecord$3(properties) && Object.hasOwn(properties, segment)) {
181
182
  const next = properties[segment];
182
- if (!isRecord$2(next)) return void 0;
183
+ if (!isRecord$3(next)) return void 0;
183
184
  node = next;
184
- } else if (/^\d+$/.test(segment) && isRecord$2(node.items)) node = node.items;
185
+ } else if (/^\d+$/.test(segment) && isRecord$3(node.items)) node = node.items;
185
186
  else return;
186
187
  }
187
188
  return node;
@@ -189,7 +190,7 @@ function fragmentAt(schema, segments) {
189
190
  function valueAt(input, segments) {
190
191
  let node = input;
191
192
  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 if (isRecord$3(node) && Object.hasOwn(node, segment)) node = node[segment];
193
194
  else return void 0;
194
195
  return node;
195
196
  }
@@ -225,11 +226,11 @@ function numberExpectation(type, schema) {
225
226
  }
226
227
  function arrayExpectation(schema) {
227
228
  const size = range(schema.minItems, schema.maxItems, "item");
228
- const items = isRecord$2(schema.items) ? expectation(schema.items) : void 0;
229
+ const items = isRecord$3(schema.items) ? expectation(schema.items) : void 0;
229
230
  return `an array${size === void 0 ? "" : ` of ${size}`}${items === void 0 ? "" : `, each ${items}`}`;
230
231
  }
231
232
  function objectExpectation(schema) {
232
- const keys = isRecord$2(schema.properties) ? Object.keys(schema.properties) : [];
233
+ const keys = isRecord$3(schema.properties) ? Object.keys(schema.properties) : [];
233
234
  return keys.length === 0 ? "an object" : `an object with the keys ${keys.join(", ")}`;
234
235
  }
235
236
  function typeExpectation(type, schema) {
@@ -252,13 +253,13 @@ function expectation(schema) {
252
253
  if ("const" in schema) return `exactly ${JSON.stringify(schema.const)}`;
253
254
  if (Array.isArray(schema.enum)) return `one of ${listValues(schema.enum)}`;
254
255
  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
+ const parts = Array.isArray(alternatives) ? alternatives.map((option) => isRecord$3(option) ? expectation(option) : void 0) : (Array.isArray(schema.type) ? schema.type : [schema.type]).map((type) => typeExpectation(type, schema));
256
257
  return parts.every((part) => part !== void 0) ? parts.join(" or ") : void 0;
257
258
  }
258
259
  /** The keys of `value` that an object schema closed to other keys does not list. */
259
260
  function unknownKeys(fragment, value) {
260
261
  const { additionalProperties, properties } = fragment;
261
- if (additionalProperties !== false || !isRecord$2(properties) || !isRecord$2(value)) return [];
262
+ if (additionalProperties !== false || !isRecord$3(properties) || !isRecord$3(value)) return [];
262
263
  return Object.keys(value).filter((key) => !Object.hasOwn(properties, key));
263
264
  }
264
265
  /** The fix for one issue: imperative, naming the field and what the schema accepts there. */
@@ -268,7 +269,7 @@ function fixForIssue({ subject, schema, input, segments, message }) {
268
269
  if (fragment !== void 0) {
269
270
  const unknown = unknownKeys(fragment, valueAt(input, segments));
270
271
  if (unknown.length > 0) {
271
- const allowed = isRecord$2(fragment.properties) ? Object.keys(fragment.properties) : [];
272
+ const allowed = isRecord$3(fragment.properties) ? Object.keys(fragment.properties) : [];
272
273
  const nouns = unknown.length === 1 ? subject.noun : `${subject.noun}s`;
273
274
  const where = allowed.length === 0 ? `no ${subject.noun}s are allowed there` : `the ${subject.noun}s allowed there are: ${allowed.join(", ")}`;
274
275
  return `Remove the ${nouns} ${unknown.map((key) => quote$1(key)).join(", ")} from ${target}; ${where}.`;
@@ -280,7 +281,7 @@ function fixForIssue({ subject, schema, input, segments, message }) {
280
281
  //#endregion
281
282
  //#region src/contracts/schema.ts
282
283
  const SNAPSHOTS = /* @__PURE__ */ new WeakMap();
283
- function toJsonSchema$1(schema) {
284
+ function toJsonSchema$2(schema) {
284
285
  try {
285
286
  return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" });
286
287
  } catch {
@@ -291,8 +292,8 @@ function schemaSnapshot(collection) {
291
292
  let snapshot = SNAPSHOTS.get(collection);
292
293
  if (snapshot === void 0) {
293
294
  snapshot = {
294
- frontmatter: toJsonSchema$1(collection.schema),
295
- components: new Map([...collection.components].map(([name, component]) => [name, toJsonSchema$1(component.props)]))
295
+ frontmatter: toJsonSchema$2(collection.schema),
296
+ components: new Map([...collection.components].map(([name, component]) => [name, toJsonSchema$2(component.props)]))
296
297
  };
297
298
  SNAPSHOTS.set(collection, snapshot);
298
299
  }
@@ -540,6 +541,211 @@ function describeBuiltInContracts() {
540
541
  ].map((contract) => describeContract(contract));
541
542
  }
542
543
  //#endregion
544
+ //#region src/search/chunks.ts
545
+ /**
546
+ * The pieces of an entry that semantic search embeds (v0.5): one summary, and one piece for each
547
+ * section the body has under an `h2`. Vectors are derived data, like the Comark document: a change
548
+ * of this version makes every vector one to make again.
549
+ */
550
+ const CHUNKER_VERSION = "1";
551
+ /** How long a piece may grow, in characters, before it is cut at a word. */
552
+ const CHUNK_LIMITS = {
553
+ summary: 1200,
554
+ section: 2e3
555
+ };
556
+ /** The text a reader sees in a document, its blocks on lines of their own. */
557
+ function documentText(document) {
558
+ return document.nodes.map((node) => textOf(node)).join("\n");
559
+ }
560
+ /** The text a reader sees in a node: its strings, never its tag, attributes or comments. */
561
+ function textOf(node) {
562
+ if (typeof node === "string") return node;
563
+ if (!Array.isArray(node) || node[0] === null) return "";
564
+ return node.slice(2).map((child) => textOf(child)).join("");
565
+ }
566
+ function tagOf(node) {
567
+ return Array.isArray(node) && typeof node[0] === "string" ? node[0] : null;
568
+ }
569
+ /** The words of a text in one line, cut at the last word that fits in `limit`. */
570
+ function fit(text, limit) {
571
+ const flat = text.replace(/\s+/g, " ").trim();
572
+ if (flat.length <= limit) return flat;
573
+ const cut = flat.slice(0, limit);
574
+ const lastSpace = cut.lastIndexOf(" ");
575
+ return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim();
576
+ }
577
+ /**
578
+ * The pieces of an entry: the summary (its title, its description and the text before the first
579
+ * `h2`), then each section (its heading and its text up to the next `h2`). An `h1` adds nothing:
580
+ * it repeats the title. A section with no text is left out.
581
+ */
582
+ function chunksOf(entry) {
583
+ const intro = [];
584
+ const sections = [];
585
+ for (const node of entry.document.nodes) {
586
+ const tag = tagOf(node);
587
+ if (tag === "h1") continue;
588
+ if (tag === "h2") {
589
+ sections.push({
590
+ heading: textOf(node).trim(),
591
+ parts: []
592
+ });
593
+ continue;
594
+ }
595
+ const text = textOf(node);
596
+ const current = sections.at(-1);
597
+ if (current === void 0) intro.push(text);
598
+ else current.parts.push(text);
599
+ }
600
+ const chunks = [{
601
+ index: 0,
602
+ kind: "summary",
603
+ heading: null,
604
+ text: fit([
605
+ entry.title,
606
+ entry.description ?? "",
607
+ intro.join(" ")
608
+ ].filter((part) => part !== "").join("\n"), CHUNK_LIMITS.summary)
609
+ }];
610
+ for (const section of sections) {
611
+ const body = section.parts.join(" ").trim();
612
+ if (body === "") continue;
613
+ chunks.push({
614
+ index: chunks.length,
615
+ kind: "section",
616
+ heading: section.heading,
617
+ text: fit(`${section.heading}\n${body}`, CHUNK_LIMITS.section)
618
+ });
619
+ }
620
+ return chunks;
621
+ }
622
+ //#endregion
623
+ //#region src/contracts/rules.ts
624
+ function present(frontmatter, field) {
625
+ return isRecord$3(frontmatter) ? frontmatter[field] : void 0;
626
+ }
627
+ const GRAPHEMES = new Intl.Segmenter(void 0, { granularity: "grapheme" });
628
+ /** The length of a value as a reader counts it: characters of a text (an emoji is one), items of a list. */
629
+ function lengthOf(value) {
630
+ if (typeof value === "string") return [...GRAPHEMES.segment(value)].length;
631
+ return Array.isArray(value) ? value.length : void 0;
632
+ }
633
+ /** A text, or a list, of a field has between `min` and `max` characters or items. */
634
+ function fieldLength(params, options = {}) {
635
+ const bounds = [params.min === void 0 ? null : `at least ${params.min}`, params.max === void 0 ? null : `at most ${params.max}`].filter((bound) => bound !== null);
636
+ const unless = params.unless === void 0 ? "" : `, unless ${quote$1(params.unless)} is given`;
637
+ return registerBuiltIn({
638
+ id: options.id ?? "field-length",
639
+ severity: options.severity ?? "error",
640
+ description: `${quote$1(params.field)} has ${bounds.join(" and ")} characters or items${unless}.`,
641
+ rule: {
642
+ name: "field-length",
643
+ params: { ...params }
644
+ },
645
+ check: ({ frontmatter }) => {
646
+ const value = present(frontmatter, params.field);
647
+ const length = lengthOf(value);
648
+ if (length === void 0) return [];
649
+ const unit = typeof value === "string" ? "character" : "item";
650
+ if (params.unless !== void 0 && present(frontmatter, params.unless) !== void 0) return [];
651
+ const path = `/frontmatter/${params.field}`;
652
+ if (params.max !== void 0 && length > params.max) return [{
653
+ path,
654
+ message: `${quote$1(params.field)} has ${count(length, unit)}; the most is ${params.max}.`,
655
+ fix: `Shorten ${quote$1(params.field)} to ${count(params.max, unit)} or fewer${params.unless === void 0 ? "" : `, or add ${quote$1(params.unless)}`}.`
656
+ }];
657
+ if (params.min !== void 0 && length < params.min) return [{
658
+ path,
659
+ message: `${quote$1(params.field)} has ${count(length, unit)}; the least is ${params.min}.`,
660
+ fix: `Lengthen ${quote$1(params.field)} to ${count(params.min, unit)} or more.`
661
+ }];
662
+ return [];
663
+ }
664
+ }, { readsFrontmatter: true });
665
+ }
666
+ /** A text field matches a pattern. */
667
+ function fieldPattern(params, options = {}) {
668
+ const pattern = new RegExp(`^(?:${params.pattern})$`, "u");
669
+ return registerBuiltIn({
670
+ id: options.id ?? "field-pattern",
671
+ severity: options.severity ?? "error",
672
+ description: `${quote$1(params.field)} is ${params.description}.`,
673
+ rule: {
674
+ name: "field-pattern",
675
+ params: { ...params }
676
+ },
677
+ check: ({ frontmatter }) => {
678
+ const value = present(frontmatter, params.field);
679
+ if (typeof value !== "string" || pattern.test(value)) return [];
680
+ return [{
681
+ path: `/frontmatter/${params.field}`,
682
+ message: `${quote$1(params.field)} is not ${params.description}.`,
683
+ fix: `Write ${quote$1(params.field)} as ${params.description}.`
684
+ }];
685
+ }
686
+ }, { readsFrontmatter: true });
687
+ }
688
+ /** Fields a draft may lack and a published entry must have, with something in them. */
689
+ function requiredToPublish(params, options = {}) {
690
+ const empty = (value) => value === void 0 || value === null || typeof value === "string" && value.trim() === "" || Array.isArray(value) && value.length === 0;
691
+ return registerBuiltIn({
692
+ id: options.id ?? "required-to-publish",
693
+ severity: options.severity ?? "error",
694
+ phases: ["publish"],
695
+ description: `To publish, ${params.fields.map(quote$1).join(", ")} must have a value.`,
696
+ rule: {
697
+ name: "required-to-publish",
698
+ params: { fields: [...params.fields] }
699
+ },
700
+ check: ({ frontmatter }) => params.fields.filter((field) => empty(present(frontmatter, field))).map((field) => ({
701
+ path: `/frontmatter/${field}`,
702
+ message: `${quote$1(field)} is empty, and the entry cannot be published without it.`,
703
+ fix: `Give ${quote$1(field)} a value, then publish.`
704
+ }))
705
+ }, { readsFrontmatter: true });
706
+ }
707
+ function escape(text) {
708
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
709
+ }
710
+ /** Words that may appear neither in a text field of the frontmatter nor in the body. */
711
+ function bannedWords(params, options = {}) {
712
+ const words = params.words.filter((word) => word.trim() !== "");
713
+ const pattern = words.length === 0 ? null : new RegExp(`(?<![\\p{L}\\p{N}])(?:${words.map(escape).join("|")})(?![\\p{L}\\p{N}])`, "giu");
714
+ const because = params.reason === void 0 ? "" : ` (${params.reason})`;
715
+ const found = (text) => [...new Set([...text.matchAll(pattern ?? /$^/g)].map((m) => m[0]))];
716
+ return registerBuiltIn({
717
+ id: options.id ?? "banned-words",
718
+ severity: options.severity ?? "error",
719
+ description: `None of these words: ${words.map(quote$1).join(", ")}${because}.`,
720
+ rule: {
721
+ name: "banned-words",
722
+ params: {
723
+ ...params,
724
+ words: [...params.words]
725
+ }
726
+ },
727
+ check: (context) => {
728
+ if (pattern === null) return [];
729
+ const findings = [];
730
+ const frontmatter = isRecord$3(context.frontmatter) ? context.frontmatter : {};
731
+ for (const [field, value] of Object.entries(frontmatter)) {
732
+ if (typeof value !== "string") continue;
733
+ for (const word of found(value)) findings.push({
734
+ path: `/frontmatter/${field}`,
735
+ message: `${quote$1(field)} uses ${quote$1(word)}.`,
736
+ fix: `Write ${quote$1(field)} without ${quote$1(word)}${because}.`
737
+ });
738
+ }
739
+ for (const word of found(documentText(context.markdown.document))) findings.push({
740
+ path: "/body",
741
+ message: `The body uses ${quote$1(word)}.`,
742
+ fix: `Rewrite the body without ${quote$1(word)}${because}.`
743
+ });
744
+ return findings;
745
+ }
746
+ }, { readsFrontmatter: false });
747
+ }
748
+ //#endregion
543
749
  //#region src/contracts/opt-in.ts
544
750
  /**
545
751
  * Every locale of the site has an entry in the translation group of this one. It reads the group
@@ -549,7 +755,11 @@ function describeBuiltInContracts() {
549
755
  */
550
756
  function translationPair(options = {}) {
551
757
  return registerBuiltIn({
552
- id: "translation-pair",
758
+ id: options.id ?? "translation-pair",
759
+ rule: {
760
+ name: "translation-pair",
761
+ params: {}
762
+ },
553
763
  severity: options.severity ?? "error",
554
764
  phases: ["publish"],
555
765
  description: "Every locale of the site has an entry in the translation group of this entry. Checked when publishing.",
@@ -558,7 +768,7 @@ function translationPair(options = {}) {
558
768
  const field = collection.translationGroupField;
559
769
  if (field === null) return [];
560
770
  const path = `/frontmatter${pointer([field])}`;
561
- const group = isRecord$2(context.frontmatter) ? context.frontmatter[field] : void 0;
771
+ const group = isRecord$3(context.frontmatter) ? context.frontmatter[field] : void 0;
562
772
  if (typeof group !== "string" || group === "") return [{
563
773
  path,
564
774
  message: `The entry has no translation group: its frontmatter field ${field} is empty.`,
@@ -581,7 +791,11 @@ function translationPair(options = {}) {
581
791
  */
582
792
  function internalLinksResolve(options = {}) {
583
793
  return registerBuiltIn({
584
- id: "internal-links-resolve",
794
+ id: options.id ?? "internal-links-resolve",
795
+ rule: {
796
+ name: "internal-links-resolve",
797
+ params: {}
798
+ },
585
799
  severity: options.severity ?? "error",
586
800
  phases: ["publish"],
587
801
  description: "Every link to another entry of the site points at a published entry. Checked when publishing.",
@@ -635,7 +849,11 @@ function isThisEntry(target, context) {
635
849
  /** Every image has alt text. An alt of only spaces counts as none. Checked on every write. */
636
850
  function imagesHaveAlt(options = {}) {
637
851
  return registerBuiltIn({
638
- id: "images-have-alt",
852
+ id: options.id ?? "images-have-alt",
853
+ rule: {
854
+ name: "images-have-alt",
855
+ params: {}
856
+ },
639
857
  severity: options.severity ?? "error",
640
858
  phases: ["write", "publish"],
641
859
  description: "Every image has alt text that describes it.",
@@ -673,12 +891,81 @@ function custom(options) {
673
891
  };
674
892
  }
675
893
  const contracts = {
894
+ bannedWords,
676
895
  custom,
896
+ fieldLength,
897
+ fieldPattern,
677
898
  imagesHaveAlt,
678
899
  internalLinksResolve,
900
+ requiredToPublish,
679
901
  translationPair
680
902
  };
681
903
  //#endregion
904
+ //#region src/contracts/catalog.ts
905
+ /**
906
+ * The catalog of rules (ADR-033): every rule a collection may list in its definition, by name,
907
+ * with the JSON Schema of its parameters. A site's definition stores `{ rule, severity, params }`
908
+ * and the Worker makes the contract from it here; `describe_site` shows the catalog, so an agent
909
+ * or the admin knows what each rule takes.
910
+ */
911
+ const fieldName = z.string().min(1).describe("A field of the frontmatter, by its name.");
912
+ const NO_PARAMS = z.strictObject({});
913
+ function isValidPattern(pattern) {
914
+ try {
915
+ new RegExp(pattern, "u");
916
+ return true;
917
+ } catch {
918
+ return false;
919
+ }
920
+ }
921
+ function rule(name, description, params, make) {
922
+ return {
923
+ name,
924
+ description,
925
+ params,
926
+ make: (raw, severity, id) => make(params.parse(raw), {
927
+ severity,
928
+ ...id === void 0 ? {} : { id }
929
+ })
930
+ };
931
+ }
932
+ const CATALOG = [
933
+ rule("banned-words", "Words that may appear neither in a text field of the frontmatter nor in the body.", z.strictObject({
934
+ words: z.array(z.string().min(1)).min(1).max(200),
935
+ reason: z.string().min(1).max(200).optional().describe("Why, for the fix.")
936
+ }), (params, options) => bannedWords(params, options)),
937
+ rule("field-length", "A text field has between min and max characters, or a list between min and max items; unless names a field that lifts the rule when given.", z.strictObject({
938
+ field: fieldName,
939
+ min: z.number().int().min(0).optional(),
940
+ max: z.number().int().min(0).optional(),
941
+ unless: fieldName.optional()
942
+ }).refine((params) => params.min !== void 0 || params.max !== void 0, { message: "Give min, max or both." }).refine((params) => params.min === void 0 || params.max === void 0 || params.min <= params.max, { message: "min is more than max." }), (params, options) => fieldLength(params, options)),
943
+ rule("field-pattern", "A text field matches a regular expression, as a whole.", z.strictObject({
944
+ field: fieldName,
945
+ pattern: z.string().min(1).max(500).refine(isValidPattern, { message: "Not a regular expression." }),
946
+ description: z.string().min(1).max(200).describe("What the pattern asks for, in words.")
947
+ }), (params, options) => fieldPattern(params, options)),
948
+ rule("images-have-alt", "Every image has alt text that describes it.", NO_PARAMS, (_params, options) => contracts.imagesHaveAlt(options)),
949
+ rule("internal-links-resolve", "Every link to another entry of the site points to one that exists, and is published when this one is.", NO_PARAMS, (_params, options) => contracts.internalLinksResolve(options)),
950
+ rule("required-to-publish", "Fields a draft may leave empty and a published entry must fill.", z.strictObject({ fields: z.array(fieldName).min(1) }), (params, options) => requiredToPublish(params, options)),
951
+ rule("translation-pair", "Every locale of the site has an entry in the translation group of this one, to publish.", NO_PARAMS, (_params, options) => contracts.translationPair(options))
952
+ ];
953
+ /** A rule of the catalog by its name, or `undefined`. */
954
+ function catalogRule(name) {
955
+ return CATALOG.find((candidate) => candidate.name === name);
956
+ }
957
+ /** The catalog as `describe_site` shows it: each rule with the JSON Schema of its parameters. */
958
+ function describeCatalog() {
959
+ return CATALOG.map((entry) => ({
960
+ name: entry.name,
961
+ description: entry.description,
962
+ params: z.toJSONSchema(entry.params, {
963
+ target: "draft-2020-12",
964
+ io: "input"
965
+ })
966
+ }));
967
+ }
968
+ //#endregion
682
969
  //#region src/contracts/hash.ts
683
970
  /**
684
971
  * `contractsHash` (ADR-004): what a revision was checked against, in a short string. A stored
@@ -708,10 +995,11 @@ async function sha256Hex(text) {
708
995
  function hashContracts(snapshot, contracts) {
709
996
  return sha256Hex(canonicalJson({
710
997
  components: Object.fromEntries(snapshot.components),
711
- contracts: contracts.toSorted((left, right) => compareText(left.id, right.id)).map(({ id, phases, severity }) => ({
998
+ contracts: contracts.toSorted((left, right) => compareText(left.id, right.id)).map(({ id, phases, severity, params }) => ({
712
999
  id,
713
1000
  phases,
714
- severity
1001
+ severity,
1002
+ params: params === void 0 || Object.keys(params).length === 0 ? void 0 : params
715
1003
  })),
716
1004
  frontmatter: snapshot.frontmatter
717
1005
  }));
@@ -830,7 +1118,10 @@ async function runContracts(input) {
830
1118
  phase,
831
1119
  errors,
832
1120
  warnings,
833
- contractsHash: await hashContracts(schemaSnapshot(collection), [SCHEMA_CONTRACT, ...contracts].map((contract) => describeContract(contract)))
1121
+ contractsHash: await hashContracts(schemaSnapshot(collection), [SCHEMA_CONTRACT, ...contracts].map((contract) => ({
1122
+ ...describeContract(contract),
1123
+ params: contract.rule?.params
1124
+ })))
834
1125
  };
835
1126
  }
836
1127
  //#endregion
@@ -884,7 +1175,7 @@ function uniqueTranslationViolation(holder, field) {
884
1175
  //#endregion
885
1176
  //#region src/config/values.ts
886
1177
  /** A value with keys: not null, not an array. */
887
- function isRecord$1(value) {
1178
+ function isRecord$2(value) {
888
1179
  return typeof value === "object" && value !== null && !Array.isArray(value);
889
1180
  }
890
1181
  /** A string as the messages show it: quoted, with what could break a line escaped. */
@@ -940,9 +1231,9 @@ const TARGET = "draft-2020-12";
940
1231
  * The JSON Schema that a schema accepts as input, as plain JSON data. It throws what the library
941
1232
  * throws when the schema has no JSON Schema (Zod: `z.date()`, `z.custom()`).
942
1233
  */
943
- function toJsonSchema(schema) {
1234
+ function toJsonSchema$1(schema) {
944
1235
  const converted = schema["~standard"].jsonSchema.input({ target: TARGET });
945
- if (!isRecord$1(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
1236
+ if (!isRecord$2(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
946
1237
  return JSON.parse(JSON.stringify(converted));
947
1238
  }
948
1239
  /** Reference chains longer than this are cycles. */
@@ -954,7 +1245,7 @@ function pointTo(root, ref) {
954
1245
  let node = root;
955
1246
  for (const part of ref.slice(2).split("/")) {
956
1247
  const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
957
- if (!isRecord$1(node) || !Object.hasOwn(node, key)) return void 0;
1248
+ if (!isRecord$2(node) || !Object.hasOwn(node, key)) return void 0;
958
1249
  node = node[key];
959
1250
  }
960
1251
  return node;
@@ -966,7 +1257,7 @@ function pointTo(root, ref) {
966
1257
  function resolveSchema(root, schema) {
967
1258
  let node = schema;
968
1259
  for (let hop = 0; hop <= MAX_REF_HOPS; hop += 1) {
969
- if (!isRecord$1(node)) return null;
1260
+ if (!isRecord$2(node)) return null;
970
1261
  const ref = node["$ref"];
971
1262
  if (typeof ref !== "string") return node;
972
1263
  node = pointTo(root, ref);
@@ -1015,7 +1306,7 @@ const NO_BUILT_IN = [];
1015
1306
  function describeCollection(collection, builtIn) {
1016
1307
  return {
1017
1308
  name: collection.name,
1018
- schema: toJsonSchema(collection.schema),
1309
+ schema: toJsonSchema$1(collection.schema),
1019
1310
  routes: { ...collection.routes },
1020
1311
  translationGroupField: collection.translationGroupField,
1021
1312
  tagsField: collection.tagsField,
@@ -1024,7 +1315,7 @@ function describeCollection(collection, builtIn) {
1024
1315
  name,
1025
1316
  description: component.description ?? null,
1026
1317
  example: component.example ?? null,
1027
- props: toJsonSchema(component.props)
1318
+ props: toJsonSchema$1(component.props)
1028
1319
  })),
1029
1320
  contracts: [...builtIn, ...collection.contracts].map((contract) => describeContract(contract)),
1030
1321
  maxMarkdownBytes: collection.maxMarkdownBytes,
@@ -1065,6 +1356,7 @@ function describeResolvedSite(site, options = {}) {
1065
1356
  key: site.key,
1066
1357
  locales: [...site.locales],
1067
1358
  defaultLocale: site.defaultLocale,
1359
+ url: site.url,
1068
1360
  styleGuide: site.styleGuide,
1069
1361
  collections: [...site.collections.values()].map((collection) => describeCollection(collection, builtIn))
1070
1362
  });
@@ -1082,14 +1374,14 @@ function standardProps(value) {
1082
1374
  /** A Standard Schema that also converts to JSON Schema, which is what a collection needs. */
1083
1375
  function isDescribedSchema(value) {
1084
1376
  const standard = standardProps(value);
1085
- if (!isRecord$1(standard) || typeof standard["validate"] !== "function") return false;
1377
+ if (!isRecord$2(standard) || typeof standard["validate"] !== "function") return false;
1086
1378
  const converter = standard["jsonSchema"];
1087
- return isRecord$1(converter) && typeof converter["input"] === "function";
1379
+ return isRecord$2(converter) && typeof converter["input"] === "function";
1088
1380
  }
1089
1381
  /** What `isDescribedSchema` found missing, for the message. */
1090
1382
  function whyNotDescribed(value) {
1091
1383
  const standard = standardProps(value);
1092
- if (!isRecord$1(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
1384
+ if (!isRecord$2(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
1093
1385
  if (typeof standard["validate"] !== "function") return "has no ~standard.validate function, so it is no Standard Schema";
1094
1386
  return "has no ~standard.jsonSchema.input function, so describe_site could not show it to agents";
1095
1387
  }
@@ -1107,7 +1399,7 @@ function readSchema(path, raw, kind, problems) {
1107
1399
  }
1108
1400
  let document;
1109
1401
  try {
1110
- document = toJsonSchema(raw);
1402
+ document = toJsonSchema$1(raw);
1111
1403
  } catch (error) {
1112
1404
  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
1405
  return null;
@@ -1159,7 +1451,7 @@ function claimRoute(route, path, site, problems) {
1159
1451
  }
1160
1452
  /** The route of each locale, in the order of the site's locales. */
1161
1453
  function readRoutes(path, raw, collection, site, problems) {
1162
- if (!isRecord$1(raw)) {
1454
+ if (!isRecord$2(raw)) {
1163
1455
  problems.add(path, wrongType(raw, "an object with one path per locale"), ROUTES_FIX);
1164
1456
  return {};
1165
1457
  }
@@ -1207,7 +1499,7 @@ function readField(path, raw, schema, kind, problems) {
1207
1499
  return null;
1208
1500
  }
1209
1501
  if (schema === null) return raw;
1210
- const properties = isRecord$1(schema.object["properties"]) ? schema.object["properties"] : {};
1502
+ const properties = isRecord$2(schema.object["properties"]) ? schema.object["properties"] : {};
1211
1503
  if (!Object.hasOwn(properties, raw)) {
1212
1504
  const names = Object.keys(properties);
1213
1505
  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.`);
@@ -1216,7 +1508,7 @@ function readField(path, raw, schema, kind, problems) {
1216
1508
  }
1217
1509
  function readTranslations(path, raw, schema, problems) {
1218
1510
  if (raw === void 0) return null;
1219
- if (!isRecord$1(raw)) {
1511
+ if (!isRecord$2(raw)) {
1220
1512
  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
1513
  return null;
1222
1514
  }
@@ -1225,7 +1517,7 @@ function readTranslations(path, raw, schema, problems) {
1225
1517
  const COMPONENT_FIX = "Declare it with defineComponent({ props: z.strictObject({ … }) }).";
1226
1518
  function readComponent(path, name, raw, problems) {
1227
1519
  if (!NAME$1.test(name)) problems.add(path, `${quote(name)} is not a valid component name`, nameFix("callout"));
1228
- if (!isRecord$1(raw)) {
1520
+ if (!isRecord$2(raw)) {
1229
1521
  problems.add(path, wrongType(raw, "a component"), COMPONENT_FIX);
1230
1522
  return null;
1231
1523
  }
@@ -1243,7 +1535,7 @@ function readComponent(path, name, raw, problems) {
1243
1535
  function readComponents(path, raw, problems) {
1244
1536
  const components = /* @__PURE__ */ new Map();
1245
1537
  if (raw === void 0) return components;
1246
- if (!isRecord$1(raw)) {
1538
+ if (!isRecord$2(raw)) {
1247
1539
  problems.add(path, wrongType(raw, "an object that maps each component name to its definition"), COMPONENT_FIX);
1248
1540
  return components;
1249
1541
  }
@@ -1284,7 +1576,7 @@ function checkPhases(path, raw, problems) {
1284
1576
  * what makes it a `Contract`. Whether it may have its id is a question for `checkIdentity`.
1285
1577
  */
1286
1578
  function isContract(item, path, problems) {
1287
- if (!isRecord$1(item)) {
1579
+ if (!isRecord$2(item)) {
1288
1580
  problems.add(path, wrongType(item, "a contract"), "Write it as { id, severity, description, check }.");
1289
1581
  return false;
1290
1582
  }
@@ -1332,7 +1624,7 @@ function readContracts(path, raw, problems) {
1332
1624
  if (isContract(item, itemAt, problems)) {
1333
1625
  checkIdentity(item.id, isBuiltInContract(item), itemAt, ids, problems);
1334
1626
  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);
1627
+ } else if (isRecord$2(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
1336
1628
  }
1337
1629
  return contracts;
1338
1630
  }
@@ -1348,7 +1640,7 @@ function readMaxMarkdownBytes(path, raw, problems) {
1348
1640
  */
1349
1641
  function readCollection(path, name, raw, site, problems) {
1350
1642
  if (!NAME$1.test(name)) problems.add(path, `${quote(name)} is not a valid collection name`, nameFix("blog"));
1351
- if (!isRecord$1(raw)) {
1643
+ if (!isRecord$2(raw)) {
1352
1644
  problems.add(path, wrongType(raw, "a collection"), "Declare it with defineCollection({ schema, routes, … }).");
1353
1645
  return null;
1354
1646
  }
@@ -1399,7 +1691,7 @@ function readSiteUrl(path, raw, problems) {
1399
1691
  /** The origins of a site's pages that may read its published content from the browser. */
1400
1692
  function readCors(path, raw, problems) {
1401
1693
  if (raw === void 0) return { origins: [] };
1402
- if (!isRecord$1(raw) || !Array.isArray(raw["origins"])) {
1694
+ if (!isRecord$2(raw) || !Array.isArray(raw["origins"])) {
1403
1695
  problems.add(path, wrongType(raw, "an object with origins"), "Declare it as { origins: [\"https://example.com\"] }, or remove it.");
1404
1696
  return { origins: [] };
1405
1697
  }
@@ -1416,7 +1708,7 @@ function readBindingName(path, raw, problems) {
1416
1708
  return null;
1417
1709
  }
1418
1710
  function readWebhook(path, raw, problems) {
1419
- if (!isRecord$1(raw)) {
1711
+ if (!isRecord$2(raw)) {
1420
1712
  problems.add(path, wrongType(raw, "a webhook"), "Declare it as { name: \"newsletter\", url: \"https://…\", secret: \"WEBHOOK_SECRET\" }.");
1421
1713
  return null;
1422
1714
  }
@@ -1425,8 +1717,9 @@ function readWebhook(path, raw, problems) {
1425
1717
  const url = raw["url"];
1426
1718
  const urlOk = typeof url === "string" && URL.canParse(url) && new URL(url).protocol === "https:";
1427
1719
  if (!urlOk) problems.add(keyPath(path, "url"), wrongType(url, "an https URL"), "Give the https URL that receives the events.");
1428
- const secret = readBindingName(keyPath(path, "secret"), raw["secret"], problems);
1429
- if (typeof name !== "string" || !NAME.test(name) || !urlOk || secret === null) return null;
1720
+ const secret = raw["secret"] === void 0 ? null : readBindingName(keyPath(path, "secret"), raw["secret"], problems);
1721
+ if (typeof name !== "string" || !NAME.test(name) || !urlOk) return null;
1722
+ if (raw["secret"] !== void 0 && secret === null) return null;
1430
1723
  return {
1431
1724
  name,
1432
1725
  url: String(url),
@@ -1440,7 +1733,7 @@ function readEvents(path, raw, siteUrl, problems) {
1440
1733
  indexNow: null
1441
1734
  };
1442
1735
  if (raw === void 0) return events;
1443
- if (!isRecord$1(raw)) {
1736
+ if (!isRecord$2(raw)) {
1444
1737
  problems.add(path, wrongType(raw, "an object"), "Declare { webhooks: [ … ], indexNow: { … } }.");
1445
1738
  return events;
1446
1739
  }
@@ -1464,7 +1757,7 @@ function readEvents(path, raw, siteUrl, problems) {
1464
1757
  const indexNow = raw["indexNow"];
1465
1758
  if (indexNow !== void 0) {
1466
1759
  const indexPath = keyPath(path, "indexNow");
1467
- if (!isRecord$1(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"INDEXNOW_KEY\" }.");
1760
+ if (!isRecord$2(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"INDEXNOW_KEY\" }.");
1468
1761
  else {
1469
1762
  const key = readBindingName(keyPath(indexPath, "key"), indexNow["key"], problems);
1470
1763
  if (siteUrl === null) problems.add(indexPath, "needs the URL of the site", "Set url on the site: IndexNow is told the public URLs of the entries.");
@@ -1550,7 +1843,7 @@ function readDefaultLocale(path, raw, locales, problems) {
1550
1843
  const COLLECTIONS_FIX = "Declare at least one: collections: { blog: defineCollection({ schema, routes }) }.";
1551
1844
  function readCollections(path, raw, site, problems) {
1552
1845
  const collections = /* @__PURE__ */ new Map();
1553
- if (!isRecord$1(raw)) {
1846
+ if (!isRecord$2(raw)) {
1554
1847
  problems.add(path, wrongType(raw, "an object that maps each collection name to its collection"), COLLECTIONS_FIX);
1555
1848
  return collections;
1556
1849
  }
@@ -1566,7 +1859,7 @@ function readCollections(path, raw, site, problems) {
1566
1859
  function readSite(key, raw, problems) {
1567
1860
  const path = keyPath("sites", key);
1568
1861
  if (!isSiteKey(key)) problems.add(path, `${quote(key)} is not a valid site key`, SITE_KEY_FIX);
1569
- if (!isRecord$1(raw)) {
1862
+ if (!isRecord$2(raw)) {
1570
1863
  problems.add(path, wrongType(raw, "a site"), "Declare it as { locales: [\"en\"], defaultLocale: \"en\", collections: { … } }.");
1571
1864
  return null;
1572
1865
  }
@@ -1593,7 +1886,7 @@ function readSite(key, raw, problems) {
1593
1886
  }
1594
1887
  function readSites(raw, problems) {
1595
1888
  const sites = /* @__PURE__ */ new Map();
1596
- if (!isRecord$1(raw)) {
1889
+ if (!isRecord$2(raw)) {
1597
1890
  problems.add("sites", wrongType(raw, "an object that maps the site key to the site"), SITE_FIX);
1598
1891
  return sites;
1599
1892
  }
@@ -1608,7 +1901,7 @@ function readSites(raw, problems) {
1608
1901
  /** `auth` of the config: absent, or an object whose `dynamicClientRegistration` is a boolean. */
1609
1902
  function readAuth(raw, problems) {
1610
1903
  if (raw === void 0) return { dynamicClientRegistration: false };
1611
- if (!isRecord$1(raw)) {
1904
+ if (!isRecord$2(raw)) {
1612
1905
  problems.add("auth", wrongType(raw, "an object"), "Write auth: { dynamicClientRegistration: true }, or leave auth out.");
1613
1906
  return { dynamicClientRegistration: false };
1614
1907
  }
@@ -1650,7 +1943,7 @@ function resolveConfig(config) {
1650
1943
  const problems = new Problems();
1651
1944
  let sites = /* @__PURE__ */ new Map();
1652
1945
  let auth = { dynamicClientRegistration: false };
1653
- if (isRecord$1(config)) {
1946
+ if (isRecord$2(config)) {
1654
1947
  sites = readSites(config["sites"], problems);
1655
1948
  auth = readAuth(config["auth"], problems);
1656
1949
  } else problems.add("config", wrongType(config, "an object"), "Export the result of defineConfig({ sites: { … } }) from hollware.config.ts.");
@@ -1661,6 +1954,426 @@ function resolveConfig(config) {
1661
1954
  };
1662
1955
  }
1663
1956
  //#endregion
1957
+ //#region src/structure/definition.ts
1958
+ /**
1959
+ * The definition of a site as data (ADR-033): what D1 stores for it and what the operations that
1960
+ * change the structure take. Its shape is checked here; what only makes sense of the whole (a
1961
+ * route with `:slug`, a default locale among the locales, a rule's parameters) is checked when it
1962
+ * is compiled, with one problem for each thing wrong.
1963
+ */
1964
+ const jsonSchema = z.record(z.string(), z.unknown()).describe("A JSON Schema, draft 2020-12, of an object.");
1965
+ const severity$1 = z.enum(["error", "warning"]);
1966
+ const ruleDefinition = z.strictObject({
1967
+ rule: z.string().min(1).describe("The name of a rule of the catalog that describe_site lists."),
1968
+ severity: severity$1.default("error"),
1969
+ params: z.record(z.string(), z.unknown()).default({}).describe("The parameters of the rule, as the catalog describes them."),
1970
+ id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional().describe("The id of the contract, when a collection lists one rule twice; its name otherwise.")
1971
+ });
1972
+ const componentDefinitionData = z.strictObject({
1973
+ props: jsonSchema.describe("The JSON Schema of the props."),
1974
+ description: z.string().min(1).optional(),
1975
+ example: z.string().min(1).optional()
1976
+ });
1977
+ const collectionDefinitionData = z.strictObject({
1978
+ schema: jsonSchema.describe("The JSON Schema of the frontmatter."),
1979
+ routes: z.record(z.string(), z.string()).describe("The public path of an entry in each locale, with :slug: { \"en\": \"/blog/:slug/\" }."),
1980
+ translationGroupField: z.string().min(1).nullable().default(null),
1981
+ tagsField: z.string().min(1).nullable().default(null),
1982
+ sortByField: z.string().min(1).nullable().default(null),
1983
+ components: z.record(z.string(), componentDefinitionData).default({}),
1984
+ rules: z.array(ruleDefinition).default([]),
1985
+ maxMarkdownBytes: z.number().int().positive().nullable().default(null),
1986
+ styleGuide: z.string().nullable().default(null)
1987
+ });
1988
+ const webhookDefinition = z.strictObject({
1989
+ name: z.string().min(1),
1990
+ url: z.string().min(1).describe("The https URL that receives each event, signed."),
1991
+ secret: z.string().min(1).optional().describe("The Worker secret that signs the deliveries. Leave it out for Hollware to make one with make_webhook_secret and keep it sealed.")
1992
+ });
1993
+ const siteDefinitionData = z.strictObject({
1994
+ locales: z.array(z.string().min(1)).min(1),
1995
+ defaultLocale: z.string().min(1),
1996
+ url: z.string().min(1).nullable().default(null),
1997
+ styleGuide: z.string().nullable().default(null),
1998
+ cors: z.strictObject({ origins: z.array(z.string().min(1)) }).default({ origins: [] }),
1999
+ events: z.strictObject({
2000
+ webhooks: z.array(webhookDefinition).default([]),
2001
+ indexNow: z.strictObject({ key: z.string().min(1) }).nullable().default(null)
2002
+ }).default({
2003
+ webhooks: [],
2004
+ indexNow: null
2005
+ }),
2006
+ collections: z.record(z.string(), collectionDefinitionData)
2007
+ });
2008
+ //#endregion
2009
+ //#region src/structure/json-schema.ts
2010
+ /** The keywords that only say that a part below them failed; that part says what. */
2011
+ const SUMMARIES = /* @__PURE__ */ new Set([
2012
+ "properties",
2013
+ "items",
2014
+ "prefixItems",
2015
+ "additionalProperties",
2016
+ "unevaluatedProperties",
2017
+ "unevaluatedItems",
2018
+ "allOf",
2019
+ "patternProperties",
2020
+ "contains",
2021
+ "dependentSchemas",
2022
+ "$ref"
2023
+ ]);
2024
+ /** `#/tags/0` as the path of an issue: `['tags', '0']`. */
2025
+ function segmentsOf$1(location) {
2026
+ return location.replace(/^#\/?/, "").split("/").filter((segment) => segment !== "").map((segment) => decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~"));
2027
+ }
2028
+ function isRecord$1(value) {
2029
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2030
+ }
2031
+ /** The schema of the object at `segments`, to tell a declared key from an unknown one. */
2032
+ function declaredKeys(schema, segments) {
2033
+ let node = schema;
2034
+ for (const segment of segments) {
2035
+ if (!isRecord$1(node)) return /* @__PURE__ */ new Set();
2036
+ const properties = node["properties"];
2037
+ node = isRecord$1(properties) && Object.hasOwn(properties, segment) ? properties[segment] : node["items"];
2038
+ }
2039
+ const properties = isRecord$1(node) ? node["properties"] : void 0;
2040
+ return new Set(isRecord$1(properties) ? Object.keys(properties) : []);
2041
+ }
2042
+ /** The issues of a validation: one for each part that failed, at the path of the value. */
2043
+ function issuesOf(schema, errors) {
2044
+ const issues = [];
2045
+ for (const error of errors) {
2046
+ if (SUMMARIES.has(error.keyword)) continue;
2047
+ const path = segmentsOf$1(error.instanceLocation);
2048
+ if (error.keyword === "required") {
2049
+ const missing = /required property "(.*)"/.exec(error.error)?.[1];
2050
+ issues.push({
2051
+ message: missing === void 0 ? error.error : `Missing required field "${missing}".`,
2052
+ path: missing === void 0 ? path : [...path, missing]
2053
+ });
2054
+ continue;
2055
+ }
2056
+ if (error.keyword === "false") {
2057
+ const key = path.at(-1);
2058
+ if (key === void 0 || declaredKeys(schema, path.slice(0, -1)).has(key)) continue;
2059
+ issues.push({
2060
+ message: `Unrecognized key "${key}".`,
2061
+ path
2062
+ });
2063
+ continue;
2064
+ }
2065
+ issues.push({
2066
+ message: error.error,
2067
+ path
2068
+ });
2069
+ }
2070
+ return issues;
2071
+ }
2072
+ /** A JSON Schema (draft 2020-12) as a schema of Hollware. */
2073
+ function describedSchemaOf(schema) {
2074
+ const validator = new Validator(schema, "2020-12", false);
2075
+ return { "~standard": {
2076
+ version: 1,
2077
+ vendor: "hollware-json-schema",
2078
+ validate: (value) => {
2079
+ const result = validator.validate(value);
2080
+ return result.valid ? { value } : { issues: issuesOf(schema, result.errors) };
2081
+ },
2082
+ jsonSchema: {
2083
+ input: () => schema,
2084
+ output: () => schema
2085
+ }
2086
+ } };
2087
+ }
2088
+ //#endregion
2089
+ //#region src/structure/compile.ts
2090
+ /**
2091
+ * The sites of a deployment from their definitions in D1 (ADR-033), resolved as a config is: the
2092
+ * same checks, the same defaults, the same `ResolvedSite` the rest of the server reads. It throws
2093
+ * one `ConfigError` with every problem, each at its path (`sites.blog.collections.posts.rules[0]`)
2094
+ * and with its fix, so that an operation that changes the structure answers what to change.
2095
+ */
2096
+ function isObjectSchema(schema) {
2097
+ return schema["type"] === "object";
2098
+ }
2099
+ function compileCollection(path, collection, problems) {
2100
+ if (!isObjectSchema(collection.schema)) problems.add(keyPath(path, "schema"), "the schema of the frontmatter is not of an object", "Give a JSON Schema with \"type\": \"object\" and the fields under \"properties\".");
2101
+ const components = {};
2102
+ for (const [name, component] of Object.entries(collection.components)) components[name] = {
2103
+ props: describedSchemaOf(component.props),
2104
+ ...component.description === void 0 ? {} : { description: component.description },
2105
+ ...component.example === void 0 ? {} : { example: component.example }
2106
+ };
2107
+ const contracts = [];
2108
+ collection.rules.forEach((rule, index) => {
2109
+ const at = itemPath(keyPath(path, "rules"), index);
2110
+ const entry = catalogRule(rule.rule);
2111
+ if (entry === void 0) {
2112
+ problems.add(keyPath(at, "rule"), `there is no rule "${rule.rule}" in the catalog`, "Use the name of a rule that describe_site lists in its catalog.");
2113
+ return;
2114
+ }
2115
+ const params = entry.params.safeParse(rule.params);
2116
+ if (!params.success) {
2117
+ problems.add(keyPath(at, "params"), `the parameters of "${rule.rule}" are not valid: ${z.prettifyError(params.error).replaceAll("\n", " ")}`, "Give the parameters as the catalog describes them for this rule.");
2118
+ return;
2119
+ }
2120
+ contracts.push(entry.make(params.data, rule.severity, rule.id));
2121
+ });
2122
+ return {
2123
+ schema: describedSchemaOf(collection.schema),
2124
+ routes: collection.routes,
2125
+ ...collection.translationGroupField === null ? {} : { translations: { groupBy: collection.translationGroupField } },
2126
+ ...collection.tagsField === null ? {} : { tags: collection.tagsField },
2127
+ ...collection.sortByField === null ? {} : { sortBy: collection.sortByField },
2128
+ components,
2129
+ contracts,
2130
+ ...collection.maxMarkdownBytes === null ? {} : { maxMarkdownBytes: collection.maxMarkdownBytes },
2131
+ ...collection.styleGuide === null ? {} : { styleGuide: collection.styleGuide }
2132
+ };
2133
+ }
2134
+ function compileSite(path, site, problems) {
2135
+ const collections = {};
2136
+ for (const [name, collection] of Object.entries(site.collections)) collections[name] = compileCollection(keyPath(keyPath(path, "collections"), name), collection, problems);
2137
+ const [first, ...rest] = site.locales;
2138
+ return {
2139
+ locales: [first ?? "", ...rest],
2140
+ defaultLocale: site.defaultLocale,
2141
+ collections,
2142
+ ...site.url === null ? {} : { url: site.url },
2143
+ ...site.styleGuide === null ? {} : { styleGuide: site.styleGuide },
2144
+ cors: { origins: site.cors.origins },
2145
+ events: {
2146
+ webhooks: site.events.webhooks.map((webhook) => ({
2147
+ name: webhook.name,
2148
+ url: webhook.url,
2149
+ ...webhook.secret === void 0 ? {} : { secret: webhook.secret }
2150
+ })),
2151
+ ...site.events.indexNow === null ? {} : { indexNow: site.events.indexNow }
2152
+ }
2153
+ };
2154
+ }
2155
+ /** The definitions of the sites, read from D1 as JSON, checked and resolved. */
2156
+ function compileSites(definitions) {
2157
+ const problems = new Problems();
2158
+ const sites = {};
2159
+ for (const [key, raw] of Object.entries(definitions)) {
2160
+ const path = keyPath("sites", key);
2161
+ const parsed = siteDefinitionData.safeParse(raw);
2162
+ if (!parsed.success) {
2163
+ for (const issue of parsed.error.issues) problems.add([path, ...issue.path.map(String)].join("."), issue.message.replace(/\.$/, ""), "Write the definition as describe_site shows one.");
2164
+ continue;
2165
+ }
2166
+ sites[key] = compileSite(path, parsed.data, problems);
2167
+ }
2168
+ if (problems.list.length > 0) throw new ConfigError(problems.list);
2169
+ if (Object.keys(sites).length === 0) return {
2170
+ sites: /* @__PURE__ */ new Map(),
2171
+ auth: { dynamicClientRegistration: false }
2172
+ };
2173
+ return resolveConfig({ sites });
2174
+ }
2175
+ //#endregion
2176
+ //#region src/structure/from-config.ts
2177
+ function toJsonSchema(schema) {
2178
+ return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" });
2179
+ }
2180
+ function collectionOf(path, collection, problems) {
2181
+ const rules = [];
2182
+ (collection.contracts ?? []).forEach((contract, index) => {
2183
+ if (contract.rule === void 0) {
2184
+ problems.add(itemPath(keyPath(path, "contracts"), index), `the contract "${contract.id}" is code of the config's own, which a definition cannot hold`, "Replace it with a rule of the catalog (contracts.fieldLength, contracts.fieldPattern, contracts.requiredToPublish, contracts.bannedWords, or the built-in ones), then deploy again.");
2185
+ return;
2186
+ }
2187
+ rules.push({
2188
+ rule: contract.rule.name,
2189
+ severity: contract.severity,
2190
+ params: { ...contract.rule.params },
2191
+ ...contract.id === contract.rule.name ? {} : { id: contract.id }
2192
+ });
2193
+ });
2194
+ return {
2195
+ schema: toJsonSchema(collection.schema),
2196
+ routes: { ...collection.routes },
2197
+ translationGroupField: collection.translations?.groupBy ?? null,
2198
+ tagsField: collection.tags ?? null,
2199
+ sortByField: collection.sortBy ?? null,
2200
+ components: Object.fromEntries(Object.entries(collection.components ?? {}).map(([name, component]) => [name, {
2201
+ props: toJsonSchema(component.props),
2202
+ ...component.description === void 0 ? {} : { description: component.description },
2203
+ ...component.example === void 0 ? {} : { example: component.example }
2204
+ }])),
2205
+ rules,
2206
+ maxMarkdownBytes: collection.maxMarkdownBytes ?? null,
2207
+ styleGuide: collection.styleGuide ?? null
2208
+ };
2209
+ }
2210
+ /** Every site of the config as a definition, or a `ConfigError` with what keeps one from moving. */
2211
+ function definitionsFromConfig(config) {
2212
+ const problems = new Problems();
2213
+ const definitions = {};
2214
+ for (const [key, site] of Object.entries(config.sites ?? {})) {
2215
+ const path = keyPath("sites", key);
2216
+ definitions[key] = {
2217
+ locales: [...site.locales],
2218
+ defaultLocale: site.defaultLocale,
2219
+ url: site.url ?? null,
2220
+ styleGuide: site.styleGuide ?? null,
2221
+ cors: { origins: [...site.cors?.origins ?? []] },
2222
+ events: {
2223
+ webhooks: (site.events?.webhooks ?? []).map((webhook) => ({ ...webhook })),
2224
+ indexNow: site.events?.indexNow ?? null
2225
+ },
2226
+ collections: Object.fromEntries(Object.entries(site.collections).map(([name, collection]) => [name, collectionOf(keyPath(keyPath(path, "collections"), name), collection, problems)]))
2227
+ };
2228
+ }
2229
+ if (problems.list.length > 0) throw new ConfigError(problems.list);
2230
+ return definitions;
2231
+ }
2232
+ //#endregion
2233
+ //#region src/structure/presets.ts
2234
+ const PRESETS = [
2235
+ "blog",
2236
+ "docs",
2237
+ "empty"
2238
+ ];
2239
+ /** The path of an entry in each locale: the default one at the root, the others under theirs. */
2240
+ function routesFor(name, locales, defaultLocale) {
2241
+ return Object.fromEntries(locales.map((locale) => [locale, locale === defaultLocale ? `/${name}/:slug/` : `/${locale}/${name}/:slug/`]));
2242
+ }
2243
+ function collectionPreset(preset, name, locales, defaultLocale) {
2244
+ const translated = locales.length > 1;
2245
+ const base = {
2246
+ routes: routesFor(name, locales, defaultLocale),
2247
+ translationGroupField: null,
2248
+ tagsField: null,
2249
+ sortByField: null,
2250
+ components: {},
2251
+ rules: [],
2252
+ maxMarkdownBytes: null,
2253
+ styleGuide: null
2254
+ };
2255
+ switch (preset) {
2256
+ case "empty": return {
2257
+ ...base,
2258
+ schema: {
2259
+ type: "object",
2260
+ properties: { title: {
2261
+ type: "string",
2262
+ minLength: 1
2263
+ } },
2264
+ required: ["title"]
2265
+ }
2266
+ };
2267
+ case "blog": return {
2268
+ ...base,
2269
+ schema: {
2270
+ type: "object",
2271
+ properties: {
2272
+ title: {
2273
+ type: "string",
2274
+ minLength: 1
2275
+ },
2276
+ description: {
2277
+ type: "string",
2278
+ minLength: 1,
2279
+ maxLength: 160
2280
+ },
2281
+ date: {
2282
+ type: "string",
2283
+ format: "date"
2284
+ },
2285
+ tags: {
2286
+ type: "array",
2287
+ items: {
2288
+ type: "string",
2289
+ minLength: 1
2290
+ }
2291
+ },
2292
+ ...translated ? { translationGroup: {
2293
+ type: "string",
2294
+ minLength: 1
2295
+ } } : {}
2296
+ },
2297
+ required: [
2298
+ "title",
2299
+ "description",
2300
+ "date",
2301
+ ...translated ? ["translationGroup"] : []
2302
+ ]
2303
+ },
2304
+ translationGroupField: translated ? "translationGroup" : null,
2305
+ tagsField: "tags",
2306
+ sortByField: "date",
2307
+ rules: [
2308
+ {
2309
+ rule: "images-have-alt",
2310
+ severity: "error",
2311
+ params: {}
2312
+ },
2313
+ {
2314
+ rule: "internal-links-resolve",
2315
+ severity: "warning",
2316
+ params: {}
2317
+ },
2318
+ ...translated ? [{
2319
+ rule: "translation-pair",
2320
+ severity: "warning",
2321
+ params: {}
2322
+ }] : []
2323
+ ]
2324
+ };
2325
+ case "docs": return {
2326
+ ...base,
2327
+ schema: {
2328
+ type: "object",
2329
+ properties: {
2330
+ title: {
2331
+ type: "string",
2332
+ minLength: 1,
2333
+ maxLength: 70
2334
+ },
2335
+ description: {
2336
+ type: "string",
2337
+ minLength: 1,
2338
+ maxLength: 160
2339
+ },
2340
+ order: {
2341
+ type: "integer",
2342
+ minimum: 0
2343
+ }
2344
+ },
2345
+ required: ["title", "description"]
2346
+ },
2347
+ rules: [{
2348
+ rule: "images-have-alt",
2349
+ severity: "error",
2350
+ params: {}
2351
+ }, {
2352
+ rule: "internal-links-resolve",
2353
+ severity: "warning",
2354
+ params: {}
2355
+ }]
2356
+ };
2357
+ }
2358
+ }
2359
+ /** A new site: its locales, and one collection from the preset. */
2360
+ function sitePreset(preset, locales, defaultLocale) {
2361
+ const name = preset === "empty" ? "pages" : preset;
2362
+ const collections = { [name]: collectionPreset(preset, name, locales, defaultLocale) };
2363
+ return {
2364
+ locales: [...locales],
2365
+ defaultLocale,
2366
+ url: null,
2367
+ styleGuide: null,
2368
+ cors: { origins: [] },
2369
+ events: {
2370
+ webhooks: [],
2371
+ indexNow: null
2372
+ },
2373
+ collections
2374
+ };
2375
+ }
2376
+ //#endregion
1664
2377
  //#region src/errors.ts
1665
2378
  /**
1666
2379
  * The errors of an operation that are none of its outcomes: the caller asked for something it
@@ -1712,8 +2425,11 @@ const ID_PREFIXES = {
1712
2425
  revision: "rev",
1713
2426
  revisionTag: "tag",
1714
2427
  review: "rvw",
2428
+ reviewComment: "rvc",
1715
2429
  schedule: "sch",
1716
2430
  session: "ses",
2431
+ siteChange: "sit",
2432
+ siteSecret: "sec",
1717
2433
  token: "tok",
1718
2434
  user: "usr"
1719
2435
  };
@@ -3050,6 +3766,7 @@ var schemas_exports = /* @__PURE__ */ __exportAll({
3050
3766
  phase: () => phase,
3051
3767
  publication: () => publication,
3052
3768
  review: () => review,
3769
+ reviewComment: () => reviewComment,
3053
3770
  reviewId: () => reviewId,
3054
3771
  reviewStatus: () => reviewStatus,
3055
3772
  revisionId: () => revisionId,
@@ -3110,6 +3827,7 @@ const entrySummary = z.object({
3110
3827
  tags: z.array(z.string()),
3111
3828
  hasUnpublishedChanges: z.boolean().describe("Whether the latest revision differs from the published one."),
3112
3829
  publishedAt: z.string().nullable().describe("When the entry was first published, or null."),
3830
+ scheduledAt: z.string().nullable().describe("When a scheduled entry will be published, or null for one that is not scheduled."),
3113
3831
  updatedAt: z.string()
3114
3832
  });
3115
3833
  const publication = z.object({
@@ -3155,6 +3873,15 @@ const review = z.object({
3155
3873
  decidedAt: z.string().nullable(),
3156
3874
  note: z.string().nullable().describe("What the reviewer asked to change, or null.")
3157
3875
  });
3876
+ /** A comment on a review (v0.6), by a person or by the agent of one. */
3877
+ const reviewComment = z.object({
3878
+ id: z.string(),
3879
+ author: z.string().describe("The user who wrote it, or whose token their agent used."),
3880
+ authorName: z.string().nullable(),
3881
+ client: z.string().nullable().describe("The name the client gave, such as hollware-agents for the agents; null for none."),
3882
+ body: z.string(),
3883
+ at: z.string()
3884
+ });
3158
3885
  const conflict = z.object({
3159
3886
  currentVersion: z.number().int().min(1),
3160
3887
  headRevision: revisionId
@@ -3198,6 +3925,7 @@ const siteDescription = z.object({
3198
3925
  key: z.string(),
3199
3926
  locales: z.array(z.string()),
3200
3927
  defaultLocale: z.string(),
3928
+ url: z.string().nullable().describe("The public origin of the site, which starts the URL of each entry, or null."),
3201
3929
  styleGuide: z.string().nullable(),
3202
3930
  collections: z.array(collectionDescription)
3203
3931
  });
@@ -3454,7 +4182,10 @@ const listReviews = defineOperation({
3454
4182
  }),
3455
4183
  output: z.object({
3456
4184
  outcome: z.enum(["ok"]),
3457
- reviews: z.array(review.extend({ entry: entrySummary }))
4185
+ reviews: z.array(review.extend({
4186
+ entry: entrySummary,
4187
+ comments: z.array(reviewComment).describe("What was said about it, oldest first.")
4188
+ }))
3458
4189
  }),
3459
4190
  scopes: ["content:read"],
3460
4191
  effect: "read",
@@ -3490,6 +4221,62 @@ const approveReview = defineOperation({
3490
4221
  not_found: { isError: true }
3491
4222
  }
3492
4223
  });
4224
+ const requestReview = defineOperation({
4225
+ name: "request_review",
4226
+ title: "Ask for a review",
4227
+ description: "Asks a person who may publish to review the latest revision of an entry, after the same checks publish makes, if the entry is still at the version you read. It never publishes, whatever your scopes: the answer is pending_review, or why not (blocked by the contracts, warnings to acknowledge, a conflict). A new revision of the entry cancels the request.",
4228
+ input: z.strictObject({
4229
+ id: entryId,
4230
+ version,
4231
+ acknowledgeWarnings: z.array(acknowledgedWarning).default([]).describe("The warnings you accept, as needs_acknowledgement listed them.")
4232
+ }),
4233
+ output: z.object({
4234
+ outcome: z.enum([
4235
+ "pending_review",
4236
+ "blocked",
4237
+ "needs_acknowledgement",
4238
+ "conflict",
4239
+ "not_found"
4240
+ ]),
4241
+ entry: entrySummary.optional(),
4242
+ verdict: verdict.optional(),
4243
+ conflict: conflict.optional(),
4244
+ review: review.optional().describe("With pending_review: the request, as list_reviews shows it.")
4245
+ }),
4246
+ scopes: ["content:write"],
4247
+ effect: "write",
4248
+ outcomes: {
4249
+ pending_review: { isError: false },
4250
+ blocked: { isError: true },
4251
+ needs_acknowledgement: { isError: false },
4252
+ conflict: { isError: true },
4253
+ not_found: { isError: true }
4254
+ }
4255
+ });
4256
+ const commentReview = defineOperation({
4257
+ name: "comment_review",
4258
+ title: "Comment on a review",
4259
+ description: "Adds a comment to a pending review, for the person who decides it and for the author: what to change, what reads well, what the contracts cannot check. It neither approves nor closes the review.",
4260
+ input: z.strictObject({
4261
+ id: reviewId,
4262
+ body: z.string().trim().min(1).max(4e3).describe("The comment, in Markdown.")
4263
+ }),
4264
+ output: z.object({
4265
+ outcome: z.enum([
4266
+ "commented",
4267
+ "not_pending",
4268
+ "not_found"
4269
+ ]),
4270
+ comment: reviewComment.optional()
4271
+ }),
4272
+ scopes: ["content:write"],
4273
+ effect: "write",
4274
+ outcomes: {
4275
+ commented: { isError: false },
4276
+ not_pending: { isError: true },
4277
+ not_found: { isError: true }
4278
+ }
4279
+ });
3493
4280
  const requestChanges = defineOperation({
3494
4281
  name: "request_changes",
3495
4282
  title: "Ask for changes",
@@ -3538,21 +4325,205 @@ const contentStats = defineOperation({
3538
4325
  unavailable: { isError: true }
3539
4326
  }
3540
4327
  });
4328
+ const siteKey = z.string().refine(isSiteKey, { message: "Not a site key: lowercase words joined by hyphens, such as my-site." }).describe("The key of a site: lowercase letters and digits joined by hyphens, such as my-site.");
4329
+ const structureVersion = z.number().int().min(0).describe("The version of the structure you read with get_structure. Another one answers conflict.");
4330
+ const confirm = z.boolean().default(false).describe("true to go ahead when needs_confirmation listed what the change does to existing entries.");
4331
+ const problem = z.object({
4332
+ path: z.string().describe("Where, from the root: sites.<key>.collections.<name>.schema."),
4333
+ message: z.string(),
4334
+ fix: z.string()
4335
+ });
4336
+ /** What a change of the structure would do to the entries a site already has. */
4337
+ const impact = z.object({
4338
+ removedCollections: z.array(z.object({
4339
+ name: z.string(),
4340
+ entries: z.number().int()
4341
+ })).describe("Collections the change removes, with how many entries each holds."),
4342
+ removedLocales: z.array(z.object({
4343
+ locale: z.string(),
4344
+ entries: z.number().int()
4345
+ })).describe("Locales the change removes, with how many entries each holds."),
4346
+ refused: z.array(z.object({
4347
+ collection: z.string(),
4348
+ entries: z.number().int().describe("Entries whose latest frontmatter the new schema refuses."),
4349
+ examples: z.array(z.string()).describe("A few of their slugs.")
4350
+ })).describe("Collections whose new schema refuses entries they hold; they stay, and their next write is checked against it.")
4351
+ });
4352
+ const structureChange = z.object({
4353
+ outcome: z.enum([
4354
+ "updated",
4355
+ "unchanged",
4356
+ "invalid",
4357
+ "needs_confirmation",
4358
+ "conflict"
4359
+ ]),
4360
+ version: z.number().int().optional().describe("The version of the structure now."),
4361
+ problems: z.array(problem).optional().describe("With invalid: every problem, each with its fix."),
4362
+ impact: impact.optional().describe("With needs_confirmation: what the change would do."),
4363
+ currentVersion: z.number().int().optional().describe("With conflict: the version to read again.")
4364
+ });
4365
+ const STRUCTURE_OUTCOMES = {
4366
+ updated: { isError: false },
4367
+ unchanged: { isError: false },
4368
+ invalid: { isError: true },
4369
+ needs_confirmation: { isError: false },
4370
+ conflict: { isError: true }
4371
+ };
4372
+ const getStructure = defineOperation({
4373
+ name: "get_structure",
4374
+ title: "Get the structure of the site",
4375
+ description: "Gets the definition of the site as update_site and set_collection take it: its locales, URL, CORS origins, events, style guide, and each collection with the JSON Schema of its frontmatter, its routes, components and rules; with its version, and the catalog of rules a collection may use, each with the JSON Schema of its parameters.",
4376
+ input: z.strictObject({}),
4377
+ output: z.object({
4378
+ outcome: z.enum(["ok"]),
4379
+ site: z.string(),
4380
+ version: z.number().int(),
4381
+ definition: siteDefinitionData,
4382
+ catalog: z.array(z.object({
4383
+ name: z.string(),
4384
+ description: z.string(),
4385
+ params: z.record(z.string(), z.unknown()).describe("The JSON Schema of its parameters.")
4386
+ }))
4387
+ }),
4388
+ scopes: ["content:read"],
4389
+ effect: "read",
4390
+ outcomes: { ok: { isError: false } }
4391
+ });
4392
+ const updateSite = defineOperation({
4393
+ name: "update_site",
4394
+ title: "Change the structure of the site",
4395
+ description: "Replaces the definition of the site with another, at once and with no deploy: read it with get_structure, change it, and send it with the version you read. Every problem is answered at its path with its fix, and nothing changes. A change that removes a collection or a locale with entries, or a schema that refuses entries the site holds, answers needs_confirmation with what it would do; send it again with confirm to go ahead. Entries are never deleted: a removed collection keeps them out of reach until it comes back.",
4396
+ input: z.strictObject({
4397
+ version: structureVersion,
4398
+ definition: siteDefinitionData,
4399
+ confirm
4400
+ }),
4401
+ output: structureChange.describe("What became of the definition."),
4402
+ scopes: ["schema:admin"],
4403
+ effect: "destructive",
4404
+ outcomes: STRUCTURE_OUTCOMES
4405
+ });
4406
+ const setCollection = defineOperation({
4407
+ name: "set_collection",
4408
+ title: "Add, change or remove a collection",
4409
+ description: "Adds the collection name to the site, replaces it, or removes it with collection null, leaving the rest of the definition as it is; with the version of the structure you read. Answers as update_site does.",
4410
+ input: z.strictObject({
4411
+ version: structureVersion,
4412
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/).describe("The name of the collection: lowercase letters, digits and hyphens, such as blog."),
4413
+ collection: collectionDefinitionData.nullable().describe("The collection, or null to remove it."),
4414
+ confirm
4415
+ }),
4416
+ output: structureChange.describe("What became of the collection."),
4417
+ scopes: ["schema:admin"],
4418
+ effect: "destructive",
4419
+ outcomes: STRUCTURE_OUTCOMES
4420
+ });
4421
+ const createSite = defineOperation({
4422
+ name: "create_site",
4423
+ title: "Create a site",
4424
+ description: "Creates a site in this deployment, with its definition, and makes you its owner. It is served at once; to work in it, get a token of that site (the admin switches to it).",
4425
+ input: z.strictObject({
4426
+ key: siteKey,
4427
+ definition: siteDefinitionData
4428
+ }),
4429
+ output: z.object({
4430
+ outcome: z.enum([
4431
+ "created",
4432
+ "taken",
4433
+ "invalid"
4434
+ ]),
4435
+ version: z.number().int().optional(),
4436
+ problems: z.array(problem).optional().describe("With invalid: every problem, each with its fix.")
4437
+ }),
4438
+ scopes: ["schema:admin"],
4439
+ effect: "write",
4440
+ outcomes: {
4441
+ created: { isError: false },
4442
+ taken: { isError: true },
4443
+ invalid: { isError: true }
4444
+ }
4445
+ });
4446
+ const deleteSite = defineOperation({
4447
+ name: "delete_site",
4448
+ title: "Put a site in the trash",
4449
+ description: "Puts a site you own in the trash: it is served no more, and its tokens stop working. Nothing is deleted; restore_site brings it back, from a token of another site you own. The first call answers needs_confirmation with how many entries the site holds; call again with confirm.",
4450
+ input: z.strictObject({
4451
+ key: siteKey,
4452
+ confirm
4453
+ }),
4454
+ output: z.object({
4455
+ outcome: z.enum([
4456
+ "trashed",
4457
+ "needs_confirmation",
4458
+ "not_found"
4459
+ ]),
4460
+ entries: z.number().int().optional().describe("With needs_confirmation: the entries the site holds.")
4461
+ }),
4462
+ scopes: ["schema:admin"],
4463
+ effect: "destructive",
4464
+ outcomes: {
4465
+ trashed: { isError: false },
4466
+ needs_confirmation: { isError: false },
4467
+ not_found: { isError: true }
4468
+ }
4469
+ });
4470
+ const restoreSite = defineOperation({
4471
+ name: "restore_site",
4472
+ title: "Bring a site back from the trash",
4473
+ description: "Brings back a site you own from the trash, as it was: served again, with its entries and tokens.",
4474
+ input: z.strictObject({ key: siteKey }),
4475
+ output: z.object({ outcome: z.enum(["restored", "not_found"]) }),
4476
+ scopes: ["schema:admin"],
4477
+ effect: "write",
4478
+ outcomes: {
4479
+ restored: { isError: false },
4480
+ not_found: { isError: true }
4481
+ }
4482
+ });
3541
4483
  /** Every operation, sorted by name: the order of `tools/list`. */
3542
4484
  const OPERATIONS = [
3543
4485
  approveReview,
4486
+ commentReview,
3544
4487
  contentStats,
3545
4488
  createDraft,
4489
+ createSite,
4490
+ deleteSite,
3546
4491
  describeSite,
3547
4492
  getEntry,
4493
+ getStructure,
3548
4494
  getTranslations,
3549
4495
  listEntries,
3550
4496
  listReviews,
4497
+ defineOperation({
4498
+ name: "make_webhook_secret",
4499
+ title: "Make the secret of a webhook",
4500
+ description: "Makes a new secret for a webhook of the site that names no Worker secret of its own, keeps it sealed, and answers it once: give it to the receiver, which checks each delivery's signature with it. A new one replaces the old at once. unavailable when the deployment has no HOLLWARE_SECRET to seal it with.",
4501
+ input: z.strictObject({ webhook: z.string().min(1).describe("The name of the webhook, as the definition of the site has it.") }),
4502
+ output: z.object({
4503
+ outcome: z.enum([
4504
+ "made",
4505
+ "not_found",
4506
+ "unavailable"
4507
+ ]),
4508
+ secret: z.string().optional().describe("With made: the secret, shown this once.")
4509
+ }),
4510
+ scopes: ["schema:admin"],
4511
+ effect: "write",
4512
+ outcomes: {
4513
+ made: { isError: false },
4514
+ not_found: { isError: true },
4515
+ unavailable: { isError: true }
4516
+ }
4517
+ }),
3551
4518
  publish,
3552
4519
  requestChanges,
4520
+ requestReview,
4521
+ restoreSite,
3553
4522
  search,
4523
+ setCollection,
3554
4524
  unpublish,
3555
4525
  updateDraft,
4526
+ updateSite,
3556
4527
  validate
3557
4528
  ];
3558
4529
  //#endregion
@@ -3667,82 +4638,58 @@ async function verifyWebhook(check) {
3667
4638
  return sameText(signature, await signWebhook(check.secret, timestamp, check.body));
3668
4639
  }
3669
4640
  //#endregion
3670
- //#region src/search/chunks.ts
4641
+ //#region src/cache.ts
3671
4642
  /**
3672
- * The pieces of an entry that semantic search embeds (v0.5): one summary, and one piece for each
3673
- * section the body has under an `h2`. Vectors are derived data, like the Comark document: a change
3674
- * of this version makes every vector one to make again.
4643
+ * The cache tags of Hollware's content (S8): one name for each thing a page can show, so that a
4644
+ * site tags each page with what it read and purges, when an event says what changed, the pages
4645
+ * that showed it. Hollware puts the tags an event invalidates in its payload; `@hollware/nuxt`
4646
+ * tags its pages with the same functions. A tag is at most 1,024 characters of printable ASCII
4647
+ * with no space or comma, which every cache that purges by tag takes.
4648
+ *
4649
+ * - `entry:<id>`: a page that shows the entry, its own or one that embeds it;
4650
+ * - `list:<collection>:<locale>`: a page that lists the entries of a collection in a locale, and
4651
+ * `list:<collection>:*` one that lists them in every locale;
4652
+ * - `group:<collection>:<group>`: a page that links to the translations of a group;
4653
+ * - `site`: a page that shows the structure of the site, such as its navigation.
3675
4654
  */
3676
- const CHUNKER_VERSION = "1";
3677
- /** How long a piece may grow, in characters, before it is cut at a word. */
3678
- const CHUNK_LIMITS = {
3679
- summary: 1200,
3680
- section: 2e3
3681
- };
3682
- /** The text a reader sees in a node: its strings, never its tag, attributes or comments. */
3683
- function textOf(node) {
3684
- if (typeof node === "string") return node;
3685
- if (!Array.isArray(node) || node[0] === null) return "";
3686
- return node.slice(2).map((child) => textOf(child)).join("");
4655
+ const SITE_TAG = "site";
4656
+ const MAX = 1024;
4657
+ /** A part of a tag in the characters a tag may hold: percent-encoded, commas and all. */
4658
+ function part(value) {
4659
+ return encodeURIComponent(value).replaceAll(",", "%2C").replaceAll("'", "%27");
3687
4660
  }
3688
- function tagOf(node) {
3689
- return Array.isArray(node) && typeof node[0] === "string" ? node[0] : null;
4661
+ function cut(tag) {
4662
+ return tag.length <= MAX ? tag : tag.slice(0, MAX);
3690
4663
  }
3691
- /** The words of a text in one line, cut at the last word that fits in `limit`. */
3692
- function fit(text, limit) {
3693
- const flat = text.replace(/\s+/g, " ").trim();
3694
- if (flat.length <= limit) return flat;
3695
- const cut = flat.slice(0, limit);
3696
- const lastSpace = cut.lastIndexOf(" ");
3697
- return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim();
4664
+ function entryTag(id) {
4665
+ return cut(`entry:${part(id)}`);
4666
+ }
4667
+ function listTag(collection, locale) {
4668
+ return cut(`list:${part(collection)}:${part(locale)}`);
4669
+ }
4670
+ function groupTag(collection, group) {
4671
+ return cut(`group:${part(collection)}:${part(group)}`);
4672
+ }
4673
+ /** The locale of a list of every locale. */
4674
+ const EVERY_LOCALE = "*";
4675
+ /** The tags of a page that shows these entries, each its own. */
4676
+ function tagsOfEntries(entries) {
4677
+ return [...new Set(entries.map((entry) => entryTag(entry.id)))];
3698
4678
  }
3699
4679
  /**
3700
- * The pieces of an entry: the summary (its title, its description and the text before the first
3701
- * `h2`), then each section (its heading and its text up to the next `h2`). An `h1` adds nothing:
3702
- * it repeats the title. A section with no text is left out.
4680
+ * The tags an event about an entry invalidates: the pages that show it, the lists of its
4681
+ * collection in its locale and in every locale, and the pages that link to its translations.
3703
4682
  */
3704
- function chunksOf(entry) {
3705
- const intro = [];
3706
- const sections = [];
3707
- for (const node of entry.document.nodes) {
3708
- const tag = tagOf(node);
3709
- if (tag === "h1") continue;
3710
- if (tag === "h2") {
3711
- sections.push({
3712
- heading: textOf(node).trim(),
3713
- parts: []
3714
- });
3715
- continue;
3716
- }
3717
- const text = textOf(node);
3718
- const current = sections.at(-1);
3719
- if (current === void 0) intro.push(text);
3720
- else current.parts.push(text);
3721
- }
3722
- const chunks = [{
3723
- index: 0,
3724
- kind: "summary",
3725
- heading: null,
3726
- text: fit([
3727
- entry.title,
3728
- entry.description ?? "",
3729
- intro.join(" ")
3730
- ].filter((part) => part !== "").join("\n"), CHUNK_LIMITS.summary)
3731
- }];
3732
- for (const section of sections) {
3733
- const body = section.parts.join(" ").trim();
3734
- if (body === "") continue;
3735
- chunks.push({
3736
- index: chunks.length,
3737
- kind: "section",
3738
- heading: section.heading,
3739
- text: fit(`${section.heading}\n${body}`, CHUNK_LIMITS.section)
3740
- });
3741
- }
3742
- return chunks;
4683
+ function tagsOfChange(entry) {
4684
+ return [
4685
+ entryTag(entry.id),
4686
+ listTag(entry.collection, entry.locale),
4687
+ listTag(entry.collection, "*"),
4688
+ ...entry.translationGroup === null ? [] : [groupTag(entry.collection, entry.translationGroup)]
4689
+ ];
3743
4690
  }
3744
4691
  //#endregion
3745
4692
  //#region src/index.ts
3746
4693
  const name = "@hollware/core";
3747
4694
  //#endregion
3748
- export { BUILT_IN_CONTRACT_IDS, CHUNKER_VERSION, CHUNK_LIMITS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, READ_TOKEN_PREFIX, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, TIMESTAMP_HEADER, approveReview, chunksOf, contentStats, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, describeBuiltInContracts, describeContract, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, name, newId, processMarkdown, publish, renderHtml, requestChanges, resolveConfig, runContracts, schemas_exports as schemas, search, signWebhook, sizeLimitViolation, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyWebhook };
4695
+ export { BUILT_IN_CONTRACT_IDS, CATALOG, CHUNKER_VERSION, CHUNK_LIMITS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, EVERY_LOCALE, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, PRESETS, READ_TOKEN_PREFIX, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, SITE_TAG, TIMESTAMP_HEADER, approveReview, canonicalJson, catalogRule, chunksOf, collectionDefinitionData, collectionPreset, commentReview, compileSites, componentDefinitionData, contentStats, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeContract, describeResolvedSite, describeSite, describedSchemaOf, entryTag, generatePersonalToken, generateReadToken, getEntry, getTranslations, groupTag, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, listTag, name, newId, processMarkdown, publish, renderHtml, requestChanges, requestReview, resolveConfig, routesFor, ruleDefinition, runContracts, schemas_exports as schemas, search, signWebhook, siteDefinitionData, sitePreset, sizeLimitViolation, tagsOfChange, tagsOfEntries, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyWebhook, webhookDefinition };