@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,339 @@
|
|
|
1
|
+
import { CLI_BINARY_NAME } from "./constants.js";
|
|
2
|
+
import {
|
|
3
|
+
frontMatterKeysFor,
|
|
4
|
+
PLACE_REVIEW_RATINGS,
|
|
5
|
+
TEMPLATE_ALBUM,
|
|
6
|
+
TEMPLATE_ARTICLE,
|
|
7
|
+
TEMPLATE_PLACE_REVIEW,
|
|
8
|
+
TEMPLATE_RECIPE,
|
|
9
|
+
} from "./typed-pages.js";
|
|
10
|
+
import { PAGES_DIRECTORY } from "./workspace.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `help page <type>` for the four templates beyond free-form (TR00893).
|
|
14
|
+
*
|
|
15
|
+
* The front-matter fields are read from `typed-pages.js`, the module that
|
|
16
|
+
* enforces them, so the reference cannot list a field the parser refuses. The
|
|
17
|
+
* worked examples are checked against that same parser by the test suite.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const FIELD_HELP = Object.freeze({
|
|
21
|
+
title: { type: "string", required: true, description: "The page title." },
|
|
22
|
+
path: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: true,
|
|
25
|
+
description:
|
|
26
|
+
"URL path, e.g. journal/first-post. Each segment starts with a letter or digit and uses only letters, digits, '.', '_' and '-'.",
|
|
27
|
+
},
|
|
28
|
+
description: {
|
|
29
|
+
type: "string",
|
|
30
|
+
required: false,
|
|
31
|
+
description: "Short description used in listings and search results.",
|
|
32
|
+
},
|
|
33
|
+
template: {
|
|
34
|
+
type: "string",
|
|
35
|
+
required: true,
|
|
36
|
+
description: "Selects the template. A page's template never changes after it is created.",
|
|
37
|
+
},
|
|
38
|
+
displayDate: {
|
|
39
|
+
type: "YYYY-MM-DD",
|
|
40
|
+
required: false,
|
|
41
|
+
description:
|
|
42
|
+
"The date the page is about or was originally published. Set it to preserve an imported date; omit it to use the "
|
|
43
|
+
+ "first publication date. An empty value clears it.",
|
|
44
|
+
},
|
|
45
|
+
coverImage: {
|
|
46
|
+
type: "media reference",
|
|
47
|
+
required: false,
|
|
48
|
+
description:
|
|
49
|
+
"A file recorded by 'media upload', e.g. media/cover.jpg. The site requires the cover to be one of the page's own "
|
|
50
|
+
+ "images, so the same file must also appear in the body, introduction, steps or album images "
|
|
51
|
+
+ "(pages.cover_image_unused). Omit it for automatic selection; an empty value clears it.",
|
|
52
|
+
},
|
|
53
|
+
prepTimeMinutes: { type: "whole number", required: false, description: "Preparation time in minutes." },
|
|
54
|
+
cookTimeMinutes: { type: "whole number", required: false, description: "Cooking time in minutes." },
|
|
55
|
+
servings: { type: "whole number", required: false, description: "Number of servings." },
|
|
56
|
+
recipeCategory: { type: "string", required: false, description: "For example Dessert." },
|
|
57
|
+
recipeCuisine: { type: "string", required: false, description: "For example American." },
|
|
58
|
+
cookingMethod: { type: "string", required: false, description: "For example Baking." },
|
|
59
|
+
seamless: { type: "true | false", required: false, description: "Overrides the site's seamless-album setting." },
|
|
60
|
+
borderWidth: {
|
|
61
|
+
type: "whole number 0-20",
|
|
62
|
+
required: false,
|
|
63
|
+
description: "Overrides the site's album border width in pixels.",
|
|
64
|
+
},
|
|
65
|
+
placeId: {
|
|
66
|
+
type: "UUID",
|
|
67
|
+
required: true,
|
|
68
|
+
description:
|
|
69
|
+
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record; this "
|
|
70
|
+
+ "CLI does not search for places.",
|
|
71
|
+
},
|
|
72
|
+
rating: {
|
|
73
|
+
type: Object.keys(PLACE_REVIEW_RATINGS).join(" | "),
|
|
74
|
+
required: true,
|
|
75
|
+
description: "The reviewer's verdict.",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const ARTICLE_EXAMPLE = `---
|
|
80
|
+
title: How we found the trailhead
|
|
81
|
+
path: journal/how-we-found-the-trailhead
|
|
82
|
+
description: A short note about the first hike.
|
|
83
|
+
template: article
|
|
84
|
+
displayDate: 2023-06-18
|
|
85
|
+
coverImage: media/trailhead.jpg
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
We parked at the end of the gravel road and walked the last mile.
|
|
89
|
+
|
|
90
|
+

|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const RECIPE_EXAMPLE = `---
|
|
94
|
+
title: Lemon bars
|
|
95
|
+
path: recipes/lemon-bars
|
|
96
|
+
template: recipe
|
|
97
|
+
displayDate: 2022-11-04
|
|
98
|
+
prepTimeMinutes: 15
|
|
99
|
+
cookTimeMinutes: 35
|
|
100
|
+
servings: 16
|
|
101
|
+
recipeCategory: Dessert
|
|
102
|
+
recipeCuisine: American
|
|
103
|
+
cookingMethod: Baking
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
Tart, sweet, and easy to double.
|
|
107
|
+
|
|
108
|
+
## Ingredients
|
|
109
|
+
|
|
110
|
+
### Crust
|
|
111
|
+
|
|
112
|
+
- 2 cups flour
|
|
113
|
+
- 1/2 cup powdered sugar
|
|
114
|
+
|
|
115
|
+
### Filling
|
|
116
|
+
|
|
117
|
+
- 4 eggs
|
|
118
|
+
- 1 1/2 cups sugar
|
|
119
|
+
|
|
120
|
+
## Instructions
|
|
121
|
+
|
|
122
|
+
1. Press the crust into a pan and bake for 20 minutes.
|
|
123
|
+
2. Whisk the filling and pour it over the warm crust.
|
|
124
|
+
|
|
125
|
+
Bake until set.
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const ALBUM_EXAMPLE = `---
|
|
129
|
+
title: Weekend at the lake
|
|
130
|
+
path: albums/lake-weekend
|
|
131
|
+
template: album
|
|
132
|
+
displayDate: 2023-07-01
|
|
133
|
+
seamless: true
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
Three days, one cabin.
|
|
137
|
+
|
|
138
|
+
## Images
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+

|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
const PLACE_REVIEW_EXAMPLE = `---
|
|
145
|
+
title: Corner Cafe
|
|
146
|
+
path: reviews/corner-cafe
|
|
147
|
+
template: place-review
|
|
148
|
+
placeId: 0198a3f2-7c4e-4a10-9b2d-3f6e5d4c3b2a
|
|
149
|
+
rating: will-return
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
The espresso is excellent and the pastries sell out by ten.
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
// The id `media upload` records for a file, shown here as a stand-in.
|
|
156
|
+
const EXAMPLE_IMAGE_ID = "c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1";
|
|
157
|
+
|
|
158
|
+
const PARAGRAPH = (text) => ({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] });
|
|
159
|
+
|
|
160
|
+
function documentExample(template, extra, data) {
|
|
161
|
+
return { template, ...extra, data };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const TYPE_DETAILS = Object.freeze({
|
|
165
|
+
[TEMPLATE_ARTICLE]: {
|
|
166
|
+
displayName: "Article",
|
|
167
|
+
summary: "A dated post: a title, an optional cover image, and a rich-text body. The template for blog posts.",
|
|
168
|
+
body: "Everything after the front matter is the article body, in the same Markdown subset as a free-form page.",
|
|
169
|
+
markdownExample: ARTICLE_EXAMPLE,
|
|
170
|
+
documentExample: documentExample(
|
|
171
|
+
TEMPLATE_ARTICLE,
|
|
172
|
+
{ displayDate: "2023-06-18", coverImageId: EXAMPLE_IMAGE_ID },
|
|
173
|
+
{ body: PARAGRAPH("We parked at the end of the gravel road and walked the last mile.") },
|
|
174
|
+
),
|
|
175
|
+
dataFields: ["body: ProseMirror document (required, not empty)"],
|
|
176
|
+
},
|
|
177
|
+
[TEMPLATE_RECIPE]: {
|
|
178
|
+
displayName: "Recipe",
|
|
179
|
+
summary: "Structured ingredients and steps with times, servings and category, published with recipe markup.",
|
|
180
|
+
body:
|
|
181
|
+
"Text before '## Ingredients' is the introduction (optional). '## Ingredients' holds '- ' bullets, optionally split "
|
|
182
|
+
+ "by '### Group name' headings. '## Instructions' holds numbered or bulleted steps, optionally split by "
|
|
183
|
+
+ "'### Section name' headings; indent a step's later paragraphs by two spaces. Those two headings are the only "
|
|
184
|
+
+ "level-2 headings allowed, and both are required: text that does not fit is refused, never dropped. The "
|
|
185
|
+
+ "introduction and each step take paragraphs, headings, lists, quotes, code, images, tables and components, but "
|
|
186
|
+
+ "not sections or inline facts, which the site rejects there (pages.node_unsupported_for_template).",
|
|
187
|
+
markdownExample: RECIPE_EXAMPLE,
|
|
188
|
+
documentExample: documentExample(
|
|
189
|
+
TEMPLATE_RECIPE,
|
|
190
|
+
{ displayDate: "2022-11-04" },
|
|
191
|
+
{
|
|
192
|
+
introductionBody: PARAGRAPH("Tart, sweet, and easy to double."),
|
|
193
|
+
ingredientGroups: [{
|
|
194
|
+
name: "Crust",
|
|
195
|
+
ingredients: [{ rawText: "2 cups flour", quantity: "", unit: "", name: "", parsed: false }],
|
|
196
|
+
}],
|
|
197
|
+
instructionSections: [{ name: "", stepBodies: [PARAGRAPH("Press the crust into a pan and bake.")] }],
|
|
198
|
+
prepTimeMinutes: 15,
|
|
199
|
+
cookTimeMinutes: 35,
|
|
200
|
+
servings: 16,
|
|
201
|
+
recipeCategory: "Dessert",
|
|
202
|
+
recipeCuisine: "American",
|
|
203
|
+
cookingMethod: "Baking",
|
|
204
|
+
},
|
|
205
|
+
),
|
|
206
|
+
dataFields: [
|
|
207
|
+
"introductionBody: ProseMirror document (optional)",
|
|
208
|
+
"ingredientGroups: [{ name, ingredients: [{ rawText, quantity, unit, name, parsed }] }]; at least one ingredient",
|
|
209
|
+
"instructionSections: [{ name, stepBodies: [ProseMirror document] }]; at least one step",
|
|
210
|
+
"prepTimeMinutes, cookTimeMinutes, servings: whole numbers; recipeCategory, recipeCuisine, cookingMethod: strings",
|
|
211
|
+
],
|
|
212
|
+
note:
|
|
213
|
+
"A Markdown source supplies each ingredient as text only, so it publishes as written. A .pm.json source may also "
|
|
214
|
+
+ "carry quantity, unit and name with parsed: true.",
|
|
215
|
+
},
|
|
216
|
+
[TEMPLATE_ALBUM]: {
|
|
217
|
+
displayName: "Album",
|
|
218
|
+
summary: "A gallery of images with an optional introduction and per-album layout overrides.",
|
|
219
|
+
body:
|
|
220
|
+
"Text before '## Images' is the introduction (optional). '## Images' holds one '' line "
|
|
221
|
+
+ "per image, in display order, each recorded by 'media upload'. It is the only level-2 heading allowed. The "
|
|
222
|
+
+ "introduction takes paragraphs, headings, lists, quotes, code, images, tables and components, but not sections "
|
|
223
|
+
+ "or inline facts (pages.node_unsupported_for_template).",
|
|
224
|
+
markdownExample: ALBUM_EXAMPLE,
|
|
225
|
+
documentExample: documentExample(
|
|
226
|
+
TEMPLATE_ALBUM,
|
|
227
|
+
{ displayDate: "2023-07-01" },
|
|
228
|
+
{
|
|
229
|
+
introductionBody: PARAGRAPH("Three days, one cabin."),
|
|
230
|
+
images: [{
|
|
231
|
+
imageId: EXAMPLE_IMAGE_ID,
|
|
232
|
+
caption: "Sunrise over the dock",
|
|
233
|
+
width: 1600,
|
|
234
|
+
height: 900,
|
|
235
|
+
}],
|
|
236
|
+
seamless: true,
|
|
237
|
+
},
|
|
238
|
+
),
|
|
239
|
+
dataFields: [
|
|
240
|
+
"introductionBody: ProseMirror document (optional)",
|
|
241
|
+
"images: [{ imageId, caption, width, height }]; at least one",
|
|
242
|
+
"seamless: true | false and borderWidth: 0-20 (optional; omitted inherits the site default)",
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
[TEMPLATE_PLACE_REVIEW]: {
|
|
246
|
+
displayName: "Place review",
|
|
247
|
+
summary: "A review of a place with a three-step rating, listed on the site's places archive.",
|
|
248
|
+
body: "Everything after the front matter is the review body.",
|
|
249
|
+
markdownExample: PLACE_REVIEW_EXAMPLE,
|
|
250
|
+
documentExample: documentExample(
|
|
251
|
+
TEMPLATE_PLACE_REVIEW,
|
|
252
|
+
{},
|
|
253
|
+
{
|
|
254
|
+
placeId: "0198a3f2-7c4e-4a10-9b2d-3f6e5d4c3b2a",
|
|
255
|
+
rating: "will-return",
|
|
256
|
+
body: PARAGRAPH("The espresso is excellent and the pastries sell out by ten."),
|
|
257
|
+
},
|
|
258
|
+
),
|
|
259
|
+
dataFields: [
|
|
260
|
+
"placeId: UUID of an existing Taproot place",
|
|
261
|
+
`rating: ${Object.keys(PLACE_REVIEW_RATINGS).join(" | ")}`,
|
|
262
|
+
"body: ProseMirror document (required, not empty)",
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
export const TYPED_PAGE_TYPES = Object.freeze(Object.keys(TYPE_DETAILS));
|
|
268
|
+
|
|
269
|
+
function typedReference(type) {
|
|
270
|
+
const details = TYPE_DETAILS[type];
|
|
271
|
+
return Object.freeze({
|
|
272
|
+
type,
|
|
273
|
+
displayName: details.displayName,
|
|
274
|
+
summary: details.summary,
|
|
275
|
+
workspace: Object.freeze({
|
|
276
|
+
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
|
+
+ "The template is declared in the source (front matter 'template', or the .pm.json 'template' field) and is "
|
|
280
|
+
+ "immutable after creation: pages push refuses a source whose template differs from the live page's "
|
|
281
|
+
+ "(pages.template_immutable).",
|
|
282
|
+
pull:
|
|
283
|
+
"pull writes each page of this template as a .pm.json document, byte-stable across pulls. A page you authored as "
|
|
284
|
+
+ "Markdown keeps its Markdown source, and a change made on the site since the last pull is reported as a conflict.",
|
|
285
|
+
formats: Object.freeze([
|
|
286
|
+
Object.freeze({
|
|
287
|
+
extension: ".md",
|
|
288
|
+
purpose: "Author the page as Markdown with front matter.",
|
|
289
|
+
metadata: Object.freeze(
|
|
290
|
+
frontMatterKeysFor(type).map((name) => Object.freeze({ name, ...FIELD_HELP[name] })),
|
|
291
|
+
),
|
|
292
|
+
}),
|
|
293
|
+
Object.freeze({
|
|
294
|
+
extension: ".pm.json",
|
|
295
|
+
purpose:
|
|
296
|
+
"The pulled form: { template, displayDate?, coverImageId?, data }. Title, path and description come from the manifest.",
|
|
297
|
+
dataFields: Object.freeze(details.dataFields),
|
|
298
|
+
}),
|
|
299
|
+
]),
|
|
300
|
+
}),
|
|
301
|
+
markdown: Object.freeze({ body: details.body, example: details.markdownExample }),
|
|
302
|
+
document: Object.freeze({ example: details.documentExample, ...(details.note ? { note: details.note } : {}) }),
|
|
303
|
+
workflow: Object.freeze([
|
|
304
|
+
Object.freeze({ command: `${CLI_BINARY_NAME} media upload`, result: "Upload cover and album images first." }),
|
|
305
|
+
Object.freeze({ command: `${CLI_BINARY_NAME} validate`, result: "Check the workspace before sending anything." }),
|
|
306
|
+
Object.freeze({ command: `${CLI_BINARY_NAME} pages push`, result: "Create or update an unapproved draft." }),
|
|
307
|
+
Object.freeze({
|
|
308
|
+
command: `${CLI_BINARY_NAME} approve, deploy --staging, deploy --production`,
|
|
309
|
+
result: "Stage, review and promote, as for any page.",
|
|
310
|
+
}),
|
|
311
|
+
]),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const REFERENCES = Object.freeze(Object.fromEntries(TYPED_PAGE_TYPES.map((type) => [type, typedReference(type)])));
|
|
316
|
+
|
|
317
|
+
/** The reference for one non-free-form template, or `undefined`. */
|
|
318
|
+
export function getTypedPageReference(type) {
|
|
319
|
+
return Object.hasOwn(REFERENCES, type) ? REFERENCES[type] : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function formatField(field) {
|
|
323
|
+
return ` ${field.name.padEnd(18)} ${field.type}; ${field.required ? "required" : "optional"}; ${field.description}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The plain-text `help page <type>` rendering. */
|
|
327
|
+
export function formatTypedPageReference(page) {
|
|
328
|
+
const markdown = page.workspace.formats.find((format) => format.extension === ".md");
|
|
329
|
+
const json = page.workspace.formats.find((format) => format.extension === ".pm.json");
|
|
330
|
+
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\nMarkdown front matter:\n${
|
|
331
|
+
markdown.metadata.map(formatField).join("\n")
|
|
332
|
+
}\n\nMarkdown body: ${page.markdown.body}\n\nMarkdown example:\n${page.markdown.example}\n.pm.json (${json.purpose})\n data:\n${
|
|
333
|
+
json.dataFields.map((field) => ` ${field}`).join("\n")
|
|
334
|
+
}\n${page.document.note === undefined ? "" : ` ${page.document.note}\n`}\n.pm.json example:\n${
|
|
335
|
+
JSON.stringify(page.document.example, null, 2)
|
|
336
|
+
}\n\nWorkflow:\n${
|
|
337
|
+
page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
|
|
338
|
+
}\n\nUse --json for the exact structure.\n`;
|
|
339
|
+
}
|