@taprootio/site-authoring 0.14.1 → 0.16.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.
Files changed (49) hide show
  1. package/README.md +181 -16
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +123 -1
  6. package/src/authors-contract.js +170 -0
  7. package/src/cli.js +355 -30
  8. package/src/constants.js +9 -0
  9. package/src/content/components.js +64 -6
  10. package/src/content/free-form-sections.js +21 -4
  11. package/src/content/link-warnings.js +109 -0
  12. package/src/content/markdown.js +161 -26
  13. package/src/content/tiptap-prosemirror.ts +66 -8
  14. package/src/content/validate-document.js +30 -6
  15. package/src/content/vocabulary.js +26 -0
  16. package/src/errors.js +22 -0
  17. package/src/espalier-shared.js +11 -0
  18. package/src/fixture-init.js +55 -3
  19. package/src/font-catalog.js +87 -0
  20. package/src/footer-contract.js +1 -1
  21. package/src/output.js +19 -1
  22. package/src/page-metadata.js +92 -0
  23. package/src/presentation-reference.js +21 -7
  24. package/src/problems.js +116 -0
  25. package/src/reference-help.js +157 -41
  26. package/src/session.js +13 -0
  27. package/src/staging-check.js +62 -0
  28. package/src/theme-proposals.js +393 -0
  29. package/src/theme-validation.js +31 -10
  30. package/src/typed-page-reference.js +97 -21
  31. package/src/typed-pages.js +196 -13
  32. package/src/verbs/approve.js +63 -14
  33. package/src/verbs/authors.js +167 -0
  34. package/src/verbs/deploy.js +5 -28
  35. package/src/verbs/index.js +22 -0
  36. package/src/verbs/media-upload.js +127 -30
  37. package/src/verbs/nav-push.js +7 -0
  38. package/src/verbs/pages-meta-set.js +303 -0
  39. package/src/verbs/pages-push.js +814 -224
  40. package/src/verbs/places.js +61 -0
  41. package/src/verbs/plan.js +595 -0
  42. package/src/verbs/pull.js +255 -20
  43. package/src/verbs/redirects-check.js +4 -10
  44. package/src/verbs/theme-push.js +68 -36
  45. package/src/verbs/validate.js +309 -217
  46. package/src/video-prepare.js +48 -12
  47. package/src/workspace-check.js +199 -0
  48. package/src/workspace-ledger.js +173 -0
  49. package/src/workspace.js +105 -10
@@ -152,3 +152,65 @@ export async function checkStagingRedirects(client, siteId, onProgress, now) {
152
152
  },
153
153
  };
154
154
  }
155
+
156
+ /**
157
+ * The staging redirect check and a fresh preview handoff, as redirects check and
158
+ * deploy --staging report them. A check that cannot finish keeps the same shape,
159
+ * with the reason.
160
+ */
161
+ export async function inspectStagingPreview(client, siteId, onProgress, now) {
162
+ let routeCheck;
163
+ let redirects;
164
+ let stagingUrl;
165
+ try {
166
+ ({ routeCheck, redirects, stagingUrl } = await checkStagingRedirects(client, siteId, onProgress, now));
167
+ if (!redirects.verified) {
168
+ onProgress(
169
+ "Warning: staging redirects are not all verified. Check the reported mismatches against the staged release; run 'taproot-site redirects check' again once the deployment has completed.",
170
+ );
171
+ }
172
+ } catch (error) {
173
+ onProgress(
174
+ "Warning: the authenticated staging redirect check could not finish. Run 'taproot-site redirects check' again.",
175
+ );
176
+ routeCheck = "unresolved";
177
+ redirects = {
178
+ total: 0,
179
+ checked: 0,
180
+ matched: 0,
181
+ failed: 0,
182
+ verified: false,
183
+ items: [],
184
+ error: redirectCheckError(error),
185
+ };
186
+ }
187
+ try {
188
+ // Mint after checks so the final single-use URL has never been consumed.
189
+ const stagingPreview = await mintStagingPreviewHandoff(client, siteId, { now });
190
+ return { routeCheck, redirects, stagingPreview };
191
+ } catch {
192
+ onProgress("Warning: a staging handoff could not be minted.");
193
+ return {
194
+ routeCheck,
195
+ redirects,
196
+ stagingPreview: {
197
+ url: "",
198
+ ...(stagingUrl ? { stagingUrl } : {}),
199
+ warning: "Staging handoff unavailable; run redirects check to retry.",
200
+ },
201
+ };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Why the automatic redirect check could not finish. Only this CLI's own
207
+ * errors carry their code and message; anything else (a timeout, a network
208
+ * failure) is named, never echoed, since its text is not ours to publish.
209
+ */
210
+ function redirectCheckError(error) {
211
+ if (error instanceof SiteAuthoringError) {
212
+ return { code: error.code, message: sanitizeDiagnostic(error.message, "The redirect check could not finish.") };
213
+ }
214
+ const name = typeof error?.name === "string" && /^[A-Za-z]{1,40}$/u.test(error.name) ? error.name : "Error";
215
+ return { code: "redirects.check_failed", message: `The redirect check could not finish (${name}).` };
216
+ }
@@ -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
+ }
@@ -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 ??= import(new URL("./theme-fit-report.js", import.meta.resolve("@taprootio/espalier/shared/theme")).href);
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
- const { ROOT_SURFACE, themeFitReportSuite } = await loadFitReport();
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 [`Espalier could not build the fit report, so no fit lints were checked: ${error?.message ?? error}`];
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 warnings = [];
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
- warnings.push(`${scheme}: ${surface}fit lint ${lint.id} — ${lint.message}`);
453
+ items.push({ scheme, report, lint, text: `${scheme}: ${surface}fit lint ${lint.id} — ${lint.message}` });
442
454
  }
443
455
  }
444
456
  }
445
- return warnings;
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
- const lints = await fitLintWarnings(light, dark);
479
- return { light, dark, ...boundedThemeWarnings([...warnings, ...lints, ...inert]) };
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) {