@taprootio/site-authoring 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/verbs/pull.js CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  PAGE_STATUS_DELETED,
9
9
  PAGE_STATUS_DRAFT,
10
10
  PAGE_STATUS_UNKNOWN,
11
- TEMPLATE_TYPE_FREE_FORM,
12
11
  withRefusalGuidance,
13
12
  } from "../api.js";
14
13
  import {
@@ -32,6 +31,7 @@ import {
32
31
  SETTINGS_TYPE_TAPROOT_STYLES,
33
32
  } from "../settings-catalog.js";
34
33
  import { ApiError } from "../transport.js";
34
+ import { isAuthorableTemplateType, sentDisplayDate, workspaceDocumentFromPage } from "../typed-pages.js";
35
35
  import {
36
36
  canonicalDocumentHash,
37
37
  deleteWorkspaceFile,
@@ -47,6 +47,7 @@ import {
47
47
  normalizePageBodyRevision,
48
48
  normalizePagePath,
49
49
  PAGE_SOURCE_EXTENSIONS,
50
+ pageContentKey,
50
51
  PAGE_SOURCE_FORMAT_MARKDOWN,
51
52
  PAGE_WORKSPACE_MODE_EDITABLE,
52
53
  PAGE_WORKSPACE_MODE_METADATA_ONLY,
@@ -481,13 +482,9 @@ function pathFromRead(page, summary) {
481
482
  return normalized === undefined ? summary.path : normalized;
482
483
  }
483
484
 
484
- function freeFormBody(page) {
485
- const template = page.template;
486
- if (template === null || typeof template !== "object") return undefined;
487
- const data = template.freeFormData;
488
- if (data === null || typeof data !== "object") return undefined;
489
- const body = data.body;
490
- return body !== null && typeof body === "object" && !Array.isArray(body) ? body : undefined;
485
+ /** The date a read reports for a page whose push sends one, or `undefined` for a free-form page. */
486
+ function trackedDisplayDate(page, templateType) {
487
+ return sentDisplayDate(templateType) ? (typeof page.displayDate === "string" ? page.displayDate : "") : undefined;
491
488
  }
492
489
 
493
490
  function notThisSite(message, field) {
@@ -601,9 +598,9 @@ export async function pull(invocation) {
601
598
  ({ pages, truncated } = await listSitePages(client, siteId, { onProgress }));
602
599
  }
603
600
  const live = pages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
604
- const freeForm = live.filter((summary) => summary.templateType === TEMPLATE_TYPE_FREE_FORM);
605
- const tracked = await resolveTrackedSources(config.workspaceDir, freeForm, registry, onProgress);
606
- const pageFiles = assignPageFiles(freeForm, tracked);
601
+ const authorable = live.filter((summary) => isAuthorableTemplateType(summary.templateType));
602
+ const tracked = await resolveTrackedSources(config.workspaceDir, authorable, registry, onProgress);
603
+ const pageFiles = assignPageFiles(authorable, tracked);
607
604
 
608
605
  // The redirect map, with the revision a later push is fenced by (TR00702).
609
606
  // Read here, before the first workspace write, for the same reason the
@@ -732,18 +729,18 @@ export async function pull(invocation) {
732
729
  const conflicts = [];
733
730
  let bufferedBytes = 0;
734
731
  let revisionsRecordedWithoutBodyComparison = 0;
735
- for (const summary of freeForm) {
732
+ for (const summary of authorable) {
736
733
  const source = tracked.get(summary.pageId);
737
734
  if (source === undefined) continue;
738
- onProgress(`Reading free-form page '${summary.path}'.`);
735
+ onProgress(`Reading page '${summary.path}'.`);
739
736
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
740
- const body = freeFormBody(page);
737
+ const body = workspaceDocumentFromPage(page, summary.templateType);
741
738
  const description = typeof page.shortDescription === "string" ? page.shortDescription : "";
742
739
  if (body === undefined) {
743
740
  // The site's body is unreadable, but the workspace file is still this
744
741
  // page's one source. Dropping it from the registry would let the next
745
742
  // pull mint the competing document all over again.
746
- onProgress(`Page '${summary.path}' has no readable free-form body; keeping '${source.file}' as its source.`);
743
+ onProgress(`Page '${summary.path}' has no readable body; keeping '${source.file}' as its source.`);
747
744
  trackedPlans.set(summary.pageId, {
748
745
  description,
749
746
  title: titleFromRead(page, summary),
@@ -798,9 +795,17 @@ export async function pull(invocation) {
798
795
  // projection moved across the API deploy. This costs one pull's detection
799
796
  // at the upgrade boundary, the same trade `withoutSupersededRemoteHashes`
800
797
  // accepts.
801
- const remoteChanged = revision !== undefined
798
+ const revisionChanged = revision !== undefined
802
799
  ? source.baseline?.revision !== undefined && source.baseline.revision !== revision
803
800
  : source.baseline?.remoteHash !== undefined && source.baseline.remoteHash !== remoteHash;
801
+ // A typed page's display date is edited in the app and is not covered by
802
+ // the revision, so it is compared on its own: otherwise a push would
803
+ // silently write a stale date over the newer one (TR00893).
804
+ const remoteDisplayDate = trackedDisplayDate(page, summary.templateType);
805
+ const dateChanged = remoteDisplayDate !== undefined
806
+ && source.baseline?.displayDate !== undefined
807
+ && source.baseline.displayDate !== remoteDisplayDate;
808
+ const remoteChanged = revisionChanged || dateChanged;
804
809
  // Markdown is one-way, so a Markdown source can never be rewritten from
805
810
  // the site's document and is always kept. A ProseMirror source can be
806
811
  // refreshed — but only over content the author has not edited since the
@@ -860,12 +865,22 @@ export async function pull(invocation) {
860
865
  ? {
861
866
  remoteHash,
862
867
  sourceHash: localChanged ? source.baseline?.sourceHash : sourceHash,
868
+ // Carried, never minted here: a kept source is only known to match
869
+ // the site if a push or an earlier pull already said so.
870
+ ...(source.baseline?.contentKey === undefined ? {} : { contentKey: source.baseline.contentKey }),
863
871
  ...(revision === undefined ? {} : { revision }),
872
+ ...(remoteDisplayDate === undefined ? {} : { displayDate: remoteDisplayDate }),
864
873
  }
865
874
  : {
866
875
  remoteHash,
867
876
  sourceHash: workspaceContentHash(serialized),
877
+ contentKey: pageContentKey(workspaceContentHash(serialized), {
878
+ title: titleFromRead(page, summary),
879
+ path: pathFromRead(page, summary),
880
+ description,
881
+ }),
868
882
  ...(revision === undefined ? {} : { revision }),
883
+ ...(remoteDisplayDate === undefined ? {} : { displayDate: remoteDisplayDate }),
869
884
  },
870
885
  ...(keepLocal ? { baselineBody: serialized, baselineFile } : { refresh: serialized, baselineFile }),
871
886
  });
@@ -887,7 +902,10 @@ export async function pull(invocation) {
887
902
  // what the fallback comparison above is made against, so it is equally
888
903
  // the thing a record has to name.
889
904
  const observed = await readObservedPageRecord(config.workspaceDir, summary.pageId);
905
+ // A typed page's date moves without moving the revision, so the date
906
+ // the record showed must match too or its differences are stale.
890
907
  const alreadyShown = observed !== undefined
908
+ && observed.displayDate === remoteDisplayDate
891
909
  && (revision === undefined
892
910
  ? observed.revision === undefined && observed.remoteHash === remoteHash
893
911
  : observed.revision === revision);
@@ -922,6 +940,7 @@ export async function pull(invocation) {
922
940
  differences,
923
941
  observedRevision: revision,
924
942
  observedRemoteHash: remoteHash,
943
+ observedDisplayDate: remoteDisplayDate,
925
944
  observedRevisionFile: internalPageObservedRevisionFile(summary.pageId),
926
945
  });
927
946
  }
@@ -972,6 +991,7 @@ export async function pull(invocation) {
972
991
  ...(conflict.observedRevision === undefined
973
992
  ? { remoteHash: conflict.observedRemoteHash }
974
993
  : { revision: conflict.observedRevision }),
994
+ ...(conflict.observedDisplayDate === undefined ? {} : { displayDate: conflict.observedDisplayDate }),
975
995
  ...(conflict.differences === undefined ? {} : { differences: conflict.differences }),
976
996
  });
977
997
  }
@@ -993,7 +1013,7 @@ export async function pull(invocation) {
993
1013
  isGenerated: summary.isGenerated,
994
1014
  workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
995
1015
  };
996
- if (summary.templateType === TEMPLATE_TYPE_FREE_FORM) {
1016
+ if (isAuthorableTemplateType(summary.templateType)) {
997
1017
  // This pull is reconciling the page, so the manifest below carries the
998
1018
  // newer and stronger claim and the "what the operator was shown" record
999
1019
  // is spent. Leaving it would let a push override a remote change this
@@ -1026,9 +1046,9 @@ export async function pull(invocation) {
1026
1046
  manifestPages.push(entry);
1027
1047
  continue;
1028
1048
  }
1029
- onProgress(`Reading free-form page '${summary.path}'.`);
1049
+ onProgress(`Reading page '${summary.path}'.`);
1030
1050
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
1031
- const body = freeFormBody(page);
1051
+ const body = workspaceDocumentFromPage(page, summary.templateType);
1032
1052
  entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
1033
1053
  entry.title = titleFromRead(page, summary);
1034
1054
  entry.path = pathFromRead(page, summary);
@@ -1036,17 +1056,24 @@ export async function pull(invocation) {
1036
1056
  // The server accepts and stores a body it never validates, so an
1037
1057
  // unreadable one is a real state. It is reported rather than written
1038
1058
  // as an empty document that a later push would send back.
1039
- onProgress(`Page '${summary.path}' has no readable free-form body; snapshotting metadata only.`);
1059
+ onProgress(`Page '${summary.path}' has no readable body; snapshotting metadata only.`);
1040
1060
  } else {
1041
1061
  entry.file = pageFiles.get(summary.pageId);
1042
1062
  const source = Buffer.from(`${JSON.stringify(body, undefined, 2)}\n`, "utf8");
1043
1063
  entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
1044
1064
  entry.sourceFormat = pageSourceFormat(entry.file);
1045
1065
  const revision = normalizePageBodyRevision(page.bodyRevision);
1066
+ const displayDate = trackedDisplayDate(page, summary.templateType);
1046
1067
  entry.baseline = {
1047
1068
  remoteHash: canonicalDocumentHash(body),
1048
1069
  sourceHash: workspaceContentHash(source),
1070
+ contentKey: pageContentKey(workspaceContentHash(source), {
1071
+ title: entry.title,
1072
+ path: entry.path,
1073
+ description: entry.description,
1074
+ }),
1049
1075
  ...(revision === undefined ? {} : { revision }),
1076
+ ...(displayDate === undefined ? {} : { displayDate }),
1050
1077
  };
1051
1078
  await writeWorkspaceFile(config.workspaceDir, entry.file, source);
1052
1079
  bodies += 1;
@@ -1,7 +1,7 @@
1
1
  import { lstat, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { NAVIGATION_MAXIMUM_DEPTH, TEMPLATE_TYPE_FREE_FORM } from "../api.js";
4
+ import { NAVIGATION_MAXIMUM_DEPTH } from "../api.js";
5
5
  import { VERB_VALIDATE } from "../constants.js";
6
6
  import { freeFormRootPresentation, sharedThemeContextNames } from "../content/free-form-sections.js";
7
7
  import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
@@ -41,6 +41,7 @@ import { validateFooterWorkspaceDocument } from "./footer-push.js";
41
41
  import { validateNavigationWorkspaceDocument } from "./nav-push.js";
42
42
  import { validateWorkspacePageDocument, validateWorkspacePageSource } from "./pages-push.js";
43
43
  import { validateThemeWorkspace } from "./theme-push.js";
44
+ import { documentImageIds, documentTemplate, isAuthorableTemplateType, PAGE_TEMPLATES } from "../typed-pages.js";
44
45
 
45
46
  const MAXIMUM_REPORTED = 200;
46
47
  const FIXTURE_ROOT_KEYS = new Set(FIXTURE_ROOT_FIELDS);
@@ -277,7 +278,7 @@ function validateFixtureManifest(manifest) {
277
278
  !isPlainObject(entry)
278
279
  || !isCanonicalUuid(entry.pageId)
279
280
  || !isCanonicalUuid(entry.resourceId)
280
- || entry.templateType !== TEMPLATE_TYPE_FREE_FORM
281
+ || !isAuthorableTemplateType(entry.templateType)
281
282
  || entry.workspaceMode !== PAGE_WORKSPACE_MODE_EDITABLE
282
283
  || typeof entry.file !== "string"
283
284
  || !entry.file.startsWith(`${PAGES_DIRECTORY}/`)
@@ -286,7 +287,7 @@ function validateFixtureManifest(manifest) {
286
287
  ) {
287
288
  fail(
288
289
  "fixture.page_invalid",
289
- `${field} must bind one editable free-form source to deterministic page/resource identities and a usable path.`,
290
+ `${field} must bind one editable page source to deterministic page/resource identities and a usable path.`,
290
291
  field,
291
292
  );
292
293
  }
@@ -499,7 +500,27 @@ export async function validateFixture(invocation = {}) {
499
500
  file,
500
501
  );
501
502
  }
503
+ // The manifest states which template the page has, and the source must be
504
+ // one: a recipe document bound as an article would push as the wrong type.
505
+ if (PAGE_TEMPLATES[documentTemplate(page.document)].wireType !== entry.templateType) {
506
+ fail(
507
+ "fixture.page_template_mismatch",
508
+ `'${file}' is a ${documentTemplate(page.document)} source, but its fixture manifest entry binds `
509
+ + `${entry.templateType}.`,
510
+ file,
511
+ );
512
+ }
502
513
  validatePageImageReferences(page.document, file, imageIds, deliveryOrigins);
514
+ // Album images and the cover image are bare ids with no delivery URL, so
515
+ // the walk above cannot see them.
516
+ const unknownImage = documentImageIds(page.document).find((imageId) => !imageIds.has(imageId));
517
+ if (unknownImage !== undefined) {
518
+ fail(
519
+ "fixture.image_reference_unknown",
520
+ `Fixture image '${unknownImage}' is not declared in fixture.imageIds.`,
521
+ file,
522
+ );
523
+ }
503
524
  validatedPages.push({ file, path: page.pagePath });
504
525
  if (pageUsesRootBand(page.document)) rootBandPages.push(file);
505
526
  }
package/src/workspace.js CHANGED
@@ -218,7 +218,14 @@ export async function readObservedPageRecord(workspaceDir, pageId) {
218
218
  // A record naming neither version says nothing about what was shown, which is
219
219
  // the same position as no record at all.
220
220
  if (revision === undefined && remoteHash === undefined) return undefined;
221
- return { revision, remoteHash, differences: normalizeObservedDifferences(parsed.differences) };
221
+ return {
222
+ revision,
223
+ remoteHash,
224
+ // The date the refusal showed, for a typed page. A date edit does not move
225
+ // the revision, so the record has to name it to say what was seen.
226
+ displayDate: normalizeBaselineDisplayDate(parsed.displayDate),
227
+ differences: normalizeObservedDifferences(parsed.differences),
228
+ };
222
229
  }
223
230
 
224
231
  /**
@@ -317,10 +324,31 @@ export function canonicalDocumentHash(document_) {
317
324
  return `sha256:${createHash("sha256").update(canonicalJson(document_, 0), "utf8").digest("hex")}`;
318
325
  }
319
326
 
327
+ /**
328
+ * What a push would send for a page, as one hash: the source's bytes plus the
329
+ * title, path and description that travel with it (for a `.pm.json` source they
330
+ * come from the manifest, not the file). `pages push` skips a page whose key
331
+ * equals the one recorded when the workspace last agreed with the site, so an
332
+ * unedited page is never re-sent (an update would turn an approved page back
333
+ * into a draft). A Markdown source also folds in the hash of the document it
334
+ * converts to, because its media references resolve through the media manifest:
335
+ * re-uploading an image changes what is sent without changing the file.
336
+ */
337
+ export function pageContentKey(sourceHash, { title, path, description }, documentHash = "") {
338
+ return workspaceContentHash(
339
+ Buffer.from(JSON.stringify([sourceHash, title ?? "", path ?? "", description ?? "", documentHash]), "utf8"),
340
+ );
341
+ }
342
+
320
343
  function normalizeBaselineHash(value) {
321
344
  return typeof value === "string" && WORKSPACE_CONTENT_HASH.test(value) ? value : undefined;
322
345
  }
323
346
 
347
+ // A calendar date or "" (no date). Anything else is a damaged baseline member.
348
+ function normalizeBaselineDisplayDate(value) {
349
+ return typeof value === "string" && (value === "" || /^\d{4}-\d{2}-\d{2}$/u.test(value)) ? value : undefined;
350
+ }
351
+
324
352
  /**
325
353
  * Whether a manifest entry names a file `walkWorkspaceFiles` could have
326
354
  * produced: under `pages/`, bounded, and every segment in the workspace
@@ -388,14 +416,20 @@ export function pageSourceRegistry(manifest) {
388
416
  const declared = entry.baseline;
389
417
  const remoteHash = normalizeBaselineHash(declared?.remoteHash);
390
418
  const sourceHash = normalizeBaselineHash(declared?.sourceHash);
419
+ const contentKey = normalizeBaselineHash(declared?.contentKey);
391
420
  const revision = normalizePageBodyRevision(declared?.revision);
392
- const baseline = remoteHash === undefined && sourceHash === undefined && revision === undefined
393
- ? undefined
394
- : {
395
- ...(remoteHash === undefined ? {} : { remoteHash }),
396
- ...(sourceHash === undefined ? {} : { sourceHash }),
397
- ...(revision === undefined ? {} : { revision }),
398
- };
421
+ const displayDate = normalizeBaselineDisplayDate(declared?.displayDate);
422
+ const baseline =
423
+ remoteHash === undefined && sourceHash === undefined && revision === undefined && displayDate === undefined
424
+ && contentKey === undefined
425
+ ? undefined
426
+ : {
427
+ ...(remoteHash === undefined ? {} : { remoteHash }),
428
+ ...(sourceHash === undefined ? {} : { sourceHash }),
429
+ ...(contentKey === undefined ? {} : { contentKey }),
430
+ ...(revision === undefined ? {} : { revision }),
431
+ ...(displayDate === undefined ? {} : { displayDate }),
432
+ };
399
433
  const record = {
400
434
  pageId: entry.pageId,
401
435
  file: entry.file,