@taprootio/site-authoring 0.15.0 → 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 +121 -8
- package/package.json +1 -1
- package/src/api.js +65 -0
- package/src/authors-contract.js +170 -0
- package/src/cli.js +211 -11
- package/src/constants.js +3 -0
- package/src/content/link-warnings.js +109 -0
- package/src/fixture-init.js +55 -3
- package/src/page-metadata.js +92 -0
- package/src/reference-help.js +58 -8
- package/src/staging-check.js +62 -0
- package/src/typed-page-reference.js +86 -18
- package/src/typed-pages.js +165 -4
- 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 +9 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +419 -36
- package/src/verbs/plan.js +11 -1
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/validate.js +47 -2
- package/src/workspace.js +58 -6
package/src/reference-help.js
CHANGED
|
@@ -64,7 +64,12 @@ import {
|
|
|
64
64
|
REDIRECTS_FILE_NAME,
|
|
65
65
|
} from "./redirects-contract.js";
|
|
66
66
|
import { SETTINGS_GROUPS } from "./settings-catalog.js";
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
formatTypedPageReference,
|
|
69
|
+
getTypedPageReference,
|
|
70
|
+
MARKDOWN_PAGE_TYPES,
|
|
71
|
+
TYPED_PAGE_TYPES,
|
|
72
|
+
} from "./typed-page-reference.js";
|
|
68
73
|
import { PAGE_TEMPLATES } from "./typed-pages.js";
|
|
69
74
|
import {
|
|
70
75
|
INTERNAL_PAGE_BASELINE_DIRECTORY,
|
|
@@ -72,6 +77,7 @@ import {
|
|
|
72
77
|
NAVIGATION_FILE_NAME,
|
|
73
78
|
PAGE_SOURCE_EXTENSIONS,
|
|
74
79
|
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
80
|
+
PAGE_WORKSPACE_MODE_METADATA_ONLY,
|
|
75
81
|
PAGES_DIRECTORY,
|
|
76
82
|
pageSourceFormat,
|
|
77
83
|
SETTINGS_DIRECTORY,
|
|
@@ -80,7 +86,7 @@ import {
|
|
|
80
86
|
|
|
81
87
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
82
88
|
|
|
83
|
-
export const REFERENCE_VERSION =
|
|
89
|
+
export const REFERENCE_VERSION = 35;
|
|
84
90
|
|
|
85
91
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
86
92
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -672,10 +678,16 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
672
678
|
"Run 'redirects check' after staging, or read deploy --staging's automatic check. The Node CLI consumes "
|
|
673
679
|
+ "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
|
|
674
680
|
+ "manual redirects. It reports each path, HTTP status and Location without following targets.",
|
|
681
|
+
"The result carries routeCheck (resolved or unresolved) and redirects: { revision, revisionUnchanged, "
|
|
682
|
+
+ "verified, total, checked, matched, failed, items: [{ path, status, location, expectedStatus, matches, "
|
|
683
|
+
+ "error? }], itemsTruncated? }, at the top level of both redirects check and deploy --staging. An entry "
|
|
684
|
+
+ "whose request failed carries error: \"request_failed\". A check that cannot finish at all reports "
|
|
685
|
+
+ "verified: false, an empty items list and redirects.error { code, message }.",
|
|
675
686
|
"verified is true only if every entry matched and the map revision stayed unchanged during the check. "
|
|
676
687
|
+ "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
|
|
677
688
|
+ "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; "
|
|
678
|
-
+ "large JSON lists are truncated explicitly with failed entries first.
|
|
689
|
+
+ "large JSON lists are truncated explicitly with failed entries first (redirects.itemsTruncated). Progress "
|
|
690
|
+
+ "output reports every entry on stderr; --quiet hides it.",
|
|
679
691
|
"stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
|
|
680
692
|
+ "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
|
|
681
693
|
+ "five minutes and remains subject to current site/host/credential permission.",
|
|
@@ -937,7 +949,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
937
949
|
+ `sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. Album images and the cover image must be `
|
|
938
950
|
+ "listed in fixture.imageIds. "
|
|
939
951
|
+ "status, hasDraft, isGenerated, and description are recorded by pull and not read: the page key set "
|
|
940
|
-
+ "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string."
|
|
952
|
+
+ "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string. "
|
|
953
|
+
+ `An entry with workspaceMode ${PAGE_WORKSPACE_MODE_METADATA_ONLY} names a page the fixture has no source for, `
|
|
954
|
+
+ "such as a generated section page navigation links to: pageId, resourceId and path, and no file.",
|
|
941
955
|
`file lives under '${PAGES_DIRECTORY}/' and ends in ${PAGE_SOURCE_EXTENSIONS.join(" or ")}; sourceFormat must be `
|
|
942
956
|
+ `the format that extension implies (${
|
|
943
957
|
PAGE_SOURCE_EXTENSIONS.map((extension) => `${extension} is '${pageSourceFormat(`page${extension}`)}'`).join(
|
|
@@ -971,8 +985,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
971
985
|
"validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
|
|
972
986
|
"validate --init <new-directory> exports the current pulled workspace as a validated version-7 fixture with "
|
|
973
987
|
+ "appearance and footer metadata. Run from the workspace or its configured project; --config may select the source.",
|
|
974
|
-
"Initialization keeps editable pages of every template as workspace documents
|
|
975
|
-
+ "
|
|
988
|
+
"Initialization keeps editable pages of every template as workspace documents. A metadata-only page that "
|
|
989
|
+
+ "navigation, the footer or a page links to is kept as a metadata-only entry (initialized.linkedPages); other "
|
|
990
|
+
+ "metadata-only pages are left out (initialized.skippedPages). It never copies credentials, "
|
|
976
991
|
+ "internal reconciliation state, or deployment receipts. UUIDs and HTTP(S) origins are replaced with deterministic "
|
|
977
992
|
+ "fixture identities and example.test origins; URL credentials, queries and fragments are removed. Authored prose is retained.",
|
|
978
993
|
"The destination must be outside the source workspace and must not exist; its parent must already exist without symlinks. Exports are bounded to 64 MiB. "
|
|
@@ -1518,6 +1533,31 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1518
1533
|
+ "refuses before changing anything under pages/ and preserves the site's version as internal state. A "
|
|
1519
1534
|
+ "locally edited .pm.json is kept rather than overwritten, and only collides when the site changed too.",
|
|
1520
1535
|
conflictError: "pages.pull_conflict",
|
|
1536
|
+
metadata:
|
|
1537
|
+
"A .pm.json page's title, path and description live in the manifest, not the file. Change the title or "
|
|
1538
|
+
+ "description with pages meta set <path> --title/--description; a Markdown page keeps them in its front "
|
|
1539
|
+
+ "matter. pull keeps such an edit until pages push sends it, and a site edit to the same page is a "
|
|
1540
|
+
+ "pages.pull_conflict. A description over 160 characters is warned about (search results cut it off); "
|
|
1541
|
+
+ "over 1000 is refused.",
|
|
1542
|
+
authorship:
|
|
1543
|
+
"Pages are authorless by default: a page created without an author publishes with no byline, and pages push "
|
|
1544
|
+
+ "lists the pages it created that way in pages.authorless. Name an author with author: in the front matter "
|
|
1545
|
+
+ "(every template) or, for a .pm.json page, pages meta set <path> --author. An author is a site author "
|
|
1546
|
+
+ "without a Taproot account, named by handle (jane-doe), or a site member who can create pages, named by "
|
|
1547
|
+
+ "email address. authors list shows who can be named and rewrites authors.json; authors add <handle> "
|
|
1548
|
+
+ "--name <name> [--email <address>] creates a site author, whose address stays private. A site author's "
|
|
1549
|
+
+ "name appears on the byline and in structured data, with no profile link or follow button. authors add "
|
|
1550
|
+
+ "again with the same handle and name succeeds (existing: true); another name for a taken handle is "
|
|
1551
|
+
+ "authors.handle_taken. Only the CLI assigns authors, and only to a page that has none. authors.json is as old "
|
|
1552
|
+
+ "as the last pull or authors list, so offline commands (validate, pages meta set) only warn "
|
|
1553
|
+
+ "(pages.author_unverified) about a name it lacks; pages push and plan read the site's current list once on a "
|
|
1554
|
+
+ "miss and refuse only a name the site lacks. The CLI only fills a page that has no author: a different one "
|
|
1555
|
+
+ "already on the page is pages.author_conflict, which names the way out (pages meta set <path> --author "
|
|
1556
|
+
+ "<the site's author> for a .pm.json page, the author: line for Markdown, or pages meta set <path> --author "
|
|
1557
|
+
+ "\"\" to drop an author that was never sent), naming the author a page already has changes nothing, and a "
|
|
1558
|
+
+ "name that is unknown or belongs to a member who cannot create pages is pages.author_unknown either way. "
|
|
1559
|
+
+ "Text that is neither a handle nor an address is pages.author_invalid.",
|
|
1560
|
+
authorshipErrors: Object.freeze(["pages.author_invalid", "pages.author_unknown", "pages.author_conflict"]),
|
|
1521
1561
|
conflictDetail:
|
|
1522
1562
|
"A reported conflict names the JSON paths at which the site's document differs from the copy this workspace "
|
|
1523
1563
|
+ "last reconciled with, in error.differences, which carries three states. Absent: the two documents were "
|
|
@@ -1569,13 +1609,23 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1569
1609
|
inheritedForTracked: true,
|
|
1570
1610
|
defaultForNew: "",
|
|
1571
1611
|
}),
|
|
1612
|
+
Object.freeze({
|
|
1613
|
+
name: "author",
|
|
1614
|
+
type: "string",
|
|
1615
|
+
requiredForNew: false,
|
|
1616
|
+
inheritedForTracked: true,
|
|
1617
|
+
defaultForNew: "",
|
|
1618
|
+
description:
|
|
1619
|
+
"A site author's handle (jane-doe) or a member's email address. Omit it for an authorless page; it "
|
|
1620
|
+
+ "only fills a page that has none, and never replaces one.",
|
|
1621
|
+
}),
|
|
1572
1622
|
Object.freeze({
|
|
1573
1623
|
name: "template",
|
|
1574
1624
|
type: "string",
|
|
1575
1625
|
requiredForNew: false,
|
|
1576
1626
|
inheritedForTracked: false,
|
|
1577
1627
|
defaultForNew: "free-form",
|
|
1578
|
-
description: `Other templates: ${
|
|
1628
|
+
description: `Other templates: ${MARKDOWN_PAGE_TYPES.join(", ")}; see '${CLI_BINARY_NAME} help pages'.`,
|
|
1579
1629
|
}),
|
|
1580
1630
|
]),
|
|
1581
1631
|
}),
|
|
@@ -1866,7 +1916,7 @@ export function formatReferenceResult(result) {
|
|
|
1866
1916
|
page.workspace.formats.map((format) => ` ${format.extension.padEnd(9)} ${format.purpose}`).join("\n")
|
|
1867
1917
|
}\n\nOne source per page:\n rule ${sourceRule.rule}\n manifest fields ${
|
|
1868
1918
|
sourceRule.manifestFields.join(", ")
|
|
1869
|
-
}\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n\nSystem pages: ${page.workspace.systemPages}\n\nMarkdown front matter:\n${
|
|
1919
|
+
}\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n authors ${sourceRule.authorship} (${sourceRule.authorshipErrors.join(", ")})\n\nSystem pages: ${page.workspace.systemPages}\n\nMarkdown front matter:\n${
|
|
1870
1920
|
markdownFormat.metadata.map(formatMetadata).join("\n")
|
|
1871
1921
|
}\n\nDocument root: { "type": "doc", "content": [...] }\nSupported nodes: ${
|
|
1872
1922
|
page.document.nodes.join(", ")
|
package/src/staging-check.js
CHANGED
|
@@ -152,3 +152,65 @@ export async function checkStagingRedirects(client, siteId, onProgress, now) {
|
|
|
152
152
|
},
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The staging redirect check and a fresh preview handoff, as redirects check and
|
|
158
|
+
* deploy --staging report them. A check that cannot finish keeps the same shape,
|
|
159
|
+
* with the reason.
|
|
160
|
+
*/
|
|
161
|
+
export async function inspectStagingPreview(client, siteId, onProgress, now) {
|
|
162
|
+
let routeCheck;
|
|
163
|
+
let redirects;
|
|
164
|
+
let stagingUrl;
|
|
165
|
+
try {
|
|
166
|
+
({ routeCheck, redirects, stagingUrl } = await checkStagingRedirects(client, siteId, onProgress, now));
|
|
167
|
+
if (!redirects.verified) {
|
|
168
|
+
onProgress(
|
|
169
|
+
"Warning: staging redirects are not all verified. Check the reported mismatches against the staged release; run 'taproot-site redirects check' again once the deployment has completed.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
onProgress(
|
|
174
|
+
"Warning: the authenticated staging redirect check could not finish. Run 'taproot-site redirects check' again.",
|
|
175
|
+
);
|
|
176
|
+
routeCheck = "unresolved";
|
|
177
|
+
redirects = {
|
|
178
|
+
total: 0,
|
|
179
|
+
checked: 0,
|
|
180
|
+
matched: 0,
|
|
181
|
+
failed: 0,
|
|
182
|
+
verified: false,
|
|
183
|
+
items: [],
|
|
184
|
+
error: redirectCheckError(error),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
// Mint after checks so the final single-use URL has never been consumed.
|
|
189
|
+
const stagingPreview = await mintStagingPreviewHandoff(client, siteId, { now });
|
|
190
|
+
return { routeCheck, redirects, stagingPreview };
|
|
191
|
+
} catch {
|
|
192
|
+
onProgress("Warning: a staging handoff could not be minted.");
|
|
193
|
+
return {
|
|
194
|
+
routeCheck,
|
|
195
|
+
redirects,
|
|
196
|
+
stagingPreview: {
|
|
197
|
+
url: "",
|
|
198
|
+
...(stagingUrl ? { stagingUrl } : {}),
|
|
199
|
+
warning: "Staging handoff unavailable; run redirects check to retry.",
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Why the automatic redirect check could not finish. Only this CLI's own
|
|
207
|
+
* errors carry their code and message; anything else (a timeout, a network
|
|
208
|
+
* failure) is named, never echoed, since its text is not ours to publish.
|
|
209
|
+
*/
|
|
210
|
+
function redirectCheckError(error) {
|
|
211
|
+
if (error instanceof SiteAuthoringError) {
|
|
212
|
+
return { code: error.code, message: sanitizeDiagnostic(error.message, "The redirect check could not finish.") };
|
|
213
|
+
}
|
|
214
|
+
const name = typeof error?.name === "string" && /^[A-Za-z]{1,40}$/u.test(error.name) ? error.name : "Error";
|
|
215
|
+
return { code: "redirects.check_failed", message: `The redirect check could not finish (${name}).` };
|
|
216
|
+
}
|
|
@@ -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,
|
|
@@ -270,48 +279,106 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
270
279
|
"body: ProseMirror document (required, not empty)",
|
|
271
280
|
],
|
|
272
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
|
+
},
|
|
273
322
|
});
|
|
274
323
|
|
|
275
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));
|
|
276
327
|
|
|
277
328
|
function typedReference(type) {
|
|
278
329
|
const details = TYPE_DETAILS[type];
|
|
330
|
+
const generated = type === TEMPLATE_GENERATED;
|
|
279
331
|
return Object.freeze({
|
|
280
332
|
type,
|
|
281
333
|
displayName: details.displayName,
|
|
282
334
|
summary: details.summary,
|
|
283
335
|
workspace: Object.freeze({
|
|
284
336
|
directory: `${PAGES_DIRECTORY}/`,
|
|
285
|
-
sourceRule:
|
|
286
|
-
`One source file per page, exactly as for a free-form page: see '${CLI_BINARY_NAME} help page free-form'. `
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.",
|
|
293
350
|
formats: Object.freeze([
|
|
294
|
-
Object.freeze({
|
|
351
|
+
...(generated ? [] : [Object.freeze({
|
|
295
352
|
extension: ".md",
|
|
296
353
|
purpose: "Author the page as Markdown with front matter.",
|
|
297
354
|
metadata: Object.freeze(
|
|
298
355
|
frontMatterKeysFor(type).map((name) => Object.freeze({ name, ...FIELD_HELP[name] })),
|
|
299
356
|
),
|
|
300
|
-
}),
|
|
357
|
+
})]),
|
|
301
358
|
Object.freeze({
|
|
302
359
|
extension: ".pm.json",
|
|
303
|
-
purpose:
|
|
304
|
-
"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.",
|
|
305
364
|
dataFields: Object.freeze(details.dataFields),
|
|
306
365
|
}),
|
|
307
366
|
]),
|
|
308
367
|
}),
|
|
309
|
-
markdown: Object.freeze({ body: details.body, example: details.markdownExample }),
|
|
368
|
+
...(generated ? {} : { markdown: Object.freeze({ body: details.body, example: details.markdownExample }) }),
|
|
310
369
|
document: Object.freeze({ example: details.documentExample, ...(details.note ? { note: details.note } : {}) }),
|
|
311
370
|
workflow: Object.freeze([
|
|
312
|
-
|
|
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." })]),
|
|
313
377
|
Object.freeze({ command: `${CLI_BINARY_NAME} validate`, result: "Check the workspace before sending anything." }),
|
|
314
|
-
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
|
+
}),
|
|
315
382
|
Object.freeze({
|
|
316
383
|
command: `${CLI_BINARY_NAME} approve, deploy --staging, deploy --production`,
|
|
317
384
|
result: "Stage, review and promote, as for any page.",
|
|
@@ -335,9 +402,10 @@ function formatField(field) {
|
|
|
335
402
|
export function formatTypedPageReference(page) {
|
|
336
403
|
const markdown = page.workspace.formats.find((format) => format.extension === ".md");
|
|
337
404
|
const json = page.workspace.formats.find((format) => format.extension === ".pm.json");
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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${
|
|
341
409
|
json.dataFields.map((field) => ` ${field}`).join("\n")
|
|
342
410
|
}\n${page.document.note === undefined ? "" : ` ${page.document.note}\n`}\n.pm.json example:\n${
|
|
343
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
|
|
@@ -67,6 +75,8 @@ const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
|
|
|
67
75
|
]),
|
|
68
76
|
[TEMPLATE_ALBUM]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "seamless", "borderWidth", "place"]),
|
|
69
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([]),
|
|
70
80
|
});
|
|
71
81
|
|
|
72
82
|
export const FRONT_MATTER_KEYS = Object.freeze(
|
|
@@ -96,6 +106,52 @@ const DISPLAY_DATE = /^\d{4}-\d{2}-\d{2}$/u;
|
|
|
96
106
|
const ALBUM_IMAGES_HEADING = "images";
|
|
97
107
|
const RECIPE_HEADINGS = Object.freeze(["ingredients", "instructions"]);
|
|
98
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
|
+
|
|
99
155
|
function fail(code, message, field) {
|
|
100
156
|
return new SiteAuthoringError(code, message, { field });
|
|
101
157
|
}
|
|
@@ -116,6 +172,32 @@ export function isAuthorableTemplateType(wireType) {
|
|
|
116
172
|
return templateNameForWireType(wireType) !== undefined;
|
|
117
173
|
}
|
|
118
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
|
+
|
|
119
201
|
/** The template a canonical workspace document represents. */
|
|
120
202
|
export function documentTemplate(document_) {
|
|
121
203
|
return isPlainObject(document_) && typeof document_.template === "string" && document_.type === undefined
|
|
@@ -262,6 +344,22 @@ function canonicalIngredient(value) {
|
|
|
262
344
|
const DATA_BUILDERS = Object.freeze({
|
|
263
345
|
[TEMPLATE_ARTICLE]: (raw) => definedEntries([["body", raw.body], ["place", raw.place]]),
|
|
264
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
|
+
]),
|
|
265
363
|
[TEMPLATE_ALBUM]: (raw) =>
|
|
266
364
|
definedEntries([
|
|
267
365
|
["introductionBody", raw.introductionBody],
|
|
@@ -339,6 +437,30 @@ function canonicalSourceData(template, data, file) {
|
|
|
339
437
|
body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }),
|
|
340
438
|
});
|
|
341
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
|
+
}
|
|
342
464
|
if (template === TEMPLATE_ALBUM) {
|
|
343
465
|
requireKeys(data, ALBUM_KEYS, file, path);
|
|
344
466
|
const images = requireArray(data.images, file, `${path}.images`);
|
|
@@ -442,6 +564,14 @@ export function typedDocumentFromJson(parsed, file) {
|
|
|
442
564
|
file,
|
|
443
565
|
);
|
|
444
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
|
+
}
|
|
445
575
|
const displayDate = parsed.displayDate === undefined ? undefined : requireDisplayDate(parsed.displayDate, file);
|
|
446
576
|
let coverImageId;
|
|
447
577
|
if (parsed.coverImageId !== undefined) {
|
|
@@ -482,6 +612,17 @@ const PAGE_PROJECTIONS = Object.freeze({
|
|
|
482
612
|
if (body === undefined || rating === undefined || typeof raw.placeId !== "string") return undefined;
|
|
483
613
|
return DATA_BUILDERS[TEMPLATE_PLACE_REVIEW]({ placeId: raw.placeId, rating, body });
|
|
484
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,
|
|
485
626
|
[TEMPLATE_ALBUM]: (raw) =>
|
|
486
627
|
DATA_BUILDERS[TEMPLATE_ALBUM]({
|
|
487
628
|
introductionBody: presentIntroduction(raw.introductionBody),
|
|
@@ -524,9 +665,15 @@ export function workspaceDocumentFromPage(page, listedTemplateType) {
|
|
|
524
665
|
if (name === TEMPLATE_FREE_FORM) return isPlainObject(raw.body) ? raw.body : undefined;
|
|
525
666
|
const data = PAGE_PROJECTIONS[name](raw);
|
|
526
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;
|
|
527
670
|
return typedDocument(name, {
|
|
528
|
-
displayDate: typeof page.displayDate === "string" && page.displayDate !== ""
|
|
529
|
-
|
|
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,
|
|
530
677
|
data,
|
|
531
678
|
});
|
|
532
679
|
}
|
|
@@ -558,7 +705,7 @@ export function contentDocuments(document_) {
|
|
|
558
705
|
/** Whether a push sends `displayDate` for pages of this wire template type (every template but free-form). */
|
|
559
706
|
export function sentDisplayDate(wireType) {
|
|
560
707
|
const name = templateNameForWireType(wireType);
|
|
561
|
-
return name !== undefined && name !== TEMPLATE_FREE_FORM;
|
|
708
|
+
return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED;
|
|
562
709
|
}
|
|
563
710
|
|
|
564
711
|
// The nodes the API accepts in an album or recipe introduction and in a recipe
|
|
@@ -742,6 +889,9 @@ export function wireTemplate(document_) {
|
|
|
742
889
|
? { body: document_ }
|
|
743
890
|
: template === TEMPLATE_PLACE_REVIEW
|
|
744
891
|
? { ...document_.data, rating: PLACE_REVIEW_RATINGS[document_.data.rating] }
|
|
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 }
|
|
745
895
|
: document_.data.place === undefined
|
|
746
896
|
? document_.data
|
|
747
897
|
: { ...document_.data, place: { placeId: document_.data.place } };
|
|
@@ -960,6 +1110,16 @@ function parseFlag(fields, key, file) {
|
|
|
960
1110
|
return value === "true";
|
|
961
1111
|
}
|
|
962
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
|
+
|
|
963
1123
|
/**
|
|
964
1124
|
* Front-matter fields the declared template does not accept.
|
|
965
1125
|
*
|
|
@@ -988,6 +1148,7 @@ export function templateFrontMatterFault(template, fieldNames, file) {
|
|
|
988
1148
|
* module stays pure.
|
|
989
1149
|
*/
|
|
990
1150
|
export async function typedDocumentFromMarkdown({ template, fields, markdown, file, convert, resolveImage }) {
|
|
1151
|
+
if (template === TEMPLATE_GENERATED) throw generatedMarkdownFault(file);
|
|
991
1152
|
const displayDate = fields.has("displayDate") ? requireDisplayDate(fields.get("displayDate"), file) : undefined;
|
|
992
1153
|
let coverImageId;
|
|
993
1154
|
if (fields.has("coverImage")) {
|