@taprootio/site-authoring 0.19.0 → 0.20.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 CHANGED
@@ -645,6 +645,24 @@ Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal `<br>` tags are rejecte
645
645
  with the exact fact field path. A label is optional and stays outside the value's link. See
646
646
  `taproot-site help page free-form` and `taproot-site help component cta`.
647
647
 
648
+ ### Forms by id in 0.20.0
649
+
650
+ A page stores the form it places by the form's id, so renaming a form never
651
+ breaks a page, but a workspace still names a form by its key: `forms/<key>.json`
652
+ and the `formKey` of a `component:form` block are unchanged, and the same files
653
+ still push to a fresh site whose ids all differ. `pages push` looks each key up
654
+ on the site, sends the form's id, and refuses a key the site does not have
655
+ (`pages.form_unknown`) or has archived (`pages.form_archived`) before it writes
656
+ anything; `plan` reports the same. `pages pull` writes the key back. An id the
657
+ site no longer has stays an id in the pulled file, and a push of it is refused
658
+ until you choose a form. Form names are now unique on a site, ignoring case.
659
+ The deploy that introduces this rewrites the stored page bodies, which changes the
660
+ body revision of every page that places a form, so the first `pages pull` after it
661
+ can report `pages.pull_conflict` for such a Markdown page even though its content
662
+ is the same. Run `taproot-site pages push <path>` for that page, or delete the file
663
+ and pull again. `forms validate` and `forms push` also refuse two forms with the
664
+ same name (`forms.name_duplicate`, `forms.name_taken`).
665
+
648
666
  ### Place categories in 0.18.0
649
667
 
