@taprootio/site-authoring 0.8.3 → 0.8.5
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 +85 -3
- package/examples/riverbend-wellness/settings/taproot-styles.json +2 -188
- package/package.json +2 -2
- package/src/api.js +35 -0
- package/src/cli.js +126 -5
- package/src/constants.js +8 -1
- package/src/content/components.js +18 -2
- package/src/content/tiptap-prosemirror.ts +23 -2
- package/src/delivery-browser.js +276 -0
- package/src/delivery-check.js +821 -0
- package/src/errors.js +12 -1
- package/src/presentation-reference.js +110 -11
- package/src/reference-help.js +122 -4
- package/src/settings-catalog.js +14 -1
- package/src/staging-check.js +9 -0
- package/src/theme-projection.js +213 -0
- package/src/theme-validation.js +64 -4
- package/src/verbs/delivery-check.js +185 -0
- package/src/verbs/deploy.js +72 -3
- package/src/verbs/index.js +6 -0
- package/src/verbs/preview-revoke.js +2 -2
- package/src/verbs/staging-review.js +29 -0
- package/src/verbs/theme-push.js +3 -3
|
@@ -0,0 +1,213 @@
|
|
|
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";
|
|
12
|
+
|
|
13
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
14
|
+
import { missingThemeFields } from "./theme-validation.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Project one stored scheme theme to the complete theme a consumer actually
|
|
18
|
+
* renders (TR00775).
|
|
19
|
+
*
|
|
20
|
+
* Taproot stores what was written. A site whose theme was seeded or written
|
|
21
|
+
* 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.
|
|
29
|
+
*
|
|
30
|
+
* Semantic mappings are the one group that is *not* filled from defaults.
|
|
31
|
+
* Espalier compiles them from `roles` at merge time; a mapping stored in
|
|
32
|
+
* `semanticMappings` pins that token and shadows whatever the roles would
|
|
33
|
+
* have produced. Copying the defaults' twenty-three mappings into the
|
|
34
|
+
* document would therefore freeze them and make later role changes inert
|
|
35
|
+
* — the failure a complete seeded theme already exhibits. The projection
|
|
36
|
+
* keeps only the mappings that are authored pins: those the stored marker
|
|
37
|
+
* names, plus any mapping whose value differs from the effective default
|
|
38
|
+
* (a pin added by hand without updating the marker). It writes the marker
|
|
39
|
+
* for exactly that set, so `theme push` stores pins only and roles compile
|
|
40
|
+
* for everything else.
|
|
41
|
+
*
|
|
42
|
+
* Layering alone is not rendering parity: `mergeTheme` also lets an
|
|
43
|
+
* unpinned type-role ink inherit a customized `text` mapping, and lets a
|
|
44
|
+
* `chroma.text` override flow into the type-role chroma bands the document
|
|
45
|
+
* did not set. Filling those groups from the defaults, or dropping a
|
|
46
|
+
* cached mapping that was holding a token at its default, would change
|
|
47
|
+
* what the site renders. So the projection is reconciled against Espalier
|
|
48
|
+
* itself: the stored theme is resolved exactly the way its consumers resolve
|
|
49
|
+
* it, the projected document is resolved the same way, and every difference
|
|
50
|
+
* is written back as the rendered value (a mapping pin, a chroma band). The
|
|
51
|
+
* result validates, keeps the author's pins, and renders what the site
|
|
52
|
+
* rendered before the pull.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const SCHEME_DEFAULTS = Object.freeze({
|
|
56
|
+
light: { defaults: DEFAULT_LIGHT_THEME, house: buildTaprootLightTheme },
|
|
57
|
+
dark: { defaults: DEFAULT_DARK_THEME, house: buildTaprootDarkTheme },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function isPlainObject(value) {
|
|
61
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sameMapping(left, right) {
|
|
65
|
+
return isPlainObject(left) && isPlainObject(right)
|
|
66
|
+
&& left.source === right.source && left.lightness === right.lightness
|
|
67
|
+
&& Object.keys(left).length === Object.keys(right).length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The complete theme a consumer layers a stored theme onto: Espalier's
|
|
72
|
+
* scheme defaults, and — unless the theme is managed externally — the
|
|
73
|
+
* Taproot house theme with the empty page background both the BFF and the
|
|
74
|
+
* generator clear before rendering.
|
|
75
|
+
*/
|
|
76
|
+
export function effectiveThemeBase(scheme, { managedExternally = false } = {}) {
|
|
77
|
+
const { defaults, house } = SCHEME_DEFAULTS[scheme];
|
|
78
|
+
const houseTheme = parseTheme(house("")) ?? {};
|
|
79
|
+
if (managedExternally) {
|
|
80
|
+
// Espalier's defaults carry no page background at all; the stored
|
|
81
|
+
// contract requires both keys. No image is the rendered truth, and the
|
|
82
|
+
// opacity of an absent image is neutral, so the house value fills it.
|
|
83
|
+
return {
|
|
84
|
+
...defaults,
|
|
85
|
+
pageBackgroundImage: "",
|
|
86
|
+
pageBackgroundImageOpacity: houseTheme.pageBackgroundImageOpacity,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { ...mergePartials(defaults, houseTheme), pageBackgroundImage: "" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function projectPulledTheme(stored, scheme, { managedExternally = false } = {}) {
|
|
93
|
+
const source = isPlainObject(stored) ? stored : {};
|
|
94
|
+
const base = effectiveThemeBase(scheme, { managedExternally });
|
|
95
|
+
const { semanticMappings: _baseMappings, ...layerable } = base;
|
|
96
|
+
// The marker is set on the base so mergePartials treats every stored
|
|
97
|
+
// mapping as authored; the pin filter below then decides which survive.
|
|
98
|
+
const merged = mergePartials({ ...layerable, semanticMappings: {}, explicitMappingTokens: [] }, source);
|
|
99
|
+
|
|
100
|
+
const marker = Array.isArray(source.explicitMappingTokens)
|
|
101
|
+
? new Set(source.explicitMappingTokens.filter((token) => typeof token === "string"))
|
|
102
|
+
: undefined;
|
|
103
|
+
const pins = {};
|
|
104
|
+
if (isPlainObject(source.semanticMappings)) {
|
|
105
|
+
for (const [token, mapping] of Object.entries(source.semanticMappings)) {
|
|
106
|
+
const cachedDefault = sameMapping(mapping, base.semanticMappings?.[token]);
|
|
107
|
+
if (marker?.has(token) || !cachedDefault) pins[token] = mapping;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const projected = reconcileWithRendering(
|
|
111
|
+
{ ...merged, semanticMappings: pins, explicitMappingTokens: Object.keys(pins) },
|
|
112
|
+
source,
|
|
113
|
+
scheme,
|
|
114
|
+
managedExternally,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
requireProjectedThemeComplete(projected, scheme);
|
|
118
|
+
return projected;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What a consumer renders for a stored theme: the partial it layers, resolved over Espalier's defaults. */
|
|
122
|
+
function renderedTheme(stored, scheme, managedExternally) {
|
|
123
|
+
const { defaults, house } = SCHEME_DEFAULTS[scheme];
|
|
124
|
+
const layered = managedExternally
|
|
125
|
+
? layerThemes(encodeTheme(stored))
|
|
126
|
+
: layerThemes(house(""), encodeTheme({ pageBackgroundImage: "" }), encodeTheme(stored));
|
|
127
|
+
return mergeTheme(defaults, parseTheme(layered) ?? {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function canonicalJson(value) {
|
|
131
|
+
if (Array.isArray(value)) return JSON.stringify(value.map((entry) => JSON.parse(canonicalJson(entry))));
|
|
132
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
133
|
+
return JSON.stringify(Object.fromEntries(Object.keys(value).sort().map((key) => [key, JSON.parse(canonicalJson(value[key]))])));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bring the projected document to rendering parity with the stored theme.
|
|
138
|
+
* Two passes suffice: a mapping pin can change what a companion token
|
|
139
|
+
* inherits, so the comparison runs again after the first corrections.
|
|
140
|
+
*
|
|
141
|
+
* One deliberate exception: a cached default mapping on a token the stored
|
|
142
|
+
* theme's own roles would move is the shadowing pin TR00801 documents and
|
|
143
|
+
* warns about. Restoring it would keep the roles inert, so it stays dropped
|
|
144
|
+
* and the role-compiled value is accepted as the rendering the author asked
|
|
145
|
+
* for. Every other difference is written back.
|
|
146
|
+
*/
|
|
147
|
+
function reconcileWithRendering(projected, stored, scheme, managedExternally) {
|
|
148
|
+
const { defaults } = SCHEME_DEFAULTS[scheme];
|
|
149
|
+
let rendered;
|
|
150
|
+
let roleCompiled;
|
|
151
|
+
try {
|
|
152
|
+
rendered = renderedTheme(stored, scheme, managedExternally);
|
|
153
|
+
const declaresRoles = isPlainObject(stored.roles) && Object.keys(stored.roles).length > 0;
|
|
154
|
+
roleCompiled = declaresRoles
|
|
155
|
+
? mergeTheme(defaults, { ...stored, semanticMappings: {}, explicitMappingTokens: [] }).semanticMappings
|
|
156
|
+
: undefined;
|
|
157
|
+
} catch {
|
|
158
|
+
// An invalid stored theme cannot be rendered for comparison; validation
|
|
159
|
+
// reports it, and the layered projection is the best available document.
|
|
160
|
+
return projected;
|
|
161
|
+
}
|
|
162
|
+
let current = projected;
|
|
163
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
164
|
+
let candidate;
|
|
165
|
+
try {
|
|
166
|
+
candidate = mergeTheme(defaults, current);
|
|
167
|
+
} catch {
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
let changed = false;
|
|
171
|
+
for (const key of Object.keys(rendered)) {
|
|
172
|
+
if (key === "explicitMappingTokens") continue;
|
|
173
|
+
if (canonicalJson(rendered[key]) === canonicalJson(candidate[key])) continue;
|
|
174
|
+
changed = true;
|
|
175
|
+
if (key === "semanticMappings") {
|
|
176
|
+
const pins = { ...current.semanticMappings };
|
|
177
|
+
for (const [token, mapping] of Object.entries(rendered.semanticMappings)) {
|
|
178
|
+
if (canonicalJson(mapping) === canonicalJson(candidate.semanticMappings[token])) continue;
|
|
179
|
+
const storedMapping = isPlainObject(stored.semanticMappings) ? stored.semanticMappings[token] : undefined;
|
|
180
|
+
const cachedDefaultTheRolesMove = roleCompiled !== undefined
|
|
181
|
+
&& sameMapping(storedMapping, defaults.semanticMappings[token])
|
|
182
|
+
&& canonicalJson(candidate.semanticMappings[token]) === canonicalJson(roleCompiled[token]);
|
|
183
|
+
if (cachedDefaultTheRolesMove) continue;
|
|
184
|
+
pins[token] = structuredClone(mapping);
|
|
185
|
+
}
|
|
186
|
+
current = { ...current, semanticMappings: pins, explicitMappingTokens: Object.keys(pins) };
|
|
187
|
+
} else {
|
|
188
|
+
current = { ...current, [key]: structuredClone(rendered[key]) };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!changed) break;
|
|
192
|
+
}
|
|
193
|
+
return current;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The refusal for a projection that still lacks required fields. Reachable
|
|
198
|
+
* only when Espalier's defaults and the server response together omit a key
|
|
199
|
+
* this CLI requires — a contract drift between releases, never a stale
|
|
200
|
+
* workspace — so the remedy is a report, not another pull. Exported so the
|
|
201
|
+
* refusal itself is testable without an incomplete default to hand.
|
|
202
|
+
*/
|
|
203
|
+
export function requireProjectedThemeComplete(projected, scheme) {
|
|
204
|
+
const missing = missingThemeFields(projected, scheme);
|
|
205
|
+
if (missing.length === 0) return;
|
|
206
|
+
throw new SiteAuthoringError(
|
|
207
|
+
"theme.projection_incomplete",
|
|
208
|
+
"Taproot's settings response and the Espalier defaults this CLI ships still omit required theme fields. "
|
|
209
|
+
+ "This is a contract problem between the server projection and this CLI release, not a stale workspace: "
|
|
210
|
+
+ "repeating taproot-site pull cannot supply them. Report the listed paths with the CLI and server versions.",
|
|
211
|
+
{ field: missing[0], alternatives: missing },
|
|
212
|
+
);
|
|
213
|
+
}
|
package/src/theme-validation.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DARK_THEME,
|
|
3
|
+
DEFAULT_LIGHT_THEME,
|
|
4
|
+
encodeTheme,
|
|
5
|
+
LIGHTNESS_KEYS,
|
|
6
|
+
mergeTheme,
|
|
7
|
+
SEMANTIC_COLOR_NAMES,
|
|
8
|
+
validateThemePair,
|
|
9
|
+
} from "@taprootio/espalier/shared/theme";
|
|
2
10
|
|
|
3
11
|
import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
4
12
|
|
|
@@ -286,6 +294,53 @@ function refuseAgentStylesheets(theme, scheme) {
|
|
|
286
294
|
}
|
|
287
295
|
}
|
|
288
296
|
|
|
297
|
+
const SCHEME_DEFAULTS = Object.freeze({ light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME });
|
|
298
|
+
|
|
299
|
+
function sameMapping(left, right) {
|
|
300
|
+
return isPlainObject(left) && isPlainObject(right)
|
|
301
|
+
&& left.source === right.source && left.lightness === right.lightness;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Roles compile the semantic mappings at render time, and any mapping present
|
|
306
|
+
* in `semanticMappings` pins its token over that compilation (TR00801). A pin
|
|
307
|
+
* that merely repeats Espalier's default on a token the declared roles would
|
|
308
|
+
* have moved is the one an author never meant: it came from a copied example
|
|
309
|
+
* or a pre-projection pull, and it silently keeps the role from reaching the
|
|
310
|
+
* token — the accepted-but-gray link WTFM saw. Which tokens the roles move is
|
|
311
|
+
* asked of Espalier itself, by compiling the same roles with no pins at all;
|
|
312
|
+
* a token the roles leave at its default, or one the marker names as a
|
|
313
|
+
* deliberate pin, is never reported. It is a warning rather than a refusal
|
|
314
|
+
* because the pair still renders exactly as declared; Espalier validation
|
|
315
|
+
* owns the invalid cases.
|
|
316
|
+
*/
|
|
317
|
+
export function shadowedRoleMappingWarnings(theme, scheme) {
|
|
318
|
+
if (!isPlainObject(theme) || !isPlainObject(theme.roles) || Object.keys(theme.roles).length === 0) return [];
|
|
319
|
+
if (!isPlainObject(theme.semanticMappings)) return [];
|
|
320
|
+
const defaults = SCHEME_DEFAULTS[scheme];
|
|
321
|
+
let compiled;
|
|
322
|
+
try {
|
|
323
|
+
compiled = mergeTheme(defaults, { ...theme, semanticMappings: {}, explicitMappingTokens: [] }).semanticMappings;
|
|
324
|
+
} catch {
|
|
325
|
+
// An invalid theme is Espalier validation's finding, not this one's.
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
const deliberate = new Set(Array.isArray(theme.explicitMappingTokens) ? theme.explicitMappingTokens : []);
|
|
329
|
+
const warnings = [];
|
|
330
|
+
for (const [token, mapping] of Object.entries(theme.semanticMappings)) {
|
|
331
|
+
if (deliberate.has(token)) continue;
|
|
332
|
+
const fallback = defaults.semanticMappings[token];
|
|
333
|
+
if (sameMapping(mapping, fallback) && !sameMapping(compiled[token], fallback)) {
|
|
334
|
+
warnings.push(
|
|
335
|
+
`${scheme}: semanticMappings.${token} repeats the Espalier default and pins the token, so the declared roles `
|
|
336
|
+
+ `never reach it; remove the pin to let the roles compile ${token}, or list it in explicitMappingTokens `
|
|
337
|
+
+ `if the default is the intent.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return warnings;
|
|
342
|
+
}
|
|
343
|
+
|
|
289
344
|
export function validateAndEncodeThemePair(lightTheme, darkTheme) {
|
|
290
345
|
requireCompleteTheme(lightTheme, "light");
|
|
291
346
|
requireCompleteTheme(darkTheme, "dark");
|
|
@@ -317,7 +372,12 @@ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
|
|
|
317
372
|
}
|
|
318
373
|
refuseAgentStylesheets(lightTheme, "light");
|
|
319
374
|
refuseAgentStylesheets(darkTheme, "dark");
|
|
320
|
-
const
|
|
375
|
+
const allWarnings = [
|
|
376
|
+
...result.warnings,
|
|
377
|
+
...shadowedRoleMappingWarnings(lightTheme, "light"),
|
|
378
|
+
...shadowedRoleMappingWarnings(darkTheme, "dark"),
|
|
379
|
+
];
|
|
380
|
+
const warnings = allWarnings
|
|
321
381
|
.slice(0, MAXIMUM_THEME_WARNINGS)
|
|
322
382
|
.map((warning) =>
|
|
323
383
|
[...sanitizeDiagnostic(warning, "Theme validation warning.")]
|
|
@@ -328,8 +388,8 @@ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
|
|
|
328
388
|
light,
|
|
329
389
|
dark,
|
|
330
390
|
warnings,
|
|
331
|
-
warningCount:
|
|
332
|
-
warningsTruncated:
|
|
391
|
+
warningCount: allWarnings.length,
|
|
392
|
+
warningsTruncated: allWarnings.length > warnings.length,
|
|
333
393
|
};
|
|
334
394
|
}
|
|
335
395
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEPLOYMENT_ENVIRONMENT_PRODUCTION,
|
|
3
|
+
DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
4
|
+
DEPLOYMENT_STATUS_COMPLETED,
|
|
5
|
+
getStagingPreviewStatus,
|
|
6
|
+
listDeployments,
|
|
7
|
+
mintStagingPreviewHandoff,
|
|
8
|
+
withRefusalGuidance,
|
|
9
|
+
} from "../api.js";
|
|
10
|
+
import { DEPLOY_TARGET_PRODUCTION, DEPLOY_TARGET_STAGING, VERB_DELIVERY_CHECK } from "../constants.js";
|
|
11
|
+
import { probeDeliveryWithBrowser } from "../delivery-browser.js";
|
|
12
|
+
import { checkDelivery, DELIVERY_LIMITS } from "../delivery-check.js";
|
|
13
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
14
|
+
import { openSession, successResult } from "../session.js";
|
|
15
|
+
import { authorizeStagingHandoff } from "../staging-check.js";
|
|
16
|
+
import { readManifest } from "../workspace.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `delivery check (--staging | --production)` — verify what a visitor
|
|
20
|
+
* receives after a deployment completed (TR00824).
|
|
21
|
+
*
|
|
22
|
+
* The deployment log says the job finished. This verb reads the target the
|
|
23
|
+
* way a visitor does and reports three dimensions separately: the completed
|
|
24
|
+
* deployment (from the site's deployment log and the workspace record), HTTP
|
|
25
|
+
* delivery of the authored routes, their assets and the declared runtime, and
|
|
26
|
+
* — only when a browser is available — what a fresh and a returning browser
|
|
27
|
+
* actually loaded. Nothing is purged, republished, rolled back or mutated.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function usageError(code, message, field) {
|
|
31
|
+
return new SiteAuthoringError(code, message, { field, exitCode: 2 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function environmentFor(target) {
|
|
35
|
+
return target === DEPLOY_TARGET_STAGING ? DEPLOYMENT_ENVIRONMENT_STAGING : DEPLOYMENT_ENVIRONMENT_PRODUCTION;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function resolveTarget(client, siteId, target, invocation, onProgress, now) {
|
|
39
|
+
if (typeof invocation.deliveryUrl === "string" && invocation.deliveryUrl !== "") {
|
|
40
|
+
if (target === DEPLOY_TARGET_STAGING) {
|
|
41
|
+
// The staging edge serves only an authorized preview session bound to
|
|
42
|
+
// the acknowledged host, so another origin could not be checked as
|
|
43
|
+
// staging; refusing is truer than an unauthenticated probe.
|
|
44
|
+
throw usageError(
|
|
45
|
+
"delivery.url_not_for_staging",
|
|
46
|
+
"--url applies to --production; --staging always checks the site's acknowledged staging host with an "
|
|
47
|
+
+ "authorized preview session.",
|
|
48
|
+
"url",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return { baseUrl: invocation.deliveryUrl, source: "option" };
|
|
52
|
+
}
|
|
53
|
+
if (target === DEPLOY_TARGET_STAGING) {
|
|
54
|
+
const status = await getStagingPreviewStatus(client, siteId);
|
|
55
|
+
if (!status.ready) {
|
|
56
|
+
throw new SiteAuthoringError(
|
|
57
|
+
"delivery.staging_unavailable",
|
|
58
|
+
"The site has no acknowledged staging host yet, so there is nothing a visitor could receive. Wait for the "
|
|
59
|
+
+ "staging hostname to become ready (taproot-site status), then run delivery check again.",
|
|
60
|
+
{ field: "stagingUrl" },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
// The staging edge serves nothing without the preview cookie; the
|
|
64
|
+
// handoff is consumed here, once, and the cookie never leaves this run.
|
|
65
|
+
onProgress("Authorizing a staging preview session for the check.");
|
|
66
|
+
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|
|
67
|
+
const cookie = await authorizeStagingHandoff(client, handoff, client.timeoutSignal(90_000));
|
|
68
|
+
// The cookie is bound to the host the handoff was minted for; if the
|
|
69
|
+
// acknowledged host moved between the status read and the mint, that
|
|
70
|
+
// host — not the earlier status — is the only origin the cookie may reach.
|
|
71
|
+
if (handoff.stagingUrl !== status.stagingUrl) {
|
|
72
|
+
onProgress(`The acknowledged staging host changed to ${handoff.stagingUrl} while the check started; checking that host.`);
|
|
73
|
+
}
|
|
74
|
+
return { baseUrl: handoff.stagingUrl, source: "staging-status", cookie };
|
|
75
|
+
}
|
|
76
|
+
throw usageError(
|
|
77
|
+
"delivery.production_url_required",
|
|
78
|
+
"delivery check --production needs the site's public origin: pass --url https://<published-domain>/ using the "
|
|
79
|
+
+ "primaryDomain that 'taproot-site sites' reports. A site-authoring key cannot read hosting configuration, so "
|
|
80
|
+
+ "the CLI does not guess it.",
|
|
81
|
+
"url",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function deliveryCheck(invocation) {
|
|
86
|
+
const target = invocation.deployTarget;
|
|
87
|
+
if (target !== DEPLOY_TARGET_STAGING && target !== DEPLOY_TARGET_PRODUCTION) {
|
|
88
|
+
throw usageError("delivery.target_missing", "delivery check requires exactly one of --staging or --production.", "deployTarget");
|
|
89
|
+
}
|
|
90
|
+
const session = await openSession(invocation);
|
|
91
|
+
const { client, config, siteId, now, onProgress } = session;
|
|
92
|
+
const environment = environmentFor(target);
|
|
93
|
+
const manifest = await readManifest(config.workspaceDir, siteId, { required: false });
|
|
94
|
+
const recorded = manifest?.deployments?.[target];
|
|
95
|
+
|
|
96
|
+
return await withRefusalGuidance(onProgress, "delivery check", async () => {
|
|
97
|
+
onProgress("Reading the deployment log.");
|
|
98
|
+
const { deployments } = await listDeployments(client, siteId, { environment });
|
|
99
|
+
const completed = deployments.find((deployment) => deployment.status === DEPLOYMENT_STATUS_COMPLETED);
|
|
100
|
+
if (completed === undefined) {
|
|
101
|
+
throw new SiteAuthoringError(
|
|
102
|
+
"delivery.no_completed_deployment",
|
|
103
|
+
`No completed ${target} deployment exists for this site, so there is no delivery to verify. Run `
|
|
104
|
+
+ `'taproot-site deploy --${target}' first.`,
|
|
105
|
+
{ field: "deploymentId" },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const deployment = {
|
|
109
|
+
id: completed.id,
|
|
110
|
+
completedAt: completed.completedAt,
|
|
111
|
+
recordedInWorkspace: typeof recorded?.id === "string" ? recorded.id === completed.id : undefined,
|
|
112
|
+
...(typeof recorded?.id === "string" && recorded.id !== completed.id ? { workspaceRecordedId: recorded.id } : {}),
|
|
113
|
+
};
|
|
114
|
+
if (deployment.recordedInWorkspace === false) {
|
|
115
|
+
onProgress(
|
|
116
|
+
`Note: the workspace recorded ${target} deployment ${recorded.id}, but the latest completed ${target} deployment is ${completed.id}; the latest one is what visitors receive and is what this check verifies.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resolved = await resolveTarget(client, siteId, target, invocation, onProgress, now);
|
|
121
|
+
const routes = (manifest?.pages ?? [])
|
|
122
|
+
.filter((page) => page !== null && typeof page === "object" && typeof page.path === "string")
|
|
123
|
+
.map((page) => (page.path === "" ? "/" : `/${page.path.replace(/^\/+/u, "")}/`.replace(/\/\/$/u, "/")));
|
|
124
|
+
onProgress(`Checking ${resolved.baseUrl} (${Math.min(routes.length + 1, DELIVERY_LIMITS.routes)} route(s), assets, runtime).`);
|
|
125
|
+
const report = await checkDelivery({
|
|
126
|
+
fetch: client.fetch,
|
|
127
|
+
timeoutSignal: client.timeoutSignal,
|
|
128
|
+
baseUrl: resolved.baseUrl,
|
|
129
|
+
routes,
|
|
130
|
+
cookie: resolved.cookie,
|
|
131
|
+
propagationWaitSeconds: invocation.propagationWaitSeconds ?? 0,
|
|
132
|
+
now,
|
|
133
|
+
});
|
|
134
|
+
for (const failure of report.failures) onProgress(`Delivery: ${failure}`);
|
|
135
|
+
|
|
136
|
+
let browser;
|
|
137
|
+
if (invocation.browser === false) {
|
|
138
|
+
browser = { status: "unchecked", reason: "disabled", note: "Browser verification was not requested." };
|
|
139
|
+
} else {
|
|
140
|
+
onProgress("Browser dimension: probing with Playwright when it is installed.");
|
|
141
|
+
browser = await probeDeliveryWithBrowser({
|
|
142
|
+
baseUrl: resolved.baseUrl,
|
|
143
|
+
expectedEntryUrl: report.runtime.entry?.url ?? "",
|
|
144
|
+
capabilities: report.runtime.capabilities,
|
|
145
|
+
cookie: resolved.cookie,
|
|
146
|
+
importPlaywright: invocation.importPlaywright,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (browser.status === "unchecked") onProgress(`Browser dimension unchecked: ${browser.reason}.`);
|
|
150
|
+
else if (browser.status === "checked" && !browser.ok) onProgress("Browser dimension: a load did not match the declared runtime or left the site origin; see browser.fresh and browser.returning.");
|
|
151
|
+
if (browser.status === "checked" && browser.returningCache?.status !== "checked") onProgress("Browser dimension: cache reuse on the returning load could not be observed; it is unchecked.");
|
|
152
|
+
|
|
153
|
+
const verdict = report.verdict === "delivered" && browser.status === "checked" && !browser.ok ? "degraded" : report.verdict;
|
|
154
|
+
onProgress(
|
|
155
|
+
verdict === "delivered"
|
|
156
|
+
? "Delivery verified over HTTP" + (browser.status === "checked" ? " and in a browser." : "; browser behaviour unchecked.")
|
|
157
|
+
: `Delivery ${verdict}: ${report.failures.length} HTTP finding(s)` + (browser.status === "checked" && !browser.ok ? " and a browser mismatch." : "."),
|
|
158
|
+
);
|
|
159
|
+
return successResult(VERB_DELIVERY_CHECK, siteId, {
|
|
160
|
+
environment,
|
|
161
|
+
target: { ...report.target, resolvedFrom: resolved.source },
|
|
162
|
+
deployment,
|
|
163
|
+
verdict,
|
|
164
|
+
routes: report.routes,
|
|
165
|
+
assets: report.assets,
|
|
166
|
+
links: report.links,
|
|
167
|
+
localOnlyReferences: report.localOnlyReferences,
|
|
168
|
+
...(report.localOnlyReferencesTotal === undefined ? {} : { localOnlyReferencesTotal: report.localOnlyReferencesTotal }),
|
|
169
|
+
runtime: report.runtime,
|
|
170
|
+
browser,
|
|
171
|
+
failures: report.failures,
|
|
172
|
+
...(report.failuresTruncated ? { failuresTruncated: true, failuresTotal: report.failuresTotal } : {}),
|
|
173
|
+
propagation: report.propagation,
|
|
174
|
+
limits: report.limits,
|
|
175
|
+
checkedAt: report.checkedAt,
|
|
176
|
+
doesNotProve: [
|
|
177
|
+
"a full visual or accessibility audit",
|
|
178
|
+
"member-only content delivery, which stays behind the member boundary",
|
|
179
|
+
...(browser.status === "checked" && browser.returningCache?.status === "checked"
|
|
180
|
+
? []
|
|
181
|
+
: ["what a returning browser loads from its own cache"]),
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
package/src/verbs/deploy.js
CHANGED
|
@@ -10,10 +10,19 @@ import {
|
|
|
10
10
|
waitForDeployment,
|
|
11
11
|
withRefusalGuidance,
|
|
12
12
|
} from "../api.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DEPLOY_TARGET_PRODUCTION,
|
|
15
|
+
DEPLOY_TARGET_STAGING,
|
|
16
|
+
REFUSAL_CAPABILITY_MISSING,
|
|
17
|
+
REFUSAL_CREDENTIAL_REJECTED,
|
|
18
|
+
REFUSAL_UNCLASSIFIED,
|
|
19
|
+
SURFACE_DOCS_PRESENTATION,
|
|
20
|
+
VERB_DEPLOY,
|
|
21
|
+
} from "../constants.js";
|
|
14
22
|
import { SiteAuthoringError } from "../errors.js";
|
|
15
23
|
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
16
24
|
import { checkStagingRedirects } from "../staging-check.js";
|
|
25
|
+
import { ApiError } from "../transport.js";
|
|
17
26
|
import { readManifest, writeManifest } from "../workspace.js";
|
|
18
27
|
|
|
19
28
|
/**
|
|
@@ -157,6 +166,65 @@ async function inspectStagingPreview(client, siteId, onProgress, now) {
|
|
|
157
166
|
}
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
const PRESENTATION_REVIEW_GUIDANCE = "Open the URL once in a browser, then switch the site's theme toggle between "
|
|
170
|
+
+ "light and dark to review both schemes; no page draft is needed to preview a presentation change.";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Why a managed Docs staging handoff could not be minted, as a stable reason
|
|
174
|
+
* the caller can act on. The recovery never points at production or at a host
|
|
175
|
+
* the CLI has not been told about.
|
|
176
|
+
*/
|
|
177
|
+
function presentationHandoffUnavailable(error) {
|
|
178
|
+
if (error instanceof ApiError) {
|
|
179
|
+
const refusal = error.refusalKind();
|
|
180
|
+
if (refusal === REFUSAL_CREDENTIAL_REJECTED || refusal === REFUSAL_CAPABILITY_MISSING) {
|
|
181
|
+
return {
|
|
182
|
+
reason: "staging.authority_denied",
|
|
183
|
+
recovery: "This credential cannot view staging for this site. Re-issue a site-authoring key that carries "
|
|
184
|
+
+ "the Deployments capability, then run 'taproot-site staging review' for a handoff.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (refusal === REFUSAL_UNCLASSIFIED && error.hasField("SiteId")) {
|
|
188
|
+
return {
|
|
189
|
+
reason: "staging.surface_refused",
|
|
190
|
+
recovery: "The site no longer accepts external presentation authoring (its Docs source is prebuilt). "
|
|
191
|
+
+ "Run 'taproot-site use' to re-record the site's surface; a prebuilt Docs site stages nothing.",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (refusal === REFUSAL_UNCLASSIFIED && error.hasField("Host")) {
|
|
195
|
+
return {
|
|
196
|
+
reason: "staging.host_unavailable",
|
|
197
|
+
recovery: "The site has no acknowledged staging host yet. Wait for the staging hostname to become ready "
|
|
198
|
+
+ "(check 'taproot-site status'), then run 'taproot-site staging review' to mint a review handoff.",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
reason: "staging.handoff_unavailable",
|
|
204
|
+
recovery: "Run 'taproot-site staging review' to mint a fresh review handoff; the staged presentation is "
|
|
205
|
+
+ "already deployed and waits for review.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A managed Docs site deploys settings only, so there are no page drafts or
|
|
211
|
+
* redirects to inspect on staging; the review handoff is the whole handoff.
|
|
212
|
+
* It is minted after the deployment completed, bound by the server to the
|
|
213
|
+
* site's exact acknowledged staging host, and single-use like every other.
|
|
214
|
+
*/
|
|
215
|
+
async function mintPresentationStagingHandoff(client, siteId, onProgress, now) {
|
|
216
|
+
try {
|
|
217
|
+
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|
|
218
|
+
onProgress(`Staging review handoff minted for ${handoff.stagingUrl}. ${PRESENTATION_REVIEW_GUIDANCE}`);
|
|
219
|
+
return { ...handoff, review: PRESENTATION_REVIEW_GUIDANCE };
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error instanceof SiteAuthoringError && !(error instanceof ApiError)) throw error;
|
|
222
|
+
const unavailable = presentationHandoffUnavailable(error);
|
|
223
|
+
onProgress(`Warning: the deployment completed, but a staging review handoff could not be minted (${unavailable.reason}). ${unavailable.recovery}`);
|
|
224
|
+
return { url: "", stagingUrl: "", ...unavailable };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
160
228
|
export async function deploy(invocation) {
|
|
161
229
|
const target = invocation.deployTarget;
|
|
162
230
|
if (target !== DEPLOY_TARGET_STAGING && target !== DEPLOY_TARGET_PRODUCTION) {
|
|
@@ -298,7 +366,7 @@ export async function deploy(invocation) {
|
|
|
298
366
|
});
|
|
299
367
|
await recordDeployment(config, manifest, "staging", completed);
|
|
300
368
|
if (presentationOnly) {
|
|
301
|
-
|
|
369
|
+
const stagingPreview = await mintPresentationStagingHandoff(client, siteId, onProgress, now);
|
|
302
370
|
return successResult(VERB_DEPLOY, siteId, {
|
|
303
371
|
target,
|
|
304
372
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
@@ -310,7 +378,8 @@ export async function deploy(invocation) {
|
|
|
310
378
|
},
|
|
311
379
|
deployment: completed,
|
|
312
380
|
readiness: reportReadiness(readiness),
|
|
313
|
-
|
|
381
|
+
stagingPreview,
|
|
382
|
+
nextStep: stagingPreview.url === "" ? "staging review" : "deploy --production",
|
|
314
383
|
});
|
|
315
384
|
}
|
|
316
385
|
const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
|
package/src/verbs/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VERB_APPROVE,
|
|
3
|
+
VERB_DELIVERY_CHECK,
|
|
3
4
|
VERB_DEPLOY,
|
|
4
5
|
VERB_ENV,
|
|
5
6
|
VERB_FOOTER_PUSH,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
VERB_REDIRECTS_PULL,
|
|
16
17
|
VERB_REDIRECTS_PUSH,
|
|
17
18
|
VERB_SITES,
|
|
19
|
+
VERB_STAGING_REVIEW,
|
|
18
20
|
VERB_STATUS,
|
|
19
21
|
VERB_THEME_PUSH,
|
|
20
22
|
VERB_USE,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
VERB_WHOAMI,
|
|
23
25
|
} from "../constants.js";
|
|
24
26
|
import { approve } from "./approve.js";
|
|
27
|
+
import { deliveryCheck } from "./delivery-check.js";
|
|
25
28
|
import { deploy } from "./deploy.js";
|
|
26
29
|
import { env } from "./env.js";
|
|
27
30
|
import { footerPush } from "./footer-push.js";
|
|
@@ -37,6 +40,7 @@ import { redirectsCheck } from "./redirects-check.js";
|
|
|
37
40
|
import { redirectsPull } from "./redirects-pull.js";
|
|
38
41
|
import { redirectsPush } from "./redirects-push.js";
|
|
39
42
|
import { sites } from "./sites.js";
|
|
43
|
+
import { stagingReview } from "./staging-review.js";
|
|
40
44
|
import { status } from "./status.js";
|
|
41
45
|
import { themePush } from "./theme-push.js";
|
|
42
46
|
import { use } from "./use.js";
|
|
@@ -131,4 +135,6 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
131
135
|
[VERB_WHOAMI]: whoami,
|
|
132
136
|
[VERB_ENV]: env,
|
|
133
137
|
[VERB_STATUS]: status,
|
|
138
|
+
[VERB_DELIVERY_CHECK]: deliveryCheck,
|
|
139
|
+
[VERB_STAGING_REVIEW]: stagingReview,
|
|
134
140
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AUTHORING_PREVIEW_STATUS_REVOKED,
|
|
3
3
|
revokeAuthoringPreview,
|
|
4
|
-
|
|
4
|
+
translateAuthoringPreviewRevokeApiError,
|
|
5
5
|
withRefusalGuidance,
|
|
6
6
|
} from "../api.js";
|
|
7
7
|
import { VERB_PREVIEW_REVOKE } from "../constants.js";
|
|
@@ -56,6 +56,6 @@ export async function previewRevoke(invocation) {
|
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
58
|
} catch (error) {
|
|
59
|
-
throw
|
|
59
|
+
throw translateAuthoringPreviewRevokeApiError(error);
|
|
60
60
|
}
|
|
61
61
|
}
|