@taprootio/site-authoring 0.19.0 → 0.21.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 (43) hide show
  1. package/README.md +132 -28
  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 +71 -0
  8. package/src/cli-release.js +9 -1
  9. package/src/cli.js +128 -15
  10. package/src/constants.js +19 -0
  11. package/src/content/components.js +8 -5
  12. package/src/errors.js +3 -0
  13. package/src/footer-contract.js +5 -3
  14. package/src/form-references.js +103 -0
  15. package/src/forms-workspace.js +24 -0
  16. package/src/index.js +1 -0
  17. package/src/output.js +9 -0
  18. package/src/presentation-reference.js +41 -6
  19. package/src/problems.js +6 -1
  20. package/src/redirects-contract.js +50 -140
  21. package/src/reference-help.js +83 -36
  22. package/src/reserved-paths.js +4 -10
  23. package/src/setting-reference.js +438 -0
  24. package/src/site-check-findings.js +99 -0
  25. package/src/site-check-rules.json +22 -0
  26. package/src/site-checks.js +337 -0
  27. package/src/staging-check.js +48 -9
  28. package/src/staging-served.js +320 -0
  29. package/src/transport.js +13 -0
  30. package/src/typed-page-reference.js +128 -4
  31. package/src/typed-pages.js +1 -1
  32. package/src/verbs/changelog.js +62 -0
  33. package/src/verbs/deploy.js +38 -10
  34. package/src/verbs/forms-push.js +22 -1
  35. package/src/verbs/index.js +3 -0
  36. package/src/verbs/nav-push.js +49 -1
  37. package/src/verbs/pages-push.js +45 -1
  38. package/src/verbs/plan.js +4 -2
  39. package/src/verbs/pull.js +17 -3
  40. package/src/verbs/redirects-check.js +4 -1
  41. package/src/verbs/staging-review.js +1 -1
  42. package/src/verbs/status.js +35 -28
  43. package/src/verbs/validate.js +21 -0