650
668
  A place has one category for every site, taken from Google's types, and a place
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/cli.js CHANGED
@@ -239,7 +239,9 @@ const VERBS = Object.freeze([
239
239
  + "and announced on the human channel. New downloads and compared bodies are excluded. "
240
240
  + "A page Taproot generates (a tag, an archive, a place, a folder) is pulled as an update-only '.pm.json' "
241
241
  + "(template 'generated'): you edit its custom title, breadcrumb title, custom description and introduction, "
242
- + "and the manifest keeps the title and description the site reports.",
242
+ + "and the manifest keeps the title and description the site reports. "
243
+ + "A form a page places is written back by its key, the name 'forms/<key>.json' uses, though the site stores "
244
+ + "the form's id.",
243
245
  },
244
246
  {
245
247
  name: VERB_PAGES_META_SET,
@@ -305,6 +307,8 @@ const VERBS = Object.freeze([
305
307
  positionals: "pagePaths",
306
308
  note:
307
309
  "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
310
+ + "A page names a form by key (formKey); the push looks each key up on the site, sends the form's id, and "
311
+ + "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing. "
308
312
  + "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
309
313
  + "(content.link_autolinked) without refusing. "
310
314
  + "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
@@ -394,7 +398,7 @@ const VERBS = Object.freeze([
394
398
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
395
399
  tokens: ["plan"],
396
400
  summary: "Check the whole workspace against the live site and order what is left to send. Writes nothing.",
397
- note: "Runs every check the pushes run — each page against the site's pages, revisions and videos; media that "
401
+ note: "Runs every check the pushes run — each page against the site's pages, revisions, videos and forms; media that "
398
402
  + "pages reference and media upload has not sent; the theme, appearance and footer colors against the site's "
399
403
  + "presentation revision; the footer against its draft; navigation against the live page list — and lists "
400
404
  + "every problem with its area, file, code and field. It then orders the steps that remain: media upload, "
@@ -352,11 +352,14 @@ const SPACER = Object.freeze({
352
352
 
353
353
  /**
354
354
  * A form defined under Site → Forms (or with `forms push`), placed by key. The
355
- * key is checked against the site's forms when the page is published, not here:
356
- * this table only knows the shape.
355
+ * workspace names a form by its key so the same files push to any site; the site
356
+ * stores the form's id, and `pages push` and `pages pull` translate between the
357
+ * two (TR01216). The key is checked against the site's forms when the page is
358
+ * pushed, not here: this table only knows the shape.
357
359
  */
358
360
  const FORM = Object.freeze({
359
- formKey: str(),
361
+ // Required: a placement with no form chosen can never publish, so it is refused here, not at deploy.
362
+ formKey: str({ required: true, nonWhitespace: true }),
360
363
  });
361
364
 
362
365
  function deepFreeze(value) {
@@ -648,9 +651,9 @@ const COMPONENT_DEFINITIONS = Object.freeze({
648
651
  FORM,
649
652
  "Form",
650
653
  "A form from Site → Forms that visitors fill in and submit.",
651
- { formKey: "" },
654
+ {},
652
655
  [
653
- "Set formKey to the key of an existing form ('forms push' creates it); an unknown or archived key fails the publish.",
656
+ "Set formKey to the key of an existing form ('forms push' creates it); 'pages push' refuses an unknown or archived key, and the site stores the form's id, not the key.",
654
657
  "The button text and what happens after a response is sent belong to the form, not the page: set submit_label and after_submit in the form's file (see 'help forms').",
655
658
  "The form's field labels are its accessible names; write each so it makes sense out of context.",
656
659
  ],
@@ -0,0 +1,103 @@
1
+ import { listSiteForms } from "./forms-api.js";
2
+ import { contentDocuments, parseComponentData } from "./typed-pages.js";
3
+
4
+ /**
5
+ * Where the workspace and the site spell a placed form differently (TR01216).
6
+ *
7
+ * A workspace names a form by its key, as `forms/<key>.json` and the Markdown
8
+ * `form` fence do, so the same workspace can be pushed to a fresh site whose
9
+ * ids all differ. A page stored on the site names the form by its id. `pages
10
+ * push` turns keys into ids on the way out and `pages pull` turns ids back
11
+ * into keys, the way a form's `page_path` and `page_resource_id` already
12
+ * translate. The translation is block-scoped: a `formKey` anywhere but a `form`
13
+ * component block is author prose.
14
+ */
15
+
16
+ const FORM_COMPONENT = "form";
17
+
18
+ /** Each `form` component block in the document's content documents, with its parsed data. */
19
+ function formBlocks(document_) {
20
+ const found = [];
21
+ const visit = (value) => {
22
+ if (Array.isArray(value)) {
23
+ value.forEach(visit);
24
+ } else if (value !== null && typeof value === "object") {
25
+ if (value.type === "componentBlock" && value.attrs?.componentType === FORM_COMPONENT) {
26
+ const data = typeof value.attrs.componentData === "string"
27
+ ? parseComponentData(value.attrs.componentData)
28
+ : undefined;
29
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) found.push({ attrs: value.attrs, data });
30
+ return;
31
+ }
32
+ Object.values(value).forEach(visit);
33
+ }
34
+ };
35
+ for (const { doc } of contentDocuments(document_)) visit(doc);
36
+ return found;
37
+ }
38
+
39
+ /** The distinct form keys a workspace document places, in document order. */
40
+ export function placedFormKeys(document_) {
41
+ const keys = [];
42
+ for (const { data } of formBlocks(document_)) {
43
+ if (typeof data.formKey === "string" && data.formKey !== "" && !keys.includes(data.formKey)) keys.push(data.formKey);
44
+ }
45
+ return keys;
46
+ }
47
+
48
+ /** Whether a document read from the site places any form by id. */
49
+ export function placesFormIds(document_) {
50
+ return formBlocks(document_).some(({ data }) => typeof data.formId === "string");
51
+ }
52
+
53
+ /**
54
+ * A copy of the document with every placed form named by id, for the wire. A key
55
+ * the index does not know is left as it is: `placedFormProblems` has already
56
+ * refused the push for it, so nothing sends it.
57
+ */
58
+ export function withFormIds(document_, idByKey) {
59
+ return translate(document_, (data) => {
60
+ const id = typeof data.formKey === "string" ? idByKey.get(data.formKey) : undefined;
61
+ if (id === undefined) return undefined;
62
+ const rest = { ...data };
63
+ delete rest.formKey;
64
+ return { ...rest, formId: id };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * A copy of a document read from the site with every placed form named by key,
70
+ * for the workspace. An id the site no longer has stays an id: pull must not hide
71
+ * what the site holds, and a push of that file is refused until the author picks a form.
72
+ */
73
+ export function withFormKeys(document_, keyById) {
74
+ return translate(document_, (data) => {
75
+ const key = typeof data.formId === "string" ? keyById.get(data.formId) : undefined;
76
+ if (key === undefined) return undefined;
77
+ const rest = { ...data };
78
+ delete rest.formId;
79
+ return { ...rest, formKey: key };
80
+ });
81
+ }
82
+
83
+ function translate(document_, convert) {
84
+ const copy = structuredClone(document_);
85
+ for (const { attrs, data } of formBlocks(copy)) {
86
+ const converted = convert(data);
87
+ if (converted !== undefined) attrs.componentData = JSON.stringify(converted);
88
+ }
89
+ return copy;
90
+ }
91
+
92
+ /** The site's forms by key and by id, archived ones marked. One request serves a whole push or pull. */
93
+ export async function readFormIndex(client, siteId) {
94
+ const idByKey = new Map();
95
+ const keyById = new Map();
96
+ const archivedKeys = new Set();
97
+ for (const form of await listSiteForms(client, siteId)) {
98
+ keyById.set(form.id, form.key);
99
+ if (form.archived) archivedKeys.add(form.key);
100
+ else idByKey.set(form.key, form.id);
101
+ }
102
+ return { idByKey, keyById, archivedKeys };
103
+ }
@@ -49,9 +49,33 @@ export async function readWorkspaceForms(workspaceDir, keys, validationOptions =
49
49
  const document_ = await readWorkspaceJson(workspaceDir, file, FORM_LIMITS.fileBytes);
50
50
  forms.push({ key, file, document: validateFormDocument(document_, key, validationOptions) });
51
51
  }
52
+ requireDistinctNames(forms);
52
53
  return forms;
53
54
  }
54
55
 
56
+ /** A form name as the site compares it: trimmed, ignoring case (TR01216). */
57
+ export function comparableFormName(name) {
58
+ return String(name).trim().toLowerCase();
59
+ }
60
+
61
+ /** Names are unique per site, so two files in one push cannot share one. */
62
+ function requireDistinctNames(forms) {
63
+ const seen = new Map();
64
+ for (const form of forms) {
65
+ const name = comparableFormName(form.document.name);
66
+ const other = seen.get(name);
67
+ if (other !== undefined) {
68
+ throw new SiteAuthoringError(
69
+ "forms.name_duplicate",
70
+ `${form.file} and ${other} both name the form '${form.document.name}'. A form name is unique on a site, `
71
+ + "ignoring case; rename one of them.",
72
+ { field: `${form.file}:name` },
73
+ );
74
+ }
75
+ seen.set(name, form.file);
76
+ }
77
+ }
78
+
55
79
  /**
56
80
  * The forms baseline in the pull manifest (TR01061).
57
81
  *
@@ -86,7 +86,7 @@ import {
86
86
 
87
87
  export { getAppearanceReference, getFooterReference, getThemeReference };
88
88
 
89
- export const REFERENCE_VERSION = 36;
89
+ export const REFERENCE_VERSION = 37;
90
90
 
91
91
  // The date-limit examples `help forms` prints; tests run them through the form validator.
92
92
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -775,7 +775,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
775
775
  + "version of each form this workspace last saw. 'forms validate' needs only taproot-site.json.",
776
776
  `The file is { key, name, sink, retention_days, notify_owner, definition }. key must equal the file's name `
777
777
  + `and is lowercase letters, digits, and single hyphens, at most ${FORM_KEY_MAXIMUM_LENGTH} characters. name is `
778
- + `1 to ${FORM_LIMITS.nameCharacters} characters. sink is one of ${FORM_SINKS.join(", ")} and defaults to `
778
+ + `1 to ${FORM_LIMITS.nameCharacters} characters and unique on the site, ignoring case: two files, or a file and another form on the site, with the same name `
779
+ + "are refused (forms.name_duplicate, forms.name_taken) before anything is written. "
780
+ + `sink is one of ${FORM_SINKS.join(", ")} and defaults to `
779
781
  + "'none' (store submissions only); 'newsletter' requests double-opt-in confirmation for the contact address. "
780
782
  + "A newsletter sink requires a required email contact field and at least one consent checkbox; every consent checkbox must be checked. "
781
783
  + `retention_days is ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum} (a site's plan may cap it lower): `
@@ -846,8 +848,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
846
848
  "Submissions are never pulled or pushed. They are visitors' personal data: read, mark, export and delete "
847
849
  + "them on the site's Forms page.",
848
850
  "To show a form, place the free-form component 'form' on a page (a component:form block) with formKey set "
849
- + "to the form's key ('help component form' lists its properties). Push the form first and deploy: the release "
850
- + "freezes the form it was built with.",
851
+ + "to the form's key ('help component form' lists its properties). Push the form first: 'pages push' turns the "
852
+ + "key into the form's id for the site and refuses a key the site does not have or has archived, and 'pages pull' "
853
+ + "turns the id back into the key. Then deploy: the release freezes the form it was built with.",
851
854
  "The delegation.content capability carries site.forms.manage, which every forms verb needs: it covers "
852
855
  + "definitions, settings and status. Visitors' submissions are in no capability: reading them "
853
856
  + "(site.forms.submissions.read) and finding, marking or deleting them (site.forms.submissions.manage) belong "
@@ -769,7 +769,7 @@ export const COVER_IMAGE_UNUSED_MESSAGE = "The selected cover image must be used
769
769
  * component JSON, so a string that only parses once decoded is read the way the
770
770
  * server reads it.
771
771
  */
772
- function parseComponentData(text) {
772
+ export function parseComponentData(text) {
773
773
  let decoded = text;
774
774
  for (let round = 0; round < 3 && /&(?:quot|amp|#39|lt|gt);/u.test(decoded); round += 1) {
775
775
  decoded = decoded.replace(/&quot;/gu, "\"").replace(/&#39;/gu, "'").replace(/&lt;/gu, "<").replace(/&gt;/gu, ">")
@@ -5,11 +5,12 @@ import {
5
5
  createSiteForm,
6
6
  getPageIndex,
7
7
  getSiteFormByKey,
8
+ listSiteForms,
8
9
  saveSiteFormVersion,
9
10
  updateSiteFormSettings,
10
11
  } from "../forms-api.js";
11
12
  import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition, validateNewsletterDefinition } from "../forms-contract.js";
12
- import { readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
13
+ import { comparableFormName, readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
13
14
  import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
14
15
  import { ApiError } from "../transport.js";
15
16
  import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
@@ -155,6 +156,26 @@ export async function formsPush(invocation) {
155
156
  );
156
157
  }
157
158
 
159
+ // A form name is unique on the site, ignoring case, so a name another form holds is refused here with
160
+ // the rest of the checks, not by the first write that reaches it.
161
+ const naming = plans.filter((plan) => plan.action === "create" || plan.settingsChanged);
162
+ if (naming.length > 0) {
163
+ const siteForms = await withRefusalGuidance(onProgress, "forms push", async () => await listSiteForms(client, siteId));
164
+ for (const plan of naming) {
165
+ const name = comparableFormName(plan.desired.name);
166
+ const holder = siteForms.find((siteForm) => siteForm.key !== plan.form.key && comparableFormName(siteForm.name) === name);
167
+ if (holder !== undefined) {
168
+ throw new SiteAuthoringError(
169
+ "forms.name_taken",
170
+ `${formFileName(plan.form.key)}: the site's form '${holder.key}'${holder.archived ? " (archived)" : ""} already `
171
+ + `has the name '${plan.desired.name}'. A form name is unique on a site, ignoring case; rename one of them. `
172
+ + "Nothing was written.",
173
+ { field: `${formFileName(plan.form.key)}:name` },
174
+ );
175
+ }
176
+ }
177
+ }
178
+
158
179
  const results = [];
159
180
  for (const plan of plans) {
160
181
  let { desired } = plan;
@@ -21,6 +21,7 @@ import {
21
21
  } from "../authors-contract.js";
22
22
  import { VERB_PAGES_PUSH } from "../constants.js";
23
23
  import { autolinkWarnings } from "../content/link-warnings.js";
24
+ import { placedFormKeys, readFormIndex, withFormIds } from "../form-references.js";
24
25
  import { ApiError } from "../transport.js";
25
26
  import { waitForProcessing } from "./media-upload.js";
26
27
  import { SiteAuthoringError } from "../errors.js";
@@ -878,6 +879,46 @@ export async function placedVideoProblems(client, siteId, planned) {
878
879
  return problems;
879
880
  }
880
881
 
882
+ /**
883
+ * The pages that place a form the site does not have, or has archived, and the
884
+ * site's forms by key for the wire (TR01216). A page is stored with the form's id,
885
+ * so a key that names no active form cannot be sent at all. Pages that will not be
886
+ * sent are not asked about, and a push that places no form reads nothing.
887
+ */
888
+ export async function placedFormProblems(client, siteId, planned) {
889
+ const placements = new Map();
890
+ for (const page of planned) {
891
+ if (page.unchanged) continue;
892
+ for (const key of placedFormKeys(page.document)) {
893
+ if (!placements.has(key)) placements.set(key, page.file);
894
+ }
895
+ }
896
+ if (placements.size === 0) return { problems: [], idByKey: new Map() };
897
+
898
+ const index = await readFormIndex(client, siteId);
899
+ const problems = [];
900
+ for (const [key, file] of placements) {
901
+ if (index.idByKey.has(key)) continue;
902
+ problems.push(...problemsOf(
903
+ index.archivedKeys.has(key)
904
+ ? documentError(
905
+ "pages.form_archived",
906
+ `'${file}' places the form '${key}', which is archived. Restore the form under Site → Forms or remove it `
907
+ + "from the page.",
908
+ file,
909
+ )
910
+ : documentError(
911
+ "pages.form_unknown",
912
+ `'${file}' places the form '${key}', which this site does not have. Run 'taproot-site forms push' to create `
913
+ + "it, or fix the key.",
914
+ file,
915
+ ),
916
+ { area: CHECK_AREA.pages, file },
917
+ ));
918
+ }
919
+ return { problems, idByKey: index.idByKey };
920
+ }
921
+
881
922
  /**
882
923
  * Gives each `video-embed` block that has no poster the provider's thumbnail,
883
924
  * copied into the site's images by the server (TR01109). One request per provider
@@ -1565,6 +1606,9 @@ export async function pagesPush(invocation) {
1565
1606
  // push, before anything is written, so a page cannot be sent that
1566
1607
  // publishing would then refuse.
1567
1608
  plan.problems.push(...await placedVideoProblems(client, siteId, planned));
1609
+ // Likewise a form: pages store its id, which only the site's own forms can supply.
1610
+ const placedForms = await placedFormProblems(client, siteId, planned);
1611
+ plan.problems.push(...placedForms.problems);
1568
1612
 
1569
1613
  // Under apply, the workspace read ledger has already held every source and
1570
1614
  // manifest to the plan. What it cannot see is the site: every page the plan
@@ -1634,7 +1678,7 @@ export async function pagesPush(invocation) {
1634
1678
  path: page.pagePath,
1635
1679
  title: page.title,
1636
1680
  shortDescription: page.description,
1637
- template: wireTemplate(page.document),
1681
+ template: wireTemplate(withFormIds(page.document, placedForms.idByKey)),
1638
1682
  ...wirePageFields(page.document),
1639
1683
  // Only a name the site has not been told: repeating the author a page
1640
1684
  // already has would let a later change of that person's permissions
package/src/verbs/plan.js CHANGED
@@ -45,6 +45,7 @@ import { navPush, validateNavigationWorkspaceDocument } from "./nav-push.js";
45
45
  import {
46
46
  liveAuthorsRefresher,
47
47
  pagesPush,
48
+ placedFormProblems,
48
49
  placedVideoProblems,
49
50
  planPages,
50
51
  plannedPageItem,
@@ -283,6 +284,7 @@ async function preflight(session, invocation) {
283
284
  });
284
285
  problems.push(...pages.problems);
285
286
  problems.push(...await placedVideoProblems(client, siteId, pages.planned));
287
+ problems.push(...(await placedFormProblems(client, siteId, pages.planned)).problems);
286
288
  const sending = pages.planned.filter((page) => !page.unchanged);
287
289
 
288
290
  // An image a page names must be one the site holds; an image this plan
@@ -433,7 +435,7 @@ function planReport(planned) {
433
435
  ready: problems.length === 0,
434
436
  steps,
435
437
  checked: [
436
- "every page source, against the live site's pages, revisions, and video library",
438
+ "every page source, against the live site's pages, revisions, video library, and the forms it places",
437
439
  "media under media/ that pages reference and media upload has not sent",
438
440
  "the theme, appearance, and footer colors, against the site's presentation revision",
439
441
  "the footer document, against the site's footer draft and the pages it still holds",
@@ -441,7 +443,7 @@ function planReport(planned) {
441
443
  "navigation shape and page targets, against the live page list and navigation",
442
444
  ],
443
445
  notChecked: [
444
- "redirects and forms: run 'redirects push' and 'forms push' on their own",
446
+ "redirects, and the forms files themselves: run 'redirects push' and 'forms push' on their own",
445
447
  "rendering: preview pages before approving them",
446
448
  "approval, staging, and production, which stay separate commands",
447
449
  ],
package/src/verbs/pull.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  SETTINGS_TYPE_TAPROOT_STYLES,
34
34
  } from "../settings-catalog.js";
35
35
  import { ApiError } from "../transport.js";
36
+ import { placesFormIds, readFormIndex, withFormKeys } from "../form-references.js";
36
37
  import {
37
38
  generatedIdentity,
38
39
  isAuthorableTemplateType,
@@ -643,6 +644,15 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
643
644
 
644
645
  export async function pull(invocation) {
645
646
  const { client, config, siteId, surface, now, onProgress } = await openSession(invocation);
647
+ // A page names its forms by id on the site and by key in the workspace (TR01216). The
648
+ // site's forms are read once, and only when a page body places one.
649
+ let formKeys;
650
+ const workspaceBodyFromPage = async (page, listedTemplateType) => {
651
+ const body = workspaceDocumentFromPage(page, listedTemplateType);
652
+ if (body === undefined || !placesFormIds(body)) return body;
653
+ formKeys ??= readFormIndex(client, siteId).then((index) => index.keyById);
654
+ return withFormKeys(body, await formKeys);
655
+ };
646
656
  await ensureWorkspaceRoot(config);
647
657
 
648
658
  await requireWorkspaceBelongsToSite(config.workspaceDir, siteId);
@@ -891,7 +901,7 @@ export async function pull(invocation) {
891
901
  if (source === undefined) continue;
892
902
  onProgress(`Reading page '${summary.path}'.`);
893
903
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
894
- const body = workspaceDocumentFromPage(page, summary.templateType);
904
+ const body = await workspaceBodyFromPage(page, summary.templateType);
895
905
  const description = typeof page.shortDescription === "string" ? page.shortDescription : "";
896
906
  if (body === undefined) {
897
907
  // The site's body is unreadable, but the workspace file is still this
@@ -1261,7 +1271,7 @@ export async function pull(invocation) {
1261
1271
  }
1262
1272
  onProgress(`Reading page '${summary.path}'.`);
1263
1273
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
1264
- const body = workspaceDocumentFromPage(page, summary.templateType);
1274
+ const body = await workspaceBodyFromPage(page, summary.templateType);
1265
1275
  entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
1266
1276
  entry.title = titleFromRead(page, summary);
1267
1277
  entry.path = pathFromRead(page, summary);