@taprootio/site-authoring 0.19.0 → 0.21.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.
Files changed (43) hide show
  1. package/README.md +132 -28
  2. package/examples/riverbend-wellness/nav.json +3 -6
  3. package/examples/riverbend-wellness/pages/visit.md +1 -1
  4. package/package.json +2 -2
  5. package/src/api.js +118 -31
  6. package/src/changelog.js +48 -0
  7. package/src/changelog.json +71 -0
  8. package/src/cli-release.js +9 -1
  9. package/src/cli.js +128 -15
  10. package/src/constants.js +19 -0
  11. package/src/content/components.js +8 -5
  12. package/src/errors.js +3 -0
  13. package/src/footer-contract.js +5 -3
  14. package/src/form-references.js +103 -0
  15. package/src/forms-workspace.js +24 -0
  16. package/src/index.js +1 -0
  17. package/src/output.js +9 -0
  18. package/src/presentation-reference.js +41 -6
  19. package/src/problems.js +6 -1
  20. package/src/redirects-contract.js +50 -140
  21. package/src/reference-help.js +83 -36
  22. package/src/reserved-paths.js +4 -10
  23. package/src/setting-reference.js +438 -0
  24. package/src/site-check-findings.js +99 -0
  25. package/src/site-check-rules.json +22 -0
  26. package/src/site-checks.js +337 -0
  27. package/src/staging-check.js +48 -9
  28. package/src/staging-served.js +320 -0
  29. package/src/transport.js +13 -0
  30. package/src/typed-page-reference.js +128 -4
  31. package/src/typed-pages.js +1 -1
  32. package/src/verbs/changelog.js +62 -0
  33. package/src/verbs/deploy.js +38 -10
  34. package/src/verbs/forms-push.js +22 -1
  35. package/src/verbs/index.js +3 -0
  36. package/src/verbs/nav-push.js +49 -1
  37. package/src/verbs/pages-push.js +45 -1
  38. package/src/verbs/plan.js +4 -2
  39. package/src/verbs/pull.js +17 -3
  40. package/src/verbs/redirects-check.js +4 -1
  41. package/src/verbs/staging-review.js +1 -1
  42. package/src/verbs/status.js +35 -28
  43. package/src/verbs/validate.js +21 -0
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  DEPLOY_TARGET_PRODUCTION,
17
17
  DEPLOY_TARGET_STAGING,
18
+ LIMITS,
18
19
  REFUSAL_CAPABILITY_MISSING,
19
20
  REFUSAL_CREDENTIAL_REJECTED,
20
21
  REFUSAL_UNCLASSIFIED,
@@ -23,23 +24,28 @@ import {
23
24
  } from "../constants.js";
24
25
  import { SiteAuthoringError } from "../errors.js";
25
26
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
26
- import { inspectStagingPreview } from "../staging-check.js";
27
+ import { HANDOFF_NOT_REQUESTED_PROGRESS, handoffNotRequested, inspectStagingPreview } from "../staging-check.js";
28
+ import { confirmStagingServed, readStagingReleaseBefore, unverifiedStagingServed } from "../staging-served.js";
27
29
  import { ApiError } from "../transport.js";
28
30
  import { readManifest, writeManifest } from "../workspace.js";
29
31
 
30
32
  /**
31
- * When a redirect goes live (TR00702, TR00968).
33
+ * When a redirect goes live (TR00702, TR00968, TR01236).
32
34
  *
33
35
  * A redirect or gone entry is a file inside the release this deploy publishes.
34
36
  * It goes live with that release and no other way: staging shows it as soon as
35
37
  * the staged release is served, production shows it when the release is
36
- * promoted. There is no separate store to wait on, so a check that does not see
37
- * an entry is looking at a release that does not carry it yet.
38
+ * promoted. The release itself reaches the edge after the deployment reports
39
+ * completed: Cloudflare propagates the route and the Worker caches it, so the
40
+ * previous release can answer for about two minutes (the note below quotes the
41
+ * ceiling). A check that does not see an entry is looking at a release that is
42
+ * not served yet.
38
43
  */
