@taprootio/site-authoring 0.14.1 → 0.15.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 +62 -10
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +58 -1
- package/src/cli.js +147 -22
- package/src/constants.js +6 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +100 -34
- package/src/session.js +13 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +11 -3
- package/src/typed-pages.js +31 -9
- package/src/verbs/index.js +13 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-push.js +406 -199
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +585 -0
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +264 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +47 -4
|
@@ -75,6 +75,30 @@ export const MARK_TYPES = Object.freeze([
|
|
|
75
75
|
"link",
|
|
76
76
|
]);
|
|
77
77
|
|
|
78
|
+
// The renderer's `LINK_REL_TOKENS` and `LINK_TARGETS` (TR01190): every HTML
|
|
79
|
+
// link type valid on `<a>` except `opener`. The renderer publishes only these
|
|
80
|
+
// and ignores the rest, so the CLI refuses the rest. The parity test reads
|
|
81
|
+
// both lists from the copied renderer.
|
|
82
|
+
export const LINK_REL_TOKENS = Object.freeze([
|
|
83
|
+
"nofollow",
|
|
84
|
+
"sponsored",
|
|
85
|
+
"ugc",
|
|
86
|
+
"me",
|
|
87
|
+
"noopener",
|
|
88
|
+
"noreferrer",
|
|
89
|
+
"external",
|
|
90
|
+
"author",
|
|
91
|
+
"license",
|
|
92
|
+
"tag",
|
|
93
|
+
"bookmark",
|
|
94
|
+
"help",
|
|
95
|
+
"prev",
|
|
96
|
+
"next",
|
|
97
|
+
"privacy-policy",
|
|
98
|
+
"terms-of-service",
|
|
99
|
+
]);
|
|
100
|
+
export const LINK_TARGETS = Object.freeze(["_blank", "_self"]);
|
|
101
|
+
|
|
78
102
|
export const NODE_TYPE_SET = Object.freeze(new Set(NODE_TYPES));
|
|
79
103
|
export const MARK_TYPE_SET = Object.freeze(new Set(MARK_TYPES));
|
|
80
104
|
|
|
@@ -108,6 +132,8 @@ export const CONTENT_ERROR_CODES = Object.freeze({
|
|
|
108
132
|
markMisplaced: "content.mark_misplaced",
|
|
109
133
|
unknownMark: "content.unknown_mark",
|
|
110
134
|
linkHref: "content.link_href",
|
|
135
|
+
linkRel: "content.link_rel",
|
|
136
|
+
linkTarget: "content.link_target",
|
|
111
137
|
// Attributes
|
|
112
138
|
attrsInvalid: "content.attrs_invalid",
|
|
113
139
|
attrUnknown: "content.attr_unknown",
|
package/src/errors.js
CHANGED
|
@@ -91,6 +91,8 @@ export function sanitizeDiagnostic(value, fallback = "The operation failed.") {
|
|
|
91
91
|
return [...clean].slice(0, LIMITS.diagnosticScalars).join("") || fallback;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
const MAXIMUM_PROBLEMS = 5_000;
|
|
95
|
+
|
|
94
96
|
/**
|
|
95
97
|
* The fixed wording every `theme.setting_missing` detail carries. A required
|
|
96
98
|
* key that is absent is exactly that: absence alone says nothing about when
|
|
@@ -175,6 +177,26 @@ export class SiteAuthoringError extends Error {
|
|
|
175
177
|
return this;
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Attach every problem a whole-workspace check found (TR01002). The JSON
|
|
182
|
+
* result carries a bounded prefix and the count; stderr lists them all.
|
|
183
|
+
*/
|
|
184
|
+
withProblems(problems) {
|
|
185
|
+
const list = Array.isArray(problems) ? problems : [];
|
|
186
|
+
this.problems = list
|
|
187
|
+
.slice(0, MAXIMUM_PROBLEMS)
|
|
188
|
+
.filter((problem) => typeof problem?.code === "string")
|
|
189
|
+
.map((problem) => ({
|
|
190
|
+
...(typeof problem.area === "string" ? { area: sanitizeDiagnostic(problem.area, "") } : {}),
|
|
191
|
+
...(typeof problem.file === "string" ? { file: sanitizeDiagnostic(problem.file, "") } : {}),
|
|
192
|
+
code: /^[a-z0-9_.-]+$/u.test(problem.code) ? problem.code : "site.failed",
|
|
193
|
+
...(typeof problem.field === "string" ? { field: sanitizeDiagnostic(problem.field, "") } : {}),
|
|
194
|
+
message: sanitizeDiagnostic(problem.message),
|
|
195
|
+
}));
|
|
196
|
+
this.problemCount = list.length;
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
|
|
178
200
|
/** Attach the non-secret identity needed to inspect or revoke a created preview. */
|
|
179
201
|
withPreviewRecovery(preview) {
|
|
180
202
|
const recovery = normalizePreviewRecovery(preview);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Espalier module that sits beside the exported theme module but is not
|
|
3
|
+
* exported itself (the fit report, the color engine). Its package root
|
|
4
|
+
* re-exports every component module, which registers custom elements as it
|
|
5
|
+
* loads, so these are loaded from beside the theme module instead, sharing its
|
|
6
|
+
* instance. The exact Espalier pin and this package's tests catch a release
|
|
7
|
+
* that moves them.
|
|
8
|
+
*/
|
|
9
|
+
export function loadEspalierShared(file) {
|
|
10
|
+
return import(new URL(`./${file}`, import.meta.resolve("@taprootio/espalier/shared/theme")).href);
|
|
11
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import definitions from "@taprootio/espalier/css/fonts/font-definitions.json" with { type: "json" };
|
|
2
|
+
|
|
3
|
+
import { CLI_BINARY_NAME } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `help fonts` (TR01188): the font families a published site can load, read
|
|
7
|
+
* from the catalog Espalier ships with the CLI. A theme's font fields name a
|
|
8
|
+
* family from it; the generator fetches that family's faces at publish time.
|
|
9
|
+
*
|
|
10
|
+
* The whole catalog is larger than one result may be, so the topic lists the
|
|
11
|
+
* categories and `help fonts <category>` lists one category's families.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A catalog variant is `regular`, `italic`, or a weight with an optional `italic`. */
|
|
15
|
+
function variantWeight(variant) {
|
|
16
|
+
return variant === "regular" || variant === "italic" ? 400 : Number.parseInt(variant, 10);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only upright weights: a publish loads one upright face per font field, and
|
|
20
|
+
// the browser slants it for italics.
|
|
21
|
+
const FAMILIES = Object.freeze(definitions.map((definition) => Object.freeze({
|
|
22
|
+
family: definition.family,
|
|
23
|
+
category: definition.category,
|
|
24
|
+
weights: Object.freeze(definition.variants.filter((variant) => !variant.endsWith("italic")).map(variantWeight)),
|
|
25
|
+
})));
|
|
26
|
+
|
|
27
|
+
export const FONT_CATEGORIES = Object.freeze([...new Set(FAMILIES.map((entry) => entry.category))]);
|
|
28
|
+
|
|
29
|
+
const DETAILS = Object.freeze([
|
|
30
|
+
"Set fontBody, fontHeadings, fontBrand, fontMonospace or fontMenu in settings/taproot-styles.json to a CSS "
|
|
31
|
+
+ "font-family list that starts with a family from this catalog, spelled exactly as listed (the match is "
|
|
32
|
+
+ "case-sensitive), such as \"Inter, sans-serif\". Finish with a CSS generic family: sans-serif, serif or "
|
|
33
|
+
+ "monospace, or cursive for a handwriting family. A first family that is not listed is not loaded: the page "
|
|
34
|
+
+ "falls back to the rest of the list, so a misspelled name publishes quietly in a fallback font.",
|
|
35
|
+
"Each font field's weight (fontWeightBody and the others) must be one of that family's weights; normal means 400 "
|
|
36
|
+
+ "and bold 700. Any other weight, or lighter or bolder, would fail the publish for a catalog family, so validate "
|
|
37
|
+
+ "and theme push refuse it and name the nearest weight the family has. An unset weight "
|
|
38
|
+
+ "takes the default: 400 for body and monospace, 700 for headings, brand and menu (brand also inherits the "
|
|
39
|
+
+ "headings family). So when a family lacks the default weight for its field, set that field's weight.",
|
|
40
|
+
"A publish loads one upright face per font field and serves it from the site itself; visitors never contact a "
|
|
41
|
+
+ "font service. Italic text is the browser slanting that face. A family with no weights offers only italics and "
|
|
42
|
+
+ "cannot be used.",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** The catalog's categories, or one category's families. */
|
|
46
|
+
export function getFontCatalogReference(category) {
|
|
47
|
+
const base = {
|
|
48
|
+
title: "Font catalog",
|
|
49
|
+
summary: "The font families a published site can load, by category, with the weights each offers.",
|
|
50
|
+
usage: `${CLI_BINARY_NAME} help fonts [category] [--json]`,
|
|
51
|
+
details: DETAILS,
|
|
52
|
+
};
|
|
53
|
+
if (category === undefined) {
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
...base,
|
|
56
|
+
familyCount: FAMILIES.length,
|
|
57
|
+
categories: Object.freeze(FONT_CATEGORIES.map((name) => Object.freeze({
|
|
58
|
+
category: name,
|
|
59
|
+
familyCount: FAMILIES.filter((entry) => entry.category === name).length,
|
|
60
|
+
helpCommand: `${CLI_BINARY_NAME} help fonts ${name}`,
|
|
61
|
+
}))),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (!FONT_CATEGORIES.includes(category)) return undefined;
|
|
65
|
+
const families = FAMILIES.filter((entry) => entry.category === category)
|
|
66
|
+
.map(({ family, weights }) => Object.freeze({ family, weights }));
|
|
67
|
+
return Object.freeze({ ...base, category, familyCount: families.length, families: Object.freeze(families) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatFontCatalog(reference) {
|
|
71
|
+
const header = `${reference.title}\n${reference.summary}\n\nUsage: ${reference.usage}\n\n${
|
|
72
|
+
reference.details.map((detail) => `- ${detail}`).join("\n")
|
|
73
|
+
}\n\n`;
|
|
74
|
+
if (reference.families === undefined) {
|
|
75
|
+
return `${header}${reference.familyCount} families:\n${
|
|
76
|
+
reference.categories.map((entry) =>
|
|
77
|
+
` ${entry.category.padEnd(14)}${String(entry.familyCount).padStart(4)} ${entry.helpCommand}`
|
|
78
|
+
).join("\n")
|
|
79
|
+
}\n\nAdd --json for stable machine-readable output.\n`;
|
|
80
|
+
}
|
|
81
|
+
const width = Math.max(...reference.families.map(({ family }) => family.length)) + 2;
|
|
82
|
+
return `${header}${reference.familyCount} ${reference.category} families and their weights:\n${
|
|
83
|
+
reference.families.map(({ family, weights }) =>
|
|
84
|
+
` ${family.padEnd(width)}${weights.length > 0 ? weights.join(" ") : "none (italic only; cannot be used)"}`
|
|
85
|
+
).join("\n")
|
|
86
|
+
}\n\nAdd --json for the same list as stable machine-readable output.\n`;
|
|
87
|
+
}
|
package/src/footer-contract.js
CHANGED
|
@@ -235,7 +235,7 @@ function target(value, path, knownPageResourceIds, tolerateStoredValues) {
|
|
|
235
235
|
if (knownPageResourceIds && !knownPageResourceIds.has(value.pageResourceId)) {
|
|
236
236
|
fail(
|
|
237
237
|
"footer.page_reference_unknown",
|
|
238
|
-
`${path}.pageResourceId must identify a page tracked by the pull manifest.`,
|
|
238
|
+
`${path}.pageResourceId must identify a page tracked by the pull manifest that the site still holds.`,
|
|
239
239
|
`${path}.pageResourceId`,
|
|
240
240
|
);
|
|
241
241
|
}
|
package/src/output.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
sanitizeDiagnostic,
|
|
20
20
|
SiteAuthoringError,
|
|
21
21
|
} from "./errors.js";
|
|
22
|
+
import { problemsWithinBudget } from "./problems.js";
|
|
22
23
|
|
|
23
24
|
const SAFE_SCALAR = /^[a-z][a-z0-9]*(?: [a-z][a-z0-9]*)?$/u;
|
|
24
25
|
const MAXIMUM_OUTPUT_PATH_LENGTH = 4_096;
|
|
@@ -53,6 +54,13 @@ export function failureResult(error) {
|
|
|
53
54
|
if (Array.isArray(error.differences)) {
|
|
54
55
|
result.error.differences = error.differences;
|
|
55
56
|
}
|
|
57
|
+
if (Array.isArray(error.problems) && error.problems.length > 0) {
|
|
58
|
+
const problems = problemsWithinBudget(error.problems);
|
|
59
|
+
const count = Math.max(error.problemCount ?? 0, error.problems.length);
|
|
60
|
+
result.error.problems = problems;
|
|
61
|
+
result.error.problemCount = count;
|
|
62
|
+
if (problems.length < count) result.error.problemsTruncated = true;
|
|
63
|
+
}
|
|
56
64
|
const previewDiagnostic = normalizePreviewDiagnostic(error.previewDiagnostic);
|
|
57
65
|
if (previewDiagnostic) result.error.diagnostic = previewDiagnostic;
|
|
58
66
|
const previewRecovery = normalizePreviewRecovery(error.previewRecovery);
|
|
@@ -159,6 +167,12 @@ export async function writeGithubActionsOutput(outputPath, result) {
|
|
|
159
167
|
await appendInspectedGithubOutput(outputPath, stats, `${lines.join("\n")}\n`);
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
function problemLocation(problem) {
|
|
171
|
+
if (problem.file === undefined) return problem.field ?? problem.area ?? "-";
|
|
172
|
+
if (problem.field === undefined || problem.field.startsWith(problem.file)) return problem.field ?? problem.file;
|
|
173
|
+
return `${problem.file} ${problem.field}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
162
176
|
export function humanFailure(error) {
|
|
163
177
|
const field = error.field ? ` field=${sanitizeDiagnostic(error.field, "unknown")}` : "";
|
|
164
178
|
const status = error.status ? ` status=${sanitizeDiagnostic(error.status, "unknown")}` : "";
|
|
@@ -167,8 +181,12 @@ export function humanFailure(error) {
|
|
|
167
181
|
? ` refusal=${sanitizeDiagnostic(kind, "unknown")}`
|
|
168
182
|
: "";
|
|
169
183
|
const details = error.details?.map((item) => `${item.field}: ${item.message}`).join("\n");
|
|
184
|
+
const problems = error.problems?.length > 1
|
|
185
|
+
? error.problems.map((problem) => ` ${problemLocation(problem)} [${problem.code}]: ${problem.message}`).join("\n")
|
|
186
|
+
: undefined;
|
|
170
187
|
return `${CLI_BINARY_NAME} failed [${error.code}]${field}${status}${refusal}: ${sanitizeDiagnostic(error.message)}`
|
|
171
|
-
+ (details ? `\n${details}` : "")
|
|
188
|
+
+ (details ? `\n${details}` : "")
|
|
189
|
+
+ (problems ? `\n${problems}` : "");
|
|
172
190
|
}
|
|
173
191
|
|
|
174
192
|
export function serializeResult(result) {
|
|
@@ -227,14 +227,25 @@ const THEME_GROUPS = Object.freeze([
|
|
|
227
227
|
summary: "Choose families and weights, then tune type, spacing, radius, and fluid viewport scales together.",
|
|
228
228
|
fields: Object.freeze([
|
|
229
229
|
...["fontBody", "fontHeadings", "fontBrand", "fontMonospace"].map((name) =>
|
|
230
|
-
themeField(
|
|
230
|
+
themeField(
|
|
231
|
+
name,
|
|
232
|
+
"CSS font-family starting with a family from 'help fonts'; an unlisted family is not loaded. Empty "
|
|
233
|
+
+ "inherits the consuming surface's fallback.",
|
|
234
|
+
)
|
|
231
235
|
),
|
|
232
236
|
themeField(
|
|
233
237
|
"fontMenu",
|
|
234
|
-
"CSS font-family for navigation menu items and group labels
|
|
238
|
+
"CSS font-family for navigation menu items and group labels, starting with a family from 'help fonts'; "
|
|
239
|
+
+ "empty falls back to fontBody, never to headings.",
|
|
235
240
|
),
|
|
236
241
|
...["fontWeightBody", "fontWeightHeadings", "fontWeightBrand", "fontWeightMonospace", "fontWeightMenu"].map(
|
|
237
|
-
(name) =>
|
|
242
|
+
(name) =>
|
|
243
|
+
themeField(
|
|
244
|
+
name,
|
|
245
|
+
"CSS font-weight. For a catalog family it must be one of the family's weights in 'help fonts' "
|
|
246
|
+
+ "(normal is 400, bold 700); validate and theme push refuse any other value, which would fail the "
|
|
247
|
+
+ "publish. Unset, it defaults to 400 for body and monospace and 700 for headings, brand and menu.",
|
|
248
|
+
),
|
|
238
249
|
),
|
|
239
250
|
themeField("rootFontSize", "Root font size in px.", { minimum: 1, warningMaximum: 100 }),
|
|
240
251
|
themeField("typeRatio", "Modular type-scale ratio.", { exclusiveMinimum: 1, warningMaximum: 1.3 }),
|
|
@@ -323,11 +334,14 @@ export const ROLE_RESOLUTION = Object.freeze({
|
|
|
323
334
|
+ "makes a mapping a pin: the resolver recompiles every token the marker leaves out, so a mapping it does not "
|
|
324
335
|
+ "name is a cached value that is never applied. To pin a token, set the mapping and add the token to the "
|
|
325
336
|
+ "marker; to let a role reach it, drop the token from the marker or delete the mapping. A document that "
|
|
326
|
-
+ "carries no explicitMappingTokens at all predates the marker, and every mapping in it is still a pin."
|
|
337
|
+
+ "carries no explicitMappingTokens at all predates the marker, and every mapping in it is still a pin. The "
|
|
338
|
+
+ "marker is the root's alone: a mapping in a context's semanticMappings is a pin by itself, needs no marker, "
|
|
339
|
+
+ "and a context cannot carry one.",
|
|
327
340
|
"Otherwise the token is compiled from the roles, and an APCA contrast check may nudge an action or ink stop so "
|
|
328
341
|
+ "the pair stays readable.",
|
|
329
342
|
"Otherwise the Espalier default mapping applies.",
|
|
330
|
-
"A context rebinds roles, lightness, tones and pins for one zone with the same precedence inside that zone
|
|
343
|
+
"A context rebinds roles, lightness, tones and pins for one zone with the same precedence inside that zone; its "
|
|
344
|
+
+ "pins need no explicitMappingTokens.",
|
|
331
345
|
]),
|
|
332
346
|
});
|
|
333
347
|
|
|
@@ -392,12 +406,12 @@ export function getThemeReference() {
|
|
|
392
406
|
"Check the filled action before anything else looks finished. Its ramp stop is chosen by the engine near a mid-band target rather than named by the theme, so seating the ramp — the remedy for every other anchored token — cannot move it, and declaring an action role does not either. The only lever is pinning semanticMappings.actionBackground.",
|
|
393
407
|
"A brand colour between roughly L 0.55 and L 0.83 cannot be a button surface at its own lightness: across that band no ink, not even pure black, reaches the Lc 75 APCA asks of a button label. Espalier then walks the label to the far end of the ramp and the button renders inverted — a dark surface carrying a pale label where the swatch wanted the opposite. Pin the stop just past the band instead. A gold at L 0.81 pinned to a tone at L 0.85 carries a dark label at Lc 76; the same gold left to the engine compiles to a brown one.",
|
|
394
408
|
"Name a tone as a lowercase slug — gold-band, not goldBand. theme push refuses any other name, and refuses a mapping that names a tone its theme or context never declares.",
|
|
395
|
-
"theme push runs Espalier's fit report over the root and every context in both schemes and lists each finding as a warning reading 'fit lint <id>'; theme push --dry-run does the same and writes nothing, and validate does it for an offline fixture. Read the lints before calling a theme done: action-anchor-inversion names a filled action that rendered the opposite way round from the swatches it declares, apca-target-unmet names a pair enforcement could not rescue, and action-canvas-separation and link-hover-ordering cover an action lost in its canvas and a hover weaker than its link. A lint is a warning, not a refusal, so a push still goes through with one — the lints are what tell an agent the design did not survive the compile.",
|
|
409
|
+
"theme push runs Espalier's fit report over the root and every context in both schemes and lists each finding as a warning reading 'fit lint <id>'; theme push --dry-run does the same and writes nothing, and validate does it for an offline fixture. Read the lints before calling a theme done: action-anchor-inversion names a filled action that rendered the opposite way round from the swatches it declares, apca-target-unmet names a pair enforcement could not rescue, and action-canvas-separation and link-hover-ordering cover an action lost in its canvas and a hover weaker than its link. A lint is a warning, not a refusal, so a push still goes through with one — the lints are what tell an agent the design did not survive the compile. Contrast and status-color warnings end with 'Nearest passing value: …' when the CLI finds one: it applied that value to a copy of the theme and checked that the warning clears without raising another, so start there and check again. validate and theme push refuse a font weight a catalog family has no face for (theme.font_weight_unavailable), because the publish would fail, and name the nearest weight it has.",
|
|
396
410
|
"Add named contexts for whole zones, including inverted zones, and rebind lightness when the zone changes brightness. A context that declares its own action needs its own actionBackground pin: without one it compiles at the engine's mid-band stop, and where the root is pinned it carries the root's pin instead, whatever action colour the context declares.",
|
|
397
411
|
"Tune typography, type/space ratios, radii, and viewport interpolation as one layout system.",
|
|
398
412
|
"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.",
|
|
399
413
|
"Retune danger/success/warning/info through intents without changing their meanings; design data palettes separately.",
|
|
400
|
-
"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 mapping the marker does not name is never applied, so a hand-added pin needs its token added to the marker or theme push reports it as inert.",
|
|
414
|
+
"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 mapping the marker does not name is never applied, so a hand-added pin at the root needs its token added to the marker or theme push reports it as inert. A pin inside a context needs no marker.",
|
|
401
415
|
"Run theme push --dry-run and clear its warnings, fit lints included; then run theme push and verify text, actions, focus states, and both schemes in authoring previews.",
|
|
402
416
|
],
|
|
403
417
|
example: THEME_EXAMPLE,
|
package/src/problems.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collecting problems instead of stopping at the first (TR01002, TR00823).
|
|
5
|
+
*
|
|
6
|
+
* A check that runs over a whole workspace reports every refusal it finds, so
|
|
7
|
+
* an importer fixing hundreds of converted pages learns them all in one run.
|
|
8
|
+
* Only `SiteAuthoringError`s are collected: anything else is a defect, and
|
|
9
|
+
* hiding it inside a list would turn a crash into a refusal.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The parts of a workspace a check reports a problem against. */
|
|
13
|
+
export const CHECK_AREA = Object.freeze({
|
|
14
|
+
media: "media",
|
|
15
|
+
pages: "pages",
|
|
16
|
+
presentation: "presentation",
|
|
17
|
+
footer: "footer",
|
|
18
|
+
navigation: "navigation",
|
|
19
|
+
redirects: "redirects",
|
|
20
|
+
forms: "forms",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Kept off the JSON so a problem serializes as plain data, but remembered so a
|
|
24
|
+
// single refusal can be rethrown exactly as it was raised.
|
|
25
|
+
const SOURCE = Symbol("source error");
|
|
26
|
+
|
|
27
|
+
function withContext(problem, context, error) {
|
|
28
|
+
const located = {
|
|
29
|
+
...(context.area === undefined ? {} : { area: context.area }),
|
|
30
|
+
...(context.file === undefined || problem.file !== undefined ? {} : { file: context.file }),
|
|
31
|
+
...problem,
|
|
32
|
+
};
|
|
33
|
+
if (located.field === located.file) delete located.field;
|
|
34
|
+
Object.defineProperty(located, SOURCE, { value: error });
|
|
35
|
+
return located;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The problems one refusal stands for: its own list when it carries one, otherwise itself. */
|
|
39
|
+
export function problemsOf(error, context = {}) {
|
|
40
|
+
const own = Array.isArray(error.problems) && error.problems.length > 0
|
|
41
|
+
? error.problems
|
|
42
|
+
: [{ code: error.code, ...(error.field === undefined ? {} : { field: error.field }), message: error.message }];
|
|
43
|
+
return own.map((problem) => withContext(problem, context, error));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs `action`, adding its refusal to `problems` instead of throwing. Returns
|
|
48
|
+
* the action's value, or undefined when it refused.
|
|
49
|
+
*/
|
|
50
|
+
export async function collectProblems(problems, context, action) {
|
|
51
|
+
try {
|
|
52
|
+
return await action();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!(error instanceof SiteAuthoringError)) throw error;
|
|
55
|
+
problems.push(...problemsOf(error, context));
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One refusal for a list of problems. When they all came from one refusal it
|
|
62
|
+
* is rethrown as raised, alternatives and differences included, so a check
|
|
63
|
+
* that finds one problem fails exactly as it did before problems were
|
|
64
|
+
* collected. Otherwise the code, message and field are the first refusal's and
|
|
65
|
+
* `error.problems` lists every problem.
|
|
66
|
+
*/
|
|
67
|
+
export function problemsError(problems, summary) {
|
|
68
|
+
const [first] = problems;
|
|
69
|
+
if (problems.every((problem) => problem[SOURCE] !== undefined && problem[SOURCE] === first[SOURCE])) {
|
|
70
|
+
return first[SOURCE];
|
|
71
|
+
}
|
|
72
|
+
return new SiteAuthoringError(
|
|
73
|
+
first[SOURCE]?.code ?? first.code,
|
|
74
|
+
`${summary}: ${problems.length} problems. First: ${first[SOURCE]?.message ?? first.message}`,
|
|
75
|
+
{ field: first[SOURCE]?.field ?? first.field ?? first.file },
|
|
76
|
+
).withProblems(problems);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A whole-workspace check can find thousands of problems. A JSON result keeps
|
|
80
|
+
// as many as fit well inside its 64 KiB bound; stderr lists them all.
|
|
81
|
+
const PROBLEM_BUDGET_BYTES = 40 * 1024;
|
|
82
|
+
|
|
83
|
+
/** The leading items whose JSON fits `budget` bytes. */
|
|
84
|
+
export function withinByteBudget(items, budget) {
|
|
85
|
+
const kept = [];
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
for (const item of items) {
|
|
88
|
+
bytes += Buffer.byteLength(JSON.stringify(item), "utf8") + 1;
|
|
89
|
+
if (bytes > budget) break;
|
|
90
|
+
kept.push(item);
|
|
91
|
+
}
|
|
92
|
+
return kept;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The leading problems that fit the result's byte budget, or a smaller one. */
|
|
96
|
+
export function problemsWithinBudget(problems, budget = PROBLEM_BUDGET_BYTES) {
|
|
97
|
+
return withinByteBudget(problems, Math.min(budget, PROBLEM_BUDGET_BYTES));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Under `apply`, a step refuses to send what its plan did not list: an input
|
|
102
|
+
* edited, or a site state changed, while an earlier step ran was never reviewed.
|
|
103
|
+
*/
|
|
104
|
+
export function planStale(subject, field) {
|
|
105
|
+
return new SiteAuthoringError(
|
|
106
|
+
"apply.plan_stale",
|
|
107
|
+
`${subject} changed after the plan was made, so this step stopped before sending it. Run 'taproot-site plan' again and `
|
|
108
|
+
+ "apply the new planHash.",
|
|
109
|
+
{ field },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Throws when `problems` is not empty. */
|
|
114
|
+
export function refuseProblems(problems, summary) {
|
|
115
|
+
if (problems.length > 0) throw problemsError(problems, summary);
|
|
116
|
+
}
|