@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,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
|
+
}
|
package/src/theme-projection.js
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
64
|
+
export function projectPulledTheme(stored, scheme) {
|
|
87
65
|
const source = isPlainObject(stored) ? stored : {};
|
|
88
|
-
const base = effectiveThemeBase(scheme
|
|
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
|
|
115
|
-
|
|
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,
|
|
141
|
-
const
|
|
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
|
|
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.
|
package/src/theme-validation.js
CHANGED
|
@@ -300,7 +300,8 @@ function refuseAgentStylesheets(theme, scheme) {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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(
|
|
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
|