@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.
- package/README.md +128 -29
- package/examples/riverbend-wellness/nav.json +3 -6
- package/examples/riverbend-wellness/pages/visit.md +1 -1
- package/package.json +2 -2
- package/src/api.js +118 -31
- package/src/changelog.js +48 -0
- package/src/changelog.json +83 -0
- package/src/cli-release.js +9 -1
- package/src/cli.js +129 -14
- package/src/constants.js +19 -0
- package/src/errors.js +3 -0
- package/src/footer-contract.js +5 -3
- package/src/forms-workspace.js +22 -0
- package/src/heading-outline.js +76 -0
- package/src/index.js +1 -0
- package/src/output.js +9 -0
- package/src/page-metadata.js +2 -1
- package/src/page-structure-checks.js +356 -0
- package/src/presentation-reference.js +41 -6
- package/src/problems.js +6 -1
- package/src/redirects-contract.js +50 -140
- package/src/reference-help.js +77 -33
- package/src/reserved-paths.js +4 -10
- package/src/setting-reference.js +438 -0
- package/src/settings-catalog.js +2 -3
- package/src/site-check-findings.js +188 -0
- package/src/site-check-rules.json +35 -0
- package/src/site-checks.js +353 -0
- package/src/staging-check.js +48 -9
- package/src/staging-served.js +320 -0
- package/src/theme-projection.js +28 -55
- package/src/theme-validation.js +2 -1
- package/src/transport.js +13 -0
- package/src/typed-page-reference.js +128 -4
- package/src/verbs/changelog.js +62 -0
- package/src/verbs/deploy.js +38 -10
- package/src/verbs/index.js +3 -0
- package/src/verbs/nav-push.js +49 -1
- package/src/verbs/pages-push.js +17 -5
- package/src/verbs/plan.js +2 -1
- package/src/verbs/pull.js +5 -1
- package/src/verbs/redirects-check.js +4 -1
- package/src/verbs/staging-review.js +1 -1
- package/src/verbs/status.js +35 -28
- package/src/verbs/validate.js +24 -0
- package/src/workspace-check.js +26 -1
- package/src/workspace.js +5 -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
|
+
}
|
package/src/settings-catalog.js
CHANGED
|
@@ -200,12 +200,11 @@ export function projectSettingsGroup(group, response) {
|
|
|
200
200
|
if (field.wireType === "theme") {
|
|
201
201
|
// A stored theme is what was written, possibly before a required key
|
|
202
202
|
// existed; the workspace gets the complete theme consumers actually
|
|
203
|
-
// render, resolved the way they resolve it (TR00775)
|
|
204
|
-
//
|
|
203
|
+
// render, resolved the way they resolve it (TR00775): as stored, over
|
|
204
|
+
// Espalier's defaults alone (TR01060).
|
|
205
205
|
projected[field.name] = projectPulledTheme(
|
|
206
206
|
projected[field.name],
|
|
207
207
|
field.name === "lightTheme" ? "light" : "dark",
|
|
208
|
-
{ managedExternally: source[`${field.name}ManagedExternally`] === true },
|
|
209
208
|
);
|
|
210
209
|
}
|
|
211
210
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
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 { isFormKey } from "./forms-contract.js";
|
|
5
|
+
import { DESCRIPTION_WARN_LENGTH, pageStructureFindings, TITLE_WARN_LENGTH } from "./page-structure-checks.js";
|
|
6
|
+
import { missingPageLinkFindings, normalizedPathSet } from "./site-checks.js";
|
|
7
|
+
import { documentTemplate, TEMPLATE_FREE_FORM, TEMPLATE_GENERATED } from "./typed-pages.js";
|
|
8
|
+
import { displayPagePath } from "./workspace.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The CLI's side of the site validation pipeline (TR01085). The rules, their
|
|
12
|
+
* severities and where they run are `shared/site-check-rules.json`; `validate`
|
|
13
|
+
* evaluates the ones that name it offline, and lists the rest as checked by the
|
|
14
|
+
* site, where `status` shows them. A finding carries a rule id and values; the
|
|
15
|
+
* words below are this surface's own.
|
|
16
|
+
*/
|
|
17
|
+
export const SITE_CHECK_RULES = Object.freeze(rules.rules);
|
|
18
|
+
|
|
19
|
+
/** The CLI's sentence for each rule, keyed by rule id. A rule without an entry falls back to its id. */
|
|
20
|
+
export const SITE_CHECK_WORDING = Object.freeze({
|
|
21
|
+
"link.page_missing": (finding) => `links to ${finding.subject}, which is not a page of this site`,
|
|
22
|
+
"image.missing": () => "places an image that is no longer available",
|
|
23
|
+
"form.none_chosen": () => "has a Form block with no form chosen",
|
|
24
|
+
"form.unreadable": () => "places a form by an old key or an id the site cannot read",
|
|
25
|
+
"form.missing": () => "places a form that does not exist",
|
|
26
|
+
"form.archived": (finding) => `places the form '${finding.detail}', which is archived`,
|
|
27
|
+
"form.key_unknown": (finding) =>
|
|
28
|
+
isFormKey(finding.subject)
|
|
29
|
+
? `places the form '${finding.subject}', but the workspace has no forms/${finding.subject}.json (add that form file, run 'forms pull' if the site has it, or fix the key)`
|
|
30
|
+
: `places the form '${finding.subject}', which is not a form key (lowercase letters, digits and single hyphens)`,
|
|
31
|
+
"form.not_supported_on_site_type": () => "places a form, but forms are available on standard sites only",
|
|
32
|
+
"form.after_submit_page_unpublished": (finding) =>
|
|
33
|
+
`the form '${finding.detail}' sends visitors to ${finding.subject || "a page that no longer exists"}, which the release does not publish`,
|
|
34
|
+
"form.after_submit_page_not_allowed": (finding) =>
|
|
35
|
+
`the form '${finding.detail}' sends visitors to ${finding.subject}, which is not a page you created`,
|
|
36
|
+
"redirect.source_invalid": () => "has a source that is not an absolute site path",
|
|
37
|
+
"redirect.source_index_html": () => "has a source ending in index.html",
|
|
38
|
+
"redirect.source_integration_data": () => "has a source under the integration data root",
|
|
39
|
+
"redirect.source_share_link": () => "has a source under the share-link root",
|
|
40
|
+
"redirect.source_runtime_mirror": () => "has a source under the published-site runtime",
|
|
41
|
+
"redirect.source_generated_file": () => "has a source naming a file the site generates",
|
|
42
|
+
"redirect.source_not_storable": () => "has a source the publisher cannot store",
|
|
43
|
+
"seo.h1_missing": () =>
|
|
44
|
+
"has no h1: no level 1 heading, banner title or hero title opens the page, so search engines find no page title",
|
|
45
|
+
"seo.h1_demoted": (finding) =>
|
|
46
|
+
`has the level 1 heading '${finding.subject}', which publishes as an h2 because ${H1_CLAIMANT[finding.detail] ?? "another element"} is already the page's h1`,
|
|
47
|
+
"seo.h1_multiple": (finding) =>
|
|
48
|
+
`has the hero title '${finding.subject}', which is a second h1 because ${H1_CLAIMANT[finding.detail] ?? "another element"} is already the page's h1`,
|
|
49
|
+
"seo.heading_renumbered": (finding) => {
|
|
50
|
+
const [authored, published] = finding.detail.split(">");
|
|
51
|
+
return `has the heading '${finding.subject}' written as an h${authored}, which publishes as an h${published} so no level is skipped`;
|
|
52
|
+
},
|
|
53
|
+
"seo.heading_before_title": (finding) =>
|
|
54
|
+
`has the heading '${finding.subject}' before the page's h1; it publishes as an h${finding.detail}`,
|
|
55
|
+
"a11y.image_alt_missing": () => "places an image with no alt text",
|
|
56
|
+
"a11y.link_text_empty": (finding) => `has a link to ${finding.subject} with no text`,
|
|
57
|
+
"seo.title_long": (finding) =>
|
|
58
|
+
`has a title of ${finding.detail} characters; search results show about ${TITLE_WARN_LENGTH}`,
|
|
59
|
+
"seo.description_missing": () => "has no description; search results use the start of the page instead",
|
|
60
|
+
"seo.description_long": (finding) =>
|
|
61
|
+
`has a description of ${finding.detail} characters; search results show about ${DESCRIPTION_WARN_LENGTH}`,
|
|
62
|
+
"seo.social_image_fallback": (finding) =>
|
|
63
|
+
finding.detail === "first_image"
|
|
64
|
+
? "has no cover image; link previews use the page's first image only when it is public and at least 1200 pixels wide"
|
|
65
|
+
: "has no cover image and no images, so link previews show no picture",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** What a finding's `detail` names as the element that already claims the page's h1. */
|
|
69
|
+
const H1_CLAIMANT = Object.freeze({
|
|
70
|
+
heading: "the first level 1 heading",
|
|
71
|
+
banner: "the banner at the top",
|
|
72
|
+
hero: "the hero title",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** The sentence for a finding, falling back to the rule id for a rule this release has no words for. */
|
|
76
|
+
export function describeSiteFinding(finding) {
|
|
77
|
+
const words = SITE_CHECK_WORDING[finding.ruleId];
|
|
78
|
+
return words === undefined ? `was flagged by the site check '${finding.ruleId}'` : words(finding);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Progress names this many; the JSON list is bounded by bytes instead.
|
|
82
|
+
const MAXIMUM_PROGRESS_LINES = 20;
|
|
83
|
+
|
|
84
|
+
const rulesRunningAt = (point) => SITE_CHECK_RULES.filter((rule) => rule.runsAt.includes(point)).map((rule) => rule.id);
|
|
85
|
+
|
|
86
|
+
// The document rules `validate` runs offline, as the set the shared evaluator filters by.
|
|
87
|
+
const OFFLINE_DOCUMENT_RULES = new Set(
|
|
88
|
+
SITE_CHECK_RULES.filter((rule) => rule.scope === "document" && rule.runsAt.includes("validate")).map((rule) => rule.id),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/** The page-structure findings for one planned page, or none for a document the rules cannot read. */
|
|
92
|
+
function structureFindingsFor(page) {
|
|
93
|
+
if (page.document === null || typeof page.document !== "object") return [];
|
|
94
|
+
const template = documentTemplate(page.document);
|
|
95
|
+
// A generated page's title and description come from the site, not from an author.
|
|
96
|
+
if (template === TEMPLATE_GENERATED) return [];
|
|
97
|
+
const freeForm = template === TEMPLATE_FREE_FORM;
|
|
98
|
+
return pageStructureFindings(
|
|
99
|
+
{
|
|
100
|
+
freeForm,
|
|
101
|
+
body: freeForm ? page.document : undefined,
|
|
102
|
+
title: page.title,
|
|
103
|
+
description: page.declaredDescription ?? page.description,
|
|
104
|
+
},
|
|
105
|
+
OFFLINE_DOCUMENT_RULES,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Evaluates the offline page rules over the planned pages. `pagePaths` is every
|
|
111
|
+
* page path the workspace knows, and `pagesComplete` says whether that list is
|
|
112
|
+
* the whole site: a truncated manifest cannot say a link is broken.
|
|
113
|
+
*/
|
|
114
|
+
export function evaluateOfflineSiteChecks({
|
|
115
|
+
planned,
|
|
116
|
+
pagePaths,
|
|
117
|
+
pagesComplete = true,
|
|
118
|
+
formKeysChecked = true,
|
|
119
|
+
onProgress = () => {},
|
|
120
|
+
}) {
|
|
121
|
+
const known = normalizedPathSet([...pagePaths, ...(planned ?? []).map((page) => page.pagePath)]);
|
|
122
|
+
|
|
123
|
+
const found = [];
|
|
124
|
+
for (const page of planned ?? []) {
|
|
125
|
+
if (page.document === null || typeof page.document !== "object") continue;
|
|
126
|
+
// The link rule needs every page path; the structure rules read only the page itself.
|
|
127
|
+
const linkFindings = pagesComplete ? missingPageLinkFindings(page.document, page.pagePath ?? "", known) : [];
|
|
128
|
+
for (const finding of [...linkFindings, ...structureFindingsFor(page)]) found.push({ page, finding });
|
|
129
|
+
}
|
|
130
|
+
// Errors first, then in catalogue order, so the bounded list and the progress lines keep the rules that
|
|
131
|
+
// matter most when a large site raises a frequent one (a missing description) on every page.
|
|
132
|
+
const catalogueOrder = (ruleId) => SITE_CHECK_RULES.findIndex((rule) => rule.id === ruleId);
|
|
133
|
+
const severityOrder = (finding) => (finding.severity === "error" ? 0 : 1);
|
|
134
|
+
found.sort((left, right) =>
|
|
135
|
+
severityOrder(left.finding) - severityOrder(right.finding)
|
|
136
|
+
|| catalogueOrder(left.finding.ruleId) - catalogueOrder(right.finding.ruleId)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const items = [];
|
|
140
|
+
for (const { page, finding } of found) {
|
|
141
|
+
const subject = [...sanitizeDiagnostic(finding.subject, "")].slice(0, 300).join("");
|
|
142
|
+
const item = {
|
|
143
|
+
rule: finding.ruleId,
|
|
144
|
+
severity: finding.severity,
|
|
145
|
+
file: page.file,
|
|
146
|
+
path: displayPagePath(page.pagePath),
|
|
147
|
+
subject,
|
|
148
|
+
...(finding.detail === "" ? {} : { detail: finding.detail }),
|
|
149
|
+
};
|
|
150
|
+
if (items.length < MAXIMUM_PROGRESS_LINES) {
|
|
151
|
+
onProgress(`Warning: ${item.file} ${describeSiteFinding({ ...finding, subject })}.`);
|
|
152
|
+
}
|
|
153
|
+
items.push(item);
|
|
154
|
+
}
|
|
155
|
+
if (items.length > MAXIMUM_PROGRESS_LINES) {
|
|
156
|
+
onProgress(`Warning: ${items.length - MAXIMUM_PROGRESS_LINES} more site check finding(s); see findings.`);
|
|
157
|
+
}
|
|
158
|
+
const reported = boundedByBytes(items, 8 * 1024);
|
|
159
|
+
const errors = items.filter((item) => item.severity === "error").length;
|
|
160
|
+
return {
|
|
161
|
+
findings: {
|
|
162
|
+
total: items.length,
|
|
163
|
+
errors,
|
|
164
|
+
warnings: items.length - errors,
|
|
165
|
+
items: reported.items,
|
|
166
|
+
...(reported.truncated ? { truncated: true } : {}),
|
|
167
|
+
},
|
|
168
|
+
siteChecks: {
|
|
169
|
+
ranOffline: rulesRunningAt("validate"),
|
|
170
|
+
// Rules that need the site's own records; `taproot-site status` shows what the site finds.
|
|
171
|
+
checkedBySite: SITE_CHECK_RULES.filter((rule) => !rule.runsAt.includes("validate") && rule.runsAt.includes("health"))
|
|
172
|
+
.map((rule) => rule.id),
|
|
173
|
+
// Rules the deploy itself applies; the deploy refusal names them, and no read reports them beforehand.
|
|
174
|
+
checkedAtDeploy: SITE_CHECK_RULES.filter((rule) =>
|
|
175
|
+
!rule.runsAt.includes("validate") && !rule.runsAt.includes("health") && rule.runsAt.includes("deploy")
|
|
176
|
+
).map((rule) => rule.id),
|
|
177
|
+
// Rules only the page editor evaluates: a cover, a description field, or a description's length (which the CLI reports as descriptionWarnings).
|
|
178
|
+
checkedInEditor: SITE_CHECK_RULES.filter((rule) =>
|
|
179
|
+
!rule.runsAt.includes("validate") && !rule.runsAt.includes("health") && !rule.runsAt.includes("deploy")
|
|
180
|
+
&& rule.runsAt.includes("edit")
|
|
181
|
+
).map((rule) => rule.id),
|
|
182
|
+
// A rule that had nothing to read: a truncated page manifest, or a workspace with no form files.
|
|
183
|
+
...(pagesComplete && formKeysChecked
|
|
184
|
+
? {}
|
|
185
|
+
: { skipped: [...(pagesComplete ? [] : ["link.page_missing"]), ...(formKeysChecked ? [] : ["form.key_unknown"])] }),
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|