@taprootio/site-authoring 0.21.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 CHANGED
@@ -150,8 +150,17 @@ autolinking), and `pages push` reports the same for the pages it sends.
150
150
  `validate` also runs the site checks that need nothing but the workspace, from
151
151
  the same rule catalogue the editor and the site-health page read. `findings`
152
152
  lists each one as a rule id (`link.page_missing`, a link to a page the workspace
153
- does not have), a severity, the file, and the value found; a warning never
154
- refuses. `siteChecks.checkedBySite` names the rules that need the site's own
153
+ does not have), a severity, the file, and the value found (and a `detail` for
154
+ some); a warning never refuses. For a free-form page the rules also read its headings,
155
+ images and links the way the publisher does: `seo.h1_missing` when no `#` heading,
156
+ banner title or hero title is the page's `<h1>`; `seo.h1_demoted` when a `#` heading
157
+ publishes as an `<h2>` because the banner's overlay text, a hero title or an earlier
158
+ `#` heading already is the `<h1>` (`detail` says which); `seo.h1_multiple` for a hero
159
+ title after the `<h1>`; `seo.heading_renumbered` and `seo.heading_before_title` for
160
+ headings published at another level; and `a11y.image_alt_missing` and
161
+ `a11y.link_text_empty`. Any page gets `seo.title_long` and `seo.description_missing`.
162
+ `siteChecks.checkedInEditor` names the rules only the page editor evaluates (a cover, a description field, or a
163
+ description's length, which `descriptionWarnings` reports here). `siteChecks.checkedBySite` names the rules that need the site's own
155
164
  records, such as placed images and forms: `status` reports those under
156
165
  `siteHealth`; `siteChecks.checkedAtDeploy` names the ones only a deploy applies. A redirect source the site would refuse also names its rule in
157
166
  `error.problems[].rule`.
@@ -718,7 +727,11 @@ on the site, sends the form's id, and refuses a key the site does not have
718
727
  (`pages.form_unknown`) or has archived (`pages.form_archived`) before it writes
719
728
  anything; `plan` reports the same. `pages pull` writes the key back. An id the
720
729
  site no longer has stays an id in the pulled file, and a push of it is refused
721
- until you choose a form. Form names are now unique on a site, ignoring case.
730
+ until you choose a form. Form names are now unique on a site, ignoring case. `validate`
731
+ checks the same keys offline against the workspace's own form files: when the
732
+ workspace has a `forms/` directory, a `formKey` no `forms/<key>.json` in it names is refused
733
+ (`pages.form_unknown`, rule `form.key_unknown`) naming the file it expected. A workspace
734
+ with no `forms/` directory is not checked, because its forms may live only on the site.
722
735
  The deploy that introduces this rewrites the stored page bodies, which changes the
723
736
  body revision of every page that places a form, so the first `pages pull` after it
724
737
  can report `pages.pull_conflict` for such a Markdown page even though its content
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -1,5 +1,17 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.22.0",
5
+ "note": "validate warns when a free-form page has no h1 or a banner or hero already claims it, and about alt text, link text, long titles and missing descriptions.",
6
+ "behavior": [
7
+ "validate reports page-structure warnings in findings, never refusing: seo.h1_missing for a free-form page with no # heading, banner title or hero title; seo.h1_demoted when a # heading publishes as an h2 because the banner's overlay text, a hero title or an earlier # heading is already the h1 (detail names which); seo.h1_multiple for a hero title after the h1; seo.heading_renumbered and seo.heading_before_title for headings published at another level; a11y.image_alt_missing; a11y.link_text_empty. Any page gets seo.title_long and seo.description_missing.",
8
+ "validate's siteChecks gains checkedInEditor, the rules only the page editor evaluates (a cover, a description field, or a description's length, which descriptionWarnings reports here).",
9
+ "The API refuses a page save that newly places a form that does not exist or is archived, and a first form block on a Profile or Docs site, with a validation error on Forms; status lists form.not_supported_on_site_type among the site findings. preview-page now renders the form a page places, from its current version.",
10
+ "validate refuses a component:form formKey that none of the workspace's forms/<key>.json files has (pages.form_unknown, rule form.key_unknown), naming the file it expected; a workspace with no forms/ directory is not checked. pages push and plan say whether the missing key's file is in the workspace.",
11
+ "pull writes a stored light or dark theme as the generator renders it: as stored over Espalier's defaults alone. It no longer layers the Taproot house theme under a theme the CLI does not manage, so a sparse theme from such a site pulls with Espalier's values for the keys it never set, so the pulled theme matches what the published site renders."
12
+ ],
13
+ "help": []
14
+ },
3
15
  {
4
16
  "version": "0.21.0",
5
17
  "note": "Review URLs stay out of deploy output unless you ask; deploy --staging waits until staging serves the new release; validate reports site checks; help corrections for free-form h1, legal pages, settings and logos; this changelog.",
package/src/cli.js CHANGED
@@ -125,7 +125,13 @@ const VERBS = Object.freeze([
125
125
  + "linkWarnings without refusing; pages push reports the same for the pages it sends. "
126
126
  + "The site checks that can run offline are reported in findings (a rule id, severity, file, and the value "
127
127
  + "found, such as link.page_missing for a link to a page the workspace does not have) and never refuse a "
128
- + "warning; siteChecks.checkedBySite names the rules that need the site's own records (images, forms), which "
128
+ + "warning. For a free-form page they include the page-structure rules: seo.h1_missing when no heading, banner "
129
+ + "title or hero title is the page's h1, seo.h1_demoted when a # heading is published as an h2 because the "
130
+ + "banner's overlay text, a hero title or an earlier # heading already is the h1 (detail names which), "
131
+ + "seo.heading_renumbered and seo.heading_before_title for headings published at another level, "
132
+ + "a11y.image_alt_missing and a11y.link_text_empty; for any page seo.title_long and seo.description_missing. "
133
+ + "siteChecks.checkedInEditor names the rules only the page editor evaluates (a cover, a description field, or a description's length, which descriptionWarnings reports here); "
134
+ + "siteChecks.checkedBySite names the rules that need the site's own records (images, forms), which "
129
135
  + "'status' reports under siteHealth. A redirect source the site would refuse names its rule in "
130
136
  + "error.problems[].rule. This proves local structure and semantics only. It does not prove "
131
137
  + "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
@@ -332,7 +338,7 @@ const VERBS = Object.freeze([
332
338
  note:
333
339
  "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
334
340
  + "A page names a form by key (formKey); the push looks each key up on the site, sends the form's id, and "
335
- + "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing. "
341
+ + "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing, and says whether the key's forms/<key>.json is in the workspace. validate refuses the same key offline (rule form.key_unknown) when the workspace has a forms/ directory and no file in it is for that key. "
336
342
  + "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
337
343
  + "(content.link_autolinked) without refusing. "
338
344
  + "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
@@ -53,6 +53,28 @@ export async function readWorkspaceForms(workspaceDir, keys, validationOptions =
53
53
  return forms;
54
54
  }
55
55
 
56
+ /**
57
+ * The keys of the form files the workspace holds, read from file names alone (TR01081), or undefined
58
+ * when the folder cannot be read (a name outside the safe grammar, a link, too many files): the
59
+ * answer is only a hint for a refusal, so a workspace that cannot say changes nothing, and
60
+ * `forms validate` reports the folder's problem. A file whose name is not a form key is left out.
61
+ */
62
+ export async function workspaceFormKeys(workspaceDir) {
63
+ let files;
64
+ try {
65
+ files = await walkWorkspaceFiles(workspaceDir, FORMS_DIRECTORY, [FORM_FILE_EXTENSION]);
66
+ } catch (error) {
67
+ if (error instanceof SiteAuthoringError) return undefined;
68
+ throw error;
69
+ }
70
+ const keys = new Set();
71
+ for (const file of files) {
72
+ const key = file.slice(FORMS_DIRECTORY.length + 1, -FORM_FILE_EXTENSION.length);
73
+ if (!key.includes("/") && isFormKey(key)) keys.add(key);
74
+ }
75
+ return keys;
76
+ }
77
+
56
78
  /** A form name as the site compares it: trimmed, ignoring case (TR01216). */
57
79
  export function comparableFormName(name) {
58
80
  return String(name).trim().toLowerCase();
@@ -0,0 +1,76 @@
1
+ // Generated from shared/heading-outline.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * How a published page numbers its headings (TR00929, TR00946, TR00947).
4
+ *
5
+ * The generator renders a page with this state machine, and the page-structure
6
+ * checks in `page-structure-checks.ts` replay the same machine over a document
7
+ * to tell an author which element claims the page's `<h1>` and which headings
8
+ * the publisher renumbers. One owner keeps what the warning says in step with
9
+ * what ships. The generator's `component-renderer.ts` and the site-authoring
10
+ * package import copies of this file; `node scripts/sync-field-validation.mjs`
11
+ * writes them, so edit only this file.
12
+ *
13
+ * It is dependency-free so every consumer can carry a copy.
14
+ */
15
+ export function createHeadingOutlineState(options = {}) {
16
+ const titleOpen = options.titleOpen === true;
17
+ const parentLevel = options.parentLevel ?? 1;
18
+ return { level: parentLevel, titleOpen, titleClaimed: !titleOpen, parentLevel, open: [] };
19
+ }
20
+ // Not `Array.prototype.at`: the editor's TypeScript library predates it.
21
+ function lastOpen(state) {
22
+ return state.open[state.open.length - 1];
23
+ }
24
+ /**
25
+ * The level to render an author heading at (TR00946). It closes the open
26
+ * headings at its authored level or deeper, then sits one level below the
27
+ * heading still open, so an h3 straight after the page's h1 becomes an h2,
28
+ * and so do the h3s after it until the author's own h2. A free-form page's
29
+ * first author h1 is its title; any other h1 is demoted.
30
+ */
31
+ export function nextHeadingLevel(state, authored) {
32
+ if (!state)
33
+ return authored;
34
+ if (authored === 1 && !state.titleClaimed) {
35
+ recordFixedHeading(state, 1);
36
+ return 1;
37
+ }
38
+ while ((lastOpen(state)?.authored ?? 0) >= authored)
39
+ state.open.pop();
40
+ const level = Math.min(6, (lastOpen(state)?.level ?? state.parentLevel) + 1);
41
+ state.open.push({ authored, level });
42
+ state.level = level;
43
+ return level;
44
+ }
45
+ /** A heading whose level the page fixes, such as a hero title or CTA heading. */
46
+ export function recordFixedHeading(state, level) {
47
+ if (level === 1)
48
+ state.titleClaimed = true;
49
+ while ((lastOpen(state)?.level ?? 0) >= level)
50
+ state.open.pop();
51
+ state.open.push({ authored: level, level });
52
+ state.level = level;
53
+ }
54
+ /**
55
+ * Whether an Image Banner is the page's title: it opens a free-form page,
56
+ * shows an image, and carries overlay text. Without an image the overlay is
57
+ * not drawn, so the banner carries no heading.
58
+ */
59
+ export function bannerOpensPage(state, data) {
60
+ return state?.titleOpen === true
61
+ && Boolean(data.image?.src)
62
+ && typeof data.overlayText === "string"
63
+ && data.overlayText.trim() !== "";
64
+ }
65
+ /**
66
+ * The level of the heading a hero or CTA component fixes on the page, or
67
+ * undefined when the component carries none. A hero title is always an h1;
68
+ * a CTA heading is an h2.
69
+ */
70
+ export function componentFixedHeadingLevel(type, data) {
71
+ if (type === "hero-section" && data.title)
72
+ return 1;
73
+ if (type === "cta" && data.heading)
74
+ return 2;
75
+ return undefined;
76
+ }
@@ -1,4 +1,5 @@
1
1
  import { SiteAuthoringError } from "./errors.js";
2
+ import { DESCRIPTION_WARN_LENGTH } from "./page-structure-checks.js";
2
3
  import { boundedByBytes } from "./session.js";
3
4
  import { displayPagePath } from "./workspace.js";
4
5
 
@@ -7,7 +8,7 @@ import { displayPagePath } from "./workspace.js";
7
8
  * 160 characters search engines cut it off, so it is warned about; past 1000 it
8
9
  * is refused, here and by the API (TR01194).
9
10
  */
10
- export const DESCRIPTION_WARN_LENGTH = 160;
11
+ export { DESCRIPTION_WARN_LENGTH };
11
12
  export const DESCRIPTION_MAXIMUM_LENGTH = 1000;
12
13
  export const PAGE_DESCRIPTION_LONG = "pages.description_long";
13
14
 
@@ -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
+ }
@@ -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). The read-only
204
- // provenance flag says whether Taproot's house look still layers under it.
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
  }
@@ -1,7 +1,10 @@
1
1
  import { sanitizeDiagnostic } from "./errors.js";
2
2
  import { boundedByBytes } from "./session.js";
3
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";
4
6
  import { missingPageLinkFindings, normalizedPathSet } from "./site-checks.js";
7
+ import { documentTemplate, TEMPLATE_FREE_FORM, TEMPLATE_GENERATED } from "./typed-pages.js";
5
8
  import { displayPagePath } from "./workspace.js";
6
9
 
7
10
  /**
@@ -21,6 +24,11 @@ export const SITE_CHECK_WORDING = Object.freeze({
21
24
  "form.unreadable": () => "places a form by an old key or an id the site cannot read",
22
25
  "form.missing": () => "places a form that does not exist",
23
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",
24
32
  "form.after_submit_page_unpublished": (finding) =>
25
33
  `the form '${finding.detail}' sends visitors to ${finding.subject || "a page that no longer exists"}, which the release does not publish`,
26
34
  "form.after_submit_page_not_allowed": (finding) =>
@@ -32,6 +40,36 @@ export const SITE_CHECK_WORDING = Object.freeze({
32
40
  "redirect.source_runtime_mirror": () => "has a source under the published-site runtime",
33
41
  "redirect.source_generated_file": () => "has a source naming a file the site generates",
34
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",
35
73
  });
36
74
 
37
75
  /** The sentence for a finding, falling back to the rule id for a rule this release has no words for. */
@@ -45,32 +83,74 @@ const MAXIMUM_PROGRESS_LINES = 20;
45
83
 
46
84
  const rulesRunningAt = (point) => SITE_CHECK_RULES.filter((rule) => rule.runsAt.includes(point)).map((rule) => rule.id);
47
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
+
48
109
  /**
49
110
  * Evaluates the offline page rules over the planned pages. `pagePaths` is every
50
111
  * page path the workspace knows, and `pagesComplete` says whether that list is
51
112
  * the whole site: a truncated manifest cannot say a link is broken.
52
113
  */
53
- export function evaluateOfflineSiteChecks({ planned, pagePaths, pagesComplete = true, onProgress = () => {} }) {
114
+ export function evaluateOfflineSiteChecks({
115
+ planned,
116
+ pagePaths,
117
+ pagesComplete = true,
118
+ formKeysChecked = true,
119
+ onProgress = () => {},
120
+ }) {
54
121
  const known = normalizedPathSet([...pagePaths, ...(planned ?? []).map((page) => page.pagePath)]);
55
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
+
56
139
  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
- }
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 })}.`);
73
152
  }
153
+ items.push(item);
74
154
  }
75
155
  if (items.length > MAXIMUM_PROGRESS_LINES) {
76
156
  onProgress(`Warning: ${items.length - MAXIMUM_PROGRESS_LINES} more site check finding(s); see findings.`);
@@ -91,9 +171,18 @@ export function evaluateOfflineSiteChecks({ planned, pagePaths, pagesComplete =
91
171
  checkedBySite: SITE_CHECK_RULES.filter((rule) => !rule.runsAt.includes("validate") && rule.runsAt.includes("health"))
92
172
  .map((rule) => rule.id),
93
173
  // 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"] }),
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"])] }),
97
186
  },
98
187
  };
99
188
  }
@@ -1,7 +1,7 @@
1
1
  {
2
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"],
3
+ "about": "The catalogue of site checks (TR01085, TR00947). Severity: 'error' blocks a deploy, 'warning' informs. Scope: 'page' rules read one page body plus cheap site lookups, 'site' rules read the site's own records, 'document' rules read only one document and its metadata, so the editor and the CLI evaluate them from shared/page-structure-checks.ts and the server does not. runsAt names the run points that evaluate the rule: save, validate (the CLI, offline), deploy, health, edit (the editor, over the open document in the browser). appliesTo, when present, limits a rule to those page types. 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", "edit"],
5
5
  "rules": [
6
6
  { "id": "link.page_missing", "severity": "warning", "scope": "page", "runsAt": ["save", "validate", "health"], "subject": "page_path", "detail": "none" },
7
7
  { "id": "image.missing", "severity": "warning", "scope": "page", "runsAt": ["save", "health"], "subject": "image_id", "detail": "none" },
@@ -9,14 +9,27 @@
9
9
  { "id": "form.unreadable", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_reference_text", "detail": "none" },
10
10
  { "id": "form.missing", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_id", "detail": "none" },
11
11
  { "id": "form.archived", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_id", "detail": "form_name" },
12
+ { "id": "form.not_supported_on_site_type", "severity": "error", "scope": "page", "runsAt": ["save", "health"], "subject": "none", "detail": "none" },
12
13
  { "id": "form.after_submit_page_unpublished", "severity": "error", "scope": "site", "runsAt": ["deploy"], "subject": "page_path", "detail": "form_name" },
13
14
  { "id": "form.after_submit_page_not_allowed", "severity": "error", "scope": "site", "runsAt": ["deploy"], "subject": "page_path", "detail": "form_name" },
15
+ { "id": "form.key_unknown", "severity": "error", "scope": "site", "runsAt": ["validate"], "subject": "form_key", "detail": "none" },
14
16
  { "id": "redirect.source_invalid", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "none" },
15
17
  { "id": "redirect.source_index_html", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
16
18
  { "id": "redirect.source_integration_data", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
17
19
  { "id": "redirect.source_share_link", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
18
20
  { "id": "redirect.source_runtime_mirror", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
19
21
  { "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" }
22
+ { "id": "redirect.source_not_storable", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
23
+ { "id": "seo.h1_missing", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "none", "detail": "none" },
24
+ { "id": "seo.h1_demoted", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "heading_text", "detail": "h1_claimant" },
25
+ { "id": "seo.h1_multiple", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "heading_text", "detail": "h1_claimant" },
26
+ { "id": "seo.heading_renumbered", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "heading_text", "detail": "authored_and_published_level" },
27
+ { "id": "seo.heading_before_title", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "heading_text", "detail": "published_level" },
28
+ { "id": "a11y.image_alt_missing", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "image_id", "detail": "none" },
29
+ { "id": "a11y.link_text_empty", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "link_target", "detail": "none" },
30
+ { "id": "seo.title_long", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit"], "subject": "none", "detail": "character_count" },
31
+ { "id": "seo.description_missing", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit"], "subject": "none", "detail": "none" },
32
+ { "id": "seo.description_long", "severity": "warning", "scope": "document", "runsAt": ["edit"], "subject": "none", "detail": "character_count" },
33
+ { "id": "seo.social_image_fallback", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["edit"], "subject": "none", "detail": "social_image_source" }
21
34
  ]
22
35
  }
@@ -15,6 +15,7 @@
15
15
  * and re-run it.
16
16
  */
17
17
  export const RULE_LINK_PAGE_MISSING = "link.page_missing";
18
+ export const RULE_FORM_KEY_UNKNOWN = "form.key_unknown";
18
19
  export const RULE_REDIRECT_SOURCE_INVALID = "redirect.source_invalid";
19
20
  export const RULE_REDIRECT_SOURCE_INDEX_HTML = "redirect.source_index_html";
20
21
  export const RULE_REDIRECT_SOURCE_INTEGRATION_DATA = "redirect.source_integration_data";
@@ -222,6 +223,21 @@ export function missingPageLinkFindings(templateData, currentPagePath, knownPath
222
223
  .filter((link) => !knownPaths.has(normalizeStoredPath(link).toLowerCase()))
223
224
  .map((link) => ({ ruleId: RULE_LINK_PAGE_MISSING, severity: "warning", subject: link, detail: "" }));
224
225
  }
226
+ /**
227
+ * The `form.key_unknown` findings (TR01081): every placed form key that no form file of the
228
+ * workspace has. A workspace names a form by key (`forms/<key>.json`); the server stores the id.
229
+ * `knownKeys` is the workspace's own form keys, and a caller with no form files passes nothing
230
+ * to check, since a workspace for a site whose forms were created elsewhere must still push.
231
+ */
232
+ export function unknownFormKeyFindings(placedKeys, knownKeys) {
233
+ const findings = [];
234
+ for (const key of new Set(placedKeys)) {
235
+ if (!knownKeys.has(key)) {
236
+ findings.push({ ruleId: RULE_FORM_KEY_UNKNOWN, severity: "error", subject: key, detail: "" });
237
+ }
238
+ }
239
+ return findings;
240
+ }
225
241
  // ---------------------------------------------------------------- redirects
226
242
  const RUNTIME_MIRROR_PATH = /^taproot\/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\/|$)/iu;
227
243
  /** Whether a site-relative path (no leading slash) is `taproot/<semver>` or under it. */
@@ -1,17 +1,7 @@
1
- import {
2
- buildTaprootDarkTheme,
3
- buildTaprootLightTheme,
4
- DEFAULT_DARK_THEME,
5
- DEFAULT_LIGHT_THEME,
6
- encodeTheme,
7
- layerThemes,
8
- mergePartials,
9
- mergeTheme,
10
- parseTheme,
11
- } from "@taprootio/espalier/shared/theme";
1
+ import { encodeTheme, layerThemes, mergePartials, mergeTheme, parseTheme } from "@taprootio/espalier/shared/theme";
12
2
 
13
3
  import { SiteAuthoringError } from "./errors.js";
14
- import { authoredMappingTokens, missingThemeFields } from "./theme-validation.js";
4
+ import { authoredMappingTokens, missingThemeFields, SCHEME_DEFAULTS } from "./theme-validation.js";
15
5
 
16
6
  /**
17
7
  * Project one stored scheme theme to the complete theme a consumer actually
@@ -19,13 +9,14 @@ import { authoredMappingTokens, missingThemeFields } from "./theme-validation.js
19
9
  *
20
10
  * Taproot stores what was written. A site whose theme was seeded or written
21
11
  * before a key existed keeps a sparse document, and every consumer — the
22
- * app's preview, the generator, the published runtime — resolves it by
23
- * layering the same defaults underneath: Espalier's scheme defaults plus,
24
- * for a theme Taproot still manages, the Taproot house theme with an empty
25
- * page background. A theme the CLI manages (`*ThemeManagedExternally`) is
26
- * rendered as stored over Espalier's defaults alone. `pull` writes exactly
27
- * that effective theme, so a fresh workspace validates and a designer edits
28
- * every group with its authoritative value instead of reconstructing it.
12
+ * app's preview, the generator, the published runtime — resolves it the same
13
+ * way for every theme: as stored, over Espalier's scheme defaults alone. No
14
+ * renderer layers the Taproot house theme under a stored theme any more
15
+ * (TR00879), whoever manages it. (The site editor keeps a house reference
16
+ * layer for its own inheritance display; it is inert for a complete theme and
17
+ * is not what the site publishes.) `pull` writes exactly that effective theme,
18
+ * so a fresh workspace validates and a designer edits every group with its
19
+ * authoritative value instead of reconstructing it.
29
20
  *
30
21
  * Semantic mappings are the one group that is *not* filled from defaults.
31
22
  * Espalier compiles them from `roles` at merge time, and since 4.18.0 the
@@ -52,40 +43,27 @@ import { authoredMappingTokens, missingThemeFields } from "./theme-validation.js
52
43
  * rendered before the pull.
53
44
  */
54
45
 
55
- const SCHEME_DEFAULTS = Object.freeze({
56
- light: { defaults: DEFAULT_LIGHT_THEME, house: buildTaprootLightTheme },
57
- dark: { defaults: DEFAULT_DARK_THEME, house: buildTaprootDarkTheme },
58
- });
46
+ // Espalier's defaults carry no page background at all, but a stored theme must have both keys. No image is
47
+ // the rendered truth, and the opacity of an absent image is neutral; these are the values pull has always
48
+ // written there, so a pulled theme does not change.
49
+ const NO_IMAGE_BACKGROUND_OPACITY = Object.freeze({ light: 0.5, dark: 0.55 });
59
50
 
60
51
  function isPlainObject(value) {
61
52
  return value !== null && typeof value === "object" && !Array.isArray(value);
62
53
  }
63
54
 
64
- /**
65
- * The complete theme a consumer layers a stored theme onto: Espalier's
66
- * scheme defaults, and — unless the theme is managed externally — the
67
- * Taproot house theme with the empty page background both the BFF and the
68
- * generator clear before rendering.
69
- */
70
- export function effectiveThemeBase(scheme, { managedExternally = false } = {}) {
71
- const { defaults, house } = SCHEME_DEFAULTS[scheme];
72
- const houseTheme = parseTheme(house("")) ?? {};
73
- if (managedExternally) {
74
- // Espalier's defaults carry no page background at all; the stored
75
- // contract requires both keys. No image is the rendered truth, and the
76
- // opacity of an absent image is neutral, so the house value fills it.
77
- return {
78
- ...defaults,
79
- pageBackgroundImage: "",
80
- pageBackgroundImageOpacity: houseTheme.pageBackgroundImageOpacity,
81
- };
82
- }
83
- return { ...mergePartials(defaults, houseTheme), pageBackgroundImage: "" };
55
+ /** The complete theme a consumer layers a stored theme onto: Espalier's scheme defaults, with no page background. */
56
+ export function effectiveThemeBase(scheme) {
57
+ return {
58
+ ...SCHEME_DEFAULTS[scheme],
59
+ pageBackgroundImage: "",
60
+ pageBackgroundImageOpacity: NO_IMAGE_BACKGROUND_OPACITY[scheme],
61
+ };
84
62
  }
85
63
 
86
- export function projectPulledTheme(stored, scheme, { managedExternally = false } = {}) {
64
+ export function projectPulledTheme(stored, scheme) {
87
65
  const source = isPlainObject(stored) ? stored : {};
88
- const base = effectiveThemeBase(scheme, { managedExternally });
66
+ const base = effectiveThemeBase(scheme);
89
67
  const { semanticMappings: _baseMappings, ...layerable } = base;
90
68
  // The marker is set on the base so mergePartials treats every stored
91
69
  // mapping as authored; the pin filter below then decides which survive.
@@ -102,7 +80,6 @@ export function projectPulledTheme(stored, scheme, { managedExternally = false }
102
80
  { ...merged, semanticMappings: pins, explicitMappingTokens: Object.keys(pins) },
103
81
  source,
104
82
  scheme,
105
- managedExternally,
106
83
  authored,
107
84
  );
108
85
 
@@ -111,12 +88,8 @@ export function projectPulledTheme(stored, scheme, { managedExternally = false }
111
88
  }
112
89
 
113
90
  /** What a consumer renders for a stored theme: the partial it layers, resolved over Espalier's defaults. */
114
- function renderedTheme(stored, scheme, managedExternally) {
115
- const { defaults, house } = SCHEME_DEFAULTS[scheme];
116
- const layered = managedExternally
117
- ? layerThemes(encodeTheme(stored))
118
- : layerThemes(house(""), encodeTheme({ pageBackgroundImage: "" }), encodeTheme(stored));
119
- return mergeTheme(defaults, parseTheme(layered) ?? {});
91
+ function renderedTheme(stored, scheme) {
92
+ return mergeTheme(SCHEME_DEFAULTS[scheme], parseTheme(layerThemes(encodeTheme(stored))) ?? {});
120
93
  }
121
94
 
122
95
  function canonicalJson(value) {
@@ -137,11 +110,11 @@ function canonicalJson(value) {
137
110
  * out of the projection and keep recompiling. Every other difference between
138
111
  * the projection and the stored theme's rendering is written back.
139
112
  */
140
- function reconcileWithRendering(projected, stored, scheme, managedExternally, authored) {
141
- const { defaults } = SCHEME_DEFAULTS[scheme];
113
+ function reconcileWithRendering(projected, stored, scheme, authored) {
114
+ const defaults = SCHEME_DEFAULTS[scheme];
142
115
  let rendered;
143
116
  try {
144
- rendered = renderedTheme(stored, scheme, managedExternally);
117
+ rendered = renderedTheme(stored, scheme);
145
118
  } catch {
146
119
  // An invalid stored theme cannot be rendered for comparison; validation
147
120
  // reports it, and the layered projection is the best available document.
@@ -300,7 +300,8 @@ function refuseAgentStylesheets(theme, scheme) {
300
300
  }
301
301
  }
302
302
 
303
- const SCHEME_DEFAULTS = Object.freeze({ light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME });
303
+ /** Espalier's complete default theme for each scheme: the base every stored theme is rendered over. */
304
+ export const SCHEME_DEFAULTS = Object.freeze({ light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME });
304
305
 
305
306
  function sameMapping(left, right) {
306
307
  return isPlainObject(left) && isPlainObject(right)
@@ -22,6 +22,7 @@ import {
22
22
  import { VERB_PAGES_PUSH } from "../constants.js";
23
23
  import { autolinkWarnings } from "../content/link-warnings.js";
24
24
  import { placedFormKeys, readFormIndex, withFormIds } from "../form-references.js";
25
+ import { workspaceFormKeys } from "../forms-workspace.js";
25
26
  import { ApiError } from "../transport.js";
26
27
  import { waitForProcessing } from "./media-upload.js";
27
28
  import { SiteAuthoringError } from "../errors.js";
@@ -883,9 +884,11 @@ export async function placedVideoProblems(client, siteId, planned) {
883
884
  * The pages that place a form the site does not have, or has archived, and the
884
885
  * site's forms by key for the wire (TR01216). A page is stored with the form's id,
885
886
  * so a key that names no active form cannot be sent at all. Pages that will not be
886
- * sent are not asked about, and a push that places no form reads nothing.
887
+ * sent are not asked about, and a push that places no form reads nothing. `readLocalFormKeys`, when given,
888
+ * reads the workspace's own form files (TR01081), only once a key turns out to be one the site lacks, so the
889
+ * refusal can say whether the key's file is there to push.
887
890
  */
888
- export async function placedFormProblems(client, siteId, planned) {
891
+ export async function placedFormProblems(client, siteId, planned, readLocalFormKeys) {
889
892
  const placements = new Map();
890
893
  for (const page of planned) {
891
894
  if (page.unchanged) continue;
@@ -897,8 +900,14 @@ export async function placedFormProblems(client, siteId, planned) {
897
900
 
898
901
  const index = await readFormIndex(client, siteId);
899
902
  const problems = [];
903
+ let localFormKeys;
904
+ let localRead = false;
900
905
  for (const [key, file] of placements) {
901
906
  if (index.idByKey.has(key)) continue;
907
+ if (!localRead && !index.archivedKeys.has(key) && readLocalFormKeys !== undefined) {
908
+ localFormKeys = await readLocalFormKeys();
909
+ localRead = true;
910
+ }
902
911
  problems.push(...problemsOf(
903
912
  index.archivedKeys.has(key)
904
913
  ? documentError(
@@ -909,8 +918,11 @@ export async function placedFormProblems(client, siteId, planned) {
909
918
  )
910
919
  : documentError(
911
920
  "pages.form_unknown",
912
- `'${file}' places the form '${key}', which this site does not have. Run 'taproot-site forms push' to create `
913
- + "it, or fix the key.",
921
+ localFormKeys?.has(key) === false
922
+ ? `'${file}' places the form '${key}', which this site does not have, and the workspace has no forms/${key}.json. `
923
+ + "Add the form file and run 'taproot-site forms push', or fix the key."
924
+ : `'${file}' places the form '${key}', which this site does not have. Run 'taproot-site forms push' to create `
925
+ + "it, or fix the key.",
914
926
  file,
915
927
  ),
916
928
  { area: CHECK_AREA.pages, file },
@@ -1607,7 +1619,7 @@ export async function pagesPush(invocation) {
1607
1619
  // publishing would then refuse.
1608
1620
  plan.problems.push(...await placedVideoProblems(client, siteId, planned));
1609
1621
  // Likewise a form: pages store its id, which only the site's own forms can supply.
1610
- const placedForms = await placedFormProblems(client, siteId, planned);
1622
+ const placedForms = await placedFormProblems(client, siteId, planned, () => workspaceFormKeys(config.workspaceDir));
1611
1623
  plan.problems.push(...placedForms.problems);
1612
1624
 
1613
1625
  // Under apply, the workspace read ledger has already held every source and
package/src/verbs/plan.js CHANGED
@@ -27,6 +27,7 @@ import { sharedThemeContextNames } from "../content/free-form-sections.js";
27
27
  import { SiteAuthoringError } from "../errors.js";
28
28
  import { computeFooterContentHash, computeFooterDraftHash, stableJson } from "../footer-draft-hash.js";
29
29
  import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
30
+ import { workspaceFormKeys } from "../forms-workspace.js";
30
31
  import { CHECK_AREA, collectProblems, problemsError, problemsOf, problemsWithinBudget, withinByteBudget } from "../problems.js";
31
32
  import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
32
33
  import { IMAGE_IDENTIFIER, usedImageIds } from "../typed-pages.js";
@@ -284,7 +285,7 @@ async function preflight(session, invocation) {
284
285
  });
285
286
  problems.push(...pages.problems);
286
287
  problems.push(...await placedVideoProblems(client, siteId, pages.planned));
287
- problems.push(...(await placedFormProblems(client, siteId, pages.planned)).problems);
288
+ problems.push(...(await placedFormProblems(client, siteId, pages.planned, () => workspaceFormKeys(workspaceDir))).problems);
288
289
  const sending = pages.planned.filter((page) => !page.unchanged);
289
290
 
290
291
  // An image a page names must be one the site holds; an image this plan
@@ -659,6 +659,7 @@ function offlineSiteChecks(checked, manifest, onProgress) {
659
659
  planned: checked.pages?.planned,
660
660
  pagePaths: manifest.pages.map((entry) => normalizePagePath(entry?.path)).filter((value) => value !== undefined),
661
661
  pagesComplete: manifest.pagesTruncated !== true,
662
+ formKeysChecked: checked.formKeysChecked === true,
662
663
  onProgress,
663
664
  });
664
665
  }
@@ -782,7 +783,8 @@ export async function validateFixture(invocation = {}) {
782
783
  "remote concurrency or current revisions",
783
784
  "server normalization and pull round trips",
784
785
  "the site checks listed in siteChecks.checkedBySite (images, forms), which 'taproot-site status' reports under "
785
- + "siteHealth, or siteChecks.checkedAtDeploy, which only a deploy applies",
786
+ + "siteHealth, siteChecks.checkedAtDeploy, which only a deploy applies, or siteChecks.checkedInEditor, "
787
+ + "which only the page editor evaluates (a cover, a description field, or a description's length)",
786
788
  "preview or published rendering",
787
789
  ],
788
790
  nextStep: "Run a real pull and authorized preview before approval or deployment.",
@@ -846,7 +848,8 @@ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgr
846
848
  "pages, revisions, or videos that changed on the site since the pull",
847
849
  "server normalization and pull round trips",
848
850
  "the site checks listed in siteChecks.checkedBySite (images, forms), which 'taproot-site status' reports under "
849
- + "siteHealth, or siteChecks.checkedAtDeploy, which only a deploy applies",
851
+ + "siteHealth, siteChecks.checkedAtDeploy, which only a deploy applies, or siteChecks.checkedInEditor, "
852
+ + "which only the page editor evaluates (a cover, a description field, or a description's length)",
850
853
  "preview or published rendering",
851
854
  ],
852
855
  nextStep: "Run 'taproot-site plan' to check the workspace against the live site and see the ordered apply.",
@@ -1,6 +1,10 @@
1
1
  import { sharedThemeContextNames } from "./content/free-form-sections.js";
2
2
  import { readAppearanceWorkspaceContext, FOOTER_SETTINGS_FILE } from "./footer-workspace.js";
3
+ import { placedFormKeys } from "./form-references.js";
4
+ import { FORMS_DIRECTORY } from "./forms-contract.js";
3
5
  import { readWorkspaceForms } from "./forms-workspace.js";
6
+ import { describeSiteFinding } from "./site-check-findings.js";
7
+ import { unknownFormKeyFindings } from "./site-checks.js";
4
8
  import { CHECK_AREA, collectProblems, problemsOf } from "./problems.js";
5
9
  import { normalizeRedirectPath, REDIRECTS_FILE_NAME, validateRedirectsDocument } from "./redirects-contract.js";
6
10
  import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "./settings-catalog.js";
@@ -16,6 +20,7 @@ import {
16
20
  readWorkspaceJson,
17
21
  walkWorkspaceFiles,
18
22
  WORKSPACE_LIMITS,
23
+ workspaceDirectoryExists,
19
24
  workspaceFileExists,
20
25
  } from "./workspace.js";
21
26
  import { SiteAuthoringError } from "./errors.js";
@@ -135,7 +140,27 @@ export async function checkWorkspace({
135
140
  onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"} against the shared field schema.`);
