@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/README.md +15 -3
- package/examples/riverbend-wellness/manifest.fixture.json +56 -0
- package/examples/riverbend-wellness/pages/green-smoothie.md +28 -0
- package/examples/riverbend-wellness/pages/journal-welcome.md +13 -0
- package/examples/riverbend-wellness/pages/juice-bar.md +11 -0
- package/examples/riverbend-wellness/pages/studio-tour.pm.json +34 -0
- package/package.json +1 -1
- package/src/api.js +3 -9
- package/src/cli.js +2 -0
- package/src/content/components.js +0 -6
- package/src/reference-help.js +89 -15
- package/src/typed-page-reference.js +339 -0
- package/src/typed-pages.js +952 -0
- package/src/verbs/delivery-check.js +23 -3
- package/src/verbs/pages-push.js +200 -61
- package/src/verbs/pull.js +47 -20
- package/src/verbs/validate.js +24 -3
- package/src/workspace.js +42 -8
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
605
|
-
const tracked = await resolveTrackedSources(config.workspaceDir,
|
|
606
|
-
const pageFiles = assignPageFiles(
|
|
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
|
|
732
|
+
for (const summary of authorable) {
|
|
736
733
|
const source = tracked.get(summary.pageId);
|
|
737
734
|
if (source === undefined) continue;
|
|
738
|
-
onProgress(`Reading
|
|
735
|
+
onProgress(`Reading page '${summary.path}'.`);
|
|
739
736
|
const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
|
|
740
|
-
const body =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1049
|
+
onProgress(`Reading page '${summary.path}'.`);
|
|
1030
1050
|
const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
|
|
1031
|
-
const body =
|
|
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
|
|
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;
|
package/src/verbs/validate.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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,
|