@taprootio/site-authoring 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -29
- package/examples/riverbend-wellness/nav.json +3 -6
- package/examples/riverbend-wellness/pages/visit.md +1 -1
- package/package.json +2 -2
- package/src/api.js +118 -31
- package/src/changelog.js +48 -0
- package/src/changelog.json +83 -0
- package/src/cli-release.js +9 -1
- package/src/cli.js +129 -14
- package/src/constants.js +19 -0
- package/src/errors.js +3 -0
- package/src/footer-contract.js +5 -3
- package/src/forms-workspace.js +22 -0
- package/src/heading-outline.js +76 -0
- package/src/index.js +1 -0
- package/src/output.js +9 -0
- package/src/page-metadata.js +2 -1
- package/src/page-structure-checks.js +356 -0
- package/src/presentation-reference.js +41 -6
- package/src/problems.js +6 -1
- package/src/redirects-contract.js +50 -140
- package/src/reference-help.js +77 -33
- package/src/reserved-paths.js +4 -10
- package/src/setting-reference.js +438 -0
- package/src/settings-catalog.js +2 -3
- package/src/site-check-findings.js +188 -0
- package/src/site-check-rules.json +35 -0
- package/src/site-checks.js +353 -0
- package/src/staging-check.js +48 -9
- package/src/staging-served.js +320 -0
- package/src/theme-projection.js +28 -55
- package/src/theme-validation.js +2 -1
- package/src/transport.js +13 -0
- package/src/typed-page-reference.js +128 -4
- package/src/verbs/changelog.js +62 -0
- package/src/verbs/deploy.js +38 -10
- package/src/verbs/index.js +3 -0
- package/src/verbs/nav-push.js +49 -1
- package/src/verbs/pages-push.js +17 -5
- package/src/verbs/plan.js +2 -1
- package/src/verbs/pull.js +5 -1
- package/src/verbs/redirects-check.js +4 -1
- package/src/verbs/staging-review.js +1 -1
- package/src/verbs/status.js +35 -28
- package/src/verbs/validate.js +24 -0
- package/src/workspace-check.js +26 -1
- package/src/workspace.js +5 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Generated from shared/page-structure-checks.ts by scripts/sync-field-validation.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* The page-structure site checks (TR00947): the SEO and accessibility oddities
|
|
4
|
+
* a creator can fix in the editor or the CLI before publishing.
|
|
5
|
+
*
|
|
6
|
+
* `shared/site-check-rules.json` is the catalogue; these are the `document`
|
|
7
|
+
* scope rules in it. They read one document and the page's own metadata, never
|
|
8
|
+
* the site's records, so the editor evaluates the open document in the browser
|
|
9
|
+
* and the CLI evaluates a workspace file offline, from copies of this one
|
|
10
|
+
* source. The API carries no second implementation: the rules are advice, and
|
|
11
|
+
* nothing on the server needs them to decide a save or a deploy.
|
|
12
|
+
*
|
|
13
|
+
* The heading rules replay the generator's own outline machine
|
|
14
|
+
* (`heading-outline.ts`) over the document, so what they say matches what is
|
|
15
|
+
* published. `generator/src/page-structure-parity.test.ts` renders every
|
|
16
|
+
* heading case in `site-check-corpus.json` and compares it with what these
|
|
17
|
+
* rules predicted.
|
|
18
|
+
*
|
|
19
|
+
* It is dependency-free so the editor and the published site-authoring package
|
|
20
|
+
* can carry copies. `node scripts/sync-field-validation.mjs` writes them; edit
|
|
21
|
+
* only this file and re-run it.
|
|
22
|
+
*/
|
|
23
|
+
import { bannerOpensPage, componentFixedHeadingLevel, createHeadingOutlineState, nextHeadingLevel, recordFixedHeading, } from "./heading-outline.js";
|
|
24
|
+
export const RULE_H1_MISSING = "seo.h1_missing";
|
|
25
|
+
export const RULE_H1_DEMOTED = "seo.h1_demoted";
|
|
26
|
+
export const RULE_H1_MULTIPLE = "seo.h1_multiple";
|
|
27
|
+
export const RULE_HEADING_RENUMBERED = "seo.heading_renumbered";
|
|
28
|
+
export const RULE_HEADING_BEFORE_TITLE = "seo.heading_before_title";
|
|
29
|
+
export const RULE_IMAGE_ALT_MISSING = "a11y.image_alt_missing";
|
|
30
|
+
export const RULE_LINK_TEXT_EMPTY = "a11y.link_text_empty";
|
|
31
|
+
export const RULE_TITLE_LONG = "seo.title_long";
|
|
32
|
+
export const RULE_DESCRIPTION_MISSING = "seo.description_missing";
|
|
33
|
+
export const RULE_DESCRIPTION_LONG = "seo.description_long";
|
|
34
|
+
export const RULE_SOCIAL_IMAGE_FALLBACK = "seo.social_image_fallback";
|
|
35
|
+
const RULE_ORDER = [
|
|
36
|
+
RULE_H1_MISSING,
|
|
37
|
+
RULE_H1_DEMOTED,
|
|
38
|
+
RULE_H1_MULTIPLE,
|
|
39
|
+
RULE_HEADING_RENUMBERED,
|
|
40
|
+
RULE_HEADING_BEFORE_TITLE,
|
|
41
|
+
RULE_IMAGE_ALT_MISSING,
|
|
42
|
+
RULE_LINK_TEXT_EMPTY,
|
|
43
|
+
RULE_TITLE_LONG,
|
|
44
|
+
RULE_DESCRIPTION_MISSING,
|
|
45
|
+
RULE_DESCRIPTION_LONG,
|
|
46
|
+
RULE_SOCIAL_IMAGE_FALLBACK,
|
|
47
|
+
];
|
|
48
|
+
/** Search results cut a title off after about this many characters. */
|
|
49
|
+
export const TITLE_WARN_LENGTH = 60;
|
|
50
|
+
/** Search results cut a description off after about this many characters. */
|
|
51
|
+
export const DESCRIPTION_WARN_LENGTH = 160;
|
|
52
|
+
const MAXIMUM_DEPTH = 256;
|
|
53
|
+
const MAXIMUM_SUBJECT_LENGTH = 80;
|
|
54
|
+
const characters = (value) => [...value].length;
|
|
55
|
+
function isNode(value) {
|
|
56
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
function children(node) {
|
|
59
|
+
return Array.isArray(node.content) ? node.content.filter(isNode) : [];
|
|
60
|
+
}
|
|
61
|
+
function clip(value) {
|
|
62
|
+
const text = value.replace(/\s+/gu, " ").trim();
|
|
63
|
+
const letters = [...text];
|
|
64
|
+
return letters.length > MAXIMUM_SUBJECT_LENGTH ? `${letters.slice(0, MAXIMUM_SUBJECT_LENGTH - 1).join("")}…` : text;
|
|
65
|
+
}
|
|
66
|
+
function textOf(node) {
|
|
67
|
+
if (node.type === "text")
|
|
68
|
+
return typeof node.text === "string" ? node.text : "";
|
|
69
|
+
return children(node).map(textOf).join(node.type === "hardBreak" ? " " : "");
|
|
70
|
+
}
|
|
71
|
+
function componentData(node) {
|
|
72
|
+
const raw = node.attrs?.componentData;
|
|
73
|
+
if (typeof raw === "string") {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(raw);
|
|
76
|
+
return isNode(parsed) ? parsed : {};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// The publisher reads only a JSON string here; anything else renders as an empty component.
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
function nonBlank(value) {
|
|
86
|
+
return typeof value === "string" && value.trim() !== "";
|
|
87
|
+
}
|
|
88
|
+
function imageSource(value) {
|
|
89
|
+
return isNode(value) && nonBlank(value.src);
|
|
90
|
+
}
|
|
91
|
+
function clampLevel(value) {
|
|
92
|
+
const level = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 1;
|
|
93
|
+
return Math.min(4, Math.max(1, level));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The headings, images and links of a free-form body, as the publisher meets them.
|
|
97
|
+
* Every component block counts as content before a banner, though the publisher drops
|
|
98
|
+
* one that renders only a comment (a Latest posts block with no posts, an unknown type), and
|
|
99
|
+
* an integration placement renders nothing or a status paragraph depending on the site;
|
|
100
|
+
* whether it does can depend on the site, so a banner after such a block is the one case
|
|
101
|
+
* where this walk and the publisher can differ.
|
|
102
|
+
*/
|
|
103
|
+
function walkBody(body) {
|
|
104
|
+
const findings = [];
|
|
105
|
+
// Findings about headings wait for the whole walk: whether the page has an h1 is known only at the end.
|
|
106
|
+
const headings = [];
|
|
107
|
+
const extraTitles = [];
|
|
108
|
+
const outline = createHeadingOutlineState({ titleOpen: true });
|
|
109
|
+
let claimant;
|
|
110
|
+
let imageCount = 0;
|
|
111
|
+
let hasContent = false;
|
|
112
|
+
// Adopted integration markup is opaque here; it may hold the page's own h1.
|
|
113
|
+
let opaqueH1 = false;
|
|
114
|
+
const seeContent = () => {
|
|
115
|
+
hasContent = true;
|
|
116
|
+
outline.titleOpen = false;
|
|
117
|
+
};
|
|
118
|
+
const visitLinks = (node, path) => {
|
|
119
|
+
// A link split by formatting is several adjacent text nodes; the link is empty only when all of them are.
|
|
120
|
+
let run;
|
|
121
|
+
const close = () => {
|
|
122
|
+
if (run && run.text.trim() === "") {
|
|
123
|
+
findings.push({ ruleId: RULE_LINK_TEXT_EMPTY, severity: "warning", subject: clip(run.href), detail: "", path });
|
|
124
|
+
}
|
|
125
|
+
run = undefined;
|
|
126
|
+
};
|
|
127
|
+
for (const child of children(node)) {
|
|
128
|
+
const href = child.type === "text" && Array.isArray(child.marks)
|
|
129
|
+
? child.marks.find((mark) => isNode(mark) && mark.type === "link")?.attrs?.href
|
|
130
|
+
: undefined;
|
|
131
|
+
if (typeof href !== "string") {
|
|
132
|
+
close();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (run && run.href !== href)
|
|
136
|
+
close();
|
|
137
|
+
run = { href, text: `${run?.text ?? ""}${typeof child.text === "string" ? child.text : ""}` };
|
|
138
|
+
}
|
|
139
|
+
close();
|
|
140
|
+
};
|
|
141
|
+
const visit = (node, path, depth) => {
|
|
142
|
+
if (depth > MAXIMUM_DEPTH)
|
|
143
|
+
return;
|
|
144
|
+
switch (node.type) {
|
|
145
|
+
case "heading": {
|
|
146
|
+
const authored = clampLevel(node.attrs?.level);
|
|
147
|
+
const titleWasOpen = !outline.titleClaimed;
|
|
148
|
+
const level = nextHeadingLevel(outline, authored);
|
|
149
|
+
const text = clip(textOf(node));
|
|
150
|
+
const claimsTitle = authored === 1 && titleWasOpen;
|
|
151
|
+
if (claimsTitle)
|
|
152
|
+
claimant = { kind: "heading", text };
|
|
153
|
+
headings.push({
|
|
154
|
+
path,
|
|
155
|
+
text,
|
|
156
|
+
authored,
|
|
157
|
+
level,
|
|
158
|
+
role: claimsTitle ? "title" : titleWasOpen ? "before_title" : authored === 1 ? "demoted" : "numbered",
|
|
159
|
+
});
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "taprootImage": {
|
|
163
|
+
imageCount++;
|
|
164
|
+
seeContent();
|
|
165
|
+
if (!nonBlank(node.attrs?.alt)) {
|
|
166
|
+
const id = node.attrs?.imageId;
|
|
167
|
+
findings.push({
|
|
168
|
+
ruleId: RULE_IMAGE_ALT_MISSING,
|
|
169
|
+
severity: "warning",
|
|
170
|
+
subject: typeof id === "string" ? id : "",
|
|
171
|
+
detail: "",
|
|
172
|
+
path,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
case "componentBlock": {
|
|
178
|
+
const type = typeof node.attrs?.componentType === "string" ? node.attrs.componentType : "";
|
|
179
|
+
const data = componentData(node);
|
|
180
|
+
// The banner decides whether it is the title before the page counts it as content.
|
|
181
|
+
const opensPage = type === "image-banner" && bannerOpensPage(outline, data);
|
|
182
|
+
const fixedLevel = componentFixedHeadingLevel(type, data);
|
|
183
|
+
if (type === "image-banner" && imageSource(data.image)) {
|
|
184
|
+
imageCount++;
|
|
185
|
+
if (!nonBlank(data.altText)) {
|
|
186
|
+
findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// The social preview also draws on a hero's picture.
|
|
190
|
+
if (type === "hero-section" && imageSource(data.media)) {
|
|
191
|
+
imageCount++;
|
|
192
|
+
if (!nonBlank(data.media.alt)) {
|
|
193
|
+
findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (type === "cta" && imageSource(data.mediaImage) && !nonBlank(data.mediaImage.alt)) {
|
|
197
|
+
findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
|
|
198
|
+
}
|
|
199
|
+
if (opensPage) {
|
|
200
|
+
recordFixedHeading(outline, 1);
|
|
201
|
+
claimant = { kind: "banner", text: clip(String(data.overlayText)) };
|
|
202
|
+
}
|
|
203
|
+
else if (fixedLevel === 1) {
|
|
204
|
+
const text = clip(String(data.title));
|
|
205
|
+
if (outline.titleClaimed)
|
|
206
|
+
extraTitles.push({ path, text });
|
|
207
|
+
else
|
|
208
|
+
claimant = { kind: "hero", text };
|
|
209
|
+
recordFixedHeading(outline, 1);
|
|
210
|
+
}
|
|
211
|
+
else if (fixedLevel !== undefined) {
|
|
212
|
+
recordFixedHeading(outline, fixedLevel);
|
|
213
|
+
}
|
|
214
|
+
seeContent();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
case "section": {
|
|
218
|
+
// A section's photo background is an image too; its children are still walked below.
|
|
219
|
+
const background = node.attrs?.background;
|
|
220
|
+
if (isNode(background) && imageSource(background.image))
|
|
221
|
+
imageCount++;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "text":
|
|
225
|
+
if (nonBlank(node.text))
|
|
226
|
+
seeContent();
|
|
227
|
+
return;
|
|
228
|
+
case "integrationFragment": {
|
|
229
|
+
// The publisher treats an adopted fragment with markup as content before a banner, and its bytes may
|
|
230
|
+
// carry the page's own `<h1>`, which this walk cannot see.
|
|
231
|
+
const html = node.attrs?.html;
|
|
232
|
+
if (nonBlank(html)) {
|
|
233
|
+
seeContent();
|
|
234
|
+
if (/<h1[\s>]/iu.test(html))
|
|
235
|
+
opaqueH1 = true;
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
case "inlineFacts":
|
|
240
|
+
// A fact list is visible content; one with no items renders nothing.
|
|
241
|
+
if (Array.isArray(node.attrs?.items) && node.attrs.items.length > 0)
|
|
242
|
+
seeContent();
|
|
243
|
+
return;
|
|
244
|
+
default:
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
visitLinks(node, path);
|
|
248
|
+
children(node).forEach((child, index) => visit(child, [...path, index], depth + 1));
|
|
249
|
+
};
|
|
250
|
+
if (isNode(body))
|
|
251
|
+
children(body).forEach((child, index) => visit(child, [index], 1));
|
|
252
|
+
if (claimant === undefined) {
|
|
253
|
+
// A page with nothing on it has nothing to title yet.
|
|
254
|
+
if (hasContent && !opaqueH1) {
|
|
255
|
+
findings.unshift({ ruleId: RULE_H1_MISSING, severity: "warning", subject: "", detail: "" });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const kind = claimant.kind;
|
|
260
|
+
// A Heading 1 with no text still claims the title, so the page publishes an empty `<h1>`.
|
|
261
|
+
if (claimant.kind === "heading" && claimant.text === "" && hasContent) {
|
|
262
|
+
findings.unshift({ ruleId: RULE_H1_MISSING, severity: "warning", subject: "", detail: "" });
|
|
263
|
+
}
|
|
264
|
+
for (const heading of headings) {
|
|
265
|
+
if (heading.role === "demoted") {
|
|
266
|
+
findings.push({
|
|
267
|
+
ruleId: RULE_H1_DEMOTED,
|
|
268
|
+
severity: "warning",
|
|
269
|
+
subject: heading.text,
|
|
270
|
+
detail: kind,
|
|
271
|
+
path: heading.path,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
else if (heading.role === "before_title") {
|
|
275
|
+
findings.push({
|
|
276
|
+
ruleId: RULE_HEADING_BEFORE_TITLE,
|
|
277
|
+
severity: "warning",
|
|
278
|
+
subject: heading.text,
|
|
279
|
+
detail: String(heading.level),
|
|
280
|
+
path: heading.path,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
else if (heading.role === "numbered" && heading.level !== heading.authored) {
|
|
284
|
+
findings.push({
|
|
285
|
+
ruleId: RULE_HEADING_RENUMBERED,
|
|
286
|
+
severity: "warning",
|
|
287
|
+
subject: heading.text,
|
|
288
|
+
detail: `${heading.authored}>${heading.level}`,
|
|
289
|
+
path: heading.path,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const title of extraTitles) {
|
|
294
|
+
findings.push({
|
|
295
|
+
ruleId: RULE_H1_MULTIPLE,
|
|
296
|
+
severity: "warning",
|
|
297
|
+
subject: title.text,
|
|
298
|
+
detail: kind,
|
|
299
|
+
path: title.path,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { findings, imageCount, hasContent };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The warnings for one page. Headings, images and links are checked on a
|
|
307
|
+
* free-form body only; the title, description and social preview checks run
|
|
308
|
+
* for any page whose surface supplies those fields. `rules` limits the result
|
|
309
|
+
* to the rule ids a surface runs: the catalogue's `runsAt` says which.
|
|
310
|
+
*/
|
|
311
|
+
export function pageStructureFindings(page, rules) {
|
|
312
|
+
// Catalogue order, so a surface that shows only the first few keeps the heading rules.
|
|
313
|
+
return collectFindings(page)
|
|
314
|
+
.filter((finding) => rules === undefined || rules.has(finding.ruleId))
|
|
315
|
+
.sort((left, right) => RULE_ORDER.indexOf(left.ruleId) - RULE_ORDER.indexOf(right.ruleId));
|
|
316
|
+
}
|
|
317
|
+
function collectFindings(page) {
|
|
318
|
+
const findings = [];
|
|
319
|
+
let imageCount = 0;
|
|
320
|
+
if (page.freeForm) {
|
|
321
|
+
const walked = walkBody(page.body);
|
|
322
|
+
findings.push(...walked.findings);
|
|
323
|
+
imageCount = walked.imageCount;
|
|
324
|
+
}
|
|
325
|
+
if (typeof page.title === "string" && characters(page.title.trim()) > TITLE_WARN_LENGTH) {
|
|
326
|
+
findings.push({
|
|
327
|
+
ruleId: RULE_TITLE_LONG,
|
|
328
|
+
severity: "warning",
|
|
329
|
+
subject: "",
|
|
330
|
+
detail: String(characters(page.title.trim())),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (typeof page.description === "string") {
|
|
334
|
+
const description = page.description.trim();
|
|
335
|
+
if (description === "") {
|
|
336
|
+
findings.push({ ruleId: RULE_DESCRIPTION_MISSING, severity: "warning", subject: "", detail: "" });
|
|
337
|
+
}
|
|
338
|
+
else if (characters(description) > DESCRIPTION_WARN_LENGTH) {
|
|
339
|
+
findings.push({
|
|
340
|
+
ruleId: RULE_DESCRIPTION_LONG,
|
|
341
|
+
severity: "warning",
|
|
342
|
+
subject: "",
|
|
343
|
+
detail: String(characters(description)),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (page.freeForm && page.hasCover === false) {
|
|
348
|
+
findings.push({
|
|
349
|
+
ruleId: RULE_SOCIAL_IMAGE_FALLBACK,
|
|
350
|
+
severity: "warning",
|
|
351
|
+
subject: "",
|
|
352
|
+
detail: imageCount > 0 ? "first_image" : "none",
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return findings;
|
|
356
|
+
}
|
|
@@ -44,6 +44,12 @@ import {
|
|
|
44
44
|
REQUIRED_THEME_PROPERTIES,
|
|
45
45
|
validateAndLintThemePair,
|
|
46
46
|
} from "./theme-validation.js";
|
|
47
|
+
import {
|
|
48
|
+
describeSettingField,
|
|
49
|
+
formatSettingsReference,
|
|
50
|
+
PAGE_BACKGROUND_IMAGE_RENDERING,
|
|
51
|
+
PAGE_BACKGROUND_VERSUS_CANVAS,
|
|
52
|
+
} from "./setting-reference.js";
|
|
47
53
|
import {
|
|
48
54
|
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
49
55
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
@@ -273,14 +279,23 @@ const THEME_GROUPS = Object.freeze([
|
|
|
273
279
|
}),
|
|
274
280
|
Object.freeze({
|
|
275
281
|
name: "surface-assets",
|
|
276
|
-
summary:
|
|
282
|
+
summary:
|
|
283
|
+
"Preserve pulled asset fields; use appearance image ids for the logos, favicon and canvas image. The page "
|
|
284
|
+
+ "background image is a theme field behind the page content, unlike the canvas image, which paints only the "
|
|
285
|
+
+ "side margins (help settings has both).",
|
|
277
286
|
fields: Object.freeze([
|
|
278
287
|
themeField("stylesheets", "Agent-authored site themes must keep this exact array empty.", { exact: [] }),
|
|
279
288
|
themeField(
|
|
280
289
|
"pageBackgroundImage",
|
|
281
|
-
"CSS background-image
|
|
290
|
+
"CSS background-image (the app's Page style > Background image). " + PAGE_BACKGROUND_IMAGE_RENDERING
|
|
291
|
+
+ " Today it is a CSS url(...) of a delivered image, the src that .taproot-site-media.json records; it tiles "
|
|
292
|
+
+ "at the image's natural pixel size from the top-left. The complete Taproot workspace includes an empty value.",
|
|
293
|
+
),
|
|
294
|
+
themeField(
|
|
295
|
+
"pageBackgroundImageOpacity",
|
|
296
|
+
"How strongly the page background image shows, from 0 (invisible) to 1 (fully opaque).",
|
|
297
|
+
{ minimum: 0, maximum: 1 },
|
|
282
298
|
),
|
|
283
|
-
themeField("pageBackgroundImageOpacity", "Page background opacity.", { minimum: 0, maximum: 1 }),
|
|
284
299
|
themeField("boxBackgroundImage", "Optional inherited box-surface background image."),
|
|
285
300
|
themeField("boxBackgroundImageOpacity", "Optional box background opacity.", { minimum: 0, maximum: 1 }),
|
|
286
301
|
themeField("vellumOpacity", "Optional modal-vellum opacity.", { minimum: 0, maximum: 1 }),
|
|
@@ -418,6 +433,9 @@ export function getThemeReference() {
|
|
|
418
433
|
};
|
|
419
434
|
}
|
|
420
435
|
|
|
436
|
+
// "linkHoverColor" reads "link hover color", so a new footer color token needs no second table.
|
|
437
|
+
const footerColorLabel = (name) => name.replace(/([A-Z])/gu, " $1").toLowerCase();
|
|
438
|
+
|
|
421
439
|
function appearanceColorReference(definition) {
|
|
422
440
|
return definition.type === "appearance-color"
|
|
423
441
|
? {
|
|
@@ -432,6 +450,7 @@ export function getAppearanceReference() {
|
|
|
432
450
|
const scalarFields = APPEARANCE_SCALAR_FIELDS.map((field) => ({
|
|
433
451
|
file: APPEARANCE_FILES[field.settingsType],
|
|
434
452
|
path: "settings." + field.name,
|
|
453
|
+
...describeSettingField(field.settingsType, field.name),
|
|
435
454
|
type: field.type,
|
|
436
455
|
...(field.values ? { values: field.values } : {}),
|
|
437
456
|
...(field.minimum !== undefined ? { minimum: field.minimum, maximum: field.maximum } : {}),
|
|
@@ -447,27 +466,36 @@ export function getAppearanceReference() {
|
|
|
447
466
|
{
|
|
448
467
|
file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
|
|
449
468
|
path: "settings.lightTheme",
|
|
469
|
+
...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "lightTheme"),
|
|
450
470
|
type: "complete-espalier-theme",
|
|
451
471
|
},
|
|
452
472
|
{
|
|
453
473
|
file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
|
|
454
474
|
path: "settings.darkTheme",
|
|
475
|
+
...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "darkTheme"),
|
|
455
476
|
type: "complete-espalier-theme",
|
|
456
477
|
},
|
|
457
478
|
...scalarFields,
|
|
458
479
|
...APPEARANCE_FOOTER_COLOR_FIELDS.map((field) => ({
|
|
459
480
|
file: field.file,
|
|
460
481
|
path: field.path,
|
|
482
|
+
appLabel: "Footer",
|
|
483
|
+
description: `The footer's ${footerColorLabel(field.name)} in the ${field.scheme} scheme. footer push owns the rest of the footer.`,
|
|
484
|
+
renders: "The footer, once footerSettings.enabled is true (default false).",
|
|
461
485
|
type: field.type,
|
|
462
486
|
...appearanceColorReference(field),
|
|
463
487
|
default: field.default,
|
|
464
488
|
})),
|
|
465
489
|
],
|
|
466
490
|
readOnlyProjections: APPEARANCE_READ_ONLY_FIELDS,
|
|
491
|
+
pageBackgroundVersusCanvas: PAGE_BACKGROUND_VERSUS_CANVAS,
|
|
492
|
+
fieldMap: CLI_BINARY_NAME + " help settings maps every pulled field, including the read-only ones, to its app control.",
|
|
467
493
|
imageReferences:
|
|
468
494
|
"Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
|
|
469
495
|
logoContract:
|
|
470
496
|
"lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting. "
|
|
497
|
+
+ "Each takes one image id: upload the highest-resolution file you have and assign it. An @2x or @3x suffix is only "
|
|
498
|
+
+ "part of a filename, nothing pairs it with another file, and the platform generates the sizes. "
|
|
471
499
|
+ "Prefer transparent PNG or WebP artwork with genuine alpha so the header background shows through, including "
|
|
472
500
|
+ "inside letter counters. Avoid baked-in background rectangles unless the brand intentionally uses a badge. "
|
|
473
501
|
+ "Keep padding tight and verify the delivered image preserves transparency. Logo style and coloration are "
|
|
@@ -776,11 +804,17 @@ export function formatPresentationReference(reference) {
|
|
|
776
804
|
}
|
|
777
805
|
if (reference.referenceKind === "appearance") {
|
|
778
806
|
return reference.title + "\nUsage: " + reference.usage + "\n\nWritable fields:\n"
|
|
779
|
-
+ reference.fields.map((field) =>
|
|
780
|
-
"
|
|
781
|
-
|
|
807
|
+
+ reference.fields.map((field) =>
|
|
808
|
+
" " + field.file + " :: " + field.path + " — " + fieldConstraint(field)
|
|
809
|
+
+ "\n app: " + field.appLabel + ". " + field.description
|
|
810
|
+
+ (field.renders === undefined ? "" : "\n renders: " + field.renders)
|
|
811
|
+
).join("\n")
|
|
782
812
|
+ "\n\nRead-only server projections:\n"
|
|
783
813
|
+ reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
|
|
814
|
+
+ "\n\nPage background image versus canvas image:\n - " + reference.pageBackgroundVersusCanvas.pageBackground
|
|
815
|
+
+ "\n - " + reference.pageBackgroundVersusCanvas.canvas
|
|
816
|
+
+ "\n - " + reference.pageBackgroundVersusCanvas.shared
|
|
817
|
+
+ "\n\nField map: " + reference.fieldMap
|
|
784
818
|
+ "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
|
|
785
819
|
+ "\nLogo contrast: " + reference.logoContrastContract
|
|
786
820
|
+ "\nFavicon: " + reference.faviconContract
|
|
@@ -794,6 +828,7 @@ export function formatPresentationReference(reference) {
|
|
|
794
828
|
+ "\nRetry: " + reference.retry
|
|
795
829
|
+ "\nRecovery: " + reference.recovery + "\n";
|
|
796
830
|
}
|
|
831
|
+
if (reference.referenceKind === "settings") return formatSettingsReference(reference);
|
|
797
832
|
if (reference.referenceKind === "footer") {
|
|
798
833
|
const messages = reference.messages.map((message) =>
|
|
799
834
|
message.name + " (unlisted fields rejected)\n"
|
package/src/problems.js
CHANGED
|
@@ -39,7 +39,12 @@ function withContext(problem, context, error) {
|
|
|
39
39
|
export function problemsOf(error, context = {}) {
|
|
40
40
|
const own = Array.isArray(error.problems) && error.problems.length > 0
|
|
41
41
|
? error.problems
|
|
42
|
-
: [{
|
|
42
|
+
: [{
|
|
43
|
+
code: error.code,
|
|
44
|
+
...(error.field === undefined ? {} : { field: error.field }),
|
|
45
|
+
...(error.rule === undefined ? {} : { rule: error.rule }),
|
|
46
|
+
message: error.message,
|
|
47
|
+
}];
|
|
43
48
|
return own.map((problem) => withContext(problem, context, error));
|
|
44
49
|
}
|
|
45
50
|
|