136
141
  }
137
142
 
138
- return { problems, settingsOnly, presentation, footer, navigation, pages, redirects, forms };
143
+ // A page places a form by key, and the workspace's form files are what a key can name (TR01081). Only
144
+ // when the workspace carries a forms/ directory: one for a site whose forms were created elsewhere still
145
+ // pushes. A directory with no files left in it counts, since that is what deleting the last form leaves.
146
+ const formKeysChecked = forms !== undefined && await workspaceDirectoryExists(workspaceDir, FORMS_DIRECTORY);
147
+ if (pages !== undefined && formKeysChecked) {
148
+ const known = new Set(forms.map((form) => form.key));
149
+ for (const page of pages.planned) {
150
+ for (const finding of unknownFormKeyFindings(placedFormKeys(page.document), known)) {
151
+ problems.push(...problemsOf(
152
+ new SiteAuthoringError(
153
+ "pages.form_unknown",
154
+ `'${page.file}' ${describeSiteFinding(finding)}.`,
155
+ { field: page.file, rule: finding.ruleId },
156
+ ),
157
+ { area: CHECK_AREA.pages, file: page.file },
158
+ ));
159
+ }
160
+ }
161
+ }
162
+
163
+ return { problems, settingsOnly, presentation, footer, navigation, pages, redirects, forms, formKeysChecked };
139
164
  }
140
165
 
141
166
  /**
package/src/workspace.js CHANGED
@@ -1088,6 +1088,11 @@ export async function deleteWorkspaceFile(workspaceDir, relativePath) {
1088
1088
  return true;
1089
1089
  }
1090
1090
 
1091
+ /** Whether the workspace has this directory (a link or a file in its place is refused, as everywhere). */
1092
+ export async function workspaceDirectoryExists(workspaceDir, relativePath) {
1093
+ return await requireRealDirectoryChain(workspaceDir, relativePath, { includeLeaf: true });
1094
+ }
1095
+
1091
1096
  /**
1092
1097
  * Collects the workspace files a push verb should consider, in a stable sorted
1093
1098
  * order so two runs of the same workspace produce the same request sequence.