@taprootio/site-authoring 0.20.0 → 0.22.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 (47) hide show
  1. package/README.md +128 -29
  2. package/examples/riverbend-wellness/nav.json +3 -6
  3. package/examples/riverbend-wellness/pages/visit.md +1 -1
  4. package/package.json +2 -2
  5. package/src/api.js +118 -31
  6. package/src/changelog.js +48 -0
  7. package/src/changelog.json +83 -0
  8. package/src/cli-release.js +9 -1
  9. package/src/cli.js +129 -14
  10. package/src/constants.js +19 -0
  11. package/src/errors.js +3 -0
  12. package/src/footer-contract.js +5 -3
  13. package/src/forms-workspace.js +22 -0
  14. package/src/heading-outline.js +76 -0
  15. package/src/index.js +1 -0
  16. package/src/output.js +9 -0
  17. package/src/page-metadata.js +2 -1
  18. package/src/page-structure-checks.js +356 -0
  19. package/src/presentation-reference.js +41 -6
  20. package/src/problems.js +6 -1
  21. package/src/redirects-contract.js +50 -140
  22. package/src/reference-help.js +77 -33
  23. package/src/reserved-paths.js +4 -10
  24. package/src/setting-reference.js +438 -0
  25. package/src/settings-catalog.js +2 -3
  26. package/src/site-check-findings.js +188 -0
  27. package/src/site-check-rules.json +35 -0
  28. package/src/site-checks.js +353 -0
  29. package/src/staging-check.js +48 -9
  30. package/src/staging-served.js +320 -0
  31. package/src/theme-projection.js +28 -55
  32. package/src/theme-validation.js +2 -1
  33. package/src/transport.js +13 -0
  34. package/src/typed-page-reference.js +128 -4
  35. package/src/verbs/changelog.js +62 -0
  36. package/src/verbs/deploy.js +38 -10
  37. package/src/verbs/index.js +3 -0
  38. package/src/verbs/nav-push.js +49 -1
  39. package/src/verbs/pages-push.js +17 -5
  40. package/src/verbs/plan.js +2 -1
  41. package/src/verbs/pull.js +5 -1
  42. package/src/verbs/redirects-check.js +4 -1
  43. package/src/verbs/staging-review.js +1 -1
  44. package/src/verbs/status.js +35 -28
  45. package/src/verbs/validate.js +24 -0
  46. package/src/workspace-check.js +26 -1
  47. package/src/workspace.js +5 -0
