@hollware/core 0.9.1 → 0.11.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
@@ -55,6 +55,203 @@ var Problems = class {
55
55
  }
56
56
  };
57
57
  //#endregion
58
+ //#region src/config/values.ts
59
+ /** A value with keys: not null, not an array. */
60
+ function isRecord$4(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+ /** A string as the messages show it: quoted, with what could break a line escaped. */
64
+ function quote$1(text) {
65
+ return JSON.stringify(text);
66
+ }
67
+ /** Text of an error that someone else wrote, on one line, so that a problem stays one line. */
68
+ function oneLine(text) {
69
+ return text.replace(/\s+/g, " ").trim();
70
+ }
71
+ /** What a value is, for a message: `"es"`, `42`, `null`, `an array`. */
72
+ function found(value) {
73
+ if (value === null) return "null";
74
+ switch (typeof value) {
75
+ case "string": return quote$1(value);
76
+ case "number":
77
+ case "boolean":
78
+ case "bigint": return String(value);
79
+ case "function": return "a function";
80
+ case "symbol": return "a symbol";
81
+ default: return Array.isArray(value) ? "an array" : "an object";
82
+ }
83
+ }
84
+ /** `is 42; it must be a string`, or `is missing; …` when the value is not there. */
85
+ function wrongType(value, expected) {
86
+ return `${value === void 0 ? "is missing" : `is ${found(value)}`}; it must be ${expected}`;
87
+ }
88
+ /** An optional string: `null` when it is absent, and a problem when it is anything else. */
89
+ function readOptionalString(path, value, fix, problems) {
90
+ if (value === void 0) return null;
91
+ if (typeof value === "string") return value;
92
+ problems.add(path, wrongType(value, "a string"), fix);
93
+ return null;
94
+ }
95
+ const PLAIN_KEY = /^[A-Za-z0-9_-]+$/;
96
+ /** The path of a key inside `parent`: `sites.demo`, or `sites["my site"]` for an odd key. */
97
+ function keyPath(parent, key) {
98
+ return PLAIN_KEY.test(key) ? `${parent}.${key}` : `${parent}[${quote$1(key)}]`;
99
+ }
100
+ /** The path of an item of an array: `sites.demo.locales[1]`. */
101
+ function itemPath(parent, index) {
102
+ return `${parent}[${index}]`;
103
+ }
104
+ //#endregion
105
+ //#region src/config/events.ts
106
+ /** The name of a Worker secret or variable: what `wrangler secret put` takes. */
107
+ const BINDING_NAME = /^[A-Z][A-Z0-9_]*$/;
108
+ /**
109
+ * The Worker secrets a definition may not name: Hollware's own (its master secret, its analytics
110
+ * token) and Cloudflare's. A site's definition is written by its owner or an agent with
111
+ * schema:admin, who need not hold the account, so it names only secrets made for its webhooks.
112
+ */
113
+ const RESERVED_SECRET = /^(HOLLWARE|CLOUDFLARE|CF)_/;
114
+ /**
115
+ * An IndexNow key, which is public by design (search engines fetch it at `/<key>.txt`): the
116
+ * definition holds the key itself, never the name of a Worker secret, so no value of the Worker
117
+ * can be sent to IndexNow (T-69 of docs/security/threat-model.md).
118
+ */
119
+ const INDEXNOW_KEY = /^[A-Za-z0-9-]{8,128}$/;
120
+ /** A subscriber's name: lowercase words joined by hyphens. */
121
+ const NAME$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
122
+ /** The public URL of a site: an absolute http or https URL with no path, query or fragment. */
123
+ function readSiteUrl(path, raw, problems) {
124
+ if (raw === void 0) return null;
125
+ const fix = "Write the public origin of the site, such as \"https://example.com\".";
126
+ if (typeof raw !== "string" || !URL.canParse(raw)) {
127
+ problems.add(path, wrongType(raw, "an absolute URL"), fix);
128
+ return null;
129
+ }
130
+ const url = new URL(raw);
131
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
132
+ problems.add(path, `${quote$1(raw)} is not an origin`, fix);
133
+ return null;
134
+ }
135
+ return url.origin;
136
+ }
137
+ /** The origins of a site's pages that may read its published content from the browser. */
138
+ function readCors(path, raw, problems) {
139
+ if (raw === void 0) return { origins: [] };
140
+ if (!isRecord$4(raw) || !Array.isArray(raw["origins"])) {
141
+ problems.add(path, wrongType(raw, "an object with origins"), "Declare it as { origins: [\"https://example.com\"] }, or remove it.");
142
+ return { origins: [] };
143
+ }
144
+ const origins = [];
145
+ for (const [index, origin] of raw["origins"].entries()) {
146
+ const read = readSiteUrl(itemPath(keyPath(path, "origins"), index), origin, problems);
147
+ if (read !== null && !origins.includes(read)) origins.push(read);
148
+ }
149
+ return { origins };
150
+ }
151
+ function readBindingName(path, raw, problems) {
152
+ if (typeof raw === "string" && BINDING_NAME.test(raw) && RESERVED_SECRET.test(raw)) {
153
+ problems.add(path, `names ${quote$1(raw)}, a secret of Hollware or of Cloudflare, which no webhook may use`, "Leave the secret out, and Hollware makes one for the webhook (make_webhook_secret).");
154
+ return null;
155
+ }
156
+ if (typeof raw === "string" && BINDING_NAME.test(raw)) return raw;
157
+ problems.add(path, wrongType(raw, "the name of a Worker secret"), "Name the secret that holds the value, such as \"WEBHOOK_SECRET\", and set it with wrangler secret put.");
158
+ return null;
159
+ }
160
+ function readWebhook(path, raw, problems) {
161
+ if (!isRecord$4(raw)) {
162
+ problems.add(path, wrongType(raw, "a webhook"), "Declare it as { name: \"newsletter\", url: \"https://…\", secret: \"WEBHOOK_SECRET\" }.");
163
+ return null;
164
+ }
165
+ const name = raw["name"];
166
+ if (typeof name !== "string" || !NAME$1.test(name)) problems.add(keyPath(path, "name"), wrongType(name, "a name"), "Name it with lowercase words joined by hyphens, such as \"newsletter\".");
167
+ const url = raw["url"];
168
+ const urlOk = typeof url === "string" && URL.canParse(url) && new URL(url).protocol === "https:";
169
+ if (!urlOk) problems.add(keyPath(path, "url"), wrongType(url, "an https URL"), "Give the https URL that receives the events.");
170
+ const secret = raw["secret"] === void 0 ? null : readBindingName(keyPath(path, "secret"), raw["secret"], problems);
171
+ if (typeof name !== "string" || !NAME$1.test(name) || !urlOk) return null;
172
+ if (raw["secret"] !== void 0 && secret === null) return null;
173
+ return {
174
+ name,
175
+ url: String(url),
176
+ secret
177
+ };
178
+ }
179
+ /** Who hears about publications (ADR-025): webhooks, and IndexNow when the site has a URL. */
180
+ function readEvents(path, raw, siteUrl, problems) {
181
+ const events = {
182
+ webhooks: [],
183
+ indexNow: null
184
+ };
185
+ if (raw === void 0) return events;
186
+ if (!isRecord$4(raw)) {
187
+ problems.add(path, wrongType(raw, "an object"), "Declare { webhooks: [ … ], indexNow: { … } }.");
188
+ return events;
189
+ }
190
+ const webhooks = raw["webhooks"];
191
+ if (webhooks !== void 0) {
192
+ if (!Array.isArray(webhooks)) problems.add(keyPath(path, "webhooks"), wrongType(webhooks, "a list"), "List the webhooks.");
193
+ else {
194
+ const seen = /* @__PURE__ */ new Set();
195
+ webhooks.forEach((item, index) => {
196
+ const webhook = readWebhook(itemPath(keyPath(path, "webhooks"), index), item, problems);
197
+ if (webhook === null) return;
198
+ if (seen.has(webhook.name)) {
199
+ problems.add(itemPath(keyPath(path, "webhooks"), index), `repeats the name ${quote$1(webhook.name)}`, "Give each webhook a name of its own.");
200
+ return;
201
+ }
202
+ seen.add(webhook.name);
203
+ events.webhooks.push(webhook);
204
+ });
205
+ }
206
+ }
207
+ const indexNow = raw["indexNow"];
208
+ if (indexNow !== void 0) {
209
+ const indexPath = keyPath(path, "indexNow");
210
+ if (!isRecord$4(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"<your IndexNow key>\" }.");
211
+ else {
212
+ const raw = indexNow["key"];
213
+ const key = typeof raw === "string" && INDEXNOW_KEY.test(raw) ? raw : null;
214
+ if (key === null) problems.add(keyPath(indexPath, "key"), wrongType(raw, "an IndexNow key: 8 to 128 letters, digits or hyphens"), "Put the key itself, the one your site serves at /<key>.txt, not the name of a secret.");
215
+ 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.");
216
+ else if (key !== null) events.indexNow = { key };
217
+ }
218
+ }
219
+ return events;
220
+ }
221
+ //#endregion
222
+ //#region src/config/audit.ts
223
+ /** The fewest days a site may keep its activity log for, when it sets a retention (ADR-037). */
224
+ const AUDIT_RETENTION_MIN_DAYS = 30;
225
+ /** The most days a retention may be: ten years. Beyond that, keep it with no retention. */
226
+ const AUDIT_RETENTION_MAX_DAYS = 3650;
227
+ /** What a site that says nothing keeps: no addresses, and the log for as long as the site exists. */
228
+ const DEFAULT_AUDIT = {
229
+ ipAddresses: "off",
230
+ retentionDays: null
231
+ };
232
+ /** What the activity log of a site keeps, as its owner chose (ADR-037). */
233
+ function readAudit(path, raw, problems) {
234
+ if (raw === void 0 || raw === null) return DEFAULT_AUDIT;
235
+ if (!isRecord$4(raw)) {
236
+ problems.add(path, wrongType(raw, "an object"), "Declare it as { ipAddresses: \"off\", retentionDays: null }, or remove it.");
237
+ return DEFAULT_AUDIT;
238
+ }
239
+ let ipAddresses = "off";
240
+ const addresses = raw["ipAddresses"];
241
+ if (addresses === "hashed") ipAddresses = "hashed";
242
+ else if (addresses !== void 0 && addresses !== "off") problems.add(keyPath(path, "ipAddresses"), wrongType(addresses, "\"off\" or \"hashed\""), "Use \"off\" to keep no address, or \"hashed\" to keep a keyed hash of each.");
243
+ let retentionDays = null;
244
+ const days = raw["retentionDays"];
245
+ if (days !== void 0 && days !== null) {
246
+ if (typeof days !== "number" || !Number.isInteger(days) || days < 30 || days > 3650) problems.add(keyPath(path, "retentionDays"), `${typeof days === "number" ? String(days) : wrongType(days, "a number")} is not a whole number of days from 30 to ${AUDIT_RETENTION_MAX_DAYS}`, "Give the days to keep each line, or null to keep the log for as long as the site exists.");
247
+ else retentionDays = days;
248
+ }
249
+ return {
250
+ ipAddresses,
251
+ retentionDays
252
+ };
253
+ }
254
+ //#endregion
58
255
  //#region src/config/constants.ts
59
256
  /** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
60
257
  const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
@@ -70,7 +267,7 @@ const MAX_MARKDOWN_BYTES_CEILING = 786432;
70
267
  * letter, at most 63 characters. A component name is what Markdown writes after the colons
71
268
  * (`::callout`). A site key has its own rule (`site-key.ts`).
72
269
  */
73
- const NAME$1 = /^[a-z][a-z0-9-]{0,62}$/;
270
+ const NAME = /^[a-z][a-z0-9-]{0,62}$/;
74
271
  /** A language tag: a language, then an optional script and region: `es`, `pt-BR`, `zh-Hant-TW`. */
75
272
  const LOCALE_TAG = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
76
273
  /** The id of a contract: lowercase words joined by single hyphens. */
@@ -140,7 +337,7 @@ function clip(text, max = MAX_ECHO_LENGTH) {
140
337
  return `${last >= 55296 && last <= 56319 ? head.slice(0, -1) : head}…`;
141
338
  }
142
339
  /** Text from the entry as a message shows it: clipped, in JSON quotes, control characters escaped. */
143
- function quote$1(text) {
340
+ function quote(text) {
144
341
  return JSON.stringify(clip(text));
145
342
  }
146
343
  /** A JSON Pointer (RFC 6901) from its segments: `['tags', '0']` is `/tags/0`, none is the root. */
@@ -264,7 +461,7 @@ function unknownKeys(fragment, value) {
264
461
  }
265
462
  /** The fix for one issue: imperative, naming the field and what the schema accepts there. */
266
463
  function fixForIssue({ subject, schema, input, segments, message }) {
267
- const target = segments.length === 0 ? subject.container : `the ${subject.noun} ${quote$1(label(segments))}`;
464
+ const target = segments.length === 0 ? subject.container : `the ${subject.noun} ${quote(label(segments))}`;
268
465
  const fragment = schema === null ? void 0 : fragmentAt(schema, segments);
269
466
  if (fragment !== void 0) {
270
467
  const unknown = unknownKeys(fragment, valueAt(input, segments));
@@ -272,7 +469,7 @@ function fixForIssue({ subject, schema, input, segments, message }) {
272
469
  const allowed = isRecord$3(fragment.properties) ? Object.keys(fragment.properties) : [];
273
470
  const nouns = unknown.length === 1 ? subject.noun : `${subject.noun}s`;
274
471
  const where = allowed.length === 0 ? `no ${subject.noun}s are allowed there` : `the ${subject.noun}s allowed there are: ${allowed.join(", ")}`;
275
- return `Remove the ${nouns} ${unknown.map((key) => quote$1(key)).join(", ")} from ${target}; ${where}.`;
472
+ return `Remove the ${nouns} ${unknown.map((key) => quote(key)).join(", ")} from ${target}; ${where}.`;
276
473
  }
277
474
  }
278
475
  const expected = fragment === void 0 ? void 0 : expectation(fragment);
@@ -354,15 +551,15 @@ const COMPONENTS = registerBuiltIn({
354
551
  const result = await definition.props["~standard"].validate(use.props);
355
552
  if (!result.issues) continue;
356
553
  const subject = {
357
- container: `the component ${quote$1(use.name)}`,
554
+ container: `the component ${quote(use.name)}`,
358
555
  noun: "prop"
359
556
  };
360
557
  for (const issue of result.issues) {
361
558
  const segments = issueSegments(issue);
362
- const which = segments.length === 0 ? "The props" : `The prop ${quote$1(label(segments))}`;
559
+ const which = segments.length === 0 ? "The props" : `The prop ${quote(label(segments))}`;
363
560
  findings.push({
364
561
  path: "/body",
365
- message: `${which} of the component ${quote$1(use.name)} ${segments.length === 0 ? "are" : "is"} invalid: ${issue.message}`,
562
+ message: `${which} of the component ${quote(use.name)} ${segments.length === 0 ? "are" : "is"} invalid: ${issue.message}`,
366
563
  fix: fixForIssue({
367
564
  subject,
368
565
  schema: schemas.get(use.name) ?? null,
@@ -429,16 +626,16 @@ const RAW_HTML = markerContract("raw-html", "The body holds Markdown and the com
429
626
  const UNKNOWN_COMPONENT = markerContract("unknown-component", "The body uses only the components that the collection declares.", (marker, collection) => {
430
627
  const components = allowedComponents(collection);
431
628
  return {
432
- message: `The component ${quote$1(marker.name)} does not exist in this collection.`,
629
+ message: `The component ${quote(marker.name)} does not exist in this collection.`,
433
630
  fix: components.length === 0 ? "This collection allows no components: remove it and use Markdown." : `Use one of the allowed components: ${components.join(", ")}.`
434
631
  };
435
632
  });
436
633
  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) => ({
437
- message: `The attribute ${quote$1(marker.name)} is not allowed.`,
438
- 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.`
634
+ message: `The attribute ${quote(marker.name)} is not allowed.`,
635
+ fix: `Remove the attribute ${quote(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.`
439
636
  }));
440
637
  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) => ({
441
- message: `The URL ${quote$1(marker.name)} is not allowed.`,
638
+ message: `The URL ${quote(marker.name)} is not allowed.`,
442
639
  fix: "Use an https: or http: URL, a mailto: address, a path of this site that starts with /, or an anchor that starts with #."
443
640
  }));
444
641
  //#endregion
@@ -633,11 +830,11 @@ function lengthOf(value) {
633
830
  /** A text, or a list, of a field has between `min` and `max` characters or items. */
634
831
  function fieldLength(params, options = {}) {
635
832
  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`;
833
+ const unless = params.unless === void 0 ? "" : `, unless ${quote(params.unless)} is given`;
637
834
  return registerBuiltIn({
638
835
  id: options.id ?? "field-length",
639
836
  severity: options.severity ?? "error",
640
- description: `${quote$1(params.field)} has ${bounds.join(" and ")} characters or items${unless}.`,
837
+ description: `${quote(params.field)} has ${bounds.join(" and ")} characters or items${unless}.`,
641
838
  rule: {
642
839
  name: "field-length",
643
840
  params: { ...params }
@@ -651,13 +848,13 @@ function fieldLength(params, options = {}) {
651
848
  const path = `/frontmatter/${params.field}`;
652
849
  if (params.max !== void 0 && length > params.max) return [{
653
850
  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)}`}.`
851
+ message: `${quote(params.field)} has ${count(length, unit)}; the most is ${params.max}.`,
852
+ fix: `Shorten ${quote(params.field)} to ${count(params.max, unit)} or fewer${params.unless === void 0 ? "" : `, or add ${quote(params.unless)}`}.`
656
853
  }];
657
854
  if (params.min !== void 0 && length < params.min) return [{
658
855
  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.`
856
+ message: `${quote(params.field)} has ${count(length, unit)}; the least is ${params.min}.`,
857
+ fix: `Lengthen ${quote(params.field)} to ${count(params.min, unit)} or more.`
661
858
  }];
662
859
  return [];
663
860
  }
@@ -669,7 +866,7 @@ function fieldPattern(params, options = {}) {
669
866
  return registerBuiltIn({
670
867
  id: options.id ?? "field-pattern",
671
868
  severity: options.severity ?? "error",
672
- description: `${quote$1(params.field)} is ${params.description}.`,
869
+ description: `${quote(params.field)} is ${params.description}.`,
673
870
  rule: {
674
871
  name: "field-pattern",
675
872
  params: { ...params }
@@ -679,8 +876,8 @@ function fieldPattern(params, options = {}) {
679
876
  if (typeof value !== "string" || pattern.test(value)) return [];
680
877
  return [{
681
878
  path: `/frontmatter/${params.field}`,
682
- message: `${quote$1(params.field)} is not ${params.description}.`,
683
- fix: `Write ${quote$1(params.field)} as ${params.description}.`
879
+ message: `${quote(params.field)} is not ${params.description}.`,
880
+ fix: `Write ${quote(params.field)} as ${params.description}.`
684
881
  }];
685
882
  }
686
883
  }, { readsFrontmatter: true });
@@ -692,15 +889,15 @@ function requiredToPublish(params, options = {}) {
692
889
  id: options.id ?? "required-to-publish",
693
890
  severity: options.severity ?? "error",
694
891
  phases: ["publish"],
695
- description: `To publish, ${params.fields.map(quote$1).join(", ")} must have a value.`,
892
+ description: `To publish, ${params.fields.map(quote).join(", ")} must have a value.`,
696
893
  rule: {
697
894
  name: "required-to-publish",
698
895
  params: { fields: [...params.fields] }
699
896
  },
700
897
  check: ({ frontmatter }) => params.fields.filter((field) => empty(present(frontmatter, field))).map((field) => ({
701
898
  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.`
899
+ message: `${quote(field)} is empty, and the entry cannot be published without it.`,
900
+ fix: `Give ${quote(field)} a value, then publish.`
704
901
  }))
705
902
  }, { readsFrontmatter: true });
706
903
  }
@@ -716,7 +913,7 @@ function bannedWords(params, options = {}) {
716
913
  return registerBuiltIn({
717
914
  id: options.id ?? "banned-words",
718
915
  severity: options.severity ?? "error",
719
- description: `None of these words: ${words.map(quote$1).join(", ")}${because}.`,
916
+ description: `None of these words: ${words.map(quote).join(", ")}${because}.`,
720
917
  rule: {
721
918
  name: "banned-words",
722
919
  params: {
@@ -732,14 +929,14 @@ function bannedWords(params, options = {}) {
732
929
  if (typeof value !== "string") continue;
733
930
  for (const word of found(value)) findings.push({
734
931
  path: `/frontmatter/${field}`,
735
- message: `${quote$1(field)} uses ${quote$1(word)}.`,
736
- fix: `Write ${quote$1(field)} without ${quote$1(word)}${because}.`
932
+ message: `${quote(field)} uses ${quote(word)}.`,
933
+ fix: `Write ${quote(field)} without ${quote(word)}${because}.`
737
934
  });
738
935
  }
739
936
  for (const word of found(documentText(context.markdown.document))) findings.push({
740
937
  path: "/body",
741
- message: `The body uses ${quote$1(word)}.`,
742
- fix: `Rewrite the body without ${quote$1(word)}${because}.`
938
+ message: `The body uses ${quote(word)}.`,
939
+ fix: `Rewrite the body without ${quote(word)}${because}.`
743
940
  });
744
941
  return findings;
745
942
  }
@@ -778,8 +975,8 @@ function translationPair(options = {}) {
778
975
  const present = /* @__PURE__ */ new Set([context.locale, ...found.map((entry) => entry.locale)]);
779
976
  return site.locales.filter((locale) => !present.has(locale)).map((locale) => ({
780
977
  path,
781
- message: `The ${locale} version of the translation group ${quote$1(group)} does not exist.`,
782
- fix: `Create the entry in ${locale} with ${field}: ${quote$1(group)} in its frontmatter before publishing.`
978
+ message: `The ${locale} version of the translation group ${quote(group)} does not exist.`,
979
+ fix: `Create the entry in ${locale} with ${field}: ${quote(group)} in its frontmatter before publishing.`
783
980
  }));
784
981
  }
785
982
  }, { readsFrontmatter: true });
@@ -826,7 +1023,7 @@ function internalLinksResolve(options = {}) {
826
1023
  const entry = `the entry ${clip(`${target.collection}/${target.locale}/${target.slug}`)}`;
827
1024
  findings.push({
828
1025
  path: "/body",
829
- 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}).`,
1026
+ message: found === null ? `The link ${quote(link.href)} points at ${entry}, which does not exist.` : `The link ${quote(link.href)} points at ${entry}, which is not published yet (${found.status}).`,
830
1027
  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.`,
831
1028
  line: link.line
832
1029
  });
@@ -859,7 +1056,7 @@ function imagesHaveAlt(options = {}) {
859
1056
  description: "Every image has alt text that describes it.",
860
1057
  check: (context) => context.markdown.extracted.images.filter((image) => image.alt.trim() === "").map((image) => ({
861
1058
  path: "/body",
862
- message: `The image ${quote$1(image.src)} has no alt text.`,
1059
+ message: `The image ${quote(image.src)} has no alt text.`,
863
1060
  fix: `Add alt text that describes the image, as in ![a short description](${clip(image.src)}).`,
864
1061
  line: image.line
865
1062
  }))
@@ -1026,7 +1223,7 @@ function toViolations(contract, findings) {
1026
1223
  severity: contract.severity,
1027
1224
  path: finding.path,
1028
1225
  message: finding.message,
1029
- fix: finding.fix.trim() === "" ? `Change the entry so that the rule ${quote$1(contract.id)} passes: ${contract.description}` : finding.fix,
1226
+ fix: finding.fix.trim() === "" ? `Change the entry so that the rule ${quote(contract.id)} passes: ${contract.description}` : finding.fix,
1030
1227
  ...finding.line === void 0 ? {} : { line: finding.line }
1031
1228
  }));
1032
1229
  }
@@ -1043,8 +1240,8 @@ function thrown(contract, error) {
1043
1240
  contract: contract.id,
1044
1241
  severity: "error",
1045
1242
  path: "",
1046
- message: `The contract ${quote$1(contract.id)} failed with an error while it checked this entry: ${clip(describeError(error), 200)}`,
1047
- 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)}.`
1243
+ message: `The contract ${quote(contract.id)} failed with an error while it checked this entry: ${clip(describeError(error), 200)}`,
1244
+ 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(contract.id)}.`
1048
1245
  };
1049
1246
  }
1050
1247
  async function settle(contract, context) {
@@ -1169,57 +1366,10 @@ function uniqueTranslationViolation(holder, field) {
1169
1366
  severity: "error",
1170
1367
  path: `/frontmatter${pointer([field])}`,
1171
1368
  message: `Another entry of this collection and locale, ${holder}, already belongs to this translation group.`,
1172
- fix: `Give ${quote$1(field)} another value, or change the entry ${holder} with update_draft: a translation group holds one entry per locale.`
1369
+ fix: `Give ${quote(field)} another value, or change the entry ${holder} with update_draft: a translation group holds one entry per locale.`
1173
1370
  };
1174
1371
  }
1175
1372
  //#endregion
1176
- //#region src/config/values.ts
1177
- /** A value with keys: not null, not an array. */
1178
- function isRecord$2(value) {
1179
- return typeof value === "object" && value !== null && !Array.isArray(value);
1180
- }
1181
- /** A string as the messages show it: quoted, with what could break a line escaped. */
1182
- function quote(text) {
1183
- return JSON.stringify(text);
1184
- }
1185
- /** Text of an error that someone else wrote, on one line, so that a problem stays one line. */
1186
- function oneLine(text) {
1187
- return text.replace(/\s+/g, " ").trim();
1188
- }
1189
- /** What a value is, for a message: `"es"`, `42`, `null`, `an array`. */
1190
- function found(value) {
1191
- if (value === null) return "null";
1192
- switch (typeof value) {
1193
- case "string": return quote(value);
1194
- case "number":
1195
- case "boolean":
1196
- case "bigint": return String(value);
1197
- case "function": return "a function";
1198
- case "symbol": return "a symbol";
1199
- default: return Array.isArray(value) ? "an array" : "an object";
1200
- }
1201
- }
1202
- /** `is 42; it must be a string`, or `is missing; …` when the value is not there. */
1203
- function wrongType(value, expected) {
1204
- return `${value === void 0 ? "is missing" : `is ${found(value)}`}; it must be ${expected}`;
1205
- }
1206
- /** An optional string: `null` when it is absent, and a problem when it is anything else. */
1207
- function readOptionalString(path, value, fix, problems) {
1208
- if (value === void 0) return null;
1209
- if (typeof value === "string") return value;
1210
- problems.add(path, wrongType(value, "a string"), fix);
1211
- return null;
1212
- }
1213
- const PLAIN_KEY = /^[A-Za-z0-9_-]+$/;
1214
- /** The path of a key inside `parent`: `sites.demo`, or `sites["my site"]` for an odd key. */
1215
- function keyPath(parent, key) {
1216
- return PLAIN_KEY.test(key) ? `${parent}.${key}` : `${parent}[${quote(key)}]`;
1217
- }
1218
- /** The path of an item of an array: `sites.demo.locales[1]`. */
1219
- function itemPath(parent, index) {
1220
- return `${parent}[${index}]`;
1221
- }
1222
- //#endregion
1223
1373
  //#region src/config/json-schema.ts
1224
1374
  /**
1225
1375
  * JSON Schema as the config uses it: what agents see of a schema, and what the checks read of it
@@ -1233,7 +1383,7 @@ const TARGET = "draft-2020-12";
1233
1383
  */
1234
1384
  function toJsonSchema$1(schema) {
1235
1385
  const converted = schema["~standard"].jsonSchema.input({ target: TARGET });
1236
- if (!isRecord$2(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
1386
+ if (!isRecord$4(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
1237
1387
  return JSON.parse(JSON.stringify(converted));
1238
1388
  }
1239
1389
  /** Reference chains longer than this are cycles. */
@@ -1245,7 +1395,7 @@ function pointTo(root, ref) {
1245
1395
  let node = root;
1246
1396
  for (const part of ref.slice(2).split("/")) {
1247
1397
  const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
1248
- if (!isRecord$2(node) || !Object.hasOwn(node, key)) return void 0;
1398
+ if (!isRecord$4(node) || !Object.hasOwn(node, key)) return void 0;
1249
1399
  node = node[key];
1250
1400
  }
1251
1401
  return node;
@@ -1257,7 +1407,7 @@ function pointTo(root, ref) {
1257
1407
  function resolveSchema(root, schema) {
1258
1408
  let node = schema;
1259
1409
  for (let hop = 0; hop <= MAX_REF_HOPS; hop += 1) {
1260
- if (!isRecord$2(node)) return null;
1410
+ if (!isRecord$4(node)) return null;
1261
1411
  const ref = node["$ref"];
1262
1412
  if (typeof ref !== "string") return node;
1263
1413
  node = pointTo(root, ref);
@@ -1374,14 +1524,14 @@ function standardProps(value) {
1374
1524
  /** A Standard Schema that also converts to JSON Schema, which is what a collection needs. */
1375
1525
  function isDescribedSchema(value) {
1376
1526
  const standard = standardProps(value);
1377
- if (!isRecord$2(standard) || typeof standard["validate"] !== "function") return false;
1527
+ if (!isRecord$4(standard) || typeof standard["validate"] !== "function") return false;
1378
1528
  const converter = standard["jsonSchema"];
1379
- return isRecord$2(converter) && typeof converter["input"] === "function";
1529
+ return isRecord$4(converter) && typeof converter["input"] === "function";
1380
1530
  }
1381
1531
  /** What `isDescribedSchema` found missing, for the message. */
1382
1532
  function whyNotDescribed(value) {
1383
1533
  const standard = standardProps(value);
1384
- if (!isRecord$2(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
1534
+ if (!isRecord$4(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
1385
1535
  if (typeof standard["validate"] !== "function") return "has no ~standard.validate function, so it is no Standard Schema";
1386
1536
  return "has no ~standard.jsonSchema.input function, so describe_site could not show it to agents";
1387
1537
  }
@@ -1429,12 +1579,12 @@ const SLUG_PLACEHOLDER = /:slug(?![A-Za-z0-9_])/g;
1429
1579
  function isRoutePattern(path, route, problems) {
1430
1580
  let valid = true;
1431
1581
  if (!route.startsWith("/")) {
1432
- problems.add(path, `${quote(route)} does not start with "/"`, "Write the path from the root of the site, such as \"/blog/:slug/\".");
1582
+ problems.add(path, `${quote$1(route)} does not start with "/"`, "Write the path from the root of the site, such as \"/blog/:slug/\".");
1433
1583
  valid = false;
1434
1584
  }
1435
1585
  const slugs = [...route.matchAll(SLUG_PLACEHOLDER)].length;
1436
1586
  if (slugs !== 1) {
1437
- 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/\".");
1587
+ problems.add(path, slugs === 0 ? `${quote$1(route)} has no :slug` : `${quote$1(route)} has :slug ${slugs} times`, "Use :slug exactly once, where the slug of the entry goes, such as \"/blog/:slug/\".");
1438
1588
  valid = false;
1439
1589
  }
1440
1590
  return valid;
@@ -1447,11 +1597,11 @@ function claimRoute(route, path, site, problems) {
1447
1597
  site.routes.set(pattern, path);
1448
1598
  return;
1449
1599
  }
1450
- 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.");
1600
+ problems.add(path, `${quote$1(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.");
1451
1601
  }
1452
1602
  /** The route of each locale, in the order of the site's locales. */
1453
1603
  function readRoutes(path, raw, collection, site, problems) {
1454
- if (!isRecord$2(raw)) {
1604
+ if (!isRecord$4(raw)) {
1455
1605
  problems.add(path, wrongType(raw, "an object with one path per locale"), ROUTES_FIX);
1456
1606
  return {};
1457
1607
  }
@@ -1459,12 +1609,12 @@ function readRoutes(path, raw, collection, site, problems) {
1459
1609
  const routePath = keyPath(path, locale);
1460
1610
  const route = raw[locale];
1461
1611
  const known = !site.localesOk || site.locales.includes(locale);
1462
- 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.`);
1612
+ if (!known) problems.add(routePath, `is a route for ${quote$1(locale)}, which is not a locale of the site (${site.locales.join(", ")})`, `Remove it, or add ${quote$1(locale)} to the locales of the site.`);
1463
1613
  if (typeof route !== "string") problems.add(routePath, wrongType(route, "a path such as \"/blog/:slug/\""), ROUTES_FIX);
1464
1614
  else if (isRoutePattern(routePath, route, problems) && known) claimRoute(route, routePath, site, problems);
1465
1615
  }
1466
1616
  if (site.localesOk) {
1467
- 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/`)}.`);
1617
+ for (const locale of site.locales) if (!Object.hasOwn(raw, locale)) problems.add(path, `has no route for the locale ${quote$1(locale)}`, `Add ${keyPath("routes", locale)}, a path such as ${quote$1(`/${locale}/${collection}/:slug/`)}.`);
1468
1618
  }
1469
1619
  const routes = {};
1470
1620
  for (const locale of site.locales) {
@@ -1476,12 +1626,12 @@ function readRoutes(path, raw, collection, site, problems) {
1476
1626
  const GROUP_BY = {
1477
1627
  mustBe: "a string",
1478
1628
  accepts: isStringSchema,
1479
- fix: (field) => `Declare ${quote(field)} as z.string() in the schema, or name another field.`
1629
+ fix: (field) => `Declare ${quote$1(field)} as z.string() in the schema, or name another field.`
1480
1630
  };
1481
1631
  const TAGS = {
1482
1632
  mustBe: "an array of strings",
1483
1633
  accepts: isStringArraySchema,
1484
- fix: (field) => `Declare ${quote(field)} as z.array(z.string()) in the schema, or name another field.`
1634
+ fix: (field) => `Declare ${quote$1(field)} as z.array(z.string()) in the schema, or name another field.`
1485
1635
  };
1486
1636
  /**
1487
1637
  * Listings sort by this field as text, which is chronological only for ISO 8601 dates. A string
@@ -1490,7 +1640,7 @@ const TAGS = {
1490
1640
  const SORT_BY = {
1491
1641
  mustBe: "a string with the \"date\" or \"date-time\" format",
1492
1642
  accepts: isDateSchema,
1493
- fix: (field) => `Declare ${quote(field)} as z.iso.date() or z.iso.datetime() in the schema, or name another field.`
1643
+ fix: (field) => `Declare ${quote$1(field)} as z.iso.date() or z.iso.datetime() in the schema, or name another field.`
1494
1644
  };
1495
1645
  /** The name of a field of the frontmatter, checked against the schema when there is one. */
1496
1646
  function readField(path, raw, schema, kind, problems) {
@@ -1499,16 +1649,16 @@ function readField(path, raw, schema, kind, problems) {
1499
1649
  return null;
1500
1650
  }
1501
1651
  if (schema === null) return raw;
1502
- const properties = isRecord$2(schema.object["properties"]) ? schema.object["properties"] : {};
1652
+ const properties = isRecord$4(schema.object["properties"]) ? schema.object["properties"] : {};
1503
1653
  if (!Object.hasOwn(properties, raw)) {
1504
1654
  const names = Object.keys(properties);
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.`);
1506
- } 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));
1655
+ problems.add(path, `${quote$1(raw)} is not a property of the schema${names.length === 0 ? ", which declares none" : ` (its properties are ${names.map(quote$1).join(", ")})`}`, `Name a field that the schema declares, or add ${quote$1(raw)} to the schema.`);
1656
+ } else if (!kind.accepts(schema.document, properties[raw])) problems.add(path, `${quote$1(raw)} is declared as ${describeShape(schema.document, properties[raw])}, but it must be ${kind.mustBe}`, kind.fix(raw));
1507
1657
  return raw;
1508
1658
  }
1509
1659
  function readTranslations(path, raw, schema, problems) {
1510
1660
  if (raw === void 0) return null;
1511
- if (!isRecord$2(raw)) {
1661
+ if (!isRecord$4(raw)) {
1512
1662
  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.");
1513
1663
  return null;
1514
1664
  }
@@ -1516,8 +1666,8 @@ function readTranslations(path, raw, schema, problems) {
1516
1666
  }
1517
1667
  const COMPONENT_FIX = "Declare it with defineComponent({ props: z.strictObject({ … }) }).";
1518
1668
  function readComponent(path, name, raw, problems) {
1519
- if (!NAME$1.test(name)) problems.add(path, `${quote(name)} is not a valid component name`, nameFix("callout"));
1520
- if (!isRecord$2(raw)) {
1669
+ if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid component name`, nameFix("callout"));
1670
+ if (!isRecord$4(raw)) {
1521
1671
  problems.add(path, wrongType(raw, "a component"), COMPONENT_FIX);
1522
1672
  return null;
1523
1673
  }
@@ -1535,7 +1685,7 @@ function readComponent(path, name, raw, problems) {
1535
1685
  function readComponents(path, raw, problems) {
1536
1686
  const components = /* @__PURE__ */ new Map();
1537
1687
  if (raw === void 0) return components;
1538
- if (!isRecord$2(raw)) {
1688
+ if (!isRecord$4(raw)) {
1539
1689
  problems.add(path, wrongType(raw, "an object that maps each component name to its definition"), COMPONENT_FIX);
1540
1690
  return components;
1541
1691
  }
@@ -1567,7 +1717,7 @@ function checkPhases(path, raw, problems) {
1567
1717
  for (let index = 0; index < phases.length; index += 1) {
1568
1718
  const phase = phases[index];
1569
1719
  if (!isPhase(phase)) problems.add(itemPath(path, index), wrongType(phase, "\"write\" or \"publish\""), PHASES_FIX);
1570
- else if (seen.has(phase)) problems.add(itemPath(path, index), `repeats ${quote(phase)}`, PHASES_FIX);
1720
+ else if (seen.has(phase)) problems.add(itemPath(path, index), `repeats ${quote$1(phase)}`, PHASES_FIX);
1571
1721
  seen.add(phase);
1572
1722
  }
1573
1723
  }
@@ -1576,7 +1726,7 @@ function checkPhases(path, raw, problems) {
1576
1726
  * what makes it a `Contract`. Whether it may have its id is a question for `checkIdentity`.
1577
1727
  */
1578
1728
  function isContract(item, path, problems) {
1579
- if (!isRecord$2(item)) {
1729
+ if (!isRecord$4(item)) {
1580
1730
  problems.add(path, wrongType(item, "a contract"), "Write it as { id, severity, description, check }.");
1581
1731
  return false;
1582
1732
  }
@@ -1599,7 +1749,7 @@ function isContract(item, path, problems) {
1599
1749
  */
1600
1750
  function checkIdentity(id, own, path, ids, problems) {
1601
1751
  if (RESERVED.has(id) && !own) {
1602
- 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().`);
1752
+ problems.add(keyPath(path, "id"), `${quote$1(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().`);
1603
1753
  return;
1604
1754
  }
1605
1755
  const first = ids.get(id);
@@ -1607,7 +1757,7 @@ function checkIdentity(id, own, path, ids, problems) {
1607
1757
  ids.set(id, path);
1608
1758
  return;
1609
1759
  }
1610
- 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.");
1760
+ problems.add(keyPath(path, "id"), `${quote$1(id)} is already the id of the contract at ${first}`, "Give every contract of the collection its own id.");
1611
1761
  }
1612
1762
  function readContracts(path, raw, problems) {
1613
1763
  const contracts = [];
@@ -1624,7 +1774,7 @@ function readContracts(path, raw, problems) {
1624
1774
  if (isContract(item, itemAt, problems)) {
1625
1775
  checkIdentity(item.id, isBuiltInContract(item), itemAt, ids, problems);
1626
1776
  contracts.push(item);
1627
- } else if (isRecord$2(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
1777
+ } else if (isRecord$4(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
1628
1778
  }
1629
1779
  return contracts;
1630
1780
  }
@@ -1639,8 +1789,8 @@ function readMaxMarkdownBytes(path, raw, problems) {
1639
1789
  * problem already: `resolveConfig` throws when there is any, so nothing else keeps track of them.
1640
1790
  */
1641
1791
  function readCollection(path, name, raw, site, problems) {
1642
- if (!NAME$1.test(name)) problems.add(path, `${quote(name)} is not a valid collection name`, nameFix("blog"));
1643
- if (!isRecord$2(raw)) {
1792
+ if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid collection name`, nameFix("blog"));
1793
+ if (!isRecord$4(raw)) {
1644
1794
  problems.add(path, wrongType(raw, "a collection"), "Declare it with defineCollection({ schema, routes, … }).");
1645
1795
  return null;
1646
1796
  }
@@ -1668,105 +1818,6 @@ function readCollection(path, name, raw, site, problems) {
1668
1818
  };
1669
1819
  }
1670
1820
  //#endregion
1671
- //#region src/config/events.ts
1672
- /** The name of a Worker secret or variable: what `wrangler secret put` takes. */
1673
- const BINDING_NAME = /^[A-Z][A-Z0-9_]*$/;
1674
- /** A subscriber's name: lowercase words joined by hyphens. */
1675
- const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1676
- /** The public URL of a site: an absolute http or https URL with no path, query or fragment. */
1677
- function readSiteUrl(path, raw, problems) {
1678
- if (raw === void 0) return null;
1679
- const fix = "Write the public origin of the site, such as \"https://example.com\".";
1680
- if (typeof raw !== "string" || !URL.canParse(raw)) {
1681
- problems.add(path, wrongType(raw, "an absolute URL"), fix);
1682
- return null;
1683
- }
1684
- const url = new URL(raw);
1685
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
1686
- problems.add(path, `${quote(raw)} is not an origin`, fix);
1687
- return null;
1688
- }
1689
- return url.origin;
1690
- }
1691
- /** The origins of a site's pages that may read its published content from the browser. */
1692
- function readCors(path, raw, problems) {
1693
- if (raw === void 0) return { origins: [] };
1694
- if (!isRecord$2(raw) || !Array.isArray(raw["origins"])) {
1695
- problems.add(path, wrongType(raw, "an object with origins"), "Declare it as { origins: [\"https://example.com\"] }, or remove it.");
1696
- return { origins: [] };
1697
- }
1698
- const origins = [];
1699
- for (const [index, origin] of raw["origins"].entries()) {
1700
- const read = readSiteUrl(itemPath(keyPath(path, "origins"), index), origin, problems);
1701
- if (read !== null && !origins.includes(read)) origins.push(read);
1702
- }
1703
- return { origins };
1704
- }
1705
- function readBindingName(path, raw, problems) {
1706
- if (typeof raw === "string" && BINDING_NAME.test(raw)) return raw;
1707
- problems.add(path, wrongType(raw, "the name of a Worker secret"), "Name the secret that holds the value, such as \"WEBHOOK_SECRET\", and set it with wrangler secret put.");
1708
- return null;
1709
- }
1710
- function readWebhook(path, raw, problems) {
1711
- if (!isRecord$2(raw)) {
1712
- problems.add(path, wrongType(raw, "a webhook"), "Declare it as { name: \"newsletter\", url: \"https://…\", secret: \"WEBHOOK_SECRET\" }.");
1713
- return null;
1714
- }
1715
- const name = raw["name"];
1716
- if (typeof name !== "string" || !NAME.test(name)) problems.add(keyPath(path, "name"), wrongType(name, "a name"), "Name it with lowercase words joined by hyphens, such as \"newsletter\".");
1717
- const url = raw["url"];
1718
- const urlOk = typeof url === "string" && URL.canParse(url) && new URL(url).protocol === "https:";
1719
- if (!urlOk) problems.add(keyPath(path, "url"), wrongType(url, "an https URL"), "Give the https URL that receives the events.");
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;
1723
- return {
1724
- name,
1725
- url: String(url),
1726
- secret
1727
- };
1728
- }
1729
- /** Who hears about publications (ADR-025): webhooks, and IndexNow when the site has a URL. */
1730
- function readEvents(path, raw, siteUrl, problems) {
1731
- const events = {
1732
- webhooks: [],
1733
- indexNow: null
1734
- };
1735
- if (raw === void 0) return events;
1736
- if (!isRecord$2(raw)) {
1737
- problems.add(path, wrongType(raw, "an object"), "Declare { webhooks: [ … ], indexNow: { … } }.");
1738
- return events;
1739
- }
1740
- const webhooks = raw["webhooks"];
1741
- if (webhooks !== void 0) {
1742
- if (!Array.isArray(webhooks)) problems.add(keyPath(path, "webhooks"), wrongType(webhooks, "a list"), "List the webhooks.");
1743
- else {
1744
- const seen = /* @__PURE__ */ new Set();
1745
- webhooks.forEach((item, index) => {
1746
- const webhook = readWebhook(itemPath(keyPath(path, "webhooks"), index), item, problems);
1747
- if (webhook === null) return;
1748
- if (seen.has(webhook.name)) {
1749
- problems.add(itemPath(keyPath(path, "webhooks"), index), `repeats the name ${quote(webhook.name)}`, "Give each webhook a name of its own.");
1750
- return;
1751
- }
1752
- seen.add(webhook.name);
1753
- events.webhooks.push(webhook);
1754
- });
1755
- }
1756
- }
1757
- const indexNow = raw["indexNow"];
1758
- if (indexNow !== void 0) {
1759
- const indexPath = keyPath(path, "indexNow");
1760
- if (!isRecord$2(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"INDEXNOW_KEY\" }.");
1761
- else {
1762
- const key = readBindingName(keyPath(indexPath, "key"), indexNow["key"], problems);
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.");
1764
- else if (key !== null) events.indexNow = { key };
1765
- }
1766
- }
1767
- return events;
1768
- }
1769
- //#endregion
1770
1821
  //#region src/config/site-key.ts
1771
1822
  /**
1772
1823
  * The key of a site, which is the id of its row in the database: lowercase letters and digits
@@ -1816,14 +1867,14 @@ function readLocales(path, raw, problems) {
1816
1867
  }
1817
1868
  locales.push(tag);
1818
1869
  if (!LOCALE_TAG.test(tag)) {
1819
- problems.add(tagPath, `${quote(tag)} is not a language tag`, LOCALE_FIX);
1870
+ problems.add(tagPath, `${quote$1(tag)} is not a language tag`, LOCALE_FIX);
1820
1871
  ok = false;
1821
1872
  continue;
1822
1873
  }
1823
1874
  const first = firstAt.get(tag);
1824
1875
  if (first === void 0) firstAt.set(tag, index);
1825
1876
  else {
1826
- problems.add(tagPath, `${quote(tag)} is already listed at ${itemPath(path, first)}`, "List each locale once.");
1877
+ problems.add(tagPath, `${quote$1(tag)} is already listed at ${itemPath(path, first)}`, "List each locale once.");
1827
1878
  ok = false;
1828
1879
  }
1829
1880
  }
@@ -1837,13 +1888,13 @@ function readDefaultLocale(path, raw, locales, problems) {
1837
1888
  problems.add(path, wrongType(raw, "one of the locales"), "Set it to the main language of the site, one of its locales.");
1838
1889
  return "";
1839
1890
  }
1840
- 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.`);
1891
+ if (locales.length > 0 && !locales.includes(raw)) problems.add(path, `is ${quote$1(raw)}, which is not one of the locales (${locales.map(quote$1).join(", ")})`, `Set it to one of ${locales.map(quote$1).join(", ")}, or add ${quote$1(raw)} to locales.`);
1841
1892
  return raw;
1842
1893
  }
1843
1894
  const COLLECTIONS_FIX = "Declare at least one: collections: { blog: defineCollection({ schema, routes }) }.";
1844
1895
  function readCollections(path, raw, site, problems) {
1845
1896
  const collections = /* @__PURE__ */ new Map();
1846
- if (!isRecord$2(raw)) {
1897
+ if (!isRecord$4(raw)) {
1847
1898
  problems.add(path, wrongType(raw, "an object that maps each collection name to its collection"), COLLECTIONS_FIX);
1848
1899
  return collections;
1849
1900
  }
@@ -1858,8 +1909,8 @@ function readCollections(path, raw, site, problems) {
1858
1909
  /** Checks one site; `null` when it is no object, which is a problem already. */
1859
1910
  function readSite(key, raw, problems) {
1860
1911
  const path = keyPath("sites", key);
1861
- if (!isSiteKey(key)) problems.add(path, `${quote(key)} is not a valid site key`, SITE_KEY_FIX);
1862
- if (!isRecord$2(raw)) {
1912
+ if (!isSiteKey(key)) problems.add(path, `${quote$1(key)} is not a valid site key`, SITE_KEY_FIX);
1913
+ if (!isRecord$4(raw)) {
1863
1914
  problems.add(path, wrongType(raw, "a site"), "Declare it as { locales: [\"en\"], defaultLocale: \"en\", collections: { … } }.");
1864
1915
  return null;
1865
1916
  }
@@ -1881,12 +1932,13 @@ function readSite(key, raw, problems) {
1881
1932
  collections,
1882
1933
  url,
1883
1934
  cors: readCors(keyPath(path, "cors"), raw["cors"], problems),
1884
- events: readEvents(keyPath(path, "events"), raw["events"], url, problems)
1935
+ events: readEvents(keyPath(path, "events"), raw["events"], url, problems),
1936
+ audit: readAudit(keyPath(path, "audit"), raw["audit"], problems)
1885
1937
  };
1886
1938
  }
1887
1939
  function readSites(raw, problems) {
1888
1940
  const sites = /* @__PURE__ */ new Map();
1889
- if (!isRecord$2(raw)) {
1941
+ if (!isRecord$4(raw)) {
1890
1942
  problems.add("sites", wrongType(raw, "an object that maps the site key to the site"), SITE_FIX);
1891
1943
  return sites;
1892
1944
  }
@@ -1901,7 +1953,7 @@ function readSites(raw, problems) {
1901
1953
  /** `auth` of the config: absent, or an object whose `dynamicClientRegistration` is a boolean. */
1902
1954
  function readAuth(raw, problems) {
1903
1955
  if (raw === void 0) return { dynamicClientRegistration: false };
1904
- if (!isRecord$2(raw)) {
1956
+ if (!isRecord$4(raw)) {
1905
1957
  problems.add("auth", wrongType(raw, "an object"), "Write auth: { dynamicClientRegistration: true }, or leave auth out.");
1906
1958
  return { dynamicClientRegistration: false };
1907
1959
  }
@@ -1943,7 +1995,7 @@ function resolveConfig(config) {
1943
1995
  const problems = new Problems();
1944
1996
  let sites = /* @__PURE__ */ new Map();
1945
1997
  let auth = { dynamicClientRegistration: false };
1946
- if (isRecord$2(config)) {
1998
+ if (isRecord$4(config)) {
1947
1999
  sites = readSites(config["sites"], problems);
1948
2000
  auth = readAuth(config["auth"], problems);
1949
2001
  } else problems.add("config", wrongType(config, "an object"), "Export the result of defineConfig({ sites: { … } }) from hollware.config.ts.");
@@ -1954,6 +2006,69 @@ function resolveConfig(config) {
1954
2006
  };
1955
2007
  }
1956
2008
  //#endregion
2009
+ //#region src/audit/index.ts
2010
+ /**
2011
+ * The actions of the activity log (ADR-037). A line names one of these, and nothing else: a new
2012
+ * operation or route that changes something is not logged until it is named here.
2013
+ */
2014
+ /** Every operation that is not a read: each call of one is a line, whatever its outcome. */
2015
+ const OPERATION_AUDIT_ACTIONS = [
2016
+ "create_draft",
2017
+ "update_draft",
2018
+ "publish",
2019
+ "unpublish",
2020
+ "delete_entry",
2021
+ "restore_entry",
2022
+ "approve_review",
2023
+ "request_review",
2024
+ "comment_review",
2025
+ "request_changes",
2026
+ "update_site",
2027
+ "set_collection",
2028
+ "create_site",
2029
+ "delete_site",
2030
+ "restore_site",
2031
+ "make_webhook_secret"
2032
+ ];
2033
+ /** What people do outside the operations: their tokens, members, passkeys, sessions and apps. */
2034
+ const IDENTITY_AUDIT_ACTIONS = [
2035
+ "token.create",
2036
+ "preview.create",
2037
+ "token.revoke",
2038
+ "member.role",
2039
+ "member.remove",
2040
+ "invitation.create",
2041
+ "invitation.accept",
2042
+ "session.sign_in",
2043
+ "session.sign_out",
2044
+ "session.end_all",
2045
+ "passkey.remove",
2046
+ "app.allow",
2047
+ "app.revoke",
2048
+ "app.refresh_reused",
2049
+ "schedule.publish"
2050
+ ];
2051
+ const AUDIT_ACTIONS = [...OPERATION_AUDIT_ACTIONS, ...IDENTITY_AUDIT_ACTIONS];
2052
+ /** `denied`: a scope or a role was missing; `failed`: any other error the caller got. */
2053
+ const AUDIT_OUTCOMES = [
2054
+ "ok",
2055
+ "denied",
2056
+ "failed"
2057
+ ];
2058
+ /**
2059
+ * Who acted: a person in the admin, a personal token, an app a person allowed through OAuth, or
2060
+ * the deployment itself (its cron).
2061
+ */
2062
+ const AUDIT_ACTOR_KINDS = [
2063
+ "user",
2064
+ "token",
2065
+ "oauth_client",
2066
+ "system"
2067
+ ];
2068
+ function isAuditAction(value) {
2069
+ return AUDIT_ACTIONS.includes(value);
2070
+ }
2071
+ //#endregion
1957
2072
  //#region src/structure/definition.ts
1958
2073
  /**
1959
2074
  * The definition of a site as data (ADR-033): what D1 stores for it and what the operations that
@@ -2003,6 +2118,13 @@ const siteDefinitionData = z.strictObject({
2003
2118
  webhooks: [],
2004
2119
  indexNow: null
2005
2120
  }),
2121
+ audit: z.strictObject({
2122
+ ipAddresses: z.enum(["off", "hashed"]).default("off").describe("Whether the activity log keeps the address of each request, as a hash keyed by the site, or nothing of it."),
2123
+ retentionDays: z.number().int().min(30).max(AUDIT_RETENTION_MAX_DAYS).nullable().default(null).describe("The days each line of the activity log is kept; null keeps it as long as the site.")
2124
+ }).default({
2125
+ ipAddresses: "off",
2126
+ retentionDays: null
2127
+ }).describe("What the activity log of the site keeps (ADR-037). Only an owner should change it."),
2006
2128
  collections: z.record(z.string(), collectionDefinitionData)
2007
2129
  });
2008
2130
  //#endregion
@@ -2025,19 +2147,19 @@ const SUMMARIES = /* @__PURE__ */ new Set([
2025
2147
  function segmentsOf$1(location) {
2026
2148
  return location.replace(/^#\/?/, "").split("/").filter((segment) => segment !== "").map((segment) => decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~"));
2027
2149
  }
2028
- function isRecord$1(value) {
2150
+ function isRecord$2(value) {
2029
2151
  return typeof value === "object" && value !== null && !Array.isArray(value);
2030
2152
  }
2031
2153
  /** The schema of the object at `segments`, to tell a declared key from an unknown one. */
2032
2154
  function declaredKeys(schema, segments) {
2033
2155
  let node = schema;
2034
2156
  for (const segment of segments) {
2035
- if (!isRecord$1(node)) return /* @__PURE__ */ new Set();
2157
+ if (!isRecord$2(node)) return /* @__PURE__ */ new Set();
2036
2158
  const properties = node["properties"];
2037
- node = isRecord$1(properties) && Object.hasOwn(properties, segment) ? properties[segment] : node["items"];
2159
+ node = isRecord$2(properties) && Object.hasOwn(properties, segment) ? properties[segment] : node["items"];
2038
2160
  }
2039
- const properties = isRecord$1(node) ? node["properties"] : void 0;
2040
- return new Set(isRecord$1(properties) ? Object.keys(properties) : []);
2161
+ const properties = isRecord$2(node) ? node["properties"] : void 0;
2162
+ return new Set(isRecord$2(properties) ? Object.keys(properties) : []);
2041
2163
  }
2042
2164
  /** The issues of a validation: one for each part that failed, at the path of the value. */
2043
2165
  function issuesOf(schema, errors) {
@@ -2149,7 +2271,8 @@ function compileSite(path, site, problems) {
2149
2271
  ...webhook.secret === void 0 ? {} : { secret: webhook.secret }
2150
2272
  })),
2151
2273
  ...site.events.indexNow === null ? {} : { indexNow: site.events.indexNow }
2152
- }
2274
+ },
2275
+ audit: site.audit
2153
2276
  };
2154
2277
  }
2155
2278
  /** The definitions of the sites, read from D1 as JSON, checked and resolved. */
@@ -2223,6 +2346,10 @@ function definitionsFromConfig(config) {
2223
2346
  webhooks: (site.events?.webhooks ?? []).map((webhook) => ({ ...webhook })),
2224
2347
  indexNow: site.events?.indexNow ?? null
2225
2348
  },
2349
+ ...site.audit === void 0 ? {} : { audit: {
2350
+ ipAddresses: site.audit.ipAddresses ?? "off",
2351
+ retentionDays: site.audit.retentionDays ?? null
2352
+ } },
2226
2353
  collections: Object.fromEntries(Object.entries(site.collections).map(([name, collection]) => [name, collectionOf(keyPath(keyPath(path, "collections"), name), collection, problems)]))
2227
2354
  };
2228
2355
  }
@@ -2370,6 +2497,10 @@ function sitePreset(preset, locales, defaultLocale) {
2370
2497
  webhooks: [],
2371
2498
  indexNow: null
2372
2499
  },
2500
+ audit: {
2501
+ ipAddresses: "off",
2502
+ retentionDays: null
2503
+ },
2373
2504
  collections
2374
2505
  };
2375
2506
  }
@@ -2384,6 +2515,7 @@ const ERROR_CODES = [
2384
2515
  "invalid_input",
2385
2516
  "unknown_collection",
2386
2517
  "unknown_locale",
2518
+ "invalid_preview",
2387
2519
  "insufficient_scope",
2388
2520
  "output_too_large",
2389
2521
  "timeout",
@@ -2413,6 +2545,7 @@ var HollwareError = class extends Error {
2413
2545
  const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2414
2546
  /** The kinds of ids and their prefixes. A new kind adds its prefix here. */
2415
2547
  const ID_PREFIXES = {
2548
+ auditLine: "aud",
2416
2549
  challenge: "chl",
2417
2550
  delivery: "dlv",
2418
2551
  embedding: "emb",
@@ -3515,7 +3648,7 @@ function utf8ByteLength(text) {
3515
3648
  }
3516
3649
  return bytes;
3517
3650
  }
3518
- function isRecord(value) {
3651
+ function isRecord$1(value) {
3519
3652
  return typeof value === "object" && value !== null && !Array.isArray(value);
3520
3653
  }
3521
3654
  function describe(value) {
@@ -3548,8 +3681,8 @@ function linesBeforeBody(source, body) {
3548
3681
  function yamlViolation(error, source) {
3549
3682
  const reason = Reflect.get(error, "reason");
3550
3683
  const mark = Reflect.get(error, "mark");
3551
- const markLine = isRecord(mark) ? mark.line : void 0;
3552
- const markColumn = isRecord(mark) ? mark.column : void 0;
3684
+ const markLine = isRecord$1(mark) ? mark.line : void 0;
3685
+ const markColumn = isRecord$1(mark) ? mark.column : void 0;
3553
3686
  const firstLine = source.startsWith("---\r\n") ? 1 : 2;
3554
3687
  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);
3555
3688
  }
@@ -3575,7 +3708,7 @@ async function run(markdown, options) {
3575
3708
  };
3576
3709
  }
3577
3710
  const frontmatter = split.data;
3578
- if (!isRecord(frontmatter)) return {
3711
+ if (!isRecord$1(frontmatter)) return {
3579
3712
  ok: false,
3580
3713
  violations: [frontmatterNotMapping(describe(frontmatter))]
3581
3714
  };
@@ -3682,7 +3815,20 @@ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
3682
3815
  function isProps(value) {
3683
3816
  return typeof value === "object" && value !== null && !Array.isArray(value);
3684
3817
  }
3685
- function sanitizeProps(raw) {
3818
+ /** The value a path such as `frontmatter.url` names in the data, or `undefined`. */
3819
+ function resolvePath(data, path) {
3820
+ let value = data;
3821
+ for (const part of path.split(".")) {
3822
+ if (typeof value !== "object" || value === null || !Object.hasOwn(value, part)) return void 0;
3823
+ value = Reflect.get(value, part);
3824
+ }
3825
+ return value;
3826
+ }
3827
+ /** The name of an attribute without the `:` or `v-bind:` of a binding. */
3828
+ function unbound(key) {
3829
+ return key.startsWith("v-bind:") ? key.slice(7) : key.replace(/^:/, "");
3830
+ }
3831
+ function sanitizeProps(raw, data) {
3686
3832
  const props = {};
3687
3833
  if (!isProps(raw)) return props;
3688
3834
  for (const key of Object.keys(raw)) {
@@ -3691,8 +3837,15 @@ function sanitizeProps(raw) {
3691
3837
  if (isForbiddenAttribute(name)) continue;
3692
3838
  const value = raw[key];
3693
3839
  if (URL_ATTRIBUTES.has(name)) {
3840
+ const kind = name === "src" ? "image" : "link";
3694
3841
  const url = urlValue(key, value);
3695
- if (url === void 0 || !isSafeUrl(url, name === "src" ? "image" : "link")) continue;
3842
+ if (url === void 0) continue;
3843
+ if (isBinding(key) && url === value) {
3844
+ const bound = resolvePath(data, url);
3845
+ if (typeof bound === "string" && isSafeUrl(bound, kind)) setOwn(props, unbound(key), bound.replaceAll("&", "&amp;"));
3846
+ continue;
3847
+ }
3848
+ if (!isSafeUrl(url, kind)) continue;
3696
3849
  }
3697
3850
  setOwn(props, key, typeof value === "string" && !isBinding(key) ? value.replaceAll("&", "&amp;") : value);
3698
3851
  }
@@ -3704,19 +3857,19 @@ function marker(name) {
3704
3857
  name: TAG_NAME.test(name) ? shorten(name) : "?"
3705
3858
  }];
3706
3859
  }
3707
- function sanitizeElement(node, depth) {
3860
+ function sanitizeElement(node, depth, data) {
3708
3861
  const tag = node[0];
3709
3862
  if (typeof tag !== "string") return void 0;
3710
3863
  if (!NATIVE_TAGS.has(tag) && tag !== "hollware-marker") {
3711
3864
  if (!TAG_NAME.test(tag) || DANGEROUS_TAGS.has(tag.toLowerCase())) return marker(tag);
3712
3865
  }
3713
- const element = [tag, sanitizeProps(node[1])];
3866
+ const element = [tag, sanitizeProps(node[1], data)];
3714
3867
  if (depth >= 100) return element;
3715
3868
  for (let at = 2; at < node.length; at++) {
3716
3869
  const child = node[at];
3717
3870
  if (typeof child === "string") element.push(child);
3718
3871
  else if (Array.isArray(child)) {
3719
- const clean = sanitizeElement(child, depth + 1);
3872
+ const clean = sanitizeElement(child, depth + 1, data);
3720
3873
  if (clean !== void 0) element.push(clean);
3721
3874
  }
3722
3875
  }
@@ -3725,10 +3878,14 @@ function sanitizeElement(node, depth) {
3725
3878
  /** Copies a document through the rules of this file. */
3726
3879
  function sanitizeDocument(document) {
3727
3880
  const nodes = [];
3881
+ const data = {
3882
+ frontmatter: document.frontmatter,
3883
+ meta: document.meta
3884
+ };
3728
3885
  if (Array.isArray(document.nodes)) {
3729
3886
  for (const node of document.nodes) if (typeof node === "string") nodes.push(node);
3730
3887
  else if (Array.isArray(node)) {
3731
- const clean = sanitizeElement(node, 1);
3888
+ const clean = sanitizeElement(node, 1, data);
3732
3889
  if (clean !== void 0) nodes.push(clean);
3733
3890
  }
3734
3891
  }
@@ -3828,6 +3985,7 @@ const entrySummary = z.object({
3828
3985
  hasUnpublishedChanges: z.boolean().describe("Whether the latest revision differs from the published one."),
3829
3986
  publishedAt: z.string().nullable().describe("When the entry was first published, or null."),
3830
3987
  scheduledAt: z.string().nullable().describe("When a scheduled entry will be published, or null for one that is not scheduled."),
3988
+ deletedAt: z.string().nullable().describe("When the entry went to the trash, or null for one that is not in the trash."),
3831
3989
  updatedAt: z.string()
3832
3990
  });
3833
3991
  const publication = z.object({
@@ -3958,12 +4116,13 @@ const describeSite = defineOperation({
3958
4116
  const listEntries = defineOperation({
3959
4117
  name: "list_entries",
3960
4118
  title: "List entries",
3961
- 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.",
4119
+ 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. Entries in the trash are left out, unless trashed asks for them.",
3962
4120
  input: z.strictObject({
3963
4121
  collection: collectionName,
3964
4122
  locale: locale.optional(),
3965
4123
  tag: z.string().min(1).optional(),
3966
4124
  status: entryStatus.optional(),
4125
+ trashed: z.boolean().optional().describe("true to list the entries in the trash, last trashed first, instead of the others."),
3967
4126
  limit: z.number().int().min(1).max(100).default(20),
3968
4127
  cursor: z.string().min(1).optional()
3969
4128
  }),
@@ -4046,7 +4205,7 @@ const getTranslations = defineOperation({
4046
4205
  const createDraft = defineOperation({
4047
4206
  name: "create_draft",
4048
4207
  title: "Create a draft",
4049
- 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.",
4208
+ 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. An entry in the trash keeps its slug and translation: the answer is in_trash with that entry, and restore_entry brings it back.",
4050
4209
  input: z.strictObject({
4051
4210
  collection: collectionName,
4052
4211
  locale,
@@ -4054,15 +4213,20 @@ const createDraft = defineOperation({
4054
4213
  markdown
4055
4214
  }),
4056
4215
  output: z.object({
4057
- outcome: z.enum(["saved", "refused"]),
4058
- entry: entrySummary.optional(),
4216
+ outcome: z.enum([
4217
+ "saved",
4218
+ "refused",
4219
+ "in_trash"
4220
+ ]),
4221
+ entry: entrySummary.optional().describe("With saved, the new draft; with in_trash, the entry in the trash that holds it."),
4059
4222
  verdict: verdict.optional()
4060
4223
  }),
4061
4224
  scopes: ["content:write"],
4062
4225
  effect: "write",
4063
4226
  outcomes: {
4064
4227
  saved: { isError: false },
4065
- refused: { isError: true }
4228
+ refused: { isError: true },
4229
+ in_trash: { isError: true }
4066
4230
  }
4067
4231
  });
4068
4232
  const updateDraft = defineOperation({
@@ -4172,6 +4336,54 @@ const unpublish = defineOperation({
4172
4336
  not_found: { isError: true }
4173
4337
  }
4174
4338
  });
4339
+ const deleteEntry = defineOperation({
4340
+ name: "delete_entry",
4341
+ title: "Move an entry to the trash",
4342
+ description: "Moves a draft to the trash, if it is still at the version you read: every operation treats it as gone, its pending review is cancelled, and nothing is deleted. It keeps its slug and translation, and restore_entry brings it back as the draft it was. A published or scheduled entry answers published: unpublish it first.",
4343
+ input: z.strictObject({
4344
+ id: entryId,
4345
+ version
4346
+ }),
4347
+ output: z.object({
4348
+ outcome: z.enum([
4349
+ "trashed",
4350
+ "published",
4351
+ "conflict",
4352
+ "not_found"
4353
+ ]),
4354
+ entry: entrySummary.optional(),
4355
+ conflict: conflict.optional()
4356
+ }),
4357
+ scopes: ["content:write"],
4358
+ effect: "destructive",
4359
+ outcomes: {
4360
+ trashed: { isError: false },
4361
+ published: { isError: true },
4362
+ conflict: { isError: true },
4363
+ not_found: { isError: true }
4364
+ }
4365
+ });
4366
+ const restoreEntry = defineOperation({
4367
+ name: "restore_entry",
4368
+ title: "Bring an entry back from the trash",
4369
+ description: "Brings an entry back from the trash as the draft it was, with its revisions, at the next version. An entry that is not in the trash answers not_trashed.",
4370
+ input: z.strictObject({ id: entryId }),
4371
+ output: z.object({
4372
+ outcome: z.enum([
4373
+ "restored",
4374
+ "not_trashed",
4375
+ "not_found"
4376
+ ]),
4377
+ entry: entrySummary.optional()
4378
+ }),
4379
+ scopes: ["content:write"],
4380
+ effect: "write",
4381
+ outcomes: {
4382
+ restored: { isError: false },
4383
+ not_trashed: { isError: true },
4384
+ not_found: { isError: true }
4385
+ }
4386
+ });
4175
4387
  const listReviews = defineOperation({
4176
4388
  name: "list_reviews",
4177
4389
  title: "List the reviews",
@@ -4487,6 +4699,7 @@ const OPERATIONS = [
4487
4699
  contentStats,
4488
4700
  createDraft,
4489
4701
  createSite,
4702
+ deleteEntry,
4490
4703
  deleteSite,
4491
4704
  describeSite,
4492
4705
  getEntry,
@@ -4518,6 +4731,7 @@ const OPERATIONS = [
4518
4731
  publish,
4519
4732
  requestChanges,
4520
4733
  requestReview,
4734
+ restoreEntry,
4521
4735
  restoreSite,
4522
4736
  search,
4523
4737
  setCollection,
@@ -4606,13 +4820,13 @@ const TIMESTAMP_HEADER = "hollware-timestamp";
4606
4820
  const EVENT_ID_HEADER = "hollware-event-id";
4607
4821
  /** How far a timestamp may be from the receiver's clock, by default: five minutes. */
4608
4822
  const TOLERANCE_SECONDS = 300;
4609
- const encoder = new TextEncoder();
4823
+ const encoder$1 = new TextEncoder();
4610
4824
  async function hmac(secret, text) {
4611
- const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
4825
+ const key = await crypto.subtle.importKey("raw", encoder$1.encode(secret), {
4612
4826
  name: "HMAC",
4613
4827
  hash: "SHA-256"
4614
4828
  }, false, ["sign"]);
4615
- return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
4829
+ return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder$1.encode(text)))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
4616
4830
  }
4617
4831
  /** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
4618
4832
  async function signWebhook(secret, timestamp, body) {
@@ -4625,9 +4839,20 @@ function sameText(a, b) {
4625
4839
  for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
4626
4840
  return difference === 0;
4627
4841
  }
4842
+ /** The id of the event a body holds, or `null` when it holds none. */
4843
+ function eventIdOf(body) {
4844
+ try {
4845
+ const value = JSON.parse(body);
4846
+ const id = typeof value === "object" && value !== null ? Reflect.get(value, "id") : null;
4847
+ return typeof id === "string" && id !== "" ? id : null;
4848
+ } catch {
4849
+ return null;
4850
+ }
4851
+ }
4628
4852
  /**
4629
4853
  * Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
4630
- * its timestamp is within the tolerance, so a captured request cannot be replayed later.
4854
+ * its timestamp is within the tolerance, so a captured request cannot be replayed later; with
4855
+ * `isNew`, also that its event was not received before, so it cannot be replayed sooner either.
4631
4856
  */
4632
4857
  async function verifyWebhook(check) {
4633
4858
  const signature = check.headers.get(SIGNATURE_HEADER);
@@ -4635,7 +4860,196 @@ async function verifyWebhook(check) {
4635
4860
  if (signature === null || !Number.isInteger(timestamp)) return false;
4636
4861
  const now = check.now ?? Math.floor(Date.now() / 1e3);
4637
4862
  if (Math.abs(now - timestamp) > (check.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
4638
- return sameText(signature, await signWebhook(check.secret, timestamp, check.body));
4863
+ if (!sameText(signature, await signWebhook(check.secret, timestamp, check.body))) return false;
4864
+ if (check.isNew === void 0) return true;
4865
+ const id = eventIdOf(check.body);
4866
+ return id !== null && await check.isNew(id);
4867
+ }
4868
+ //#endregion
4869
+ //#region src/preview.ts
4870
+ /**
4871
+ * The preview token (T-103 of docs/security/threat-model.md): a signed, stateless proof that a person of a site may read the
4872
+ * latest revision of one entry, for one hour. A site that reads published content only (the
4873
+ * binding of RPC, the read API) reads that entry's head instead when it presents one.
4874
+ *
4875
+ * hw_prv_<payload>.<signature>
4876
+ *
4877
+ * The payload is base64url of `{ "s": site, "e": entryId, "x": expiresAtSeconds }`, and the
4878
+ * signature the base64url HMAC-SHA256 of the payload text under a key that HKDF-SHA256 derives
4879
+ * from the Worker secret `HOLLWARE_SECRET`, bound to the site. Nothing is stored: a token cannot
4880
+ * be revoked, only wait out its hour, and rotating the secret ends every one.
4881
+ */
4882
+ const PREVIEW_TOKEN_PREFIX = "hw_prv_";
4883
+ /** The query parameter a site's page takes the token from. */
4884
+ const PREVIEW_QUERY_PARAM = "hollware-preview";
4885
+ /** How long a token lasts, in seconds. */
4886
+ const PREVIEW_TTL_SECONDS = 3600;
4887
+ /** The least characters of `HOLLWARE_SECRET` that Hollware signs anything with. */
4888
+ const PREVIEW_SECRET_MIN_LENGTH = 32;
4889
+ /** Whether a Worker's secret can sign previews: present and long enough. */
4890
+ function canSignPreviews(secret) {
4891
+ return secret !== void 0 && secret.length >= 32;
4892
+ }
4893
+ const encoder = new TextEncoder();
4894
+ function toBase64Url(bytes) {
4895
+ let binary = "";
4896
+ for (const byte of bytes) binary += String.fromCharCode(byte);
4897
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
4898
+ }
4899
+ function fromBase64Url(text) {
4900
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
4901
+ try {
4902
+ const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
4903
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
4904
+ } catch {
4905
+ return null;
4906
+ }
4907
+ }
4908
+ async function previewKey(secret, site) {
4909
+ const base = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
4910
+ return crypto.subtle.deriveKey({
4911
+ name: "HKDF",
4912
+ hash: "SHA-256",
4913
+ salt: /* @__PURE__ */ new Uint8Array(0),
4914
+ info: encoder.encode(`hollware preview\n${site}`)
4915
+ }, base, {
4916
+ name: "HMAC",
4917
+ hash: "SHA-256",
4918
+ length: 256
4919
+ }, false, ["sign", "verify"]);
4920
+ }
4921
+ /** A token for one entry of one site, valid for `PREVIEW_TTL_SECONDS` from `now`. */
4922
+ async function makePreviewToken(input) {
4923
+ if (!canSignPreviews(input.secret)) throw new Error(`HOLLWARE_SECRET must be at least 32 characters.`);
4924
+ const expires = Math.floor(input.now.getTime() / 1e3) + PREVIEW_TTL_SECONDS;
4925
+ const payload = toBase64Url(encoder.encode(JSON.stringify({
4926
+ s: input.site,
4927
+ e: input.entryId,
4928
+ x: expires
4929
+ })));
4930
+ const mac = await crypto.subtle.sign("HMAC", await previewKey(input.secret, input.site), encoder.encode(payload));
4931
+ return {
4932
+ token: `${PREVIEW_TOKEN_PREFIX}${payload}.${toBase64Url(new Uint8Array(mac))}`,
4933
+ expiresAt: (/* @__PURE__ */ new Date(expires * 1e3)).toISOString()
4934
+ };
4935
+ }
4936
+ function isRecord(value) {
4937
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4938
+ }
4939
+ /**
4940
+ * Checks a token for a site. The signature is verified (in constant time, by `crypto.subtle`)
4941
+ * before the payload is parsed, under the key of the site that is asked: a token made for another
4942
+ * site fails there. The entry the token names is the caller's to compare with the entry it read.
4943
+ */
4944
+ async function verifyPreviewToken(input) {
4945
+ if (!canSignPreviews(input.secret)) return {
4946
+ ok: false,
4947
+ reason: "no_secret"
4948
+ };
4949
+ const { token } = input;
4950
+ if (!token.startsWith("hw_prv_")) return {
4951
+ ok: false,
4952
+ reason: "malformed"
4953
+ };
4954
+ const parts = token.slice(7).split(".");
4955
+ const [payload, signature] = parts;
4956
+ if (parts.length !== 2 || payload === void 0 || signature === void 0) return {
4957
+ ok: false,
4958
+ reason: "malformed"
4959
+ };
4960
+ const mac = fromBase64Url(signature);
4961
+ const body = fromBase64Url(payload);
4962
+ if (mac === null || body === null) return {
4963
+ ok: false,
4964
+ reason: "malformed"
4965
+ };
4966
+ if (!await crypto.subtle.verify("HMAC", await previewKey(input.secret, input.site), mac, encoder.encode(payload))) return {
4967
+ ok: false,
4968
+ reason: "signature"
4969
+ };
4970
+ let claims;
4971
+ try {
4972
+ claims = JSON.parse(new TextDecoder().decode(body));
4973
+ } catch {
4974
+ return {
4975
+ ok: false,
4976
+ reason: "malformed"
4977
+ };
4978
+ }
4979
+ if (!isRecord(claims) || typeof claims["s"] !== "string" || typeof claims["e"] !== "string" || typeof claims["x"] !== "number") return {
4980
+ ok: false,
4981
+ reason: "malformed"
4982
+ };
4983
+ if (claims["s"] !== input.site) return {
4984
+ ok: false,
4985
+ reason: "site"
4986
+ };
4987
+ if (Math.floor(input.now.getTime() / 1e3) >= claims["x"]) return {
4988
+ ok: false,
4989
+ reason: "expired"
4990
+ };
4991
+ return {
4992
+ ok: true,
4993
+ entryId: claims["e"],
4994
+ expiresAt: (/* @__PURE__ */ new Date(claims["x"] * 1e3)).toISOString()
4995
+ };
4996
+ }
4997
+ //#endregion
4998
+ //#region src/events.ts
4999
+ /**
5000
+ * What Hollware sends to a site's webhooks (ADR-025), signed as `signWebhook` signs it: an event
5001
+ * about an entry that was published or unpublished, or about a change of the site's structure
5002
+ * (ADR-033). Every event carries the cache tags it invalidates (`@hollware/core`'s cache tags), so
5003
+ * a site that caches its pages purges them with no lookup. A receiver checks the signature with
5004
+ * `verifyWebhook`, then reads the body with `parseHollwareEvent`. Fields may be added in a minor
5005
+ * version, and kinds too: a receiver ignores a kind it does not know.
5006
+ */
5007
+ const base = {
5008
+ /** The id of the event: the same on every retry of its delivery. */
5009
+ id: z.string(),
5010
+ /** The key of the site. */
5011
+ site: z.string(),
5012
+ /** When the change was written, in ISO 8601. */
5013
+ at: z.string(),
5014
+ /** The cache tags the change invalidates. */
5015
+ cacheTags: z.array(z.string())
5016
+ };
5017
+ /** A publish or an unpublish: the entry, and where it lives on the site. */
5018
+ const entryEvent = z.object({
5019
+ ...base,
5020
+ kind: z.enum(["entry.published", "entry.unpublished"]),
5021
+ entry: z.object({
5022
+ id: z.string(),
5023
+ collection: z.string(),
5024
+ locale: z.string(),
5025
+ slug: z.string(),
5026
+ translationGroup: z.string().nullable()
5027
+ }),
5028
+ /** The path of the entry on the site, from the route of its collection and locale. */
5029
+ path: z.string().nullable(),
5030
+ /** The URL of the entry, when the site has a `url`. */
5031
+ url: z.string().nullable()
5032
+ });
5033
+ /** A change of the site's structure: no entry, the tags to purge. */
5034
+ const siteEvent = z.object({
5035
+ ...base,
5036
+ kind: z.literal("site.changed")
5037
+ });
5038
+ /** Every event a webhook gets. */
5039
+ const hollwareEvent = z.discriminatedUnion("kind", [entryEvent, siteEvent]);
5040
+ /**
5041
+ * The event of a webhook's body, once its signature holds, or `null` for a body that is not one
5042
+ * of the events this version knows (a newer kind, say): a receiver skips it.
5043
+ */
5044
+ function parseHollwareEvent(body) {
5045
+ let json;
5046
+ try {
5047
+ json = JSON.parse(body);
5048
+ } catch {
5049
+ return null;
5050
+ }
5051
+ const parsed = hollwareEvent.safeParse(json);
5052
+ return parsed.success ? parsed.data : null;
4639
5053
  }
4640
5054
  //#endregion
4641
5055
  //#region src/cache.ts
@@ -4688,8 +5102,19 @@ function tagsOfChange(entry) {
4688
5102
  ...entry.translationGroup === null ? [] : [groupTag(entry.collection, entry.translationGroup)]
4689
5103
  ];
4690
5104
  }
5105
+ /**
5106
+ * The tags a change of a site's structure invalidates (ADR-033): the pages that show the structure
5107
+ * (`site`, such as a navigation), and every list of its collections in each locale and in every
5108
+ * locale, as a list may change with a schema, a route or a collection that goes. `collections` and
5109
+ * `locales` are those of the definition before and after the change, together.
5110
+ */
5111
+ function tagsOfSiteChange(collections, locales) {
5112
+ const tags = /* @__PURE__ */ new Set([SITE_TAG]);
5113
+ for (const collection of collections) for (const locale of [...locales, "*"]) tags.add(listTag(collection, locale));
5114
+ return [...tags];
5115
+ }
4691
5116
  //#endregion
4692
5117
  //#region src/index.ts
4693
5118
  const name = "@hollware/core";
4694
5119
  //#endregion
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 };
5120
+ export { AUDIT_ACTIONS, AUDIT_ACTOR_KINDS, AUDIT_OUTCOMES, AUDIT_RETENTION_MAX_DAYS, AUDIT_RETENTION_MIN_DAYS, BUILT_IN_CONTRACT_IDS, CATALOG, CHUNKER_VERSION, CHUNK_LIMITS, COMARK_VERSION, ConfigError, DEFAULT_AUDIT, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, EVERY_LOCALE, HollwareError, IDENTITY_AUDIT_ACTIONS, ID_PREFIXES, INDEXNOW_KEY, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, OPERATION_AUDIT_ACTIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, PRESETS, PREVIEW_QUERY_PARAM, PREVIEW_SECRET_MIN_LENGTH, PREVIEW_TOKEN_PREFIX, PREVIEW_TTL_SECONDS, READ_TOKEN_PREFIX, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, SITE_TAG, TIMESTAMP_HEADER, approveReview, canSignPreviews, canonicalJson, catalogRule, chunksOf, collectionDefinitionData, collectionPreset, commentReview, compileSites, componentDefinitionData, contentStats, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, definitionsFromConfig, deleteEntry, describeBuiltInContracts, describeCatalog, describeContract, describeResolvedSite, describeSite, describedSchemaOf, entryEvent, entryTag, generatePersonalToken, generateReadToken, getEntry, getTranslations, groupTag, hashPersonalToken, hollwareEvent, isAuditAction, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, listTag, makePreviewToken, name, newId, parseHollwareEvent, processMarkdown, publish, renderHtml, requestChanges, requestReview, resolveConfig, restoreEntry, routesFor, ruleDefinition, runContracts, schemas_exports as schemas, search, signWebhook, siteDefinitionData, siteEvent, sitePreset, sizeLimitViolation, tagsOfChange, tagsOfEntries, tagsOfSiteChange, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyPreviewToken, verifyWebhook, webhookDefinition };