@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
package/src/errors.js
CHANGED
|
@@ -91,6 +91,17 @@ export function sanitizeDiagnostic(value, fallback = "The operation failed.") {
|
|
|
91
91
|
return [...clean].slice(0, LIMITS.diagnosticScalars).join("") || fallback;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The fixed wording every `theme.setting_missing` detail carries. A required
|
|
96
|
+
* key that is absent is exactly that: absence alone says nothing about when
|
|
97
|
+
* the key entered the contract, so the text never claims a later contract
|
|
98
|
+
* introduced it. The remedy names both truthful recoveries — restore the key
|
|
99
|
+
* locally, or refresh a workspace that is stale — without promising that a
|
|
100
|
+
* repeated pull repairs a server projection that already omits the key.
|
|
101
|
+
*/
|
|
102
|
+
export const MISSING_SETTING_MESSAGE =
|
|
103
|
+
"is required and absent (restore it locally, or run 'taproot-site pull' if this workspace is stale)";
|
|
104
|
+
|
|
94
105
|
/**
|
|
95
106
|
* The one error type this package throws. Every failure carries a stable
|
|
96
107
|
* dotted code and an exit code: 2 for a usage fault the caller fixes by
|
|
@@ -111,7 +122,7 @@ export class SiteAuthoringError extends Error {
|
|
|
111
122
|
.map((item) => ({
|
|
112
123
|
code: item.code,
|
|
113
124
|
field: sanitizeDiagnostic(item.field, "").slice(0, 200),
|
|
114
|
-
message:
|
|
125
|
+
message: MISSING_SETTING_MESSAGE,
|
|
115
126
|
}))
|
|
116
127
|
: undefined;
|
|
117
128
|
this.previewDiagnostic = normalizePreviewDiagnostic(options.previewDiagnostic);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
+
import { computeThemeProperties } from "@taprootio/espalier/shared/theme-properties";
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
DATA_SERIES_KEYS,
|
|
5
7
|
DEFAULT_DATA_RAMP_STEPS,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
DEFAULT_DARK_THEME,
|
|
12
14
|
DEFAULT_LIGHT_THEME,
|
|
13
15
|
LIGHTNESS_KEYS,
|
|
16
|
+
mergeTheme,
|
|
14
17
|
NESTED_THEME_KEYS,
|
|
15
18
|
ROLE_NAMES,
|
|
16
19
|
SEMANTIC_COLOR_NAMES,
|
|
@@ -92,13 +95,24 @@ function exampleTheme(defaults, scheme) {
|
|
|
92
95
|
paper: "#fffaf4",
|
|
93
96
|
midnight: "#21172b",
|
|
94
97
|
},
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
// Contrasting on purpose: light headings sit in the dark anchor on the
|
|
99
|
+
// light canvas, dark headings in the paper anchor on the dark canvas. The
|
|
100
|
+
// pair is resolved and contrast-checked by assertPresentationExamples.
|
|
101
|
+
roles: scheme === "light"
|
|
102
|
+
? {
|
|
103
|
+
canvas: "primary",
|
|
104
|
+
ink: { color: "primary", heading: "anchor:midnight" },
|
|
105
|
+
accent: { color: "anchor:plum", text: "anchor:plum.text", hover: "anchor:plum.hover" },
|
|
106
|
+
action: { color: "anchor:plum", ink: "anchor:paper" },
|
|
107
|
+
structure: "primary",
|
|
108
|
+
}
|
|
109
|
+
: {
|
|
110
|
+
canvas: "primary",
|
|
111
|
+
ink: { color: "primary", heading: "anchor:paper" },
|
|
112
|
+
accent: { color: "anchor:plum", text: "anchor:plum.hover", hover: "anchor:paper" },
|
|
113
|
+
action: { color: "anchor:plum", ink: "anchor:paper" },
|
|
114
|
+
structure: "primary",
|
|
115
|
+
},
|
|
102
116
|
contexts: {
|
|
103
117
|
inverted: {
|
|
104
118
|
canvas: "anchor:midnight",
|
|
@@ -251,6 +265,69 @@ const THEME_GROUPS = Object.freeze([
|
|
|
251
265
|
}),
|
|
252
266
|
]);
|
|
253
267
|
|
|
268
|
+
/**
|
|
269
|
+
* How a role binding becomes a rendered token, in the order the resolver
|
|
270
|
+
* applies it. Stated once here so help theme, the walkthrough and the
|
|
271
|
+
* validation warnings describe the same model (TR00801, TR00806).
|
|
272
|
+
*/
|
|
273
|
+
export const ROLE_RESOLUTION = Object.freeze({
|
|
274
|
+
slots: Object.freeze({
|
|
275
|
+
canvas: Object.freeze([]),
|
|
276
|
+
ink: Object.freeze(["heading"]),
|
|
277
|
+
accent: Object.freeze(["text", "hover"]),
|
|
278
|
+
action: Object.freeze(["ink"]),
|
|
279
|
+
structure: Object.freeze([]),
|
|
280
|
+
}),
|
|
281
|
+
bindingForms: Object.freeze([
|
|
282
|
+
"A role takes one mapping source, or { color, ...slots } to bind its slots separately.",
|
|
283
|
+
"A geometric source (" + COLOR_SOURCES.filter((source) => !STATUS_COLOR_SOURCES.includes(source)).join(", ")
|
|
284
|
+
+ ") is relative: it takes the seed hue through that family's angle and the token's lightness stop, so it moves "
|
|
285
|
+
+ "with seedColor and the lightness ramp. A status family (" + STATUS_COLOR_SOURCES.join(", ")
|
|
286
|
+
+ ") keeps its fixed hue from semanticHues (or its intents override) and does not move with seedColor.",
|
|
287
|
+
"anchor:<name> is absolute in hue and chroma: the anchor's declared colour supplies both, and the token's own "
|
|
288
|
+
+ "lightness stop (with APCA enforcement) still sets its lightness. anchor:<name>.<slot> selects one of the "
|
|
289
|
+
+ "anchor's declared slots (text, hover, ...) as that hue-and-chroma source and resolves the same way; neither "
|
|
290
|
+
+ "form renders the declared colour verbatim.",
|
|
291
|
+
"Anchors resolve per scheme: the same anchor:<name> in lightTheme and darkTheme reads each scheme's own anchors "
|
|
292
|
+
+ "table, so a dark scheme usually declares darker or lighter anchors under the same names.",
|
|
293
|
+
]),
|
|
294
|
+
precedence: Object.freeze([
|
|
295
|
+
"Any mapping present in semanticMappings wins for its token, whether or not explicitMappingTokens names it: "
|
|
296
|
+
+ "the shipped resolver overlays the stored mappings after compiling the roles. To let a role reach a token, "
|
|
297
|
+
+ "delete the mapping; editing the marker alone does not unpin it. explicitMappingTokens records which mappings "
|
|
298
|
+
+ "are authored pins and is what pull writes.",
|
|
299
|
+
"Otherwise the token is compiled from the roles, and an APCA contrast check may nudge an action or ink stop so "
|
|
300
|
+
+ "the pair stays readable.",
|
|
301
|
+
"Otherwise the Espalier default mapping applies.",
|
|
302
|
+
"A context rebinds roles, lightness, tones and pins for one zone with the same precedence inside that zone.",
|
|
303
|
+
]),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
function themeLightness(value) {
|
|
307
|
+
const match = /^oklch\(\s*([0-9.]+)/u.exec(value ?? "");
|
|
308
|
+
return match ? Number(match[1]) : undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The examples must render as contrasting pairs, not merely validate: a dark
|
|
313
|
+
* example whose headings resolve to a dark anchor on a dark canvas validated
|
|
314
|
+
* fine and read as a defect. Resolve both through Espalier and require the
|
|
315
|
+
* text, heading and link tokens to sit well away from the canvas lightness.
|
|
316
|
+
*/
|
|
317
|
+
function assertExampleContrast(theme, scheme) {
|
|
318
|
+
const properties = computeThemeProperties(
|
|
319
|
+
mergeTheme(scheme === "light" ? DEFAULT_LIGHT_THEME : DEFAULT_DARK_THEME, theme),
|
|
320
|
+
scheme,
|
|
321
|
+
);
|
|
322
|
+
const canvas = themeLightness(properties["--esp-color-background"]);
|
|
323
|
+
for (const token of ["--esp-color-text", "--esp-color-headings", "--esp-color-link", "--esp-color-action-text"]) {
|
|
324
|
+
const lightness = themeLightness(properties[token]);
|
|
325
|
+
if (canvas === undefined || lightness === undefined || Math.abs(lightness - canvas) < 0.4) {
|
|
326
|
+
throw new Error(`${scheme} theme example: ${token} (${properties[token]}) does not contrast with the canvas (${properties["--esp-color-background"]}).`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
254
331
|
export function getThemeReference() {
|
|
255
332
|
const version = getEspalierVersion();
|
|
256
333
|
return {
|
|
@@ -270,14 +347,15 @@ export function getThemeReference() {
|
|
|
270
347
|
semanticColors: SEMANTIC_COLOR_NAMES,
|
|
271
348
|
dataSeries: DATA_SERIES_KEYS,
|
|
272
349
|
},
|
|
350
|
+
roleResolution: ROLE_RESOLUTION,
|
|
273
351
|
workflow: [
|
|
274
|
-
"Run taproot-site pull and edit the complete light/dark pair it writes; never build a sparse theme from memory.",
|
|
352
|
+
"Run taproot-site pull and edit the complete light/dark pair it writes; never build a sparse theme from memory. pull resolves the stored theme over the same defaults every consumer renders, so a fresh workspace validates and every group carries its effective value.",
|
|
275
353
|
"Define brand anchors first, then assign canvas, ink, accent, action, and structure roles in both schemes.",
|
|
276
354
|
"Add named contexts for whole zones, including inverted zones, and rebind lightness when the zone changes brightness.",
|
|
277
355
|
"Tune typography, type/space ratios, radii, and viewport interpolation as one layout system.",
|
|
278
356
|
"Typography is per scheme on purpose: light and dark set their own moods, so a heading, body, or brand face that differs between them is a design choice, and theme push never warns about it.",
|
|
279
357
|
"Retune danger/success/warning/info through intents without changing their meanings; design data palettes separately.",
|
|
280
|
-
"Use semanticMappings only for meanings roles and contexts cannot express; scattered pins shadow the coherent model.",
|
|
358
|
+
"Use semanticMappings only for meanings roles and contexts cannot express; scattered pins shadow the coherent model. pull keeps only authored pins there and lists them in explicitMappingTokens; a default mapping copied into the document becomes a pin and makes the role for that token inert.",
|
|
281
359
|
"Run theme push, inspect warnings, then verify text, actions, focus states, and both schemes in authoring previews.",
|
|
282
360
|
],
|
|
283
361
|
example: THEME_EXAMPLE,
|
|
@@ -333,7 +411,17 @@ export function getAppearanceReference() {
|
|
|
333
411
|
imageReferences:
|
|
334
412
|
"Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
|
|
335
413
|
logoContract:
|
|
336
|
-
"lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting."
|
|
414
|
+
"lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting. "
|
|
415
|
+
+ "Prefer transparent PNG or WebP artwork with genuine alpha so the header background shows through, including "
|
|
416
|
+
+ "inside letter counters. Avoid baked-in background rectangles unless the brand intentionally uses a badge. "
|
|
417
|
+
+ "Keep padding tight and verify the delivered image preserves transparency. Logo style and coloration are "
|
|
418
|
+
+ "project-specific: choose monochrome, an accent, or multiple colors to suit the brand, not a universal recipe. "
|
|
419
|
+
+ "Keep the lettering readable and test the visible artwork against each header scheme.",
|
|
420
|
+
faviconContract:
|
|
421
|
+
"Include a favicon when designing a site: upload a square, simplified brand mark and set brand.faviconId "
|
|
422
|
+
+ "in settings/brand.json, then run theme push; faviconUrl is a read-only projection. Do not shrink a full wordmark into a favicon. "
|
|
423
|
+
+ "Inspect at 16 and 32 pixels in light and dark browser tabs; a deliberate contrasting badge can help a favicon "
|
|
424
|
+
+ "remain recognizable independently of the header theme. Verify the published icon loads, not just the setting.",
|
|
337
425
|
logoContrastContract:
|
|
338
426
|
"Evaluate lightLogoId against the rendered light-scheme header and darkLogoId against the rendered dark-scheme "
|
|
339
427
|
+ "header at desktop and compact/mobile widths. Transparent pixels provide no contrast: inspect the visible mark, "
|
|
@@ -531,6 +619,8 @@ export function getFooterReference() {
|
|
|
531
619
|
|
|
532
620
|
export function assertPresentationExamples() {
|
|
533
621
|
validateAndEncodeThemePair(THEME_EXAMPLE.lightTheme, THEME_EXAMPLE.darkTheme);
|
|
622
|
+
assertExampleContrast(THEME_EXAMPLE.lightTheme, "light");
|
|
623
|
+
assertExampleContrast(THEME_EXAMPLE.darkTheme, "dark");
|
|
534
624
|
validateFooterDocument(FOOTER_EXAMPLE);
|
|
535
625
|
}
|
|
536
626
|
|
|
@@ -590,7 +680,15 @@ export function formatPresentationReference(reference) {
|
|
|
590
680
|
+ reference.themePaths.join(", ") + ")\nComplete pulled baseline required: yes\nNested merge groups: "
|
|
591
681
|
+ reference.nestedMergeGroups.join(", ") + "\n\nDesign workflow:\n"
|
|
592
682
|
+ reference.workflow.map((step, index) => " " + (index + 1) + ". " + step).join("\n")
|
|
593
|
-
+ "\n\
|
|
683
|
+
+ "\n\nRole slots:\n"
|
|
684
|
+
+ Object.entries(reference.roleResolution.slots).map(([role, slots]) =>
|
|
685
|
+
" " + role.padEnd(10) + (slots.length === 0 ? "color only" : "color, " + slots.join(", "))
|
|
686
|
+
).join("\n")
|
|
687
|
+
+ "\n\nHow a binding resolves:\n"
|
|
688
|
+
+ reference.roleResolution.bindingForms.map((line) => " - " + line).join("\n")
|
|
689
|
+
+ "\n\nPrecedence:\n"
|
|
690
|
+
+ reference.roleResolution.precedence.map((line, index) => " " + (index + 1) + ". " + line).join("\n")
|
|
691
|
+
+ "\n\nTheme groups:\n" + groups + "\n\nValid complete pair (both schemes resolved and contrast-checked):\n"
|
|
594
692
|
+ JSON.stringify(reference.example, null, 2) + "\n";
|
|
595
693
|
}
|
|
596
694
|
if (reference.referenceKind === "appearance") {
|
|
@@ -602,6 +700,7 @@ export function formatPresentationReference(reference) {
|
|
|
602
700
|
+ reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
|
|
603
701
|
+ "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
|
|
604
702
|
+ "\nLogo contrast: " + reference.logoContrastContract
|
|
703
|
+
+ "\nFavicon: " + reference.faviconContract
|
|
605
704
|
+ "\nHeader width: " + reference.headerWidthContract
|
|
606
705
|
+ "\nMobile menu and menu font: " + reference.menuContract
|
|
607
706
|
+ "\nFooter: " + reference.footerBoundary
|
package/src/reference-help.js
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
|
|
52
52
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
53
53
|
|
|
54
|
-
export const REFERENCE_VERSION =
|
|
54
|
+
export const REFERENCE_VERSION = 24;
|
|
55
55
|
export const PAGE_TYPES = Object.freeze(["free-form"]);
|
|
56
56
|
|
|
57
57
|
function deepFreeze(value) {
|
|
@@ -134,7 +134,7 @@ const DESIGN_BLUEPRINTS = deepFreeze([
|
|
|
134
134
|
purpose: "Make a single product story legible before offering supporting choices.",
|
|
135
135
|
section: { entrance: "scale", entranceStagger: "none" },
|
|
136
136
|
components: [
|
|
137
|
-
{ type: "image-banner", properties: { ratio: "3/1", heightMode: "viewport", contentPosition: "bottom-start", scrim: "bottom", imageMotion: "
|
|
137
|
+
{ type: "image-banner", properties: { ratio: "3/1", heightMode: "viewport", contentPosition: "bottom-start", scrim: "bottom", imageMotion: "parallax" } },
|
|
138
138
|
{ type: "cta", properties: { variant: "primary", imagePosition: "right", borderWidth: 0 } },
|
|
139
139
|
],
|
|
140
140
|
},
|
|
@@ -148,7 +148,7 @@ const DESIGN_BLUEPRINTS = deepFreeze([
|
|
|
148
148
|
],
|
|
149
149
|
},
|
|
150
150
|
],
|
|
151
|
-
motionCeiling: "
|
|
151
|
+
motionCeiling: "Prefer one optional image parallax per view (imageMotion: parallax-subtle or parallax). Published pages use CSS view timelines where supported and a platform-owned scroll-linked JavaScript fallback otherwise; no custom code is needed. Movement stops when scrolling stops, pauses offscreen, and is disabled for reduced motion. Without JavaScript or native timelines the image stays static. Cinematic zoom is an explicit ambient-loop alternative. Never conceal names, price context, or actions behind motion.",
|
|
152
152
|
mediaBrief: "Commission or license product, material, and in-use photography; document crop-safe banner areas and supply alt text for informative images.",
|
|
153
153
|
integrationBoundaries: "Use outbound links for retail, commerce, search, or booking providers. This blueprint includes none of those systems and no custom code.",
|
|
154
154
|
accessibilityCautions: ["Preserve text contrast over imagery with a tested scrim and do not make text depend on hover.", "Respect reduced-motion preferences and give every campaign action a visible text label."],
|
|
@@ -374,6 +374,16 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
374
374
|
usage: `${CLI_BINARY_NAME} help preview`,
|
|
375
375
|
summary: "Describe authoring preview creation, waiting, and recovery.",
|
|
376
376
|
}),
|
|
377
|
+
Object.freeze({
|
|
378
|
+
name: "delivery",
|
|
379
|
+
usage: `${CLI_BINARY_NAME} help delivery`,
|
|
380
|
+
summary: "Verify visitor-facing delivery after a deployment: routes, assets, runtime, browser.",
|
|
381
|
+
}),
|
|
382
|
+
Object.freeze({
|
|
383
|
+
name: "walkthrough",
|
|
384
|
+
usage: `${CLI_BINARY_NAME} help walkthrough`,
|
|
385
|
+
summary: "Design a site end to end: select, pull, edit both schemes, validate, push, review staging, promote.",
|
|
386
|
+
}),
|
|
377
387
|
Object.freeze({
|
|
378
388
|
name: "theme",
|
|
379
389
|
usage: `${CLI_BINARY_NAME} help theme`,
|
|
@@ -397,6 +407,105 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
397
407
|
]);
|
|
398
408
|
|
|
399
409
|
const WORKFLOW_REFERENCES = Object.freeze({
|
|
410
|
+
delivery: Object.freeze({
|
|
411
|
+
title: "Delivery verification",
|
|
412
|
+
summary: "delivery check reads a completed deployment's target the way a visitor does and reports HTTP delivery, runtime compatibility and (optionally) browser behaviour as separate dimensions.",
|
|
413
|
+
usage: `${CLI_BINARY_NAME} delivery check (--staging | --production [--url <origin>]) [--wait <seconds>] [--no-browser]`,
|
|
414
|
+
details: Object.freeze([
|
|
415
|
+
"A completed deployment job proves Taproot wrote what it meant to write, not that a visitor receives it. The "
|
|
416
|
+
+ "check verifies the latest completed deployment for the target, and notes when the workspace recorded a "
|
|
417
|
+
+ "different one.",
|
|
418
|
+
"Routes come from the workspace manifest's pages plus '/', bounded to 20; each must answer 200 text/html "
|
|
419
|
+
+ "without redirecting. Internal link targets found in those pages are checked too (bounded to 40).",
|
|
420
|
+
"Assets are what the home page declares: favicon links, module preloads, the site bundle, and up to six "
|
|
421
|
+
+ "images. A 200 is not enough: the content type must match the kind, so an HTML error page served as an "
|
|
422
|
+
+ "image fails.",
|
|
423
|
+
"Runtime: the page's bootstrap declares the major stream, the mutable major pointer, the immutable fallback "
|
|
424
|
+
+ "copy and the capability modules. The pointer's major must match, its entry must load as JavaScript, every "
|
|
425
|
+
+ "declared capability must be resolvable, and a pointer behind the fallback's version is reported as a "
|
|
426
|
+
+ "stale runtime pointer that returning browsers may still be on.",
|
|
427
|
+
"Browser: a fresh and a returning (same-context, cached) load are compared against the pointer's entry when "
|
|
428
|
+
+ "Playwright resolves from the CLI's own install: npm install --global @taprootio/site-authoring@latest "
|
|
429
|
+
+ "playwright && npx playwright install chromium, then rerun through the installed taproot-site command (a "
|
|
430
|
+
+ "copy run through npx lives in npm's cache and cannot see a global Playwright). A DevTools "
|
|
431
|
+
+ "session on the browser fails any document request that would leave the site origin before it is sent "
|
|
432
|
+
+ "(redirect hops, frames, script-driven navigations and popups included), so a target that strays is "
|
|
433
|
+
+ "reported with offOriginNavigation and is not loaded again; without that session nothing is loaded and "
|
|
434
|
+
+ "the dimension is unchecked. Whether "
|
|
435
|
+
+ "the returning load really reused the cached runtime entry (memory or disk) is observed through the same "
|
|
436
|
+
+ "session and reported as browser.returningCache, unchecked where it cannot be observed. Every URL the "
|
|
437
|
+
+ "probe reports passes the same credential withholding as redirect Locations. Without Playwright the "
|
|
438
|
+
+ "dimension is reported as unchecked, never as passed; a cache-busted HTTP fetch cannot stand in for it.",
|
|
439
|
+
"Bounds: four concurrent requests, two attempts per request, 15 seconds each; --wait re-checks failures once "
|
|
440
|
+
+ "after at most 120 seconds. The report stays truthful after retry exhaustion: attempts and failures are "
|
|
441
|
+
+ "listed with observed and expected values and the final URL. Twenty local-only references and forty "
|
|
442
|
+
+ "failure lines are kept across the whole run, and the whole report is trimmed (successful items first, "
|
|
443
|
+
+ "then failed items, failure lines, local-only references, capability detail, and finally the runtime "
|
|
444
|
+
+ "detail down to its verdict counts, each with totals and truncation flags) so it always fits the output bound.",
|
|
445
|
+
"Read-only and bounded: the staging cookie goes only to the staging origin, only same-origin assets and the "
|
|
446
|
+
+ "runtime the page's bootstrap declares are fetched (an authored reference to any other host is content, "
|
|
447
|
+
+ "not delivery), local-only references (localhost, *.test, *.local) are reported rather than fetched, a "
|
|
448
|
+
+ "redirect Location that could reflect a credential is withheld, bodies are read up to a fixed bound, "
|
|
449
|
+
+ "member-only content is not exercised, and nothing is purged, republished or rolled back.",
|
|
450
|
+
"Verdicts: delivered (no findings), degraded (every route served and the runtime is compatible, but an asset, "
|
|
451
|
+
+ "link, pointer or browser load did not match), failed (a route or the runtime did not deliver).",
|
|
452
|
+
]),
|
|
453
|
+
example: Object.freeze({
|
|
454
|
+
command: `${CLI_BINARY_NAME} delivery check --production --url https://www.example.com/ --wait 30`,
|
|
455
|
+
}),
|
|
456
|
+
}),
|
|
457
|
+
walkthrough: Object.freeze({
|
|
458
|
+
title: "Design walkthrough",
|
|
459
|
+
summary: "One pass from selecting a site to promoting a reviewed presentation, with the reference each step relies on.",
|
|
460
|
+
usage: `${CLI_BINARY_NAME} help walkthrough`,
|
|
461
|
+
details: Object.freeze([
|
|
462
|
+
`Run the current release: npm install --global @taprootio/site-authoring, or npx --yes @taprootio/site-authoring@latest <verb>. `
|
|
463
|
+
+ "Always name @latest with npx: an unversioned npx reuses whatever it cached, and Taproot accepts only the latest "
|
|
464
|
+
+ "release. --version prints what is running; status reports the latest known release as cliRelease.",
|
|
465
|
+
`Select the site: ${CLI_BINARY_NAME} login, then ${CLI_BINARY_NAME} sites and ${CLI_BINARY_NAME} use <site-name-or-id>. `
|
|
466
|
+
+ "sites reports each site's kind and authoring surface. A standard site takes every verb. A managed Docs site "
|
|
467
|
+
+ "takes presentation only — pull, theme push, footer push, media upload, deploy, status — because its pages, "
|
|
468
|
+
+ "navigation and redirects come from the Docs artifact; a prebuilt Docs site takes no verb at all.",
|
|
469
|
+
`Pull the baseline: ${CLI_BINARY_NAME} pull writes settings/taproot-styles.json with both schemes' complete effective `
|
|
470
|
+
+ "themes (the stored theme resolved over the same defaults every consumer renders) beside brand.json, "
|
|
471
|
+
+ "site-header.json and site-publishing-preferences.json. Keep that pull as the baseline you edit; never build "
|
|
472
|
+
+ "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens.",
|
|
473
|
+
"Edit both schemes: settings.lightTheme and settings.darkTheme are separate documents. Declare anchors, then roles "
|
|
474
|
+
+ "(help theme lists the role slots and how anchor references resolve), tune typography per scheme, and keep "
|
|
475
|
+
+ "contexts and intents paired across schemes. Set appearance scalars (default scheme, header, logos, favicon) "
|
|
476
|
+
+ "in the appearance files: help appearance.",
|
|
477
|
+
"Prepare media locally: PNG, JPEG, GIF and WebP raster files only, addressed relative to the workspace root. Make "
|
|
478
|
+
+ "the variants yourself with any image tool (a transparent logo, a @2x copy, a square favicon); there is no "
|
|
479
|
+
+ "recolor, crop or SVG upload command. Upload each with media upload, then assign lightLogoId and darkLogoId "
|
|
480
|
+
+ "(and brand.faviconId) from the returned image ids so each scheme carries the artwork that reads on its header.",
|
|
481
|
+
`Validate offline: ${CLI_BINARY_NAME} validate checks the complete pair, the appearance files and the footer against `
|
|
482
|
+
+ "the contracts the site enforces, and warns when a semanticMappings pin repeats the default on a token your "
|
|
483
|
+
+ "roles would have moved (the pin would keep the role from rendering).",
|
|
484
|
+
`Push: ${CLI_BINARY_NAME} theme push writes the footer scheme colours, the appearance scalars, then the light and dark `
|
|
485
|
+
+ "themes in that order, not atomically. A failure reports completedWrites: pull, compare the remote result "
|
|
486
|
+
+ "with the workspace, reconcile, and push again. footer push owns footer prose, links and imagery.",
|
|
487
|
+
`Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation and returns a single-use review handoff `
|
|
488
|
+
+ "in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's theme toggle to check "
|
|
489
|
+
+ "both schemes: text, headings, links and hover, actions and focus, the header logos, the favicon. When no "
|
|
490
|
+
+ "handoff could be minted, stagingPreview.reason says why and staging review mints another. A page draft is "
|
|
491
|
+
+ "not needed to review a presentation change; preview page renders one persisted draft and is a different tool.",
|
|
492
|
+
`Promote only after review: ${CLI_BINARY_NAME} deploy --production promotes the completed staging deployment. `
|
|
493
|
+
+ "Never use production to discover what staging should have shown.",
|
|
494
|
+
]),
|
|
495
|
+
example: Object.freeze({
|
|
496
|
+
commands: Object.freeze([
|
|
497
|
+
`npx --yes @taprootio/site-authoring@latest login`,
|
|
498
|
+
`${CLI_BINARY_NAME} sites`,
|
|
499
|
+
`${CLI_BINARY_NAME} use "Riverbend Wellness"`,
|
|
500
|
+
`${CLI_BINARY_NAME} pull`,
|
|
501
|
+
`${CLI_BINARY_NAME} media upload media/logo.png media/logo-dark.png media/favicon.png`,
|
|
502
|
+
`${CLI_BINARY_NAME} validate`,
|
|
503
|
+
`${CLI_BINARY_NAME} theme push`,
|
|
504
|
+
`${CLI_BINARY_NAME} deploy --staging`,
|
|
505
|
+
`${CLI_BINARY_NAME} deploy --production`,
|
|
506
|
+
]),
|
|
507
|
+
}),
|
|
508
|
+
}),
|
|
400
509
|
nav: Object.freeze({
|
|
401
510
|
title: "Navigation workspace contract",
|
|
402
511
|
summary: "nav push replaces the complete tree in nav.json; item IDs are author-minted canonical lowercase UUIDs.",
|
|
@@ -511,6 +620,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
511
620
|
details: Object.freeze([
|
|
512
621
|
"Paths are relative to the configured workspace root, not the shell's current directory.",
|
|
513
622
|
"PNG, JPEG, GIF, and WebP are accepted; retina names such as logo@2x.png and logo@3x.png are supported.",
|
|
623
|
+
"For header logos prefer genuine transparent PNG/WebP, check contrast in both themes, and include a simplified square favicon. See help appearance for branding guidance.",
|
|
624
|
+
"Prepare variants locally with any image tool before uploading: a transparent logo, its @2x copy, a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
|
|
625
|
+
"Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the returned image ids; theme push writes them. See help walkthrough for the full design pass.",
|
|
514
626
|
"Each result item includes media: { imageId, src, urls, width, height, alt }.",
|
|
515
627
|
"The same src/urls delivery fields are saved in .taproot-site-media.json for page and component authoring.",
|
|
516
628
|
]),
|
|
@@ -538,6 +650,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
538
650
|
+ "remain optional and old snapshots without content identity do not block deployment.",
|
|
539
651
|
"deploy --staging and redirects check return a separate fresh single-use staging handoff in "
|
|
540
652
|
+ "stagingPreview.url, after authenticating headless redirect checks. Handoffs never go to GITHUB_OUTPUT.",
|
|
653
|
+
"On a managed Docs site deploy --staging stages settings only and still returns stagingPreview.url: open it "
|
|
654
|
+
+ "once, then use the site's theme toggle to review both schemes; no page draft is needed to preview a "
|
|
655
|
+
+ "presentation change. When no handoff can be minted the result carries stagingPreview.reason "
|
|
656
|
+
+ "(staging.host_unavailable, staging.authority_denied, staging.surface_refused, or staging.handoff_unavailable) "
|
|
657
|
+
+ "and a recovery step; run staging review to mint another (it works on every surface). Never verify a theme "
|
|
658
|
+
+ "on production instead.",
|
|
541
659
|
"A page path resolves through .taproot-site-manifest.json; a canonical page UUID works directly.",
|
|
542
660
|
"The homepage's manifest path is empty; address it as '/', which resolves to that empty root path.",
|
|
543
661
|
"Preview before approving: approve consumes the draft, so an approved page has no draft left to render and answers preview.no_draft. Review it on staging after a deploy instead.",
|
|
@@ -546,7 +664,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
546
664
|
"Success returns READY with url, snapshotId, expiresAt, storedPreviewCap, storedPreviewCount, and evictedPreviews.",
|
|
547
665
|
"The url is a single-use handoff that expires two minutes after it is minted: opening it consumes it, and a reused, shared, or bookmarked URL answers Not found. Run preview page again for another.",
|
|
548
666
|
"At the configured cap, creation revokes the oldest snapshot for the same page first, then the oldest snapshot held by the same key.",
|
|
549
|
-
`A stalled preview can be released with ${CLI_BINARY_NAME} preview revoke <page-id> <snapshot-id
|
|
667
|
+
`A stalled preview can be released with ${CLI_BINARY_NAME} preview revoke <page-id> <snapshot-id>. Revocation addresses the snapshot, not the draft: it works after approve has consumed the draft, revoking an already revoked snapshot succeeds again with status REVOKED, and an unknown, expired-and-cleaned-up, or other-page snapshot answers preview.not_found without revealing whether it exists elsewhere.`,
|
|
550
668
|
]),
|
|
551
669
|
example: Object.freeze({ command: `${CLI_BINARY_NAME} preview page classes` }),
|
|
552
670
|
}),
|
package/src/settings-catalog.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseTheme } from "@taprootio/espalier/shared/theme";
|
|
|
2
2
|
|
|
3
3
|
import { SiteAuthoringError } from "./errors.js";
|
|
4
4
|
import { projectFooterSettingsForWorkspace } from "./footer-contract.js";
|
|
5
|
+
import { projectPulledTheme } from "./theme-projection.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The settings surface the authoring CLI covers.
|
|
@@ -19,7 +20,8 @@ import { projectFooterSettingsForWorkspace } from "./footer-contract.js";
|
|
|
19
20
|
* is not: it is gated on `site.settings.manage` rather than theme management,
|
|
20
21
|
* promotes immediately rather than through staging, and is site administration
|
|
21
22
|
* rather than authored presentation.
|
|
22
|
-
* - `lightTheme` and `darkTheme` are decoded into readable JSON objects
|
|
23
|
+
* - `lightTheme` and `darkTheme` are decoded into readable JSON objects and
|
|
24
|
+
* projected to the complete effective theme (`theme-projection.js`). Theme
|
|
23
25
|
* push validates and re-encodes them only at the wire boundary.
|
|
24
26
|
* - `footerSettings` is snapshotted as its structured document. Theme push
|
|
25
27
|
* overlays only its scheme colors onto a fresh server read before using the
|
|
@@ -186,6 +188,17 @@ export function projectSettingsGroup(group, response) {
|
|
|
186
188
|
const projected = {};
|
|
187
189
|
for (const field of group.fields) {
|
|
188
190
|
projected[field.name] = normalizeField(field, source[field.name]);
|
|
191
|
+
if (field.wireType === "theme") {
|
|
192
|
+
// A stored theme is what was written, possibly before a required key
|
|
193
|
+
// existed; the workspace gets the complete theme consumers actually
|
|
194
|
+
// render, resolved the way they resolve it (TR00775). The read-only
|
|
195
|
+
// provenance flag says whether Taproot's house look still layers under it.
|
|
196
|
+
projected[field.name] = projectPulledTheme(
|
|
197
|
+
projected[field.name],
|
|
198
|
+
field.name === "lightTheme" ? "light" : "dark",
|
|
199
|
+
{ managedExternally: source[`${field.name}ManagedExternally`] === true },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
189
202
|
}
|
|
190
203
|
return projected;
|
|
191
204
|
}
|
package/src/staging-check.js
CHANGED
|
@@ -30,6 +30,10 @@ function unavailable() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Consume only the checker's handoff; never forward a platform bearer or follow a redirect. */
|
|
33
|
+
export async function authorizeStagingHandoff(client, handoff, signal) {
|
|
34
|
+
return await authorize(client, handoff, signal);
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
async function authorize(client, handoff, signal) {
|
|
34
38
|
const response = await request(client, handoff.url, undefined, signal);
|
|
35
39
|
const origin = new URL(handoff.stagingUrl).origin;
|
|
@@ -49,6 +53,11 @@ async function authorize(client, handoff, signal) {
|
|
|
49
53
|
return cookie;
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
/** Withhold a Location that could reflect a staging credential; exported for the delivery check. */
|
|
57
|
+
export function withholdCredentialLocation(value) {
|
|
58
|
+
return safeLocation(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
function safeLocation(value) {
|
|
53
62
|
if (value === null) return "";
|
|
54
63
|
// The edge could be misconfigured. Never reflect handoff credentials from a
|