@@ -0,0 +1,438 @@
1
+ import { APPEARANCE_FOOTER_COLOR_FIELDS, APPEARANCE_SCALAR_FIELDS } from "./appearance-contract.js";
2
+ import {
3
+ SETTINGS_GROUPS,
4
+ SETTINGS_TYPE_BRAND,
5
+ SETTINGS_TYPE_SITE_HEADER,
6
+ SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
7
+ SETTINGS_TYPE_TAPROOT_STYLES,
8
+ } from "./settings-catalog.js";
9
+
10
+ /**
11
+ * What every setting the CLI pulls means, which app control it is, and where it
12
+ * renders (TR01222).
13
+ *
14
+ * This is the one owner of that prose. `help settings` prints all of it, `help
15
+ * appearance` prints the entry beside each writable field, and a test fails
16
+ * when a field in `settings-catalog.js` has no entry here, so the help cannot
17
+ * drift behind a new field. Descriptions state current behavior; where the
18
+ * platform's rendering is a known limit, the entry says so plainly.
19
+ */
20
+
21
+ const BRAND = "Brand & style > Brand";
22
+ const HEADER = "Header & navigation";
23
+ const FEATURES = "Features";
24
+
25
+ function entry(appLabel, description, renders, defaultValue) {
26
+ return Object.freeze({
27
+ appLabel,
28
+ description,
29
+ ...(renders === undefined ? {} : { renders }),
30
+ ...(defaultValue === undefined ? {} : { default: defaultValue }),
31
+ });
32
+ }
33
+
34
+ /** Both schemes' image fields share a sentence shape. */
35
+ const LOGO_RENDERS = "The brand slot of the header, at the height brandLogoSize sets, and the footer's brand once the "
36
+ + "footer is enabled with its brand on. On a standard site a scheme with no logo of its own uses the other "
37
+ + "scheme's, and a site with no logo shows its brand text alone. On a profile site an unset light logo is the "
38
+ + "Taproot logo and an unset dark logo is the light one. Assign the highest-resolution file you have: the "
39
+ + "platform keeps one normalized master and serves resized WebP renditions, so an @2x suffix is only a filename "
40
+ + "and a second, smaller copy is never needed.";
41
+
42
+ /** The canvas image's rendering limits, stated once for both schemes. */
43
+ const CANVAS_RENDERS = "Only the empty side margins of a page, the left and right canvas columns of the page "
44
+ + "shell, never behind the content. The browser's default background rules apply, so the image tiles at its natural "
45
+ + "pixel size from the top-left of each margin; nothing resizes it to the margin, and the file delivered is the "
46
+ + "largest rendition (a WebP up to 2000 px wide). A standard site's pages have margins 0 px wide at every "
47
+ + "viewport, so the image does not appear on one today.";
48
+
49
+ const OPACITY_RENDERS = "Sets the opacity of the canvas image layer in the same side margins; it applies only while a "
50
+ + "canvas image is set.";
51
+
52
+ /** The page background image's rendering, stated once for the theme reference, `help settings` and the README. */
53
+ export const PAGE_BACKGROUND_IMAGE_RENDERING = "One layer across the whole page box, behind the content. The header and "
54
+ + "footer paint their own opaque backgrounds over it, so it shows behind the main content only.";
55
+
56
+ export const PAGE_BACKGROUND_VERSUS_CANVAS = Object.freeze({
57
+ pageBackground:
58
+ "Page style > Background image is the theme's pageBackgroundImage (with pageBackgroundImageOpacity) in "
59
+ + "settings.lightTheme and settings.darkTheme. " + PAGE_BACKGROUND_IMAGE_RENDERING + " Today its value is a CSS "
60
+ + "url(...) of a delivered image, taken from the src that .taproot-site-media.json records; there is no image-id "
61
+ + "field for it.",
62
+ canvas:
63
+ "Page canvas > Canvas image is lightCanvasImageId and darkCanvasImageId in settings/taproot-styles.json, with "
64
+ + "their opacity fields. It paints only the side margins outside the page content.",
65
+ shared:
66
+ "Neither sets a size, repeat or position, so both tile at the image's natural pixel size from the top-left. The "
67
+ + "published site copies the file the URL names into its own assets; it does not resize it.",
68
+ });
69
+
70
+ const TAPROOT_STYLES_ENTRIES = {
71
+ lightTheme: entry(
72
+ "Brand & style (light scheme)",
73
+ "The complete Espalier theme for the light scheme: colors, fonts, scales and the page background image.",
74
+ "Every published page, in the light scheme. help theme lists the fields.",
75
+ ),
76
+ darkTheme: entry(
77
+ "Brand & style (dark scheme)",
78
+ "The complete Espalier theme for the dark scheme: colors, fonts, scales and the page background image.",
79
+ "Every published page, in the dark scheme. help theme lists the fields.",
80
+ ),
81
+ defaultScheme: entry(
82
+ `${BRAND} > Default scheme`,
83
+ "The color scheme a first-time visitor sees: light, dark, or system to follow the visitor's device.",
84
+ "The starting scheme of every published page. A visitor who uses the theme toggle keeps their own choice.",
85
+ ),
86
+ lightLogoId: entry(
87
+ `${BRAND} > Logo`,
88
+ "The site-owned image used as the brand logo in the light scheme.",
89
+ LOGO_RENDERS,
90
+ ),
91
+ lightLogoUrl: entry(`${BRAND} > Logo`, "Read-only: the delivery URL of lightLogoId."),
92
+ darkLogoId: entry(
93
+ `${BRAND} > Logo`,
94
+ "The site-owned image used as the brand logo in the dark scheme.",
95
+ LOGO_RENDERS,
96
+ ),
97
+ darkLogoUrl: entry(`${BRAND} > Logo`, "Read-only: the delivery URL of darkLogoId."),
98
+ lightCanvasImageId: entry(
99
+ "Brand & style > Page canvas > Canvas image",
100
+ "The site-owned image painted in the side margins of a page in the light scheme.",
101
+ CANVAS_RENDERS,
102
+ ),
103
+ lightCanvasImageUrl: entry(
104
+ "Brand & style > Page canvas > Canvas image",
105
+ "Read-only: the delivery URL of lightCanvasImageId.",
106
+ ),
107
+ darkCanvasImageId: entry(
108
+ "Brand & style > Page canvas > Canvas image",
109
+ "The site-owned image painted in the side margins of a page in the dark scheme.",
110
+ CANVAS_RENDERS,
111
+ ),
112
+ darkCanvasImageUrl: entry(
113
+ "Brand & style > Page canvas > Canvas image",
114
+ "Read-only: the delivery URL of darkCanvasImageId.",
115
+ ),
116
+ lightCanvasImageOpacity: entry(
117
+ "Brand & style > Page canvas > Canvas image",
118
+ "How strongly the light canvas image shows, from 0 (invisible) to 1 (fully opaque).",
119
+ OPACITY_RENDERS,
120
+ ),
121
+ darkCanvasImageOpacity: entry(
122
+ "Brand & style > Page canvas > Canvas image",
123
+ "How strongly the dark canvas image shows, from 0 (invisible) to 1 (fully opaque).",
124
+ OPACITY_RENDERS,
125
+ ),
126
+ };
127
+
128
+ const BRAND_ENTRIES = {
129
+ faviconId: entry(
130
+ `${BRAND} > Favicon`,
131
+ "The square site-owned image the platform turns into the site's icon set.",
132
+ "Linked from the head of every published page as favicon.ico, square PNGs from 16 to 512 px and a 180 px Apple "
133
+ + "touch icon. A site with no favicon still serves /favicon.ico: a neutral mark on a standard site, the Taproot mark on a "
134
+ + "profile site. Use a simplified square mark, not a "
135
+ + "full wordmark.",
136
+ ),
137
+ faviconUrl: entry(`${BRAND} > Favicon`, "Read-only: the delivery URL of faviconId."),
138
+ };
139
+
140
+ const HEADER_ENTRIES = {
141
+ headerLayout: entry(
142
+ `${HEADER} > Layout`,
143
+ "How the brand, menu and buttons are arranged: standard (one row), centered-brand, minimal (the menu stays in "
144
+ + "the drawer) or centered-menu (brand at the start, buttons at the end, menu centered).",
145
+ "The header at the top of every published page.",
146
+ ),
147
+ headerWidth: entry(
148
+ `${HEADER} > Width`,
149
+ "contained keeps the brand and buttons at the page content edges; wide moves them to the viewport edges.",
150
+ "The header bar's content. Its background spans the viewport either way.",
151
+ ),
152
+ navDrawerStyle: entry(
153
+ `${HEADER} > Mobile menu`,
154
+ "How the navigation opens when it does not fit inline: full-screen over the whole viewport (the default) or as a "
155
+ + "side panel.",
156
+ "The drawer opened by the header's burger button, on narrow screens and wherever the menu is drawer-only.",
157
+ ),
158
+ navDrawerTransition: entry(
159
+ `${HEADER} > Menu transition`,
160
+ "How the full-screen drawer appears: fade, slide-down, slide-up, slide-left or slide-right.",
161
+ "Applies only while navDrawerStyle is full-screen.",
162
+ ),
163
+ brandText: entry(
164
+ `${BRAND} > Brand text`,
165
+ "The site name printed in the header brand.",
166
+ "The header, beside or instead of the logo, when showBrandText is on, and the footer's brand once the footer is "
167
+ + "enabled.",
168
+ ),
169
+ showBrandText: entry(
170
+ `${HEADER} > Brand text`,
171
+ "Whether brandText is printed in the header.",
172
+ "The header brand.",
173
+ ),
174
+ logoAlt: entry(
175
+ `${BRAND} > Logo alternative text`,
176
+ "The alternative text of the header logo image, read aloud and shown if the image fails.",
177
+ "The header logo and the footer's brand logo.",
178
+ ),
179
+ brandColor: entry(
180
+ `${BRAND} > Brand color`,
181
+ "The color of the brand text in both schemes, as a theme token or a custom color. Empty uses the header default.",
182
+ "The header brand text and the footer's brand text. lightBrandColor or darkBrandColor overrides it for one scheme.",
183
+ ),
184
+ lightBrandColor: entry(
185
+ `${BRAND} > Brand color`,
186
+ "The brand text color in the light scheme. Empty falls back to brandColor, then to darkBrandColor.",
187
+ "The header and footer brand text in the light scheme.",
188
+ ),
189
+ darkBrandColor: entry(
190
+ `${BRAND} > Brand color`,
191
+ "The brand text color in the dark scheme. Empty falls back to brandColor, then to the light color.",
192
+ "The header and footer brand text in the dark scheme.",
193
+ ),
194
+ navMenuDisplay: entry(
195
+ `${HEADER} > Navigation`,
196
+ "auto shows the menu inline when it fits and falls back to the drawer; drawer always uses the burger and drawer.",
197
+ "The header menu.",
198
+ ),
199
+ headerPosition: entry(
200
+ `${HEADER} > Position`,
201
+ "normal lets the header scroll away with the page; sticky keeps it at the top once it reaches it.",
202
+ "The header at the top of every published page.",
203
+ ),
204
+ showThemeToggle: entry(
205
+ `${HEADER} > Theme toggle`,
206
+ "Whether visitors get a light/dark toggle in the header.",
207
+ "The header buttons.",
208
+ ),
209
+ brandLogoSize: entry(
210
+ `${HEADER} > Logo size`,
211
+ "How tall the header logo is: standard (0.72 of the header height), large (0.85) or full (the whole header height).",
212
+ "The header logo.",
213
+ ),
214
+ brandHoverGrow: entry(
215
+ `${HEADER} > Hover emphasis`,
216
+ "Whether the header brand grows slightly while the pointer is over it.",
217
+ "The header brand.",
218
+ ),
219
+ };
220
+
221
+ const PREFERENCES_ENTRIES = {
222
+ commentsMode: entry(
223
+ `${FEATURES} > Comments`,
224
+ "Who may comment on articles, recipes, place reviews and albums, and whether a comment needs approval. pull records the enum member name, such as SITE_COMMENTS_MODE_AUTHENTICATED.",
225
+ "The comments section under those pages.",
226
+ "SITE_COMMENTS_MODE_AUTHENTICATED",
227
+ ),
228
+ tiptapImagePlacement: entry(
229
+ `${FEATURES} > Image placement`,
230
+ "The site's default placement for images in a page body: left, left-text-right, center, right or right-text-left.",
231
+ "Images in page bodies.",
232
+ "left",
233
+ ),
234
+ timeZone: entry(
235
+ `${FEATURES} > Time zone`,
236
+ "The site's IANA time zone, which decides what \"today\" means for relative date limits on forms.",
237
+ "Form date pickers and the check at submit. A change applies after a deployment.",
238
+ "UTC",
239
+ ),
240
+ allowSearch: entry(
241
+ `${FEATURES} > Search`,
242
+ "Whether the published site offers site search.",
243
+ "The header search button. A change applies after a deployment.",
244
+ false,
245
+ ),
246
+ showFollowButton: entry(
247
+ `${FEATURES} > Follow button`,
248
+ "Whether pages show a Follow button. Readers need a Taproot account to follow an author, so leave it off until "
249
+ + "reader sign-up is available.",
250
+ "The byline of article, album, recipe and place-review pages and the profile home. A change applies after a "
251
+ + "deployment.",
252
+ false,
253
+ ),
254
+ albumSeamless: entry(
255
+ `${FEATURES} > Album layout`,
256
+ "Whether album images sit edge to edge (seamless) by default. An album can set its own seamless value.",
257
+ "Album pages.",
258
+ false,
259
+ ),
260
+ hideLightboxComments: entry(
261
+ `${FEATURES} > Comments in the lightbox`,
262
+ "True hides comments inside the image lightbox; the app shows this as a switch that reads the other way round.",
263
+ "The image lightbox.",
264
+ false,
265
+ ),
266
+ tiptapImageMaxHeightVh: entry(
267
+ `${FEATURES} > Image height`,
268
+ "The tallest an image in a page body may be, as a percent of the screen height (45, 60, 75 or 90).",
269
+ "Images in page bodies.",
270
+ 60,
271
+ ),
272
+ tiptapImageMaxImagesPerRow: entry(
273
+ `${FEATURES} > Consecutive images per row`,
274
+ "How many consecutive images in a page body share a row: 1, 2 or 3.",
275
+ "Images in page bodies.",
276
+ 2,
277
+ ),
278
+ tiptapImageBorderWidth: entry(
279
+ `${FEATURES} > Image border width`,
280
+ "The border around images in a page body in pixels, from 0 (no border) to 20.",
281
+ "Images in page bodies.",
282
+ 2,
283
+ ),
284
+ albumBorderWidth: entry(
285
+ `${FEATURES} > Album border width`,
286
+ "The default border around album images in pixels, from 0 to 20. An album can set its own borderWidth.",
287
+ "Album pages.",
288
+ 0,
289
+ ),
290
+ albumMaxImagesPerRow: entry(
291
+ `${FEATURES} > Maximum images per album row`,
292
+ "The most images an album shows side by side in one row, from 1 to 5.",
293
+ "Album pages.",
294
+ 2,
295
+ ),
296
+ albumMaxRowHeightVh: entry(
297
+ `${FEATURES} > Album maximum row height`,
298
+ "The tallest an album row may be, as a percent of the screen height (1 to 100). A partial last row stays no "
299
+ + "taller than the rows before it.",
300
+ "Album pages.",
301
+ 90,
302
+ ),
303
+ footerSettings: entry(
304
+ "Footer",
305
+ "The whole footer: link columns, aside and bottom text, brand, feature image and per-scheme colors and background.",
306
+ "The footer of every published page, but only once footerSettings.enabled is true. help footer has the closed "
307
+ + "schema.",
308
+ { enabled: false },
309
+ ),
310
+ };
311
+
312
+ export const SETTING_FIELD_REFERENCE = Object.freeze({
313
+ [SETTINGS_TYPE_TAPROOT_STYLES]: Object.freeze(TAPROOT_STYLES_ENTRIES),
314
+ [SETTINGS_TYPE_BRAND]: Object.freeze(BRAND_ENTRIES),
315
+ [SETTINGS_TYPE_SITE_HEADER]: Object.freeze(HEADER_ENTRIES),
316
+ [SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]: Object.freeze(PREFERENCES_ENTRIES),
317
+ });
318
+
319
+ /** The description, app control and rendering note for one catalog field, for the references that list it. */
320
+ export function describeSettingField(settingsType, name) {
321
+ const known = SETTING_FIELD_REFERENCE[settingsType]?.[name];
322
+ if (known === undefined) throw new TypeError(`No setting reference for ${settingsType} ${name}.`);
323
+ return known;
324
+ }
325
+
326
+ const WRITABLE_SCALARS = new Set(
327
+ APPEARANCE_SCALAR_FIELDS.map((field) => `${field.settingsType}:${field.name}`),
328
+ );
329
+ const THEME_FOOTER_COLORS = APPEARANCE_FOOTER_COLOR_FIELDS.length;
330
+
331
+ /** How the CLI changes a field: the verb that writes it, or that it is read-only here. */
332
+ function writeVerb(group, field) {
333
+ if (field.wireType === "theme" || WRITABLE_SCALARS.has(`${group.settingsType}:${field.name}`)) return "theme push";
334
+ if (field.name === "footerSettings") {
335
+ return `footer push (content); theme push (the ${THEME_FOOTER_COLORS} scheme colors)`;
336
+ }
337
+ if (field.name.endsWith("Url")) return "read-only projection of the matching id; never authored";
338
+ return "read-only: pull records it, change it in the app";
339
+ }
340
+
341
+ /**
342
+ * App controls that save into lightTheme and darkTheme rather than a field of
343
+ * their own, so a lookup by label still finds a path. Both schemes carry each
344
+ * field; the app edits the scheme it is showing.
345
+ */
346
+ export const THEME_CONTROLS = Object.freeze([
347
+ Object.freeze({
348
+ appLabel: "Brand & style > Color harmony (and its hue and chroma controls)",
349
+ fields: Object.freeze(["seedColor", "angles", "variantChroma"]),
350
+ }),
351
+ Object.freeze({
352
+ appLabel: "Brand & style > Semantic color mappings > Color source",
353
+ fields: Object.freeze(["semanticMappings"]),
354
+ }),
355
+ Object.freeze({
356
+ appLabel: "Brand & style > Page style > Background image",
357
+ fields: Object.freeze(["pageBackgroundImage", "pageBackgroundImageOpacity"]),
358
+ renders: PAGE_BACKGROUND_IMAGE_RENDERING,
359
+ }),
360
+ Object.freeze({
361
+ appLabel: "Brand & style > Brand > Brand font and Brand weight",
362
+ fields: Object.freeze(["fontBrand", "fontWeightBrand"]),
363
+ }),
364
+ Object.freeze({
365
+ appLabel: "Brand & style > Brand > Menu font and Menu weight",
366
+ fields: Object.freeze(["fontMenu", "fontWeightMenu"]),
367
+ }),
368
+ Object.freeze({
369
+ appLabel: "Brand & style > Typography > Headings and Headings weight",
370
+ fields: Object.freeze(["fontHeadings", "fontWeightHeadings"]),
371
+ }),
372
+ Object.freeze({
373
+ appLabel: "Brand & style > Typography > Body and Body weight",
374
+ fields: Object.freeze(["fontBody", "fontWeightBody"]),
375
+ }),
376
+ Object.freeze({
377
+ appLabel: "Brand & style > Typography > Monospace and Monospace weight",
378
+ fields: Object.freeze(["fontMonospace", "fontWeightMonospace"]),
379
+ }),
380
+ ]);
381
+
382
+ /** The reference `help settings` prints: every catalog field with its meaning, app control and write path. */
383
+ export function getSettingsReference() {
384
+ const scalarDefaults = new Map(
385
+ APPEARANCE_SCALAR_FIELDS.map((field) => [`${field.settingsType}:${field.name}`, field.default]),
386
+ );
387
+ return {
388
+ title: "Site settings: what each field is, which app control it is, and where it renders",
389
+ referenceKind: "settings",
390
+ usage: "pull | theme push | footer push",
391
+ groups: SETTINGS_GROUPS.map((group) => ({
392
+ file: `settings/${group.file}`,
393
+ fields: group.fields.map((field) => {
394
+ const known = SETTING_FIELD_REFERENCE[group.settingsType][field.name];
395
+ const scalarKey = `${group.settingsType}:${field.name}`;
396
+ const defaultValue = scalarDefaults.has(scalarKey) ? scalarDefaults.get(scalarKey) : known.default;
397
+ return {
398
+ path: `settings.${field.name}`,
399
+ wireType: field.wireType,
400
+ appLabel: known.appLabel,
401
+ description: known.description,
402
+ ...(defaultValue === undefined ? {} : { default: defaultValue }),
403
+ ...(known.renders === undefined ? {} : { renders: known.renders }),
404
+ write: writeVerb(group, field),
405
+ };
406
+ }),
407
+ })),
408
+ themeControls: THEME_CONTROLS.map((control) => ({
409
+ ...control,
410
+ file: "settings/taproot-styles.json",
411
+ paths: ["settings.lightTheme", "settings.darkTheme"],
412
+ })),
413
+ pageBackgroundVersusCanvas: PAGE_BACKGROUND_VERSUS_CANVAS,
414
+ };
415
+ }
416
+
417
+ export function formatSettingsReference(reference) {
418
+ const groups = reference.groups.map((group) =>
419
+ `${group.file}\n${
420
+ group.fields.map((field) =>
421
+ ` ${field.path} (${field.wireType}; ${field.write}${
422
+ field.default === undefined ? "" : `; default ${JSON.stringify(field.default)}`
423
+ })\n app: ${field.appLabel}\n ${field.description}${
424
+ field.renders === undefined ? "" : `\n renders: ${field.renders}`
425
+ }`
426
+ ).join("\n")
427
+ }`
428
+ ).join("\n\n");
429
+ const controls = reference.themeControls.map((control) =>
430
+ ` ${control.appLabel}\n ${control.file} :: ${control.paths.join(" and ")} :: ${control.fields.join(", ")}${
431
+ control.renders === undefined ? "" : `\n renders: ${control.renders}`
432
+ }`
433
+ ).join("\n");
434
+ const contrast = reference.pageBackgroundVersusCanvas;
435
+ return `${reference.title}\nUsage: ${reference.usage}\n\nPage background image versus canvas image:\n - ${
436
+ contrast.pageBackground
437
+ }\n - ${contrast.canvas}\n - ${contrast.shared}\n\nApp controls stored inside the theme (help theme describes each field):\n${controls}\n\n${groups}\n`;
438
+ }
@@ -0,0 +1,99 @@
1
+ import { sanitizeDiagnostic } from "./errors.js";
2
+ import { boundedByBytes } from "./session.js";
3
+ import rules from "./site-check-rules.json" with { type: "json" };
4
+ import { missingPageLinkFindings, normalizedPathSet } from "./site-checks.js";
5
+ import { displayPagePath } from "./workspace.js";
6
+
7
+ /**
8
+ * The CLI's side of the site validation pipeline (TR01085). The rules, their
9
+ * severities and where they run are `shared/site-check-rules.json`; `validate`
10
+ * evaluates the ones that name it offline, and lists the rest as checked by the
11
+ * site, where `status` shows them. A finding carries a rule id and values; the
12
+ * words below are this surface's own.
13
+ */
14
+ export const SITE_CHECK_RULES = Object.freeze(rules.rules);
15
+
16
+ /** The CLI's sentence for each rule, keyed by rule id. A rule without an entry falls back to its id. */
17
+ export const SITE_CHECK_WORDING = Object.freeze({
18
+ "link.page_missing": (finding) => `links to ${finding.subject}, which is not a page of this site`,
19
+ "image.missing": () => "places an image that is no longer available",
20
+ "form.none_chosen": () => "has a Form block with no form chosen",
21
+ "form.unreadable": () => "places a form by an old key or an id the site cannot read",
22
+ "form.missing": () => "places a form that does not exist",
23
+ "form.archived": (finding) => `places the form '${finding.detail}', which is archived`,
24
+ "form.after_submit_page_unpublished": (finding) =>
25
+ `the form '${finding.detail}' sends visitors to ${finding.subject || "a page that no longer exists"}, which the release does not publish`,
26
+ "form.after_submit_page_not_allowed": (finding) =>
27
+ `the form '${finding.detail}' sends visitors to ${finding.subject}, which is not a page you created`,
28
+ "redirect.source_invalid": () => "has a source that is not an absolute site path",
29
+ "redirect.source_index_html": () => "has a source ending in index.html",
30
+ "redirect.source_integration_data": () => "has a source under the integration data root",
31
+ "redirect.source_share_link": () => "has a source under the share-link root",
32
+ "redirect.source_runtime_mirror": () => "has a source under the published-site runtime",
33
+ "redirect.source_generated_file": () => "has a source naming a file the site generates",
34
+ "redirect.source_not_storable": () => "has a source the publisher cannot store",
35
+ });
36
+
37
+ /** The sentence for a finding, falling back to the rule id for a rule this release has no words for. */
38
+ export function describeSiteFinding(finding) {
39
+ const words = SITE_CHECK_WORDING[finding.ruleId];
40
+ return words === undefined ? `was flagged by the site check '${finding.ruleId}'` : words(finding);
41
+ }
42
+
43
+ // Progress names this many; the JSON list is bounded by bytes instead.
44
+ const MAXIMUM_PROGRESS_LINES = 20;
45
+
46
+ const rulesRunningAt = (point) => SITE_CHECK_RULES.filter((rule) => rule.runsAt.includes(point)).map((rule) => rule.id);
47
+
48
+ /**
49
+ * Evaluates the offline page rules over the planned pages. `pagePaths` is every
50
+ * page path the workspace knows, and `pagesComplete` says whether that list is
51
+ * the whole site: a truncated manifest cannot say a link is broken.
52
+ */
53
+ export function evaluateOfflineSiteChecks({ planned, pagePaths, pagesComplete = true, onProgress = () => {} }) {
54
+ const known = normalizedPathSet([...pagePaths, ...(planned ?? []).map((page) => page.pagePath)]);
55
+
56
+ const items = [];
57
+ if (pagesComplete) {
58
+ for (const page of planned ?? []) {
59
+ if (page.document === null || typeof page.document !== "object") continue;
60
+ for (const finding of missingPageLinkFindings(page.document, page.pagePath ?? "", known)) {
61
+ const item = {
62
+ rule: finding.ruleId,
63
+ severity: finding.severity,
64
+ file: page.file,
65
+ path: displayPagePath(page.pagePath),
66
+ subject: [...sanitizeDiagnostic(finding.subject, "")].slice(0, 300).join(""),
67
+ };
68
+ if (items.length < MAXIMUM_PROGRESS_LINES) {
69
+ onProgress(`Warning: ${item.file} ${describeSiteFinding({ ...finding, subject: item.subject })}.`);
70
+ }
71
+ items.push(item);
72
+ }
73
+ }
74
+ }
75
+ if (items.length > MAXIMUM_PROGRESS_LINES) {
76
+ onProgress(`Warning: ${items.length - MAXIMUM_PROGRESS_LINES} more site check finding(s); see findings.`);
77
+ }
78
+ const reported = boundedByBytes(items, 8 * 1024);
79
+ const errors = items.filter((item) => item.severity === "error").length;
80
+ return {
81
+ findings: {
82
+ total: items.length,
83
+ errors,
84
+ warnings: items.length - errors,
85
+ items: reported.items,
86
+ ...(reported.truncated ? { truncated: true } : {}),
87
+ },
88
+ siteChecks: {
89
+ ranOffline: rulesRunningAt("validate"),
90
+ // Rules that need the site's own records; `taproot-site status` shows what the site finds.
91
+ checkedBySite: SITE_CHECK_RULES.filter((rule) => !rule.runsAt.includes("validate") && rule.runsAt.includes("health"))
92
+ .map((rule) => rule.id),
93
+ // Rules the deploy itself applies; the deploy refusal names them, and no read reports them beforehand.
94
+ checkedAtDeploy: SITE_CHECK_RULES.filter((rule) => !rule.runsAt.includes("validate") && !rule.runsAt.includes("health"))
95
+ .map((rule) => rule.id),
96
+ ...(pagesComplete ? {} : { skipped: ["link.page_missing"] }),
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": 1,
3
+ "about": "The catalogue of site checks (TR01085). Severity: 'error' blocks a deploy, 'warning' informs. Scope: 'page' rules read one page body, 'site' rules read the site's own records. runsAt names the run points that evaluate the rule: save, validate (the CLI, offline), deploy, health. subject and detail say what a finding's two values hold. The typed registry in Taproot.Domain/SiteChecks/SiteCheckRules.cs must list the same rules; SiteCheckTests fails on a difference.",
4
+ "runPoints": ["save", "validate", "deploy", "health"],
5
+ "rules": [
6
+ { "id": "link.page_missing", "severity": "warning", "scope": "page", "runsAt": ["save", "validate", "health"], "subject": "page_path", "detail": "none" },
7
+ { "id": "image.missing", "severity": "warning", "scope": "page", "runsAt": ["save", "health"], "subject": "image_id", "detail": "none" },
8
+ { "id": "form.none_chosen", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "none", "detail": "none" },
9
+ { "id": "form.unreadable", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_reference_text", "detail": "none" },
10
+ { "id": "form.missing", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_id", "detail": "none" },
11
+ { "id": "form.archived", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_id", "detail": "form_name" },
12
+ { "id": "form.after_submit_page_unpublished", "severity": "error", "scope": "site", "runsAt": ["deploy"], "subject": "page_path", "detail": "form_name" },
13
+ { "id": "form.after_submit_page_not_allowed", "severity": "error", "scope": "site", "runsAt": ["deploy"], "subject": "page_path", "detail": "form_name" },
14
+ { "id": "redirect.source_invalid", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "none" },
15
+ { "id": "redirect.source_index_html", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
16
+ { "id": "redirect.source_integration_data", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
17
+ { "id": "redirect.source_share_link", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
18
+ { "id": "redirect.source_runtime_mirror", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
19
+ { "id": "redirect.source_generated_file", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
20
+ { "id": "redirect.source_not_storable", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" }
21
+ ]
22
+ }