@hollware/core 0.9.0 → 0.10.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.d.mts +238 -1
- package/dist/index.mjs +382 -239
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -55,6 +55,170 @@ 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$3(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$3(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$3(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$3(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$3(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
|
|
58
222
|
//#region src/config/constants.ts
|
|
59
223
|
/** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
|
|
60
224
|
const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
|
|
@@ -70,7 +234,7 @@ const MAX_MARKDOWN_BYTES_CEILING = 786432;
|
|
|
70
234
|
* letter, at most 63 characters. A component name is what Markdown writes after the colons
|
|
71
235
|
* (`::callout`). A site key has its own rule (`site-key.ts`).
|
|
72
236
|
*/
|
|
73
|
-
const NAME
|
|
237
|
+
const NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
74
238
|
/** A language tag: a language, then an optional script and region: `es`, `pt-BR`, `zh-Hant-TW`. */
|
|
75
239
|
const LOCALE_TAG = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
76
240
|
/** The id of a contract: lowercase words joined by single hyphens. */
|
|
@@ -125,7 +289,7 @@ function runtimeOf(contractId, context) {
|
|
|
125
289
|
*/
|
|
126
290
|
/** The most characters of the entry's own text that a message repeats. */
|
|
127
291
|
const MAX_ECHO_LENGTH = 80;
|
|
128
|
-
function isRecord$
|
|
292
|
+
function isRecord$2(value) {
|
|
129
293
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
294
|
}
|
|
131
295
|
/** Orders two texts by UTF-16 code units: the same in every runtime, unlike `localeCompare`. */
|
|
@@ -140,7 +304,7 @@ function clip(text, max = MAX_ECHO_LENGTH) {
|
|
|
140
304
|
return `${last >= 55296 && last <= 56319 ? head.slice(0, -1) : head}…`;
|
|
141
305
|
}
|
|
142
306
|
/** Text from the entry as a message shows it: clipped, in JSON quotes, control characters escaped. */
|
|
143
|
-
function quote
|
|
307
|
+
function quote(text) {
|
|
144
308
|
return JSON.stringify(clip(text));
|
|
145
309
|
}
|
|
146
310
|
/** A JSON Pointer (RFC 6901) from its segments: `['tags', '0']` is `/tags/0`, none is the root. */
|
|
@@ -178,11 +342,11 @@ function fragmentAt(schema, segments) {
|
|
|
178
342
|
let node = schema;
|
|
179
343
|
for (const segment of segments) {
|
|
180
344
|
const properties = node.properties;
|
|
181
|
-
if (isRecord$
|
|
345
|
+
if (isRecord$2(properties) && Object.hasOwn(properties, segment)) {
|
|
182
346
|
const next = properties[segment];
|
|
183
|
-
if (!isRecord$
|
|
347
|
+
if (!isRecord$2(next)) return void 0;
|
|
184
348
|
node = next;
|
|
185
|
-
} else if (/^\d+$/.test(segment) && isRecord$
|
|
349
|
+
} else if (/^\d+$/.test(segment) && isRecord$2(node.items)) node = node.items;
|
|
186
350
|
else return;
|
|
187
351
|
}
|
|
188
352
|
return node;
|
|
@@ -190,7 +354,7 @@ function fragmentAt(schema, segments) {
|
|
|
190
354
|
function valueAt(input, segments) {
|
|
191
355
|
let node = input;
|
|
192
356
|
for (const segment of segments) if (Array.isArray(node) && /^\d+$/.test(segment)) node = node[Number(segment)];
|
|
193
|
-
else if (isRecord$
|
|
357
|
+
else if (isRecord$2(node) && Object.hasOwn(node, segment)) node = node[segment];
|
|
194
358
|
else return void 0;
|
|
195
359
|
return node;
|
|
196
360
|
}
|
|
@@ -226,11 +390,11 @@ function numberExpectation(type, schema) {
|
|
|
226
390
|
}
|
|
227
391
|
function arrayExpectation(schema) {
|
|
228
392
|
const size = range(schema.minItems, schema.maxItems, "item");
|
|
229
|
-
const items = isRecord$
|
|
393
|
+
const items = isRecord$2(schema.items) ? expectation(schema.items) : void 0;
|
|
230
394
|
return `an array${size === void 0 ? "" : ` of ${size}`}${items === void 0 ? "" : `, each ${items}`}`;
|
|
231
395
|
}
|
|
232
396
|
function objectExpectation(schema) {
|
|
233
|
-
const keys = isRecord$
|
|
397
|
+
const keys = isRecord$2(schema.properties) ? Object.keys(schema.properties) : [];
|
|
234
398
|
return keys.length === 0 ? "an object" : `an object with the keys ${keys.join(", ")}`;
|
|
235
399
|
}
|
|
236
400
|
function typeExpectation(type, schema) {
|
|
@@ -253,26 +417,26 @@ function expectation(schema) {
|
|
|
253
417
|
if ("const" in schema) return `exactly ${JSON.stringify(schema.const)}`;
|
|
254
418
|
if (Array.isArray(schema.enum)) return `one of ${listValues(schema.enum)}`;
|
|
255
419
|
const alternatives = schema.anyOf ?? schema.oneOf;
|
|
256
|
-
const parts = Array.isArray(alternatives) ? alternatives.map((option) => isRecord$
|
|
420
|
+
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));
|
|
257
421
|
return parts.every((part) => part !== void 0) ? parts.join(" or ") : void 0;
|
|
258
422
|
}
|
|
259
423
|
/** The keys of `value` that an object schema closed to other keys does not list. */
|
|
260
424
|
function unknownKeys(fragment, value) {
|
|
261
425
|
const { additionalProperties, properties } = fragment;
|
|
262
|
-
if (additionalProperties !== false || !isRecord$
|
|
426
|
+
if (additionalProperties !== false || !isRecord$2(properties) || !isRecord$2(value)) return [];
|
|
263
427
|
return Object.keys(value).filter((key) => !Object.hasOwn(properties, key));
|
|
264
428
|
}
|
|
265
429
|
/** The fix for one issue: imperative, naming the field and what the schema accepts there. */
|
|
266
430
|
function fixForIssue({ subject, schema, input, segments, message }) {
|
|
267
|
-
const target = segments.length === 0 ? subject.container : `the ${subject.noun} ${quote
|
|
431
|
+
const target = segments.length === 0 ? subject.container : `the ${subject.noun} ${quote(label(segments))}`;
|
|
268
432
|
const fragment = schema === null ? void 0 : fragmentAt(schema, segments);
|
|
269
433
|
if (fragment !== void 0) {
|
|
270
434
|
const unknown = unknownKeys(fragment, valueAt(input, segments));
|
|
271
435
|
if (unknown.length > 0) {
|
|
272
|
-
const allowed = isRecord$
|
|
436
|
+
const allowed = isRecord$2(fragment.properties) ? Object.keys(fragment.properties) : [];
|
|
273
437
|
const nouns = unknown.length === 1 ? subject.noun : `${subject.noun}s`;
|
|
274
438
|
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
|
|
439
|
+
return `Remove the ${nouns} ${unknown.map((key) => quote(key)).join(", ")} from ${target}; ${where}.`;
|
|
276
440
|
}
|
|
277
441
|
}
|
|
278
442
|
const expected = fragment === void 0 ? void 0 : expectation(fragment);
|
|
@@ -354,15 +518,15 @@ const COMPONENTS = registerBuiltIn({
|
|
|
354
518
|
const result = await definition.props["~standard"].validate(use.props);
|
|
355
519
|
if (!result.issues) continue;
|
|
356
520
|
const subject = {
|
|
357
|
-
container: `the component ${quote
|
|
521
|
+
container: `the component ${quote(use.name)}`,
|
|
358
522
|
noun: "prop"
|
|
359
523
|
};
|
|
360
524
|
for (const issue of result.issues) {
|
|
361
525
|
const segments = issueSegments(issue);
|
|
362
|
-
const which = segments.length === 0 ? "The props" : `The prop ${quote
|
|
526
|
+
const which = segments.length === 0 ? "The props" : `The prop ${quote(label(segments))}`;
|
|
363
527
|
findings.push({
|
|
364
528
|
path: "/body",
|
|
365
|
-
message: `${which} of the component ${quote
|
|
529
|
+
message: `${which} of the component ${quote(use.name)} ${segments.length === 0 ? "are" : "is"} invalid: ${issue.message}`,
|
|
366
530
|
fix: fixForIssue({
|
|
367
531
|
subject,
|
|
368
532
|
schema: schemas.get(use.name) ?? null,
|
|
@@ -429,16 +593,16 @@ const RAW_HTML = markerContract("raw-html", "The body holds Markdown and the com
|
|
|
429
593
|
const UNKNOWN_COMPONENT = markerContract("unknown-component", "The body uses only the components that the collection declares.", (marker, collection) => {
|
|
430
594
|
const components = allowedComponents(collection);
|
|
431
595
|
return {
|
|
432
|
-
message: `The component ${quote
|
|
596
|
+
message: `The component ${quote(marker.name)} does not exist in this collection.`,
|
|
433
597
|
fix: components.length === 0 ? "This collection allows no components: remove it and use Markdown." : `Use one of the allowed components: ${components.join(", ")}.`
|
|
434
598
|
};
|
|
435
599
|
});
|
|
436
600
|
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
|
|
438
|
-
fix: `Remove the attribute ${quote
|
|
601
|
+
message: `The attribute ${quote(marker.name)} is not allowed.`,
|
|
602
|
+
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
603
|
}));
|
|
440
604
|
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
|
|
605
|
+
message: `The URL ${quote(marker.name)} is not allowed.`,
|
|
442
606
|
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
607
|
}));
|
|
444
608
|
//#endregion
|
|
@@ -622,7 +786,7 @@ function chunksOf(entry) {
|
|
|
622
786
|
//#endregion
|
|
623
787
|
//#region src/contracts/rules.ts
|
|
624
788
|
function present(frontmatter, field) {
|
|
625
|
-
return isRecord$
|
|
789
|
+
return isRecord$2(frontmatter) ? frontmatter[field] : void 0;
|
|
626
790
|
}
|
|
627
791
|
const GRAPHEMES = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
628
792
|
/** The length of a value as a reader counts it: characters of a text (an emoji is one), items of a list. */
|
|
@@ -633,11 +797,11 @@ function lengthOf(value) {
|
|
|
633
797
|
/** A text, or a list, of a field has between `min` and `max` characters or items. */
|
|
634
798
|
function fieldLength(params, options = {}) {
|
|
635
799
|
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
|
|
800
|
+
const unless = params.unless === void 0 ? "" : `, unless ${quote(params.unless)} is given`;
|
|
637
801
|
return registerBuiltIn({
|
|
638
802
|
id: options.id ?? "field-length",
|
|
639
803
|
severity: options.severity ?? "error",
|
|
640
|
-
description: `${quote
|
|
804
|
+
description: `${quote(params.field)} has ${bounds.join(" and ")} characters or items${unless}.`,
|
|
641
805
|
rule: {
|
|
642
806
|
name: "field-length",
|
|
643
807
|
params: { ...params }
|
|
@@ -651,13 +815,13 @@ function fieldLength(params, options = {}) {
|
|
|
651
815
|
const path = `/frontmatter/${params.field}`;
|
|
652
816
|
if (params.max !== void 0 && length > params.max) return [{
|
|
653
817
|
path,
|
|
654
|
-
message: `${quote
|
|
655
|
-
fix: `Shorten ${quote
|
|
818
|
+
message: `${quote(params.field)} has ${count(length, unit)}; the most is ${params.max}.`,
|
|
819
|
+
fix: `Shorten ${quote(params.field)} to ${count(params.max, unit)} or fewer${params.unless === void 0 ? "" : `, or add ${quote(params.unless)}`}.`
|
|
656
820
|
}];
|
|
657
821
|
if (params.min !== void 0 && length < params.min) return [{
|
|
658
822
|
path,
|
|
659
|
-
message: `${quote
|
|
660
|
-
fix: `Lengthen ${quote
|
|
823
|
+
message: `${quote(params.field)} has ${count(length, unit)}; the least is ${params.min}.`,
|
|
824
|
+
fix: `Lengthen ${quote(params.field)} to ${count(params.min, unit)} or more.`
|
|
661
825
|
}];
|
|
662
826
|
return [];
|
|
663
827
|
}
|
|
@@ -669,7 +833,7 @@ function fieldPattern(params, options = {}) {
|
|
|
669
833
|
return registerBuiltIn({
|
|
670
834
|
id: options.id ?? "field-pattern",
|
|
671
835
|
severity: options.severity ?? "error",
|
|
672
|
-
description: `${quote
|
|
836
|
+
description: `${quote(params.field)} is ${params.description}.`,
|
|
673
837
|
rule: {
|
|
674
838
|
name: "field-pattern",
|
|
675
839
|
params: { ...params }
|
|
@@ -679,8 +843,8 @@ function fieldPattern(params, options = {}) {
|
|
|
679
843
|
if (typeof value !== "string" || pattern.test(value)) return [];
|
|
680
844
|
return [{
|
|
681
845
|
path: `/frontmatter/${params.field}`,
|
|
682
|
-
message: `${quote
|
|
683
|
-
fix: `Write ${quote
|
|
846
|
+
message: `${quote(params.field)} is not ${params.description}.`,
|
|
847
|
+
fix: `Write ${quote(params.field)} as ${params.description}.`
|
|
684
848
|
}];
|
|
685
849
|
}
|
|
686
850
|
}, { readsFrontmatter: true });
|
|
@@ -692,15 +856,15 @@ function requiredToPublish(params, options = {}) {
|
|
|
692
856
|
id: options.id ?? "required-to-publish",
|
|
693
857
|
severity: options.severity ?? "error",
|
|
694
858
|
phases: ["publish"],
|
|
695
|
-
description: `To publish, ${params.fields.map(quote
|
|
859
|
+
description: `To publish, ${params.fields.map(quote).join(", ")} must have a value.`,
|
|
696
860
|
rule: {
|
|
697
861
|
name: "required-to-publish",
|
|
698
862
|
params: { fields: [...params.fields] }
|
|
699
863
|
},
|
|
700
864
|
check: ({ frontmatter }) => params.fields.filter((field) => empty(present(frontmatter, field))).map((field) => ({
|
|
701
865
|
path: `/frontmatter/${field}`,
|
|
702
|
-
message: `${quote
|
|
703
|
-
fix: `Give ${quote
|
|
866
|
+
message: `${quote(field)} is empty, and the entry cannot be published without it.`,
|
|
867
|
+
fix: `Give ${quote(field)} a value, then publish.`
|
|
704
868
|
}))
|
|
705
869
|
}, { readsFrontmatter: true });
|
|
706
870
|
}
|
|
@@ -716,7 +880,7 @@ function bannedWords(params, options = {}) {
|
|
|
716
880
|
return registerBuiltIn({
|
|
717
881
|
id: options.id ?? "banned-words",
|
|
718
882
|
severity: options.severity ?? "error",
|
|
719
|
-
description: `None of these words: ${words.map(quote
|
|
883
|
+
description: `None of these words: ${words.map(quote).join(", ")}${because}.`,
|
|
720
884
|
rule: {
|
|
721
885
|
name: "banned-words",
|
|
722
886
|
params: {
|
|
@@ -727,19 +891,19 @@ function bannedWords(params, options = {}) {
|
|
|
727
891
|
check: (context) => {
|
|
728
892
|
if (pattern === null) return [];
|
|
729
893
|
const findings = [];
|
|
730
|
-
const frontmatter = isRecord$
|
|
894
|
+
const frontmatter = isRecord$2(context.frontmatter) ? context.frontmatter : {};
|
|
731
895
|
for (const [field, value] of Object.entries(frontmatter)) {
|
|
732
896
|
if (typeof value !== "string") continue;
|
|
733
897
|
for (const word of found(value)) findings.push({
|
|
734
898
|
path: `/frontmatter/${field}`,
|
|
735
|
-
message: `${quote
|
|
736
|
-
fix: `Write ${quote
|
|
899
|
+
message: `${quote(field)} uses ${quote(word)}.`,
|
|
900
|
+
fix: `Write ${quote(field)} without ${quote(word)}${because}.`
|
|
737
901
|
});
|
|
738
902
|
}
|
|
739
903
|
for (const word of found(documentText(context.markdown.document))) findings.push({
|
|
740
904
|
path: "/body",
|
|
741
|
-
message: `The body uses ${quote
|
|
742
|
-
fix: `Rewrite the body without ${quote
|
|
905
|
+
message: `The body uses ${quote(word)}.`,
|
|
906
|
+
fix: `Rewrite the body without ${quote(word)}${because}.`
|
|
743
907
|
});
|
|
744
908
|
return findings;
|
|
745
909
|
}
|
|
@@ -768,7 +932,7 @@ function translationPair(options = {}) {
|
|
|
768
932
|
const field = collection.translationGroupField;
|
|
769
933
|
if (field === null) return [];
|
|
770
934
|
const path = `/frontmatter${pointer([field])}`;
|
|
771
|
-
const group = isRecord$
|
|
935
|
+
const group = isRecord$2(context.frontmatter) ? context.frontmatter[field] : void 0;
|
|
772
936
|
if (typeof group !== "string" || group === "") return [{
|
|
773
937
|
path,
|
|
774
938
|
message: `The entry has no translation group: its frontmatter field ${field} is empty.`,
|
|
@@ -778,8 +942,8 @@ function translationPair(options = {}) {
|
|
|
778
942
|
const present = /* @__PURE__ */ new Set([context.locale, ...found.map((entry) => entry.locale)]);
|
|
779
943
|
return site.locales.filter((locale) => !present.has(locale)).map((locale) => ({
|
|
780
944
|
path,
|
|
781
|
-
message: `The ${locale} version of the translation group ${quote
|
|
782
|
-
fix: `Create the entry in ${locale} with ${field}: ${quote
|
|
945
|
+
message: `The ${locale} version of the translation group ${quote(group)} does not exist.`,
|
|
946
|
+
fix: `Create the entry in ${locale} with ${field}: ${quote(group)} in its frontmatter before publishing.`
|
|
783
947
|
}));
|
|
784
948
|
}
|
|
785
949
|
}, { readsFrontmatter: true });
|
|
@@ -826,7 +990,7 @@ function internalLinksResolve(options = {}) {
|
|
|
826
990
|
const entry = `the entry ${clip(`${target.collection}/${target.locale}/${target.slug}`)}`;
|
|
827
991
|
findings.push({
|
|
828
992
|
path: "/body",
|
|
829
|
-
message: found === null ? `The link ${quote
|
|
993
|
+
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
994
|
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
995
|
line: link.line
|
|
832
996
|
});
|
|
@@ -859,7 +1023,7 @@ function imagesHaveAlt(options = {}) {
|
|
|
859
1023
|
description: "Every image has alt text that describes it.",
|
|
860
1024
|
check: (context) => context.markdown.extracted.images.filter((image) => image.alt.trim() === "").map((image) => ({
|
|
861
1025
|
path: "/body",
|
|
862
|
-
message: `The image ${quote
|
|
1026
|
+
message: `The image ${quote(image.src)} has no alt text.`,
|
|
863
1027
|
fix: `Add alt text that describes the image, as in }).`,
|
|
864
1028
|
line: image.line
|
|
865
1029
|
}))
|
|
@@ -1026,7 +1190,7 @@ function toViolations(contract, findings) {
|
|
|
1026
1190
|
severity: contract.severity,
|
|
1027
1191
|
path: finding.path,
|
|
1028
1192
|
message: finding.message,
|
|
1029
|
-
fix: finding.fix.trim() === "" ? `Change the entry so that the rule ${quote
|
|
1193
|
+
fix: finding.fix.trim() === "" ? `Change the entry so that the rule ${quote(contract.id)} passes: ${contract.description}` : finding.fix,
|
|
1030
1194
|
...finding.line === void 0 ? {} : { line: finding.line }
|
|
1031
1195
|
}));
|
|
1032
1196
|
}
|
|
@@ -1043,8 +1207,8 @@ function thrown(contract, error) {
|
|
|
1043
1207
|
contract: contract.id,
|
|
1044
1208
|
severity: "error",
|
|
1045
1209
|
path: "",
|
|
1046
|
-
message: `The contract ${quote
|
|
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
|
|
1210
|
+
message: `The contract ${quote(contract.id)} failed with an error while it checked this entry: ${clip(describeError(error), 200)}`,
|
|
1211
|
+
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
1212
|
};
|
|
1049
1213
|
}
|
|
1050
1214
|
async function settle(contract, context) {
|
|
@@ -1169,57 +1333,10 @@ function uniqueTranslationViolation(holder, field) {
|
|
|
1169
1333
|
severity: "error",
|
|
1170
1334
|
path: `/frontmatter${pointer([field])}`,
|
|
1171
1335
|
message: `Another entry of this collection and locale, ${holder}, already belongs to this translation group.`,
|
|
1172
|
-
fix: `Give ${quote
|
|
1336
|
+
fix: `Give ${quote(field)} another value, or change the entry ${holder} with update_draft: a translation group holds one entry per locale.`
|
|
1173
1337
|
};
|
|
1174
1338
|
}
|
|
1175
1339
|
//#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
1340
|
//#region src/config/json-schema.ts
|
|
1224
1341
|
/**
|
|
1225
1342
|
* JSON Schema as the config uses it: what agents see of a schema, and what the checks read of it
|
|
@@ -1233,7 +1350,7 @@ const TARGET = "draft-2020-12";
|
|
|
1233
1350
|
*/
|
|
1234
1351
|
function toJsonSchema$1(schema) {
|
|
1235
1352
|
const converted = schema["~standard"].jsonSchema.input({ target: TARGET });
|
|
1236
|
-
if (!isRecord$
|
|
1353
|
+
if (!isRecord$3(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
|
|
1237
1354
|
return JSON.parse(JSON.stringify(converted));
|
|
1238
1355
|
}
|
|
1239
1356
|
/** Reference chains longer than this are cycles. */
|
|
@@ -1245,7 +1362,7 @@ function pointTo(root, ref) {
|
|
|
1245
1362
|
let node = root;
|
|
1246
1363
|
for (const part of ref.slice(2).split("/")) {
|
|
1247
1364
|
const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
1248
|
-
if (!isRecord$
|
|
1365
|
+
if (!isRecord$3(node) || !Object.hasOwn(node, key)) return void 0;
|
|
1249
1366
|
node = node[key];
|
|
1250
1367
|
}
|
|
1251
1368
|
return node;
|
|
@@ -1257,7 +1374,7 @@ function pointTo(root, ref) {
|
|
|
1257
1374
|
function resolveSchema(root, schema) {
|
|
1258
1375
|
let node = schema;
|
|
1259
1376
|
for (let hop = 0; hop <= MAX_REF_HOPS; hop += 1) {
|
|
1260
|
-
if (!isRecord$
|
|
1377
|
+
if (!isRecord$3(node)) return null;
|
|
1261
1378
|
const ref = node["$ref"];
|
|
1262
1379
|
if (typeof ref !== "string") return node;
|
|
1263
1380
|
node = pointTo(root, ref);
|
|
@@ -1374,14 +1491,14 @@ function standardProps(value) {
|
|
|
1374
1491
|
/** A Standard Schema that also converts to JSON Schema, which is what a collection needs. */
|
|
1375
1492
|
function isDescribedSchema(value) {
|
|
1376
1493
|
const standard = standardProps(value);
|
|
1377
|
-
if (!isRecord$
|
|
1494
|
+
if (!isRecord$3(standard) || typeof standard["validate"] !== "function") return false;
|
|
1378
1495
|
const converter = standard["jsonSchema"];
|
|
1379
|
-
return isRecord$
|
|
1496
|
+
return isRecord$3(converter) && typeof converter["input"] === "function";
|
|
1380
1497
|
}
|
|
1381
1498
|
/** What `isDescribedSchema` found missing, for the message. */
|
|
1382
1499
|
function whyNotDescribed(value) {
|
|
1383
1500
|
const standard = standardProps(value);
|
|
1384
|
-
if (!isRecord$
|
|
1501
|
+
if (!isRecord$3(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
|
|
1385
1502
|
if (typeof standard["validate"] !== "function") return "has no ~standard.validate function, so it is no Standard Schema";
|
|
1386
1503
|
return "has no ~standard.jsonSchema.input function, so describe_site could not show it to agents";
|
|
1387
1504
|
}
|
|
@@ -1429,12 +1546,12 @@ const SLUG_PLACEHOLDER = /:slug(?![A-Za-z0-9_])/g;
|
|
|
1429
1546
|
function isRoutePattern(path, route, problems) {
|
|
1430
1547
|
let valid = true;
|
|
1431
1548
|
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/\".");
|
|
1549
|
+
problems.add(path, `${quote$1(route)} does not start with "/"`, "Write the path from the root of the site, such as \"/blog/:slug/\".");
|
|
1433
1550
|
valid = false;
|
|
1434
1551
|
}
|
|
1435
1552
|
const slugs = [...route.matchAll(SLUG_PLACEHOLDER)].length;
|
|
1436
1553
|
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/\".");
|
|
1554
|
+
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
1555
|
valid = false;
|
|
1439
1556
|
}
|
|
1440
1557
|
return valid;
|
|
@@ -1447,11 +1564,11 @@ function claimRoute(route, path, site, problems) {
|
|
|
1447
1564
|
site.routes.set(pattern, path);
|
|
1448
1565
|
return;
|
|
1449
1566
|
}
|
|
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.");
|
|
1567
|
+
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
1568
|
}
|
|
1452
1569
|
/** The route of each locale, in the order of the site's locales. */
|
|
1453
1570
|
function readRoutes(path, raw, collection, site, problems) {
|
|
1454
|
-
if (!isRecord$
|
|
1571
|
+
if (!isRecord$3(raw)) {
|
|
1455
1572
|
problems.add(path, wrongType(raw, "an object with one path per locale"), ROUTES_FIX);
|
|
1456
1573
|
return {};
|
|
1457
1574
|
}
|
|
@@ -1459,12 +1576,12 @@ function readRoutes(path, raw, collection, site, problems) {
|
|
|
1459
1576
|
const routePath = keyPath(path, locale);
|
|
1460
1577
|
const route = raw[locale];
|
|
1461
1578
|
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.`);
|
|
1579
|
+
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
1580
|
if (typeof route !== "string") problems.add(routePath, wrongType(route, "a path such as \"/blog/:slug/\""), ROUTES_FIX);
|
|
1464
1581
|
else if (isRoutePattern(routePath, route, problems) && known) claimRoute(route, routePath, site, problems);
|
|
1465
1582
|
}
|
|
1466
1583
|
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/`)}.`);
|
|
1584
|
+
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
1585
|
}
|
|
1469
1586
|
const routes = {};
|
|
1470
1587
|
for (const locale of site.locales) {
|
|
@@ -1476,12 +1593,12 @@ function readRoutes(path, raw, collection, site, problems) {
|
|
|
1476
1593
|
const GROUP_BY = {
|
|
1477
1594
|
mustBe: "a string",
|
|
1478
1595
|
accepts: isStringSchema,
|
|
1479
|
-
fix: (field) => `Declare ${quote(field)} as z.string() in the schema, or name another field.`
|
|
1596
|
+
fix: (field) => `Declare ${quote$1(field)} as z.string() in the schema, or name another field.`
|
|
1480
1597
|
};
|
|
1481
1598
|
const TAGS = {
|
|
1482
1599
|
mustBe: "an array of strings",
|
|
1483
1600
|
accepts: isStringArraySchema,
|
|
1484
|
-
fix: (field) => `Declare ${quote(field)} as z.array(z.string()) in the schema, or name another field.`
|
|
1601
|
+
fix: (field) => `Declare ${quote$1(field)} as z.array(z.string()) in the schema, or name another field.`
|
|
1485
1602
|
};
|
|
1486
1603
|
/**
|
|
1487
1604
|
* Listings sort by this field as text, which is chronological only for ISO 8601 dates. A string
|
|
@@ -1490,7 +1607,7 @@ const TAGS = {
|
|
|
1490
1607
|
const SORT_BY = {
|
|
1491
1608
|
mustBe: "a string with the \"date\" or \"date-time\" format",
|
|
1492
1609
|
accepts: isDateSchema,
|
|
1493
|
-
fix: (field) => `Declare ${quote(field)} as z.iso.date() or z.iso.datetime() in the schema, or name another field.`
|
|
1610
|
+
fix: (field) => `Declare ${quote$1(field)} as z.iso.date() or z.iso.datetime() in the schema, or name another field.`
|
|
1494
1611
|
};
|
|
1495
1612
|
/** The name of a field of the frontmatter, checked against the schema when there is one. */
|
|
1496
1613
|
function readField(path, raw, schema, kind, problems) {
|
|
@@ -1499,16 +1616,16 @@ function readField(path, raw, schema, kind, problems) {
|
|
|
1499
1616
|
return null;
|
|
1500
1617
|
}
|
|
1501
1618
|
if (schema === null) return raw;
|
|
1502
|
-
const properties = isRecord$
|
|
1619
|
+
const properties = isRecord$3(schema.object["properties"]) ? schema.object["properties"] : {};
|
|
1503
1620
|
if (!Object.hasOwn(properties, raw)) {
|
|
1504
1621
|
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));
|
|
1622
|
+
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.`);
|
|
1623
|
+
} 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
1624
|
return raw;
|
|
1508
1625
|
}
|
|
1509
1626
|
function readTranslations(path, raw, schema, problems) {
|
|
1510
1627
|
if (raw === void 0) return null;
|
|
1511
|
-
if (!isRecord$
|
|
1628
|
+
if (!isRecord$3(raw)) {
|
|
1512
1629
|
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
1630
|
return null;
|
|
1514
1631
|
}
|
|
@@ -1516,8 +1633,8 @@ function readTranslations(path, raw, schema, problems) {
|
|
|
1516
1633
|
}
|
|
1517
1634
|
const COMPONENT_FIX = "Declare it with defineComponent({ props: z.strictObject({ … }) }).";
|
|
1518
1635
|
function readComponent(path, name, raw, problems) {
|
|
1519
|
-
if (!NAME
|
|
1520
|
-
if (!isRecord$
|
|
1636
|
+
if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid component name`, nameFix("callout"));
|
|
1637
|
+
if (!isRecord$3(raw)) {
|
|
1521
1638
|
problems.add(path, wrongType(raw, "a component"), COMPONENT_FIX);
|
|
1522
1639
|
return null;
|
|
1523
1640
|
}
|
|
@@ -1535,7 +1652,7 @@ function readComponent(path, name, raw, problems) {
|
|
|
1535
1652
|
function readComponents(path, raw, problems) {
|
|
1536
1653
|
const components = /* @__PURE__ */ new Map();
|
|
1537
1654
|
if (raw === void 0) return components;
|
|
1538
|
-
if (!isRecord$
|
|
1655
|
+
if (!isRecord$3(raw)) {
|
|
1539
1656
|
problems.add(path, wrongType(raw, "an object that maps each component name to its definition"), COMPONENT_FIX);
|
|
1540
1657
|
return components;
|
|
1541
1658
|
}
|
|
@@ -1567,7 +1684,7 @@ function checkPhases(path, raw, problems) {
|
|
|
1567
1684
|
for (let index = 0; index < phases.length; index += 1) {
|
|
1568
1685
|
const phase = phases[index];
|
|
1569
1686
|
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);
|
|
1687
|
+
else if (seen.has(phase)) problems.add(itemPath(path, index), `repeats ${quote$1(phase)}`, PHASES_FIX);
|
|
1571
1688
|
seen.add(phase);
|
|
1572
1689
|
}
|
|
1573
1690
|
}
|
|
@@ -1576,7 +1693,7 @@ function checkPhases(path, raw, problems) {
|
|
|
1576
1693
|
* what makes it a `Contract`. Whether it may have its id is a question for `checkIdentity`.
|
|
1577
1694
|
*/
|
|
1578
1695
|
function isContract(item, path, problems) {
|
|
1579
|
-
if (!isRecord$
|
|
1696
|
+
if (!isRecord$3(item)) {
|
|
1580
1697
|
problems.add(path, wrongType(item, "a contract"), "Write it as { id, severity, description, check }.");
|
|
1581
1698
|
return false;
|
|
1582
1699
|
}
|
|
@@ -1599,7 +1716,7 @@ function isContract(item, path, problems) {
|
|
|
1599
1716
|
*/
|
|
1600
1717
|
function checkIdentity(id, own, path, ids, problems) {
|
|
1601
1718
|
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().`);
|
|
1719
|
+
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
1720
|
return;
|
|
1604
1721
|
}
|
|
1605
1722
|
const first = ids.get(id);
|
|
@@ -1607,7 +1724,7 @@ function checkIdentity(id, own, path, ids, problems) {
|
|
|
1607
1724
|
ids.set(id, path);
|
|
1608
1725
|
return;
|
|
1609
1726
|
}
|
|
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.");
|
|
1727
|
+
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
1728
|
}
|
|
1612
1729
|
function readContracts(path, raw, problems) {
|
|
1613
1730
|
const contracts = [];
|
|
@@ -1624,7 +1741,7 @@ function readContracts(path, raw, problems) {
|
|
|
1624
1741
|
if (isContract(item, itemAt, problems)) {
|
|
1625
1742
|
checkIdentity(item.id, isBuiltInContract(item), itemAt, ids, problems);
|
|
1626
1743
|
contracts.push(item);
|
|
1627
|
-
} else if (isRecord$
|
|
1744
|
+
} else if (isRecord$3(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
|
|
1628
1745
|
}
|
|
1629
1746
|
return contracts;
|
|
1630
1747
|
}
|
|
@@ -1639,8 +1756,8 @@ function readMaxMarkdownBytes(path, raw, problems) {
|
|
|
1639
1756
|
* problem already: `resolveConfig` throws when there is any, so nothing else keeps track of them.
|
|
1640
1757
|
*/
|
|
1641
1758
|
function readCollection(path, name, raw, site, problems) {
|
|
1642
|
-
if (!NAME
|
|
1643
|
-
if (!isRecord$
|
|
1759
|
+
if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid collection name`, nameFix("blog"));
|
|
1760
|
+
if (!isRecord$3(raw)) {
|
|
1644
1761
|
problems.add(path, wrongType(raw, "a collection"), "Declare it with defineCollection({ schema, routes, … }).");
|
|
1645
1762
|
return null;
|
|
1646
1763
|
}
|
|
@@ -1668,105 +1785,6 @@ function readCollection(path, name, raw, site, problems) {
|
|
|
1668
1785
|
};
|
|
1669
1786
|
}
|
|
1670
1787
|
//#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
1788
|
//#region src/config/site-key.ts
|
|
1771
1789
|
/**
|
|
1772
1790
|
* The key of a site, which is the id of its row in the database: lowercase letters and digits
|
|
@@ -1816,14 +1834,14 @@ function readLocales(path, raw, problems) {
|
|
|
1816
1834
|
}
|
|
1817
1835
|
locales.push(tag);
|
|
1818
1836
|
if (!LOCALE_TAG.test(tag)) {
|
|
1819
|
-
problems.add(tagPath, `${quote(tag)} is not a language tag`, LOCALE_FIX);
|
|
1837
|
+
problems.add(tagPath, `${quote$1(tag)} is not a language tag`, LOCALE_FIX);
|
|
1820
1838
|
ok = false;
|
|
1821
1839
|
continue;
|
|
1822
1840
|
}
|
|
1823
1841
|
const first = firstAt.get(tag);
|
|
1824
1842
|
if (first === void 0) firstAt.set(tag, index);
|
|
1825
1843
|
else {
|
|
1826
|
-
problems.add(tagPath, `${quote(tag)} is already listed at ${itemPath(path, first)}`, "List each locale once.");
|
|
1844
|
+
problems.add(tagPath, `${quote$1(tag)} is already listed at ${itemPath(path, first)}`, "List each locale once.");
|
|
1827
1845
|
ok = false;
|
|
1828
1846
|
}
|
|
1829
1847
|
}
|
|
@@ -1837,13 +1855,13 @@ function readDefaultLocale(path, raw, locales, problems) {
|
|
|
1837
1855
|
problems.add(path, wrongType(raw, "one of the locales"), "Set it to the main language of the site, one of its locales.");
|
|
1838
1856
|
return "";
|
|
1839
1857
|
}
|
|
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.`);
|
|
1858
|
+
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
1859
|
return raw;
|
|
1842
1860
|
}
|
|
1843
1861
|
const COLLECTIONS_FIX = "Declare at least one: collections: { blog: defineCollection({ schema, routes }) }.";
|
|
1844
1862
|
function readCollections(path, raw, site, problems) {
|
|
1845
1863
|
const collections = /* @__PURE__ */ new Map();
|
|
1846
|
-
if (!isRecord$
|
|
1864
|
+
if (!isRecord$3(raw)) {
|
|
1847
1865
|
problems.add(path, wrongType(raw, "an object that maps each collection name to its collection"), COLLECTIONS_FIX);
|
|
1848
1866
|
return collections;
|
|
1849
1867
|
}
|
|
@@ -1858,8 +1876,8 @@ function readCollections(path, raw, site, problems) {
|
|
|
1858
1876
|
/** Checks one site; `null` when it is no object, which is a problem already. */
|
|
1859
1877
|
function readSite(key, raw, problems) {
|
|
1860
1878
|
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$
|
|
1879
|
+
if (!isSiteKey(key)) problems.add(path, `${quote$1(key)} is not a valid site key`, SITE_KEY_FIX);
|
|
1880
|
+
if (!isRecord$3(raw)) {
|
|
1863
1881
|
problems.add(path, wrongType(raw, "a site"), "Declare it as { locales: [\"en\"], defaultLocale: \"en\", collections: { … } }.");
|
|
1864
1882
|
return null;
|
|
1865
1883
|
}
|
|
@@ -1886,7 +1904,7 @@ function readSite(key, raw, problems) {
|
|
|
1886
1904
|
}
|
|
1887
1905
|
function readSites(raw, problems) {
|
|
1888
1906
|
const sites = /* @__PURE__ */ new Map();
|
|
1889
|
-
if (!isRecord$
|
|
1907
|
+
if (!isRecord$3(raw)) {
|
|
1890
1908
|
problems.add("sites", wrongType(raw, "an object that maps the site key to the site"), SITE_FIX);
|
|
1891
1909
|
return sites;
|
|
1892
1910
|
}
|
|
@@ -1901,7 +1919,7 @@ function readSites(raw, problems) {
|
|
|
1901
1919
|
/** `auth` of the config: absent, or an object whose `dynamicClientRegistration` is a boolean. */
|
|
1902
1920
|
function readAuth(raw, problems) {
|
|
1903
1921
|
if (raw === void 0) return { dynamicClientRegistration: false };
|
|
1904
|
-
if (!isRecord$
|
|
1922
|
+
if (!isRecord$3(raw)) {
|
|
1905
1923
|
problems.add("auth", wrongType(raw, "an object"), "Write auth: { dynamicClientRegistration: true }, or leave auth out.");
|
|
1906
1924
|
return { dynamicClientRegistration: false };
|
|
1907
1925
|
}
|
|
@@ -1943,7 +1961,7 @@ function resolveConfig(config) {
|
|
|
1943
1961
|
const problems = new Problems();
|
|
1944
1962
|
let sites = /* @__PURE__ */ new Map();
|
|
1945
1963
|
let auth = { dynamicClientRegistration: false };
|
|
1946
|
-
if (isRecord$
|
|
1964
|
+
if (isRecord$3(config)) {
|
|
1947
1965
|
sites = readSites(config["sites"], problems);
|
|
1948
1966
|
auth = readAuth(config["auth"], problems);
|
|
1949
1967
|
} else problems.add("config", wrongType(config, "an object"), "Export the result of defineConfig({ sites: { … } }) from hollware.config.ts.");
|
|
@@ -3828,6 +3846,7 @@ const entrySummary = z.object({
|
|
|
3828
3846
|
hasUnpublishedChanges: z.boolean().describe("Whether the latest revision differs from the published one."),
|
|
3829
3847
|
publishedAt: z.string().nullable().describe("When the entry was first published, or null."),
|
|
3830
3848
|
scheduledAt: z.string().nullable().describe("When a scheduled entry will be published, or null for one that is not scheduled."),
|
|
3849
|
+
deletedAt: z.string().nullable().describe("When the entry went to the trash, or null for one that is not in the trash."),
|
|
3831
3850
|
updatedAt: z.string()
|
|
3832
3851
|
});
|
|
3833
3852
|
const publication = z.object({
|
|
@@ -3958,12 +3977,13 @@ const describeSite = defineOperation({
|
|
|
3958
3977
|
const listEntries = defineOperation({
|
|
3959
3978
|
name: "list_entries",
|
|
3960
3979
|
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.",
|
|
3980
|
+
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
3981
|
input: z.strictObject({
|
|
3963
3982
|
collection: collectionName,
|
|
3964
3983
|
locale: locale.optional(),
|
|
3965
3984
|
tag: z.string().min(1).optional(),
|
|
3966
3985
|
status: entryStatus.optional(),
|
|
3986
|
+
trashed: z.boolean().optional().describe("true to list the entries in the trash, last trashed first, instead of the others."),
|
|
3967
3987
|
limit: z.number().int().min(1).max(100).default(20),
|
|
3968
3988
|
cursor: z.string().min(1).optional()
|
|
3969
3989
|
}),
|
|
@@ -4046,7 +4066,7 @@ const getTranslations = defineOperation({
|
|
|
4046
4066
|
const createDraft = defineOperation({
|
|
4047
4067
|
name: "create_draft",
|
|
4048
4068
|
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.",
|
|
4069
|
+
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
4070
|
input: z.strictObject({
|
|
4051
4071
|
collection: collectionName,
|
|
4052
4072
|
locale,
|
|
@@ -4054,15 +4074,20 @@ const createDraft = defineOperation({
|
|
|
4054
4074
|
markdown
|
|
4055
4075
|
}),
|
|
4056
4076
|
output: z.object({
|
|
4057
|
-
outcome: z.enum([
|
|
4058
|
-
|
|
4077
|
+
outcome: z.enum([
|
|
4078
|
+
"saved",
|
|
4079
|
+
"refused",
|
|
4080
|
+
"in_trash"
|
|
4081
|
+
]),
|
|
4082
|
+
entry: entrySummary.optional().describe("With saved, the new draft; with in_trash, the entry in the trash that holds it."),
|
|
4059
4083
|
verdict: verdict.optional()
|
|
4060
4084
|
}),
|
|
4061
4085
|
scopes: ["content:write"],
|
|
4062
4086
|
effect: "write",
|
|
4063
4087
|
outcomes: {
|
|
4064
4088
|
saved: { isError: false },
|
|
4065
|
-
refused: { isError: true }
|
|
4089
|
+
refused: { isError: true },
|
|
4090
|
+
in_trash: { isError: true }
|
|
4066
4091
|
}
|
|
4067
4092
|
});
|
|
4068
4093
|
const updateDraft = defineOperation({
|
|
@@ -4172,6 +4197,54 @@ const unpublish = defineOperation({
|
|
|
4172
4197
|
not_found: { isError: true }
|
|
4173
4198
|
}
|
|
4174
4199
|
});
|
|
4200
|
+
const deleteEntry = defineOperation({
|
|
4201
|
+
name: "delete_entry",
|
|
4202
|
+
title: "Move an entry to the trash",
|
|
4203
|
+
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.",
|
|
4204
|
+
input: z.strictObject({
|
|
4205
|
+
id: entryId,
|
|
4206
|
+
version
|
|
4207
|
+
}),
|
|
4208
|
+
output: z.object({
|
|
4209
|
+
outcome: z.enum([
|
|
4210
|
+
"trashed",
|
|
4211
|
+
"published",
|
|
4212
|
+
"conflict",
|
|
4213
|
+
"not_found"
|
|
4214
|
+
]),
|
|
4215
|
+
entry: entrySummary.optional(),
|
|
4216
|
+
conflict: conflict.optional()
|
|
4217
|
+
}),
|
|
4218
|
+
scopes: ["content:write"],
|
|
4219
|
+
effect: "destructive",
|
|
4220
|
+
outcomes: {
|
|
4221
|
+
trashed: { isError: false },
|
|
4222
|
+
published: { isError: true },
|
|
4223
|
+
conflict: { isError: true },
|
|
4224
|
+
not_found: { isError: true }
|
|
4225
|
+
}
|
|
4226
|
+
});
|
|
4227
|
+
const restoreEntry = defineOperation({
|
|
4228
|
+
name: "restore_entry",
|
|
4229
|
+
title: "Bring an entry back from the trash",
|
|
4230
|
+
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.",
|
|
4231
|
+
input: z.strictObject({ id: entryId }),
|
|
4232
|
+
output: z.object({
|
|
4233
|
+
outcome: z.enum([
|
|
4234
|
+
"restored",
|
|
4235
|
+
"not_trashed",
|
|
4236
|
+
"not_found"
|
|
4237
|
+
]),
|
|
4238
|
+
entry: entrySummary.optional()
|
|
4239
|
+
}),
|
|
4240
|
+
scopes: ["content:write"],
|
|
4241
|
+
effect: "write",
|
|
4242
|
+
outcomes: {
|
|
4243
|
+
restored: { isError: false },
|
|
4244
|
+
not_trashed: { isError: true },
|
|
4245
|
+
not_found: { isError: true }
|
|
4246
|
+
}
|
|
4247
|
+
});
|
|
4175
4248
|
const listReviews = defineOperation({
|
|
4176
4249
|
name: "list_reviews",
|
|
4177
4250
|
title: "List the reviews",
|
|
@@ -4487,6 +4560,7 @@ const OPERATIONS = [
|
|
|
4487
4560
|
contentStats,
|
|
4488
4561
|
createDraft,
|
|
4489
4562
|
createSite,
|
|
4563
|
+
deleteEntry,
|
|
4490
4564
|
deleteSite,
|
|
4491
4565
|
describeSite,
|
|
4492
4566
|
getEntry,
|
|
@@ -4518,6 +4592,7 @@ const OPERATIONS = [
|
|
|
4518
4592
|
publish,
|
|
4519
4593
|
requestChanges,
|
|
4520
4594
|
requestReview,
|
|
4595
|
+
restoreEntry,
|
|
4521
4596
|
restoreSite,
|
|
4522
4597
|
search,
|
|
4523
4598
|
setCollection,
|
|
@@ -4638,6 +4713,63 @@ async function verifyWebhook(check) {
|
|
|
4638
4713
|
return sameText(signature, await signWebhook(check.secret, timestamp, check.body));
|
|
4639
4714
|
}
|
|
4640
4715
|
//#endregion
|
|
4716
|
+
//#region src/events.ts
|
|
4717
|
+
/**
|
|
4718
|
+
* What Hollware sends to a site's webhooks (ADR-025), signed as `signWebhook` signs it: an event
|
|
4719
|
+
* about an entry that was published or unpublished, or about a change of the site's structure
|
|
4720
|
+
* (ADR-033). Every event carries the cache tags it invalidates (`@hollware/core`'s cache tags), so
|
|
4721
|
+
* a site that caches its pages purges them with no lookup. A receiver checks the signature with
|
|
4722
|
+
* `verifyWebhook`, then reads the body with `parseHollwareEvent`. Fields may be added in a minor
|
|
4723
|
+
* version, and kinds too: a receiver ignores a kind it does not know.
|
|
4724
|
+
*/
|
|
4725
|
+
const base = {
|
|
4726
|
+
/** The id of the event: the same on every retry of its delivery. */
|
|
4727
|
+
id: z.string(),
|
|
4728
|
+
/** The key of the site. */
|
|
4729
|
+
site: z.string(),
|
|
4730
|
+
/** When the change was written, in ISO 8601. */
|
|
4731
|
+
at: z.string(),
|
|
4732
|
+
/** The cache tags the change invalidates. */
|
|
4733
|
+
cacheTags: z.array(z.string())
|
|
4734
|
+
};
|
|
4735
|
+
/** A publish or an unpublish: the entry, and where it lives on the site. */
|
|
4736
|
+
const entryEvent = z.object({
|
|
4737
|
+
...base,
|
|
4738
|
+
kind: z.enum(["entry.published", "entry.unpublished"]),
|
|
4739
|
+
entry: z.object({
|
|
4740
|
+
id: z.string(),
|
|
4741
|
+
collection: z.string(),
|
|
4742
|
+
locale: z.string(),
|
|
4743
|
+
slug: z.string(),
|
|
4744
|
+
translationGroup: z.string().nullable()
|
|
4745
|
+
}),
|
|
4746
|
+
/** The path of the entry on the site, from the route of its collection and locale. */
|
|
4747
|
+
path: z.string().nullable(),
|
|
4748
|
+
/** The URL of the entry, when the site has a `url`. */
|
|
4749
|
+
url: z.string().nullable()
|
|
4750
|
+
});
|
|
4751
|
+
/** A change of the site's structure: no entry, the tags to purge. */
|
|
4752
|
+
const siteEvent = z.object({
|
|
4753
|
+
...base,
|
|
4754
|
+
kind: z.literal("site.changed")
|
|
4755
|
+
});
|
|
4756
|
+
/** Every event a webhook gets. */
|
|
4757
|
+
const hollwareEvent = z.discriminatedUnion("kind", [entryEvent, siteEvent]);
|
|
4758
|
+
/**
|
|
4759
|
+
* The event of a webhook's body, once its signature holds, or `null` for a body that is not one
|
|
4760
|
+
* of the events this version knows (a newer kind, say): a receiver skips it.
|
|
4761
|
+
*/
|
|
4762
|
+
function parseHollwareEvent(body) {
|
|
4763
|
+
let json;
|
|
4764
|
+
try {
|
|
4765
|
+
json = JSON.parse(body);
|
|
4766
|
+
} catch {
|
|
4767
|
+
return null;
|
|
4768
|
+
}
|
|
4769
|
+
const parsed = hollwareEvent.safeParse(json);
|
|
4770
|
+
return parsed.success ? parsed.data : null;
|
|
4771
|
+
}
|
|
4772
|
+
//#endregion
|
|
4641
4773
|
//#region src/cache.ts
|
|
4642
4774
|
/**
|
|
4643
4775
|
* The cache tags of Hollware's content (S8): one name for each thing a page can show, so that a
|
|
@@ -4688,8 +4820,19 @@ function tagsOfChange(entry) {
|
|
|
4688
4820
|
...entry.translationGroup === null ? [] : [groupTag(entry.collection, entry.translationGroup)]
|
|
4689
4821
|
];
|
|
4690
4822
|
}
|
|
4823
|
+
/**
|
|
4824
|
+
* The tags a change of a site's structure invalidates (ADR-033): the pages that show the structure
|
|
4825
|
+
* (`site`, such as a navigation), and every list of its collections in each locale and in every
|
|
4826
|
+
* locale, as a list may change with a schema, a route or a collection that goes. `collections` and
|
|
4827
|
+
* `locales` are those of the definition before and after the change, together.
|
|
4828
|
+
*/
|
|
4829
|
+
function tagsOfSiteChange(collections, locales) {
|
|
4830
|
+
const tags = /* @__PURE__ */ new Set([SITE_TAG]);
|
|
4831
|
+
for (const collection of collections) for (const locale of [...locales, "*"]) tags.add(listTag(collection, locale));
|
|
4832
|
+
return [...tags];
|
|
4833
|
+
}
|
|
4691
4834
|
//#endregion
|
|
4692
4835
|
//#region src/index.ts
|
|
4693
4836
|
const name = "@hollware/core";
|
|
4694
4837
|
//#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 };
|
|
4838
|
+
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, INDEXNOW_KEY, 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, deleteEntry, describeBuiltInContracts, describeCatalog, describeContract, describeResolvedSite, describeSite, describedSchemaOf, entryEvent, entryTag, generatePersonalToken, generateReadToken, getEntry, getTranslations, groupTag, hashPersonalToken, hollwareEvent, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, listTag, 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, verifyWebhook, webhookDefinition };
|