@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.
@@ -35,6 +35,28 @@ function environmentFor(target) {
35
35
  return target === DEPLOY_TARGET_STAGING ? DEPLOYMENT_ENVIRONMENT_STAGING : DEPLOYMENT_ENVIRONMENT_PRODUCTION;
36
36
  }
37
37
 
38
+ /**
39
+ * The site-relative routes to verify, one page of every template first.
40
+ *
41
+ * `checkDelivery` looks at a bounded number of routes. A site with a long blog
42
+ * would otherwise spend the whole allowance on its first pages and never fetch
43
+ * the recipe, album or review that renders through a different template, which
44
+ * is exactly what a delivery check on a typed-content site has to prove (TR00893).
45
+ */
46
+ function routesToCheck(pages) {
47
+ const usable = pages.filter((page) => page !== null && typeof page === "object" && typeof page.path === "string");
48
+ const seenTemplates = new Set();
49
+ const firstOfTemplate = [];
50
+ const rest = [];
51
+ for (const page of usable) {
52
+ (seenTemplates.has(page.templateType) ? rest : firstOfTemplate).push(page);
53
+ seenTemplates.add(page.templateType);
54
+ }
55
+ return [...firstOfTemplate, ...rest].map((page) =>
56
+ page.path === "" ? "/" : `/${page.path.replace(/^\/+/u, "")}/`.replace(/\/\/$/u, "/")
57
+ );
58
+ }
59
+
38
60
  async function resolveTarget(client, siteId, target, invocation, onProgress, now) {
39
61
  if (typeof invocation.deliveryUrl === "string" && invocation.deliveryUrl !== "") {
40
62
  if (target === DEPLOY_TARGET_STAGING) {
@@ -118,9 +140,7 @@ export async function deliveryCheck(invocation) {
118
140
  }
119
141
 
120
142
  const resolved = await resolveTarget(client, siteId, target, invocation, onProgress, now);
121
- const routes = (manifest?.pages ?? [])
122
- .filter((page) => page !== null && typeof page === "object" && typeof page.path === "string")
123
- .map((page) => (page.path === "" ? "/" : `/${page.path.replace(/^\/+/u, "")}/`.replace(/\/\/$/u, "/")));
143
+ const routes = routesToCheck(manifest?.pages ?? []);
124
144
  onProgress(`Checking ${resolved.baseUrl} (${Math.min(routes.length + 1, DELIVERY_LIMITS.routes)} route(s), assets, runtime).`);
125
145
  const report = await checkDelivery({
126
146
  fetch: client.fetch,
@@ -27,24 +27,18 @@ import { ApiError } from "../transport.js";
27
27
  import { readManifest, writeManifest } from "../workspace.js";
28
28
 
29
29
  /**
30
- * What a deploy does not finish (TR00702).
30
+ * When a redirect goes live (TR00702, TR00968).
31
31
  *
32
- * A deploy writes the site's redirect and gone entries into the edge's
33
- * key-value store as part of syncing routing. That store is eventually
34
- * consistent, so a spot-check run the second a deploy reports success can
35
- * briefly read the previous map, and taking that for "the redirect did not
36
- * land" is the wrong conclusion to reach in front of a customer waiting to cut
37
- * DNS over.
38
- *
39
- * No number is quoted, because there is none to quote: the coordinator holds
40
- * nothing back for a standard site's redirects. Its propagation grace governs
41
- * only the *deletion* of a superseded Docs pointer namespace; a standard site's
42
- * rows are written and removed immediately.
32
+ * A redirect or gone entry is a file inside the release this deploy publishes.
33
+ * It goes live with that release and no other way: staging shows it as soon as
34
+ * the staged release is served, production shows it when the release is
35
+ * promoted. There is no separate store to wait on, so a check that does not see
36
+ * an entry is looking at a release that does not carry it yet.
43
37
  */
44
- const REDIRECT_PROPAGATION_NOTE =
45
- "Redirect and gone entries are written to the edge's key-value store when this deploy syncs routing. That "
46
- + "store is eventually consistent, so a spot-check run immediately afterwards can briefly still see the "
47
- + "previous map; re-check before concluding an entry is missing.";
38
+ const REDIRECT_ACTIVATION_NOTE =
39
+ "Redirect and gone entries live inside the release this deploy publishes: staging serves them with the staged "
40
+ + "release and production serves them once that release is promoted. There is no separate propagation delay; "
41
+ + "an entry a check does not see belongs to a release that is not being served yet.";
48
42
 
49
43
  /**
50
44
  * `deploy --staging` / `deploy --production` — readiness, deploy, poll to
@@ -158,7 +152,7 @@ async function inspectStagingPreview(client, siteId, onProgress, now) {
158
152
  checks = await checkStagingRedirects(client, siteId, onProgress, now);
159
153
  if (!checks.redirects.verified) {
160
154
  onProgress(
161
- "Warning: staging redirects are not all verified. Edge propagation may still be pending; run 'taproot-site redirects check' again.",
155
+ "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.",
162
156
  );
163
157
  }
164
158
  } catch {
@@ -288,7 +282,7 @@ export async function deploy(invocation) {
288
282
  now,
289
283
  });
290
284
  await recordDeployment(config, manifest, "production", completed);
291
- if (!presentationOnly) onProgress(REDIRECT_PROPAGATION_NOTE);
285
+ if (!presentationOnly) onProgress(REDIRECT_ACTIVATION_NOTE);
292
286
  return successResult(VERB_DEPLOY, siteId, {
293
287
  target,
294
288
  environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
@@ -398,7 +392,7 @@ export async function deploy(invocation) {
398
392
  });
399
393
  }
400
394
  const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
401
- onProgress(REDIRECT_PROPAGATION_NOTE);
395
+ onProgress(REDIRECT_ACTIVATION_NOTE);
402
396
  return successResult(VERB_DEPLOY, siteId, {
403
397
  target,
404
398
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -1,16 +1,25 @@
1
- import {
2
- createPage,
3
- freeFormTemplate,
4
- listSitePages,
5
- PAGE_STATUS_DELETED,
6
- TEMPLATE_TYPE_FREE_FORM,
7
- updatePage,
8
- withRefusalGuidance,
9
- } from "../api.js";
1
+ import { createPage, listSitePages, PAGE_STATUS_DELETED, updatePage, withRefusalGuidance } from "../api.js";
10
2
  import { VERB_PAGES_PUSH } from "../constants.js";
11
3
  import { SiteAuthoringError } from "../errors.js";
12
4
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
13
5
  import { SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
6
+ import {
7
+ contentDocuments,
8
+ COVER_IMAGE_UNUSED_MESSAGE,
9
+ documentTemplate,
10
+ FRONT_MATTER_KEYS,
11
+ PAGE_TEMPLATES,
12
+ requireTemplateName,
13
+ sentDisplayDate,
14
+ TEMPLATE_FREE_FORM,
15
+ templateFrontMatterFault,
16
+ typedDocumentFromJson,
17
+ typedDocumentFromMarkdown,
18
+ unusedCoverImageId,
19
+ unsupportedRestrictedNode,
20
+ wirePageFields,
21
+ wireTemplate,
22
+ } from "../typed-pages.js";
14
23
  import {
15
24
  hasNamedFreeFormSectionContext,
16
25
  sharedThemeContextNames,
@@ -31,7 +40,7 @@ import {
31
40
  PAGES_DIRECTORY,
32
41
  readManifest,
33
42
  readMediaManifest,
34
- readObservedPageRevision,
43
+ readObservedPageRecord,
35
44
  readWorkspaceFile,
36
45
  readWorkspaceJson,
37
46
  requireManifestSourceRegistry,
@@ -40,6 +49,8 @@ import {
40
49
  SYSTEM_PAGE_NOT_FOUND_PATH,
41
50
  walkWorkspaceFiles,
42
51
  WORKSPACE_LIMITS,
52
+ pageContentKey,
53
+ canonicalDocumentHash,
43
54
  workspaceContentHash,
44
55
  workspaceFileExists,
45
56
  writeManifest,
@@ -75,8 +86,10 @@ import {
75
86
  * Wire facts the body honors:
76
87
  * - Home (path `""`) and `404` are seeded system pages: update-only, and their
77
88
  * paths are immutable (`SystemPagePaths.IsPathChangeAllowed`).
78
- * - A page's template type is immutable after creation, so an update against a
79
- * page that is not FREE_FORM is refused rather than sent.
89
+ * - A page's template type is immutable after creation, so a source whose
90
+ * template differs from the live page's is refused rather than sent. The
91
+ * templates are free-form, article, recipe, album and place review; the
92
+ * read-only system pages stay out of reach.
80
93
  * - Creates are POST and updates are PATCH with a *whole* template, not a patch
81
94
  * mask. Neither is replayed by the transport, so each is issued once and an
82
95
  * ambiguous outcome surfaces as ambiguous.
@@ -84,7 +97,6 @@ import {
84
97
 
85
98
  const MAXIMUM_REPORTED = 200;
86
99
  const FRONT_MATTER_FENCE = "---";
87
- const FRONT_MATTER_KEYS = new Set(["title", "path", "description"]);
88
100
  const FRONT_MATTER_ENTRY = /^([A-Za-z][A-Za-z0-9_]*)[ \t]*:[ \t]*(.*)$/u;
89
101
 
90
102
  /**
@@ -122,10 +134,10 @@ function unquote(value) {
122
134
  }
123
135
 
124
136
  /**
125
- * A deliberately tiny front-matter reader: exactly the three keys the page
126
- * contract needs, and a hard error on anything else. Silently dropping an
127
- * unrecognized key is how authored metadata disappears without a trace, and the
128
- * server would never notice.
137
+ * A deliberately tiny front-matter reader: exactly the keys the page contract
138
+ * names (`FRONT_MATTER_KEYS`), and a hard error on anything else. Silently
139
+ * dropping an unrecognized key is how authored metadata disappears without a
140
+ * trace, and the server would never notice.
129
141
  */
130
142
  function parseFrontMatter(source, file) {
131
143
  const lines = source.split(/\r?\n/u);
@@ -198,10 +210,24 @@ function parseFrontMatter(source, file) {
198
210
  if (distinct.length === 1) fields.set(key, distinct[0]);
199
211
  else candidates.set(key, distinct);
200
212
  }
213
+ // Which keys are legal depends on the template, so this can only run once
214
+ // every entry has been read. It is a fault on the file like any other bad
215
+ // entry: the path may still be readable.
216
+ let template = TEMPLATE_FREE_FORM;
217
+ if (fields.has("template")) {
218
+ try {
219
+ template = requireTemplateName(fields.get("template"), file);
220
+ } catch (error) {
221
+ record(error);
222
+ }
223
+ }
224
+ const stray = templateFrontMatterFault(template, [...values.keys()], file);
225
+ if (stray !== undefined) record(stray);
201
226
  return {
202
227
  fields,
203
228
  candidates,
204
229
  fault,
230
+ template,
205
231
  markdown: lines.slice(closing + 1).join("\n"),
206
232
  };
207
233
  }
@@ -327,15 +353,16 @@ function requireContentFunctions(content) {
327
353
  return content;
328
354
  }
329
355
 
330
- function assertValid(content, document_, file) {
356
+ function assertValid(content, document_, file, { mayBeEmpty = false } = {}) {
331
357
  const outcome = content.validateDocument(document_);
332
- const errors = outcome === null || typeof outcome !== "object" ? undefined : outcome.errors;
333
- if (!Array.isArray(errors)) {
358
+ const reported = outcome === null || typeof outcome !== "object" ? undefined : outcome.errors;
359
+ if (!Array.isArray(reported)) {
334
360
  throw new SiteAuthoringError(
335
361
  "content.contract_invalid",
336
362
  "The content validator did not return a list of validation errors.",
337
363
  );
338
364
  }
365
+ const errors = mayBeEmpty ? reported.filter((error) => error?.code !== CONTENT_ERROR_CODES.emptyDocument) : reported;
339
366
  if (errors.length === 0) return;
340
367
  const first = errors[0] ?? {};
341
368
  const location = typeof first.path === "string" && first.path !== "" ? first.path : "doc";
@@ -358,6 +385,20 @@ function requireText(value, code, message, field) {
358
385
  return value;
359
386
  }
360
387
 
388
+ /**
389
+ * The template a `.pm.json` source declares, read without validating anything
390
+ * else. A file that is not JSON, or is a bare document, reads as free-form here;
391
+ * the conversion step is the one that reports why it cannot be used.
392
+ */
393
+ function peekJsonTemplate(text) {
394
+ try {
395
+ const parsed = JSON.parse(text);
396
+ return documentTemplate(parsed);
397
+ } catch {
398
+ return TEMPLATE_FREE_FORM;
399
+ }
400
+ }
401
+
361
402
  /**
362
403
  * Reads one editable page source and resolves the metadata that says *which
363
404
  * page it is* — no Markdown conversion, no media resolution, no document
@@ -381,8 +422,12 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
381
422
  let title;
382
423
  let declaredDescription;
383
424
  let declaredPath;
425
+ let template = TEMPLATE_FREE_FORM;
426
+ let fields;
384
427
  if (isMarkdown) {
385
428
  const frontMatter = parseFrontMatter(source, file);
429
+ template = frontMatter.template;
430
+ fields = frontMatter.fields;
386
431
  title = frontMatter.fields.get("title") ?? manifestEntry?.title;
387
432
  declaredDescription = frontMatter.fields.get("description") ?? manifestEntry?.description;
388
433
  declaredPath = frontMatter.fields.has("path") ? frontMatter.fields.get("path") : manifestEntry?.path;
@@ -401,6 +446,7 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
401
446
  title = manifestEntry.title;
402
447
  declaredDescription = manifestEntry.description;
403
448
  declaredPath = manifestEntry.path;
449
+ template = peekJsonTemplate(source);
404
450
  }
405
451
 
406
452
  const pagePath = normalizePagePath(declaredPath);
@@ -445,6 +491,8 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
445
491
  declaredDescription,
446
492
  fault,
447
493
  pathClaims: [],
494
+ template,
495
+ fields,
448
496
  sourceFormat: isMarkdown ? PAGE_SOURCE_FORMAT_MARKDOWN : PAGE_SOURCE_FORMAT_PROSEMIRROR,
449
497
  sourceHash: workspaceContentHash(bytes),
450
498
  markdown,
@@ -491,6 +539,33 @@ function requireReconciledRevision({ file, pagePath, target, entry, observedRevi
491
539
  );
492
540
  }
493
541
 
542
+ /**
543
+ * The same guard for a typed page's display date, which the revision does not
544
+ * cover. It refuses only when this push would change a date that moved on the
545
+ * site since the workspace last saw it: an unset date is left alone by the
546
+ * update, and a pull that showed the operator a conflict has already handed
547
+ * them the site's version.
548
+ */
549
+ function requireReconciledDisplayDate({ file, pagePath, target, entry, document_, observed }) {
550
+ const sent = documentTemplate(document_) === TEMPLATE_FREE_FORM ? undefined : document_.displayDate;
551
+ const recorded = entry?.baseline?.displayDate;
552
+ const live = target.displayDate;
553
+ if (sent === undefined || recorded === undefined) return;
554
+ // A refused pull records the date it showed. Only that date is overridable: a
555
+ // page whose date moved again since is refused again, like a moved revision.
556
+ // A record from a pull that named no date still counts as having shown it.
557
+ if (observed !== undefined && (observed.displayDate === undefined || observed.displayDate === live)) return;
558
+ if (typeof recorded !== "string" || live === recorded || live === sent) return;
559
+ throw new SiteAuthoringError(
560
+ "pages.push_conflict",
561
+ `Page '${pagePath || "/"}' has a different display date on the site (${live || "none"}) than the one this `
562
+ + `workspace last reconciled with (${recorded || "none"}), and '${file}' would replace it with `
563
+ + `${sent || "none"}. No page was pushed. Run 'taproot-site pull' to adopt the site's date, or delete `
564
+ + `'${file}' and pull again.`,
565
+ { field: file, alternatives: [recorded, live] },
566
+ );
567
+ }
568
+
494
569
  /**
495
570
  * The metadata checks that are *not* part of deciding which page a source is.
496
571
  *
@@ -516,13 +591,23 @@ export async function convertWorkspacePageSource({ source, mediaManifest, conten
516
591
  const content = requireContentFunctions(await loadContentModule(injectedContent));
517
592
  const { file } = source;
518
593
  if (source.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN) {
519
- const converted = await content.markdownToProseMirror(source.markdown, {
520
- resolveImage: makeResolveImage(mediaManifest, file),
521
- });
522
- return unwrapProseMirrorDocument(
523
- converted === null || typeof converted !== "object" ? undefined : converted.doc,
594
+ const resolveImage = makeResolveImage(mediaManifest, file);
595
+ const convert = async (markdown) => {
596
+ const converted = await content.markdownToProseMirror(markdown, { resolveImage });
597
+ return unwrapProseMirrorDocument(
598
+ converted === null || typeof converted !== "object" ? undefined : converted.doc,
599
+ file,
600
+ );
601
+ };
602
+ if (source.template === TEMPLATE_FREE_FORM) return await convert(source.markdown);
603
+ return await typedDocumentFromMarkdown({
604
+ template: source.template,
605
+ fields: source.fields,
606
+ markdown: source.markdown,
524
607
  file,
525
- );
608
+ convert,
609
+ resolveImage,
610
+ });
526
611
  }
527
612
  let parsed;
528
613
  try {
@@ -530,6 +615,7 @@ export async function convertWorkspacePageSource({ source, mediaManifest, conten
530
615
  } catch {
531
616
  throw documentError("pages.document_shape", `'${file}' is not valid JSON.`, file);
532
617
  }
618
+ if (source.template !== TEMPLATE_FREE_FORM) return typedDocumentFromJson(parsed, file);
533
619
  return unwrapProseMirrorDocument(parsed, file);
534
620
  }
535
621
 
@@ -572,20 +658,44 @@ export async function validateWorkspacePageDocument({
572
658
  getSharedThemeContexts,
573
659
  }) {
574
660
  const content = requireContentFunctions(await loadContentModule(injectedContent));
575
- assertValid(content, document_, file);
576
- if (hasNamedFreeFormSectionContext(document_)) {
577
- const contexts = typeof getSharedThemeContexts === "function"
578
- ? await getSharedThemeContexts()
579
- : await readSharedThemeContexts(workspaceDir, siteId);
580
- const contextErrors = validateFreeFormSectionContexts(document_, contexts).errors;
581
- if (contextErrors.length > 0) {
582
- const first = contextErrors[0];
661
+ // A typed page carries several ProseMirror documents (an introduction, each
662
+ // recipe step); every one goes through the same vocabulary and context checks
663
+ // a free-form body does.
664
+ const unusedCover = unusedCoverImageId(document_);
665
+ if (unusedCover !== undefined) {
666
+ throw documentError(
667
+ "pages.cover_image_unused",
668
+ `${COVER_IMAGE_UNUSED_MESSAGE} '${file}' selects cover image ${unusedCover}, but no image in the page's own `
669
+ + "content uses it: place that image in the body (or introduction, steps, or album images), or remove coverImage.",
670
+ `${file}:coverImageId`,
671
+ );
672
+ }
673
+ for (const { doc, path, mayBeEmpty, restricted } of contentDocuments(document_)) {
674
+ const location = path === "" ? file : `${file}:${path}`;
675
+ assertValid(content, doc, location, { mayBeEmpty });
676
+ const unsupported = restricted ? unsupportedRestrictedNode(doc) : undefined;
677
+ if (unsupported !== undefined) {
583
678
  throw documentError(
584
- first.code,
585
- `'${file}' has an invalid section context at ${first.path}: ${first.message}`,
586
- `${file}:${first.path}`,
679
+ "pages.node_unsupported_for_template",
680
+ `'${location}' uses the '${unsupported}' node, which a ${documentTemplate(document_)} introduction or step `
681
+ + "does not accept. Put that content in a free-form page or an article, or remove it.",
682
+ location,
587
683
  );
588
684
  }
685
+ if (hasNamedFreeFormSectionContext(doc)) {
686
+ const contexts = typeof getSharedThemeContexts === "function"
687
+ ? await getSharedThemeContexts()
688
+ : await readSharedThemeContexts(workspaceDir, siteId);
689
+ const contextErrors = validateFreeFormSectionContexts(doc, contexts).errors;
690
+ if (contextErrors.length > 0) {
691
+ const first = contextErrors[0];
692
+ throw documentError(
693
+ first.code,
694
+ `'${location}' has an invalid section context at ${first.path}: ${first.message}`,
695
+ `${location}:${first.path}`,
696
+ );
697
+ }
698
+ }
589
699
  }
590
700
  }
591
701
 
@@ -758,6 +868,7 @@ export async function pagesPush(invocation) {
758
868
  const { file, pagePath, title, declaredDescription } = source;
759
869
  onProgress(`Validating '${file}'.`);
760
870
  const manifestEntry = manifestByFile.get(file);
871
+ let observedRecord;
761
872
  requireCompletePageMetadata(source);
762
873
  const document_ = await convertWorkspacePageSource({ source, mediaManifest, content });
763
874
 
@@ -786,11 +897,13 @@ export async function pagesPush(invocation) {
786
897
  file,
787
898
  );
788
899
  }
789
- if (target.templateType !== TEMPLATE_TYPE_FREE_FORM) {
900
+ const sourceTemplate = documentTemplate(document_);
901
+ if (target.templateType !== PAGE_TEMPLATES[sourceTemplate].wireType) {
790
902
  throw documentError(
791
903
  "pages.template_immutable",
792
- `'${file}' targets page '${livePath}', whose template type is ${target.templateType}. `
793
- + "A page's template type is immutable after creation.",
904
+ `'${file}' is a ${sourceTemplate} source, but page '${livePath}' has template type `
905
+ + `${target.templateType}. A page's template type is immutable after creation: author the page `
906
+ + "with the template it already has, or create a new page at another path.",
794
907
  file,
795
908
  );
796
909
  }
@@ -799,13 +912,17 @@ export async function pagesPush(invocation) {
799
912
  // page is written, rather than discovered after the site has changed.
800
913
  const observedRecordFile = internalPageObservedRevisionFile(target.pageId);
801
914
  if (observedRecordFile !== undefined) await workspaceFileExists(config.workspaceDir, observedRecordFile);
802
- requireReconciledRevision({
915
+ const observed = await readObservedPageRecord(config.workspaceDir, target.pageId);
916
+ observedRecord = observed;
917
+ const guard = {
803
918
  file,
804
919
  pagePath,
805
920
  target,
806
921
  entry: manifestByPageId.get(target.pageId),
807
- observedRevision: await readObservedPageRevision(config.workspaceDir, target.pageId),
808
- });
922
+ observedRevision: observed?.revision,
923
+ };
924
+ requireReconciledRevision(guard);
925
+ requireReconciledDisplayDate({ ...guard, document_, observed });
809
926
  }
810
927
 
811
928
  // The duplicate-source check above only catches two workspace files
@@ -844,15 +961,33 @@ export async function pagesPush(invocation) {
844
961
  },
845
962
  });
846
963
 
964
+ const sentDescription = typeof description === "string" ? description : "";
965
+ const contentKey = pageContentKey(
966
+ source.sourceHash,
967
+ { title, path: pagePath, description: sentDescription },
968
+ source.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN ? canonicalDocumentHash(document_) : "",
969
+ );
847
970
  planned.push({
848
971
  file,
849
972
  pagePath,
850
973
  title,
851
- description: typeof description === "string" ? description : "",
974
+ contentKey,
975
+ // The source and everything sent with it match what the site last agreed
976
+ // to, so sending again would only turn an approved page back into a draft.
977
+ // Not when the operator was shown a conflict for this page (the record
978
+ // exists to let this push reassert the source), and not when the source
979
+ // moved or changed format, which the send re-registers in the manifest.
980
+ unchanged: target !== undefined
981
+ && targetEntry?.baseline?.contentKey === contentKey
982
+ && observedRecord === undefined
983
+ && targetEntry.file === file
984
+ && targetEntry.sourceFormat === source.sourceFormat,
985
+ description: sentDescription,
852
986
  document: document_,
853
987
  sourceFormat: source.sourceFormat,
854
988
  sourceHash: source.sourceHash,
855
989
  pageId: target?.pageId,
990
+ templateType: PAGE_TEMPLATES[documentTemplate(document_)].wireType,
856
991
  action: target === undefined ? "created" : "updated",
857
992
  });
858
993
  }
@@ -863,23 +998,21 @@ export async function pagesPush(invocation) {
863
998
  let manifestDirty = false;
864
999
  try {
865
1000
  for (const page of planned) {
1001
+ if (page.unchanged) {
1002
+ onProgress(`Skipping '${page.pagePath}': '${page.file}' is unchanged since the last pull or push.`);
1003
+ continue;
1004
+ }
866
1005
  onProgress(`${page.action === "created" ? "Creating" : "Updating"} '${page.pagePath}' from '${page.file}'.`);
867
- const template = freeFormTemplate(page.document);
1006
+ const fields = {
1007
+ path: page.pagePath,
1008
+ title: page.title,
1009
+ shortDescription: page.description,
1010
+ template: wireTemplate(page.document),
1011
+ ...wirePageFields(page.document),
1012
+ };
868
1013
  const summary = page.action === "created"
869
- ? await createPage(client, {
870
- siteId,
871
- path: page.pagePath,
872
- title: page.title,
873
- shortDescription: page.description,
874
- template,
875
- })
876
- : await updatePage(client, page.pageId, {
877
- pageId: page.pageId,
878
- path: page.pagePath,
879
- title: page.title,
880
- shortDescription: page.description,
881
- template,
882
- });
1014
+ ? await createPage(client, { siteId, ...fields })
1015
+ : await updatePage(client, page.pageId, { pageId: page.pageId, ...fields });
883
1016
  manifestDirty = true;
884
1017
  applied.push({
885
1018
  file: page.file,
@@ -901,7 +1034,7 @@ export async function pagesPush(invocation) {
901
1034
  title: page.title,
902
1035
  description: page.description,
903
1036
  status: summary.status,
904
- templateType: TEMPLATE_TYPE_FREE_FORM,
1037
+ templateType: page.templateType,
905
1038
  hasDraft: summary.hasDraft,
906
1039
  // The registry: this file, in this format, is now the page's one
907
1040
  // authoritative source, whatever the entry said before. That is what
@@ -927,7 +1060,11 @@ export async function pagesPush(invocation) {
927
1060
  // this workspace made itself.
928
1061
  baseline: {
929
1062
  sourceHash: page.sourceHash,
1063
+ contentKey: page.contentKey,
930
1064
  ...(summary.bodyRevision === undefined ? {} : { revision: summary.bodyRevision }),
1065
+ // Recorded for the pages whose push sends a date, so the next push
1066
+ // can tell a date the site changed from one it already holds.
1067
+ ...(sentDisplayDate(page.templateType) ? { displayDate: summary.displayDate } : {}),
931
1068
  },
932
1069
  pendingApproval: true,
933
1070
  });
@@ -953,6 +1090,7 @@ export async function pagesPush(invocation) {
953
1090
  return successResult(VERB_PAGES_PUSH, siteId, {
954
1091
  pages: {
955
1092
  total: planned.length,
1093
+ unchanged: planned.filter((page) => page.unchanged).length,
956
1094
  created: applied.filter((entry) => entry.action === "created").length,
957
1095
  updated: applied.filter((entry) => entry.action === "updated").length,
958
1096
  // What this run actually looked at, so automation can tell a targeted
@@ -973,7 +1111,8 @@ export async function pagesPush(invocation) {
973
1111
  },
974
1112
  // `pages push` writes drafts. Nothing reaches an audience until `approve`
975
1113
  // stages them and `deploy` publishes the site.
976
- nextStep: "approve",
1114
+ // Nothing to approve when every page was unchanged and nothing was sent.
1115
+ ...(applied.length > 0 ? { nextStep: "approve" } : {}),
977
1116
  });
978
1117
  });
979
1118
  }