@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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { bestAvailableWeight } from "@taprootio/espalier/shared/font-helpers";
|
|
2
|
+
import { compileFontPlan } from "@taprootio/espalier/shared/font-plan";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_DARK_THEME,
|
|
5
|
+
DEFAULT_LIGHT_THEME,
|
|
6
|
+
encodeTheme,
|
|
7
|
+
mergeTheme,
|
|
8
|
+
parseTheme,
|
|
9
|
+
validateThemePair,
|
|
10
|
+
} from "@taprootio/espalier/shared/theme";
|
|
11
|
+
import { computeThemeProperties } from "@taprootio/espalier/shared/theme-properties";
|
|
12
|
+
|
|
13
|
+
import { loadEspalierShared } from "./espalier-shared.js";
|
|
14
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Passing values for theme warnings, and the font weights a publish can load
|
|
18
|
+
* (TR01189).
|
|
19
|
+
*
|
|
20
|
+
* A contrast or status-color warning names its cause and its place; this adds
|
|
21
|
+
* the nearest value the CLI finds that passes. Each candidate is applied to a
|
|
22
|
+
* copy of the theme and the same check runs again, so only a value that clears
|
|
23
|
+
* the warning without raising any other is named. Proposals are extras: one
|
|
24
|
+
* that cannot be computed leaves the warning as Espalier wrote it.
|
|
25
|
+
*
|
|
26
|
+
* The status-collision pattern and the "No ink reaches the floor" test read
|
|
27
|
+
* Espalier's message text, which the exact Espalier pin holds still; a message
|
|
28
|
+
* that stops matching just gets no proposal.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const DEFAULTS = Object.freeze({ light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME });
|
|
32
|
+
|
|
33
|
+
/** Re-checks one run may spend on proposals, lints first; beyond it, warnings keep their words. */
|
|
34
|
+
const MAXIMUM_EVALUATIONS = 60;
|
|
35
|
+
const MAXIMUM_CANDIDATES = 24;
|
|
36
|
+
const LIGHTNESS_STEP = 0.02;
|
|
37
|
+
|
|
38
|
+
function encodePair(light, dark) {
|
|
39
|
+
return [encodeTheme(light), encodeTheme(dark)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withScheme(themes, scheme, theme) {
|
|
43
|
+
return scheme === "light" ? [theme, themes.dark] : [themes.light, theme];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function round(value) {
|
|
47
|
+
return Math.round(value * 1000) / 1000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every (scheme, surface, lint, token) a fit report suite names. */
|
|
51
|
+
function lintTokenKeys(suite, root) {
|
|
52
|
+
const keys = new Set();
|
|
53
|
+
for (const scheme of ["light", "dark"]) {
|
|
54
|
+
for (const report of suite[scheme] ?? []) {
|
|
55
|
+
for (const lint of report.lints) {
|
|
56
|
+
for (const token of lint.tokens) keys.add(`${scheme}|${report.surface ?? root}|${lint.id}|${token}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return keys;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Status colors
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const STATUS_COLLISION =
|
|
68
|
+
/^(light|dark): (?:contexts\.[^:]+: )?Status colors "([a-z]+)" and "([a-z]+)" .*retune (?:one of them|both|"([a-z]+)") via intents/u;
|
|
69
|
+
const HUE_SHIFTS = [8, -8, 16, -16, 24, -24, 36, -36, 48, -48, 64, -64];
|
|
70
|
+
const LIGHTNESS_SHIFTS = [0.04, -0.04, 0.08, -0.08, 0.12, -0.12];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An `intents` color for one of the two families a collision names, nearest
|
|
74
|
+
* first by ΔE-OK from the family's current color: hue moves before lightness,
|
|
75
|
+
* since the audit re-renders status colors at the action's lightness. Values
|
|
76
|
+
* already proposed for other families in this scheme are applied with it, so
|
|
77
|
+
* taking every proposal together cannot recreate a collision.
|
|
78
|
+
*/
|
|
79
|
+
function proposeStatusIntent(warning, themes, baseline, accepted, engine, budget) {
|
|
80
|
+
const match = STATUS_COLLISION.exec(warning);
|
|
81
|
+
if (match === null) return undefined;
|
|
82
|
+
const [, scheme, first, second, named] = match;
|
|
83
|
+
const theme = { ...themes[scheme], intents: { ...themes[scheme].intents, ...accepted[scheme] } };
|
|
84
|
+
const statusBefore = new Set(baseline.filter((entry) => entry.includes("Status colors")));
|
|
85
|
+
// The values already proposed may clear this collision on their own; a
|
|
86
|
+
// family that has a proposal gets no second one.
|
|
87
|
+
if (Object.keys(accepted[scheme]).length > 0) {
|
|
88
|
+
if (budget.remaining <= 0) return undefined;
|
|
89
|
+
budget.remaining -= 1;
|
|
90
|
+
const result = validateThemePair(...encodePair(...withScheme(themes, scheme, theme)));
|
|
91
|
+
if (result.valid && !result.warnings.includes(warning)) return "Cleared by the intents proposed above.";
|
|
92
|
+
}
|
|
93
|
+
const merged = mergeTheme(DEFAULTS[scheme], theme);
|
|
94
|
+
const properties = computeThemeProperties(merged, scheme);
|
|
95
|
+
const candidates = [];
|
|
96
|
+
const families = (named === undefined ? [second, first] : [named]).filter((family) => !(family in accepted[scheme]));
|
|
97
|
+
for (const family of families) {
|
|
98
|
+
const current = merged.intents?.[family] ?? properties[`--esp-color-${family}`];
|
|
99
|
+
const base = typeof current === "string" ? engine.parseCssColor(current) : null;
|
|
100
|
+
if (base === null || base === undefined) continue;
|
|
101
|
+
const shifts = [
|
|
102
|
+
...HUE_SHIFTS.map((hue) => [0, hue]),
|
|
103
|
+
...LIGHTNESS_SHIFTS.flatMap((lightness) => [0, ...HUE_SHIFTS].map((hue) => [lightness, hue])),
|
|
104
|
+
];
|
|
105
|
+
for (const [lightnessShift, hueShift] of shifts) {
|
|
106
|
+
const color = engine.gamutMapToSRGB({
|
|
107
|
+
l: Math.min(0.95, Math.max(0.05, base.l + lightnessShift)),
|
|
108
|
+
c: base.c,
|
|
109
|
+
h: (base.h + hueShift + 360) % 360,
|
|
110
|
+
});
|
|
111
|
+
candidates.push({ family, color, hueOnly: lightnessShift === 0, distance: engine.deltaEOK(base, color) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
candidates.sort((left, right) => Number(right.hueOnly) - Number(left.hueOnly) || left.distance - right.distance);
|
|
115
|
+
for (const { family, color } of candidates.slice(0, MAXIMUM_CANDIDATES)) {
|
|
116
|
+
if (budget.remaining <= 0) return undefined;
|
|
117
|
+
budget.remaining -= 1;
|
|
118
|
+
const value = engine.serializeOklch(color);
|
|
119
|
+
const patched = { ...theme, intents: { ...theme.intents, [family]: value } };
|
|
120
|
+
const result = validateThemePair(...encodePair(...withScheme(themes, scheme, patched)));
|
|
121
|
+
if (!result.valid || result.warnings.includes(warning)) continue;
|
|
122
|
+
if (result.warnings.some((entry) => entry.includes("Status colors") && !statusBefore.has(entry))) continue;
|
|
123
|
+
// Nor may it collide with a family already given a proposal, even where
|
|
124
|
+
// that collision was there before.
|
|
125
|
+
const proposedFamilies = Object.keys(accepted[scheme]);
|
|
126
|
+
if (
|
|
127
|
+
result.warnings.some((entry) =>
|
|
128
|
+
entry.startsWith(`${scheme}: `) && entry.includes("Status colors") && entry.includes(`"${family}"`)
|
|
129
|
+
&& proposedFamilies.some((other) => entry.includes(`"${other}"`))
|
|
130
|
+
)
|
|
131
|
+
) continue;
|
|
132
|
+
const together = Object.keys(accepted[scheme]).length > 0 ? ", with the intents proposed above" : "";
|
|
133
|
+
accepted[scheme][family] = value;
|
|
134
|
+
return `Nearest passing value: ${scheme}Theme.intents.${family} = "${value}"${together}.`;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Fit lints
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The token a lint's fix retunes: the one the lint names first, which is its
|
|
145
|
+
* own recommendation, except that when no ink can reach the floor the ground
|
|
146
|
+
* is retuned instead.
|
|
147
|
+
*/
|
|
148
|
+
function lintTarget(lint, report) {
|
|
149
|
+
if (!["action-canvas-separation", "link-hover-ordering", "apca-target-unmet"].includes(lint.id)) return undefined;
|
|
150
|
+
if (lint.id === "apca-target-unmet" && /No ink reaches the floor/u.test(lint.message)) {
|
|
151
|
+
return report.tokens.find((entry) => entry.token === lint.tokens[0])?.apca?.against;
|
|
152
|
+
}
|
|
153
|
+
return lint.tokens[0];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toneName(token) {
|
|
157
|
+
return `${token.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}-pass`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The theme with `token` pinned to `lightness` through a tone, on the lint's
|
|
162
|
+
* surface. A root without a marker already treats every mapping as a pin, so
|
|
163
|
+
* none is added; one is extended, never replaced.
|
|
164
|
+
*/
|
|
165
|
+
function pinnedTheme(theme, surface, root, token, source, tone, lightness) {
|
|
166
|
+
const mapping = { source, lightness: `tone:${tone}` };
|
|
167
|
+
if (surface === root) {
|
|
168
|
+
return {
|
|
169
|
+
...theme,
|
|
170
|
+
tones: { ...theme.tones, [tone]: lightness },
|
|
171
|
+
semanticMappings: { ...theme.semanticMappings, [token]: mapping },
|
|
172
|
+
...(Array.isArray(theme.explicitMappingTokens)
|
|
173
|
+
? { explicitMappingTokens: [...new Set([...theme.explicitMappingTokens, token])] }
|
|
174
|
+
: {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const context = theme.contexts?.[surface] ?? {};
|
|
178
|
+
return {
|
|
179
|
+
...theme,
|
|
180
|
+
contexts: {
|
|
181
|
+
...theme.contexts,
|
|
182
|
+
[surface]: {
|
|
183
|
+
...context,
|
|
184
|
+
tones: { ...context.tones, [tone]: lightness },
|
|
185
|
+
semanticMappings: { ...context.semanticMappings, [token]: mapping },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function surfaceOf(report, root) {
|
|
192
|
+
return report.surface ?? root;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function cleared(item, target, after, root) {
|
|
196
|
+
const { scheme, report, lint } = item;
|
|
197
|
+
const watched = target === lint.tokens[0] ? [target] : lint.tokens;
|
|
198
|
+
return !watched.some((token) => after.has(`${scheme}|${surfaceOf(report, root)}|${lint.id}|${token}`));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The nearest lightness, in steps from the target token's resolved one, at
|
|
203
|
+
* which the lint clears and no lint names a token anywhere in the suite that
|
|
204
|
+
* it did not name before. Returns the text and every lint token the verified
|
|
205
|
+
* theme cleared, so a context lint the same fix clears is not proposed again.
|
|
206
|
+
*/
|
|
207
|
+
function proposeLintFix(item, themes, fit, engine, budget, before) {
|
|
208
|
+
const { scheme, report, lint } = item;
|
|
209
|
+
const target = lintTarget(lint, report);
|
|
210
|
+
const entry = target === undefined ? undefined : report.tokens.find((token) => token.token === target);
|
|
211
|
+
const base = entry === undefined ? null : engine.parseCssColor(entry.resolved);
|
|
212
|
+
if (base === null || base === undefined || typeof entry.source !== "string") return undefined;
|
|
213
|
+
const root = fit.ROOT_SURFACE;
|
|
214
|
+
const tone = toneName(target);
|
|
215
|
+
const lightnesses = [];
|
|
216
|
+
for (let step = 1; lightnesses.length < MAXIMUM_CANDIDATES && step <= 1 / LIGHTNESS_STEP; step += 1) {
|
|
217
|
+
for (const lightness of [base.l + step * LIGHTNESS_STEP, base.l - step * LIGHTNESS_STEP]) {
|
|
218
|
+
if (lightness > 0.02 && lightness < 0.98) lightnesses.push(round(lightness));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const lightness of lightnesses) {
|
|
222
|
+
if (budget.remaining <= 0) return undefined;
|
|
223
|
+
budget.remaining -= 1;
|
|
224
|
+
const patched = pinnedTheme(themes[scheme], report.surface, root, target, entry.source, tone, lightness);
|
|
225
|
+
const encoded = encodePair(...withScheme(themes, scheme, patched));
|
|
226
|
+
let after;
|
|
227
|
+
try {
|
|
228
|
+
after = lintTokenKeys(fit.themeFitReportSuite(parseTheme(encoded[0]) ?? {}, parseTheme(encoded[1]) ?? {}), root);
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!cleared(item, target, after, root) || [...after].some((key) => !before.has(key))) continue;
|
|
233
|
+
// Validation costs several fit reports, so only a candidate that clears pays for it.
|
|
234
|
+
if (!validateThemePair(...encoded).valid) continue;
|
|
235
|
+
const place = report.surface === root ? `${scheme}Theme` : `${scheme}Theme.contexts.${report.surface}`;
|
|
236
|
+
const marker = report.surface !== root
|
|
237
|
+
? " (a context's pin needs no explicitMappingTokens)"
|
|
238
|
+
: Array.isArray(themes[scheme].explicitMappingTokens)
|
|
239
|
+
? ` and list ${target} in explicitMappingTokens`
|
|
240
|
+
: " (this theme has no explicitMappingTokens, so every mapping in it is a pin; do not add one)";
|
|
241
|
+
return {
|
|
242
|
+
text: `Nearest passing value: ${target} at lightness ${lightness} — in ${place} set tones["${tone}"] to `
|
|
243
|
+
+ `${lightness} and semanticMappings.${target} to {"source":"${entry.source}","lightness":"tone:${tone}"}${marker}.`,
|
|
244
|
+
clearedKeys: new Set([...before].filter((key) => !after.has(key))),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function lintKeys(item, root) {
|
|
251
|
+
return item.lint.tokens.map((token) => `${item.scheme}|${surfaceOf(item.report, root)}|${item.lint.id}|${token}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Each theme warning with the nearest passing value appended where one is
|
|
256
|
+
* found: the fit lints first, then Espalier's status-color collisions. Root
|
|
257
|
+
* lints come before context lints, and a context lint the root's proposal
|
|
258
|
+
* already clears is not proposed again.
|
|
259
|
+
*/
|
|
260
|
+
export async function proposePassingValues({ lightTheme, darkTheme, warnings, lints, fit }) {
|
|
261
|
+
const themes = { light: lightTheme, dark: darkTheme };
|
|
262
|
+
const engine = await loadEspalierShared("color-engine.js");
|
|
263
|
+
const budget = { remaining: MAXIMUM_EVALUATIONS };
|
|
264
|
+
const root = fit.ROOT_SURFACE;
|
|
265
|
+
const before = new Set(lints.flatMap((item) => lintKeys(item, root)));
|
|
266
|
+
const proposals = new Map();
|
|
267
|
+
const clearedByProposals = new Set();
|
|
268
|
+
const order = [...lints].sort((left, right) => Number(left.report.surface !== root) - Number(right.report.surface !== root));
|
|
269
|
+
for (const item of order) {
|
|
270
|
+
if (lintKeys(item, root).every((key) => clearedByProposals.has(key))) continue;
|
|
271
|
+
let proposal;
|
|
272
|
+
try {
|
|
273
|
+
proposal = proposeLintFix(item, themes, fit, engine, budget, before);
|
|
274
|
+
} catch {
|
|
275
|
+
proposal = undefined;
|
|
276
|
+
}
|
|
277
|
+
if (proposal === undefined) continue;
|
|
278
|
+
proposals.set(item, proposal.text);
|
|
279
|
+
for (const key of proposal.clearedKeys) clearedByProposals.add(key);
|
|
280
|
+
}
|
|
281
|
+
const accepted = { light: {}, dark: {} };
|
|
282
|
+
const proposedWarnings = warnings.map((warning) => {
|
|
283
|
+
let proposal;
|
|
284
|
+
try {
|
|
285
|
+
proposal = proposeStatusIntent(warning, themes, warnings, accepted, engine, budget);
|
|
286
|
+
} catch {
|
|
287
|
+
proposal = undefined;
|
|
288
|
+
}
|
|
289
|
+
return proposal === undefined ? warning : `${warning} ${proposal}`;
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
warnings: proposedWarnings,
|
|
293
|
+
lints: lints.map((item) => (proposals.has(item) ? `${item.text} ${proposals.get(item)}` : item.text)),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Font weights
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
const FONT_SLOTS = Object.freeze({
|
|
302
|
+
body: { family: "fontBody", weight: "fontWeightBody", defaultWeight: 400 },
|
|
303
|
+
headings: { family: "fontHeadings", weight: "fontWeightHeadings", defaultWeight: 700 },
|
|
304
|
+
brand: { family: "fontBrand", weight: "fontWeightBrand", defaultWeight: 700 },
|
|
305
|
+
menu: { family: "fontMenu", weight: "fontWeightMenu", defaultWeight: 700 },
|
|
306
|
+
monospace: { family: "fontMonospace", weight: "fontWeightMonospace", defaultWeight: 400 },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// About half a megabyte, so it is read only when a theme is checked.
|
|
310
|
+
let fontCatalog;
|
|
311
|
+
async function loadFontCatalog() {
|
|
312
|
+
fontCatalog ??= (await import("@taprootio/espalier/css/fonts/font-fallback-profiles.json", { with: { type: "json" } }))
|
|
313
|
+
.default;
|
|
314
|
+
return fontCatalog;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function uprightWeights(catalog, family) {
|
|
318
|
+
return Object.keys(catalog.profiles?.[family] ?? {})
|
|
319
|
+
.filter((weight) => /^\d+$/u.test(weight))
|
|
320
|
+
.sort((left, right) => Number(left) - Number(right));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The font field whose stack Espalier cannot embed, found one field at a time. */
|
|
324
|
+
function invalidFontStack(themes, catalog) {
|
|
325
|
+
for (const scheme of ["light", "dark"]) {
|
|
326
|
+
for (const { family } of Object.values(FONT_SLOTS)) {
|
|
327
|
+
const value = themes[scheme][family];
|
|
328
|
+
if (typeof value !== "string" || value === "") continue;
|
|
329
|
+
try {
|
|
330
|
+
compileFontPlan({ lightTheme: { ...DEFAULT_LIGHT_THEME, fontBody: value }, darkTheme: DEFAULT_DARK_THEME, catalog });
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return { field: `${scheme}Theme.${family}`, reason: error?.message ?? String(error) };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The refusal a publish would hit, checked the way the generator checks it:
|
|
341
|
+
* both themes merged over the defaults, compiled into a font plan against the
|
|
342
|
+
* same catalog. A font field whose catalog family has no face at that field's
|
|
343
|
+
* weight fails the plan; each such field is named with the nearest weight the
|
|
344
|
+
* family has. A font stack Espalier cannot embed is refused by name too.
|
|
345
|
+
*/
|
|
346
|
+
export async function refuseUnavailableFontWeights(encodedLight, encodedDark) {
|
|
347
|
+
const catalog = await loadFontCatalog();
|
|
348
|
+
const themes = {
|
|
349
|
+
light: mergeTheme(DEFAULT_LIGHT_THEME, parseTheme(encodedLight) ?? {}),
|
|
350
|
+
dark: mergeTheme(DEFAULT_DARK_THEME, parseTheme(encodedDark) ?? {}),
|
|
351
|
+
};
|
|
352
|
+
let plan;
|
|
353
|
+
try {
|
|
354
|
+
plan = compileFontPlan({ lightTheme: themes.light, darkTheme: themes.dark, catalog });
|
|
355
|
+
} catch (error) {
|
|
356
|
+
const invalid = invalidFontStack(themes, catalog);
|
|
357
|
+
throw new SiteAuthoringError(
|
|
358
|
+
"theme.font_stack_invalid",
|
|
359
|
+
`${invalid?.field ?? "A font field"} is not a font stack a published page can carry: `
|
|
360
|
+
+ `${invalid?.reason ?? error?.message ?? String(error)}`,
|
|
361
|
+
{ field: invalid?.field, cause: error },
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (plan.missingProfiles.length === 0) return;
|
|
365
|
+
// Espalier groups a missing face's schemes and slots independently, so each
|
|
366
|
+
// scheme is compiled on its own (beside the other's defaults, which request
|
|
367
|
+
// no catalog face) to know exactly which fields fail.
|
|
368
|
+
const misses = ["light", "dark"].flatMap((scheme) => {
|
|
369
|
+
const single = compileFontPlan({
|
|
370
|
+
lightTheme: scheme === "light" ? themes.light : DEFAULT_LIGHT_THEME,
|
|
371
|
+
darkTheme: scheme === "dark" ? themes.dark : DEFAULT_DARK_THEME,
|
|
372
|
+
catalog,
|
|
373
|
+
});
|
|
374
|
+
return single.missingProfiles.flatMap((miss) =>
|
|
375
|
+
miss.slots.map((slot) => ({ ...miss, scheme, slot: FONT_SLOTS[slot] }))
|
|
376
|
+
);
|
|
377
|
+
}).filter((miss) => miss.slot !== undefined);
|
|
378
|
+
const lines = misses.map((miss) => {
|
|
379
|
+
const field = `${miss.scheme}Theme.${miss.slot.weight}`;
|
|
380
|
+
const available = uprightWeights(catalog, miss.family);
|
|
381
|
+
const requested = miss.weight === null ? "that weight" : `weight ${miss.weight}`;
|
|
382
|
+
return available.length === 0
|
|
383
|
+
? `${field}: "${miss.family}" has no upright face at all; choose another family (see 'help fonts').`
|
|
384
|
+
: `${field}: "${miss.family}" has no face at ${requested}. Nearest passing value: ${miss.slot.weight} = `
|
|
385
|
+
+ `${bestAvailableWeight(String(miss.weight ?? miss.slot.defaultWeight), available)} (it offers `
|
|
386
|
+
+ `${available.join(", ")}; see 'help fonts').`;
|
|
387
|
+
});
|
|
388
|
+
throw new SiteAuthoringError(
|
|
389
|
+
"theme.font_weight_unavailable",
|
|
390
|
+
`Publishing would fail on a font weight the family does not have. ${lines.join(" ")}`,
|
|
391
|
+
{ field: misses[0] === undefined ? undefined : `${misses[0].scheme}Theme.${misses[0].slot.weight}` },
|
|
392
|
+
);
|
|
393
|
+
}
|
package/src/theme-validation.js
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "@taprootio/espalier/shared/theme";
|
|
11
11
|
|
|
12
12
|
import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
13
|
+
import { loadEspalierShared } from "./espalier-shared.js";
|
|
14
|
+
import { proposePassingValues, refuseUnavailableFontWeights } from "./theme-proposals.js";
|
|
13
15
|
|
|
14
16
|
export const MAXIMUM_THEME_WARNINGS = 32;
|
|
15
17
|
// Large enough for a whole fit lint: apca-target-unmet lists every pair that
|
|
@@ -403,7 +405,7 @@ let fitReport;
|
|
|
403
405
|
* release that moves it.
|
|
404
406
|
*/
|
|
405
407
|
function loadFitReport() {
|
|
406
|
-
fitReport ??=
|
|
408
|
+
fitReport ??= loadEspalierShared("theme-fit-report.js");
|
|
407
409
|
return fitReport;
|
|
408
410
|
}
|
|
409
411
|
|
|
@@ -423,26 +425,36 @@ function loadFitReport() {
|
|
|
423
425
|
* report describes exactly what the published page resolves. Lints are
|
|
424
426
|
* warnings rather than refusals, matching how Espalier classifies them.
|
|
425
427
|
*/
|
|
428
|
+
// The lint texts alone; validation itself uses fitLints. Kept for the test of
|
|
429
|
+
// a report Espalier cannot build.
|
|
426
430
|
export async function fitLintWarnings(light, dark) {
|
|
427
|
-
|
|
431
|
+
return (await fitLints(light, dark)).items.map((item) => item.text);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The fit lints with the report each came from, for proposing a passing value. */
|
|
435
|
+
async function fitLints(light, dark) {
|
|
436
|
+
const fit = await loadFitReport();
|
|
428
437
|
let suite;
|
|
429
438
|
try {
|
|
430
|
-
suite = themeFitReportSuite(parseTheme(light) ?? {}, parseTheme(dark) ?? {});
|
|
439
|
+
suite = fit.themeFitReportSuite(parseTheme(light) ?? {}, parseTheme(dark) ?? {});
|
|
431
440
|
} catch (error) {
|
|
432
441
|
// A pair that passed validation resolves, so this should not happen; if
|
|
433
442
|
// it does, say the lints were not checked rather than implying none fired.
|
|
434
|
-
return
|
|
443
|
+
return {
|
|
444
|
+
fit,
|
|
445
|
+
items: [{ text: `Espalier could not build the fit report, so no fit lints were checked: ${error?.message ?? error}` }],
|
|
446
|
+
};
|
|
435
447
|
}
|
|
436
|
-
const
|
|
448
|
+
const items = [];
|
|
437
449
|
for (const scheme of ["light", "dark"]) {
|
|
438
450
|
for (const report of suite[scheme]) {
|
|
439
|
-
const surface = report.surface === ROOT_SURFACE ? "" : `contexts.${report.surface} `;
|
|
451
|
+
const surface = report.surface === fit.ROOT_SURFACE ? "" : `contexts.${report.surface} `;
|
|
440
452
|
for (const lint of report.lints) {
|
|
441
|
-
|
|
453
|
+
items.push({ scheme, report, lint, text: `${scheme}: ${surface}fit lint ${lint.id} — ${lint.message}` });
|
|
442
454
|
}
|
|
443
455
|
}
|
|
444
456
|
}
|
|
445
|
-
return
|
|
457
|
+
return { fit, items };
|
|
446
458
|
}
|
|
447
459
|
|
|
448
460
|
function boundedThemeWarnings(allWarnings) {
|
|
@@ -475,8 +487,17 @@ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
|
|
|
475
487
|
*/
|
|
476
488
|
export async function validateAndLintThemePair(lightTheme, darkTheme) {
|
|
477
489
|
const { light, dark, warnings, inert } = checkThemePair(lightTheme, darkTheme);
|
|
478
|
-
|
|
479
|
-
|
|
490
|
+
await refuseUnavailableFontWeights(light, dark);
|
|
491
|
+
const { fit, items } = await fitLints(light, dark);
|
|
492
|
+
const proposed = await proposePassingValues({
|
|
493
|
+
lightTheme,
|
|
494
|
+
darkTheme,
|
|
495
|
+
warnings,
|
|
496
|
+
lints: items.filter((item) => item.lint !== undefined),
|
|
497
|
+
fit,
|
|
498
|
+
});
|
|
499
|
+
const unbuilt = items.filter((item) => item.lint === undefined).map((item) => item.text);
|
|
500
|
+
return { light, dark, ...boundedThemeWarnings([...proposed.warnings, ...unbuilt, ...proposed.lints, ...inert]) };
|
|
480
501
|
}
|
|
481
502
|
|
|
482
503
|
function checkThemePair(lightTheme, darkTheme) {
|
|
@@ -66,8 +66,15 @@ const FIELD_HELP = Object.freeze({
|
|
|
66
66
|
type: "UUID",
|
|
67
67
|
required: true,
|
|
68
68
|
description:
|
|
69
|
-
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record
|
|
70
|
-
+ "
|
|
69
|
+
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
|
|
70
|
+
+ "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'.",
|
|
71
|
+
},
|
|
72
|
+
place: {
|
|
73
|
+
type: "UUID",
|
|
74
|
+
required: false,
|
|
75
|
+
description:
|
|
76
|
+
"The Taproot place an article or album is about, found the same way as a place review's placeId. Omit it to "
|
|
77
|
+
+ "leave the page's place as it is; an empty value clears it.",
|
|
71
78
|
},
|
|
72
79
|
rating: {
|
|
73
80
|
type: Object.keys(PLACE_REVIEW_RATINGS).join(" | "),
|
|
@@ -172,7 +179,7 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
172
179
|
{ displayDate: "2023-06-18", coverImageId: EXAMPLE_IMAGE_ID },
|
|
173
180
|
{ body: PARAGRAPH("We parked at the end of the gravel road and walked the last mile.") },
|
|
174
181
|
),
|
|
175
|
-
dataFields: ["body: ProseMirror document (required, not empty)"],
|
|
182
|
+
dataFields: ["body: ProseMirror document (required, not empty)", "place: UUID of a Taproot place (optional; \"\" clears it)"],
|
|
176
183
|
},
|
|
177
184
|
[TEMPLATE_RECIPE]: {
|
|
178
185
|
displayName: "Recipe",
|
|
@@ -240,6 +247,7 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
240
247
|
"introductionBody: ProseMirror document (optional)",
|
|
241
248
|
"images: [{ imageId, caption, width, height }]; at least one",
|
|
242
249
|
"seamless: true | false and borderWidth: 0-20 (optional; omitted inherits the site default)",
|
|
250
|
+
"place: UUID of a Taproot place (optional; \"\" clears it)",
|
|
243
251
|
],
|
|
244
252
|
},
|
|
245
253
|
[TEMPLATE_PLACE_REVIEW]: {
|
package/src/typed-pages.js
CHANGED
|
@@ -54,7 +54,8 @@ const TYPED_PAGE_FRONT_MATTER_KEYS = Object.freeze(["displayDate", "coverImage"]
|
|
|
54
54
|
/** Front-matter fields only one template accepts. Others are refused for it. */
|
|
55
55
|
const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
|
|
56
56
|
[TEMPLATE_FREE_FORM]: Object.freeze([]),
|
|
57
|
-
|
|
57
|
+
// `place` is the Taproot place id an article or album is about (TR01003).
|
|
58
|
+
[TEMPLATE_ARTICLE]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "place"]),
|
|
58
59
|
[TEMPLATE_RECIPE]: Object.freeze([
|
|
59
60
|
...TYPED_PAGE_FRONT_MATTER_KEYS,
|
|
60
61
|
"prepTimeMinutes",
|
|
@@ -64,7 +65,7 @@ const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
|
|
|
64
65
|
"recipeCuisine",
|
|
65
66
|
"cookingMethod",
|
|
66
67
|
]),
|
|
67
|
-
[TEMPLATE_ALBUM]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "seamless", "borderWidth"]),
|
|
68
|
+
[TEMPLATE_ALBUM]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "seamless", "borderWidth", "place"]),
|
|
68
69
|
[TEMPLATE_PLACE_REVIEW]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "placeId", "rating"]),
|
|
69
70
|
});
|
|
70
71
|
|
|
@@ -89,6 +90,8 @@ const RATING_NAME_BY_WIRE = Object.freeze(
|
|
|
89
90
|
// Not `isCanonicalUuid`: that accepts only RFC 4122 versions 1 to 5, and place
|
|
90
91
|
// and image ids are minted by the server, which is free to use any version.
|
|
91
92
|
const IDENTIFIER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
93
|
+
/** An id the server minted, of any UUID version. */
|
|
94
|
+
export const IMAGE_IDENTIFIER = IDENTIFIER;
|
|
92
95
|
const DISPLAY_DATE = /^\d{4}-\d{2}-\d{2}$/u;
|
|
93
96
|
const ALBUM_IMAGES_HEADING = "images";
|
|
94
97
|
const RECIPE_HEADINGS = Object.freeze(["ingredients", "instructions"]);
|
|
@@ -230,7 +233,7 @@ const RECIPE_KEYS = [
|
|
|
230
233
|
"recipeCuisine",
|
|
231
234
|
"cookingMethod",
|
|
232
235
|
];
|
|
233
|
-
const ALBUM_KEYS = ["introductionBody", "images", "seamless", "borderWidth"];
|
|
236
|
+
const ALBUM_KEYS = ["introductionBody", "images", "seamless", "borderWidth", "place"];
|
|
234
237
|
const ALBUM_IMAGE_KEYS = ["imageId", "caption", "width", "height"];
|
|
235
238
|
const ALBUM_MAXIMUM_BORDER_WIDTH = 20;
|
|
236
239
|
const MAXIMUM_MINUTES = 100_000;
|
|
@@ -257,7 +260,7 @@ function canonicalIngredient(value) {
|
|
|
257
260
|
* server's JSON omits defaults and may gain fields).
|
|
258
261
|
*/
|
|
259
262
|
const DATA_BUILDERS = Object.freeze({
|
|
260
|
-
[TEMPLATE_ARTICLE]: (raw) => (
|
|
263
|
+
[TEMPLATE_ARTICLE]: (raw) => definedEntries([["body", raw.body], ["place", raw.place]]),
|
|
261
264
|
[TEMPLATE_PLACE_REVIEW]: (raw) => ({ placeId: raw.placeId, rating: raw.rating, body: raw.body }),
|
|
262
265
|
[TEMPLATE_ALBUM]: (raw) =>
|
|
263
266
|
definedEntries([
|
|
@@ -273,6 +276,7 @@ const DATA_BUILDERS = Object.freeze({
|
|
|
273
276
|
],
|
|
274
277
|
["seamless", raw.seamless],
|
|
275
278
|
["borderWidth", raw.borderWidth],
|
|
279
|
+
["place", raw.place],
|
|
276
280
|
]),
|
|
277
281
|
[TEMPLATE_RECIPE]: (raw) =>
|
|
278
282
|
definedEntries([
|
|
@@ -303,12 +307,22 @@ const DATA_BUILDERS = Object.freeze({
|
|
|
303
307
|
const OPTIONAL_INTRODUCTION = (value, file, path) =>
|
|
304
308
|
value === undefined ? undefined : requireDocument(value, file, path);
|
|
305
309
|
|
|
310
|
+
/** A Taproot place id, or "" to clear the page's place; omitted leaves it as the site holds it. */
|
|
311
|
+
function optionalPlace(value, file, path) {
|
|
312
|
+
if (value === undefined || value === "") return value;
|
|
313
|
+
requireIdentifier(value, file, path);
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
306
317
|
/** Strict checks on a source document's `data`, returning it in canonical form. */
|
|
307
318
|
function canonicalSourceData(template, data, file) {
|
|
308
319
|
const path = "data";
|
|
309
320
|
if (template === TEMPLATE_ARTICLE) {
|
|
310
|
-
requireKeys(data, ["body"], file, path);
|
|
311
|
-
return DATA_BUILDERS[template]({
|
|
321
|
+
requireKeys(data, ["body", "place"], file, path);
|
|
322
|
+
return DATA_BUILDERS[template]({
|
|
323
|
+
body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }),
|
|
324
|
+
place: optionalPlace(data.place, file, `${path}.place`),
|
|
325
|
+
});
|
|
312
326
|
}
|
|
313
327
|
if (template === TEMPLATE_PLACE_REVIEW) {
|
|
314
328
|
requireKeys(data, ["placeId", "rating", "body"], file, path);
|
|
@@ -348,6 +362,7 @@ function canonicalSourceData(template, data, file) {
|
|
|
348
362
|
return DATA_BUILDERS[template]({
|
|
349
363
|
...data,
|
|
350
364
|
introductionBody: OPTIONAL_INTRODUCTION(data.introductionBody, file, `${path}.introductionBody`),
|
|
365
|
+
place: optionalPlace(data.place, file, `${path}.place`),
|
|
351
366
|
});
|
|
352
367
|
}
|
|
353
368
|
requireKeys(data, RECIPE_KEYS, file, path);
|
|
@@ -451,12 +466,15 @@ const presentIntroduction = (value) => {
|
|
|
451
466
|
return doc !== undefined && Array.isArray(doc.content) && doc.content.length === 0 ? undefined : doc;
|
|
452
467
|
};
|
|
453
468
|
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
469
|
+
// The site stores a page's place with its name and address; the workspace keeps only the id.
|
|
470
|
+
const pagePlaceId = (value) =>
|
|
471
|
+
isPlainObject(value) && typeof value.placeId === "string" && value.placeId !== "" ? value.placeId : undefined;
|
|
454
472
|
|
|
455
473
|
/** Lenient projections of what the site returned for each template's data. */
|
|
456
474
|
const PAGE_PROJECTIONS = Object.freeze({
|
|
457
475
|
[TEMPLATE_ARTICLE]: (raw) => {
|
|
458
476
|
const body = presentDocument(raw.body);
|
|
459
|
-
return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body });
|
|
477
|
+
return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body, place: pagePlaceId(raw.place) });
|
|
460
478
|
},
|
|
461
479
|
[TEMPLATE_PLACE_REVIEW]: (raw) => {
|
|
462
480
|
const body = presentDocument(raw.body);
|
|
@@ -470,6 +488,7 @@ const PAGE_PROJECTIONS = Object.freeze({
|
|
|
470
488
|
images: listOf(raw.images).filter((image) => isPlainObject(image) && typeof image.imageId === "string"),
|
|
471
489
|
seamless: typeof raw.seamless === "boolean" ? raw.seamless : undefined,
|
|
472
490
|
borderWidth: Number.isSafeInteger(raw.borderWidth) ? raw.borderWidth : undefined,
|
|
491
|
+
place: pagePlaceId(raw.place),
|
|
473
492
|
}),
|
|
474
493
|
[TEMPLATE_RECIPE]: (raw) =>
|
|
475
494
|
DATA_BUILDERS[TEMPLATE_RECIPE]({
|
|
@@ -723,7 +742,9 @@ export function wireTemplate(document_) {
|
|
|
723
742
|
? { body: document_ }
|
|
724
743
|
: template === TEMPLATE_PLACE_REVIEW
|
|
725
744
|
? { ...document_.data, rating: PLACE_REVIEW_RATINGS[document_.data.rating] }
|
|
726
|
-
: document_.data
|
|
745
|
+
: document_.data.place === undefined
|
|
746
|
+
? document_.data
|
|
747
|
+
: { ...document_.data, place: { placeId: document_.data.place } };
|
|
727
748
|
return { templateType: wireType, templateVersion: FREE_FORM_TEMPLATE_VERSION, [dataKey]: data };
|
|
728
749
|
}
|
|
729
750
|
|
|
@@ -975,7 +996,7 @@ export async function typedDocumentFromMarkdown({ template, fields, markdown, fi
|
|
|
975
996
|
}
|
|
976
997
|
let data;
|
|
977
998
|
if (template === TEMPLATE_ARTICLE) {
|
|
978
|
-
data = { body: await convert(markdown) };
|
|
999
|
+
data = { body: await convert(markdown), place: fields.get("place") };
|
|
979
1000
|
} else if (template === TEMPLATE_PLACE_REVIEW) {
|
|
980
1001
|
const placeId = fields.get("placeId");
|
|
981
1002
|
const rating = fields.get("rating");
|
|
@@ -993,6 +1014,7 @@ export async function typedDocumentFromMarkdown({ template, fields, markdown, fi
|
|
|
993
1014
|
...(await parseAlbumBody(markdown, { file, convert, resolveImage })),
|
|
994
1015
|
seamless: parseFlag(fields, "seamless", file),
|
|
995
1016
|
borderWidth: parseCount(fields, "borderWidth", file, ALBUM_MAXIMUM_BORDER_WIDTH),
|
|
1017
|
+
place: fields.get("place"),
|
|
996
1018
|
};
|
|
997
1019
|
} else {
|
|
998
1020
|
data = {
|