@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.
@@ -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) {
@@ -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; this "
70
- + "CLI does not search for places.",
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]: {
@@ -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
- [TEMPLATE_ARTICLE]: TYPED_PAGE_FRONT_MATTER_KEYS,
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) => ({ body: raw.body }),
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]({ body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }) });
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 = {