@@ -0,0 +1,356 @@
1
+ // Generated from shared/page-structure-checks.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The page-structure site checks (TR00947): the SEO and accessibility oddities
4
+ * a creator can fix in the editor or the CLI before publishing.
5
+ *
6
+ * `shared/site-check-rules.json` is the catalogue; these are the `document`
7
+ * scope rules in it. They read one document and the page's own metadata, never
8
+ * the site's records, so the editor evaluates the open document in the browser
9
+ * and the CLI evaluates a workspace file offline, from copies of this one
10
+ * source. The API carries no second implementation: the rules are advice, and
11
+ * nothing on the server needs them to decide a save or a deploy.
12
+ *
13
+ * The heading rules replay the generator's own outline machine
14
+ * (`heading-outline.ts`) over the document, so what they say matches what is
15
+ * published. `generator/src/page-structure-parity.test.ts` renders every
16
+ * heading case in `site-check-corpus.json` and compares it with what these
17
+ * rules predicted.
18
+ *
19
+ * It is dependency-free so the editor and the published site-authoring package
20
+ * can carry copies. `node scripts/sync-field-validation.mjs` writes them; edit
21
+ * only this file and re-run it.
22
+ */
23
+ import { bannerOpensPage, componentFixedHeadingLevel, createHeadingOutlineState, nextHeadingLevel, recordFixedHeading, } from "./heading-outline.js";
24
+ export const RULE_H1_MISSING = "seo.h1_missing";
25
+ export const RULE_H1_DEMOTED = "seo.h1_demoted";
26
+ export const RULE_H1_MULTIPLE = "seo.h1_multiple";
27
+ export const RULE_HEADING_RENUMBERED = "seo.heading_renumbered";
28
+ export const RULE_HEADING_BEFORE_TITLE = "seo.heading_before_title";
29
+ export const RULE_IMAGE_ALT_MISSING = "a11y.image_alt_missing";
30
+ export const RULE_LINK_TEXT_EMPTY = "a11y.link_text_empty";
31
+ export const RULE_TITLE_LONG = "seo.title_long";
32
+ export const RULE_DESCRIPTION_MISSING = "seo.description_missing";
33
+ export const RULE_DESCRIPTION_LONG = "seo.description_long";
34
+ export const RULE_SOCIAL_IMAGE_FALLBACK = "seo.social_image_fallback";
35
+ const RULE_ORDER = [
36
+ RULE_H1_MISSING,
37
+ RULE_H1_DEMOTED,
38
+ RULE_H1_MULTIPLE,
39
+ RULE_HEADING_RENUMBERED,
40
+ RULE_HEADING_BEFORE_TITLE,
41
+ RULE_IMAGE_ALT_MISSING,
42
+ RULE_LINK_TEXT_EMPTY,
43
+ RULE_TITLE_LONG,
44
+ RULE_DESCRIPTION_MISSING,
45
+ RULE_DESCRIPTION_LONG,
46
+ RULE_SOCIAL_IMAGE_FALLBACK,
47
+ ];
48
+ /** Search results cut a title off after about this many characters. */
49
+ export const TITLE_WARN_LENGTH = 60;
50
+ /** Search results cut a description off after about this many characters. */
51
+ export const DESCRIPTION_WARN_LENGTH = 160;
52
+ const MAXIMUM_DEPTH = 256;
53
+ const MAXIMUM_SUBJECT_LENGTH = 80;
54
+ const characters = (value) => [...value].length;
55
+ function isNode(value) {
56
+ return value !== null && typeof value === "object" && !Array.isArray(value);
57
+ }
58
+ function children(node) {
59
+ return Array.isArray(node.content) ? node.content.filter(isNode) : [];
60
+ }
61
+ function clip(value) {
62
+ const text = value.replace(/\s+/gu, " ").trim();
63
+ const letters = [...text];
64
+ return letters.length > MAXIMUM_SUBJECT_LENGTH ? `${letters.slice(0, MAXIMUM_SUBJECT_LENGTH - 1).join("")}…` : text;
65
+ }
66
+ function textOf(node) {
67
+ if (node.type === "text")
68
+ return typeof node.text === "string" ? node.text : "";
69
+ return children(node).map(textOf).join(node.type === "hardBreak" ? " " : "");
70
+ }
71
+ function componentData(node) {
72
+ const raw = node.attrs?.componentData;
73
+ if (typeof raw === "string") {
74
+ try {
75
+ const parsed = JSON.parse(raw);
76
+ return isNode(parsed) ? parsed : {};
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ }
82
+ // The publisher reads only a JSON string here; anything else renders as an empty component.
83
+ return {};
84
+ }
85
+ function nonBlank(value) {
86
+ return typeof value === "string" && value.trim() !== "";
87
+ }
88
+ function imageSource(value) {
89
+ return isNode(value) && nonBlank(value.src);
90
+ }
91
+ function clampLevel(value) {
92
+ const level = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 1;
93
+ return Math.min(4, Math.max(1, level));
94
+ }
95
+ /**
96
+ * The headings, images and links of a free-form body, as the publisher meets them.
97
+ * Every component block counts as content before a banner, though the publisher drops
98
+ * one that renders only a comment (a Latest posts block with no posts, an unknown type), and
99
+ * an integration placement renders nothing or a status paragraph depending on the site;
100
+ * whether it does can depend on the site, so a banner after such a block is the one case
101
+ * where this walk and the publisher can differ.
102
+ */
103
+ function walkBody(body) {
104
+ const findings = [];
105
+ // Findings about headings wait for the whole walk: whether the page has an h1 is known only at the end.
106
+ const headings = [];
107
+ const extraTitles = [];
108
+ const outline = createHeadingOutlineState({ titleOpen: true });
109
+ let claimant;
110
+ let imageCount = 0;
111
+ let hasContent = false;
112
+ // Adopted integration markup is opaque here; it may hold the page's own h1.
113
+ let opaqueH1 = false;
114
+ const seeContent = () => {
115
+ hasContent = true;
116
+ outline.titleOpen = false;
117
+ };
118
+ const visitLinks = (node, path) => {
119
+ // A link split by formatting is several adjacent text nodes; the link is empty only when all of them are.
120
+ let run;
121
+ const close = () => {
122
+ if (run && run.text.trim() === "") {
123
+ findings.push({ ruleId: RULE_LINK_TEXT_EMPTY, severity: "warning", subject: clip(run.href), detail: "", path });
124
+ }
125
+ run = undefined;
126
+ };
127
+ for (const child of children(node)) {
128
+ const href = child.type === "text" && Array.isArray(child.marks)
129
+ ? child.marks.find((mark) => isNode(mark) && mark.type === "link")?.attrs?.href
130
+ : undefined;
131
+ if (typeof href !== "string") {
132
+ close();
133
+ continue;
134
+ }
135
+ if (run && run.href !== href)
136
+ close();
137
+ run = { href, text: `${run?.text ?? ""}${typeof child.text === "string" ? child.text : ""}` };
138
+ }
139
+ close();
140
+ };
141
+ const visit = (node, path, depth) => {
142
+ if (depth > MAXIMUM_DEPTH)
143
+ return;
144
+ switch (node.type) {
145
+ case "heading": {
146
+ const authored = clampLevel(node.attrs?.level);
147
+ const titleWasOpen = !outline.titleClaimed;
148
+ const level = nextHeadingLevel(outline, authored);
149
+ const text = clip(textOf(node));
150
+ const claimsTitle = authored === 1 && titleWasOpen;
151
+ if (claimsTitle)
152
+ claimant = { kind: "heading", text };
153
+ headings.push({
154
+ path,
155
+ text,
156
+ authored,
157
+ level,
158
+ role: claimsTitle ? "title" : titleWasOpen ? "before_title" : authored === 1 ? "demoted" : "numbered",
159
+ });
160
+ break;
161
+ }
162
+ case "taprootImage": {
163
+ imageCount++;
164
+ seeContent();
165
+ if (!nonBlank(node.attrs?.alt)) {
166
+ const id = node.attrs?.imageId;
167
+ findings.push({
168
+ ruleId: RULE_IMAGE_ALT_MISSING,
169
+ severity: "warning",
170
+ subject: typeof id === "string" ? id : "",
171
+ detail: "",
172
+ path,
173
+ });
174
+ }
175
+ return;
176
+ }
177
+ case "componentBlock": {
178
+ const type = typeof node.attrs?.componentType === "string" ? node.attrs.componentType : "";
179
+ const data = componentData(node);
180
+ // The banner decides whether it is the title before the page counts it as content.
181
+ const opensPage = type === "image-banner" && bannerOpensPage(outline, data);
182
+ const fixedLevel = componentFixedHeadingLevel(type, data);
183
+ if (type === "image-banner" && imageSource(data.image)) {
184
+ imageCount++;
185
+ if (!nonBlank(data.altText)) {
186
+ findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
187
+ }
188
+ }
189
+ // The social preview also draws on a hero's picture.
190
+ if (type === "hero-section" && imageSource(data.media)) {
191
+ imageCount++;
192
+ if (!nonBlank(data.media.alt)) {
193
+ findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
194
+ }
195
+ }
196
+ if (type === "cta" && imageSource(data.mediaImage) && !nonBlank(data.mediaImage.alt)) {
197
+ findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
198
+ }
199
+ if (opensPage) {
200
+ recordFixedHeading(outline, 1);
201
+ claimant = { kind: "banner", text: clip(String(data.overlayText)) };
202
+ }
203
+ else if (fixedLevel === 1) {
204
+ const text = clip(String(data.title));
205
+ if (outline.titleClaimed)
206
+ extraTitles.push({ path, text });
207
+ else
208
+ claimant = { kind: "hero", text };
209
+ recordFixedHeading(outline, 1);
210
+ }
211
+ else if (fixedLevel !== undefined) {
212
+ recordFixedHeading(outline, fixedLevel);
213
+ }
214
+ seeContent();
215
+ return;
216
+ }
217
+ case "section": {
218
+ // A section's photo background is an image too; its children are still walked below.
219
+ const background = node.attrs?.background;
220
+ if (isNode(background) && imageSource(background.image))
221
+ imageCount++;
222
+ break;
223
+ }
224
+ case "text":
225
+ if (nonBlank(node.text))
226
+ seeContent();
227
+ return;
228
+ case "integrationFragment": {
229
+ // The publisher treats an adopted fragment with markup as content before a banner, and its bytes may
230
+ // carry the page's own `<h1>`, which this walk cannot see.
231
+ const html = node.attrs?.html;
232
+ if (nonBlank(html)) {
233
+ seeContent();
234
+ if (/<h1[\s>]/iu.test(html))
235
+ opaqueH1 = true;
236
+ }
237
+ return;
238
+ }
239
+ case "inlineFacts":
240
+ // A fact list is visible content; one with no items renders nothing.
241
+ if (Array.isArray(node.attrs?.items) && node.attrs.items.length > 0)
242
+ seeContent();
243
+ return;
244
+ default:
245
+ break;
246
+ }
247
+ visitLinks(node, path);
248
+ children(node).forEach((child, index) => visit(child, [...path, index], depth + 1));
249
+ };
250
+ if (isNode(body))
251
+ children(body).forEach((child, index) => visit(child, [index], 1));
252
+ if (claimant === undefined) {
253
+ // A page with nothing on it has nothing to title yet.
254
+ if (hasContent && !opaqueH1) {
255
+ findings.unshift({ ruleId: RULE_H1_MISSING, severity: "warning", subject: "", detail: "" });
256
+ }
257
+ }
258
+ else {
259
+ const kind = claimant.kind;
260
+ // A Heading 1 with no text still claims the title, so the page publishes an empty `<h1>`.
261
+ if (claimant.kind === "heading" && claimant.text === "" && hasContent) {
262
+ findings.unshift({ ruleId: RULE_H1_MISSING, severity: "warning", subject: "", detail: "" });
263
+ }
264
+ for (const heading of headings) {
265
+ if (heading.role === "demoted") {
266
+ findings.push({
267
+ ruleId: RULE_H1_DEMOTED,
268
+ severity: "warning",
269
+ subject: heading.text,
270
+ detail: kind,
271
+ path: heading.path,
272
+ });
273
+ }
274
+ else if (heading.role === "before_title") {
275
+ findings.push({
276
+ ruleId: RULE_HEADING_BEFORE_TITLE,
277
+ severity: "warning",
278
+ subject: heading.text,
279
+ detail: String(heading.level),
280
+ path: heading.path,
281
+ });
282
+ }
283
+ else if (heading.role === "numbered" && heading.level !== heading.authored) {
284
+ findings.push({
285
+ ruleId: RULE_HEADING_RENUMBERED,
286
+ severity: "warning",
287
+ subject: heading.text,
288
+ detail: `${heading.authored}>${heading.level}`,
289
+ path: heading.path,
290
+ });
291
+ }
292
+ }
293
+ for (const title of extraTitles) {
294
+ findings.push({
295
+ ruleId: RULE_H1_MULTIPLE,
296
+ severity: "warning",
297
+ subject: title.text,
298
+ detail: kind,
299
+ path: title.path,
300
+ });
301
+ }
302
+ }
303
+ return { findings, imageCount, hasContent };
304
+ }
305
+ /**
306
+ * The warnings for one page. Headings, images and links are checked on a
307
+ * free-form body only; the title, description and social preview checks run
308
+ * for any page whose surface supplies those fields. `rules` limits the result
309
+ * to the rule ids a surface runs: the catalogue's `runsAt` says which.
310
+ */
311
+ export function pageStructureFindings(page, rules) {
312
+ // Catalogue order, so a surface that shows only the first few keeps the heading rules.
313
+ return collectFindings(page)
314
+ .filter((finding) => rules === undefined || rules.has(finding.ruleId))
315
+ .sort((left, right) => RULE_ORDER.indexOf(left.ruleId) - RULE_ORDER.indexOf(right.ruleId));
316
+ }
317
+ function collectFindings(page) {
318
+ const findings = [];
319
+ let imageCount = 0;
320
+ if (page.freeForm) {
321
+ const walked = walkBody(page.body);
322
+ findings.push(...walked.findings);
323
+ imageCount = walked.imageCount;
324
+ }
325
+ if (typeof page.title === "string" && characters(page.title.trim()) > TITLE_WARN_LENGTH) {
326
+ findings.push({
327
+ ruleId: RULE_TITLE_LONG,
328
+ severity: "warning",
329
+ subject: "",
330
+ detail: String(characters(page.title.trim())),
331
+ });
332
+ }
333
+ if (typeof page.description === "string") {
334
+ const description = page.description.trim();
335
+ if (description === "") {
336
+ findings.push({ ruleId: RULE_DESCRIPTION_MISSING, severity: "warning", subject: "", detail: "" });
337
+ }
338
+ else if (characters(description) > DESCRIPTION_WARN_LENGTH) {
339
+ findings.push({
340
+ ruleId: RULE_DESCRIPTION_LONG,
341
+ severity: "warning",
342
+ subject: "",
343
+ detail: String(characters(description)),
344
+ });
345
+ }
346
+ }
347
+ if (page.freeForm && page.hasCover === false) {
348
+ findings.push({
349
+ ruleId: RULE_SOCIAL_IMAGE_FALLBACK,
350
+ severity: "warning",
351
+ subject: "",
352
+ detail: imageCount > 0 ? "first_image" : "none",
353
+ });
354
+ }
355
+ return findings;
356
+ }
@@ -44,6 +44,12 @@ import {
44
44
  REQUIRED_THEME_PROPERTIES,
45
45
  validateAndLintThemePair,
46
46
  } from "./theme-validation.js";
47
+ import {
48
+ describeSettingField,
49
+ formatSettingsReference,
50
+ PAGE_BACKGROUND_IMAGE_RENDERING,
51
+ PAGE_BACKGROUND_VERSUS_CANVAS,
52
+ } from "./setting-reference.js";
47
53
  import {
48
54
  SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
49
55
  SETTINGS_TYPE_TAPROOT_STYLES,
@@ -273,14 +279,23 @@ const THEME_GROUPS = Object.freeze([
273
279
  }),
274
280
  Object.freeze({
275
281
  name: "surface-assets",
276
- summary: "Preserve pulled asset fields; use appearance image ids for site-owned canvas art.",
282
+ summary:
283
+ "Preserve pulled asset fields; use appearance image ids for the logos, favicon and canvas image. The page "
284
+ + "background image is a theme field behind the page content, unlike the canvas image, which paints only the "
285
+ + "side margins (help settings has both).",
277
286
  fields: Object.freeze([
278
287
  themeField("stylesheets", "Agent-authored site themes must keep this exact array empty.", { exact: [] }),
279
288
  themeField(
280
289
  "pageBackgroundImage",
281
- "CSS background-image; the complete Taproot workspace includes an empty value.",
290
+ "CSS background-image (the app's Page style > Background image). " + PAGE_BACKGROUND_IMAGE_RENDERING
291
+ + " Today it is a CSS url(...) of a delivered image, the src that .taproot-site-media.json records; it tiles "
292
+ + "at the image's natural pixel size from the top-left. The complete Taproot workspace includes an empty value.",
293
+ ),
294
+ themeField(
295
+ "pageBackgroundImageOpacity",
296
+ "How strongly the page background image shows, from 0 (invisible) to 1 (fully opaque).",
297
+ { minimum: 0, maximum: 1 },
282
298
  ),
283
- themeField("pageBackgroundImageOpacity", "Page background opacity.", { minimum: 0, maximum: 1 }),
284
299
  themeField("boxBackgroundImage", "Optional inherited box-surface background image."),
285
300
  themeField("boxBackgroundImageOpacity", "Optional box background opacity.", { minimum: 0, maximum: 1 }),
286
301
  themeField("vellumOpacity", "Optional modal-vellum opacity.", { minimum: 0, maximum: 1 }),
@@ -418,6 +433,9 @@ export function getThemeReference() {
418
433
  };
419
434
  }
420
435
 
436
+ // "linkHoverColor" reads "link hover color", so a new footer color token needs no second table.
437
+ const footerColorLabel = (name) => name.replace(/([A-Z])/gu, " $1").toLowerCase();
438
+
421
439
  function appearanceColorReference(definition) {
422
440
  return definition.type === "appearance-color"
423
441
  ? {
@@ -432,6 +450,7 @@ export function getAppearanceReference() {
432
450
  const scalarFields = APPEARANCE_SCALAR_FIELDS.map((field) => ({
433
451
  file: APPEARANCE_FILES[field.settingsType],
434
452
  path: "settings." + field.name,
453
+ ...describeSettingField(field.settingsType, field.name),
435
454
  type: field.type,
436
455
  ...(field.values ? { values: field.values } : {}),
437
456
  ...(field.minimum !== undefined ? { minimum: field.minimum, maximum: field.maximum } : {}),
@@ -447,27 +466,36 @@ export function getAppearanceReference() {
447
466
  {
448
467
  file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
449
468
  path: "settings.lightTheme",
469
+ ...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "lightTheme"),
450
470
  type: "complete-espalier-theme",
451
471
  },
452
472
  {
453
473
  file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
454
474
  path: "settings.darkTheme",
475
+ ...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "darkTheme"),
455
476
  type: "complete-espalier-theme",
456
477
  },
457
478
  ...scalarFields,
458
479
  ...APPEARANCE_FOOTER_COLOR_FIELDS.map((field) => ({
459
480
  file: field.file,
460
481
  path: field.path,
482
+ appLabel: "Footer",
483
+ description: `The footer's ${footerColorLabel(field.name)} in the ${field.scheme} scheme. footer push owns the rest of the footer.`,
484
+ renders: "The footer, once footerSettings.enabled is true (default false).",
461
485
  type: field.type,
462
486
  ...appearanceColorReference(field),
463
487
  default: field.default,
464
488
  })),
465
489
  ],
466
490
  readOnlyProjections: APPEARANCE_READ_ONLY_FIELDS,
491
+ pageBackgroundVersusCanvas: PAGE_BACKGROUND_VERSUS_CANVAS,
492
+ fieldMap: CLI_BINARY_NAME + " help settings maps every pulled field, including the read-only ones, to its app control.",
467
493
  imageReferences:
468
494
  "Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
469
495
  logoContract:
470
496
  "lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting. "
497
+ + "Each takes one image id: upload the highest-resolution file you have and assign it. An @2x or @3x suffix is only "
498
+ + "part of a filename, nothing pairs it with another file, and the platform generates the sizes. "
471
499
  + "Prefer transparent PNG or WebP artwork with genuine alpha so the header background shows through, including "
472
500
  + "inside letter counters. Avoid baked-in background rectangles unless the brand intentionally uses a badge. "
473
501
  + "Keep padding tight and verify the delivered image preserves transparency. Logo style and coloration are "
@@ -776,11 +804,17 @@ export function formatPresentationReference(reference) {
776
804
  }
777
805
  if (reference.referenceKind === "appearance") {
778
806
  return reference.title + "\nUsage: " + reference.usage + "\n\nWritable fields:\n"
779
- + reference.fields.map((field) => " " + field.file + " :: " + field.path + " — " + fieldConstraint(field)).join(
780
- "\n",
781
- )
807
+ + reference.fields.map((field) =>
808
+ " " + field.file + " :: " + field.path + " — " + fieldConstraint(field)
809
+ + "\n app: " + field.appLabel + ". " + field.description
810
+ + (field.renders === undefined ? "" : "\n renders: " + field.renders)
811
+ ).join("\n")
782
812
  + "\n\nRead-only server projections:\n"
783
813
  + reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
814
+ + "\n\nPage background image versus canvas image:\n - " + reference.pageBackgroundVersusCanvas.pageBackground
815
+ + "\n - " + reference.pageBackgroundVersusCanvas.canvas
816
+ + "\n - " + reference.pageBackgroundVersusCanvas.shared
817
+ + "\n\nField map: " + reference.fieldMap
784
818
  + "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
785
819
  + "\nLogo contrast: " + reference.logoContrastContract
786
820
  + "\nFavicon: " + reference.faviconContract
@@ -794,6 +828,7 @@ export function formatPresentationReference(reference) {
794
828
  + "\nRetry: " + reference.retry
795
829
  + "\nRecovery: " + reference.recovery + "\n";
796
830
  }
831
+ if (reference.referenceKind === "settings") return formatSettingsReference(reference);
797
832
  if (reference.referenceKind === "footer") {
798
833
  const messages = reference.messages.map((message) =>
799
834
  message.name + " (unlisted fields rejected)\n"
package/src/problems.js CHANGED
@@ -39,7 +39,12 @@ function withContext(problem, context, error) {
39
39
  export function problemsOf(error, context = {}) {
40
40
  const own = Array.isArray(error.problems) && error.problems.length > 0
41
41
  ? error.problems
42
- : [{ code: error.code, ...(error.field === undefined ? {} : { field: error.field }), message: error.message }];
42
+ : [{
43
+ code: error.code,
44
+ ...(error.field === undefined ? {} : { field: error.field }),
45
+ ...(error.rule === undefined ? {} : { rule: error.rule }),
46
+ message: error.message,
47
+ }];
43
48
  return own.map((problem) => withContext(problem, context, error));
44
49
  }
45
50