39
44
  const REDIRECT_ACTIVATION_NOTE =
40
45
  "Redirect and gone entries live inside the release this deploy publishes: staging serves them with the staged "
41
- + "release and production serves them once that release is promoted. There is no separate propagation delay; "
42
- + "an entry a check does not see belongs to a release that is not being served yet.";
46
+ + "release and production serves them once that release is promoted. The edge can keep serving the previous "
47
+ + `release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so an entry a `
48
+ + "check does not see may belong to a release the edge has not switched to yet.";
43
49
 
44
50
  /**
45
51
  * `deploy --staging` / `deploy --production` — readiness, deploy, poll to
@@ -208,9 +214,9 @@ function presentationHandoffUnavailable(error) {
208
214
  * It is minted after the deployment completed, bound by the server to the
209
215
  * site's exact acknowledged staging host, and single-use like every other.
210
216
  */
211
- async function mintPresentationStagingHandoff(client, siteId, onProgress, now) {
217
+ async function mintPresentationStagingHandoff(client, siteId, onProgress, now, returnPath) {
212
218
  try {
213
- const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
219
+ const handoff = await mintStagingPreviewHandoff(client, siteId, { now, returnPath });
214
220
  onProgress(`Staging review handoff minted for ${handoff.stagingUrl}. ${PRESENTATION_REVIEW_GUIDANCE}`);
215
221
  return { ...handoff, review: PRESENTATION_REVIEW_GUIDANCE };
216
222
  } catch (error) {
@@ -387,6 +393,9 @@ export async function deploy(invocation) {
387
393
  }
388
394
  }
389
395
 
396
+ // The release staging serves now, so the wait after completion can tell when the new one replaces it
397
+ // (TR01236). A managed Docs site's pages carry no release stamp, so it is not read there.
398
+ const servedBefore = presentationOnly ? undefined : await readStagingReleaseBefore(client, siteId, now);
390
399
  const created = await deploySite(client, siteId, {
391
400
  siteId,
392
401
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -405,8 +414,22 @@ export async function deploy(invocation) {
405
414
  now,
406
415
  });
407
416
  await recordDeployment(config, manifest, "staging", completed);
417
+ const stagingServed = presentationOnly
418
+ ? unverifiedStagingServed(now(), "A managed Docs site's staging is not checked for the served release.")
419
+ : await confirmStagingServed(client, siteId, {
420
+ before: servedBefore,
421
+ deploymentId: created.id,
422
+ coalesced: created.outcome === DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
423
+ onProgress,
424
+ now,
425
+ });
426
+ if (stagingServed.state !== "served") onProgress(`Warning: ${stagingServed.message}`);
408
427
  if (presentationOnly) {
409
- const stagingPreview = await mintPresentationStagingHandoff(client, siteId, onProgress, now);
428
+ // The review URL is a live credential, so it is minted only when asked for (TR01233).
429
+ if (invocation.handoff !== true) onProgress(HANDOFF_NOT_REQUESTED_PROGRESS);
430
+ const stagingPreview = invocation.handoff === true
431
+ ? await mintPresentationStagingHandoff(client, siteId, onProgress, now, invocation.reviewPath)
432
+ : handoffNotRequested();
410
433
  return successResult(VERB_DEPLOY, siteId, {
411
434
  target,
412
435
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -419,11 +442,15 @@ export async function deploy(invocation) {
419
442
  deployment: completed,
420
443
  outcome: created.outcome,
421
444
  readiness: reportReadiness(readiness),
445
+ stagingServed,
422
446
  stagingPreview,
423
447
  nextStep: stagingPreview.url === "" ? "staging review" : "deploy --production",
424
448
  });
425
449
  }
426
- const { routeCheck, redirects, stagingPreview } = await inspectStagingPreview(client, siteId, onProgress, now);
450
+ const { routeCheck, redirects, stagingPreview } = await inspectStagingPreview(client, siteId, onProgress, now, {
451
+ handoff: invocation.handoff === true,
452
+ returnPath: invocation.reviewPath,
453
+ });
427
454
  onProgress(REDIRECT_ACTIVATION_NOTE);
428
455
  return successResult(VERB_DEPLOY, siteId, {
429
456
  target,
@@ -436,6 +463,7 @@ export async function deploy(invocation) {
436
463
  deployment: completed,
437
464
  outcome: created.outcome,
438
465
  readiness: reportReadiness(readiness),
466
+ stagingServed,
439
467
  routeCheck,
440
468
  redirects,
441
469
  stagingPreview,
@@ -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;
@@ -5,6 +5,7 @@ import {
5
5
  VERB_AUTHORS_LIST,
6
6
  VERB_DELIVERY_CHECK,
7
7
  VERB_DEPLOY,
8
+ VERB_CHANGELOG,
8
9
  VERB_ENV,
9
10
  VERB_FOOTER_PUSH,
10
11
  VERB_FORMS_PULL,
@@ -38,6 +39,7 @@ import {
38
39
  } from "../constants.js";
39
40
  import { approve } from "./approve.js";
40
41
  import { authorsAdd, authorsList } from "./authors.js";
42
+ import { changelog } from "./changelog.js";
41
43
  import { deliveryCheck } from "./delivery-check.js";
42
44
  import { deploy } from "./deploy.js";
43
45
  import { env } from "./env.js";
@@ -174,6 +176,7 @@ export const VERB_HANDLERS = Object.freeze({
174
176
  [VERB_USE]: use,
175
177
  [VERB_WHOAMI]: whoami,
176
178
  [VERB_ENV]: env,
179
+ [VERB_CHANGELOG]: changelog,
177
180
  [VERB_STATUS]: status,
178
181
  [VERB_DELIVERY_CHECK]: deliveryCheck,
179
182
  [VERB_STAGING_REVIEW]: stagingReview,
@@ -23,6 +23,7 @@ import {
23
23
  readWorkspaceJson,
24
24
  workspaceFileExists,
25
25
  WORKSPACE_LIMITS,
26
+ writeWorkspaceJson,
26
27
  } from "../workspace.js";
27
28
 
28
29
  /**
@@ -52,7 +53,9 @@ const MAXIMUM_REPORTED = 100;
52
53
  const MAXIMUM_ITEMS = 1_000;
53
54
  const MAXIMUM_TITLE_LENGTH = 200;
54
55
  const MAXIMUM_URL_LENGTH = CONTACT_OR_WEB_URL_MAX_LENGTH;
55
- const ITEM_KEYS = new Set(["id", "kind", "title", "resourceId", "externalUrl", "children"]);
56
+ // The item keys in the order the workspace file spells them; validation and projection share this list.
57
+ const NAVIGATION_ITEM_KEY_ORDER = Object.freeze(["id", "kind", "title", "resourceId", "externalUrl", "children"]);
58
+ const ITEM_KEYS = new Set(NAVIGATION_ITEM_KEY_ORDER);
56
59
 
57
60
  // `SaveSiteNavigation` maps both `id` and `resourceId` through
58
61
  // `RequestValidation.RequireGuid`. A value that is not a GUID comes back as a
@@ -182,6 +185,36 @@ function validateItem(item, itemPath, depth, seenIds, counters) {
182
185
  };
183
186
  }
184
187
 
188
+ /**
189
+ * The one workspace spelling of a navigation tree (TR01232): the item keys in a
190
+ * fixed order, with the fields that only carry a default left out. The API prints
191
+ * proto3 defaults (`externalUrl: ""`, `children: []`) and `nav push` validates into
192
+ * the same full shape, so without this a pull after a push rewrote nav.json with
193
+ * values nobody wrote. Both `pull` and `nav push` write this form, and either form
194
+ * is accepted on the way in.
195
+ */
196
+ export function projectNavigationForWorkspace(items) {
197
+ if (!Array.isArray(items)) return items;
198
+ return items.map((item) => {
199
+ if (item === null || typeof item !== "object" || Array.isArray(item)) return item;
200
+ const projected = {};
201
+ const keys = [
202
+ ...NAVIGATION_ITEM_KEY_ORDER.filter((key) => Object.hasOwn(item, key)),
203
+ ...Object.keys(item).filter((key) => !NAVIGATION_ITEM_KEY_ORDER.includes(key)),
204
+ ];
205
+ for (const key of keys) {
206
+ const value = item[key];
207
+ if ((key === "resourceId" || key === "externalUrl") && value === "") continue;
208
+ if (key === "children" && Array.isArray(value)) {
209
+ if (value.length > 0) projected.children = projectNavigationForWorkspace(value);
210
+ continue;
211
+ }
212
+ projected[key] = value;
213
+ }
214
+ return projected;
215
+ });
216
+ }
217
+
185
218
  function collectIds(items, into = new Set()) {
186
219
  for (const item of items) {
187
220
  if (item === null || typeof item !== "object") continue;
@@ -325,6 +358,21 @@ export async function navPush(invocation) {
325
358
 
326
359
  const saved = await saveNavigation(client, siteId, navItems);
327
360
  onProgress(`Replaced the navigation tree with ${validated.items} items.`);
361
+ try {
362
+ // The file now holds what the site stored, in the form pull writes, so the next pull is byte-identical.
363
+ const stored = saved.length > 0 || navItems.length === 0 ? saved : navItems;
364
+ // siteId and navItems first, in pull's order; any other key the author kept follows.
365
+ const { siteId: _siteId, navItems: _navItems, ...extras } = parsed;
366
+ await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, {
367
+ siteId,
368
+ navItems: projectNavigationForWorkspace(stored),
369
+ ...extras,
370
+ });
371
+ } catch (error) {
372
+ // The site already holds the new tree; only the local file is stale, so say the tree was replaced.
373
+ if (error instanceof SiteAuthoringError) throw error.withCompletedWrites(["navigationTree"]);
374
+ throw error;
375
+ }
328
376
 
329
377
  const removedReport = boundedList(removed, MAXIMUM_REPORTED);
330
378
  const addedReport = boundedList(added, MAXIMUM_REPORTED);
@@ -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
@@ -23,6 +23,7 @@ import { appearanceManifestEntry, footerManifestEntry, presentationManifestEntry
23
23
  import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
24
24
  import { projectRedirectMapForWorkspace, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
25
25
  import { redirectsManifestEntry } from "../redirects-workspace.js";
26
+ import { projectNavigationForWorkspace } from "./nav-push.js";
26
27
  import { boundedList, openSession, successResult } from "../session.js";
27
28
  import {
28
29
  projectSettingsGroup,
@@ -33,6 +34,7 @@ import {
33
34
  SETTINGS_TYPE_TAPROOT_STYLES,
34
35
  } from "../settings-catalog.js";
35
36
  import { ApiError } from "../transport.js";
37
+ import { placesFormIds, readFormIndex, withFormKeys } from "../form-references.js";
36
38
  import {
37
39
  generatedIdentity,
38
40
  isAuthorableTemplateType,
@@ -643,6 +645,15 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
643
645
 
644
646
  export async function pull(invocation) {
645
647
  const { client, config, siteId, surface, now, onProgress } = await openSession(invocation);
648
+ // A page names its forms by id on the site and by key in the workspace (TR01216). The
649
+ // site's forms are read once, and only when a page body places one.
650
+ let formKeys;
651
+ const workspaceBodyFromPage = async (page, listedTemplateType) => {
652
+ const body = workspaceDocumentFromPage(page, listedTemplateType);
653
+ if (body === undefined || !placesFormIds(body)) return body;
654
+ formKeys ??= readFormIndex(client, siteId).then((index) => index.keyById);
655
+ return withFormKeys(body, await formKeys);
656
+ };
646
657
  await ensureWorkspaceRoot(config);
647
658
 
648
659
  await requireWorkspaceBelongsToSite(config.workspaceDir, siteId);
@@ -891,7 +902,7 @@ export async function pull(invocation) {
891
902
  if (source === undefined) continue;
892
903
  onProgress(`Reading page '${summary.path}'.`);
893
904
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
894
- const body = workspaceDocumentFromPage(page, summary.templateType);
905
+ const body = await workspaceBodyFromPage(page, summary.templateType);
895
906
  const description = typeof page.shortDescription === "string" ? page.shortDescription : "";
896
907
  if (body === undefined) {
897
908
  // The site's body is unreadable, but the workspace file is still this
@@ -1261,7 +1272,7 @@ export async function pull(invocation) {
1261
1272
  }
1262
1273
  onProgress(`Reading page '${summary.path}'.`);
1263
1274
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
1264
- const body = workspaceDocumentFromPage(page, summary.templateType);
1275
+ const body = await workspaceBodyFromPage(page, summary.templateType);
1265
1276
  entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
1266
1277
  entry.title = titleFromRead(page, summary);
1267
1278
  entry.path = pathFromRead(page, summary);
@@ -1304,7 +1315,10 @@ export async function pull(invocation) {
1304
1315
  if (!presentationOnly) {
1305
1316
  onProgress("Reading the navigation tree.");
1306
1317
  navItems = await getNavigation(client, siteId);
1307
- await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, { siteId, navItems });
1318
+ await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, {
1319
+ siteId,
1320
+ navItems: projectNavigationForWorkspace(navItems),
1321
+ });
1308
1322
  }
1309
1323
 
1310
1324
  if (redirectMap !== undefined) {
@@ -6,7 +6,10 @@ import { inspectStagingPreview } from "../staging-check.js";
6
6
  export async function redirectsCheck(invocation) {
7
7
  const { client, siteId, onProgress, now } = await openSession(invocation);
8
8
  return await withRefusalGuidance(onProgress, "staging redirects", async () => {
9
- const { routeCheck, redirects, stagingPreview } = await inspectStagingPreview(client, siteId, onProgress, now);
9
+ const { routeCheck, redirects, stagingPreview } = await inspectStagingPreview(client, siteId, onProgress, now, {
10
+ handoff: invocation.handoff === true,
11
+ returnPath: invocation.reviewPath,
12
+ });
10
13
  return successResult(VERB_REDIRECTS_CHECK, siteId, { routeCheck, redirects, stagingPreview });
11
14
  });
12
15
  }
@@ -20,7 +20,7 @@ export async function stagingReview(invocation) {
20
20
  const { client, siteId, now, onProgress } = await openSession(invocation);
21
21
  return await withRefusalGuidance(onProgress, "staging review", async () => {
22
22
  onProgress("Minting a fresh single-use staging review handoff.");
23
- const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
23
+ const handoff = await mintStagingPreviewHandoff(client, siteId, { now, returnPath: invocation.reviewPath });
24
24
  onProgress(`Staging review handoff minted for ${handoff.stagingUrl}. ${STAGING_REVIEW_GUIDANCE}`);
25
25
  return successResult(VERB_STAGING_REVIEW, siteId, {
26
26
  stagingPreview: { ...handoff, review: STAGING_REVIEW_GUIDANCE },
@@ -1,15 +1,17 @@
1
1
  import {
2
2
  getDeploySelection,
3
3
  getPublishingReadiness,
4
- getSiteBrokenReferences,
4
+ getSiteHealth,
5
5
  IMAGE_PROCESSING_STATE_COMPLETE,
6
6
  IMAGE_PROCESSING_STATE_FAILED,
7
7
  listDeployments,
8
8
  listSiteImages,
9
9
  withRefusalGuidance,
10
10
  } from "../api.js";
11
+ import { installedWhatsNew } from "../changelog.js";
11
12
  import { isBehindLatest } from "../cli-release.js";
12
13
  import {
14
+ changelogCommandSince,
13
15
  CLI_UPGRADE_COMMAND,
14
16
  CLI_VERSION,
15
17
  EXTERNAL_WRITES_SETTING_KEY,
@@ -24,13 +26,12 @@ import { boundedList, openSession, successResult } from "../session.js";
24
26
 
25
27
  /**
26
28
  * `status` — the platform authoring switch, CLI release, deployments,
27
- * publishing readiness, image processing, and broken-reference pages.
29
+ * publishing readiness, image processing, and the site checks' findings.
28
30
  */
29
31
  const MAXIMUM_DEPLOYMENTS = 20;
30
32
  const MAXIMUM_FAILED_IMAGES = 50;
31
33
  const MAXIMUM_BLOCKERS = 50;
32
- const MAXIMUM_BROKEN_REFERENCE_PAGES = 50;
33
- const MAXIMUM_MISSING_TARGETS = 50;
34
+ const MAXIMUM_HEALTH_FINDINGS = 100;
34
35
 
35
36
  const PLATFORM_UNKNOWN_NOTE =
36
37
  "Taproot never reported the platform authoring switch on this run: either no sign-in token exchange happened "
@@ -61,9 +62,15 @@ const CLI_RELEASE_UNKNOWN_NOTE =
61
62
  */
62
63
  function reportCliRelease(release, onProgress) {
63
64
  const latestVersion = release?.latestVersion;
65
+ // The installed release's one-line note, from the same record `changelog` prints.
66
+ const whatsNew = installedWhatsNew();
67
+ const whatsNewField = whatsNew === undefined ? {} : { whatsNew };
68
+ if (whatsNew !== undefined) {
69
+ onProgress(`What's new in ${CLI_VERSION}: ${whatsNew.note} ('${changelogCommandSince("<version>")}' lists the details.)`);
70
+ }
64
71
  if (latestVersion === undefined) {
65
72
  onProgress(`CLI release: ${CLI_VERSION}. Latest not known on this run. ${CLI_RELEASE_UNKNOWN_NOTE}`);
66
- return { version: CLI_VERSION, latestKnown: false, reason: CLI_RELEASE_UNKNOWN_NOTE };
73
+ return { version: CLI_VERSION, latestKnown: false, reason: CLI_RELEASE_UNKNOWN_NOTE, ...whatsNewField };
67
74
  }
68
75
  const behind = isBehindLatest(latestVersion);
69
76
  onProgress(
@@ -71,9 +78,16 @@ function reportCliRelease(release, onProgress) {
71
78
  ? `CLI release: ${CLI_VERSION} is BEHIND the latest published release ${latestVersion}, and Taproot accepts `
72
79
  + `only the latest, so every online verb is refused (refusal=${REFUSAL_CLI_OUTDATED}). Upgrade with: `
73
80
  + CLI_UPGRADE_COMMAND
81
+ + `. Once upgraded, run '${changelogCommandSince(CLI_VERSION)}' to see what changed.`
74
82
  : `CLI release: ${CLI_VERSION}; latest published release ${latestVersion}.`,
75
83
  );
76
- return { version: CLI_VERSION, latestVersion, behind };
84
+ return {
85
+ version: CLI_VERSION,
86
+ latestVersion,
87
+ behind,
88
+ ...whatsNewField,
89
+ ...(behind ? { changelogCommand: changelogCommandSince(CLI_VERSION) } : {}),
90
+ };
77
91
  }
78
92
 
79
93
  /**
@@ -104,12 +118,12 @@ function reportPlatform(platform, onProgress) {
104
118
  /**
105
119
  * What a managed Docs site's status leaves out (TR00790), said the same way
106
120
  * the platform switch says "unknown": by shape. The image library and the
107
- * broken-reference report are page-authoring reads a Docs site refuses, so
121
+ * site-health read are page-authoring reads a Docs site refuses, so
108
122
  * they are reported as not covered rather than as empty — an automation
109
123
  * reading `failed: 0` off a report that was never made would be reading a lie.
110
124
  */
111
125
  const DOCS_SITE_NOT_COVERED_NOTE =
112
- "This is a managed Docs site: its pages come from the Docs artifact, so the image library and broken-reference "
126
+ "This is a managed Docs site: its pages come from the Docs artifact, so the image library and site-health "
113
127
  + "reads do not apply and were not made.";
114
128
 
115
129
  export async function status(invocation) {
@@ -144,25 +158,14 @@ export async function status(invocation) {
144
158
  let images = [];
145
159
  let summary = { totalImages: 0, processingImages: 0 };
146
160
  let truncated = false;
147
- let brokenReferencePages = [];
161
+ let health = { findings: [], errorCount: 0, warningCount: 0, pageCount: 0, truncated: false };
148
162
  if (!presentationOnly) {
149
163
  onProgress("Reading image processing state.");
150
164
  ({ images, summary, truncated } = await listSiteImages(client, siteId));
151
- onProgress("Reading broken references.");
152
- brokenReferencePages = await getSiteBrokenReferences(client, siteId);
165
+ onProgress("Reading site health.");
166
+ health = await getSiteHealth(client, siteId);
153
167
  }
154
- const reportedBrokenPages = boundedList(brokenReferencePages, MAXIMUM_BROKEN_REFERENCE_PAGES);
155
- const brokenPages = reportedBrokenPages.items.map((page) => {
156
- const images = boundedList(page.missingImageIds, MAXIMUM_MISSING_TARGETS);
157
- const paths = boundedList(page.missingPagePaths, MAXIMUM_MISSING_TARGETS);
158
- return {
159
- ...page,
160
- missingImageIds: images.items,
161
- missingPagePaths: paths.items,
162
- ...(images.truncated ? { missingImageIdsTruncated: true } : {}),
163
- ...(paths.truncated ? { missingPagePathsTruncated: true } : {}),
164
- };
165
- });
168
+ const reportedFindings = boundedList(health.findings, MAXIMUM_HEALTH_FINDINGS);
166
169
 
167
170
  const byState = {};
168
171
  for (const image of images) {
@@ -182,7 +185,9 @@ export async function status(invocation) {
182
185
  const blockers = boundedList(readiness.blockers, MAXIMUM_BLOCKERS);
183
186
 
184
187
  if (!presentationOnly) {
185
- onProgress(`Broken references: ${brokenReferencePages.length} page(s) with missing targets.`);
188
+ onProgress(
189
+ `Site health: ${health.errorCount} problem(s) and ${health.warningCount} warning(s) on ${health.pageCount} page(s).`,
190
+ );
186
191
  }
187
192
 
188
193
  return successResult(VERB_STATUS, siteId, {
@@ -221,11 +226,13 @@ export async function status(invocation) {
221
226
  ...(failedImages.truncated ? { failedItemsTruncated: true } : {}),
222
227
  ...(truncated ? { listTruncated: true } : {}),
223
228
  },
224
- brokenReferences: presentationOnly ? { covered: false, reason: DOCS_SITE_NOT_COVERED_NOTE } : {
229
+ siteHealth: presentationOnly ? { covered: false, reason: DOCS_SITE_NOT_COVERED_NOTE } : {
225
230
  covered: true,
226
- totalPages: brokenReferencePages.length,
227
- pages: brokenPages,
228
- ...(reportedBrokenPages.truncated ? { pagesTruncated: true } : {}),
231
+ errors: health.errorCount,
232
+ warnings: health.warningCount,
233
+ pages: health.pageCount,
234
+ findings: reportedFindings.items,
235
+ ...(reportedFindings.truncated || health.truncated ? { findingsTruncated: true } : {}),
229
236
  },
230
237
  });
231
238
  });
@@ -22,6 +22,7 @@ import { authorWarnings, descriptionWarnings } from "../page-metadata.js";
22
22
  import { CHECK_AREA, collectProblems, refuseProblems } from "../problems.js";
23
23
  import { isRedirectMapRevision, REDIRECT_KIND_GONE, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
24
24
  import { boundedList, successResult } from "../session.js";
25
+ import { evaluateOfflineSiteChecks } from "../site-check-findings.js";
25
26
  import { SETTINGS_GROUPS } from "../settings-catalog.js";
26
27
  import { checkWorkspace } from "../workspace-check.js";
27
28
  import {
@@ -648,6 +649,20 @@ function pageHints(checked, onProgress) {
648
649
  return hints;
649
650
  }
650
651
 
652
+ /**
653
+ * The offline site checks (TR01085) over the planned pages: the rules the shared
654
+ * catalogue lists for `validate`, with the rest named as checked by the site.
655
+ */
656
+ function offlineSiteChecks(checked, manifest, onProgress) {
657
+ if (checked.settingsOnly) return {};
658
+ return evaluateOfflineSiteChecks({
659
+ planned: checked.pages?.planned,
660
+ pagePaths: manifest.pages.map((entry) => normalizePagePath(entry?.path)).filter((value) => value !== undefined),
661
+ pagesComplete: manifest.pagesTruncated !== true,
662
+ onProgress,
663
+ });
664
+ }
665
+
651
666
  export async function validateFixture(invocation = {}) {
652
667
  if (invocation.init) return await initializeFixture(invocation, validateFixture);
653
668
  const onProgress = typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
@@ -736,6 +751,7 @@ export async function validateFixture(invocation = {}) {
736
751
  ...autolinkWarnings(checked.pages?.planned, onProgress),
737
752
  ...descriptionWarnings(checked.pages?.planned, onProgress),
738
753
  ...authorWarnings(checked.pages?.planned, onProgress),
754
+ ...offlineSiteChecks(checked, manifest, onProgress),
739
755
  };
740
756
  onProgress(
741
757
  `Validated ${validatedPages.length} page(s), ${checked.navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
@@ -765,6 +781,8 @@ export async function validateFixture(invocation = {}) {
765
781
  "credential authorization or live site ownership",
766
782
  "remote concurrency or current revisions",
767
783
  "server normalization and pull round trips",
784
+ "the site checks listed in siteChecks.checkedBySite (images, forms), which 'taproot-site status' reports under "
785
+ + "siteHealth, or siteChecks.checkedAtDeploy, which only a deploy applies",
768
786
  "preview or published rendering",
769
787
  ],
770
788
  nextStep: "Run a real pull and authorized preview before approval or deployment.",
@@ -801,6 +819,7 @@ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgr
801
819
  ...autolinkWarnings(checked.pages.planned, onProgress),
802
820
  ...descriptionWarnings(checked.pages.planned, onProgress),
803
821
  ...authorWarnings(checked.pages.planned, onProgress),
822
+ ...offlineSiteChecks(checked, manifest, onProgress),
804
823
  };
805
824
  onProgress(
806
825
  checked.settingsOnly
@@ -826,6 +845,8 @@ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgr
826
845
  "credential authorization or live site ownership",
827
846
  "pages, revisions, or videos that changed on the site since the pull",
828
847
  "server normalization and pull round trips",
848
+ "the site checks listed in siteChecks.checkedBySite (images, forms), which 'taproot-site status' reports under "
849
+ + "siteHealth, or siteChecks.checkedAtDeploy, which only a deploy applies",
829
850
  "preview or published rendering",
830
851
  ],
831
852
  nextStep: "Run 'taproot-site plan' to check the workspace against the live site and see the ordered apply.",