@taprootio/site-authoring 0.14.1 → 0.16.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 +181 -16
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +123 -1
- package/src/authors-contract.js +170 -0
- package/src/cli.js +355 -30
- package/src/constants.js +9 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/link-warnings.js +109 -0
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/fixture-init.js +55 -3
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/page-metadata.js +92 -0
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +157 -41
- package/src/session.js +13 -0
- package/src/staging-check.js +62 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +97 -21
- package/src/typed-pages.js +196 -13
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +5 -28
- package/src/verbs/index.js +22 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +814 -224
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +595 -0
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +309 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +105 -10
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
PLACE_REVIEW_RATINGS,
|
|
5
5
|
TEMPLATE_ALBUM,
|
|
6
6
|
TEMPLATE_ARTICLE,
|
|
7
|
+
TEMPLATE_GENERATED,
|
|
7
8
|
TEMPLATE_PLACE_REVIEW,
|
|
8
9
|
TEMPLATE_RECIPE,
|
|
9
10
|
} from "./typed-pages.js";
|
|
@@ -35,6 +36,14 @@ const FIELD_HELP = Object.freeze({
|
|
|
35
36
|
required: true,
|
|
36
37
|
description: "Selects the template. A page's template never changes after it is created.",
|
|
37
38
|
},
|
|
39
|
+
author: {
|
|
40
|
+
type: "string",
|
|
41
|
+
required: false,
|
|
42
|
+
description:
|
|
43
|
+
"Credits the page to a site author by handle (jane-doe) or to a site member who can create pages by email "
|
|
44
|
+
+ "address. Pages are authorless unless you name one, and an author already on the page is never replaced. "
|
|
45
|
+
+ "'authors list' shows who can be named; 'authors add' creates a site author without an account.",
|
|
46
|
+
},
|
|
38
47
|
displayDate: {
|
|
39
48
|
type: "YYYY-MM-DD",
|
|
40
49
|
required: false,
|
|
@@ -66,8 +75,15 @@ const FIELD_HELP = Object.freeze({
|
|
|
66
75
|
type: "UUID",
|
|
67
76
|
required: true,
|
|
68
77
|
description:
|
|
69
|
-
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record
|
|
70
|
-
+ "
|
|
78
|
+
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
|
|
79
|
+
+ "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'.",
|
|
80
|
+
},
|
|
81
|
+
place: {
|
|
82
|
+
type: "UUID",
|
|
83
|
+
required: false,
|
|
84
|
+
description:
|
|
85
|
+
"The Taproot place an article or album is about, found the same way as a place review's placeId. Omit it to "
|
|
86
|
+
+ "leave the page's place as it is; an empty value clears it.",
|
|
71
87
|
},
|
|
72
88
|
rating: {
|
|
73
89
|
type: Object.keys(PLACE_REVIEW_RATINGS).join(" | "),
|
|
@@ -172,7 +188,7 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
172
188
|
{ displayDate: "2023-06-18", coverImageId: EXAMPLE_IMAGE_ID },
|
|
173
189
|
{ body: PARAGRAPH("We parked at the end of the gravel road and walked the last mile.") },
|
|
174
190
|
),
|
|
175
|
-
dataFields: ["body: ProseMirror document (required, not empty)"],
|
|
191
|
+
dataFields: ["body: ProseMirror document (required, not empty)", "place: UUID of a Taproot place (optional; \"\" clears it)"],
|
|
176
192
|
},
|
|
177
193
|
[TEMPLATE_RECIPE]: {
|
|
178
194
|
displayName: "Recipe",
|
|
@@ -240,6 +256,7 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
240
256
|
"introductionBody: ProseMirror document (optional)",
|
|
241
257
|
"images: [{ imageId, caption, width, height }]; at least one",
|
|
242
258
|
"seamless: true | false and borderWidth: 0-20 (optional; omitted inherits the site default)",
|
|
259
|
+
"place: UUID of a Taproot place (optional; \"\" clears it)",
|
|
243
260
|
],
|
|
244
261
|
},
|
|
245
262
|
[TEMPLATE_PLACE_REVIEW]: {
|
|
@@ -262,48 +279,106 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
262
279
|
"body: ProseMirror document (required, not empty)",
|
|
263
280
|
],
|
|
264
281
|
},
|
|
282
|
+
[TEMPLATE_GENERATED]: {
|
|
283
|
+
displayName: "Generated page",
|
|
284
|
+
summary:
|
|
285
|
+
"A listing Taproot generates from the site's content: a tag, the tags index, a published year or month, a place "
|
|
286
|
+
+ "archive, or a folder. You cannot create one; you customize its title, breadcrumb, description and introduction.",
|
|
287
|
+
documentExample: documentExample(
|
|
288
|
+
TEMPLATE_GENERATED,
|
|
289
|
+
{},
|
|
290
|
+
{
|
|
291
|
+
kind: "GENERATED_PAGE_KIND_TAG",
|
|
292
|
+
tagId: "0198a3f2-7c4e-4a10-9b2d-3f6e5d4c3b2a",
|
|
293
|
+
year: 0,
|
|
294
|
+
month: 0,
|
|
295
|
+
countryCode: "",
|
|
296
|
+
regionCode: "",
|
|
297
|
+
citySlug: "",
|
|
298
|
+
categorySlug: "",
|
|
299
|
+
customTitle: "Trail notes",
|
|
300
|
+
breadcrumbTitle: "",
|
|
301
|
+
customDescription: "Field notes from the trails I walk most often.",
|
|
302
|
+
introductionBody: PARAGRAPH("Everything I have written about walking."),
|
|
303
|
+
},
|
|
304
|
+
),
|
|
305
|
+
dataFields: [
|
|
306
|
+
"kind and the identity fields (tagId, year, month, countryCode, regionCode, citySlug, categorySlug): what the "
|
|
307
|
+
+ "page is, written by pull. Leave them as pulled; a push that changes them is refused (pages.generated_identity)",
|
|
308
|
+
"customTitle: string; empty uses the generated title (the page's reader-facing title)",
|
|
309
|
+
"breadcrumbTitle: string; empty uses the generated label (used on pages beneath this one)",
|
|
310
|
+
"customDescription: string, at most 1000 characters, about 160 shown in search results; empty uses the "
|
|
311
|
+
+ "introduction's first paragraph, else the generated description",
|
|
312
|
+
"introductionBody: ProseMirror document (optional), shown above the generated listing",
|
|
313
|
+
],
|
|
314
|
+
note:
|
|
315
|
+
"The manifest's title and description for a generated page are what the site reports, recorded for reference; "
|
|
316
|
+
+ "edit customTitle and customDescription here (or with 'pages meta set'), not the manifest. "
|
|
317
|
+
+ "pages push updates the page and refuses to create one (pages.generated_create), move it (pages.generated_move) "
|
|
318
|
+
+ "or change its identity (pages.generated_identity). The site derives the page's title and description from "
|
|
319
|
+
+ "customTitle, customDescription and introductionBody, so what a push sends for them is ignored. A source the site "
|
|
320
|
+
+ "no longer has a page for is reported and skipped by a whole-workspace push.",
|
|
321
|
+
},
|
|
265
322
|
});
|
|
266
323
|
|
|
267
324
|
export const TYPED_PAGE_TYPES = Object.freeze(Object.keys(TYPE_DETAILS));
|
|
325
|
+
/** The templates a Markdown source can declare: every typed page but the update-only generated one. */
|
|
326
|
+
export const MARKDOWN_PAGE_TYPES = Object.freeze(TYPED_PAGE_TYPES.filter((type) => type !== TEMPLATE_GENERATED));
|
|
268
327
|
|
|
269
328
|
function typedReference(type) {
|
|
270
329
|
const details = TYPE_DETAILS[type];
|
|
330
|
+
const generated = type === TEMPLATE_GENERATED;
|
|
271
331
|
return Object.freeze({
|
|
272
332
|
type,
|
|
273
333
|
displayName: details.displayName,
|
|
274
334
|
summary: details.summary,
|
|
275
335
|
workspace: Object.freeze({
|
|
276
336
|
directory: `${PAGES_DIRECTORY}/`,
|
|
277
|
-
sourceRule:
|
|
278
|
-
`One source file per page, exactly as for a free-form page: see '${CLI_BINARY_NAME} help page free-form'. `
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
337
|
+
sourceRule: generated
|
|
338
|
+
? `One source file per page, exactly as for a free-form page: see '${CLI_BINARY_NAME} help page free-form'. `
|
|
339
|
+
+ "A generated page exists only because the site's content produces it, so its source exists only after "
|
|
340
|
+
+ "'pull' and is never created by hand."
|
|
341
|
+
: `One source file per page, exactly as for a free-form page: see '${CLI_BINARY_NAME} help page free-form'. `
|
|
342
|
+
+ "The template is declared in the source (front matter 'template', or the .pm.json 'template' field) and is "
|
|
343
|
+
+ "immutable after creation: pages push refuses a source whose template differs from the live page's "
|
|
344
|
+
+ "(pages.template_immutable).",
|
|
345
|
+
pull: generated
|
|
346
|
+
? "pull writes each generated page as a .pm.json document, byte-stable across pulls, and keeps your edits to it "
|
|
347
|
+
+ "when the site's copy has not changed. A change made on the site since the last pull is reported as a conflict."
|
|
348
|
+
: "pull writes each page of this template as a .pm.json document, byte-stable across pulls. A page you authored as "
|
|
349
|
+
+ "Markdown keeps its Markdown source, and a change made on the site since the last pull is reported as a conflict.",
|
|
285
350
|
formats: Object.freeze([
|
|
286
|
-
Object.freeze({
|
|
351
|
+
...(generated ? [] : [Object.freeze({
|
|
287
352
|
extension: ".md",
|
|
288
353
|
purpose: "Author the page as Markdown with front matter.",
|
|
289
354
|
metadata: Object.freeze(
|
|
290
355
|
frontMatterKeysFor(type).map((name) => Object.freeze({ name, ...FIELD_HELP[name] })),
|
|
291
356
|
),
|
|
292
|
-
}),
|
|
357
|
+
})]),
|
|
293
358
|
Object.freeze({
|
|
294
359
|
extension: ".pm.json",
|
|
295
|
-
purpose:
|
|
296
|
-
"The pulled form: { template,
|
|
360
|
+
purpose: generated
|
|
361
|
+
? "The pulled form, and the only one: { template, data }. Path comes from the manifest; title and description "
|
|
362
|
+
+ "are the custom ones in data."
|
|
363
|
+
: "The pulled form: { template, displayDate?, coverImageId?, data }. Title, path and description come from the manifest.",
|
|
297
364
|
dataFields: Object.freeze(details.dataFields),
|
|
298
365
|
}),
|
|
299
366
|
]),
|
|
300
367
|
}),
|
|
301
|
-
markdown: Object.freeze({ body: details.body, example: details.markdownExample }),
|
|
368
|
+
...(generated ? {} : { markdown: Object.freeze({ body: details.body, example: details.markdownExample }) }),
|
|
302
369
|
document: Object.freeze({ example: details.documentExample, ...(details.note ? { note: details.note } : {}) }),
|
|
303
370
|
workflow: Object.freeze([
|
|
304
|
-
|
|
371
|
+
...(generated
|
|
372
|
+
? [Object.freeze({
|
|
373
|
+
command: `${CLI_BINARY_NAME} pull, then pages meta set <path> --title/--description`,
|
|
374
|
+
result: "Pull the page, then set its custom title or description (or edit the .pm.json).",
|
|
375
|
+
})]
|
|
376
|
+
: [Object.freeze({ command: `${CLI_BINARY_NAME} media upload`, result: "Upload cover and album images first." })]),
|
|
305
377
|
Object.freeze({ command: `${CLI_BINARY_NAME} validate`, result: "Check the workspace before sending anything." }),
|
|
306
|
-
Object.freeze({
|
|
378
|
+
Object.freeze({
|
|
379
|
+
command: `${CLI_BINARY_NAME} pages push`,
|
|
380
|
+
result: generated ? "Update the generated page's unapproved draft." : "Create or update an unapproved draft.",
|
|
381
|
+
}),
|
|
307
382
|
Object.freeze({
|
|
308
383
|
command: `${CLI_BINARY_NAME} approve, deploy --staging, deploy --production`,
|
|
309
384
|
result: "Stage, review and promote, as for any page.",
|
|
@@ -327,9 +402,10 @@ function formatField(field) {
|
|
|
327
402
|
export function formatTypedPageReference(page) {
|
|
328
403
|
const markdown = page.workspace.formats.find((format) => format.extension === ".md");
|
|
329
404
|
const json = page.workspace.formats.find((format) => format.extension === ".pm.json");
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
405
|
+
const markdownSection = markdown === undefined
|
|
406
|
+
? ""
|
|
407
|
+
: `Markdown front matter:\n${markdown.metadata.map(formatField).join("\n")}\n\nMarkdown body: ${page.markdown.body}\n\nMarkdown example:\n${page.markdown.example}\n`;
|
|
408
|
+
return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n pull ${page.workspace.pull}\n\n${markdownSection}.pm.json (${json.purpose})\n data:\n${
|
|
333
409
|
json.dataFields.map((field) => ` ${field}`).join("\n")
|
|
334
410
|
}\n${page.document.note === undefined ? "" : ` ${page.document.note}\n`}\n.pm.json example:\n${
|
|
335
411
|
JSON.stringify(page.document.example, null, 2)
|
package/src/typed-pages.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FREE_FORM_TEMPLATE_VERSION, TEMPLATE_TYPE_FREE_FORM } from "./api.js";
|
|
2
2
|
import { isVideoEmbedId } from "./content/video-embed-url.js";
|
|
3
3
|
import { SiteAuthoringError } from "./errors.js";
|
|
4
|
+
import { requireDescriptionLength } from "./page-metadata.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The authored page templates (TR00893).
|
|
@@ -29,6 +30,12 @@ export const TEMPLATE_ARTICLE = "article";
|
|
|
29
30
|
export const TEMPLATE_RECIPE = "recipe";
|
|
30
31
|
export const TEMPLATE_ALBUM = "album";
|
|
31
32
|
export const TEMPLATE_PLACE_REVIEW = "place-review";
|
|
33
|
+
/**
|
|
34
|
+
* A page Taproot generates (a tag, a month, a folder). It is update-only: the
|
|
35
|
+
* owner's title, breadcrumb, description and introduction are authored, and
|
|
36
|
+
* pages push refuses to create one or to change what it is (TR01195).
|
|
37
|
+
*/
|
|
38
|
+
export const TEMPLATE_GENERATED = "generated";
|
|
32
39
|
|
|
33
40
|
/** Wire identity and the `PageTemplate` member that carries each template's data. */
|
|
34
41
|
export const PAGE_TEMPLATES = Object.freeze({
|
|
@@ -37,12 +44,13 @@ export const PAGE_TEMPLATES = Object.freeze({
|
|
|
37
44
|
[TEMPLATE_RECIPE]: Object.freeze({ wireType: "TEMPLATE_TYPE_RECIPE", dataKey: "recipeData" }),
|
|
38
45
|
[TEMPLATE_ALBUM]: Object.freeze({ wireType: "TEMPLATE_TYPE_ALBUM", dataKey: "albumData" }),
|
|
39
46
|
[TEMPLATE_PLACE_REVIEW]: Object.freeze({ wireType: "TEMPLATE_TYPE_PLACE_REVIEW", dataKey: "placeReviewData" }),
|
|
47
|
+
[TEMPLATE_GENERATED]: Object.freeze({ wireType: "TEMPLATE_TYPE_GENERATED", dataKey: "generatedPageData" }),
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
export const PAGE_TEMPLATE_NAMES = Object.freeze(Object.keys(PAGE_TEMPLATES));
|
|
43
51
|
|
|
44
52
|
/** Front-matter fields every template accepts. */
|
|
45
|
-
export const SHARED_FRONT_MATTER_KEYS = Object.freeze(["title", "path", "description", "template"]);
|
|
53
|
+
export const SHARED_FRONT_MATTER_KEYS = Object.freeze(["title", "path", "description", "template", "author"]);
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
56
|
* The page-level fields the other templates add. A free-form page's source is
|
|
@@ -54,7 +62,8 @@ const TYPED_PAGE_FRONT_MATTER_KEYS = Object.freeze(["displayDate", "coverImage"]
|
|
|
54
62
|
/** Front-matter fields only one template accepts. Others are refused for it. */
|
|
55
63
|
const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
|
|
56
64
|
[TEMPLATE_FREE_FORM]: Object.freeze([]),
|
|
57
|
-
|
|
65
|
+
// `place` is the Taproot place id an article or album is about (TR01003).
|
|
66
|
+
[TEMPLATE_ARTICLE]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "place"]),
|
|
58
67
|
[TEMPLATE_RECIPE]: Object.freeze([
|
|
59
68
|
...TYPED_PAGE_FRONT_MATTER_KEYS,
|
|
60
69
|
"prepTimeMinutes",
|
|
@@ -64,8 +73,10 @@ const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
|
|
|
64
73
|
"recipeCuisine",
|
|
65
74
|
"cookingMethod",
|
|
66
75
|
]),
|
|
67
|
-
[TEMPLATE_ALBUM]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "seamless", "borderWidth"]),
|
|
76
|
+
[TEMPLATE_ALBUM]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "seamless", "borderWidth", "place"]),
|
|
68
77
|
[TEMPLATE_PLACE_REVIEW]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "placeId", "rating"]),
|
|
78
|
+
// Generated pages have no Markdown form.
|
|
79
|
+
[TEMPLATE_GENERATED]: Object.freeze([]),
|
|
69
80
|
});
|
|
70
81
|
|
|
71
82
|
export const FRONT_MATTER_KEYS = Object.freeze(
|
|
@@ -89,10 +100,58 @@ const RATING_NAME_BY_WIRE = Object.freeze(
|
|
|
89
100
|
// Not `isCanonicalUuid`: that accepts only RFC 4122 versions 1 to 5, and place
|
|
90
101
|
// and image ids are minted by the server, which is free to use any version.
|
|
91
102
|
const IDENTIFIER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
103
|
+
/** An id the server minted, of any UUID version. */
|
|
104
|
+
export const IMAGE_IDENTIFIER = IDENTIFIER;
|
|
92
105
|
const DISPLAY_DATE = /^\d{4}-\d{2}-\d{2}$/u;
|
|
93
106
|
const ALBUM_IMAGES_HEADING = "images";
|
|
94
107
|
const RECIPE_HEADINGS = Object.freeze(["ingredients", "instructions"]);
|
|
95
108
|
|
|
109
|
+
/** The generated page kinds the site reports, as the API names them (never the unknown kind). */
|
|
110
|
+
export const GENERATED_PAGE_KINDS = Object.freeze([
|
|
111
|
+
"GENERATED_PAGE_KIND_TAG",
|
|
112
|
+
"GENERATED_PAGE_KIND_TAGS_INDEX",
|
|
113
|
+
"GENERATED_PAGE_KIND_PUBLISHED_INDEX",
|
|
114
|
+
"GENERATED_PAGE_KIND_PUBLISHED_YEAR",
|
|
115
|
+
"GENERATED_PAGE_KIND_PUBLISHED_MONTH",
|
|
116
|
+
"GENERATED_PAGE_KIND_PLACES_INDEX",
|
|
117
|
+
"GENERATED_PAGE_KIND_PLACES_COUNTRY",
|
|
118
|
+
"GENERATED_PAGE_KIND_PLACES_STATE",
|
|
119
|
+
"GENERATED_PAGE_KIND_PLACES_CITY",
|
|
120
|
+
"GENERATED_PAGE_KIND_PLACES_CATEGORY",
|
|
121
|
+
"GENERATED_PAGE_KIND_FOLDER_INDEX",
|
|
122
|
+
]);
|
|
123
|
+
/** What a generated page is: system-managed, so a push must send it back unchanged. */
|
|
124
|
+
export const GENERATED_IDENTITY_KEYS = Object.freeze([
|
|
125
|
+
"kind",
|
|
126
|
+
"tagId",
|
|
127
|
+
"year",
|
|
128
|
+
"month",
|
|
129
|
+
"countryCode",
|
|
130
|
+
"regionCode",
|
|
131
|
+
"citySlug",
|
|
132
|
+
"categorySlug",
|
|
133
|
+
]);
|
|
134
|
+
const GENERATED_KEYS = [
|
|
135
|
+
...GENERATED_IDENTITY_KEYS,
|
|
136
|
+
"customTitle",
|
|
137
|
+
"breadcrumbTitle",
|
|
138
|
+
"customDescription",
|
|
139
|
+
"introductionBody",
|
|
140
|
+
];
|
|
141
|
+
const GENERATED_TEXT_KEYS = [
|
|
142
|
+
"tagId",
|
|
143
|
+
"countryCode",
|
|
144
|
+
"regionCode",
|
|
145
|
+
"citySlug",
|
|
146
|
+
"categorySlug",
|
|
147
|
+
"customTitle",
|
|
148
|
+
"breadcrumbTitle",
|
|
149
|
+
"customDescription",
|
|
150
|
+
];
|
|
151
|
+
const GENERATED_MAXIMUM_YEAR = 9999;
|
|
152
|
+
const GENERATED_MAXIMUM_MONTH = 12;
|
|
153
|
+
const EMPTY_DOCUMENT = Object.freeze({ type: "doc", content: Object.freeze([]) });
|
|
154
|
+
|
|
96
155
|
function fail(code, message, field) {
|
|
97
156
|
return new SiteAuthoringError(code, message, { field });
|
|
98
157
|
}
|
|
@@ -113,6 +172,32 @@ export function isAuthorableTemplateType(wireType) {
|
|
|
113
172
|
return templateNameForWireType(wireType) !== undefined;
|
|
114
173
|
}
|
|
115
174
|
|
|
175
|
+
/** Whether a wire `templateType` is a generated page. */
|
|
176
|
+
export function isGeneratedTemplateType(wireType) {
|
|
177
|
+
return wireType === PAGE_TEMPLATES[TEMPLATE_GENERATED].wireType;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The system-managed fields of a generated page's `data`, which the manifest records and a push must not change. */
|
|
181
|
+
export function generatedIdentity(data) {
|
|
182
|
+
return Object.fromEntries(GENERATED_IDENTITY_KEYS.map((key) => [key, data[key]]));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The names of the identity fields that differ, for a message. */
|
|
186
|
+
export function changedGeneratedIdentity(recorded, data) {
|
|
187
|
+
return GENERATED_IDENTITY_KEYS.filter((key) => recorded[key] !== data[key]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The title a generated page is sent with: the owner's, else the one the site last reported. */
|
|
191
|
+
export function generatedPageTitle(data, reportedTitle) {
|
|
192
|
+
// Trimmed as Taproot trims it, or a padded title would never match the key a pull recorded.
|
|
193
|
+
return data.customTitle.trim() || reportedTitle;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The description a generated page is sent with: the owner's, else the one the site last reported. */
|
|
197
|
+
export function generatedPageDescription(data, reportedDescription) {
|
|
198
|
+
return data.customDescription.trim() === "" ? reportedDescription : data.customDescription.trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
116
201
|
/** The template a canonical workspace document represents. */
|
|
117
202
|
export function documentTemplate(document_) {
|
|
118
203
|
return isPlainObject(document_) && typeof document_.template === "string" && document_.type === undefined
|
|
@@ -230,7 +315,7 @@ const RECIPE_KEYS = [
|
|
|
230
315
|
"recipeCuisine",
|
|
231
316
|
"cookingMethod",
|
|
232
317
|
];
|
|
233
|
-
const ALBUM_KEYS = ["introductionBody", "images", "seamless", "borderWidth"];
|
|
318
|
+
const ALBUM_KEYS = ["introductionBody", "images", "seamless", "borderWidth", "place"];
|
|
234
319
|
const ALBUM_IMAGE_KEYS = ["imageId", "caption", "width", "height"];
|
|
235
320
|
const ALBUM_MAXIMUM_BORDER_WIDTH = 20;
|
|
236
321
|
const MAXIMUM_MINUTES = 100_000;
|
|
@@ -257,8 +342,24 @@ function canonicalIngredient(value) {
|
|
|
257
342
|
* server's JSON omits defaults and may gain fields).
|
|
258
343
|
*/
|
|
259
344
|
const DATA_BUILDERS = Object.freeze({
|
|
260
|
-
[TEMPLATE_ARTICLE]: (raw) => (
|
|
345
|
+
[TEMPLATE_ARTICLE]: (raw) => definedEntries([["body", raw.body], ["place", raw.place]]),
|
|
261
346
|
[TEMPLATE_PLACE_REVIEW]: (raw) => ({ placeId: raw.placeId, rating: raw.rating, body: raw.body }),
|
|
347
|
+
// Defaults are written out, so a page with no custom title reads the same on every pull.
|
|
348
|
+
[TEMPLATE_GENERATED]: (raw) =>
|
|
349
|
+
definedEntries([
|
|
350
|
+
["kind", raw.kind],
|
|
351
|
+
["tagId", raw.tagId ?? ""],
|
|
352
|
+
["year", raw.year ?? 0],
|
|
353
|
+
["month", raw.month ?? 0],
|
|
354
|
+
["countryCode", raw.countryCode ?? ""],
|
|
355
|
+
["regionCode", raw.regionCode ?? ""],
|
|
356
|
+
["citySlug", raw.citySlug ?? ""],
|
|
357
|
+
["categorySlug", raw.categorySlug ?? ""],
|
|
358
|
+
["customTitle", raw.customTitle ?? ""],
|
|
359
|
+
["breadcrumbTitle", raw.breadcrumbTitle ?? ""],
|
|
360
|
+
["customDescription", raw.customDescription ?? ""],
|
|
361
|
+
["introductionBody", raw.introductionBody],
|
|
362
|
+
]),
|
|
262
363
|
[TEMPLATE_ALBUM]: (raw) =>
|
|
263
364
|
definedEntries([
|
|
264
365
|
["introductionBody", raw.introductionBody],
|
|
@@ -273,6 +374,7 @@ const DATA_BUILDERS = Object.freeze({
|
|
|
273
374
|
],
|
|
274
375
|
["seamless", raw.seamless],
|
|
275
376
|
["borderWidth", raw.borderWidth],
|
|
377
|
+
["place", raw.place],
|
|
276
378
|
]),
|
|
277
379
|
[TEMPLATE_RECIPE]: (raw) =>
|
|
278
380
|
definedEntries([
|
|
@@ -303,12 +405,22 @@ const DATA_BUILDERS = Object.freeze({
|
|
|
303
405
|
const OPTIONAL_INTRODUCTION = (value, file, path) =>
|
|
304
406
|
value === undefined ? undefined : requireDocument(value, file, path);
|
|
305
407
|
|
|
408
|
+
/** A Taproot place id, or "" to clear the page's place; omitted leaves it as the site holds it. */
|
|
409
|
+
function optionalPlace(value, file, path) {
|
|
410
|
+
if (value === undefined || value === "") return value;
|
|
411
|
+
requireIdentifier(value, file, path);
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
|
|
306
415
|
/** Strict checks on a source document's `data`, returning it in canonical form. */
|
|
307
416
|
function canonicalSourceData(template, data, file) {
|
|
308
417
|
const path = "data";
|
|
309
418
|
if (template === TEMPLATE_ARTICLE) {
|
|
310
|
-
requireKeys(data, ["body"], file, path);
|
|
311
|
-
return DATA_BUILDERS[template]({
|
|
419
|
+
requireKeys(data, ["body", "place"], file, path);
|
|
420
|
+
return DATA_BUILDERS[template]({
|
|
421
|
+
body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }),
|
|
422
|
+
place: optionalPlace(data.place, file, `${path}.place`),
|
|
423
|
+
});
|
|
312
424
|
}
|
|
313
425
|
if (template === TEMPLATE_PLACE_REVIEW) {
|
|
314
426
|
requireKeys(data, ["placeId", "rating", "body"], file, path);
|
|
@@ -325,6 +437,30 @@ function canonicalSourceData(template, data, file) {
|
|
|
325
437
|
body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }),
|
|
326
438
|
});
|
|
327
439
|
}
|
|
440
|
+
if (template === TEMPLATE_GENERATED) {
|
|
441
|
+
requireKeys(data, GENERATED_KEYS, file, path);
|
|
442
|
+
if (!GENERATED_PAGE_KINDS.includes(data.kind)) {
|
|
443
|
+
throw fail(
|
|
444
|
+
"pages.document_shape",
|
|
445
|
+
`'${file}' ${path}.kind must be one of ${GENERATED_PAGE_KINDS.join(", ")}.`,
|
|
446
|
+
file,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
for (const key of GENERATED_TEXT_KEYS) {
|
|
450
|
+
if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
|
|
451
|
+
}
|
|
452
|
+
if (data.tagId !== undefined && data.tagId !== "") requireIdentifier(data.tagId, file, `${path}.tagId`);
|
|
453
|
+
if (data.year !== undefined) requireCount(data.year, file, `${path}.year`, GENERATED_MAXIMUM_YEAR);
|
|
454
|
+
if (data.month !== undefined) requireCount(data.month, file, `${path}.month`, GENERATED_MAXIMUM_MONTH);
|
|
455
|
+
// The API trims a description, so the length that counts is the trimmed one.
|
|
456
|
+
const customDescription = data.customDescription?.trim();
|
|
457
|
+
requireDescriptionLength(customDescription, `${file}:${path}.customDescription`);
|
|
458
|
+
return DATA_BUILDERS[template]({
|
|
459
|
+
...data,
|
|
460
|
+
customDescription,
|
|
461
|
+
introductionBody: OPTIONAL_INTRODUCTION(data.introductionBody, file, `${path}.introductionBody`),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
328
464
|
if (template === TEMPLATE_ALBUM) {
|
|
329
465
|
requireKeys(data, ALBUM_KEYS, file, path);
|
|
330
466
|
const images = requireArray(data.images, file, `${path}.images`);
|
|
@@ -348,6 +484,7 @@ function canonicalSourceData(template, data, file) {
|
|
|
348
484
|
return DATA_BUILDERS[template]({
|
|
349
485
|
...data,
|
|
350
486
|
introductionBody: OPTIONAL_INTRODUCTION(data.introductionBody, file, `${path}.introductionBody`),
|
|
487
|
+
place: optionalPlace(data.place, file, `${path}.place`),
|
|
351
488
|
});
|
|
352
489
|
}
|
|
353
490
|
requireKeys(data, RECIPE_KEYS, file, path);
|
|
@@ -427,6 +564,14 @@ export function typedDocumentFromJson(parsed, file) {
|
|
|
427
564
|
file,
|
|
428
565
|
);
|
|
429
566
|
}
|
|
567
|
+
if (template === TEMPLATE_GENERATED && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
|
|
568
|
+
throw fail(
|
|
569
|
+
"pages.document_shape",
|
|
570
|
+
`'${file}' is a generated page, which has no displayDate or coverImageId of its own: its document is `
|
|
571
|
+
+ "{ template, data }.",
|
|
572
|
+
file,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
430
575
|
const displayDate = parsed.displayDate === undefined ? undefined : requireDisplayDate(parsed.displayDate, file);
|
|
431
576
|
let coverImageId;
|
|
432
577
|
if (parsed.coverImageId !== undefined) {
|
|
@@ -451,12 +596,15 @@ const presentIntroduction = (value) => {
|
|
|
451
596
|
return doc !== undefined && Array.isArray(doc.content) && doc.content.length === 0 ? undefined : doc;
|
|
452
597
|
};
|
|
453
598
|
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
599
|
+
// The site stores a page's place with its name and address; the workspace keeps only the id.
|
|
600
|
+
const pagePlaceId = (value) =>
|
|
601
|
+
isPlainObject(value) && typeof value.placeId === "string" && value.placeId !== "" ? value.placeId : undefined;
|
|
454
602
|
|
|
455
603
|
/** Lenient projections of what the site returned for each template's data. */
|
|
456
604
|
const PAGE_PROJECTIONS = Object.freeze({
|
|
457
605
|
[TEMPLATE_ARTICLE]: (raw) => {
|
|
458
606
|
const body = presentDocument(raw.body);
|
|
459
|
-
return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body });
|
|
607
|
+
return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body, place: pagePlaceId(raw.place) });
|
|
460
608
|
},
|
|
461
609
|
[TEMPLATE_PLACE_REVIEW]: (raw) => {
|
|
462
610
|
const body = presentDocument(raw.body);
|
|
@@ -464,12 +612,24 @@ const PAGE_PROJECTIONS = Object.freeze({
|
|
|
464
612
|
if (body === undefined || rating === undefined || typeof raw.placeId !== "string") return undefined;
|
|
465
613
|
return DATA_BUILDERS[TEMPLATE_PLACE_REVIEW]({ placeId: raw.placeId, rating, body });
|
|
466
614
|
},
|
|
615
|
+
// A kind this package does not know cannot be pushed back safely, so it is not readable here.
|
|
616
|
+
[TEMPLATE_GENERATED]: (raw) =>
|
|
617
|
+
GENERATED_PAGE_KINDS.includes(raw.kind)
|
|
618
|
+
? DATA_BUILDERS[TEMPLATE_GENERATED]({
|
|
619
|
+
kind: raw.kind,
|
|
620
|
+
...Object.fromEntries(GENERATED_TEXT_KEYS.map((key) => [key, typeof raw[key] === "string" ? raw[key] : ""])),
|
|
621
|
+
year: Number.isSafeInteger(raw.year) ? raw.year : 0,
|
|
622
|
+
month: Number.isSafeInteger(raw.month) ? raw.month : 0,
|
|
623
|
+
introductionBody: presentIntroduction(raw.introductionBody),
|
|
624
|
+
})
|
|
625
|
+
: undefined,
|
|
467
626
|
[TEMPLATE_ALBUM]: (raw) =>
|
|
468
627
|
DATA_BUILDERS[TEMPLATE_ALBUM]({
|
|
469
628
|
introductionBody: presentIntroduction(raw.introductionBody),
|
|
470
629
|
images: listOf(raw.images).filter((image) => isPlainObject(image) && typeof image.imageId === "string"),
|
|
471
630
|
seamless: typeof raw.seamless === "boolean" ? raw.seamless : undefined,
|
|
472
631
|
borderWidth: Number.isSafeInteger(raw.borderWidth) ? raw.borderWidth : undefined,
|
|
632
|
+
place: pagePlaceId(raw.place),
|
|
473
633
|
}),
|
|
474
634
|
[TEMPLATE_RECIPE]: (raw) =>
|
|
475
635
|
DATA_BUILDERS[TEMPLATE_RECIPE]({
|
|
@@ -505,9 +665,15 @@ export function workspaceDocumentFromPage(page, listedTemplateType) {
|
|
|
505
665
|
if (name === TEMPLATE_FREE_FORM) return isPlainObject(raw.body) ? raw.body : undefined;
|
|
506
666
|
const data = PAGE_PROJECTIONS[name](raw);
|
|
507
667
|
if (data === undefined) return undefined;
|
|
668
|
+
// A generated page's date and cover are not part of its source: a push leaves them as the site holds them.
|
|
669
|
+
const generated = name === TEMPLATE_GENERATED;
|
|
508
670
|
return typedDocument(name, {
|
|
509
|
-
displayDate: typeof page.displayDate === "string" && page.displayDate !== ""
|
|
510
|
-
|
|
671
|
+
displayDate: !generated && typeof page.displayDate === "string" && page.displayDate !== ""
|
|
672
|
+
? page.displayDate
|
|
673
|
+
: undefined,
|
|
674
|
+
coverImageId: !generated && typeof page.coverImageId === "string" && page.coverImageId !== ""
|
|
675
|
+
? page.coverImageId
|
|
676
|
+
: undefined,
|
|
511
677
|
data,
|
|
512
678
|
});
|
|
513
679
|
}
|
|
@@ -539,7 +705,7 @@ export function contentDocuments(document_) {
|
|
|
539
705
|
/** Whether a push sends `displayDate` for pages of this wire template type (every template but free-form). */
|
|
540
706
|
export function sentDisplayDate(wireType) {
|
|
541
707
|
const name = templateNameForWireType(wireType);
|
|
542
|
-
return name !== undefined && name !== TEMPLATE_FREE_FORM;
|
|
708
|
+
return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED;
|
|
543
709
|
}
|
|
544
710
|
|
|
545
711
|
// The nodes the API accepts in an album or recipe introduction and in a recipe
|
|
@@ -723,7 +889,12 @@ export function wireTemplate(document_) {
|
|
|
723
889
|
? { body: document_ }
|
|
724
890
|
: template === TEMPLATE_PLACE_REVIEW
|
|
725
891
|
? { ...document_.data, rating: PLACE_REVIEW_RATINGS[document_.data.rating] }
|
|
726
|
-
|
|
892
|
+
// The API reads a missing introduction as no document at all, so the empty one is sent.
|
|
893
|
+
: template === TEMPLATE_GENERATED
|
|
894
|
+
? { ...document_.data, introductionBody: document_.data.introductionBody ?? EMPTY_DOCUMENT }
|
|
895
|
+
: document_.data.place === undefined
|
|
896
|
+
? document_.data
|
|
897
|
+
: { ...document_.data, place: { placeId: document_.data.place } };
|
|
727
898
|
return { templateType: wireType, templateVersion: FREE_FORM_TEMPLATE_VERSION, [dataKey]: data };
|
|
728
899
|
}
|
|
729
900
|
|
|
@@ -939,6 +1110,16 @@ function parseFlag(fields, key, file) {
|
|
|
939
1110
|
return value === "true";
|
|
940
1111
|
}
|
|
941
1112
|
|
|
1113
|
+
/** Generated pages are edited in their .pm.json source only. */
|
|
1114
|
+
export function generatedMarkdownFault(file) {
|
|
1115
|
+
return fail(
|
|
1116
|
+
"pages.generated_markdown",
|
|
1117
|
+
`'${file}' declares the generated template, which has no Markdown form: a generated page is pulled as a `
|
|
1118
|
+
+ ".pm.json document and edited there.",
|
|
1119
|
+
file,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
942
1123
|
/**
|
|
943
1124
|
* Front-matter fields the declared template does not accept.
|
|
944
1125
|
*
|
|
@@ -967,6 +1148,7 @@ export function templateFrontMatterFault(template, fieldNames, file) {
|
|
|
967
1148
|
* module stays pure.
|
|
968
1149
|
*/
|
|
969
1150
|
export async function typedDocumentFromMarkdown({ template, fields, markdown, file, convert, resolveImage }) {
|
|
1151
|
+
if (template === TEMPLATE_GENERATED) throw generatedMarkdownFault(file);
|
|
970
1152
|
const displayDate = fields.has("displayDate") ? requireDisplayDate(fields.get("displayDate"), file) : undefined;
|
|
971
1153
|
let coverImageId;
|
|
972
1154
|
if (fields.has("coverImage")) {
|
|
@@ -975,7 +1157,7 @@ export async function typedDocumentFromMarkdown({ template, fields, markdown, fi
|
|
|
975
1157
|
}
|
|
976
1158
|
let data;
|
|
977
1159
|
if (template === TEMPLATE_ARTICLE) {
|
|
978
|
-
data = { body: await convert(markdown) };
|
|
1160
|
+
data = { body: await convert(markdown), place: fields.get("place") };
|
|
979
1161
|
} else if (template === TEMPLATE_PLACE_REVIEW) {
|
|
980
1162
|
const placeId = fields.get("placeId");
|
|
981
1163
|
const rating = fields.get("rating");
|
|
@@ -993,6 +1175,7 @@ export async function typedDocumentFromMarkdown({ template, fields, markdown, fi
|
|
|
993
1175
|
...(await parseAlbumBody(markdown, { file, convert, resolveImage })),
|
|
994
1176
|
seamless: parseFlag(fields, "seamless", file),
|
|
995
1177
|
borderWidth: parseCount(fields, "borderWidth", file, ALBUM_MAXIMUM_BORDER_WIDTH),
|
|
1178
|
+
place: fields.get("place"),
|
|
996
1179
|
};
|
|
997
1180
|
} else {
|
|
998
1181
|
data = {
|