@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
|
@@ -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,
|
package/src/verbs/pages-push.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
79
|
-
*
|
|
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
|
|
126
|
-
*
|
|
127
|
-
* unrecognized key is how authored metadata disappears without a
|
|
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
|
|
333
|
-
if (!Array.isArray(
|
|
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
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
585
|
-
`'${
|
|
586
|
-
|
|
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
|
-
|
|
900
|
+
const sourceTemplate = documentTemplate(document_);
|
|
901
|
+
if (target.templateType !== PAGE_TEMPLATES[sourceTemplate].wireType) {
|
|
790
902
|
throw documentError(
|
|
791
903
|
"pages.template_immutable",
|
|
792
|
-
`'${file}'
|
|
793
|
-
+
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|