@taprootio/site-authoring 0.8.6 → 0.9.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 +14 -4
- package/package.json +1 -1
- package/src/api.js +57 -0
- package/src/cli.js +20 -2
- package/src/constants.js +1 -1
- package/src/footer-draft-hash.js +1 -1
- package/src/footer-workspace.js +97 -2
- package/src/presentation-reference.js +35 -10
- package/src/reference-help.js +7 -3
- package/src/transport.js +7 -0
- package/src/verbs/footer-push.js +28 -3
- package/src/verbs/pull.js +63 -15
- package/src/verbs/theme-push.js +341 -65
package/README.md
CHANGED
|
@@ -291,10 +291,20 @@ each step names the reference it relies on.
|
|
|
291
291
|
upload them with `media upload`, then assign `lightLogoId`, `darkLogoId`
|
|
292
292
|
and `brand.faviconId` from the returned ids. There is no recolor, crop or
|
|
293
293
|
SVG upload command.
|
|
294
|
-
5. **Validate, then push.** `validate` runs offline;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
push
|
|
294
|
+
5. **Validate, preview, then push.** `validate` runs offline;
|
|
295
|
+
`theme push --dry-run` reads the site and lists the JSON paths at which
|
|
296
|
+
each settings file differs from it, and whether the baseline `pull`
|
|
297
|
+
recorded is still current; `theme push` saves both themes, the appearance
|
|
298
|
+
scalars and the ten footer scheme colours in one transaction fenced by
|
|
299
|
+
that baseline. A concurrent change to any of those fields refuses the
|
|
300
|
+
whole push (`theme.concurrent_modification`) and nothing is written: keep
|
|
301
|
+
copies of the edited files, pull, re-apply, push again. A lost response is
|
|
302
|
+
safe to retry: the save is recorded as pending before it is sent, running
|
|
303
|
+
`theme push` again with the same files replays it under its original
|
|
304
|
+
baseline, and the site answers `applied: false` when it already holds the
|
|
305
|
+
change set. `pull` reads the four settings documents and the revision
|
|
306
|
+
from one snapshot, and `footer push` advances the recorded revision only
|
|
307
|
+
when its save replaced that same revision.
|
|
298
308
|
6. **Review on staging in both schemes.** `deploy --staging` returns a
|
|
299
309
|
single-use handoff in `stagingPreview.url`, on a managed Docs site too;
|
|
300
310
|
open it once and use the site's theme toggle. No page draft is needed;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
package/src/api.js
CHANGED
|
@@ -711,6 +711,63 @@ export async function saveSiteFooterSettings(client, siteId, footerSettings, exp
|
|
|
711
711
|
);
|
|
712
712
|
}
|
|
713
713
|
|
|
714
|
+
/**
|
|
715
|
+
* The shape of a presentation revision as the site reports it (TR00807): the
|
|
716
|
+
* server's SHA-256 over the presentation change set, lowercase hex. The CLI
|
|
717
|
+
* never derives one — it records what it was told and sends it back — so this
|
|
718
|
+
* is a containment check on a server string that reaches the manifest, not a
|
|
719
|
+
* claim about how the value is computed.
|
|
720
|
+
*/
|
|
721
|
+
export const PRESENTATION_REVISION = /^[0-9a-f]{64}$/u;
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Reads the site's draft presentation — the four theme-gated settings groups
|
|
725
|
+
* as `GetSettings` projects them — with the revision a save must carry
|
|
726
|
+
* (TR00807).
|
|
727
|
+
*/
|
|
728
|
+
export async function getSitePresentation(client, siteId) {
|
|
729
|
+
return requireSitePresentation(await client.request(sitePath(siteId, "presentation")), siteId);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Applies a presentation change set atomically (TR00807). `expectedRevision`
|
|
734
|
+
* is the revision the workspace was pulled at; `expectedFooterDraftHash` is
|
|
735
|
+
* the footer document's draft token from the read immediately before, because
|
|
736
|
+
* the server overlays the ten colours onto that document. Sent through the
|
|
737
|
+
* replayable-POST path: a save whose response was lost is answered as already
|
|
738
|
+
* current when it committed, and applied when it did not.
|
|
739
|
+
*/
|
|
740
|
+
export async function saveSitePresentation(client, siteId, body) {
|
|
741
|
+
const response = requireObject(
|
|
742
|
+
await client.replaceablePost(sitePath(siteId, "presentation"), { body: { siteId, ...body } }),
|
|
743
|
+
"api.presentation_contract",
|
|
744
|
+
"presentation save",
|
|
745
|
+
);
|
|
746
|
+
return {
|
|
747
|
+
applied: response.applied === true,
|
|
748
|
+
presentation: requireSitePresentation(response.presentation, siteId),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function requireSitePresentation(value, siteId) {
|
|
753
|
+
const presentation = requireObject(value, "api.presentation_contract", "site presentation");
|
|
754
|
+
if (!PRESENTATION_REVISION.test(presentation.revision ?? "")) {
|
|
755
|
+
throw new SiteAuthoringError(
|
|
756
|
+
"api.presentation_contract",
|
|
757
|
+
"Taproot returned a presentation with no revision, so a later push could not be fenced against it.",
|
|
758
|
+
{ field: "revision" },
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
if (typeof presentation.siteId === "string" && presentation.siteId !== "" && presentation.siteId !== siteId) {
|
|
762
|
+
throw new SiteAuthoringError(
|
|
763
|
+
"api.presentation_contract",
|
|
764
|
+
"Taproot returned a presentation for a different site.",
|
|
765
|
+
{ field: "siteId" },
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
return presentation;
|
|
769
|
+
}
|
|
770
|
+
|
|
714
771
|
export async function requestImageUpload(client, body) {
|
|
715
772
|
return requireObject(
|
|
716
773
|
await client.request("v1/images/request-upload", { method: "POST", body }),
|
package/src/cli.js
CHANGED
|
@@ -290,9 +290,16 @@ const VERBS = Object.freeze([
|
|
|
290
290
|
surface: SURFACE_DOCS_PRESENTATION,
|
|
291
291
|
capabilities: [CAPABILITY_DESIGN],
|
|
292
292
|
tokens: ["theme", "push"],
|
|
293
|
-
|
|
293
|
+
dryRun: true,
|
|
294
|
+
summary: "Validate and push the workspace's complete theme and appearance settings in one atomic save.",
|
|
294
295
|
note: "Run pull first. Theme JSON stays decoded in the workspace; this command validates the complete light/dark"
|
|
295
296
|
+ " pair and encodes it only at the API boundary. Image settings reference site-owned image IDs from media upload. "
|
|
297
|
+
+ "The complete change set — both themes, the appearance scalars, and the ten footer scheme colors — is saved in "
|
|
298
|
+
+ "one transaction fenced by the presentation revision pull recorded: a concurrent change to any of those "
|
|
299
|
+
+ "fields refuses the whole push (theme.concurrent_modification) and nothing is written. A save whose response "
|
|
300
|
+
+ "was lost is replayed safely; the site answers applied=false when it already holds the change set. "
|
|
301
|
+
+ "--dry-run reads the site, reports the JSON paths at which each settings file differs from it and whether "
|
|
302
|
+
+ "the recorded baseline is still current, and writes nothing. "
|
|
296
303
|
+ "pull writes each scheme's complete effective theme — the stored theme resolved over the same defaults every "
|
|
297
304
|
+ "consumer renders — so a fresh workspace validates as pulled. semanticMappings holds only authored pins, listed "
|
|
298
305
|
+ "in explicitMappingTokens; every other token compiles from roles at render time, so never copy default "
|
|
@@ -570,6 +577,9 @@ function verbHelp(verb) {
|
|
|
570
577
|
const jsonOption = verb.json
|
|
571
578
|
? "\n --json Emit the stable JSON contract (operational output is always JSON)."
|
|
572
579
|
: "";
|
|
580
|
+
const dryRunOption = verb.dryRun
|
|
581
|
+
? "\n --dry-run Read the site and report what a push would change, without writing."
|
|
582
|
+
: "";
|
|
573
583
|
const nameOption = verb.keyName
|
|
574
584
|
? `\n --name <text> Name recorded on the issued key (default "${DEFAULT_LOGIN_KEY_NAME}",\n`
|
|
575
585
|
+ ` 1-${LOGIN_KEY_NAME_MAXIMUM} characters). The approval screen shows it.`
|
|
@@ -637,7 +647,7 @@ function verbHelp(verb) {
|
|
|
637
647
|
${verb.summary}
|
|
638
648
|
${boundary}
|
|
639
649
|
|
|
640
|
-
${options}${targetOption}${deliveryTargetOption}${rawHtmlOption}${jsonOption}${nameOption}${note}
|
|
650
|
+
${options}${targetOption}${deliveryTargetOption}${rawHtmlOption}${jsonOption}${dryRunOption}${nameOption}${note}
|
|
641
651
|
`;
|
|
642
652
|
}
|
|
643
653
|
|
|
@@ -912,6 +922,7 @@ function parseArguments(arguments_) {
|
|
|
912
922
|
let browser;
|
|
913
923
|
let allowFailedPreview = false;
|
|
914
924
|
let allowRawHtml = false;
|
|
925
|
+
let dryRun = false;
|
|
915
926
|
let json = false;
|
|
916
927
|
let keyName;
|
|
917
928
|
const positionals = [];
|
|
@@ -977,6 +988,11 @@ function parseArguments(arguments_) {
|
|
|
977
988
|
allowRawHtml = true;
|
|
978
989
|
continue;
|
|
979
990
|
}
|
|
991
|
+
if (verb.dryRun && argument === "--dry-run") {
|
|
992
|
+
if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
|
|
993
|
+
dryRun = true;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
980
996
|
if (verb.json && argument === "--json") {
|
|
981
997
|
if (json) throw usageError("cli.duplicate_option", "--json may be supplied only once.");
|
|
982
998
|
json = true;
|
|
@@ -1107,6 +1123,7 @@ function parseArguments(arguments_) {
|
|
|
1107
1123
|
...(browser === undefined ? {} : { browser }),
|
|
1108
1124
|
init,
|
|
1109
1125
|
allowRawHtml,
|
|
1126
|
+
dryRun,
|
|
1110
1127
|
keyName,
|
|
1111
1128
|
positionals: verb.positionals
|
|
1112
1129
|
? {
|
|
@@ -1184,6 +1201,7 @@ export async function runCli({
|
|
|
1184
1201
|
init: parsed.init,
|
|
1185
1202
|
quiet: parsed.quiet,
|
|
1186
1203
|
allowRawHtml: parsed.allowRawHtml,
|
|
1204
|
+
dryRun: parsed.dryRun,
|
|
1187
1205
|
keyName: parsed.keyName,
|
|
1188
1206
|
capabilities: parsed.capabilities,
|
|
1189
1207
|
surface: parsed.surface,
|
package/src/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const CLI_NAME = "@taprootio/site-authoring";
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.9.0";
|
|
3
3
|
export const CLI_BINARY_NAME = "taproot-site";
|
|
4
4
|
export const RESULT_SCHEMA_VERSION = 1;
|
|
5
5
|
export const CONFIG_FILE_NAME = "taproot-site.json";
|
package/src/footer-draft-hash.js
CHANGED
|
@@ -66,7 +66,7 @@ function requireBoundedDepth(value) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** Deterministic JSON: sorted object keys, JSON.stringify value semantics. */
|
|
69
|
-
function stableJson(value) {
|
|
69
|
+
export function stableJson(value) {
|
|
70
70
|
if (value === null || typeof value !== "object") {
|
|
71
71
|
return JSON.stringify(value) ?? "null";
|
|
72
72
|
}
|
package/src/footer-workspace.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
2
2
|
import { appearanceImageIds } from "./appearance-contract.js";
|
|
3
3
|
import { footerImageIds } from "./footer-contract.js";
|
|
4
|
-
import {
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { computeFooterContentHash, computeFooterDraftHash, stableJson } from "./footer-draft-hash.js";
|
|
5
6
|
import { readManifest, readMediaManifest, writeManifest } from "./workspace.js";
|
|
6
7
|
|
|
7
8
|
export const FOOTER_SETTINGS_FILE = "settings/site-publishing-preferences.json";
|
|
@@ -73,7 +74,13 @@ export async function readAppearanceWorkspaceContext(workspaceDir, siteId) {
|
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
export async function advanceFooterManifest(
|
|
77
|
+
export async function advanceFooterManifest(
|
|
78
|
+
workspaceDir,
|
|
79
|
+
siteId,
|
|
80
|
+
footerSettings,
|
|
81
|
+
expectedDraftHash,
|
|
82
|
+
{ presentationRevision } = {},
|
|
83
|
+
) {
|
|
77
84
|
const manifest = await readManifest(workspaceDir, siteId);
|
|
78
85
|
const prior = requireFooterMetadata(manifest);
|
|
79
86
|
const hash = expectedDraftHash ?? computeFooterDraftHash(footerSettings);
|
|
@@ -90,10 +97,98 @@ export async function advanceFooterManifest(workspaceDir, siteId, footerSettings
|
|
|
90
97
|
expectedContentHash: computeFooterContentHash(footerSettings),
|
|
91
98
|
imageIds: [...new Set([...prior.imageIds, ...footerImageIds(footerSettings)])].sort(),
|
|
92
99
|
};
|
|
100
|
+
// The ten scheme colours are part of the presentation change set, so a
|
|
101
|
+
// footer save that moved one moved the revision too; the server reports the
|
|
102
|
+
// new one from the same transaction and it becomes the baseline the next
|
|
103
|
+
// theme push must carry (TR00807). One manifest write for both baselines.
|
|
104
|
+
if (presentationRevision !== undefined) {
|
|
105
|
+
manifest.presentation = presentationManifestEntry(presentationRevision);
|
|
106
|
+
}
|
|
93
107
|
await writeManifest(workspaceDir, manifest);
|
|
94
108
|
return hash;
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The manifest's record of the presentation revision this workspace was
|
|
113
|
+
* pulled at (TR00807): what `theme push` sends as its baseline, and what a
|
|
114
|
+
* dry run compares the site's current revision against.
|
|
115
|
+
*/
|
|
116
|
+
export function presentationManifestEntry(revision) {
|
|
117
|
+
if (!SHA256.test(revision ?? "")) {
|
|
118
|
+
throw new SiteAuthoringError(
|
|
119
|
+
"api.presentation_contract",
|
|
120
|
+
"Taproot returned a presentation revision that could not be recorded.",
|
|
121
|
+
{ field: "revision" },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return { revision };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The recorded presentation baseline, or `undefined` when the manifest holds
|
|
129
|
+
* none — a workspace pulled before the atomic save existed, or against a
|
|
130
|
+
* Taproot that does not serve it. Absent is a fact `theme push` refuses on,
|
|
131
|
+
* not a value to guess.
|
|
132
|
+
*/
|
|
133
|
+
export function readPresentationBaseline(manifest) {
|
|
134
|
+
const presentation = manifest.presentation;
|
|
135
|
+
if (presentation === null || typeof presentation !== "object" || Array.isArray(presentation)) return undefined;
|
|
136
|
+
return SHA256.test(presentation.revision ?? "") ? { revision: presentation.revision } : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The content identity of one presentation change set — what `theme push`
|
|
141
|
+
* sends — so a later run can tell whether a save recorded as pending was for
|
|
142
|
+
* exactly the change set it is about to send again.
|
|
143
|
+
*/
|
|
144
|
+
export function computePresentationChangeSetHash(changeSet) {
|
|
145
|
+
return createHash("sha256").update(`presentation-change-set-v1${stableJson(changeSet)}`, "utf8").digest("hex");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The record of a presentation save whose outcome this workspace does not yet
|
|
150
|
+
* know (TR00807): the baseline it was sent under and the change set it
|
|
151
|
+
* carried, written before the request and replaced by the manifest write
|
|
152
|
+
* that follows an authoritative answer. A save that committed while its
|
|
153
|
+
* response was lost leaves the site one revision ahead of the workspace; the
|
|
154
|
+
* next push of the same change set replays it under this baseline, and the
|
|
155
|
+
* site answers already-current instead of the stale refusal the workspace's
|
|
156
|
+
* own comparison would give. `undefined` when nothing is pending.
|
|
157
|
+
*/
|
|
158
|
+
export function readPendingPresentationSave(manifest) {
|
|
159
|
+
const pending = manifest.presentation?.pending;
|
|
160
|
+
if (pending === null || typeof pending !== "object" || Array.isArray(pending)) return undefined;
|
|
161
|
+
if (!SHA256.test(pending.expectedRevision ?? "") || !SHA256.test(pending.changeSetHash ?? "")) return undefined;
|
|
162
|
+
return {
|
|
163
|
+
expectedRevision: pending.expectedRevision,
|
|
164
|
+
changeSetHash: pending.changeSetHash,
|
|
165
|
+
startedAt: typeof pending.startedAt === "string" ? pending.startedAt : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function recordPendingPresentationSave(workspaceDir, siteId, { expectedRevision, changeSetHash, startedAt }) {
|
|
170
|
+
const manifest = await readManifest(workspaceDir, siteId);
|
|
171
|
+
const baseline = readPresentationBaseline(manifest);
|
|
172
|
+
if (baseline === undefined) {
|
|
173
|
+
throw new SiteAuthoringError(
|
|
174
|
+
"theme.pull_required",
|
|
175
|
+
"The pull manifest records no presentation baseline. Run 'taproot-site pull' again.",
|
|
176
|
+
{ field: "presentation.revision" },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
manifest.presentation = { revision: baseline.revision, pending: { expectedRevision, changeSetHash, startedAt } };
|
|
180
|
+
await writeManifest(workspaceDir, manifest);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Drops the pending record after an authoritative refusal; the baseline stays. */
|
|
184
|
+
export async function clearPendingPresentationSave(workspaceDir, siteId) {
|
|
185
|
+
const manifest = await readManifest(workspaceDir, siteId);
|
|
186
|
+
const baseline = readPresentationBaseline(manifest);
|
|
187
|
+
if (baseline === undefined || readPendingPresentationSave(manifest) === undefined) return;
|
|
188
|
+
manifest.presentation = presentationManifestEntry(baseline.revision);
|
|
189
|
+
await writeManifest(workspaceDir, manifest);
|
|
190
|
+
}
|
|
191
|
+
|
|
97
192
|
export function footerManifestEntry(footerSettings) {
|
|
98
193
|
return {
|
|
99
194
|
file: FOOTER_SETTINGS_FILE,
|
|
@@ -440,14 +440,34 @@ export function getAppearanceReference() {
|
|
|
440
440
|
+ "menus with many groups. The navigation face itself is not an appearance scalar: fontMenu and fontWeightMenu "
|
|
441
441
|
+ "are per-scheme theme fields set in settings.lightTheme/darkTheme the same way fontBrand is, and an empty "
|
|
442
442
|
+ "fontMenu falls back to the body font, never to headings.",
|
|
443
|
-
|
|
444
|
-
"
|
|
443
|
+
changeSet: [
|
|
444
|
+
"the ten footer scheme colors, overlaid onto the site's current footer document",
|
|
445
445
|
"default scheme, assets, favicon, and header scalars",
|
|
446
|
-
"complete light theme
|
|
446
|
+
"the complete light theme and the complete dark theme",
|
|
447
447
|
],
|
|
448
|
-
|
|
448
|
+
atomic: true,
|
|
449
|
+
revision:
|
|
450
|
+
"pull reads the four settings documents and the site's presentation revision — a server hash over exactly "
|
|
451
|
+
+ "the fields theme push writes — from one snapshot, so the recorded baseline is the revision of the "
|
|
452
|
+
+ "documents the workspace holds; theme push sends it back as the baseline. The whole change set commits in "
|
|
453
|
+
+ "one transaction or none of it does; a change to any of those fields since the pull refuses the push "
|
|
454
|
+
+ "(theme.concurrent_modification) and nothing is written. Footer prose, links and imagery, and the "
|
|
455
|
+
+ "publishing scalars are outside the revision, so an unrelated concurrent edit is preserved rather than "
|
|
456
|
+
+ "refused. footer push advances the baseline only when its save replaced that same revision.",
|
|
457
|
+
dryRun:
|
|
458
|
+
CLI_BINARY_NAME + " theme push --dry-run reads the site, lists the JSON paths at which each settings file "
|
|
459
|
+
+ "differs from it, and says whether the recorded baseline is still current or a pending save of the same "
|
|
460
|
+
+ "change set would be replayed; it writes nothing.",
|
|
461
|
+
retry:
|
|
462
|
+
"The save is recorded as pending in the manifest before it is sent. A save whose response was lost is "
|
|
463
|
+
+ "replayed by running theme push again with the same settings files: it goes under the baseline it was "
|
|
464
|
+
+ "first sent with, even after the site's revision moved, and the site answers applied=false when it already "
|
|
465
|
+
+ "holds the change set, so nothing is applied twice and no partial write is ever reported. Editing the "
|
|
466
|
+
+ "files before retrying makes the pending record moot and a moved revision refuses toward pull.",
|
|
449
467
|
recovery:
|
|
450
|
-
"
|
|
468
|
+
"On theme.concurrent_modification keep copies of the edited settings files, run " + CLI_BINARY_NAME
|
|
469
|
+
+ " pull to refresh the baseline, re-apply the edits, and push again. A Taproot without the atomic save "
|
|
470
|
+
+ "refuses with theme.server_unsupported; theme push never falls back to sequential writes.",
|
|
451
471
|
footerBoundary:
|
|
452
472
|
"theme push changes only five colors per footer scheme. Use footer push for prose, links, layout, and imagery.",
|
|
453
473
|
footerContentGuard:
|
|
@@ -604,9 +624,11 @@ export function getFooterReference() {
|
|
|
604
624
|
concurrency:
|
|
605
625
|
"pull records expectedDraftHash. A conflict returns footer.concurrent_modification; pull, reconcile, and retry.",
|
|
606
626
|
themeInteraction:
|
|
607
|
-
"theme push
|
|
608
|
-
+ "unpushed footer-content edits exist
|
|
609
|
-
+ "
|
|
627
|
+
"theme push overlays its ten scheme colors onto the site's current footer document inside its atomic save and "
|
|
628
|
+
+ "rewrites this file from the saved result. It refuses while unpushed footer-content edits exist "
|
|
629
|
+
+ "(theme.unpushed_footer_content); run " + CLI_BINARY_NAME + " footer push first, or " + CLI_BINARY_NAME
|
|
630
|
+
+ " pull to discard the local edit. A footer push that changed a scheme color advances the presentation "
|
|
631
|
+
+ "baseline theme push is fenced by.",
|
|
610
632
|
workflow: [
|
|
611
633
|
CLI_BINARY_NAME + " pull",
|
|
612
634
|
"edit settings/site-publishing-preferences.json at settings.footerSettings",
|
|
@@ -704,8 +726,11 @@ export function formatPresentationReference(reference) {
|
|
|
704
726
|
+ "\nHeader width: " + reference.headerWidthContract
|
|
705
727
|
+ "\nMobile menu and menu font: " + reference.menuContract
|
|
706
728
|
+ "\nFooter: " + reference.footerBoundary
|
|
707
|
-
+ "\nFooter content guard: " + reference.footerContentGuard + "\n\
|
|
708
|
-
+ reference.
|
|
729
|
+
+ "\nFooter content guard: " + reference.footerContentGuard + "\n\nAtomic change set (one transaction):\n"
|
|
730
|
+
+ reference.changeSet.map((step, index) => " " + (index + 1) + ". " + step).join("\n")
|
|
731
|
+
+ "\nRevision: " + reference.revision
|
|
732
|
+
+ "\nDry run: " + reference.dryRun
|
|
733
|
+
+ "\nRetry: " + reference.retry
|
|
709
734
|
+ "\nRecovery: " + reference.recovery + "\n";
|
|
710
735
|
}
|
|
711
736
|
if (reference.referenceKind === "footer") {
|
package/src/reference-help.js
CHANGED
|
@@ -481,9 +481,13 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
481
481
|
`Validate offline: ${CLI_BINARY_NAME} validate checks the complete pair, the appearance files and the footer against `
|
|
482
482
|
+ "the contracts the site enforces, and warns when a semanticMappings pin repeats the default on a token your "
|
|
483
483
|
+ "roles would have moved (the pin would keep the role from rendering).",
|
|
484
|
-
`Push: ${CLI_BINARY_NAME} theme push
|
|
485
|
-
+ "
|
|
486
|
-
+
|
|
484
|
+
`Push: ${CLI_BINARY_NAME} theme push --dry-run first: it reads the site and lists the JSON paths at which each `
|
|
485
|
+
+ "settings file differs from it, and says whether the baseline pull recorded is still current. Then "
|
|
486
|
+
+ `${CLI_BINARY_NAME} theme push saves both themes, the appearance scalars and the ten footer scheme colours in `
|
|
487
|
+
+ "one transaction fenced by that baseline: a concurrent change to any of those fields refuses the whole push "
|
|
488
|
+
+ "(theme.concurrent_modification) and nothing is written; keep copies of the edited files, pull, re-apply, "
|
|
489
|
+
+ "and push again. A lost response is safe to retry — the site answers applied=false when it already holds "
|
|
490
|
+
+ "the change set. footer push owns footer prose, links and imagery.",
|
|
487
491
|
`Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation and returns a single-use review handoff `
|
|
488
492
|
+ "in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's theme toggle to check "
|
|
489
493
|
+ "both schemes: text, headings, links and hover, actions and focus, the header logos, the favicon. When no "
|
package/src/transport.js
CHANGED
|
@@ -125,6 +125,12 @@ const UUID_PATH = "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0
|
|
|
125
125
|
* server answers `..._CONSUMED`, which `login` surfaces as an issued-but-
|
|
126
126
|
* unreceived credential the owner must revoke. Not replaying would leave that
|
|
127
127
|
* same orphan and report only a network error, which is strictly worse.
|
|
128
|
+
* - **Atomic presentation save** (TR00807) — the request carries the revision
|
|
129
|
+
* it was built against, and the server computes the revision the change set
|
|
130
|
+
* would produce. A replay of a save that committed finds the site already at
|
|
131
|
+
* that revision and answers `applied: false` with the current state; a
|
|
132
|
+
* replay of one that never committed applies it; anything else is refused as
|
|
133
|
+
* stale. No replay can apply the change set twice or report a partial one.
|
|
128
134
|
*/
|
|
129
135
|
const REPLACEABLE_POST_PATHS = Object.freeze([
|
|
130
136
|
new RegExp(
|
|
@@ -132,6 +138,7 @@ const REPLACEABLE_POST_PATHS = Object.freeze([
|
|
|
132
138
|
"u",
|
|
133
139
|
),
|
|
134
140
|
/^v1\/site-authoring\/cli-authorizations\/claim$/u,
|
|
141
|
+
new RegExp(`^v1/sites/${UUID_PATH}/presentation$`, "u"),
|
|
135
142
|
]);
|
|
136
143
|
|
|
137
144
|
/**
|
package/src/verbs/footer-push.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { saveSiteFooterSettings, withRefusalGuidance } from "../api.js";
|
|
1
|
+
import { PRESENTATION_REVISION, saveSiteFooterSettings, withRefusalGuidance } from "../api.js";
|
|
2
2
|
import { VERB_FOOTER_PUSH } from "../constants.js";
|
|
3
3
|
import { SiteAuthoringError } from "../errors.js";
|
|
4
4
|
import { projectFooterSettingsForWorkspace, validateFooterDocument } from "../footer-contract.js";
|
|
5
5
|
import { computeFooterDraftHash } from "../footer-draft-hash.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
advanceFooterManifest,
|
|
8
|
+
FOOTER_SETTINGS_FILE,
|
|
9
|
+
readFooterWorkspaceContext,
|
|
10
|
+
readPresentationBaseline,
|
|
11
|
+
} from "../footer-workspace.js";
|
|
7
12
|
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
8
13
|
import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
|
|
9
14
|
import { ApiError } from "../transport.js";
|
|
@@ -105,7 +110,27 @@ export async function footerPush(invocation) {
|
|
|
105
110
|
// fails, a stale token prevents the next push from overwriting the saved
|
|
106
111
|
// remote footer and the completed-write report tells the caller to pull.
|
|
107
112
|
await writeWorkspaceJson(config.workspaceDir, FOOTER_SETTINGS_FILE, nextDocument);
|
|
108
|
-
|
|
113
|
+
// A footer save that moved a scheme colour moved the presentation revision
|
|
114
|
+
// (TR00807); the server reports the new one from its own transaction, and
|
|
115
|
+
// the one it replaced. The baseline advances only when the replaced
|
|
116
|
+
// revision is the one this workspace was pulled at: otherwise the site's
|
|
117
|
+
// appearance moved since the pull, the workspace's appearance files do not
|
|
118
|
+
// reflect it, and a baseline taken from this save would let the next
|
|
119
|
+
// theme push overwrite those edits unseen. A Taproot that predates the
|
|
120
|
+
// fields leaves the recorded baseline alone.
|
|
121
|
+
const baseline = readPresentationBaseline(context.manifest);
|
|
122
|
+
const reported = PRESENTATION_REVISION.test(response.presentationRevision ?? "")
|
|
123
|
+
&& PRESENTATION_REVISION.test(response.previousPresentationRevision ?? "");
|
|
124
|
+
const advances = reported && baseline !== undefined && baseline.revision === response.previousPresentationRevision;
|
|
125
|
+
if (reported && baseline !== undefined && !advances) {
|
|
126
|
+
onProgress(
|
|
127
|
+
"The site's presentation moved after this workspace was pulled, so its presentation baseline was left as "
|
|
128
|
+
+ "it is; 'taproot-site theme push' will refuse until the workspace is pulled again.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
await advanceFooterManifest(config.workspaceDir, siteId, saved, footerDraftHash, {
|
|
132
|
+
presentationRevision: advances ? response.presentationRevision : undefined,
|
|
133
|
+
});
|
|
109
134
|
} catch (error) {
|
|
110
135
|
if (error instanceof SiteAuthoringError) throw error.withCompletedWrites(["footerSettings"]);
|
|
111
136
|
throw error;
|
package/src/verbs/pull.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
getNavigation,
|
|
3
3
|
getPage,
|
|
4
4
|
getSettingsGroup,
|
|
5
|
+
getSitePresentation,
|
|
5
6
|
getSiteRedirectMap,
|
|
6
7
|
listSitePages,
|
|
7
8
|
PAGE_STATUS_DELETED,
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
VERB_PULL,
|
|
18
19
|
} from "../constants.js";
|
|
19
20
|
import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
|
|
20
|
-
import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
|
|
21
|
+
import { appearanceManifestEntry, footerManifestEntry, presentationManifestEntry } from "../footer-workspace.js";
|
|
21
22
|
import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
|
|
22
23
|
import { projectRedirectMapForWorkspace, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
|
|
23
24
|
import { redirectsManifestEntry } from "../redirects-workspace.js";
|
|
@@ -626,28 +627,74 @@ export async function pull(invocation) {
|
|
|
626
627
|
// decoding is a wire-contract check and can fail on malformed stored data;
|
|
627
628
|
// discovering that after page bodies were written would strand an
|
|
628
629
|
// unbound workspace that the next pull must refuse for safety.
|
|
630
|
+
//
|
|
631
|
+
// The four groups and the presentation revision `theme push` is fenced by
|
|
632
|
+
// (TR00807) come from one read: the presentation snapshot carries the
|
|
633
|
+
// groups' documents exactly as the revision covers them, so the baseline
|
|
634
|
+
// the manifest records is the revision of the documents the workspace
|
|
635
|
+
// holds. Four per-group reads followed by a revision read would pair
|
|
636
|
+
// whatever the groups held with whatever the revision had become by then,
|
|
637
|
+
// and a push fenced by that baseline could overwrite an edit the workspace
|
|
638
|
+
// never saw. The per-group reads remain for a Taproot that predates the
|
|
639
|
+
// snapshot (404) and for a credential that cannot read it (403): both
|
|
640
|
+
// record no baseline, and `theme push` refuses toward a pull that does.
|
|
641
|
+
let presentation;
|
|
642
|
+
onProgress("Reading the presentation snapshot.");
|
|
643
|
+
try {
|
|
644
|
+
presentation = await getSitePresentation(client, siteId);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (
|
|
647
|
+
error instanceof ApiError
|
|
648
|
+
&& error.refusalKind() === REFUSAL_UNCLASSIFIED
|
|
649
|
+
&& error.httpStatus === 404
|
|
650
|
+
) {
|
|
651
|
+
onProgress(
|
|
652
|
+
"This Taproot does not serve the atomic presentation save yet; the settings groups are read one at a "
|
|
653
|
+
+ "time, the manifest records no presentation baseline, and 'theme push' will refuse until it is "
|
|
654
|
+
+ "pulled from a Taproot that does.",
|
|
655
|
+
);
|
|
656
|
+
} else if (
|
|
657
|
+
error instanceof ApiError
|
|
658
|
+
&& (error.refusalKind() === REFUSAL_UNCLASSIFIED
|
|
659
|
+
|| error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
|
|
660
|
+
&& error.httpStatus === 403
|
|
661
|
+
) {
|
|
662
|
+
onProgress(
|
|
663
|
+
"The presentation snapshot is not readable by this credential; the settings groups are read one at a "
|
|
664
|
+
+ "time and the manifest records no presentation baseline.",
|
|
665
|
+
);
|
|
666
|
+
} else {
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const presentationRevision = presentation?.revision;
|
|
671
|
+
|
|
629
672
|
const pulledSettings = [];
|
|
630
673
|
const skippedSettings = [];
|
|
631
674
|
const settingsDocuments = [];
|
|
632
675
|
let pulledFooter;
|
|
633
676
|
const pulledAppearance = {};
|
|
634
677
|
for (const group of SETTINGS_GROUPS) {
|
|
635
|
-
onProgress(`Reading settings group ${group.settingsType}.`);
|
|
636
678
|
let response;
|
|
637
|
-
|
|
638
|
-
response =
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
679
|
+
if (presentation !== undefined) {
|
|
680
|
+
response = presentation;
|
|
681
|
+
} else {
|
|
682
|
+
onProgress(`Reading settings group ${group.settingsType}.`);
|
|
683
|
+
try {
|
|
684
|
+
response = await getSettingsGroup(client, siteId, group.settingsType);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (
|
|
687
|
+
error instanceof ApiError
|
|
688
|
+
&& (error.refusalKind() === REFUSAL_UNCLASSIFIED
|
|
689
|
+
|| error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
|
|
690
|
+
&& UNAVAILABLE_SETTINGS_STATUSES.has(error.httpStatus)
|
|
691
|
+
) {
|
|
692
|
+
onProgress(`Settings group ${group.settingsType} is not readable by this credential; skipping it.`);
|
|
693
|
+
skippedSettings.push(group.settingsType);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
throw error;
|
|
649
697
|
}
|
|
650
|
-
throw error;
|
|
651
698
|
}
|
|
652
699
|
const file = `${SETTINGS_DIRECTORY}/${group.file}`;
|
|
653
700
|
const projected = projectSettingsGroup(group, response);
|
|
@@ -1048,6 +1095,7 @@ export async function pull(invocation) {
|
|
|
1048
1095
|
? { appearance: appearanceManifestEntry(pulledAppearance) }
|
|
1049
1096
|
: {}
|
|
1050
1097
|
),
|
|
1098
|
+
...(presentationRevision === undefined ? {} : { presentation: presentationManifestEntry(presentationRevision) }),
|
|
1051
1099
|
pages: manifestPages,
|
|
1052
1100
|
};
|
|
1053
1101
|
await writeManifest(config.workspaceDir, manifest);
|
package/src/verbs/theme-push.js
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getSitePresentation, saveSitePresentation, withRefusalGuidance } from "../api.js";
|
|
2
2
|
import {
|
|
3
|
+
APPEARANCE_FOOTER_COLOR_FIELDS,
|
|
3
4
|
APPEARANCE_SCALAR_FIELDS,
|
|
4
|
-
applyFooterColors,
|
|
5
5
|
buildAppearanceScalarOperations,
|
|
6
6
|
footerColorOverlay,
|
|
7
7
|
} from "../appearance-contract.js";
|
|
8
|
-
import { VERB_THEME_PUSH } from "../constants.js";
|
|
8
|
+
import { REFUSAL_UNCLASSIFIED, VERB_THEME_PUSH } from "../constants.js";
|
|
9
9
|
import { MISSING_SETTING_MESSAGE, SiteAuthoringError } from "../errors.js";
|
|
10
10
|
import { projectFooterSettingsForWorkspace } from "../footer-contract.js";
|
|
11
11
|
import { computeFooterDraftHash } from "../footer-draft-hash.js";
|
|
12
12
|
import {
|
|
13
13
|
advanceFooterManifest,
|
|
14
|
+
clearPendingPresentationSave,
|
|
15
|
+
computePresentationChangeSetHash,
|
|
14
16
|
readAppearanceWorkspaceContext,
|
|
17
|
+
readPendingPresentationSave,
|
|
18
|
+
readPresentationBaseline,
|
|
19
|
+
recordPendingPresentationSave,
|
|
15
20
|
requireFooterContentPushed,
|
|
16
21
|
} from "../footer-workspace.js";
|
|
22
|
+
import { describeJsonDifferences } from "../json-path-diff.js";
|
|
17
23
|
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
18
24
|
import {
|
|
25
|
+
projectSettingsGroup,
|
|
26
|
+
SETTINGS_GROUPS,
|
|
19
27
|
SETTINGS_TYPE_BRAND,
|
|
20
28
|
SETTINGS_TYPE_SITE_HEADER,
|
|
21
29
|
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
22
30
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
23
31
|
} from "../settings-catalog.js";
|
|
24
32
|
import { missingThemeFields, validateAndEncodeThemePair } from "../theme-validation.js";
|
|
33
|
+
import { ApiError } from "../transport.js";
|
|
25
34
|
import {
|
|
26
35
|
readWorkspaceJson,
|
|
27
36
|
SETTINGS_DIRECTORY,
|
|
@@ -36,6 +45,15 @@ const SETTINGS_FILES = Object.freeze({
|
|
|
36
45
|
[SETTINGS_TYPE_SITE_HEADER]: "site-header.json",
|
|
37
46
|
[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]: "site-publishing-preferences.json",
|
|
38
47
|
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The two theme documents travel as writes beside the scalars: the same
|
|
51
|
+
* `(group, setting, value)` triples `SetSetting` accepts, which is what the
|
|
52
|
+
* atomic save takes (TR00807). Order is the appearance registry's, themes
|
|
53
|
+
* last, so the audit row reads the way the old sequential order did.
|
|
54
|
+
*/
|
|
55
|
+
const THEME_SETTINGS = Object.freeze(["lightTheme", "darkTheme"]);
|
|
56
|
+
|
|
39
57
|
function isPlainObject(value) {
|
|
40
58
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
59
|
}
|
|
@@ -142,96 +160,354 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
|
|
|
142
160
|
return { style, brand, header, publishing, themes, scalarOperations, footerColors };
|
|
143
161
|
}
|
|
144
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The change set the atomic save receives: every appearance scalar in
|
|
165
|
+
* registry order, then the two encoded themes, plus the ten footer colours.
|
|
166
|
+
*/
|
|
167
|
+
function buildChangeSet(validated) {
|
|
168
|
+
return {
|
|
169
|
+
settings: [
|
|
170
|
+
...validated.scalarOperations.map((operation) => ({
|
|
171
|
+
settingsType: operation.settingsType,
|
|
172
|
+
setting: operation.setting,
|
|
173
|
+
value: operation.value,
|
|
174
|
+
})),
|
|
175
|
+
...THEME_SETTINGS.map((setting) => ({
|
|
176
|
+
settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
|
|
177
|
+
setting,
|
|
178
|
+
value: setting === "lightTheme" ? validated.themes.light : validated.themes.dark,
|
|
179
|
+
})),
|
|
180
|
+
],
|
|
181
|
+
footerColors: validated.footerColors,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A Taproot that answers the presentation routes with 404 predates the atomic
|
|
187
|
+
* save. The truthful answer is a refusal that says so: this verb no longer
|
|
188
|
+
* performs the sequential writes an older server would accept, because those
|
|
189
|
+
* are exactly the partial-failure shape the atomic save exists to remove.
|
|
190
|
+
*/
|
|
191
|
+
function translateServerSupport(error, action) {
|
|
192
|
+
if (
|
|
193
|
+
error instanceof ApiError
|
|
194
|
+
&& error.refusalKind() === REFUSAL_UNCLASSIFIED
|
|
195
|
+
&& error.httpStatus === 404
|
|
196
|
+
) {
|
|
197
|
+
return new SiteAuthoringError(
|
|
198
|
+
"theme.server_unsupported",
|
|
199
|
+
`This Taproot does not serve the atomic presentation ${action}. 'theme push' saves the complete change set in `
|
|
200
|
+
+ "one transaction and does not fall back to the older sequential writes; use a Taproot that serves "
|
|
201
|
+
+ "/v1/sites/{siteId}/presentation, or an older CLI release against this one.",
|
|
202
|
+
{ field: "presentation", status: error.status },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return error;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function translateSaveRefusal(error) {
|
|
209
|
+
if (error instanceof ApiError && error.hasField("ExpectedRevision")) {
|
|
210
|
+
return new SiteAuthoringError(
|
|
211
|
+
"theme.concurrent_modification",
|
|
212
|
+
"The site's presentation changed after this workspace was pulled, so the push was refused and nothing was "
|
|
213
|
+
+ "written. Keep copies of the edited settings files, run 'taproot-site pull' to refresh the baseline, "
|
|
214
|
+
+ "re-apply the edits, and push again; 'theme push --dry-run' shows what differs.",
|
|
215
|
+
{ field: "revision", status: error.status },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (error instanceof ApiError && error.hasField("ExpectedFooterDraftHash")) {
|
|
219
|
+
return new SiteAuthoringError(
|
|
220
|
+
"theme.footer_concurrent_modification",
|
|
221
|
+
"The footer document changed between this push's read and its save, so the push was refused and nothing was "
|
|
222
|
+
+ "written. Run 'taproot-site theme push' again; it re-reads the footer before saving.",
|
|
223
|
+
{ field: "expectedFooterDraftHash", status: error.status },
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return translateServerSupport(error, "save");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The workspace's copy of exactly the fields the save owns, per settings
|
|
231
|
+
* file, and the site's copy projected the way `pull` writes it — so the paths
|
|
232
|
+
* reported are paths in the files an author edits.
|
|
233
|
+
*/
|
|
234
|
+
function ownedDocuments(validated, presentation) {
|
|
235
|
+
const projected = {};
|
|
236
|
+
for (const group of SETTINGS_GROUPS) {
|
|
237
|
+
projected[group.settingsType] = projectSettingsGroup(group, presentation);
|
|
238
|
+
}
|
|
239
|
+
const owned = (settingsType, local, remote, names) => ({
|
|
240
|
+
file: `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[settingsType]}`,
|
|
241
|
+
local: Object.fromEntries(names.map((name) => [name, local[name]])),
|
|
242
|
+
remote: Object.fromEntries(names.map((name) => [name, remote[name]])),
|
|
243
|
+
});
|
|
244
|
+
const scalarNames = (settingsType) =>
|
|
245
|
+
APPEARANCE_SCALAR_FIELDS.filter((field) => field.settingsType === settingsType).map((field) => field.name);
|
|
246
|
+
const footerColors = (footer) => {
|
|
247
|
+
const colors = {};
|
|
248
|
+
for (const definition of APPEARANCE_FOOTER_COLOR_FIELDS) {
|
|
249
|
+
const scheme = isPlainObject(footer?.[definition.scheme]) ? footer[definition.scheme] : {};
|
|
250
|
+
colors[definition.scheme] ??= {};
|
|
251
|
+
colors[definition.scheme][definition.name] = scheme[definition.name] ?? "";
|
|
252
|
+
}
|
|
253
|
+
return { footerSettings: colors };
|
|
254
|
+
};
|
|
255
|
+
return [
|
|
256
|
+
owned(
|
|
257
|
+
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
258
|
+
validated.style,
|
|
259
|
+
projected[SETTINGS_TYPE_TAPROOT_STYLES],
|
|
260
|
+
[...THEME_SETTINGS, ...scalarNames(SETTINGS_TYPE_TAPROOT_STYLES)],
|
|
261
|
+
),
|
|
262
|
+
owned(SETTINGS_TYPE_BRAND, validated.brand, projected[SETTINGS_TYPE_BRAND], scalarNames(SETTINGS_TYPE_BRAND)),
|
|
263
|
+
owned(
|
|
264
|
+
SETTINGS_TYPE_SITE_HEADER,
|
|
265
|
+
validated.header,
|
|
266
|
+
projected[SETTINGS_TYPE_SITE_HEADER],
|
|
267
|
+
scalarNames(SETTINGS_TYPE_SITE_HEADER),
|
|
268
|
+
),
|
|
269
|
+
{
|
|
270
|
+
file: `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]}`,
|
|
271
|
+
local: footerColors(validated.publishing.footerSettings),
|
|
272
|
+
remote: footerColors(projected[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES].footerSettings),
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The JSON paths at which each settings file differs from the site, remote first. */
|
|
278
|
+
function describeDifferences(validated, presentation) {
|
|
279
|
+
return ownedDocuments(validated, presentation).map((document_) => {
|
|
280
|
+
const differences = describeJsonDifferences(document_.remote, document_.local);
|
|
281
|
+
return {
|
|
282
|
+
file: document_.file,
|
|
283
|
+
paths: differences.paths,
|
|
284
|
+
...(differences.truncated ? { truncated: true } : {}),
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
145
289
|
export async function themePush(invocation) {
|
|
146
290
|
const session = await openSession(invocation);
|
|
147
291
|
const { client, config, siteId, onProgress } = session;
|
|
292
|
+
const dryRun = invocation.dryRun === true;
|
|
148
293
|
// One advisory line before this verb does any work, and only when the
|
|
149
294
|
// exchange said the platform is paused. It changes nothing else: the write
|
|
150
295
|
// still runs and its refusal still classifies as platform_paused (TR00692).
|
|
151
|
-
warnIfExternalWritesPaused(session, VERB_THEME_PUSH);
|
|
296
|
+
if (!dryRun) warnIfExternalWritesPaused(session, VERB_THEME_PUSH);
|
|
152
297
|
const appearanceContext = await readAppearanceWorkspaceContext(config.workspaceDir, siteId);
|
|
153
|
-
const
|
|
298
|
+
const validated = await validateThemeWorkspace(
|
|
154
299
|
config.workspaceDir,
|
|
155
300
|
siteId,
|
|
156
301
|
appearanceContext.knownImageIds,
|
|
157
302
|
);
|
|
158
|
-
|
|
303
|
+
const { publishing, themes, scalarOperations } = validated;
|
|
304
|
+
// The save's response replaces the workspace footer document, so refuse
|
|
159
305
|
// while it still carries footer-content edits that footer push has not
|
|
160
306
|
// saved. Colour-only differences are theme push's own to write.
|
|
161
307
|
requireFooterContentPushed(appearanceContext.footer, publishing.footerSettings);
|
|
308
|
+
const baseline = readPresentationBaseline(appearanceContext.manifest);
|
|
309
|
+
if (!dryRun && baseline === undefined) {
|
|
310
|
+
throw new SiteAuthoringError(
|
|
311
|
+
"theme.pull_required",
|
|
312
|
+
"The pull manifest records no presentation baseline, so this push has no revision to be fenced by. Run "
|
|
313
|
+
+ "'taproot-site pull' against a Taproot that serves the atomic presentation save, then retry 'theme push'.",
|
|
314
|
+
{ field: "presentation.revision" },
|
|
315
|
+
);
|
|
316
|
+
}
|
|
162
317
|
onProgress(`Validated the complete light/dark theme pair and ${scalarOperations.length} appearance settings.`);
|
|
163
318
|
for (const warning of themes.warnings) onProgress(`Espalier warning: ${warning}`);
|
|
319
|
+
const warnings = {
|
|
320
|
+
items: themes.warnings,
|
|
321
|
+
count: themes.warningCount,
|
|
322
|
+
...(themes.warningsTruncated ? { truncated: true } : {}),
|
|
323
|
+
};
|
|
164
324
|
|
|
165
|
-
|
|
325
|
+
// The fresh read serves both modes: the dry run compares against it, and
|
|
326
|
+
// the push takes the footer document's draft token from it. Nothing before
|
|
327
|
+
// this point, and nothing in a dry run after it, writes to the site.
|
|
328
|
+
let current;
|
|
166
329
|
try {
|
|
167
|
-
await withRefusalGuidance(onProgress, "theme push", async () =>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
330
|
+
current = await withRefusalGuidance(onProgress, "theme push", async () => await getSitePresentation(client, siteId));
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw translateServerSupport(error, "read");
|
|
333
|
+
}
|
|
334
|
+
const stale = baseline !== undefined && baseline.revision !== current.revision;
|
|
335
|
+
// A save this workspace sent and never heard back from (TR00807). When the
|
|
336
|
+
// change set about to go is the same one, a moved revision is not evidence
|
|
337
|
+
// of a concurrent edit: it is what that save leaves behind when it
|
|
338
|
+
// committed. Replaying it under the baseline it was sent with lets the site
|
|
339
|
+
// answer already-current, or apply it if it never committed; anything else
|
|
340
|
+
// it refuses itself. A pending record for a different change set proves
|
|
341
|
+
// nothing about the site and takes the ordinary path.
|
|
342
|
+
const changeSet = buildChangeSet(validated);
|
|
343
|
+
const changeSetHash = computePresentationChangeSetHash(changeSet);
|
|
344
|
+
const pending = readPendingPresentationSave(appearanceContext.manifest);
|
|
345
|
+
const replayable = pending !== undefined && pending.changeSetHash === changeSetHash;
|
|
346
|
+
const replaying = stale && replayable;
|
|
347
|
+
|
|
348
|
+
if (dryRun) {
|
|
349
|
+
const differences = describeDifferences(validated, current);
|
|
350
|
+
const changed = differences.some((entry) => entry.paths.length > 0 || entry.truncated === true);
|
|
351
|
+
for (const entry of differences) {
|
|
352
|
+
if (entry.paths.length === 0 && entry.truncated !== true) continue;
|
|
353
|
+
onProgress(`${entry.file} differs at: ${entry.paths.join(", ")}${entry.truncated ? ", and more" : ""}.`);
|
|
354
|
+
}
|
|
355
|
+
if (!changed) onProgress("The workspace's presentation matches the site; a push would change nothing.");
|
|
356
|
+
if (baseline === undefined) {
|
|
357
|
+
onProgress("The pull manifest records no presentation baseline; a push would refuse until the workspace is re-pulled.");
|
|
358
|
+
} else if (replaying) {
|
|
359
|
+
onProgress(
|
|
360
|
+
`The site's presentation revision moved since this workspace was pulled (${baseline.revision} → `
|
|
361
|
+
+ `${current.revision}), and the manifest records a save of this same change set whose response was `
|
|
362
|
+
+ "lost; a push would replay it under its original baseline rather than refuse.",
|
|
175
363
|
);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
siteId,
|
|
181
|
-
nextFooter,
|
|
182
|
-
computeFooterDraftHash(currentFooter),
|
|
364
|
+
} else if (stale) {
|
|
365
|
+
onProgress(
|
|
366
|
+
`The site's presentation revision moved since this workspace was pulled (${baseline.revision} → `
|
|
367
|
+
+ `${current.revision}); a push would be refused. Keep copies of the edited files, pull, and re-apply.`,
|
|
183
368
|
);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
{
|
|
198
|
-
entityId: siteId,
|
|
199
|
-
settingsType: SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
200
|
-
settings: { ...publishing, footerSettings: projectedFooter },
|
|
369
|
+
}
|
|
370
|
+
onProgress("Dry run: nothing was written.");
|
|
371
|
+
return successResult(VERB_THEME_PUSH, siteId, {
|
|
372
|
+
dryRun: true,
|
|
373
|
+
revision: {
|
|
374
|
+
...(baseline === undefined ? {} : { baseline: baseline.revision }),
|
|
375
|
+
current: current.revision,
|
|
376
|
+
stale: baseline === undefined ? undefined : stale,
|
|
377
|
+
},
|
|
378
|
+
...(pending === undefined ? {} : {
|
|
379
|
+
pendingSave: {
|
|
380
|
+
...(pending.startedAt === undefined ? {} : { startedAt: pending.startedAt }),
|
|
381
|
+
sameChangeSet: replayable,
|
|
201
382
|
},
|
|
383
|
+
}),
|
|
384
|
+
changed,
|
|
385
|
+
differences,
|
|
386
|
+
warnings,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (stale && !replaying) {
|
|
391
|
+
// Refused here, before any request that could write, with the same
|
|
392
|
+
// guidance the server's fence would give — and with the paths that
|
|
393
|
+
// differ, which the server cannot know.
|
|
394
|
+
if (pending !== undefined) {
|
|
395
|
+
onProgress(
|
|
396
|
+
"The manifest records an earlier save of a different change set whose response was lost; the site may hold "
|
|
397
|
+
+ "it. The pull that refreshes the baseline shows what the site holds now.",
|
|
202
398
|
);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
399
|
+
}
|
|
400
|
+
const differences = describeDifferences(validated, current);
|
|
401
|
+
throw new SiteAuthoringError(
|
|
402
|
+
"theme.concurrent_modification",
|
|
403
|
+
`The site's presentation changed after this workspace was pulled (revision ${baseline.revision} → `
|
|
404
|
+
+ `${current.revision}), so nothing was written. Keep copies of the edited settings files, run `
|
|
405
|
+
+ "'taproot-site pull' to refresh the baseline, re-apply the edits, and push again; the paths below name "
|
|
406
|
+
+ "where the workspace and the site now differ.",
|
|
407
|
+
{ field: "revision", alternatives: differences.filter((entry) => entry.paths.length > 0).map((entry) => entry.file) },
|
|
408
|
+
).withDifferences(differences.flatMap((entry) => entry.paths.map((path) => `${entry.file}:${path}`)));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const expectedRevision = replaying ? pending.expectedRevision : baseline.revision;
|
|
412
|
+
if (replaying) {
|
|
413
|
+
onProgress(
|
|
414
|
+
`Replaying the presentation save recorded${pending.startedAt === undefined ? "" : ` at ${pending.startedAt}`} `
|
|
415
|
+
+ "whose response was lost, under the baseline it was sent with: a save that committed is answered as "
|
|
416
|
+
+ "already current and nothing is applied twice; one that did not commit is applied.",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
// Recorded before the request so a lost response leaves a record the next
|
|
420
|
+
// run can replay; the manifest write that follows an answer replaces it.
|
|
421
|
+
if (pending?.expectedRevision !== expectedRevision || pending.changeSetHash !== changeSetHash) {
|
|
422
|
+
await recordPendingPresentationSave(config.workspaceDir, siteId, {
|
|
423
|
+
expectedRevision,
|
|
424
|
+
changeSetHash,
|
|
425
|
+
startedAt: new Date(session.now()).toISOString(),
|
|
221
426
|
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The footer token comes from the read just made: the server overlays the
|
|
430
|
+
// colours onto the document it holds now. On a replay of a committed save
|
|
431
|
+
// the site is already current and the token is not consulted.
|
|
432
|
+
const currentFooter = current.sitePublishingPreferences?.footerSettings ?? {};
|
|
433
|
+
let saved;
|
|
434
|
+
try {
|
|
435
|
+
saved = await withRefusalGuidance(onProgress, "theme push", async () =>
|
|
436
|
+
await saveSitePresentation(client, siteId, {
|
|
437
|
+
expectedRevision,
|
|
438
|
+
expectedFooterDraftHash: computeFooterDraftHash(currentFooter),
|
|
439
|
+
...changeSet,
|
|
440
|
+
}));
|
|
222
441
|
} catch (error) {
|
|
223
|
-
if (error instanceof SiteAuthoringError &&
|
|
224
|
-
|
|
442
|
+
if (error instanceof SiteAuthoringError && error.code === "transport.mutation_ambiguous") {
|
|
443
|
+
onProgress(
|
|
444
|
+
"The save ended without an authoritative response and is recorded as pending. Run 'taproot-site theme "
|
|
445
|
+
+ "push' again with the same settings files: a save that committed is answered as already current and "
|
|
446
|
+
+ "nothing is applied twice; one that did not is applied.",
|
|
447
|
+
);
|
|
448
|
+
throw translateSaveRefusal(error);
|
|
225
449
|
}
|
|
226
|
-
|
|
450
|
+
// Only a fence refusal proves nothing committed; it drops the pending
|
|
451
|
+
// record so a later run compares against the site rather than replaying
|
|
452
|
+
// under a baseline the site already judged. Any other error keeps it: the
|
|
453
|
+
// server commits the presentation before it derives favicon renditions,
|
|
454
|
+
// so an error answered after that commit has moved the revision, and the
|
|
455
|
+
// record is what lets the next run of the same change set be answered as
|
|
456
|
+
// already current instead of refused as someone else's edit.
|
|
457
|
+
if (error instanceof ApiError && (error.hasField("ExpectedRevision") || error.hasField("ExpectedFooterDraftHash"))) {
|
|
458
|
+
await clearPendingPresentationSave(config.workspaceDir, siteId);
|
|
459
|
+
} else {
|
|
460
|
+
onProgress(
|
|
461
|
+
"The save was answered with an error after it may have committed; it stays recorded as pending, and running "
|
|
462
|
+
+ "'taproot-site theme push' again with the same settings files settles it either way.",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
throw translateSaveRefusal(error);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const { presentation, applied } = saved;
|
|
469
|
+
const savedFooter = presentation.sitePublishingPreferences?.footerSettings;
|
|
470
|
+
if (!isPlainObject(savedFooter)) {
|
|
471
|
+
throw new SiteAuthoringError(
|
|
472
|
+
"api.footer_settings_contract",
|
|
473
|
+
"Taproot returned no footer document with the saved presentation.",
|
|
474
|
+
{ field: "footerSettings" },
|
|
475
|
+
);
|
|
227
476
|
}
|
|
477
|
+
// The publishing document is rewritten from the site's copy — footer prose,
|
|
478
|
+
// links and imagery always come from there — and both baselines advance
|
|
479
|
+
// from the same response. Committed remotely before either local write, so
|
|
480
|
+
// a failure here leaves a stale baseline that refuses the next push rather
|
|
481
|
+
// than one that overwrites the save.
|
|
482
|
+
const projectedFooter = projectFooterSettingsForWorkspace(savedFooter);
|
|
483
|
+
await writeWorkspaceJson(
|
|
484
|
+
config.workspaceDir,
|
|
485
|
+
`${SETTINGS_DIRECTORY}/${SETTINGS_FILES[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]}`,
|
|
486
|
+
{
|
|
487
|
+
entityId: siteId,
|
|
488
|
+
settingsType: SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
489
|
+
settings: { ...publishing, footerSettings: projectedFooter },
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
await advanceFooterManifest(config.workspaceDir, siteId, projectedFooter, undefined, {
|
|
493
|
+
presentationRevision: presentation.revision,
|
|
494
|
+
});
|
|
495
|
+
onProgress(
|
|
496
|
+
applied
|
|
497
|
+
? "Saved the complete presentation in one transaction: footer scheme colors, appearance settings, and both "
|
|
498
|
+
+ "themes; Brand & Style now treats the color model as externally managed."
|
|
499
|
+
: "The site already held this presentation; nothing was written and the baseline was refreshed.",
|
|
500
|
+
);
|
|
228
501
|
|
|
502
|
+
const written = [
|
|
503
|
+
"footerSettings.light/dark colors",
|
|
504
|
+
...scalarOperations.map((operation) => `${operation.settingsType}.${operation.setting}`),
|
|
505
|
+
...THEME_SETTINGS.map((setting) => `${SETTINGS_TYPE_TAPROOT_STYLES}.${setting}`),
|
|
506
|
+
];
|
|
229
507
|
return successResult(VERB_THEME_PUSH, siteId, {
|
|
508
|
+
applied,
|
|
509
|
+
revision: presentation.revision,
|
|
230
510
|
written: boundedList(written, 100),
|
|
231
|
-
warnings
|
|
232
|
-
items: themes.warnings,
|
|
233
|
-
count: themes.warningCount,
|
|
234
|
-
...(themes.warningsTruncated ? { truncated: true } : {}),
|
|
235
|
-
},
|
|
511
|
+
warnings,
|
|
236
512
|
});
|
|
237
513
|
}
|