@taprootio/site-authoring 0.20.0 → 0.22.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 +128 -29
- package/examples/riverbend-wellness/nav.json +3 -6
- package/examples/riverbend-wellness/pages/visit.md +1 -1
- package/package.json +2 -2
- package/src/api.js +118 -31
- package/src/changelog.js +48 -0
- package/src/changelog.json +83 -0
- package/src/cli-release.js +9 -1
- package/src/cli.js +129 -14
- package/src/constants.js +19 -0
- package/src/errors.js +3 -0
- package/src/footer-contract.js +5 -3
- package/src/forms-workspace.js +22 -0
- package/src/heading-outline.js +76 -0
- package/src/index.js +1 -0
- package/src/output.js +9 -0
- package/src/page-metadata.js +2 -1
- package/src/page-structure-checks.js +356 -0
- package/src/presentation-reference.js +41 -6
- package/src/problems.js +6 -1
- package/src/redirects-contract.js +50 -140
- package/src/reference-help.js +77 -33
- package/src/reserved-paths.js +4 -10
- package/src/setting-reference.js +438 -0
- package/src/settings-catalog.js +2 -3
- package/src/site-check-findings.js +188 -0
- package/src/site-check-rules.json +35 -0
- package/src/site-checks.js +353 -0
- package/src/staging-check.js +48 -9
- package/src/staging-served.js +320 -0
- package/src/theme-projection.js +28 -55
- package/src/theme-validation.js +2 -1
- package/src/transport.js +13 -0
- package/src/typed-page-reference.js +128 -4
- package/src/verbs/changelog.js +62 -0
- package/src/verbs/deploy.js +38 -10
- package/src/verbs/index.js +3 -0
- package/src/verbs/nav-push.js +49 -1
- package/src/verbs/pages-push.js +17 -5
- package/src/verbs/plan.js +2 -1
- package/src/verbs/pull.js +5 -1
- package/src/verbs/redirects-check.js +4 -1
- package/src/verbs/staging-review.js +1 -1
- package/src/verbs/status.js +35 -28
- package/src/verbs/validate.js +24 -0
- package/src/workspace-check.js +26 -1
- package/src/workspace.js +5 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
changelogEntriesSince,
|
|
3
|
+
changelogEntryFor,
|
|
4
|
+
earliestRecordedVersion,
|
|
5
|
+
} from "../changelog.js";
|
|
6
|
+
import { compareCliVersions } from "../cli-release.js";
|
|
7
|
+
import { CLI_UPGRADE_COMMAND, CLI_VERSION, VERB_CHANGELOG } from "../constants.js";
|
|
8
|
+
import { successResult } from "../session.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `changelog [--since <version>]` — what changed in each release, offline.
|
|
12
|
+
*
|
|
13
|
+
* Only the latest CLI is accepted, so an upgrade is forced often and the agent
|
|
14
|
+
* that meets it needs to know what moved without diffing every help topic
|
|
15
|
+
* (TR01225). With no version it prints the installed release's entry; with
|
|
16
|
+
* `--since` it prints every release after that one, up to the installed one. It
|
|
17
|
+
* reads the package's own record, needs no sign-in or site, and is outside the
|
|
18
|
+
* outdated-CLI gate on purpose: it is how the upgraded CLI explains itself.
|
|
19
|
+
*/
|
|
20
|
+
export async function changelog(invocation) {
|
|
21
|
+
const onProgress = invocation.onProgress ?? (() => {});
|
|
22
|
+
const since = invocation.since;
|
|
23
|
+
const installed = changelogEntryFor(CLI_VERSION);
|
|
24
|
+
const entries = since === undefined
|
|
25
|
+
? installed === undefined ? [] : [installed]
|
|
26
|
+
: changelogEntriesSince(since);
|
|
27
|
+
const earliest = earliestRecordedVersion();
|
|
28
|
+
const startsBeforeRecord = since !== undefined && earliest !== undefined && compareCliVersions(since, earliest) < 0;
|
|
29
|
+
|
|
30
|
+
const notes = [];
|
|
31
|
+
if (since !== undefined && compareCliVersions(since, CLI_VERSION) >= 0) {
|
|
32
|
+
notes.push(
|
|
33
|
+
`This CLI is ${CLI_VERSION}, which is not newer than ${since}. Upgrade with: ${CLI_UPGRADE_COMMAND}, then run this again.`,
|
|
34
|
+
);
|
|
35
|
+
} else if (entries.length === 0) {
|
|
36
|
+
notes.push(`No changelog entry is recorded for ${CLI_VERSION}.`);
|
|
37
|
+
}
|
|
38
|
+
if (startsBeforeRecord) {
|
|
39
|
+
notes.push(`The record starts at ${earliest}; changes before it are not listed.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
onProgress(`${entry.version}${entry.date === undefined ? "" : ` (${entry.date})`}: ${entry.note}`);
|
|
44
|
+
for (const line of entry.behavior) onProgress(` - ${line}`);
|
|
45
|
+
if (entry.help.length > 0) onProgress(` Help changed: ${entry.help.map((topic) => `help ${topic}`).join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
for (const note of notes) onProgress(note);
|
|
48
|
+
|
|
49
|
+
return successResult(VERB_CHANGELOG, undefined, {
|
|
50
|
+
installedVersion: CLI_VERSION,
|
|
51
|
+
...(since === undefined ? {} : { since }),
|
|
52
|
+
entries: entries.map((entry) => ({
|
|
53
|
+
version: entry.version,
|
|
54
|
+
...(entry.date === undefined ? {} : { date: entry.date }),
|
|
55
|
+
note: entry.note,
|
|
56
|
+
behavior: [...entry.behavior],
|
|
57
|
+
help: [...entry.help],
|
|
58
|
+
})),
|
|
59
|
+
complete: !startsBeforeRecord,
|
|
60
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
61
|
+
});
|
|
62
|
+
}
|
package/src/verbs/deploy.js
CHANGED
|
@@ -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.
|
|
37
|
-
*
|
|
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.
|
|
42
|
-
+
|
|
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
|
-
|
|
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,
|
package/src/verbs/index.js
CHANGED
|
@@ -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,
|
package/src/verbs/nav-push.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/verbs/pages-push.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { VERB_PAGES_PUSH } from "../constants.js";
|
|
23
23
|
import { autolinkWarnings } from "../content/link-warnings.js";
|
|
24
24
|
import { placedFormKeys, readFormIndex, withFormIds } from "../form-references.js";
|
|
25
|
+
import { workspaceFormKeys } from "../forms-workspace.js";
|
|
25
26
|
import { ApiError } from "../transport.js";
|
|
26
27
|
import { waitForProcessing } from "./media-upload.js";
|
|
27
28
|
import { SiteAuthoringError } from "../errors.js";
|
|
@@ -883,9 +884,11 @@ export async function placedVideoProblems(client, siteId, planned) {
|
|
|
883
884
|
* The pages that place a form the site does not have, or has archived, and the
|
|
884
885
|
* site's forms by key for the wire (TR01216). A page is stored with the form's id,
|
|
885
886
|
* 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
|
+
* sent are not asked about, and a push that places no form reads nothing. `readLocalFormKeys`, when given,
|
|
888
|
+
* reads the workspace's own form files (TR01081), only once a key turns out to be one the site lacks, so the
|
|
889
|
+
* refusal can say whether the key's file is there to push.
|
|
887
890
|
*/
|
|
888
|
-
export async function placedFormProblems(client, siteId, planned) {
|
|
891
|
+
export async function placedFormProblems(client, siteId, planned, readLocalFormKeys) {
|
|
889
892
|
const placements = new Map();
|
|
890
893
|
for (const page of planned) {
|
|
891
894
|
if (page.unchanged) continue;
|
|
@@ -897,8 +900,14 @@ export async function placedFormProblems(client, siteId, planned) {
|
|
|
897
900
|
|
|
898
901
|
const index = await readFormIndex(client, siteId);
|
|
899
902
|
const problems = [];
|
|
903
|
+
let localFormKeys;
|
|
904
|
+
let localRead = false;
|
|
900
905
|
for (const [key, file] of placements) {
|
|
901
906
|
if (index.idByKey.has(key)) continue;
|
|
907
|
+
if (!localRead && !index.archivedKeys.has(key) && readLocalFormKeys !== undefined) {
|
|
908
|
+
localFormKeys = await readLocalFormKeys();
|
|
909
|
+
localRead = true;
|
|
910
|
+
}
|
|
902
911
|
problems.push(...problemsOf(
|
|
903
912
|
index.archivedKeys.has(key)
|
|
904
913
|
? documentError(
|
|
@@ -909,8 +918,11 @@ export async function placedFormProblems(client, siteId, planned) {
|
|
|
909
918
|
)
|
|
910
919
|
: documentError(
|
|
911
920
|
"pages.form_unknown",
|
|
912
|
-
|
|
913
|
-
|
|
921
|
+
localFormKeys?.has(key) === false
|
|
922
|
+
? `'${file}' places the form '${key}', which this site does not have, and the workspace has no forms/${key}.json. `
|
|
923
|
+
+ "Add the form file and run 'taproot-site forms push', or fix the key."
|
|
924
|
+
: `'${file}' places the form '${key}', which this site does not have. Run 'taproot-site forms push' to create `
|
|
925
|
+
+ "it, or fix the key.",
|
|
914
926
|
file,
|
|
915
927
|
),
|
|
916
928
|
{ area: CHECK_AREA.pages, file },
|
|
@@ -1607,7 +1619,7 @@ export async function pagesPush(invocation) {
|
|
|
1607
1619
|
// publishing would then refuse.
|
|
1608
1620
|
plan.problems.push(...await placedVideoProblems(client, siteId, planned));
|
|
1609
1621
|
// Likewise a form: pages store its id, which only the site's own forms can supply.
|
|
1610
|
-
const placedForms = await placedFormProblems(client, siteId, planned);
|
|
1622
|
+
const placedForms = await placedFormProblems(client, siteId, planned, () => workspaceFormKeys(config.workspaceDir));
|
|
1611
1623
|
plan.problems.push(...placedForms.problems);
|
|
1612
1624
|
|
|
1613
1625
|
// Under apply, the workspace read ledger has already held every source and
|
package/src/verbs/plan.js
CHANGED
|
@@ -27,6 +27,7 @@ import { sharedThemeContextNames } from "../content/free-form-sections.js";
|
|
|
27
27
|
import { SiteAuthoringError } from "../errors.js";
|
|
28
28
|
import { computeFooterContentHash, computeFooterDraftHash, stableJson } from "../footer-draft-hash.js";
|
|
29
29
|
import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
|
|
30
|
+
import { workspaceFormKeys } from "../forms-workspace.js";
|
|
30
31
|
import { CHECK_AREA, collectProblems, problemsError, problemsOf, problemsWithinBudget, withinByteBudget } from "../problems.js";
|
|
31
32
|
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
32
33
|
import { IMAGE_IDENTIFIER, usedImageIds } from "../typed-pages.js";
|
|
@@ -284,7 +285,7 @@ async function preflight(session, invocation) {
|
|
|
284
285
|
});
|
|
285
286
|
problems.push(...pages.problems);
|
|
286
287
|
problems.push(...await placedVideoProblems(client, siteId, pages.planned));
|
|
287
|
-
problems.push(...(await placedFormProblems(client, siteId, pages.planned)).problems);
|
|
288
|
+
problems.push(...(await placedFormProblems(client, siteId, pages.planned, () => workspaceFormKeys(workspaceDir))).problems);
|
|
288
289
|
const sending = pages.planned.filter((page) => !page.unchanged);
|
|
289
290
|
|
|
290
291
|
// An image a page names must be one the site holds; an image this plan
|
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,
|
|
@@ -1314,7 +1315,10 @@ export async function pull(invocation) {
|
|
|
1314
1315
|
if (!presentationOnly) {
|
|
1315
1316
|
onProgress("Reading the navigation tree.");
|
|
1316
1317
|
navItems = await getNavigation(client, siteId);
|
|
1317
|
-
await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, {
|
|
1318
|
+
await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, {
|
|
1319
|
+
siteId,
|
|
1320
|
+
navItems: projectNavigationForWorkspace(navItems),
|
|
1321
|
+
});
|
|
1318
1322
|
}
|
|
1319
1323
|
|
|
1320
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 },
|
package/src/verbs/status.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getDeploySelection,
|
|
3
3
|
getPublishingReadiness,
|
|
4
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
152
|
-
|
|
165
|
+
onProgress("Reading site health.");
|
|
166
|
+
health = await getSiteHealth(client, siteId);
|
|
153
167
|
}
|
|
154
|
-
const
|
|
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(
|
|
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
|
-
|
|
229
|
+
siteHealth: presentationOnly ? { covered: false, reason: DOCS_SITE_NOT_COVERED_NOTE } : {
|
|
225
230
|
covered: true,
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
});
|
package/src/verbs/validate.js
CHANGED
|
@@ -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,21 @@ 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
|
+
formKeysChecked: checked.formKeysChecked === true,
|
|
663
|
+
onProgress,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
651
667
|
export async function validateFixture(invocation = {}) {
|
|
652
668
|
if (invocation.init) return await initializeFixture(invocation, validateFixture);
|
|
653
669
|
const onProgress = typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
|
|
@@ -736,6 +752,7 @@ export async function validateFixture(invocation = {}) {
|
|
|
736
752
|
...autolinkWarnings(checked.pages?.planned, onProgress),
|
|
737
753
|
...descriptionWarnings(checked.pages?.planned, onProgress),
|
|
738
754
|
...authorWarnings(checked.pages?.planned, onProgress),
|
|
755
|
+
...offlineSiteChecks(checked, manifest, onProgress),
|
|
739
756
|
};
|
|
740
757
|
onProgress(
|
|
741
758
|
`Validated ${validatedPages.length} page(s), ${checked.navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
|
|
@@ -765,6 +782,9 @@ export async function validateFixture(invocation = {}) {
|
|
|
765
782
|
"credential authorization or live site ownership",
|
|
766
783
|
"remote concurrency or current revisions",
|
|
767
784
|
"server normalization and pull round trips",
|
|
785
|
+
"the site checks listed in siteChecks.checkedBySite (images, forms), which 'taproot-site status' reports under "
|
|
786
|
+
+ "siteHealth, siteChecks.checkedAtDeploy, which only a deploy applies, or siteChecks.checkedInEditor, "
|
|
787
|
+
+ "which only the page editor evaluates (a cover, a description field, or a description's length)",
|
|
768
788
|
"preview or published rendering",
|
|
769
789
|
],
|
|
770
790
|
nextStep: "Run a real pull and authorized preview before approval or deployment.",
|
|
@@ -801,6 +821,7 @@ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgr
|
|
|
801
821
|
...autolinkWarnings(checked.pages.planned, onProgress),
|
|
802
822
|
...descriptionWarnings(checked.pages.planned, onProgress),
|
|
803
823
|
...authorWarnings(checked.pages.planned, onProgress),
|
|
824
|
+
...offlineSiteChecks(checked, manifest, onProgress),
|
|
804
825
|
};
|
|
805
826
|
onProgress(
|
|
806
827
|
checked.settingsOnly
|
|
@@ -826,6 +847,9 @@ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgr
|
|
|
826
847
|
"credential authorization or live site ownership",
|
|
827
848
|
"pages, revisions, or videos that changed on the site since the pull",
|
|
828
849
|
"server normalization and pull round trips",
|
|
850
|
+
"the site checks listed in siteChecks.checkedBySite (images, forms), which 'taproot-site status' reports under "
|
|
851
|
+
+ "siteHealth, siteChecks.checkedAtDeploy, which only a deploy applies, or siteChecks.checkedInEditor, "
|
|
852
|
+
+ "which only the page editor evaluates (a cover, a description field, or a description's length)",
|
|
829
853
|
"preview or published rendering",
|
|
830
854
|
],
|
|
831
855
|
nextStep: "Run 'taproot-site plan' to check the workspace against the live site and see the ordered apply.",
|
package/src/workspace-check.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { sharedThemeContextNames } from "./content/free-form-sections.js";
|
|
2
2
|
import { readAppearanceWorkspaceContext, FOOTER_SETTINGS_FILE } from "./footer-workspace.js";
|
|
3
|
+
import { placedFormKeys } from "./form-references.js";
|
|
4
|
+
import { FORMS_DIRECTORY } from "./forms-contract.js";
|
|
3
5
|
import { readWorkspaceForms } from "./forms-workspace.js";
|
|
6
|
+
import { describeSiteFinding } from "./site-check-findings.js";
|
|
7
|
+
import { unknownFormKeyFindings } from "./site-checks.js";
|
|
4
8
|
import { CHECK_AREA, collectProblems, problemsOf } from "./problems.js";
|
|
5
9
|
import { normalizeRedirectPath, REDIRECTS_FILE_NAME, validateRedirectsDocument } from "./redirects-contract.js";
|
|
6
10
|
import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "./settings-catalog.js";
|
|
@@ -16,6 +20,7 @@ import {
|
|
|
16
20
|
readWorkspaceJson,
|
|
17
21
|
walkWorkspaceFiles,
|
|
18
22
|
WORKSPACE_LIMITS,
|
|
23
|
+
workspaceDirectoryExists,
|
|
19
24
|
workspaceFileExists,
|
|
20
25
|
} from "./workspace.js";
|
|
21
26
|
import { SiteAuthoringError } from "./errors.js";
|
|
@@ -135,7 +140,27 @@ export async function checkWorkspace({
|
|
|
135
140
|
onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"} against the shared field schema.`);
|
|
136
141
|
}
|
|
137
142
|
|
|
138
|
-
|
|
143
|
+
// A page places a form by key, and the workspace's form files are what a key can name (TR01081). Only
|
|
144
|
+
// when the workspace carries a forms/ directory: one for a site whose forms were created elsewhere still
|
|
145
|
+
// pushes. A directory with no files left in it counts, since that is what deleting the last form leaves.
|
|
146
|
+
const formKeysChecked = forms !== undefined && await workspaceDirectoryExists(workspaceDir, FORMS_DIRECTORY);
|
|
147
|
+
if (pages !== undefined && formKeysChecked) {
|
|
148
|
+
const known = new Set(forms.map((form) => form.key));
|
|
149
|
+
for (const page of pages.planned) {
|
|
150
|
+
for (const finding of unknownFormKeyFindings(placedFormKeys(page.document), known)) {
|
|
151
|
+
problems.push(...problemsOf(
|
|
152
|
+
new SiteAuthoringError(
|
|
153
|
+
"pages.form_unknown",
|
|
154
|
+
`'${page.file}' ${describeSiteFinding(finding)}.`,
|
|
155
|
+
{ field: page.file, rule: finding.ruleId },
|
|
156
|
+
),
|
|
157
|
+
{ area: CHECK_AREA.pages, file: page.file },
|
|
158
|
+
));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { problems, settingsOnly, presentation, footer, navigation, pages, redirects, forms, formKeysChecked };
|
|
139
164
|
}
|
|
140
165
|
|
|
141
166
|
/**
|