@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
@@ -367,14 +367,7 @@ function wrapMark(html: string, mark: ProseMirrorMark): string {
367
367
  case "link": {
368
368
  const href = stringAttr(mark.attrs?.href);
369
369
  if (!href || !isSafeUrl(href)) return html;
370
- return renderElement(
371
- "a",
372
- {
373
- href,
374
- rel: "noopener noreferrer nofollow",
375
- },
376
- html,
377
- );
370
+ return renderElement("a", { href, ...linkRelAttributes(mark.attrs) }, html);
378
371
  }
379
372
  default:
380
373
  return html;
@@ -1377,6 +1370,71 @@ function serializeAttributes(attrs: Record<string, string | number | boolean | n
1377
1370
  return serialized.length ? ` ${serialized.join(" ")}` : "";
1378
1371
  }
1379
1372
 
1373
+ /**
1374
+ * The rel tokens a link may carry (TR01190): every HTML link type valid on
1375
+ * `<a>` except `opener`, which hands a new tab a handle on the page.
1376
+ */
1377
+ export const LINK_REL_TOKENS = [
1378
+ "nofollow",
1379
+ "sponsored",
1380
+ "ugc",
1381
+ "me",
1382
+ "noopener",
1383
+ "noreferrer",
1384
+ "external",
1385
+ "author",
1386
+ "license",
1387
+ "tag",
1388
+ "bookmark",
1389
+ "help",
1390
+ "prev",
1391
+ "next",
1392
+ "privacy-policy",
1393
+ "terms-of-service",
1394
+ ] as const;
1395
+ /** The targets a link may carry; only `_blank` changes what renders. */
1396
+ export const LINK_TARGETS = ["_blank", "_self"] as const;
1397
+ /**
1398
+ * The editor stored this rel, with `target="_blank"`, on every link before
1399
+ * authors could choose, so a mark carrying it records no choice of either.
1400
+ */
1401
+ export const LEGACY_EDITOR_LINK_REL = "noopener noreferrer nofollow";
1402
+
1403
+ /**
1404
+ * An author's chosen tokens as a stored rel. Those three tokens in the old
1405
+ * default's order would read as no choice, so they are stored in vocabulary
1406
+ * order instead.
1407
+ */
1408
+ export function spellLinkRel(tokens: readonly string[]): string {
1409
+ const rel = tokens.join(" ");
1410
+ return rel === LEGACY_EDITOR_LINK_REL
1411
+ ? LINK_REL_TOKENS.filter((token) => tokens.includes(token)).join(" ")
1412
+ : rel;
1413
+ }
1414
+
1415
+ /** A stored rel as tokens, or undefined when it is the editor's old default (no choice). */
1416
+ export function chosenLinkRelTokens(rel: unknown): string[] | undefined {
1417
+ const tokens = stringAttr(rel)?.trim().toLowerCase().split(/\s+/u).filter(Boolean) ?? [];
1418
+ return tokens.join(" ") === LEGACY_EDITOR_LINK_REL ? undefined : tokens;
1419
+ }
1420
+
1421
+ /**
1422
+ * The `rel` and `target` a link publishes with: the author's allowed tokens in
1423
+ * their order, and noopener and noreferrer whenever it opens a new tab.
1424
+ * Anything else stored is ignored.
1425
+ */
1426
+ export function linkRelAttributes(attrs: Record<string, unknown> | undefined): { rel?: string; target?: string } {
1427
+ const stored = chosenLinkRelTokens(attrs?.rel);
1428
+ if (!stored) return {};
1429
+ const newTab = stringAttr(attrs?.target)?.trim().toLowerCase() === "_blank";
1430
+ const allowed: readonly string[] = LINK_REL_TOKENS;
1431
+ const rel = new Set([
1432
+ ...stored.filter((token) => allowed.includes(token)),
1433
+ ...(newTab ? ["noopener", "noreferrer"] : []),
1434
+ ]);
1435
+ return { ...(rel.size ? { rel: [...rel].join(" ") } : {}), ...(newTab ? { target: "_blank" } : {}) };
1436
+ }
1437
+
1380
1438
  function isSafeUrl(url: string): boolean {
1381
1439
  if (/[\x00-\x1f\x7f\\]/u.test(url)) return false;
1382
1440
  const cleaned = url.trim();
@@ -18,6 +18,8 @@ import {
18
18
  isPlainObject,
19
19
  isSafeUrl,
20
20
  isUuid,
21
+ LINK_REL_TOKENS,
22
+ LINK_TARGETS,
21
23
  MARK_TYPE_SET,
22
24
  NODE_TYPE_SET,
23
25
  normalizeCssLengthOverride,
@@ -485,12 +487,34 @@ const MARK_RULES = Object.freeze({
485
487
  { required: true, code: CODES.linkHref, missingCode: CODES.linkHref },
486
488
  ),
487
489
  // `@tiptap/extension-link` declares these four alongside `href`, and
488
- // `Mark.toJSON` emits every declared attribute — so every link the editor
489
- // has ever written carries all five, and refusing any of them would refuse
490
- // an ordinary pull/push round trip. The renderer ignores them and writes
491
- // its own `rel`.
492
- target: optionalString(),
493
- rel: optionalString(),
490
+ // `Mark.toJSON` emits every declared attribute, so every link the editor
491
+ // has ever written carries all five. The renderer publishes `rel` and
492
+ // `target="_blank"` from closed lists (TR01190), so those two are held to
493
+ // them; it ignores `class` and `title`. The editor's old default rel,
494
+ // "noopener noreferrer nofollow", is within the list and renders as no
495
+ // choice.
496
+ target: spec(
497
+ (value) => {
498
+ if (isAbsent(value) || (typeof value === "string" && value.trim() === "")) return null;
499
+ return typeof value === "string" && LINK_TARGETS.includes(value.trim().toLowerCase())
500
+ ? null
501
+ : `"_blank" (open in a new tab), "_self", or absent`;
502
+ },
503
+ { code: CODES.linkTarget },
504
+ ),
505
+ rel: spec(
506
+ (value) => {
507
+ if (isAbsent(value)) return null;
508
+ if (typeof value !== "string") return "a string of space-separated link types";
509
+ const unknown = value.trim().toLowerCase().split(/\s+/u)
510
+ .filter((token) => token && !LINK_REL_TOKENS.includes(token));
511
+ if (unknown.length === 0) return null;
512
+ return `space-separated link types from ${LINK_REL_TOKENS.join(", ")}; ${
513
+ unknown.map((token) => `"${token}"`).join(", ")
514
+ } ${unknown.length === 1 ? "is" : "are"} not one`;
515
+ },
516
+ { code: CODES.linkRel },
517
+ ),
494
518
  class: optionalString(),
495
519
  title: optionalString(),
496
520
  }),
@@ -75,6 +75,30 @@ export const MARK_TYPES = Object.freeze([
75
75
  "link",
76
76
  ]);
77
77
 
78
+ // The renderer's `LINK_REL_TOKENS` and `LINK_TARGETS` (TR01190): every HTML
79
+ // link type valid on `<a>` except `opener`. The renderer publishes only these
80
+ // and ignores the rest, so the CLI refuses the rest. The parity test reads
81
+ // both lists from the copied renderer.
82
+ export const LINK_REL_TOKENS = Object.freeze([
83
+ "nofollow",
84
+ "sponsored",
85
+ "ugc",
86
+ "me",
87
+ "noopener",
88
+ "noreferrer",
89
+ "external",
90
+ "author",
91
+ "license",
92
+ "tag",
93
+ "bookmark",
94
+ "help",
95
+ "prev",
96
+ "next",
97
+ "privacy-policy",
98
+ "terms-of-service",
99
+ ]);
100
+ export const LINK_TARGETS = Object.freeze(["_blank", "_self"]);
101
+
78
102
  export const NODE_TYPE_SET = Object.freeze(new Set(NODE_TYPES));
79
103
  export const MARK_TYPE_SET = Object.freeze(new Set(MARK_TYPES));
80
104
 
@@ -108,6 +132,8 @@ export const CONTENT_ERROR_CODES = Object.freeze({
108
132
  markMisplaced: "content.mark_misplaced",
109
133
  unknownMark: "content.unknown_mark",
110
134
  linkHref: "content.link_href",
135
+ linkRel: "content.link_rel",
136
+ linkTarget: "content.link_target",
111
137
  // Attributes
112
138
  attrsInvalid: "content.attrs_invalid",
113
139
  attrUnknown: "content.attr_unknown",
package/src/errors.js CHANGED
@@ -91,6 +91,8 @@ export function sanitizeDiagnostic(value, fallback = "The operation failed.") {
91
91
  return [...clean].slice(0, LIMITS.diagnosticScalars).join("") || fallback;
92
92
  }
93
93
 
94
+ const MAXIMUM_PROBLEMS = 5_000;
95
+
94
96
  /**
95
97
  * The fixed wording every `theme.setting_missing` detail carries. A required
96
98
  * key that is absent is exactly that: absence alone says nothing about when
@@ -175,6 +177,26 @@ export class SiteAuthoringError extends Error {
175
177
  return this;
176
178
  }
177
179
 
180
+ /**
181
+ * Attach every problem a whole-workspace check found (TR01002). The JSON
182
+ * result carries a bounded prefix and the count; stderr lists them all.
183
+ */
184
+ withProblems(problems) {
185
+ const list = Array.isArray(problems) ? problems : [];
186
+ this.problems = list
187
+ .slice(0, MAXIMUM_PROBLEMS)
188
+ .filter((problem) => typeof problem?.code === "string")
189
+ .map((problem) => ({
190
+ ...(typeof problem.area === "string" ? { area: sanitizeDiagnostic(problem.area, "") } : {}),
191
+ ...(typeof problem.file === "string" ? { file: sanitizeDiagnostic(problem.file, "") } : {}),
192
+ code: /^[a-z0-9_.-]+$/u.test(problem.code) ? problem.code : "site.failed",
193
+ ...(typeof problem.field === "string" ? { field: sanitizeDiagnostic(problem.field, "") } : {}),
194
+ message: sanitizeDiagnostic(problem.message),
195
+ }));
196
+ this.problemCount = list.length;
197
+ return this;
198
+ }
199
+
178
200
  /** Attach the non-secret identity needed to inspect or revoke a created preview. */
179
201
  withPreviewRecovery(preview) {
180
202
  const recovery = normalizePreviewRecovery(preview);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * An Espalier module that sits beside the exported theme module but is not
3
+ * exported itself (the fit report, the color engine). Its package root
4
+ * re-exports every component module, which registers custom elements as it
5
+ * loads, so these are loaded from beside the theme module instead, sharing its
6
+ * instance. The exact Espalier pin and this package's tests catch a release
7
+ * that moves them.
8
+ */
9
+ export function loadEspalierShared(file) {
10
+ return import(new URL(`./${file}`, import.meta.resolve("@taprootio/espalier/shared/theme")).href);
11
+ }
@@ -15,6 +15,8 @@ import {
15
15
  MANIFEST_FILE_NAME,
16
16
  MANIFEST_VERSION,
17
17
  PAGE_WORKSPACE_MODE_EDITABLE,
18
+ normalizePagePath,
19
+ PAGE_WORKSPACE_MODE_METADATA_ONLY,
18
20
  readManifest,
19
21
  readMediaManifest,
20
22
  readWorkspaceJson,
@@ -143,10 +145,14 @@ export async function initializeFixture(invocation, validate) {
143
145
  status: entry.status,
144
146
  templateType: entry.templateType,
145
147
  hasDraft: entry.hasDraft,
146
- isGenerated: false,
148
+ isGenerated: entry.isGenerated === true,
147
149
  file,
148
150
  sourceFormat: "prosemirror",
149
151
  workspaceMode: "editable",
152
+ // What a generated page is, which its identity check needs offline.
153
+ ...(entry.generated !== null && typeof entry.generated === "object" && !Array.isArray(entry.generated)
154
+ ? { generated: entry.generated }
155
+ : {}),
150
156
  }));
151
157
  add(file, sanitize(page.document));
152
158
  }
@@ -194,6 +200,44 @@ export async function initializeFixture(invocation, validate) {
194
200
  ),
195
201
  );
196
202
  }
203
+ // Navigation and footer links may point at pages the fixture has no source
204
+ // for, such as generated section pages; they travel as metadata-only entries
205
+ // so the fixture validates as the site does (TR01198).
206
+ const editablePageCount = pages.length;
207
+ const linkedResourceIds = new Set();
208
+ (function collect(value) {
209
+ if (Array.isArray(value)) value.forEach(collect);
210
+ else if (value && typeof value === "object") {
211
+ for (const [key, child] of Object.entries(value)) {
212
+ if ((key === "resourceId" || key === "pageResourceId") && typeof child === "string") {
213
+ linkedResourceIds.add(child);
214
+ } else collect(child);
215
+ }
216
+ }
217
+ })([...documents.values()]);
218
+ for (const entry of source.pages) {
219
+ if (entry.workspaceMode !== PAGE_WORKSPACE_MODE_METADATA_ONLY) continue;
220
+ if (!isCanonicalUuid(entry.pageId) || !isCanonicalUuid(entry.resourceId)) continue;
221
+ if (!linkedResourceIds.has(identity(entry.resourceId))) continue;
222
+ const linkedPath = normalizePagePath(entry.path);
223
+ if (linkedPath === undefined || linkedPath === "") {
224
+ refuse(
225
+ `A page that is linked to has no usable path of its own (${JSON.stringify(entry.path ?? null)}); pull again.`,
226
+ "pages",
227
+ );
228
+ }
229
+ pages.push(sanitize({
230
+ pageId: entry.pageId,
231
+ resourceId: entry.resourceId,
232
+ path: linkedPath,
233
+ title: typeof entry.title === "string" ? entry.title : "",
234
+ status: entry.status,
235
+ templateType: entry.templateType,
236
+ hasDraft: entry.hasDraft === true,
237
+ isGenerated: entry.isGenerated === true,
238
+ workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
239
+ }));
240
+ }
197
241
  const redirects = documents.get("redirects.json");
198
242
  // A fixture hash is a local placeholder, not authority to mutate the original site.
199
243
  const revision = createHash("sha256").update(JSON.stringify(redirects.entries)).digest("hex");
@@ -242,10 +286,18 @@ export async function initializeFixture(invocation, validate) {
242
286
  try {
243
287
  for (const [file, value] of documents) await writeWorkspaceJson(destination, file, value);
244
288
  const result = await validate({ ...invocation, init: false, cwd, fixturePath: destination });
245
- invocation.onProgress?.(`Initialized a sanitized fixture with ${pages.length} editable page(s).`);
289
+ invocation.onProgress?.(
290
+ `Initialized a sanitized fixture with ${editablePageCount} editable page(s)`
291
+ + `${pages.length > editablePageCount ? ` and ${pages.length - editablePageCount} linked page(s)` : ""}.`,
292
+ );
246
293
  return {
247
294
  ...result,
248
- initialized: { directory: destination, pages: pages.length, skippedPages: source.pages.length - pages.length },
295
+ initialized: {
296
+ directory: destination,
297
+ pages: editablePageCount,
298
+ linkedPages: pages.length - editablePageCount,
299
+ skippedPages: source.pages.length - pages.length,
300
+ },
249
301
  };
250
302
  } catch (error) {
251
303
  await rm(destination, { recursive: true, force: true });
@@ -0,0 +1,87 @@
1
+ import definitions from "@taprootio/espalier/css/fonts/font-definitions.json" with { type: "json" };
2
+
3
+ import { CLI_BINARY_NAME } from "./constants.js";
4
+
5
+ /**
6
+ * `help fonts` (TR01188): the font families a published site can load, read
7
+ * from the catalog Espalier ships with the CLI. A theme's font fields name a
8
+ * family from it; the generator fetches that family's faces at publish time.
9
+ *
10
+ * The whole catalog is larger than one result may be, so the topic lists the
11
+ * categories and `help fonts <category>` lists one category's families.
12
+ */
13
+
14
+ /** A catalog variant is `regular`, `italic`, or a weight with an optional `italic`. */
15
+ function variantWeight(variant) {
16
+ return variant === "regular" || variant === "italic" ? 400 : Number.parseInt(variant, 10);
17
+ }
18
+
19
+ // Only upright weights: a publish loads one upright face per font field, and
20
+ // the browser slants it for italics.
21
+ const FAMILIES = Object.freeze(definitions.map((definition) => Object.freeze({
22
+ family: definition.family,
23
+ category: definition.category,
24
+ weights: Object.freeze(definition.variants.filter((variant) => !variant.endsWith("italic")).map(variantWeight)),
25
+ })));
26
+
27
+ export const FONT_CATEGORIES = Object.freeze([...new Set(FAMILIES.map((entry) => entry.category))]);
28
+
29
+ const DETAILS = Object.freeze([
30
+ "Set fontBody, fontHeadings, fontBrand, fontMonospace or fontMenu in settings/taproot-styles.json to a CSS "
31
+ + "font-family list that starts with a family from this catalog, spelled exactly as listed (the match is "
32
+ + "case-sensitive), such as \"Inter, sans-serif\". Finish with a CSS generic family: sans-serif, serif or "
33
+ + "monospace, or cursive for a handwriting family. A first family that is not listed is not loaded: the page "
34
+ + "falls back to the rest of the list, so a misspelled name publishes quietly in a fallback font.",
35
+ "Each font field's weight (fontWeightBody and the others) must be one of that family's weights; normal means 400 "
36
+ + "and bold 700. Any other weight, or lighter or bolder, would fail the publish for a catalog family, so validate "
37
+ + "and theme push refuse it and name the nearest weight the family has. An unset weight "
38
+ + "takes the default: 400 for body and monospace, 700 for headings, brand and menu (brand also inherits the "
39
+ + "headings family). So when a family lacks the default weight for its field, set that field's weight.",
40
+ "A publish loads one upright face per font field and serves it from the site itself; visitors never contact a "
41
+ + "font service. Italic text is the browser slanting that face. A family with no weights offers only italics and "
42
+ + "cannot be used.",
43
+ ]);
44
+
45
+ /** The catalog's categories, or one category's families. */
46
+ export function getFontCatalogReference(category) {
47
+ const base = {
48
+ title: "Font catalog",
49
+ summary: "The font families a published site can load, by category, with the weights each offers.",
50
+ usage: `${CLI_BINARY_NAME} help fonts [category] [--json]`,
51
+ details: DETAILS,
52
+ };
53
+ if (category === undefined) {
54
+ return Object.freeze({
55
+ ...base,
56
+ familyCount: FAMILIES.length,
57
+ categories: Object.freeze(FONT_CATEGORIES.map((name) => Object.freeze({
58
+ category: name,
59
+ familyCount: FAMILIES.filter((entry) => entry.category === name).length,
60
+ helpCommand: `${CLI_BINARY_NAME} help fonts ${name}`,
61
+ }))),
62
+ });
63
+ }
64
+ if (!FONT_CATEGORIES.includes(category)) return undefined;
65
+ const families = FAMILIES.filter((entry) => entry.category === category)
66
+ .map(({ family, weights }) => Object.freeze({ family, weights }));
67
+ return Object.freeze({ ...base, category, familyCount: families.length, families: Object.freeze(families) });
68
+ }
69
+
70
+ export function formatFontCatalog(reference) {
71
+ const header = `${reference.title}\n${reference.summary}\n\nUsage: ${reference.usage}\n\n${
72
+ reference.details.map((detail) => `- ${detail}`).join("\n")
73
+ }\n\n`;
74
+ if (reference.families === undefined) {
75
+ return `${header}${reference.familyCount} families:\n${
76
+ reference.categories.map((entry) =>
77
+ ` ${entry.category.padEnd(14)}${String(entry.familyCount).padStart(4)} ${entry.helpCommand}`
78
+ ).join("\n")
79
+ }\n\nAdd --json for stable machine-readable output.\n`;
80
+ }
81
+ const width = Math.max(...reference.families.map(({ family }) => family.length)) + 2;
82
+ return `${header}${reference.familyCount} ${reference.category} families and their weights:\n${
83
+ reference.families.map(({ family, weights }) =>
84
+ ` ${family.padEnd(width)}${weights.length > 0 ? weights.join(" ") : "none (italic only; cannot be used)"}`
85
+ ).join("\n")
86
+ }\n\nAdd --json for the same list as stable machine-readable output.\n`;
87
+ }
@@ -235,7 +235,7 @@ function target(value, path, knownPageResourceIds, tolerateStoredValues) {
235
235
  if (knownPageResourceIds && !knownPageResourceIds.has(value.pageResourceId)) {
236
236
  fail(
237
237
  "footer.page_reference_unknown",
238
- `${path}.pageResourceId must identify a page tracked by the pull manifest.`,
238
+ `${path}.pageResourceId must identify a page tracked by the pull manifest that the site still holds.`,
239
239
  `${path}.pageResourceId`,
240
240
  );
241
241
  }
package/src/output.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  sanitizeDiagnostic,
20
20
  SiteAuthoringError,
21
21
  } from "./errors.js";
22
+ import { problemsWithinBudget } from "./problems.js";
22
23
 
23
24
  const SAFE_SCALAR = /^[a-z][a-z0-9]*(?: [a-z][a-z0-9]*)?$/u;
24
25
  const MAXIMUM_OUTPUT_PATH_LENGTH = 4_096;
@@ -53,6 +54,13 @@ export function failureResult(error) {
53
54
  if (Array.isArray(error.differences)) {
54
55
  result.error.differences = error.differences;
55
56
  }
57
+ if (Array.isArray(error.problems) && error.problems.length > 0) {
58
+ const problems = problemsWithinBudget(error.problems);
59
+ const count = Math.max(error.problemCount ?? 0, error.problems.length);
60
+ result.error.problems = problems;
61
+ result.error.problemCount = count;
62
+ if (problems.length < count) result.error.problemsTruncated = true;
63
+ }
56
64
  const previewDiagnostic = normalizePreviewDiagnostic(error.previewDiagnostic);
57
65
  if (previewDiagnostic) result.error.diagnostic = previewDiagnostic;
58
66
  const previewRecovery = normalizePreviewRecovery(error.previewRecovery);
@@ -159,6 +167,12 @@ export async function writeGithubActionsOutput(outputPath, result) {
159
167
  await appendInspectedGithubOutput(outputPath, stats, `${lines.join("\n")}\n`);
160
168
  }
161
169
 
170
+ function problemLocation(problem) {
171
+ if (problem.file === undefined) return problem.field ?? problem.area ?? "-";
172
+ if (problem.field === undefined || problem.field.startsWith(problem.file)) return problem.field ?? problem.file;
173
+ return `${problem.file} ${problem.field}`;
174
+ }
175
+
162
176
  export function humanFailure(error) {
163
177
  const field = error.field ? ` field=${sanitizeDiagnostic(error.field, "unknown")}` : "";
164
178
  const status = error.status ? ` status=${sanitizeDiagnostic(error.status, "unknown")}` : "";
@@ -167,8 +181,12 @@ export function humanFailure(error) {
167
181
  ? ` refusal=${sanitizeDiagnostic(kind, "unknown")}`
168
182
  : "";
169
183
  const details = error.details?.map((item) => `${item.field}: ${item.message}`).join("\n");
184
+ const problems = error.problems?.length > 1
185
+ ? error.problems.map((problem) => ` ${problemLocation(problem)} [${problem.code}]: ${problem.message}`).join("\n")
186
+ : undefined;
170
187
  return `${CLI_BINARY_NAME} failed [${error.code}]${field}${status}${refusal}: ${sanitizeDiagnostic(error.message)}`
171
- + (details ? `\n${details}` : "");
188
+ + (details ? `\n${details}` : "")
189
+ + (problems ? `\n${problems}` : "");
172
190
  }
173
191
 
174
192
  export function serializeResult(result) {
@@ -0,0 +1,92 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+ import { boundedByBytes } from "./session.js";
3
+ import { displayPagePath } from "./workspace.js";
4
+
5
+ /**
6
+ * A page description is what search results and link previews show. Past about
7
+ * 160 characters search engines cut it off, so it is warned about; past 1000 it
8
+ * is refused, here and by the API (TR01194).
9
+ */
10
+ export const DESCRIPTION_WARN_LENGTH = 160;
11
+ export const DESCRIPTION_MAXIMUM_LENGTH = 1000;
12
+ export const PAGE_DESCRIPTION_LONG = "pages.description_long";
13
+
14
+ const length = (value) => [...value].length;
15
+
16
+ /** Refuses a description the API would refuse. */
17
+ export function requireDescriptionLength(description, field = "description") {
18
+ if (typeof description === "string" && length(description) > DESCRIPTION_MAXIMUM_LENGTH) {
19
+ throw new SiteAuthoringError(
20
+ "pages.description_too_long",
21
+ `The description is ${length(description)} characters; it may be at most ${DESCRIPTION_MAXIMUM_LENGTH} `
22
+ + `(search results show about ${DESCRIPTION_WARN_LENGTH}).`,
23
+ { field },
24
+ );
25
+ }
26
+ }
27
+
28
+ /** Warnings for descriptions search results will cut off, named per page. */
29
+ export function descriptionWarnings(pages, onProgress) {
30
+ const items = [];
31
+ for (const page of pages ?? []) {
32
+ const description = page.declaredDescription ?? page.description;
33
+ if (typeof description !== "string" || length(description) <= DESCRIPTION_WARN_LENGTH) continue;
34
+ const item = {
35
+ code: PAGE_DESCRIPTION_LONG,
36
+ file: page.file,
37
+ path: displayPagePath(page.pagePath ?? page.path),
38
+ length: length(description),
39
+ };
40
+ onProgress(
41
+ `Warning: ${item.file}'s description is ${item.length} characters; search results show about `
42
+ + `${DESCRIPTION_WARN_LENGTH}.`,
43
+ );
44
+ items.push(item);
45
+ }
46
+ const reported = boundedByBytes(items, 8 * 1024);
47
+ return items.length === 0
48
+ ? {}
49
+ : {
50
+ descriptionWarnings: {
51
+ total: items.length,
52
+ items: reported.items,
53
+ ...(reported.truncated ? { truncated: true } : {}),
54
+ },
55
+ };
56
+ }
57
+
58
+ export const PAGE_AUTHOR_UNVERIFIED = "pages.author_unverified";
59
+
60
+ /**
61
+ * Warnings for authors an offline run could not confirm (TR01196). The only
62
+ * evidence offline is `authors.json`, which is as old as the last `pull` or
63
+ * `authors list`, so a name missing from it may simply be newer; it is flagged
64
+ * rather than refused, and an online run settles it against the site.
65
+ */
66
+ export function authorWarnings(pages, onProgress) {
67
+ const items = [];
68
+ for (const page of pages ?? []) {
69
+ if (page.authorUnverified !== true) continue;
70
+ const item = {
71
+ code: PAGE_AUTHOR_UNVERIFIED,
72
+ file: page.file,
73
+ path: displayPagePath(page.pagePath ?? page.path),
74
+ author: page.author,
75
+ };
76
+ onProgress(
77
+ `Warning: ${item.file} names author '${item.author}', who is not in authors.json; it may have been added `
78
+ + "since. 'taproot-site authors list' refreshes it, and 'pages push' checks the name against the site.",
79
+ );
80
+ items.push(item);
81
+ }
82
+ const reported = boundedByBytes(items, 8 * 1024);
83
+ return items.length === 0
84
+ ? {}
85
+ : {
86
+ authorWarnings: {
87
+ total: items.length,
88
+ items: reported.items,
89
+ ...(reported.truncated ? { truncated: true } : {}),
90
+ },
91
+ };
92
+ }
@@ -227,14 +227,25 @@ const THEME_GROUPS = Object.freeze([
227
227
  summary: "Choose families and weights, then tune type, spacing, radius, and fluid viewport scales together.",
228
228
  fields: Object.freeze([
229
229
  ...["fontBody", "fontHeadings", "fontBrand", "fontMonospace"].map((name) =>
230
- themeField(name, "CSS font-family; empty inherits the consuming surface's fallback.")
230
+ themeField(
231
+ name,
232
+ "CSS font-family starting with a family from 'help fonts'; an unlisted family is not loaded. Empty "
233
+ + "inherits the consuming surface's fallback.",
234
+ )
231
235
  ),
232
236
  themeField(
233
237
  "fontMenu",
234
- "CSS font-family for navigation menu items and group labels; empty falls back to fontBody, never to headings.",
238
+ "CSS font-family for navigation menu items and group labels, starting with a family from 'help fonts'; "
239
+ + "empty falls back to fontBody, never to headings.",
235
240
  ),
236
241
  ...["fontWeightBody", "fontWeightHeadings", "fontWeightBrand", "fontWeightMonospace", "fontWeightMenu"].map(
237
- (name) => themeField(name, "CSS font-weight: 1–1000, normal/bold/lighter/bolder, or a CSS-wide keyword."),
242
+ (name) =>
243
+ themeField(
244
+ name,
245
+ "CSS font-weight. For a catalog family it must be one of the family's weights in 'help fonts' "
246
+ + "(normal is 400, bold 700); validate and theme push refuse any other value, which would fail the "
247
+ + "publish. Unset, it defaults to 400 for body and monospace and 700 for headings, brand and menu.",
248
+ ),
238
249
  ),
239
250
  themeField("rootFontSize", "Root font size in px.", { minimum: 1, warningMaximum: 100 }),
240
251
  themeField("typeRatio", "Modular type-scale ratio.", { exclusiveMinimum: 1, warningMaximum: 1.3 }),
@@ -323,11 +334,14 @@ export const ROLE_RESOLUTION = Object.freeze({
323
334
  + "makes a mapping a pin: the resolver recompiles every token the marker leaves out, so a mapping it does not "
324
335
  + "name is a cached value that is never applied. To pin a token, set the mapping and add the token to the "
325
336
  + "marker; to let a role reach it, drop the token from the marker or delete the mapping. A document that "
326
- + "carries no explicitMappingTokens at all predates the marker, and every mapping in it is still a pin.",
337
+ + "carries no explicitMappingTokens at all predates the marker, and every mapping in it is still a pin. The "
338
+ + "marker is the root's alone: a mapping in a context's semanticMappings is a pin by itself, needs no marker, "
339
+ + "and a context cannot carry one.",
327
340
  "Otherwise the token is compiled from the roles, and an APCA contrast check may nudge an action or ink stop so "
328
341
  + "the pair stays readable.",
329
342
  "Otherwise the Espalier default mapping applies.",
330
- "A context rebinds roles, lightness, tones and pins for one zone with the same precedence inside that zone.",
343
+ "A context rebinds roles, lightness, tones and pins for one zone with the same precedence inside that zone; its "
344
+ + "pins need no explicitMappingTokens.",
331
345
  ]),
332
346
  });
333
347
 
@@ -392,12 +406,12 @@ export function getThemeReference() {
392
406
  "Check the filled action before anything else looks finished. Its ramp stop is chosen by the engine near a mid-band target rather than named by the theme, so seating the ramp — the remedy for every other anchored token — cannot move it, and declaring an action role does not either. The only lever is pinning semanticMappings.actionBackground.",
393
407
  "A brand colour between roughly L 0.55 and L 0.83 cannot be a button surface at its own lightness: across that band no ink, not even pure black, reaches the Lc 75 APCA asks of a button label. Espalier then walks the label to the far end of the ramp and the button renders inverted — a dark surface carrying a pale label where the swatch wanted the opposite. Pin the stop just past the band instead. A gold at L 0.81 pinned to a tone at L 0.85 carries a dark label at Lc 76; the same gold left to the engine compiles to a brown one.",
394
408
  "Name a tone as a lowercase slug — gold-band, not goldBand. theme push refuses any other name, and refuses a mapping that names a tone its theme or context never declares.",
395
- "theme push runs Espalier's fit report over the root and every context in both schemes and lists each finding as a warning reading 'fit lint <id>'; theme push --dry-run does the same and writes nothing, and validate does it for an offline fixture. Read the lints before calling a theme done: action-anchor-inversion names a filled action that rendered the opposite way round from the swatches it declares, apca-target-unmet names a pair enforcement could not rescue, and action-canvas-separation and link-hover-ordering cover an action lost in its canvas and a hover weaker than its link. A lint is a warning, not a refusal, so a push still goes through with one — the lints are what tell an agent the design did not survive the compile.",
409
+ "theme push runs Espalier's fit report over the root and every context in both schemes and lists each finding as a warning reading 'fit lint <id>'; theme push --dry-run does the same and writes nothing, and validate does it for an offline fixture. Read the lints before calling a theme done: action-anchor-inversion names a filled action that rendered the opposite way round from the swatches it declares, apca-target-unmet names a pair enforcement could not rescue, and action-canvas-separation and link-hover-ordering cover an action lost in its canvas and a hover weaker than its link. A lint is a warning, not a refusal, so a push still goes through with one — the lints are what tell an agent the design did not survive the compile. Contrast and status-color warnings end with 'Nearest passing value: …' when the CLI finds one: it applied that value to a copy of the theme and checked that the warning clears without raising another, so start there and check again. validate and theme push refuse a font weight a catalog family has no face for (theme.font_weight_unavailable), because the publish would fail, and name the nearest weight it has.",
396
410
  "Add named contexts for whole zones, including inverted zones, and rebind lightness when the zone changes brightness. A context that declares its own action needs its own actionBackground pin: without one it compiles at the engine's mid-band stop, and where the root is pinned it carries the root's pin instead, whatever action colour the context declares.",
397
411
  "Tune typography, type/space ratios, radii, and viewport interpolation as one layout system.",
398
412
  "Typography is per scheme on purpose: light and dark set their own moods, so a heading, body, or brand face that differs between them is a design choice, and theme push never warns about it.",
399
413
  "Retune danger/success/warning/info through intents without changing their meanings; design data palettes separately.",
400
- "Use semanticMappings only for meanings roles and contexts cannot express; scattered pins shadow the coherent model. pull keeps only authored pins there and lists them in explicitMappingTokens; a mapping the marker does not name is never applied, so a hand-added pin needs its token added to the marker or theme push reports it as inert.",
414
+ "Use semanticMappings only for meanings roles and contexts cannot express; scattered pins shadow the coherent model. pull keeps only authored pins there and lists them in explicitMappingTokens; a mapping the marker does not name is never applied, so a hand-added pin at the root needs its token added to the marker or theme push reports it as inert. A pin inside a context needs no marker.",
401
415
  "Run theme push --dry-run and clear its warnings, fit lints included; then run theme push and verify text, actions, focus states, and both schemes in authoring previews.",
402
416
  ],
403
417
  example: THEME_EXAMPLE,