@taprootio/site-authoring 0.11.1 → 0.12.1
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 +16 -4
- 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 +6 -2
- package/src/content/components.js +0 -6
- package/src/redirects-contract.js +101 -9
- package/src/reference-help.js +100 -21
- 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/deploy.js +13 -19
- package/src/verbs/pages-push.js +200 -61
- package/src/verbs/pull.js +47 -20
- package/src/verbs/redirects-check.js +1 -1
- package/src/verbs/validate.js +24 -3
- package/src/workspace.js +42 -8
package/src/reference-help.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
import { TEMPLATE_TYPE_FREE_FORM } from "./api.js";
|
|
4
3
|
import { CLI_BINARY_NAME } from "./constants.js";
|
|
5
4
|
import {
|
|
6
5
|
canonicalizeComponentData,
|
|
@@ -35,6 +34,8 @@ import {
|
|
|
35
34
|
REDIRECTS_FILE_NAME,
|
|
36
35
|
} from "./redirects-contract.js";
|
|
37
36
|
import { SETTINGS_GROUPS } from "./settings-catalog.js";
|
|
37
|
+
import { formatTypedPageReference, getTypedPageReference, TYPED_PAGE_TYPES } from "./typed-page-reference.js";
|
|
38
|
+
import { PAGE_TEMPLATES } from "./typed-pages.js";
|
|
38
39
|
import {
|
|
39
40
|
INTERNAL_PAGE_BASELINE_DIRECTORY,
|
|
40
41
|
MANIFEST_VERSION,
|
|
@@ -49,8 +50,8 @@ import {
|
|
|
49
50
|
|
|
50
51
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
51
52
|
|
|
52
|
-
export const REFERENCE_VERSION =
|
|
53
|
-
export const PAGE_TYPES = Object.freeze(["free-form"]);
|
|
53
|
+
export const REFERENCE_VERSION = 28;
|
|
54
|
+
export const PAGE_TYPES = Object.freeze(["free-form", ...TYPED_PAGE_TYPES]);
|
|
54
55
|
|
|
55
56
|
function deepFreeze(value) {
|
|
56
57
|
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
@@ -334,7 +335,7 @@ export function getDesignBlueprint(type) {
|
|
|
334
335
|
return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
|
|
335
336
|
}
|
|
336
337
|
export const REFERENCE_TOPICS = Object.freeze([
|
|
337
|
-
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types." }),
|
|
338
|
+
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types: free-form, article, recipe, album, place-review." }),
|
|
338
339
|
Object.freeze({
|
|
339
340
|
name: "page",
|
|
340
341
|
usage: `${CLI_BINARY_NAME} help page <page-type>`,
|
|
@@ -397,6 +398,11 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
397
398
|
usage: `${CLI_BINARY_NAME} help footer`,
|
|
398
399
|
summary: "Author and concurrency-safely push the complete footer document.",
|
|
399
400
|
}),
|
|
401
|
+
Object.freeze({
|
|
402
|
+
name: "import",
|
|
403
|
+
usage: `${CLI_BINARY_NAME} help import`,
|
|
404
|
+
summary: "Import an existing WordPress site from its export: mapping rules, order of work, and what to report.",
|
|
405
|
+
}),
|
|
400
406
|
Object.freeze({
|
|
401
407
|
name: "fixture",
|
|
402
408
|
usage: `${CLI_BINARY_NAME} help fixture`,
|
|
@@ -405,6 +411,60 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
405
411
|
]);
|
|
406
412
|
|
|
407
413
|
const WORKFLOW_REFERENCES = Object.freeze({
|
|
414
|
+
import: Object.freeze({
|
|
415
|
+
title: "Importing a WordPress site",
|
|
416
|
+
summary: "Turn a WordPress export (WXR) into a published Taproot site with the templates, dates, media and redirects preserved, and a written account of everything that could not be mapped.",
|
|
417
|
+
usage: `${CLI_BINARY_NAME} pull | media upload <files...> | pages push | redirects push | nav push | deploy --staging`,
|
|
418
|
+
details: Object.freeze([
|
|
419
|
+
"Source: a WordPress export file (Tools > Export > All content, WXR 1.2). Read the live site only when the owner "
|
|
420
|
+
+ "has said you may; the export names every media URL but carries no image bytes, so downloading originals from "
|
|
421
|
+
+ "the old site also needs that permission. Work in a fresh workspace: 'use' the site, then 'pull'.",
|
|
422
|
+
"Order of work: 1 parse the export; 2 classify every item; 3 stage media; 4 write page sources; 5 check every "
|
|
423
|
+
+ "source; 6 pages push; 7 nav.json and nav push; 8 redirects.json and redirects push; 9 approve, deploy "
|
|
424
|
+
+ "--staging, review it; 10 redirects check and delivery check --staging; 11 deploy --production; 12 delivery "
|
|
425
|
+
+ "check --production --url <the origin 'sites' reports as primaryDomain>. Do not promote until the account of unmapped content has been read by the owner.",
|
|
426
|
+
"Classify. Published post: article (the default); recipe when it has an Ingredients list and a Steps list "
|
|
427
|
+
+ "(help page recipe); album when it is a gallery with little text (help page album). Published page: a free-form "
|
|
428
|
+
+ "page at its path, children under their parent's path. Never guess a place review: it needs a Taproot "
|
|
429
|
+
+ "placeId this CLI cannot look up, so import the post as an article and list it. Not imported, and always "
|
|
430
|
+
+ "listed: drafts, pending, private, scheduled and password-protected items; custom post types; comments; the WordPress front "
|
|
431
|
+
+ "page (Taproot seeds its own home page: ask the owner what belongs there).",
|
|
432
|
+
"Paths and dates. A post's path is '<first category slug, ignoring the default Uncategorized>/<post name>' ('journal/' with no category; a post or page whose slug has no ASCII letters gets a name like post-<id>, listed), a "
|
|
433
|
+
+ "page's is its post name under its parents. Keep the original publication date in displayDate (the date "
|
|
434
|
+
+ "part of wp:post_date). Categories beyond the first, and all tags, have no Taproot equivalent (tags are "
|
|
435
|
+
+ "generated per page type): list them. The featured image becomes coverImage on typed pages, and the same image must also appear in the page's own content (put it at the top of the body), or the site refuses the cover; a free-form page "
|
|
436
|
+
+ "has none, so list it.",
|
|
437
|
+
"Content. Convert the HTML to the Markdown subset. Keep headings from h2 to h4 (the title is the h1; deeper levels are refused, so make h5 and h6 an h4). Decode HTML entities (&, ’) in titles, excerpts, menu labels and term names; WordPress stores them encoded. An image "
|
|
438
|
+
+ "must stand alone in its own block, so split paragraphs around images and lift images out of links; a link "
|
|
439
|
+
+ "to the full-size file is dropped, so list it. Tables need a header row and 2 to 12 columns, else write "
|
|
440
|
+
+ "a list and report it. Remove Gutenberg block comments and the more-tag marker. In a recipe, times and servings "
|
|
441
|
+
+ "go to front matter, text after the steps moves into the introduction with its headings dropped to level 3, and the only "
|
|
442
|
+
+ "level-2 headings are Ingredients and Instructions; an album's only level-2 heading is Images.",
|
|
443
|
+
"Report, never silently drop. Shortcodes with no equivalent ([contact-form-7], [adsense], maps, recipe cards "
|
|
444
|
+
+ "whose data sits in separate records), iframes and other embeds (a YouTube, Vimeo, X/Twitter, Instagram or TikTok address becomes a plain "
|
|
445
|
+
+ "link), scripts, video and audio elements, inline styles, galleries (flattened to sequential images, or an "
|
|
446
|
+
+ "album when they are the post), links to content you did not import (keep the absolute old link), and images "
|
|
447
|
+
+ "on other hosts. Every item goes in the account with its page and what happened to it.",
|
|
448
|
+
"Media. Save each original in the workspace's media/ directory (PNG, JPEG, GIF or WebP up to 32 MiB each; list SVG, AVIF, HEIC and oversized files, which cannot be uploaded; use the original, not "
|
|
449
|
+
+ "the -300x200 thumbnail WordPress also stores), then upload in batches of at most 10 files (conservative: the limit depends on how long the delivery URLs are, and was measured only with short stand-in URLs): 'media upload' "
|
|
450
|
+
+ "prints every file's delivery URLs into a result capped at 64 KiB, and a longer batch uploads everything, "
|
|
451
|
+
+ "writes .taproot-site-media.json, then exits 1 with output.too_large. If that happens the files are already "
|
|
452
|
+
+ "uploaded; run the same paths again (they deduplicate) in smaller batches.",
|
|
453
|
+
"Check before sending. 'pages push' validates every source before it sends anything but stops at the first "
|
|
454
|
+
+ "problem. On a large import, validate the sources yourself first so you see every problem at once: run the "
|
|
455
|
+
+ "package's validators over each file, or push one page at a time ('pages push <path>').",
|
|
456
|
+
"Navigation. A site can have several menus (header, footer): ask which is primary and list the rest. Rebuild it as nav.json after 'pages push', at most three levels deep with canonical UUID ids, 1000 items and 200-character titles (help nav); a label-only dropdown heading (a WordPress '#' link) is a GROUP_HEADER, and a child of an item you cannot map is listed, not promoted (page items carry the resourceId from the "
|
|
457
|
+
+ "manifest; a custom link is an EXTERNAL_URL item; a category or tag item has no target, so list it).",
|
|
458
|
+
"Redirects. At most 2000 entries; never redirect to a page you did not push. Every imported page's old path (from its <link>) becomes a 301 to its new path, unless the two "
|
|
459
|
+
+ "are the same. A category archive redirects to its folder when it became one. Query-string URLs (?p=12), "
|
|
460
|
+
+ "feeds, tag archives, author archives and attachment pages cannot be redirect sources or have no "
|
|
461
|
+
+ "destination: list them and ask the owner whether each is 410 or goes to an index. A source that a live page "
|
|
462
|
+
+ "occupies is refused by the site.",
|
|
463
|
+
"Finish with the account: counts of pages by template, media staged, redirects written, then every unmapped "
|
|
464
|
+
+ "item grouped by kind with examples, the questions only the owner can answer, and the verification results "
|
|
465
|
+
+ "(redirects check, delivery check).",
|
|
466
|
+
]),
|
|
467
|
+
}),
|
|
408
468
|
delivery: Object.freeze({
|
|
409
469
|
title: "Delivery verification",
|
|
410
470
|
summary: "delivery check reads a completed deployment's target the way a visitor does and reports HTTP delivery, runtime compatibility and (optionally) browser behaviour as separate dimensions.",
|
|
@@ -542,8 +602,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
542
602
|
+ "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
|
|
543
603
|
+ "manual redirects. It reports each path, HTTP status and Location without following targets.",
|
|
544
604
|
"verified is true only if every entry matched and the map revision stayed unchanged during the check. "
|
|
545
|
-
+ "The check proves the current map at staging, not a historical deployment. A mismatch
|
|
546
|
-
+ "
|
|
605
|
+
+ "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
|
|
606
|
+
+ "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
|
|
547
607
|
+ "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
|
|
548
608
|
"stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
|
|
549
609
|
+ "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
|
|
@@ -594,10 +654,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
594
654
|
"Converting an engagement's CSV into this file is the agent's job. The CLI's contract is JSON, on "
|
|
595
655
|
+ "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
|
|
596
656
|
+ "which are simply gone.",
|
|
597
|
-
"Entries take effect on the next deploy, staging first.
|
|
598
|
-
+ "when the
|
|
599
|
-
+ "
|
|
600
|
-
+ "
|
|
657
|
+
"Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
|
|
658
|
+
+ "exactly when the release does: staging with the staged release, production when it is promoted. There is "
|
|
659
|
+
+ "no separate propagation delay; an entry a check does not see belongs to a release that is not being "
|
|
660
|
+
+ "served yet.",
|
|
661
|
+
"A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
|
|
662
|
+
+ "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. The platform "
|
|
663
|
+
+ "already redirects every '…/index.html' address to its directory, so no entry is needed for one. A source "
|
|
664
|
+
+ "that could not be stored as a file (a dot-prefixed segment, a reserved device name, an escaped percent "
|
|
665
|
+
+ "sign) is refused too. 'validate' names the entry and the reason.",
|
|
601
666
|
]),
|
|
602
667
|
example: Object.freeze({
|
|
603
668
|
siteId: "<site-uuid>",
|
|
@@ -683,8 +748,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
683
748
|
`manifestVersion must be ${MANIFEST_VERSION}; fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
|
|
684
749
|
"siteId, every pageId and resourceId, every settings entityId, and every fixture.imageIds entry is a canonical "
|
|
685
750
|
+ "lowercase UUID. They are deterministic fixture identities, not identities from a live site.",
|
|
686
|
-
`Each pages[] entry binds one editable
|
|
687
|
-
+
|
|
751
|
+
`Each pages[] entry binds one editable page source: pageId, resourceId, path, title, templateType `
|
|
752
|
+
+ `(${Object.values(PAGE_TEMPLATES).map((template) => template.wireType).join(", ")}; the source must be that template), file, `
|
|
753
|
+
+ `sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. Album images and the cover image must be `
|
|
754
|
+
+ "listed in fixture.imageIds. "
|
|
688
755
|
+ "status, hasDraft, isGenerated, and description are recorded by pull and not read: the page key set "
|
|
689
756
|
+ "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string.",
|
|
690
757
|
`file lives under '${PAGES_DIRECTORY}/' and ends in ${PAGE_SOURCE_EXTENSIONS.join(" or ")}; sourceFormat must be `
|
|
@@ -716,7 +783,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
716
783
|
"validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
|
|
717
784
|
"validate --init <new-directory> exports the current pulled workspace as a validated version-7 fixture with "
|
|
718
785
|
+ "appearance and footer metadata. Run from the workspace or its configured project; --config may select the source.",
|
|
719
|
-
"Initialization keeps editable
|
|
786
|
+
"Initialization keeps editable pages of every template as workspace documents and reports excluded metadata-only pages. "
|
|
720
787
|
+ "References to excluded pages must be resolved before the fixture can validate. It never copies credentials, "
|
|
721
788
|
+ "internal reconciliation state, or deployment receipts. UUIDs and HTTP(S) origins are replaced with deterministic "
|
|
722
789
|
+ "fixture identities and example.test origins; URL credentials, queries and fragments are removed. Authored prose is retained.",
|
|
@@ -1302,6 +1369,14 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1302
1369
|
inheritedForTracked: true,
|
|
1303
1370
|
defaultForNew: "",
|
|
1304
1371
|
}),
|
|
1372
|
+
Object.freeze({
|
|
1373
|
+
name: "template",
|
|
1374
|
+
type: "string",
|
|
1375
|
+
requiredForNew: false,
|
|
1376
|
+
inheritedForTracked: false,
|
|
1377
|
+
defaultForNew: "free-form",
|
|
1378
|
+
description: `Other templates: ${TYPED_PAGE_TYPES.join(", ")}; see '${CLI_BINARY_NAME} help pages'.`,
|
|
1379
|
+
}),
|
|
1305
1380
|
]),
|
|
1306
1381
|
}),
|
|
1307
1382
|
Object.freeze({
|
|
@@ -1387,17 +1462,20 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1387
1462
|
});
|
|
1388
1463
|
|
|
1389
1464
|
export function listPageTypeReferences() {
|
|
1390
|
-
return PAGE_TYPES.map((type) =>
|
|
1391
|
-
type
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1465
|
+
return PAGE_TYPES.map((type) => {
|
|
1466
|
+
const reference = type === "free-form" ? FREE_FORM_REFERENCE : getTypedPageReference(type);
|
|
1467
|
+
return {
|
|
1468
|
+
type,
|
|
1469
|
+
displayName: reference.displayName,
|
|
1470
|
+
summary: reference.summary,
|
|
1471
|
+
helpCommand: `${CLI_BINARY_NAME} help page ${type}`,
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
1396
1474
|
}
|
|
1397
1475
|
|
|
1398
1476
|
export function getPageTypeReference(type) {
|
|
1399
|
-
if (type
|
|
1400
|
-
return
|
|
1477
|
+
if (type === "free-form") return { ...FREE_FORM_REFERENCE, components: listComponentTypeReferences() };
|
|
1478
|
+
return getTypedPageReference(type);
|
|
1401
1479
|
}
|
|
1402
1480
|
|
|
1403
1481
|
function schemaLabel(schema) {
|
|
@@ -1557,6 +1635,7 @@ export function formatReferenceResult(result) {
|
|
|
1557
1635
|
}
|
|
1558
1636
|
case "page": {
|
|
1559
1637
|
const page = result.page;
|
|
1638
|
+
if (page.type !== "free-form") return formatTypedPageReference(page);
|
|
1560
1639
|
const markdownFormat = page.workspace.formats.find((format) => format.extension === ".md");
|
|
1561
1640
|
const sourceRule = page.workspace.sourceRule;
|
|
1562
1641
|
return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace:\n${
|
|
@@ -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
|
+
}
|