@taprootio/site-authoring 0.11.1 → 0.12.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/README.md +15 -3
- package/examples/riverbend-wellness/manifest.fixture.json +56 -0
- package/examples/riverbend-wellness/pages/green-smoothie.md +28 -0
- package/examples/riverbend-wellness/pages/journal-welcome.md +13 -0
- package/examples/riverbend-wellness/pages/juice-bar.md +11 -0
- package/examples/riverbend-wellness/pages/studio-tour.pm.json +34 -0
- package/package.json +1 -1
- package/src/api.js +3 -9
- package/src/cli.js +2 -0
- package/src/content/components.js +0 -6
- package/src/reference-help.js +89 -15
- package/src/typed-page-reference.js +339 -0
- package/src/typed-pages.js +952 -0
- package/src/verbs/delivery-check.js +23 -3
- package/src/verbs/pages-push.js +200 -61
- package/src/verbs/pull.js +47 -20
- package/src/verbs/validate.js +24 -3
- package/src/workspace.js +42 -8
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import { FREE_FORM_TEMPLATE_VERSION, TEMPLATE_TYPE_FREE_FORM } from "./api.js";
|
|
2
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The authored page templates (TR00893).
|
|
6
|
+
*
|
|
7
|
+
* A page has one template for life, and each template has one *workspace
|
|
8
|
+
* document*: the object every reader and writer in this package agrees on.
|
|
9
|
+
* Free-form keeps its historical shape (the ProseMirror document itself). The
|
|
10
|
+
* other templates use a small wrapper:
|
|
11
|
+
*
|
|
12
|
+
* { "template": "recipe", "displayDate": "2024-05-01", "coverImageId": "…", "data": { … } }
|
|
13
|
+
*
|
|
14
|
+
* `pull` writes that wrapper, a Markdown source is converted into it, and
|
|
15
|
+
* `pages push` turns it into the API's `PageTemplate`. Because all three meet
|
|
16
|
+
* at the same object, a pull-edit-push cycle changes only what the agent
|
|
17
|
+
* changed, and there is one place that knows each template's shape.
|
|
18
|
+
*
|
|
19
|
+
* `data` follows the API's field names (`Pages.proto`) so nothing needs a
|
|
20
|
+
* translation table; the two deliberate differences are that defaults are
|
|
21
|
+
* always written out (the API's JSON omits them, and a document that gained
|
|
22
|
+
* and lost keys between reads would never be byte-stable) and that a place
|
|
23
|
+
* review's rating uses the words an author writes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const TEMPLATE_FREE_FORM = "free-form";
|
|
27
|
+
export const TEMPLATE_ARTICLE = "article";
|
|
28
|
+
export const TEMPLATE_RECIPE = "recipe";
|
|
29
|
+
export const TEMPLATE_ALBUM = "album";
|
|
30
|
+
export const TEMPLATE_PLACE_REVIEW = "place-review";
|
|
31
|
+
|
|
32
|
+
/** Wire identity and the `PageTemplate` member that carries each template's data. */
|
|
33
|
+
export const PAGE_TEMPLATES = Object.freeze({
|
|
34
|
+
[TEMPLATE_FREE_FORM]: Object.freeze({ wireType: TEMPLATE_TYPE_FREE_FORM, dataKey: "freeFormData" }),
|
|
35
|
+
[TEMPLATE_ARTICLE]: Object.freeze({ wireType: "TEMPLATE_TYPE_ARTICLE", dataKey: "articleData" }),
|
|
36
|
+
[TEMPLATE_RECIPE]: Object.freeze({ wireType: "TEMPLATE_TYPE_RECIPE", dataKey: "recipeData" }),
|
|
37
|
+
[TEMPLATE_ALBUM]: Object.freeze({ wireType: "TEMPLATE_TYPE_ALBUM", dataKey: "albumData" }),
|
|
38
|
+
[TEMPLATE_PLACE_REVIEW]: Object.freeze({ wireType: "TEMPLATE_TYPE_PLACE_REVIEW", dataKey: "placeReviewData" }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const PAGE_TEMPLATE_NAMES = Object.freeze(Object.keys(PAGE_TEMPLATES));
|
|
42
|
+
|
|
43
|
+
/** Front-matter fields every template accepts. */
|
|
44
|
+
export const SHARED_FRONT_MATTER_KEYS = Object.freeze(["title", "path", "description", "template"]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The page-level fields the other templates add. A free-form page's source is
|
|
48
|
+
* its bare document, which has nowhere to carry them, so it does not accept
|
|
49
|
+
* them.
|
|
50
|
+
*/
|
|
51
|
+
const TYPED_PAGE_FRONT_MATTER_KEYS = Object.freeze(["displayDate", "coverImage"]);
|
|
52
|
+
|
|
53
|
+
/** Front-matter fields only one template accepts. Others are refused for it. */
|
|
54
|
+
const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
|
|
55
|
+
[TEMPLATE_FREE_FORM]: Object.freeze([]),
|
|
56
|
+
[TEMPLATE_ARTICLE]: TYPED_PAGE_FRONT_MATTER_KEYS,
|
|
57
|
+
[TEMPLATE_RECIPE]: Object.freeze([
|
|
58
|
+
...TYPED_PAGE_FRONT_MATTER_KEYS,
|
|
59
|
+
"prepTimeMinutes",
|
|
60
|
+
"cookTimeMinutes",
|
|
61
|
+
"servings",
|
|
62
|
+
"recipeCategory",
|
|
63
|
+
"recipeCuisine",
|
|
64
|
+
"cookingMethod",
|
|
65
|
+
]),
|
|
66
|
+
[TEMPLATE_ALBUM]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "seamless", "borderWidth"]),
|
|
67
|
+
[TEMPLATE_PLACE_REVIEW]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "placeId", "rating"]),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const FRONT_MATTER_KEYS = Object.freeze(
|
|
71
|
+
new Set([...SHARED_FRONT_MATTER_KEYS, ...Object.values(TEMPLATE_FRONT_MATTER_KEYS).flat()]),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/** The accepted front-matter fields for one template, for messages and help. */
|
|
75
|
+
export function frontMatterKeysFor(template) {
|
|
76
|
+
return [...SHARED_FRONT_MATTER_KEYS, ...(TEMPLATE_FRONT_MATTER_KEYS[template] ?? [])];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const PLACE_REVIEW_RATINGS = Object.freeze({
|
|
80
|
+
"wont-return": "PLACE_REVIEW_RATING_WONT_RETURN",
|
|
81
|
+
"might-return": "PLACE_REVIEW_RATING_MIGHT_RETURN",
|
|
82
|
+
"will-return": "PLACE_REVIEW_RATING_WILL_RETURN",
|
|
83
|
+
});
|
|
84
|
+
const RATING_NAME_BY_WIRE = Object.freeze(
|
|
85
|
+
Object.fromEntries(Object.entries(PLACE_REVIEW_RATINGS).map(([name, wire]) => [wire, name])),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Not `isCanonicalUuid`: that accepts only RFC 4122 versions 1 to 5, and place
|
|
89
|
+
// and image ids are minted by the server, which is free to use any version.
|
|
90
|
+
const IDENTIFIER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
91
|
+
const DISPLAY_DATE = /^\d{4}-\d{2}-\d{2}$/u;
|
|
92
|
+
const ALBUM_IMAGES_HEADING = "images";
|
|
93
|
+
const RECIPE_HEADINGS = Object.freeze(["ingredients", "instructions"]);
|
|
94
|
+
|
|
95
|
+
function fail(code, message, field) {
|
|
96
|
+
return new SiteAuthoringError(code, message, { field });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isPlainObject(value) {
|
|
100
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Template identity ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** The template name for a wire `templateType`, or `undefined` when it is not authorable here. */
|
|
106
|
+
export function templateNameForWireType(wireType) {
|
|
107
|
+
return PAGE_TEMPLATE_NAMES.find((name) => PAGE_TEMPLATES[name].wireType === wireType);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether `pull` and `pages push` handle pages of this wire `templateType`. */
|
|
111
|
+
export function isAuthorableTemplateType(wireType) {
|
|
112
|
+
return templateNameForWireType(wireType) !== undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The template a canonical workspace document represents. */
|
|
116
|
+
export function documentTemplate(document_) {
|
|
117
|
+
return isPlainObject(document_) && typeof document_.template === "string" && document_.type === undefined
|
|
118
|
+
? document_.template
|
|
119
|
+
: TEMPLATE_FREE_FORM;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function requireTemplateName(value, file) {
|
|
123
|
+
if (typeof value === "string" && Object.hasOwn(PAGE_TEMPLATES, value)) return value;
|
|
124
|
+
throw fail(
|
|
125
|
+
"pages.template_unknown",
|
|
126
|
+
`'${file}' declares template '${typeof value === "string" ? value : ""}'. Supported templates: `
|
|
127
|
+
+ `${PAGE_TEMPLATE_NAMES.join(", ")}.`,
|
|
128
|
+
file,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Strict shape helpers (source documents) ──────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function requireKeys(value, allowed, file, path) {
|
|
135
|
+
if (!isPlainObject(value)) throw fail("pages.document_shape", `'${file}' ${path} must be an object.`, file);
|
|
136
|
+
const unknown = Object.keys(value).find((key) => !allowed.includes(key));
|
|
137
|
+
if (unknown !== undefined) {
|
|
138
|
+
throw fail(
|
|
139
|
+
"pages.document_shape",
|
|
140
|
+
`'${file}' ${path} has unsupported field '${unknown}'. Supported fields: ${allowed.join(", ")}.`,
|
|
141
|
+
`${file}:${path}.${unknown}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function requireString(value, file, path, { allowEmpty = true } = {}) {
|
|
148
|
+
if (typeof value !== "string" || (!allowEmpty && value.trim() === "")) {
|
|
149
|
+
throw fail(
|
|
150
|
+
"pages.document_shape",
|
|
151
|
+
`'${file}' ${path} must be ${allowEmpty ? "a string" : "a non-empty string"}.`,
|
|
152
|
+
file,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function requireCount(value, file, path, maximum) {
|
|
159
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
|
160
|
+
throw fail("pages.document_shape", `'${file}' ${path} must be an integer from 0 to ${maximum}.`, file);
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function requireDocument(value, file, path, { nonEmpty = false } = {}) {
|
|
166
|
+
if (!isPlainObject(value) || value.type !== "doc") {
|
|
167
|
+
throw fail(
|
|
168
|
+
"pages.document_shape",
|
|
169
|
+
`'${file}' ${path} must be a ProseMirror document: an object with "type": "doc".`,
|
|
170
|
+
file,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
// The server refuses an empty body only after earlier pages were sent, so the
|
|
174
|
+
// same rule is applied here, before anything is.
|
|
175
|
+
if (nonEmpty && (!Array.isArray(value.content) || value.content.length === 0)) {
|
|
176
|
+
throw fail("pages.body_empty", `'${file}' ${path} has no content.`, file);
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function requireArray(value, file, path) {
|
|
182
|
+
if (!Array.isArray(value)) throw fail("pages.document_shape", `'${file}' ${path} must be a list.`, file);
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function requireIdentifier(value, file, path) {
|
|
187
|
+
if (typeof value !== "string" || !IDENTIFIER.test(value)) {
|
|
188
|
+
throw fail("pages.document_shape", `'${file}' ${path} must be a UUID.`, file);
|
|
189
|
+
}
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The API parses the date as a .NET DateOnly, whose years run 0001 to 9999. The
|
|
194
|
+
// calendar is checked by hand because `Date` would accept year 0 and throws on
|
|
195
|
+
// a month of 13, which is a crash where a refusal is wanted.
|
|
196
|
+
function isCalendarDate(value) {
|
|
197
|
+
if (typeof value !== "string" || !DISPLAY_DATE.test(value)) return false;
|
|
198
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
199
|
+
if (year < 1 || month < 1 || month > 12 || day < 1) return false;
|
|
200
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
201
|
+
const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
202
|
+
return day <= daysInMonth;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function requireDisplayDate(value, file) {
|
|
206
|
+
if (value === "") return value;
|
|
207
|
+
if (!isCalendarDate(value)) {
|
|
208
|
+
throw fail(
|
|
209
|
+
"pages.display_date_invalid",
|
|
210
|
+
`'${file}' declares displayDate '${typeof value === "string" ? value : ""}', which is not a real calendar date. `
|
|
211
|
+
+ "Use YYYY-MM-DD, or leave the value empty to use the first publication date.",
|
|
212
|
+
file,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Canonical data per template ──────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
const INGREDIENT_KEYS = ["rawText", "quantity", "unit", "name", "parsed"];
|
|
221
|
+
const RECIPE_KEYS = [
|
|
222
|
+
"introductionBody",
|
|
223
|
+
"ingredientGroups",
|
|
224
|
+
"instructionSections",
|
|
225
|
+
"prepTimeMinutes",
|
|
226
|
+
"cookTimeMinutes",
|
|
227
|
+
"servings",
|
|
228
|
+
"recipeCategory",
|
|
229
|
+
"recipeCuisine",
|
|
230
|
+
"cookingMethod",
|
|
231
|
+
];
|
|
232
|
+
const ALBUM_KEYS = ["introductionBody", "images", "seamless", "borderWidth"];
|
|
233
|
+
const ALBUM_IMAGE_KEYS = ["imageId", "caption", "width", "height"];
|
|
234
|
+
const ALBUM_MAXIMUM_BORDER_WIDTH = 20;
|
|
235
|
+
const MAXIMUM_MINUTES = 100_000;
|
|
236
|
+
const MAXIMUM_SERVINGS = 10_000;
|
|
237
|
+
|
|
238
|
+
function definedEntries(entries) {
|
|
239
|
+
return Object.fromEntries(entries.filter(([, value]) => value !== undefined));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function canonicalIngredient(value) {
|
|
243
|
+
return {
|
|
244
|
+
rawText: value.rawText,
|
|
245
|
+
quantity: value.quantity ?? "",
|
|
246
|
+
unit: value.unit ?? "",
|
|
247
|
+
name: value.name ?? "",
|
|
248
|
+
parsed: value.parsed === true,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Builds the canonical `data` object from either a source document (strict:
|
|
254
|
+
* unknown fields are refused, because silently dropping an authored field is
|
|
255
|
+
* how content disappears) or a page read from the site (lenient, since the
|
|
256
|
+
* server's JSON omits defaults and may gain fields).
|
|
257
|
+
*/
|
|
258
|
+
const DATA_BUILDERS = Object.freeze({
|
|
259
|
+
[TEMPLATE_ARTICLE]: (raw) => ({ body: raw.body }),
|
|
260
|
+
[TEMPLATE_PLACE_REVIEW]: (raw) => ({ placeId: raw.placeId, rating: raw.rating, body: raw.body }),
|
|
261
|
+
[TEMPLATE_ALBUM]: (raw) =>
|
|
262
|
+
definedEntries([
|
|
263
|
+
["introductionBody", raw.introductionBody],
|
|
264
|
+
[
|
|
265
|
+
"images",
|
|
266
|
+
raw.images.map((image) => ({
|
|
267
|
+
imageId: image.imageId,
|
|
268
|
+
caption: image.caption ?? "",
|
|
269
|
+
width: image.width ?? 0,
|
|
270
|
+
height: image.height ?? 0,
|
|
271
|
+
})),
|
|
272
|
+
],
|
|
273
|
+
["seamless", raw.seamless],
|
|
274
|
+
["borderWidth", raw.borderWidth],
|
|
275
|
+
]),
|
|
276
|
+
[TEMPLATE_RECIPE]: (raw) =>
|
|
277
|
+
definedEntries([
|
|
278
|
+
["introductionBody", raw.introductionBody],
|
|
279
|
+
[
|
|
280
|
+
"ingredientGroups",
|
|
281
|
+
raw.ingredientGroups.map((group) => ({
|
|
282
|
+
name: group.name ?? "",
|
|
283
|
+
ingredients: group.ingredients.map(canonicalIngredient),
|
|
284
|
+
})),
|
|
285
|
+
],
|
|
286
|
+
[
|
|
287
|
+
"instructionSections",
|
|
288
|
+
raw.instructionSections.map((section) => ({
|
|
289
|
+
name: section.name ?? "",
|
|
290
|
+
stepBodies: section.stepBodies,
|
|
291
|
+
})),
|
|
292
|
+
],
|
|
293
|
+
["prepTimeMinutes", raw.prepTimeMinutes ?? 0],
|
|
294
|
+
["cookTimeMinutes", raw.cookTimeMinutes ?? 0],
|
|
295
|
+
["servings", raw.servings ?? 0],
|
|
296
|
+
["recipeCategory", raw.recipeCategory ?? ""],
|
|
297
|
+
["recipeCuisine", raw.recipeCuisine ?? ""],
|
|
298
|
+
["cookingMethod", raw.cookingMethod ?? ""],
|
|
299
|
+
]),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const OPTIONAL_INTRODUCTION = (value, file, path) =>
|
|
303
|
+
value === undefined ? undefined : requireDocument(value, file, path);
|
|
304
|
+
|
|
305
|
+
/** Strict checks on a source document's `data`, returning it in canonical form. */
|
|
306
|
+
function canonicalSourceData(template, data, file) {
|
|
307
|
+
const path = "data";
|
|
308
|
+
if (template === TEMPLATE_ARTICLE) {
|
|
309
|
+
requireKeys(data, ["body"], file, path);
|
|
310
|
+
return DATA_BUILDERS[template]({ body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }) });
|
|
311
|
+
}
|
|
312
|
+
if (template === TEMPLATE_PLACE_REVIEW) {
|
|
313
|
+
requireKeys(data, ["placeId", "rating", "body"], file, path);
|
|
314
|
+
requireIdentifier(data.placeId, file, `${path}.placeId`);
|
|
315
|
+
if (!Object.hasOwn(PLACE_REVIEW_RATINGS, data.rating)) {
|
|
316
|
+
throw fail(
|
|
317
|
+
"pages.document_shape",
|
|
318
|
+
`'${file}' ${path}.rating must be one of ${Object.keys(PLACE_REVIEW_RATINGS).join(", ")}.`,
|
|
319
|
+
file,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return DATA_BUILDERS[template]({
|
|
323
|
+
...data,
|
|
324
|
+
body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (template === TEMPLATE_ALBUM) {
|
|
328
|
+
requireKeys(data, ALBUM_KEYS, file, path);
|
|
329
|
+
const images = requireArray(data.images, file, `${path}.images`);
|
|
330
|
+
if (images.length === 0) {
|
|
331
|
+
throw fail("pages.album_images_missing", `'${file}' is an album with no images.`, file);
|
|
332
|
+
}
|
|
333
|
+
images.forEach((image, index) => {
|
|
334
|
+
const at = `${path}.images[${index}]`;
|
|
335
|
+
requireKeys(image, ALBUM_IMAGE_KEYS, file, at);
|
|
336
|
+
requireIdentifier(image.imageId, file, `${at}.imageId`);
|
|
337
|
+
if (image.caption !== undefined) requireString(image.caption, file, `${at}.caption`);
|
|
338
|
+
if (image.width !== undefined) requireCount(image.width, file, `${at}.width`, 100_000);
|
|
339
|
+
if (image.height !== undefined) requireCount(image.height, file, `${at}.height`, 100_000);
|
|
340
|
+
});
|
|
341
|
+
if (data.seamless !== undefined && typeof data.seamless !== "boolean") {
|
|
342
|
+
throw fail("pages.document_shape", `'${file}' ${path}.seamless must be true or false.`, file);
|
|
343
|
+
}
|
|
344
|
+
if (data.borderWidth !== undefined) {
|
|
345
|
+
requireCount(data.borderWidth, file, `${path}.borderWidth`, ALBUM_MAXIMUM_BORDER_WIDTH);
|
|
346
|
+
}
|
|
347
|
+
return DATA_BUILDERS[template]({
|
|
348
|
+
...data,
|
|
349
|
+
introductionBody: OPTIONAL_INTRODUCTION(data.introductionBody, file, `${path}.introductionBody`),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
requireKeys(data, RECIPE_KEYS, file, path);
|
|
353
|
+
const ingredientGroups = requireArray(data.ingredientGroups, file, `${path}.ingredientGroups`);
|
|
354
|
+
ingredientGroups.forEach((group, groupIndex) => {
|
|
355
|
+
const at = `${path}.ingredientGroups[${groupIndex}]`;
|
|
356
|
+
requireKeys(group, ["name", "ingredients"], file, at);
|
|
357
|
+
if (group.name !== undefined) requireString(group.name, file, `${at}.name`);
|
|
358
|
+
requireArray(group.ingredients, file, `${at}.ingredients`).forEach((ingredient, index) => {
|
|
359
|
+
const ingredientAt = `${at}.ingredients[${index}]`;
|
|
360
|
+
requireKeys(ingredient, INGREDIENT_KEYS, file, ingredientAt);
|
|
361
|
+
requireString(ingredient.rawText, file, `${ingredientAt}.rawText`, { allowEmpty: false });
|
|
362
|
+
for (const key of ["quantity", "unit", "name"]) {
|
|
363
|
+
if (ingredient[key] !== undefined) requireString(ingredient[key], file, `${ingredientAt}.${key}`);
|
|
364
|
+
}
|
|
365
|
+
if (ingredient.parsed !== undefined && typeof ingredient.parsed !== "boolean") {
|
|
366
|
+
throw fail("pages.document_shape", `'${file}' ${ingredientAt}.parsed must be true or false.`, file);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
const instructionSections = requireArray(data.instructionSections, file, `${path}.instructionSections`);
|
|
371
|
+
instructionSections.forEach((section, sectionIndex) => {
|
|
372
|
+
const at = `${path}.instructionSections[${sectionIndex}]`;
|
|
373
|
+
requireKeys(section, ["name", "stepBodies"], file, at);
|
|
374
|
+
if (section.name !== undefined) requireString(section.name, file, `${at}.name`);
|
|
375
|
+
requireArray(section.stepBodies, file, `${at}.stepBodies`).forEach((step, index) =>
|
|
376
|
+
requireDocument(step, file, `${at}.stepBodies[${index}]`, { nonEmpty: true })
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
const hasIngredient = ingredientGroups.some((group) => group.ingredients.length > 0);
|
|
380
|
+
const hasStep = instructionSections.some((section) => section.stepBodies.length > 0);
|
|
381
|
+
if (!hasIngredient || !hasStep) {
|
|
382
|
+
throw fail(
|
|
383
|
+
"pages.recipe_incomplete",
|
|
384
|
+
`'${file}' is a recipe, which needs at least one ingredient and one instruction step.`,
|
|
385
|
+
file,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (data.prepTimeMinutes !== undefined) {
|
|
389
|
+
requireCount(data.prepTimeMinutes, file, `${path}.prepTimeMinutes`, MAXIMUM_MINUTES);
|
|
390
|
+
}
|
|
391
|
+
if (data.cookTimeMinutes !== undefined) {
|
|
392
|
+
requireCount(data.cookTimeMinutes, file, `${path}.cookTimeMinutes`, MAXIMUM_MINUTES);
|
|
393
|
+
}
|
|
394
|
+
if (data.servings !== undefined) requireCount(data.servings, file, `${path}.servings`, MAXIMUM_SERVINGS);
|
|
395
|
+
for (const key of ["recipeCategory", "recipeCuisine", "cookingMethod"]) {
|
|
396
|
+
if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
|
|
397
|
+
}
|
|
398
|
+
return DATA_BUILDERS[template]({
|
|
399
|
+
...data,
|
|
400
|
+
introductionBody: OPTIONAL_INTRODUCTION(data.introductionBody, file, `${path}.introductionBody`),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The canonical workspace document for a typed page, with keys in one fixed
|
|
406
|
+
* order. The order is part of the byte-stability contract.
|
|
407
|
+
*/
|
|
408
|
+
function typedDocument(template, { displayDate, coverImageId, data }) {
|
|
409
|
+
return {
|
|
410
|
+
template,
|
|
411
|
+
...(displayDate === undefined ? {} : { displayDate }),
|
|
412
|
+
...(coverImageId === undefined ? {} : { coverImageId }),
|
|
413
|
+
data,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Validates a parsed `.pm.json` that declares a template, and returns its canonical form. */
|
|
418
|
+
export function typedDocumentFromJson(parsed, file) {
|
|
419
|
+
requireKeys(parsed, ["template", "displayDate", "coverImageId", "data"], file, "document");
|
|
420
|
+
const template = requireTemplateName(parsed.template, file);
|
|
421
|
+
if (template === TEMPLATE_FREE_FORM) {
|
|
422
|
+
throw fail(
|
|
423
|
+
"pages.document_shape",
|
|
424
|
+
`'${file}' declares the free-form template, whose source is the ProseMirror document itself, `
|
|
425
|
+
+ "not a wrapper.",
|
|
426
|
+
file,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const displayDate = parsed.displayDate === undefined ? undefined : requireDisplayDate(parsed.displayDate, file);
|
|
430
|
+
let coverImageId;
|
|
431
|
+
if (parsed.coverImageId !== undefined) {
|
|
432
|
+
coverImageId = parsed.coverImageId === "" ? "" : requireIdentifier(parsed.coverImageId, file, "coverImageId");
|
|
433
|
+
}
|
|
434
|
+
return typedDocument(template, {
|
|
435
|
+
displayDate,
|
|
436
|
+
coverImageId,
|
|
437
|
+
data: canonicalSourceData(template, parsed.data, file),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── The site's page → the workspace document (pull) ──────────────────────────
|
|
442
|
+
|
|
443
|
+
const ratingName = (wire) => RATING_NAME_BY_WIRE[wire];
|
|
444
|
+
const presentDocument = (value) => (isPlainObject(value) && value.type === "doc" ? value : undefined);
|
|
445
|
+
// The site answers a missing introduction with an empty document, so an empty
|
|
446
|
+
// one reads as "no introduction". Keeping it would make an authored page and
|
|
447
|
+
// its own pulled copy differ.
|
|
448
|
+
const presentIntroduction = (value) => {
|
|
449
|
+
const doc = presentDocument(value);
|
|
450
|
+
return doc !== undefined && Array.isArray(doc.content) && doc.content.length === 0 ? undefined : doc;
|
|
451
|
+
};
|
|
452
|
+
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
453
|
+
|
|
454
|
+
/** Lenient projections of what the site returned for each template's data. */
|
|
455
|
+
const PAGE_PROJECTIONS = Object.freeze({
|
|
456
|
+
[TEMPLATE_ARTICLE]: (raw) => {
|
|
457
|
+
const body = presentDocument(raw.body);
|
|
458
|
+
return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body });
|
|
459
|
+
},
|
|
460
|
+
[TEMPLATE_PLACE_REVIEW]: (raw) => {
|
|
461
|
+
const body = presentDocument(raw.body);
|
|
462
|
+
const rating = ratingName(raw.rating);
|
|
463
|
+
if (body === undefined || rating === undefined || typeof raw.placeId !== "string") return undefined;
|
|
464
|
+
return DATA_BUILDERS[TEMPLATE_PLACE_REVIEW]({ placeId: raw.placeId, rating, body });
|
|
465
|
+
},
|
|
466
|
+
[TEMPLATE_ALBUM]: (raw) =>
|
|
467
|
+
DATA_BUILDERS[TEMPLATE_ALBUM]({
|
|
468
|
+
introductionBody: presentIntroduction(raw.introductionBody),
|
|
469
|
+
images: listOf(raw.images).filter((image) => isPlainObject(image) && typeof image.imageId === "string"),
|
|
470
|
+
seamless: typeof raw.seamless === "boolean" ? raw.seamless : undefined,
|
|
471
|
+
borderWidth: Number.isSafeInteger(raw.borderWidth) ? raw.borderWidth : undefined,
|
|
472
|
+
}),
|
|
473
|
+
[TEMPLATE_RECIPE]: (raw) =>
|
|
474
|
+
DATA_BUILDERS[TEMPLATE_RECIPE]({
|
|
475
|
+
...raw,
|
|
476
|
+
introductionBody: presentIntroduction(raw.introductionBody),
|
|
477
|
+
ingredientGroups: listOf(raw.ingredientGroups).filter(isPlainObject).map((group) => ({
|
|
478
|
+
name: group.name,
|
|
479
|
+
ingredients: listOf(group.ingredients).filter((item) =>
|
|
480
|
+
isPlainObject(item) && typeof item.rawText === "string"
|
|
481
|
+
),
|
|
482
|
+
})),
|
|
483
|
+
instructionSections: listOf(raw.instructionSections).filter(isPlainObject).map((section) => ({
|
|
484
|
+
name: section.name,
|
|
485
|
+
stepBodies: listOf(section.stepBodies).map(presentDocument).filter((step) => step !== undefined),
|
|
486
|
+
})),
|
|
487
|
+
}),
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The workspace document for a page read from the site, or `undefined` when the
|
|
492
|
+
* read carries nothing this package can represent. Free-form pages keep their
|
|
493
|
+
* bare body exactly as stored: the server never validates it, and a body this
|
|
494
|
+
* package would refuse to push is still the site's, so pull must not hide it.
|
|
495
|
+
* The listing's template type stands in when the read does not state one.
|
|
496
|
+
*/
|
|
497
|
+
export function workspaceDocumentFromPage(page, listedTemplateType) {
|
|
498
|
+
const template = page?.template;
|
|
499
|
+
if (!isPlainObject(template)) return undefined;
|
|
500
|
+
const name = templateNameForWireType(template.templateType ?? listedTemplateType);
|
|
501
|
+
if (name === undefined) return undefined;
|
|
502
|
+
const raw = template[PAGE_TEMPLATES[name].dataKey];
|
|
503
|
+
if (!isPlainObject(raw)) return undefined;
|
|
504
|
+
if (name === TEMPLATE_FREE_FORM) return isPlainObject(raw.body) ? raw.body : undefined;
|
|
505
|
+
const data = PAGE_PROJECTIONS[name](raw);
|
|
506
|
+
if (data === undefined) return undefined;
|
|
507
|
+
return typedDocument(name, {
|
|
508
|
+
displayDate: typeof page.displayDate === "string" && page.displayDate !== "" ? page.displayDate : undefined,
|
|
509
|
+
coverImageId: typeof page.coverImageId === "string" && page.coverImageId !== "" ? page.coverImageId : undefined,
|
|
510
|
+
data,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── The workspace document → the wire (push) ─────────────────────────────────
|
|
515
|
+
|
|
516
|
+
/** Every ProseMirror document in a workspace document, with where it sits, for validation. */
|
|
517
|
+
export function contentDocuments(document_) {
|
|
518
|
+
const template = documentTemplate(document_);
|
|
519
|
+
if (template === TEMPLATE_FREE_FORM) return [{ doc: document_, path: "" }];
|
|
520
|
+
const { data } = document_;
|
|
521
|
+
const found = [];
|
|
522
|
+
const add = (doc, path, options = {}) => {
|
|
523
|
+
if (doc !== undefined) found.push({ doc, path, ...options });
|
|
524
|
+
};
|
|
525
|
+
add(data.body, "data.body");
|
|
526
|
+
// The server accepts an empty introduction (the editor saves one for every
|
|
527
|
+
// recipe and album that has none), so it is validated but may be blank. It,
|
|
528
|
+
// and every recipe step, also accepts a narrower set of nodes than a body.
|
|
529
|
+
add(data.introductionBody, "data.introductionBody", { mayBeEmpty: true, restricted: true });
|
|
530
|
+
for (const [sectionIndex, section] of (data.instructionSections ?? []).entries()) {
|
|
531
|
+
for (const [stepIndex, step] of section.stepBodies.entries()) {
|
|
532
|
+
add(step, `data.instructionSections[${sectionIndex}].stepBodies[${stepIndex}]`, { restricted: true });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return found;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Whether a push sends `displayDate` for pages of this wire template type (every template but free-form). */
|
|
539
|
+
export function sentDisplayDate(wireType) {
|
|
540
|
+
const name = templateNameForWireType(wireType);
|
|
541
|
+
return name !== undefined && name !== TEMPLATE_FREE_FORM;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The nodes the API accepts in an album or recipe introduction and in a recipe
|
|
545
|
+
// step (`MembershipTermsDocumentRules.AllowedNodeTypes`). A page body accepts
|
|
546
|
+
// more, such as `section` and `inlineFacts`, so a document valid as a body can
|
|
547
|
+
// still be refused there, after earlier pages have been saved.
|
|
548
|
+
const RESTRICTED_NODE_TYPES = new Set([
|
|
549
|
+
"doc",
|
|
550
|
+
"paragraph",
|
|
551
|
+
"text",
|
|
552
|
+
"hardBreak",
|
|
553
|
+
"heading",
|
|
554
|
+
"bulletList",
|
|
555
|
+
"orderedList",
|
|
556
|
+
"listItem",
|
|
557
|
+
"blockquote",
|
|
558
|
+
"codeBlock",
|
|
559
|
+
"horizontalRule",
|
|
560
|
+
"taprootImage",
|
|
561
|
+
"componentBlock",
|
|
562
|
+
"table",
|
|
563
|
+
]);
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The first node type in `doc` that a restricted document (an introduction or a
|
|
567
|
+
* recipe step) cannot hold, or `undefined`. A table is allowed only directly
|
|
568
|
+
* under the document root; the content validator already checks its cells.
|
|
569
|
+
*/
|
|
570
|
+
export function unsupportedRestrictedNode(doc) {
|
|
571
|
+
const visit = (node, parentType) => {
|
|
572
|
+
if (!isPlainObject(node)) return undefined;
|
|
573
|
+
if (!RESTRICTED_NODE_TYPES.has(node.type) || (node.type === "table" && parentType !== "doc")) return node.type;
|
|
574
|
+
if (node.type === "table") return undefined;
|
|
575
|
+
for (const child of Array.isArray(node.content) ? node.content : []) {
|
|
576
|
+
const unsupported = visit(child, node.type);
|
|
577
|
+
if (unsupported !== undefined) return unsupported;
|
|
578
|
+
}
|
|
579
|
+
return undefined;
|
|
580
|
+
};
|
|
581
|
+
return visit(doc, undefined);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The API's wording when a selected cover image is not one of the page's own
|
|
586
|
+
* images (`CreatePagePipelineHandlerBase`, `UpdatePagePipelineHandlerBase`).
|
|
587
|
+
* Kept identical so the CLI's refusal and the server's read the same.
|
|
588
|
+
*/
|
|
589
|
+
export const COVER_IMAGE_UNUSED_MESSAGE = "The selected cover image must be used by this page.";
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Every image id the page uses, the way the API's `PageImageReferenceExtractor`
|
|
593
|
+
* counts them: album images, and any image node, section background or
|
|
594
|
+
* decoration, integration fragment or component data inside its documents.
|
|
595
|
+
* Read loosely (every `imageId` and `imageIds` value) because a cover the CLI
|
|
596
|
+
* wrongly thinks unused would be refused for nothing, and the server has the
|
|
597
|
+
* final say.
|
|
598
|
+
*/
|
|
599
|
+
export function usedImageIds(document_) {
|
|
600
|
+
const found = new Set();
|
|
601
|
+
const visit = (value) => {
|
|
602
|
+
if (Array.isArray(value)) {
|
|
603
|
+
value.forEach(visit);
|
|
604
|
+
} else if (isPlainObject(value)) {
|
|
605
|
+
for (const [key, child] of Object.entries(value)) {
|
|
606
|
+
if (key === "imageId" && typeof child === "string") found.add(child.toLowerCase());
|
|
607
|
+
else if (key === "imageIds" && Array.isArray(child)) {
|
|
608
|
+
child.filter((id) => typeof id === "string").forEach((id) => found.add(id.toLowerCase()));
|
|
609
|
+
} else if (key === "componentData" && typeof child === "string") {
|
|
610
|
+
// The server decodes HTML entities (up to three rounds) before reading component JSON.
|
|
611
|
+
let text = child;
|
|
612
|
+
for (let round = 0; round < 3 && /&(?:quot|amp|#39|lt|gt);/u.test(text); round += 1) {
|
|
613
|
+
text = text.replace(/"/gu, "\"").replace(/'/gu, "'").replace(/</gu, "<").replace(/>/gu, ">")
|
|
614
|
+
.replace(/&/gu, "&");
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
visit(JSON.parse(child));
|
|
618
|
+
} catch {
|
|
619
|
+
try {
|
|
620
|
+
visit(JSON.parse(text));
|
|
621
|
+
} catch { /* not JSON: nothing to read */ }
|
|
622
|
+
}
|
|
623
|
+
} else visit(child);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
for (const { doc } of contentDocuments(document_)) visit(doc);
|
|
628
|
+
for (const image of document_.data?.images ?? []) found.add(image.imageId.toLowerCase());
|
|
629
|
+
return found;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The selected cover image when the page does not use it, else `undefined`. */
|
|
633
|
+
export function unusedCoverImageId(document_) {
|
|
634
|
+
if (documentTemplate(document_) === TEMPLATE_FREE_FORM) return undefined;
|
|
635
|
+
const cover = document_.coverImageId;
|
|
636
|
+
return typeof cover === "string" && cover !== "" && !usedImageIds(document_).has(cover.toLowerCase()) ? cover : undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** The image ids a typed document references directly: its cover and its album images. */
|
|
640
|
+
export function documentImageIds(document_) {
|
|
641
|
+
if (documentTemplate(document_) === TEMPLATE_FREE_FORM) return [];
|
|
642
|
+
return [
|
|
643
|
+
...(document_.coverImageId ? [document_.coverImageId] : []),
|
|
644
|
+
...(document_.data.images ?? []).map((image) => image.imageId),
|
|
645
|
+
];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** The API `PageTemplate` for a workspace document. */
|
|
649
|
+
export function wireTemplate(document_) {
|
|
650
|
+
const template = documentTemplate(document_);
|
|
651
|
+
const { wireType, dataKey } = PAGE_TEMPLATES[template];
|
|
652
|
+
const data = template === TEMPLATE_FREE_FORM
|
|
653
|
+
? { body: document_ }
|
|
654
|
+
: template === TEMPLATE_PLACE_REVIEW
|
|
655
|
+
? { ...document_.data, rating: PLACE_REVIEW_RATINGS[document_.data.rating] }
|
|
656
|
+
: document_.data;
|
|
657
|
+
return { templateType: wireType, templateVersion: FREE_FORM_TEMPLATE_VERSION, [dataKey]: data };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The page-level fields beside the template. Omitted means "leave as it is" on
|
|
662
|
+
* an update, and an empty string means "clear", which is what the API does.
|
|
663
|
+
*/
|
|
664
|
+
export function wirePageFields(document_) {
|
|
665
|
+
if (documentTemplate(document_) === TEMPLATE_FREE_FORM) return {};
|
|
666
|
+
return definedEntries([
|
|
667
|
+
["displayDate", document_.displayDate],
|
|
668
|
+
["coverImageId", document_.coverImageId],
|
|
669
|
+
]);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── Markdown sources ─────────────────────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
const FENCE = /^\s{0,3}(```|~~~)/u;
|
|
675
|
+
const H2 = /^##[ \t]+(.+?)[ \t]*#*[ \t]*$/u;
|
|
676
|
+
const H3 = /^###[ \t]+(.+?)[ \t]*#*[ \t]*$/u;
|
|
677
|
+
const BULLET = /^[-*+][ \t]+(.*)$/u;
|
|
678
|
+
const STEP = /^(?:\d{1,3}[.)]|[-*+])[ \t]+(.*)$/u;
|
|
679
|
+
const IMAGE_LINE = /^!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)$/u;
|
|
680
|
+
const CONTINUATION = /^(?: {2,}|\t)/u;
|
|
681
|
+
|
|
682
|
+
/** Splits a body at its level-2 headings, ignoring anything inside a code fence. */
|
|
683
|
+
function splitSections(markdown) {
|
|
684
|
+
const lead = [];
|
|
685
|
+
const sections = [];
|
|
686
|
+
let current;
|
|
687
|
+
let fence;
|
|
688
|
+
for (const line of markdown.split(/\r?\n/u)) {
|
|
689
|
+
const fenceMatch = FENCE.exec(line);
|
|
690
|
+
if (fenceMatch !== null) {
|
|
691
|
+
if (fence === undefined) fence = fenceMatch[1];
|
|
692
|
+
else if (fenceMatch[1] === fence) fence = undefined;
|
|
693
|
+
}
|
|
694
|
+
const heading = fence === undefined && fenceMatch === null ? H2.exec(line) : null;
|
|
695
|
+
if (heading !== null) {
|
|
696
|
+
current = { title: heading[1].trim(), lines: [] };
|
|
697
|
+
sections.push(current);
|
|
698
|
+
} else {
|
|
699
|
+
(current?.lines ?? lead).push(line);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return { lead: lead.join("\n"), sections };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function requireSectionTitles(sections, expected, file, template) {
|
|
706
|
+
const titles = sections.map((section) => section.title.toLowerCase());
|
|
707
|
+
const unknown = sections.find((section) => !expected.includes(section.title.toLowerCase()));
|
|
708
|
+
if (
|
|
709
|
+
unknown !== undefined || titles.length !== expected.length
|
|
710
|
+
|| expected.some((title, index) => titles[index] !== title)
|
|
711
|
+
) {
|
|
712
|
+
const names = expected.map((title) => `'## ${title[0].toUpperCase()}${title.slice(1)}'`).join(" then ");
|
|
713
|
+
throw fail(
|
|
714
|
+
`pages.${template}_sections_invalid`,
|
|
715
|
+
`'${file}' is ${template === TEMPLATE_ALBUM ? "an" : "a"} ${template}, whose body is an introduction followed by `
|
|
716
|
+
+ `${names}. ${
|
|
717
|
+
unknown === undefined ? "" : `The heading '## ${unknown.title}' is not part of that shape; `
|
|
718
|
+
+ "move that text into the introduction so nothing is dropped. "
|
|
719
|
+
}Found: ${
|
|
720
|
+
sections.length === 0 ? "no ## headings" : sections.map((section) => `'## ${section.title}'`).join(", ")
|
|
721
|
+
}.`,
|
|
722
|
+
file,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Removes up to `width` leading spaces (or one tab) from a step's continuation line. */
|
|
728
|
+
function dedent(line, width) {
|
|
729
|
+
if (line.startsWith("\t")) return line.slice(1);
|
|
730
|
+
let removed = 0;
|
|
731
|
+
while (removed < width && line[removed] === " ") removed += 1;
|
|
732
|
+
return line.slice(removed);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function groupedLines(lines, file, template, itemPattern, describe) {
|
|
736
|
+
const groups = [{ name: "", items: [] }];
|
|
737
|
+
let step;
|
|
738
|
+
// How far the open item's marker pushes its text in ("1. " is 3), which is
|
|
739
|
+
// how far its continuation lines are indented. Removing exactly that much
|
|
740
|
+
// keeps the relative indentation of nested lists and code inside the step.
|
|
741
|
+
let markerWidth = 0;
|
|
742
|
+
let blanks = 0;
|
|
743
|
+
const closeStep = () => {
|
|
744
|
+
if (step !== undefined) groups.at(-1).items.push(step.join("\n"));
|
|
745
|
+
step = undefined;
|
|
746
|
+
};
|
|
747
|
+
let fence;
|
|
748
|
+
for (const line of lines) {
|
|
749
|
+
const insideFence = fence !== undefined;
|
|
750
|
+
const fenceMatch = FENCE.exec(line);
|
|
751
|
+
if (fenceMatch !== null && fence === undefined) fence = fenceMatch[1];
|
|
752
|
+
else if (fenceMatch !== null && fenceMatch[1] === fence) fence = undefined;
|
|
753
|
+
if (line.trim() === "") {
|
|
754
|
+
blanks += 1;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
// A fence, and everything inside it, belongs to the step that holds it: its
|
|
758
|
+
// lines are code, not list items or headings, whatever they start with.
|
|
759
|
+
if ((insideFence || fenceMatch !== null) && step !== undefined) {
|
|
760
|
+
for (let index = 0; index < blanks; index += 1) step.push("");
|
|
761
|
+
step.push(dedent(line, markerWidth));
|
|
762
|
+
blanks = 0;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const group = H3.exec(line);
|
|
766
|
+
if (group !== null) {
|
|
767
|
+
closeStep();
|
|
768
|
+
groups.push({ name: group[1].trim(), items: [] });
|
|
769
|
+
blanks = 0;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const item = itemPattern.exec(line);
|
|
773
|
+
if (item !== null) {
|
|
774
|
+
closeStep();
|
|
775
|
+
step = [item[1]];
|
|
776
|
+
markerWidth = line.length - item[1].length;
|
|
777
|
+
blanks = 0;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const continuation = step === undefined ? null : CONTINUATION.exec(line);
|
|
781
|
+
if (continuation !== null) {
|
|
782
|
+
for (let index = 0; index < blanks; index += 1) step.push("");
|
|
783
|
+
step.push(dedent(line, markerWidth));
|
|
784
|
+
blanks = 0;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
throw fail(
|
|
788
|
+
`pages.${template}_section_invalid`,
|
|
789
|
+
`'${file}' has a line in ${describe} that is not a list item or a '### group' heading: `
|
|
790
|
+
+ `'${line.trim().slice(0, 80)}'.`,
|
|
791
|
+
file,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
closeStep();
|
|
795
|
+
return groups.filter((group) => group.items.length > 0 || group.name !== "");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function parseRecipeBody(markdown, { file, convert }) {
|
|
799
|
+
const { lead, sections } = splitSections(markdown);
|
|
800
|
+
requireSectionTitles(sections, RECIPE_HEADINGS, file, TEMPLATE_RECIPE);
|
|
801
|
+
const ingredientGroups = groupedLines(sections[0].lines, file, TEMPLATE_RECIPE, BULLET, "the Ingredients section")
|
|
802
|
+
.map((group) => ({
|
|
803
|
+
name: group.name,
|
|
804
|
+
ingredients: group.items.map((rawText) => ({ rawText: rawText.trim() })),
|
|
805
|
+
}))
|
|
806
|
+
.filter((group) => group.ingredients.length > 0);
|
|
807
|
+
const instructionSections = [];
|
|
808
|
+
for (
|
|
809
|
+
const group of groupedLines(sections[1].lines, file, TEMPLATE_RECIPE, STEP, "the Instructions section")
|
|
810
|
+
) {
|
|
811
|
+
const stepBodies = [];
|
|
812
|
+
for (const text of group.items) stepBodies.push(await convert(text));
|
|
813
|
+
if (stepBodies.length > 0) instructionSections.push({ name: group.name, stepBodies });
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
introductionBody: lead.trim() === "" ? undefined : await convert(lead),
|
|
817
|
+
ingredientGroups,
|
|
818
|
+
instructionSections,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function parseAlbumBody(markdown, { file, convert, resolveImage }) {
|
|
823
|
+
const { lead, sections } = splitSections(markdown);
|
|
824
|
+
requireSectionTitles(sections, [ALBUM_IMAGES_HEADING], file, TEMPLATE_ALBUM);
|
|
825
|
+
const images = [];
|
|
826
|
+
for (const line of sections[0].lines) {
|
|
827
|
+
if (line.trim() === "") continue;
|
|
828
|
+
const match = IMAGE_LINE.exec(line.trim());
|
|
829
|
+
if (match === null) {
|
|
830
|
+
throw fail(
|
|
831
|
+
"pages.album_section_invalid",
|
|
832
|
+
`'${file}' has a line in the Images section that is not '': `
|
|
833
|
+
+ `'${line.trim().slice(0, 80)}'.`,
|
|
834
|
+
file,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
const image = await resolveImage(match[2]);
|
|
838
|
+
images.push({ imageId: image.imageId, caption: match[1], width: image.width, height: image.height });
|
|
839
|
+
}
|
|
840
|
+
if (images.length === 0) {
|
|
841
|
+
throw fail("pages.album_images_missing", `'${file}' is an album whose Images section lists no images.`, file);
|
|
842
|
+
}
|
|
843
|
+
return { introductionBody: lead.trim() === "" ? undefined : await convert(lead), images };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function parseCount(fields, key, file, maximum) {
|
|
847
|
+
if (!fields.has(key)) return undefined;
|
|
848
|
+
const value = fields.get(key);
|
|
849
|
+
if (!/^\d{1,6}$/u.test(value) || Number(value) > maximum) {
|
|
850
|
+
throw fail(
|
|
851
|
+
"pages.front_matter_value_invalid",
|
|
852
|
+
`'${file}' declares ${key} '${value}', which must be a whole number from 0 to ${maximum}.`,
|
|
853
|
+
key,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
return Number(value);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function parseFlag(fields, key, file) {
|
|
860
|
+
if (!fields.has(key)) return undefined;
|
|
861
|
+
const value = fields.get(key);
|
|
862
|
+
if (value !== "true" && value !== "false") {
|
|
863
|
+
throw fail(
|
|
864
|
+
"pages.front_matter_value_invalid",
|
|
865
|
+
`'${file}' declares ${key} '${value}', which must be true or false.`,
|
|
866
|
+
key,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
return value === "true";
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Front-matter fields the declared template does not accept.
|
|
874
|
+
*
|
|
875
|
+
* Kept separate from the parse so the fault is recorded against the file the
|
|
876
|
+
* way every other metadata fault is, and so the message can name what the
|
|
877
|
+
* template *does* accept.
|
|
878
|
+
*/
|
|
879
|
+
export function templateFrontMatterFault(template, fieldNames, file) {
|
|
880
|
+
const accepted = frontMatterKeysFor(template);
|
|
881
|
+
const stray = fieldNames.find((name) => !accepted.includes(name));
|
|
882
|
+
return stray === undefined
|
|
883
|
+
? undefined
|
|
884
|
+
: fail(
|
|
885
|
+
"pages.front_matter_unknown",
|
|
886
|
+
`'${file}' declares '${stray}', which the ${template} template does not accept. `
|
|
887
|
+
+ `Supported fields: ${accepted.join(", ")}.`,
|
|
888
|
+
stray,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Converts a Markdown source into its canonical workspace document.
|
|
894
|
+
*
|
|
895
|
+
* `convert` turns Markdown into a ProseMirror document and `resolveImage` maps
|
|
896
|
+
* a media reference through the media manifest; both are injected so this
|
|
897
|
+
* module stays pure.
|
|
898
|
+
*/
|
|
899
|
+
export async function typedDocumentFromMarkdown({ template, fields, markdown, file, convert, resolveImage }) {
|
|
900
|
+
const displayDate = fields.has("displayDate") ? requireDisplayDate(fields.get("displayDate"), file) : undefined;
|
|
901
|
+
let coverImageId;
|
|
902
|
+
if (fields.has("coverImage")) {
|
|
903
|
+
const reference = fields.get("coverImage");
|
|
904
|
+
coverImageId = reference === "" ? "" : (await resolveImage(reference)).imageId;
|
|
905
|
+
}
|
|
906
|
+
let data;
|
|
907
|
+
if (template === TEMPLATE_ARTICLE) {
|
|
908
|
+
data = { body: await convert(markdown) };
|
|
909
|
+
} else if (template === TEMPLATE_PLACE_REVIEW) {
|
|
910
|
+
const placeId = fields.get("placeId");
|
|
911
|
+
const rating = fields.get("rating");
|
|
912
|
+
if (placeId === undefined || rating === undefined) {
|
|
913
|
+
throw fail(
|
|
914
|
+
"pages.place_review_invalid",
|
|
915
|
+
`'${file}' is a place review, so its front matter must declare placeId (the Taproot place's id) and rating `
|
|
916
|
+
+ `(${Object.keys(PLACE_REVIEW_RATINGS).join(", ")}).`,
|
|
917
|
+
file,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
data = { placeId, rating, body: await convert(markdown) };
|
|
921
|
+
} else if (template === TEMPLATE_ALBUM) {
|
|
922
|
+
data = {
|
|
923
|
+
...(await parseAlbumBody(markdown, { file, convert, resolveImage })),
|
|
924
|
+
seamless: parseFlag(fields, "seamless", file),
|
|
925
|
+
borderWidth: parseCount(fields, "borderWidth", file, ALBUM_MAXIMUM_BORDER_WIDTH),
|
|
926
|
+
};
|
|
927
|
+
} else {
|
|
928
|
+
data = {
|
|
929
|
+
...(await parseRecipeBody(markdown, { file, convert })),
|
|
930
|
+
prepTimeMinutes: parseCount(fields, "prepTimeMinutes", file, MAXIMUM_MINUTES),
|
|
931
|
+
cookTimeMinutes: parseCount(fields, "cookTimeMinutes", file, MAXIMUM_MINUTES),
|
|
932
|
+
servings: parseCount(fields, "servings", file, MAXIMUM_SERVINGS),
|
|
933
|
+
recipeCategory: fields.get("recipeCategory"),
|
|
934
|
+
recipeCuisine: fields.get("recipeCuisine"),
|
|
935
|
+
cookingMethod: fields.get("cookingMethod"),
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
// The same strict pass a `.pm.json` source gets, so a Markdown source and a
|
|
939
|
+
// pulled document are held to one definition of "valid".
|
|
940
|
+
return typedDocumentFromJson(
|
|
941
|
+
{
|
|
942
|
+
template,
|
|
943
|
+
...definedEntries([["displayDate", displayDate], ["coverImageId", coverImageId]]),
|
|
944
|
+
data: withoutUndefined(data),
|
|
945
|
+
},
|
|
946
|
+
file,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function withoutUndefined(value) {
|
|
951
|
+
return definedEntries(Object.entries(value));
|
|
952
|
+
}
|