@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.
Files changed (47) hide show
  1. package/README.md +128 -29
  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 +83 -0
  8. package/src/cli-release.js +9 -1
  9. package/src/cli.js +129 -14
  10. package/src/constants.js +19 -0
  11. package/src/errors.js +3 -0
  12. package/src/footer-contract.js +5 -3
  13. package/src/forms-workspace.js +22 -0
  14. package/src/heading-outline.js +76 -0
  15. package/src/index.js +1 -0
  16. package/src/output.js +9 -0
  17. package/src/page-metadata.js +2 -1
  18. package/src/page-structure-checks.js +356 -0
  19. package/src/presentation-reference.js +41 -6
  20. package/src/problems.js +6 -1
  21. package/src/redirects-contract.js +50 -140
  22. package/src/reference-help.js +77 -33
  23. package/src/reserved-paths.js +4 -10
  24. package/src/setting-reference.js +438 -0
  25. package/src/settings-catalog.js +2 -3
  26. package/src/site-check-findings.js +188 -0
  27. package/src/site-check-rules.json +35 -0
  28. package/src/site-checks.js +353 -0
  29. package/src/staging-check.js +48 -9
  30. package/src/staging-served.js +320 -0
  31. package/src/theme-projection.js +28 -55
  32. package/src/theme-validation.js +2 -1
  33. package/src/transport.js +13 -0
  34. package/src/typed-page-reference.js +128 -4
  35. package/src/verbs/changelog.js +62 -0
  36. package/src/verbs/deploy.js +38 -10
  37. package/src/verbs/index.js +3 -0
  38. package/src/verbs/nav-push.js +49 -1
  39. package/src/verbs/pages-push.js +17 -5
  40. package/src/verbs/plan.js +2 -1
  41. package/src/verbs/pull.js +5 -1
  42. package/src/verbs/redirects-check.js +4 -1
  43. package/src/verbs/staging-review.js +1 -1
  44. package/src/verbs/status.js +35 -28
  45. package/src/verbs/validate.js +24 -0
  46. package/src/workspace-check.js +26 -1
  47. package/src/workspace.js +5 -0
@@ -0,0 +1,320 @@
1
+ import {
2
+ DEPLOYMENT_ENVIRONMENT_STAGING,
3
+ DEPLOYMENT_PENDING_STATUSES,
4
+ DEPLOYMENT_STATUS_COMPLETED,
5
+ DEPLOYMENT_STATUS_FAILED,
6
+ listDeployments,
7
+ mintStagingPreviewHandoff,
8
+ poll,
9
+ } from "./api.js";
10
+ import { LIMITS } from "./constants.js";
11
+ import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
12
+ import { authorizeStagingHandoff, fetchStagingPage } from "./staging-check.js";
13
+ import { ApiError } from "./transport.js";
14
+
15
+ /**
16
+ * Which release the staging host serves right now (TR01236).
17
+ *
18
+ * A staging deployment reports completed once the platform has asked Cloudflare
19
+ * to write the host's route. The edge then needs up to about two minutes to
20
+ * serve it: the route propagates through Workers KV, and the Worker keeps its
21
+ * own KV read cache and per-isolate cache on top. A cache-busting query cannot
22
+ * help, because the release comes from the route entry and not the URL. The
23
+ * Worker stamps the release it served into every staged page, so the CLI reads
24
+ * that stamp and waits until it changes, instead of letting a reviewer open the
25
+ * previous version.
26
+ *
27
+ * The CLI cannot learn the new deployment's release id (the public deployment
28
+ * record does not carry a Standard site's), so it compares against what staging
29
+ * served before the deployment was sent. That comparison is only sound when the
30
+ * edge had settled on the previous release, which the caller checks.
31
+ */
32
+
33
+ const IDENTITY_ATTRIBUTE = /\sdata-review-identity="([^"]{1,200})"/u;
34
+ // The read before the deploy is bounded so a slow host cannot hold up sending it.
35
+ const BEFORE_READ_MILLISECONDS = 15_000;
36
+ // The deployments listing that tells whether the previous deployment has settled has its own budget.
37
+ const PREVIOUS_DEPLOYMENT_MILLISECONDS = 10_000;
38
+
39
+ /** The release a staged HTML document says it came from, or null when it names none. */
40
+ export function parseServedStagingRelease(html) {
41
+ const attribute = IDENTITY_ATTRIBUTE.exec(html)?.[1];
42
+ if (attribute === undefined) return null;
43
+ try {
44
+ const identity = JSON.parse(attribute.replaceAll(""", "\""));
45
+ if (Array.isArray(identity) && identity.length === 2 && identity[0] === "staging" && isCanonicalUuid(identity[1])) {
46
+ return identity[1];
47
+ }
48
+ } catch {
49
+ // An unreadable stamp is the same as no stamp.
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * The release a staged response names, reading at most `maximumBytes` of its body and
56
+ * stopping at the first stamp. A body past the bound is cut, which loses the stamp rather
57
+ * than memory.
58
+ */
59
+ export async function readServedStagingRelease(response, maximumBytes = LIMITS.stagingServedPageBytes) {
60
+ return parseServedStagingRelease(await readBoundedText(response, maximumBytes));
61
+ }
62
+
63
+ async function readBoundedText(response, maximumBytes) {
64
+ if (!response.body) return "";
65
+ const reader = response.body.getReader();
66
+ const decoder = new TextDecoder();
67
+ let text = "";
68
+ let bytes = 0;
69
+ try {
70
+ for (;;) {
71
+ const { done, value } = await reader.read();
72
+ if (done) break;
73
+ bytes += value.byteLength;
74
+ text += decoder.decode(value, { stream: true });
75
+ if (bytes >= maximumBytes || IDENTITY_ATTRIBUTE.test(text)) break;
76
+ }
77
+ } finally {
78
+ await reader.cancel().catch(() => {});
79
+ }
80
+ return text;
81
+ }
82
+
83
+ // Every network step of a read ends by the read's deadline, not only the API call.
84
+ function deadlineSignal(client, now, deadline) {
85
+ return deadline === undefined ? undefined : client.timeoutSignal(Math.max(1, deadline - now()));
86
+ }
87
+
88
+ async function openSession(client, siteId, now, deadline) {
89
+ const handoff = await mintStagingPreviewHandoff(client, siteId, { now, deadline });
90
+ const cookie = await authorizeStagingHandoff(client, handoff, deadlineSignal(client, now, deadline));
91
+ return { cookie, origin: new URL(handoff.stagingUrl).origin };
92
+ }
93
+
94
+ /**
95
+ * One read of the staging host's served release, inside `deadline`.
96
+ *
97
+ * Returns `{ kind: "release", releaseId }` when the host served a staged page,
98
+ * `{ kind: "none" }` when it has no staged release to show yet (the site's
99
+ * first staging deployment has no host until it completes, or the host answered
100
+ * with a page that carries no stamp), and `{ kind: "unknown" }` when the read
101
+ * itself failed. A failed read never says which release is served.
102
+ */
103
+ export async function observeStagingRelease(client, siteId, now, session, deadline) {
104
+ try {
105
+ const open = session.current ?? await openSession(client, siteId, now, deadline);
106
+ session.current = open;
107
+ const response = await fetchStagingPage(
108
+ client,
109
+ `${open.origin}/`,
110
+ open.cookie,
111
+ deadlineSignal(client, now, deadline),
112
+ );
113
+ if (response.status !== 200 && response.status !== 404) {
114
+ await response.body?.cancel().catch(() => {});
115
+ // A redirect or refusal means the gate did not let the read through, not that nothing is served.
116
+ return { kind: "unknown" };
117
+ }
118
+ const releaseId = await readServedStagingRelease(response);
119
+ return releaseId === null ? { kind: "none" } : { kind: "release", releaseId };
120
+ } catch (error) {
121
+ if (client.signal?.aborted) throw error;
122
+ // The first staging deployment has no acknowledged host to mint a handoff for.
123
+ if (error instanceof ApiError && error.hasField("Host")) return { kind: "none" };
124
+ return { kind: "unknown" };
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Whether the edge has settled on the newest completed staging deployment, and which
130
+ * deployment that is: state "none" (the site has never completed one, so any release
131
+ * staging serves is new), "recent" (it completed inside the propagation ceiling, so
132
+ * the edge may still be moving to it), "settled", or "unknown" (the listing could not
133
+ * be read, or its answer cannot be trusted). Only "none" and "settled" allow a
134
+ * comparison. `id` is that newest completed deployment, or null.
135
+ */
136
+ async function previousStagingDeployment(client, siteId, now) {
137
+ const result = (state, id = null) => ({ state, id });
138
+ try {
139
+ const { deployments, truncated } = await listDeployments(
140
+ client,
141
+ siteId,
142
+ { environment: DEPLOYMENT_ENVIRONMENT_STAGING, pageSize: 5 },
143
+ { deadline: now() + PREVIOUS_DEPLOYMENT_MILLISECONDS, now },
144
+ );
145
+ // A deployment still under way (another terminal's, with a different candidate) will move the edge
146
+ // before this one does, so a change from the baseline could be its release.
147
+ if (deployments.some((deployment) => DEPLOYMENT_PENDING_STATUSES.includes(deployment.status))) return result("unknown");
148
+ const completed = deployments.find((deployment) => deployment.status === DEPLOYMENT_STATUS_COMPLETED);
149
+ if (completed === undefined) return truncated ? result("unknown") : result("none");
150
+ const completedAt = Date.parse(completed.completedAt ?? "");
151
+ if (!Number.isFinite(completedAt)) return result("unknown");
152
+ // A completion ahead of this machine's clock is treated as recent: the clocks disagree, so
153
+ // the age cannot be trusted and the wait should not claim to know.
154
+ return result(
155
+ now() - completedAt < LIMITS.stagingPropagationCeilingSeconds * 1000 ? "recent" : "settled",
156
+ completed.id,
157
+ );
158
+ } catch (error) {
159
+ if (client.signal?.aborted) throw error;
160
+ return result("unknown");
161
+ }
162
+ }
163
+
164
+ /**
165
+ * True when another staging deployment, not this one, may have moved the edge since the
166
+ * baseline was taken: any non-failed deployment newer than the baseline's newest completed
167
+ * one (another terminal's, submitted after the baseline listing and before this request).
168
+ * A listing that cannot show where the baseline sits, or cannot be read, is ambiguous too.
169
+ */
170
+ async function interveningStagingDeployment(client, siteId, now, { baselineDeploymentId, deploymentId }) {
171
+ try {
172
+ // A wider page than the baseline's, so this request's own row cannot push the baseline off it.
173
+ const { deployments, truncated } = await listDeployments(
174
+ client,
175
+ siteId,
176
+ { environment: DEPLOYMENT_ENVIRONMENT_STAGING, pageSize: 20 },
177
+ { deadline: now() + PREVIOUS_DEPLOYMENT_MILLISECONDS, now },
178
+ );
179
+ const baselineAt = baselineDeploymentId === null
180
+ ? deployments.length
181
+ : deployments.findIndex((deployment) => deployment.id === baselineDeploymentId);
182
+ if (baselineAt < 0 || (baselineDeploymentId === null && truncated)) return true;
183
+ return deployments.slice(0, baselineAt).some((deployment) =>
184
+ deployment.id !== deploymentId && deployment.status !== DEPLOYMENT_STATUS_FAILED
185
+ );
186
+ } catch (error) {
187
+ if (client.signal?.aborted) throw error;
188
+ return true;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * The release staging serves before a deployment is sent, to tell when it
194
+ * changes. `unsettled` marks a read taken when the previous staging deployment may
195
+ * still be propagating (or could not be checked): what it saw can be that
196
+ * deployment's predecessor, so a later change cannot be attributed to this one.
197
+ */
198
+ export async function readStagingReleaseBefore(client, siteId, now) {
199
+ // Listed first, so the previous deployment's age is judged no later than the read it qualifies.
200
+ const { state: previous, id: baselineDeploymentId } = await previousStagingDeployment(client, siteId, now);
201
+ const observed = await observeStagingRelease(client, siteId, now, {}, now() + BEFORE_READ_MILLISECONDS);
202
+ // A site that never completed a staging deployment serves nothing yet, whether its host
203
+ // has no route (a derived address) or refuses a handoff for want of one.
204
+ if (previous === "none" && observed.kind !== "release") return { kind: "none", unsettled: false, baselineDeploymentId };
205
+ const unsettled = previous === "recent" || previous === "unknown";
206
+ // A site that has served a release and gave no readable baseline has nothing to compare against.
207
+ if (observed.kind === "none") return { kind: "unknown", unsettled, baselineDeploymentId };
208
+ return { ...observed, unsettled, baselineDeploymentId };
209
+ }
210
+
211
+ /**
212
+ * What a deployment reports when the served release cannot be confirmed: only
213
+ * the ceiling is known. `reason` says why, so a skipped check does not read as a
214
+ * failed one.
215
+ */
216
+ export function unverifiedStagingServed(startedAt, reason = "The CLI could not confirm that staging serves the new release.") {
217
+ const ceiling = LIMITS.stagingPropagationCeilingSeconds;
218
+ const expectedBy = new Date(startedAt + ceiling * 1000).toISOString();
219
+ return {
220
+ state: "unverified",
221
+ ceilingSeconds: ceiling,
222
+ expectedBy,
223
+ message: `${reason} The edge can serve the previous release for up to ${ceiling} seconds after a deployment completes; wait until ${expectedBy} before reviewing.`,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Waits, within a bound, until staging serves a release other than `before`, and
229
+ * says what it found. It never fails the deployment: the deployment completed,
230
+ * and what is uncertain is only when the edge catches up.
231
+ *
232
+ * States: `served` (a different release answered twice in a row), `propagating`
233
+ * (the last read at the bound still showed the previous release), and
234
+ * `unverified` (the answer cannot be attributed to this deployment, or the last
235
+ * read did not name the previous release, so only the ceiling is known).
236
+ */
237
+ export async function confirmStagingServed(client, siteId, { before, deploymentId, coalesced = false, onProgress, now }) {
238
+ const startedAt = now();
239
+ const ceiling = LIMITS.stagingPropagationCeilingSeconds;
240
+ if (before.kind === "unknown") return unverifiedStagingServed(startedAt);
241
+ if (before.unsettled) {
242
+ return unverifiedStagingServed(
243
+ startedAt,
244
+ "The previous staging deployment completed moments ago, so a change in what staging serves cannot be attributed to this one.",
245
+ );
246
+ }
247
+ if (coalesced) {
248
+ return unverifiedStagingServed(
249
+ startedAt,
250
+ "This request joined a deployment that was already under way, which may already be served.",
251
+ );
252
+ }
253
+ onProgress(
254
+ `Deployment completed. The edge can serve the previous release for up to ${ceiling} seconds; waiting for staging to serve the new one.`,
255
+ );
256
+ const session = {};
257
+ let streak = 0;
258
+ // Whether the latest read still named the baseline release: the only evidence of "still the previous release".
259
+ let stillBaseline = false;
260
+ let lastReported = startedAt;
261
+ try {
262
+ await poll({
263
+ client,
264
+ now,
265
+ onProgress,
266
+ timeoutMilliseconds: LIMITS.stagingServedMilliseconds,
267
+ intervalMilliseconds: LIMITS.stagingServedPollMilliseconds,
268
+ read: async ({ deadline }) => await observeStagingRelease(client, siteId, now, session, deadline),
269
+ evaluate: (observed) => {
270
+ stillBaseline = observed.kind === "release" && before.kind === "release"
271
+ && observed.releaseId === before.releaseId;
272
+ const isNew = observed.kind === "release"
273
+ && (before.kind !== "release" || observed.releaseId !== before.releaseId);
274
+ streak = isNew ? streak + 1 : 0;
275
+ if (streak >= LIMITS.stagingServedConfirmations) return { done: true, value: true };
276
+ // One line every fifteen seconds: enough to show it is alive, not a scrolling log.
277
+ if (stillBaseline && now() - lastReported >= 15_000) {
278
+ lastReported = now();
279
+ return { done: false, progress: "Staging is still serving the previous release; checking again." };
280
+ }
281
+ return { done: false };
282
+ },
283
+ timeoutCode: "staging.served_timeout",
284
+ });
285
+ } catch (error) {
286
+ if (!(error instanceof SiteAuthoringError) || error.code !== "staging.served_timeout") throw error;
287
+ const waited = Math.round((now() - startedAt) / 1000);
288
+ if (!stillBaseline) {
289
+ // The wait outlasted the ceiling, so there is no later time to wait for.
290
+ return {
291
+ state: "unverified",
292
+ waitedSeconds: waited,
293
+ ceilingSeconds: ceiling,
294
+ message: `The CLI could not confirm which release staging serves after ${waited} seconds, which is past the ${ceiling}-second ceiling. Open the handoff and confirm the release before reviewing.`,
295
+ };
296
+ }
297
+ return {
298
+ state: "propagating",
299
+ waitedSeconds: waited,
300
+ ceilingSeconds: ceiling,
301
+ message: `Staging was still serving the previous release ${waited} seconds after the deployment completed. Wait a minute and run 'taproot-site staging review' for a fresh handoff before reviewing.`,
302
+ };
303
+ }
304
+ // The baseline was read before this request was sent; a deployment another terminal completed
305
+ // in between could be the release that answered, so check the history again before claiming it.
306
+ if (
307
+ await interveningStagingDeployment(client, siteId, now, {
308
+ baselineDeploymentId: before.baselineDeploymentId ?? null,
309
+ deploymentId,
310
+ })
311
+ ) {
312
+ return unverifiedStagingServed(
313
+ now(),
314
+ "Another staging deployment ran around this one, so a change in what staging serves cannot be attributed to it.",
315
+ );
316
+ }
317
+ const seconds = Math.round((now() - startedAt) / 1000);
318
+ onProgress(`Staging serves the new release (${seconds} seconds after the deployment completed).`);
319
+ return { state: "served", servedAfterSeconds: seconds, ceilingSeconds: ceiling };
320
+ }
@@ -1,17 +1,7 @@
1
- import {
2
- buildTaprootDarkTheme,
3
- buildTaprootLightTheme,
4
- DEFAULT_DARK_THEME,
5
- DEFAULT_LIGHT_THEME,
6
- encodeTheme,
7
- layerThemes,
8
- mergePartials,
9
- mergeTheme,
10
- parseTheme,
11
- } from "@taprootio/espalier/shared/theme";
1
+ import { encodeTheme, layerThemes, mergePartials, mergeTheme, parseTheme } from "@taprootio/espalier/shared/theme";
12
2
 
13
3
  import { SiteAuthoringError } from "./errors.js";
14
- import { authoredMappingTokens, missingThemeFields } from "./theme-validation.js";
4
+ import { authoredMappingTokens, missingThemeFields, SCHEME_DEFAULTS } from "./theme-validation.js";
15
5
 
16
6
  /**
17
7
  * Project one stored scheme theme to the complete theme a consumer actually
@@ -19,13 +9,14 @@ import { authoredMappingTokens, missingThemeFields } from "./theme-validation.js
19
9
  *
20
10
  * Taproot stores what was written. A site whose theme was seeded or written
21
11
  * before a key existed keeps a sparse document, and every consumer — the
22
- * app's preview, the generator, the published runtime — resolves it by
23
- * layering the same defaults underneath: Espalier's scheme defaults plus,
24
- * for a theme Taproot still manages, the Taproot house theme with an empty
25
- * page background. A theme the CLI manages (`*ThemeManagedExternally`) is
26
- * rendered as stored over Espalier's defaults alone. `pull` writes exactly
27
- * that effective theme, so a fresh workspace validates and a designer edits
28
- * every group with its authoritative value instead of reconstructing it.
12
+ * app's preview, the generator, the published runtime — resolves it the same
13
+ * way for every theme: as stored, over Espalier's scheme defaults alone. No
14
+ * renderer layers the Taproot house theme under a stored theme any more
15
+ * (TR00879), whoever manages it. (The site editor keeps a house reference
16
+ * layer for its own inheritance display; it is inert for a complete theme and
17
+ * is not what the site publishes.) `pull` writes exactly that effective theme,
18
+ * so a fresh workspace validates and a designer edits every group with its
19
+ * authoritative value instead of reconstructing it.
29
20
  *
30
21
  * Semantic mappings are the one group that is *not* filled from defaults.
31
22
  * Espalier compiles them from `roles` at merge time, and since 4.18.0 the
@@ -52,40 +43,27 @@ import { authoredMappingTokens, missingThemeFields } from "./theme-validation.js
52
43
  * rendered before the pull.
53
44
  */
54
45
 
55
- const SCHEME_DEFAULTS = Object.freeze({
56
- light: { defaults: DEFAULT_LIGHT_THEME, house: buildTaprootLightTheme },
57
- dark: { defaults: DEFAULT_DARK_THEME, house: buildTaprootDarkTheme },
58
- });
46
+ // Espalier's defaults carry no page background at all, but a stored theme must have both keys. No image is
47
+ // the rendered truth, and the opacity of an absent image is neutral; these are the values pull has always
48
+ // written there, so a pulled theme does not change.
49
+ const NO_IMAGE_BACKGROUND_OPACITY = Object.freeze({ light: 0.5, dark: 0.55 });
59
50
 
60
51
  function isPlainObject(value) {
61
52
  return value !== null && typeof value === "object" && !Array.isArray(value);
62
53
  }
63
54
 
64
- /**
65
- * The complete theme a consumer layers a stored theme onto: Espalier's
66
- * scheme defaults, and — unless the theme is managed externally — the
67
- * Taproot house theme with the empty page background both the BFF and the
68
- * generator clear before rendering.
69
- */
70
- export function effectiveThemeBase(scheme, { managedExternally = false } = {}) {
71
- const { defaults, house } = SCHEME_DEFAULTS[scheme];
72
- const houseTheme = parseTheme(house("")) ?? {};
73
- if (managedExternally) {
74
- // Espalier's defaults carry no page background at all; the stored
75
- // contract requires both keys. No image is the rendered truth, and the
76
- // opacity of an absent image is neutral, so the house value fills it.
77
- return {
78
- ...defaults,
79
- pageBackgroundImage: "",
80
- pageBackgroundImageOpacity: houseTheme.pageBackgroundImageOpacity,
81
- };
82
- }
83
- return { ...mergePartials(defaults, houseTheme), pageBackgroundImage: "" };
55
+ /** The complete theme a consumer layers a stored theme onto: Espalier's scheme defaults, with no page background. */
56
+ export function effectiveThemeBase(scheme) {
57
+ return {
58
+ ...SCHEME_DEFAULTS[scheme],
59
+ pageBackgroundImage: "",
60
+ pageBackgroundImageOpacity: NO_IMAGE_BACKGROUND_OPACITY[scheme],
61
+ };
84
62
  }
85
63
 
86
- export function projectPulledTheme(stored, scheme, { managedExternally = false } = {}) {
64
+ export function projectPulledTheme(stored, scheme) {
87
65
  const source = isPlainObject(stored) ? stored : {};
88
- const base = effectiveThemeBase(scheme, { managedExternally });
66
+ const base = effectiveThemeBase(scheme);
89
67
  const { semanticMappings: _baseMappings, ...layerable } = base;
90
68
  // The marker is set on the base so mergePartials treats every stored
91
69
  // mapping as authored; the pin filter below then decides which survive.
@@ -102,7 +80,6 @@ export function projectPulledTheme(stored, scheme, { managedExternally = false }
102
80
  { ...merged, semanticMappings: pins, explicitMappingTokens: Object.keys(pins) },
103
81
  source,
104
82
  scheme,
105
- managedExternally,
106
83
  authored,
107
84
  );
108
85
 
@@ -111,12 +88,8 @@ export function projectPulledTheme(stored, scheme, { managedExternally = false }
111
88
  }
112
89
 
113
90
  /** What a consumer renders for a stored theme: the partial it layers, resolved over Espalier's defaults. */
114
- function renderedTheme(stored, scheme, managedExternally) {
115
- const { defaults, house } = SCHEME_DEFAULTS[scheme];
116
- const layered = managedExternally
117
- ? layerThemes(encodeTheme(stored))
118
- : layerThemes(house(""), encodeTheme({ pageBackgroundImage: "" }), encodeTheme(stored));
119
- return mergeTheme(defaults, parseTheme(layered) ?? {});
91
+ function renderedTheme(stored, scheme) {
92
+ return mergeTheme(SCHEME_DEFAULTS[scheme], parseTheme(layerThemes(encodeTheme(stored))) ?? {});
120
93
  }
121
94
 
122
95
  function canonicalJson(value) {
@@ -137,11 +110,11 @@ function canonicalJson(value) {
137
110
  * out of the projection and keep recompiling. Every other difference between
138
111
  * the projection and the stored theme's rendering is written back.
139
112
  */
140
- function reconcileWithRendering(projected, stored, scheme, managedExternally, authored) {
141
- const { defaults } = SCHEME_DEFAULTS[scheme];
113
+ function reconcileWithRendering(projected, stored, scheme, authored) {
114
+ const defaults = SCHEME_DEFAULTS[scheme];
142
115
  let rendered;
143
116
  try {
144
- rendered = renderedTheme(stored, scheme, managedExternally);
117
+ rendered = renderedTheme(stored, scheme);
145
118
  } catch {
146
119
  // An invalid stored theme cannot be rendered for comparison; validation
147
120
  // reports it, and the layered projection is the best available document.
@@ -300,7 +300,8 @@ function refuseAgentStylesheets(theme, scheme) {
300
300
  }
301
301
  }
302
302
 
303
- const SCHEME_DEFAULTS = Object.freeze({ light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME });
303
+ /** Espalier's complete default theme for each scheme: the base every stored theme is rendered over. */
304
+ export const SCHEME_DEFAULTS = Object.freeze({ light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME });
304
305
 
305
306
  function sameMapping(left, right) {
306
307
  return isPlainObject(left) && isPlainObject(right)
package/src/transport.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./constants.js";
28
28
  import { Readable } from "node:stream";
29
29
 
30
+ import { normalizeLatestCliVersion } from "./cli-release.js";
30
31
  import { validateApiBaseUrl } from "./config.js";
31
32
  import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
32
33
 
@@ -574,6 +575,18 @@ export class ApiError extends SiteAuthoringError {
574
575
  return undefined;
575
576
  }
576
577
 
578
+ /**
579
+ * The latest published release a `cli_outdated` refusal names, or undefined.
580
+ * Taproot states it only inside the refusal's sentence, so it is read from
581
+ * there and kept only if it parses as a version (TR01225).
582
+ */
583
+ latestCliVersion() {
584
+ if (this.refusalKind() !== REFUSAL_CLI_OUTDATED) return undefined;
585
+ const text = this.descriptionFor(CLI_UPGRADE_REFUSAL_FIELD) ?? this.message;
586
+ const match = /accepts only the latest published release, (\S+?)\. Upgrade with:/u.exec(text);
587
+ return match === null ? undefined : normalizeLatestCliVersion(match[1]);
588
+ }
589
+
577
590
  /**
578
591
  * Classifies a refusal into the one thing the caller should do next. The
579
592
  * six kinds are genuinely different behaviors, and collapsing them into
@@ -323,9 +323,120 @@ const TYPE_DETAILS = Object.freeze({
323
323
  },
324
324
  });
325
325
 
326
- export const TYPED_PAGE_TYPES = Object.freeze(Object.keys(TYPE_DETAILS));
327
- /** The templates a Markdown source can declare: every typed page but the update-only generated one. */
328
- export const MARKDOWN_PAGE_TYPES = Object.freeze(TYPED_PAGE_TYPES.filter((type) => type !== TEMPLATE_GENERATED));
326
+ /**
327
+ * The legal page type (TR01221). It is described here and nowhere else in the
328
+ * CLI: it has no template name in `typed-pages.js` because a site-authoring
329
+ * key cannot author it, so `pull` records it as a metadata-only page and
330
+ * `pages push` never sees a source for it.
331
+ */
332
+ export const LEGAL_PAGE_TYPE = "legal";
333
+
334
+ export const TYPED_PAGE_TYPES = Object.freeze([...Object.keys(TYPE_DETAILS), LEGAL_PAGE_TYPE]);
335
+ /** The templates a Markdown source can declare: the authorable typed pages, not generated or legal. */
336
+ export const MARKDOWN_PAGE_TYPES = Object.freeze(
337
+ TYPED_PAGE_TYPES.filter((type) => type !== TEMPLATE_GENERATED && type !== LEGAL_PAGE_TYPE),
338
+ );
339
+
340
+ /**
341
+ * The default for any privacy policy or other legal text that names the
342
+ * platform (TR01224). `help page free-form` prints it too, because a policy
343
+ * written through the CLI is a free-form page.
344
+ */
345
+ export const LEGAL_PLATFORM_LINK_GUIDANCE = Object.freeze({
346
+ rule:
347
+ "Where a privacy policy or other legal page names Taproot, link the first mention to https://www.taproot.io by "
348
+ + "default, so readers can learn how the platform affects them. Leave it plain only if the owner asks.",
349
+ example: "This site is published on [Taproot](https://www.taproot.io).",
350
+ });
351
+
352
+ function legalReference() {
353
+ return Object.freeze({
354
+ type: LEGAL_PAGE_TYPE,
355
+ displayName: "Legal page",
356
+ summary:
357
+ "A versioned legal document such as a privacy policy or terms: an automatic h1, a version and effective date, and "
358
+ + "permanent links to each version. The CLI sees it as metadata only; it is created and published in the app.",
359
+ readOnly: true,
360
+ workspace: Object.freeze({
361
+ directory: `${PAGES_DIRECTORY}/`,
362
+ templateType: "TEMPLATE_TYPE_LEGAL",
363
+ sourceRule:
364
+ "A legal page has no source file. 'pull' lists it in .taproot-site-manifest.json with templateType "
365
+ + "TEMPLATE_TYPE_LEGAL and workspaceMode \"metadata-only\" and writes nothing under pages/ for it, so 'pages push' has "
366
+ + "nothing to send.",
367
+ }),
368
+ fields: Object.freeze([
369
+ Object.freeze({ name: "title", description: "The page title. It prints as the page's h1, so the body should not open with its own title." }),
370
+ Object.freeze({
371
+ name: "body",
372
+ description: "The rich-text document. A table of contents is built from its level-2 headings.",
373
+ }),
374
+ Object.freeze({
375
+ name: "documentKey",
376
+ description:
377
+ "The document's stable identity, set in the app. The permanent version address exists only once the page has "
378
+ + "a document key and a version.",
379
+ }),
380
+ Object.freeze({
381
+ name: "documentRole",
382
+ description:
383
+ "The typed purpose of the document, such as membership_terms or privacy_notice. Selected in the app; fixed for membership terms.",
384
+ }),
385
+ Object.freeze({ name: "summaryOfChanges", description: "A short note on what this version changed, written by the owner." }),
386
+ Object.freeze({
387
+ name: "material",
388
+ description: "Set by a person: this version's changes need readers to accept again when the site's policy asks for that.",
389
+ }),
390
+ Object.freeze({
391
+ name: "version, effectiveDate, supersedesVersion",
392
+ description:
393
+ "Assigned by Taproot when the document is promoted to production: the version number, the promotion date, and "
394
+ + "the version it replaces. They cannot be written, so a document that has not been promoted shows none of them.",
395
+ }),
396
+ ]),
397
+ rendering: Object.freeze([
398
+ "An automatic <h1> from the title, then an Effective, Version and Supersedes list, a download link for a PDF of "
399
+ + "the version, the summary of changes, and a table of contents, once those exist.",
400
+ "Two addresses once published: the current document at /<path>, and an immutable copy of each version at "
401
+ + "/<path>/<version> (and /<path>/<version>.pdf). Later versions never change an earlier address.",
402
+ ]),
403
+ authoring: Object.freeze({
404
+ cli:
405
+ "Read-only. 'pull' records the page's identity, path, title and status. No verb creates, edits, converts, "
406
+ + "approves or publishes one. A bare 'approve' stages every tracked page with a draft, a pending legal draft "
407
+ + "included, and the request holding it is refused (up to 100 pages go in one request): name the pages ('approve <path>').",
408
+ key:
409
+ "A site-authoring key cannot create or publish a legal page, and a production deploy whose selected pages include "
410
+ + "one is refused for a key, because the permission that governs legal documents (site.legal_documents.manage) is "
411
+ + "outside the key's delegation.content, delegation.design and delegation.deployments capabilities. Until a "
412
+ + "person promotes an app-approved legal page, every production promotion of a release staged after that approval is refused. This is how keys work today; whether a key should ever author a legal page is an open owner decision.",
413
+ app:
414
+ "A signed-in person who may manage the site's legal documents creates the page, writes its body, and publishes it "
415
+ + "with a production deployment in the app. Taproot assigns the version and effective date at that point.",
416
+ }),
417
+ choosing: Object.freeze({
418
+ legal:
419
+ "Choose a legal page when readers must be able to see which version applied and when it took effect, such as a "
420
+ + "privacy policy, terms of service or membership terms.",
421
+ freeForm:
422
+ "A free-form page is an ordinary page. It has no version history, no effective date, no permanent version "
423
+ + "addresses and no automatic heading: open its Markdown with a '# ' line holding the title (see "
424
+ + `'${CLI_BINARY_NAME} help page free-form'). The CLI cannot author a legal page, so a policy written here is an ordinary `
425
+ + "page, free-form unless another template fits: put the date it was last updated in its text, and tell the owner it is not versioned.",
426
+ }),
427
+ platformLink: LEGAL_PLATFORM_LINK_GUIDANCE,
428
+ workflow: Object.freeze([
429
+ Object.freeze({
430
+ command: `${CLI_BINARY_NAME} pull`,
431
+ result: "Records the page as metadata-only in the manifest; nothing to edit.",
432
+ }),
433
+ Object.freeze({
434
+ command: "the app",
435
+ result: "A person with the legal permission edits the draft and promotes it with a production deployment.",
436
+ }),
437
+ ]),
438
+ });
439
+ }
329
440
 
330
441
  function typedReference(type) {
331
442
  const details = TYPE_DETAILS[type];
@@ -389,7 +500,11 @@ function typedReference(type) {
389
500
  });
390
501
  }
391
502
 
392
- const REFERENCES = Object.freeze(Object.fromEntries(TYPED_PAGE_TYPES.map((type) => [type, typedReference(type)])));
503
+ const REFERENCES = Object.freeze(
504
+ Object.fromEntries(
505
+ TYPED_PAGE_TYPES.map((type) => [type, type === LEGAL_PAGE_TYPE ? legalReference() : typedReference(type)]),
506
+ ),
507
+ );
393
508
 
394
509
  /** The reference for one non-free-form template, or `undefined`. */
395
510
  export function getTypedPageReference(type) {
@@ -400,8 +515,17 @@ function formatField(field) {
400
515
  return ` ${field.name.padEnd(18)} ${field.type}; ${field.required ? "required" : "optional"}; ${field.description}`;
401
516
  }
402
517
 
518
+ function formatLegalReference(page) {
519
+ return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n\nFields (set in the app):\n${
520
+ page.fields.map((field) => ` ${field.name}: ${field.description}`).join("\n")
521
+ }\n\nHow it renders:\n${page.rendering.map((line) => ` - ${line}`).join("\n")}\n\nWho can author it:\n CLI ${page.authoring.cli}\n site-authoring key ${page.authoring.key}\n app ${page.authoring.app}\n\nLegal page or free-form page:\n legal ${page.choosing.legal}\n free-form ${page.choosing.freeForm}\n\nLinking Taproot: ${page.platformLink.rule}\n example ${page.platformLink.example}\n\nWorkflow:\n${
522
+ page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
523
+ }\n\nUse --json for the exact structure.\n`;
524
+ }
525
+
403
526
  /** The plain-text `help page <type>` rendering. */
404
527
  export function formatTypedPageReference(page) {
528
+ if (page.type === LEGAL_PAGE_TYPE) return formatLegalReference(page);
405
529
  const markdown = page.workspace.formats.find((format) => format.extension === ".md");
406
530
  const json = page.workspace.formats.find((format) => format.extension === ".pm.json");
407
531
  const markdownSection = markdown === undefined