@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
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { SiteAuthoringError } from "./errors.js";
|
|
2
|
-
import { RUNTIME_MIRROR_PATH_REASON
|
|
2
|
+
import { RUNTIME_MIRROR_PATH_REASON } from "./reserved-paths.js";
|
|
3
|
+
import {
|
|
4
|
+
isRequestSpelling,
|
|
5
|
+
normalizeRedirectPath,
|
|
6
|
+
redirectSourceRule,
|
|
7
|
+
resolvesAway,
|
|
8
|
+
RULE_REDIRECT_SOURCE_GENERATED_FILE,
|
|
9
|
+
RULE_REDIRECT_SOURCE_INDEX_HTML,
|
|
10
|
+
RULE_REDIRECT_SOURCE_INTEGRATION_DATA,
|
|
11
|
+
RULE_REDIRECT_SOURCE_INVALID,
|
|
12
|
+
RULE_REDIRECT_SOURCE_NOT_STORABLE,
|
|
13
|
+
RULE_REDIRECT_SOURCE_RUNTIME_MIRROR,
|
|
14
|
+
RULE_REDIRECT_SOURCE_SHARE_LINK,
|
|
15
|
+
} from "./site-checks.js";
|
|
16
|
+
|
|
17
|
+
export { normalizeRedirectPath };
|
|
3
18
|
|
|
4
19
|
/**
|
|
5
20
|
* The redirect map's workspace contract (TR00702).
|
|
@@ -64,64 +79,14 @@ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
|
64
79
|
const ENTRY_KEYS = new Set(["path", "kind", "target", "status", "origin"]);
|
|
65
80
|
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
66
81
|
|
|
67
|
-
// An empty interior segment, or a dot or slash hidden behind percent-encoding.
|
|
68
|
-
// '//' is also where a protocol-relative URL hides, which is why the leading
|
|
69
|
-
// case is covered by this rule rather than by a separate prefix check.
|
|
70
|
-
const RESOLVABLE_SPELLING = /\/\/|%2e|%2f/iu;
|
|
71
|
-
|
|
72
|
-
// A spelling no browser would send as a pathname: anything outside printable
|
|
73
|
-
// ASCII, one of the characters a URL parser escapes on sight, or a '%' that is
|
|
74
|
-
// not an escape.
|
|
75
|
-
// No `i` flag: under Unicode case folding it would let U+212A (KELVIN SIGN)
|
|
76
|
-
// and U+017F (LONG S) through `[^!-~]`, which the site refuses.
|
|
77
|
-
const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Whether a path is already written the way a browser sends a pathname.
|
|
81
|
-
*
|
|
82
|
-
* The published-site edge keys on `url.pathname`, which the URL parser has
|
|
83
|
-
* already percent-encoded, and its `normalizePathname` decodes nothing — while
|
|
84
|
-
* every rule here compares the string as written. So '/old%20page -> /old page'
|
|
85
|
-
* is two different strings to loop detection and one path to the edge, which
|
|
86
|
-
* matches '/old%20page', resolves the target with `new URL` back to
|
|
87
|
-
* '/old%20page', and serves a permanent self-loop. A source written with a
|
|
88
|
-
* literal space or a non-ASCII character is the same mismatch the other way,
|
|
89
|
-
* stored under a key no request pathname ever matches.
|
|
90
|
-
*
|
|
91
|
-
* Refused rather than encoded here. URL escaping is not reproducibly canonical
|
|
92
|
-
* between the API's .NET and the edge's WHATWG parser, so a spelling this pass
|
|
93
|
-
* encoded would still not be the one the Worker computes and the layers would
|
|
94
|
-
* go on comparing different strings. Write what a browser sends and every layer
|
|
95
|
-
* compares it unchanged — and the byte bounds then measure that same spelling.
|
|
96
|
-
*/
|
|
97
|
-
function isRequestSpelling(value) {
|
|
98
|
-
return !UNSENDABLE_SPELLING.test(value);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Whether a path names one thing to this pass and another wherever it is
|
|
103
|
-
* resolved.
|
|
104
|
-
*
|
|
105
|
-
* Every rule here compares the string as written, and nothing downstream does:
|
|
106
|
-
* the generator resolves a source with path.resolve, so '/x/../visit' writes
|
|
107
|
-
* over the page rendered at '/visit', and the published-site edge resolves a
|
|
108
|
-
* target with `new URL`, so '/a -> /x/../a' is the self-loop that loop
|
|
109
|
-
* detection just passed. Percent-encoded dots and slashes are the same trick
|
|
110
|
-
* spelled so a segment scan cannot see it, so they are refused wherever they
|
|
111
|
-
* appear rather than decoded and re-examined.
|
|
112
|
-
*/
|
|
113
|
-
function resolvesAway(value) {
|
|
114
|
-
if (RESOLVABLE_SPELLING.test(value)) return true;
|
|
115
|
-
return value.split("/").some((segment) => segment === "." || segment === "..");
|
|
116
|
-
}
|
|
117
82
|
|
|
118
83
|
/** Whether the value is a redirect-map revision as the server mints one. */
|
|
119
84
|
export function isRedirectMapRevision(value) {
|
|
120
85
|
return typeof value === "string" && SHA256.test(value);
|
|
121
86
|
}
|
|
122
87
|
|
|
123
|
-
function entryError(code, message, field) {
|
|
124
|
-
return new SiteAuthoringError(code, `${field}: ${message}`, { field });
|
|
88
|
+
function entryError(code, message, field, rule) {
|
|
89
|
+
return new SiteAuthoringError(code, `${field}: ${message}`, { field, rule });
|
|
125
90
|
}
|
|
126
91
|
|
|
127
92
|
function isPlainObject(value) {
|
|
@@ -133,93 +98,32 @@ function utf8Length(value) {
|
|
|
133
98
|
}
|
|
134
99
|
|
|
135
100
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* some other layer would resolve away is refused rather than repaired (see
|
|
141
|
-
* `resolvesAway`), and so is one no browser would send as a pathname (see
|
|
142
|
-
* `isRequestSpelling`, which subsumes the control-character refusal).
|
|
101
|
+
* Why a source rule refuses a path, in the words the CLI shows. The rule ids are
|
|
102
|
+
* the site checks' (shared/site-check-rules.json); the decisions themselves are
|
|
103
|
+
* made by `redirectSourceRule` in the shared source, which the API's C#
|
|
104
|
+
* implementation also passes the same corpus for.
|
|
143
105
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// The root segment the published output reserves for integration data; the release
|
|
161
|
-
// inventory leaves everything under it out.
|
|
162
|
-
const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
|
|
163
|
-
|
|
164
|
-
// Root-level files the site generates. A redirect is a marker file inside the
|
|
165
|
-
// published site (TR00968), so a source that names one has nowhere to live.
|
|
166
|
-
const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
|
|
167
|
-
// The longest decoded path segment a marker file name may have; the API rule and the
|
|
168
|
-
// generator use the same 200 bytes.
|
|
169
|
-
const MAX_MARKER_SEGMENT_BYTES = 200;
|
|
170
|
-
const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
|
|
171
|
-
|
|
172
|
-
function isWindowsDeviceSegment(segment) {
|
|
173
|
-
const stem = segment.split(".", 1)[0].toLowerCase();
|
|
174
|
-
return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
|
|
175
|
-
}
|
|
106
|
+
const SOURCE_RULE_REASONS = Object.freeze({
|
|
107
|
+
[RULE_REDIRECT_SOURCE_INDEX_HTML]: () =>
|
|
108
|
+
"the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
|
|
109
|
+
+ "a redirect source, and the page or home file there is generated.",
|
|
110
|
+
[RULE_REDIRECT_SOURCE_INTEGRATION_DATA]: (first) =>
|
|
111
|
+
`'/${first}' is reserved for integration data, so nothing under it can be a redirect source.`,
|
|
112
|
+
[RULE_REDIRECT_SOURCE_SHARE_LINK]: () =>
|
|
113
|
+
"'/s/' is reserved for share links, so nothing under it can be a redirect source.",
|
|
114
|
+
[RULE_REDIRECT_SOURCE_RUNTIME_MIRROR]: () => RUNTIME_MIRROR_PATH_REASON,
|
|
115
|
+
[RULE_REDIRECT_SOURCE_GENERATED_FILE]: (first) =>
|
|
116
|
+
`'/${first}' is a file the site generates, so neither it nor anything under it can be a redirect source.`,
|
|
117
|
+
[RULE_REDIRECT_SOURCE_NOT_STORABLE]: () =>
|
|
118
|
+
"a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
|
|
119
|
+
+ "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).",
|
|
120
|
+
});
|
|
176
121
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
* named above, and the marker is stored under the decoded spelling, so a path
|
|
183
|
-
* that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
|
|
184
|
-
* segment, a reserved device name) is refused. The site root is left to the
|
|
185
|
-
* occupancy rule.
|
|
186
|
-
*/
|
|
187
|
-
export function redirectSourceRefusalReason(path) {
|
|
188
|
-
if (path === "/") return undefined;
|
|
189
|
-
// Every rule reads the decoded spelling: it is the name the marker is stored
|
|
190
|
-
// under, and the name the generator's own files use (`/%69ndex.html` is
|
|
191
|
-
// `/index.html`).
|
|
192
|
-
let decoded;
|
|
193
|
-
try {
|
|
194
|
-
decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
|
|
195
|
-
} catch {
|
|
196
|
-
return "it holds a percent escape that does not decode.";
|
|
197
|
-
}
|
|
198
|
-
const segments = decoded.split("/");
|
|
199
|
-
const first = segments[0].toLowerCase();
|
|
200
|
-
if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
|
|
201
|
-
return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
|
|
202
|
-
+ "a redirect source, and the page or home file there is generated.";
|
|
203
|
-
}
|
|
204
|
-
if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
|
|
205
|
-
return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
|
|
206
|
-
}
|
|
207
|
-
if (isRuntimeMirrorPath(decoded)) return RUNTIME_MIRROR_PATH_REASON;
|
|
208
|
-
if (GENERATED_ROOT_FILES.includes(first)) {
|
|
209
|
-
return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
|
|
210
|
-
}
|
|
211
|
-
if (
|
|
212
|
-
decoded.includes("..")
|
|
213
|
-
|| decoded !== decoded.normalize("NFC")
|
|
214
|
-
|| segments.some((segment) =>
|
|
215
|
-
segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
|
|
216
|
-
|| isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
|
|
217
|
-
)
|
|
218
|
-
) {
|
|
219
|
-
return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
|
|
220
|
-
+ "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
|
|
221
|
-
}
|
|
222
|
-
return undefined;
|
|
122
|
+
/** The refusal for a normalized source: its site-check rule id and the reason, or undefined when it can be a redirect. */
|
|
123
|
+
export function redirectSourceRefusal(path) {
|
|
124
|
+
const refusal = redirectSourceRule(path);
|
|
125
|
+
if (refusal === undefined) return undefined;
|
|
126
|
+
return { ruleId: refusal.ruleId, reason: SOURCE_RULE_REASONS[refusal.ruleId](refusal.detail) };
|
|
223
127
|
}
|
|
224
128
|
|
|
225
129
|
function looksAbsolute(value) {
|
|
@@ -281,6 +185,7 @@ function validateEntry(value, index, seen) {
|
|
|
281
185
|
+ "or '..' segment, no empty segment, and no percent-encoded dot or slash. Write the percent-encoded "
|
|
282
186
|
+ "spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
|
|
283
187
|
`${field}.path`,
|
|
188
|
+
RULE_REDIRECT_SOURCE_INVALID,
|
|
284
189
|
);
|
|
285
190
|
}
|
|
286
191
|
// The site root is not refused here. A home page rename records an entry at
|
|
@@ -303,9 +208,14 @@ function validateEntry(value, index, seen) {
|
|
|
303
208
|
`${field}.path`,
|
|
304
209
|
);
|
|
305
210
|
}
|
|
306
|
-
const sourceRefusal =
|
|
211
|
+
const sourceRefusal = redirectSourceRefusal(path);
|
|
307
212
|
if (sourceRefusal !== undefined) {
|
|
308
|
-
throw entryError(
|
|
213
|
+
throw entryError(
|
|
214
|
+
"redirects.path_unpublishable",
|
|
215
|
+
`'${path}': ${sourceRefusal.reason}`,
|
|
216
|
+
`${field}.path`,
|
|
217
|
+
sourceRefusal.ruleId,
|
|
218
|
+
);
|
|
309
219
|
}
|
|
310
220
|
// The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
|
|
311
221
|
// file, and the second would silently replace the first.
|
package/src/reference-help.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
import { CLI_BINARY_NAME } from "./constants.js";
|
|
3
|
+
import { CLI_BINARY_NAME, LIMITS } from "./constants.js";
|
|
4
4
|
import {
|
|
5
5
|
canonicalizeComponentData,
|
|
6
6
|
COMPONENT_TYPES,
|
|
@@ -64,9 +64,11 @@ import {
|
|
|
64
64
|
REDIRECTS_FILE_NAME,
|
|
65
65
|
} from "./redirects-contract.js";
|
|
66
66
|
import { SETTINGS_GROUPS } from "./settings-catalog.js";
|
|
67
|
+
import { getSettingsReference } from "./setting-reference.js";
|
|
67
68
|
import {
|
|
68
69
|
formatTypedPageReference,
|
|
69
70
|
getTypedPageReference,
|
|
71
|
+
LEGAL_PLATFORM_LINK_GUIDANCE,
|
|
70
72
|
MARKDOWN_PAGE_TYPES,
|
|
71
73
|
TYPED_PAGE_TYPES,
|
|
72
74
|
} from "./typed-page-reference.js";
|
|
@@ -84,9 +86,9 @@ import {
|
|
|
84
86
|
SYSTEM_PAGE_NOT_FOUND_PATH,
|
|
85
87
|
} from "./workspace.js";
|
|
86
88
|
|
|
87
|
-
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
89
|
+
export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
|
|
88
90
|
|
|
89
|
-
export const REFERENCE_VERSION =
|
|
91
|
+
export const REFERENCE_VERSION = 38;
|
|
90
92
|
|
|
91
93
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
92
94
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -387,7 +389,7 @@ export function getDesignBlueprint(type) {
|
|
|
387
389
|
return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
|
|
388
390
|
}
|
|
389
391
|
export const REFERENCE_TOPICS = Object.freeze([
|
|
390
|
-
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List
|
|
392
|
+
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and the read-only legal page." }),
|
|
391
393
|
Object.freeze({
|
|
392
394
|
name: "page",
|
|
393
395
|
usage: `${CLI_BINARY_NAME} help page <page-type>`,
|
|
@@ -450,6 +452,11 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
450
452
|
usage: `${CLI_BINARY_NAME} help appearance`,
|
|
451
453
|
summary: "Map site presentation decisions to theme-push workspace fields.",
|
|
452
454
|
}),
|
|
455
|
+
Object.freeze({
|
|
456
|
+
name: "settings",
|
|
457
|
+
usage: `${CLI_BINARY_NAME} help settings`,
|
|
458
|
+
summary: "Map every pulled site setting to its app control, say what it does and where it renders, and which verb writes it.",
|
|
459
|
+
}),
|
|
453
460
|
Object.freeze({
|
|
454
461
|
name: "footer",
|
|
455
462
|
usage: `${CLI_BINARY_NAME} help footer`,
|
|
@@ -501,7 +508,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
501
508
|
+ "part of wp:post_date). Categories beyond the first, and all tags, have no Taproot equivalent (tags are "
|
|
502
509
|
+ "generated per page type): list them. The featured image becomes coverImage on typed pages, and the same image must also appear in the page's own content (put it at the top of the body), or the site refuses the cover; a free-form page "
|
|
503
510
|
+ "has none, so list it.",
|
|
504
|
-
"Content. Convert the HTML to the Markdown subset. Keep headings from h2 to h4 (the title
|
|
511
|
+
"Content. Convert the HTML to the Markdown subset. Keep body headings from h2 to h4 (deeper levels are refused, so make h5 and h6 an h4). The h1 depends on the template: an article or place review prints its title as the h1, so its body starts at h2 (a recipe and an album print their titles too; their fixed level-2 headings are set out below); a free-form page prints no title, so open its Markdown with a '# ' line holding the page title (the first level-1 heading becomes the h1; see help page free-form). Decode HTML entities (&, ’) in titles, excerpts, menu labels and term names; WordPress stores them encoded. An image "
|
|
505
512
|
+ "must stand alone in its own block, so split paragraphs around images and lift images out of links; a link "
|
|
506
513
|
+ "to the full-size file is dropped, so list it. Tables need a header row and 2 to 12 columns, else write "
|
|
507
514
|
+ "a list and report it. Remove Gutenberg block comments and the more-tag marker. In a recipe, times and servings "
|
|
@@ -604,7 +611,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
604
611
|
+ "contexts and intents paired across schemes. Set appearance scalars (default scheme, header, logos, favicon) "
|
|
605
612
|
+ "in the appearance files: help appearance.",
|
|
606
613
|
"Prepare media locally: PNG, JPEG, GIF and WebP raster files only, addressed relative to the workspace root. Make "
|
|
607
|
-
+ "the variants yourself with any image tool (a transparent logo
|
|
614
|
+
+ "the variants yourself with any image tool (a transparent logo at the highest resolution you have, a square favicon); there is no "
|
|
608
615
|
+ "recolor, crop or SVG upload command. Upload each with media upload, then assign lightLogoId and darkLogoId "
|
|
609
616
|
+ "(and brand.faviconId) from the image ids .taproot-site-media.json records for those files, so each scheme carries the artwork that reads on its header.",
|
|
610
617
|
`Validate offline: ${CLI_BINARY_NAME} validate checks the whole workspace at once — the complete pair, the `
|
|
@@ -623,10 +630,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
623
630
|
+ "(theme.concurrent_modification) and nothing is written; keep copies of the edited files, pull, re-apply, "
|
|
624
631
|
+ "and push again. A lost response is safe to retry — the site answers applied=false when it already holds "
|
|
625
632
|
+ "the change set. footer push owns footer prose, links and imagery.",
|
|
626
|
-
`Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation
|
|
627
|
-
+
|
|
628
|
-
+ "
|
|
629
|
-
+ "
|
|
633
|
+
`Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation; it reports no review URL by default `
|
|
634
|
+
+ `because the URL is a live credential, so run ${CLI_BINARY_NAME} staging review (or add --handoff to deploy) for `
|
|
635
|
+
+ "a single-use handoff in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's "
|
|
636
|
+
+ "theme toggle to check both schemes: text, headings, links and hover, actions and focus, the header logos, the "
|
|
637
|
+
+ "favicon. When a requested handoff could not be minted, stagingPreview.reason says why and staging review mints "
|
|
638
|
+
+ "another (add --path <page> to open it on the page under review). A page draft is "
|
|
630
639
|
+ "not needed to review a presentation change; preview page renders one persisted draft and is a different tool.",
|
|
631
640
|
`Promote only after review: ${CLI_BINARY_NAME} deploy --production promotes the completed staging deployment. `
|
|
632
641
|
+ "Never use production to discover what staging should have shown.",
|
|
@@ -650,7 +659,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
650
659
|
summary: "nav push replaces the complete tree in nav.json; item IDs are author-minted canonical lowercase UUIDs.",
|
|
651
660
|
usage: `${CLI_BINARY_NAME} nav push`,
|
|
652
661
|
details: Object.freeze([
|
|
653
|
-
"nav.json is { siteId, navItems }, not a bare array."
|
|
662
|
+
"nav.json is { siteId, navItems }, not a bare array. Defaults are left out: an item has no resourceId or "
|
|
663
|
+
+ "externalUrl unless its kind uses one, and no children when it has none. pull and nav push both write that "
|
|
664
|
+
+ "form (nav push rewrites the file after it saves), and either form is accepted on the way in, so a pull after "
|
|
665
|
+
+ "a push shows no change you did not make.",
|
|
654
666
|
"kind is NAV_ITEM_KIND_PAGE, NAV_ITEM_KIND_EXTERNAL_URL, or NAV_ITEM_KIND_GROUP_HEADER.",
|
|
655
667
|
"PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
|
|
656
668
|
"EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
|
|
@@ -664,7 +676,6 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
664
676
|
kind: "NAV_ITEM_KIND_PAGE",
|
|
665
677
|
title: "Classes",
|
|
666
678
|
resourceId: "<resourceId-from-manifest>",
|
|
667
|
-
children: Object.freeze([]),
|
|
668
679
|
})]),
|
|
669
680
|
}),
|
|
670
681
|
}),
|
|
@@ -688,9 +699,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
688
699
|
+ "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
|
|
689
700
|
+ "large JSON lists are truncated explicitly with failed entries first (redirects.itemsTruncated). Progress "
|
|
690
701
|
+ "output reports every entry on stderr; --quiet hides it.",
|
|
691
|
-
"
|
|
692
|
-
+ "
|
|
693
|
-
+ "
|
|
702
|
+
"redirects check and deploy --staging report no review URL unless you pass --handoff; the check "
|
|
703
|
+
+ "authenticates with its own handoff either way. With --handoff, stagingPreview.url is a fresh, single-use "
|
|
704
|
+
+ "two-minute handoff, minted after checks. It is emitted only in final JSON, excluded from progress and "
|
|
705
|
+
+ "GITHUB_OUTPUT; keep it private. Without it, stagingPreview.reason is staging.handoff_not_requested and "
|
|
706
|
+
+ "'staging review' mints one. The staging cookie lasts five minutes and remains subject to current "
|
|
707
|
+
+ "site/host/credential permission.",
|
|
694
708
|
`${REDIRECTS_FILE_NAME} is { siteId, revision, entries }, not a bare array. 'redirects pull' writes it and `
|
|
695
709
|
+ "records the revision in .taproot-site-manifest.json; 'pull' writes it too.",
|
|
696
710
|
"An entry is { path, kind, target, status }. kind is 'redirect' (the default) or 'gone'; a gone entry "
|
|
@@ -738,9 +752,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
738
752
|
+ "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
|
|
739
753
|
+ "which are simply gone.",
|
|
740
754
|
"Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
|
|
741
|
-
+ "exactly when the release does: staging with the staged release, production when it is promoted.
|
|
742
|
-
+
|
|
743
|
-
+ "
|
|
755
|
+
+ "exactly when the release does: staging with the staged release, production when it is promoted. The edge "
|
|
756
|
+
+ `can keep serving the previous release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so an entry a `
|
|
757
|
+
+ "check does not see may belong to a release the edge has not switched to yet; deploy --staging waits for "
|
|
758
|
+
+ "staging to serve the new release and reports stagingServed.",
|
|
744
759
|
"A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
|
|
745
760
|
+ "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. Nothing under "
|
|
746
761
|
+ "'/taproot/<version>/' (for example '/taproot/5.0.62/manifest.json') can be a source either: the platform "
|
|
@@ -790,8 +805,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
790
805
|
+ "Delivery also requires Taproot's rollout and email provider readiness; opt-in enables neither, and earlier submissions are not backfilled.",
|
|
791
806
|
`definition is { fields, contact_field?, submit_label?, after_submit? } (contact_field only with several email fields). Each field is { id, type, label, hint?, ...constraints }; the closed `
|
|
792
807
|
+ `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
|
|
793
|
-
+ "characters, drawn under the field on the published form
|
|
794
|
-
+ "
|
|
808
|
+
+ "characters, drawn under the field on the published form. The published form marks a required field itself: its label "
|
|
809
|
+
+ "ends with ' *' and a '* Required' legend sits above the fields, so do not write 'required' into a label or hint; the "
|
|
810
|
+
+ "stored label stays exactly as written. "
|
|
795
811
|
+ `At most ${MAX_FIELDS} fields and ${MAX_SUBMISSION_BYTES} bytes `
|
|
796
812
|
+ "per submission. There are no author-supplied regular expressions: use 'format' from its closed list. "
|
|
797
813
|
+ "The exact constraints per type are in shared/field-schema.json, which 'forms validate' enforces by "
|
|
@@ -824,7 +840,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
824
840
|
+ "a deploy whose release does not publish that page is refused, naming the form and the page. The path in "
|
|
825
841
|
+ "each release is the one the page has in that release, so the published form never asks the site where "
|
|
826
842
|
+ "to go. The page must be one you created, the home page included: a generated page (folder, tag or "
|
|
827
|
-
+ "archive index), the 404 page, a legal page, a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
|
|
843
|
+
+ "archive index), the 404 page, a legal page (help page legal), a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
|
|
828
844
|
+ "the 'form' component takes only formKey.",
|
|
829
845
|
"A form's contact address is the value of one email field, stored with each submission. That is what lets the "
|
|
830
846
|
+ "owner find and delete every row for a visitor who asks. With exactly one email field, that field is the "
|
|
@@ -881,9 +897,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
881
897
|
usage: `${CLI_BINARY_NAME} media upload [path...]`,
|
|
882
898
|
details: Object.freeze([
|
|
883
899
|
"Paths are relative to the configured workspace root, not the shell's current directory.",
|
|
884
|
-
"PNG, JPEG, GIF, and WebP are accepted
|
|
900
|
+
"PNG, JPEG, GIF, and WebP are accepted. Each file uploads as its own image: a name such as logo@2x.png is only a filename, nothing pairs it with logo.png, and the platform generates the display sizes from the one file you assign (see help appearance for logos).",
|
|
885
901
|
"For header logos prefer genuine transparent PNG/WebP, check contrast in both themes, and include a simplified square favicon. See help appearance for branding guidance.",
|
|
886
|
-
"Prepare variants locally with any image tool before uploading: a transparent logo
|
|
902
|
+
"Prepare variants locally with any image tool before uploading: a transparent logo at the highest resolution you have (never a 1x and a 2x pair: assign one file and the platform generates the sizes; keep it under the 32 MiB upload limit), a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
|
|
887
903
|
"Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the image ids .taproot-site-media.json records for those files; theme push writes them. See help walkthrough for the full design pass.",
|
|
888
904
|
"Each result item reports file, imageId, deduplicated, width, height and processingState; the delivery URLs (src, urls) are in .taproot-site-media.json, not the result, so an upload of any size reports within its bound. media.total and videos.total count every file, and media.itemsTruncated or videos.itemsTruncated says that list stopped short.",
|
|
889
905
|
"MP4, MOV, and WebM files are uploaded as videos (up to 250 MB each; the site's licence sets the cap, and the container is read from the file's bytes, not its name). The CLI reads each file with mediabunny. A file that is H.264 and AAC but not already an MP4 with its index first is rewritten as one without re-encoding; anything else is sent as it is and judged by the server. When the server refuses a file, its one-line message is printed as media.video_refused. A video is ready to play as soon as the upload is confirmed. The CLI sets no poster, because Node has no video decoder: the Videos page picks one automatically for a video uploaded there, and lets the owner choose another frame, so set a CLI-uploaded video's poster there, with its title and caption. The command reports videos.items: { file, videoId, title, caption, fileName, contentType, byteLength, deduplicated, durationMilliseconds, component }, where component.markdown is the component:video fence and component.block the componentBlock node that place it. Needs the site's licence to include video.",
|
|
@@ -891,8 +907,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
891
907
|
"In Markdown, name an uploaded image by its path (such as media/photo.webp) wherever an image goes: body images, coverImage, album images, component image fields and section images. pages push resolves each path through .taproot-site-media.json, which also keeps the src/urls delivery fields a .pm.json image record carries.",
|
|
892
908
|
]),
|
|
893
909
|
example: Object.freeze({
|
|
894
|
-
command: `${CLI_BINARY_NAME} media upload media/logo
|
|
895
|
-
markdownImage: "media/logo
|
|
910
|
+
command: `${CLI_BINARY_NAME} media upload media/logo.png`,
|
|
911
|
+
markdownImage: "media/logo.png",
|
|
896
912
|
pmJsonImageRecord: Object.freeze({
|
|
897
913
|
imageId: "<image-uuid>",
|
|
898
914
|
src: "<preferred-delivery-url>",
|
|
@@ -913,11 +929,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
913
929
|
+ "selected page's content until a newer matching preview is ready, the content changes, or the operator "
|
|
914
930
|
+ "explicitly supplies --allow-failed-preview. Pending retries do not clear a known failure; previews "
|
|
915
931
|
+ "remain optional and old snapshots without content identity do not block deployment.",
|
|
916
|
-
"deploy --staging and redirects check
|
|
917
|
-
+ "
|
|
918
|
-
"
|
|
919
|
-
+ "
|
|
920
|
-
+ "
|
|
932
|
+
"deploy --staging and redirects check report no review URL by default, because it is a live single-use "
|
|
933
|
+
+ "credential and their JSON is often saved to logs (stagingPreview.reason staging.handoff_not_requested). Run "
|
|
934
|
+
+ "staging review for one, or pass --handoff to get a fresh single-use handoff in stagingPreview.url, minted "
|
|
935
|
+
+ "after the headless redirect checks. staging review --path <page> (and --review-path <page> beside --handoff) "
|
|
936
|
+
+ "opens the review on that page instead of the home page. Handoffs never go to GITHUB_OUTPUT.",
|
|
937
|
+
"On a managed Docs site deploy --staging stages settings only: run staging review (or deploy --staging "
|
|
938
|
+
+ "--handoff) and open the URL once, then use the site's theme toggle to review both schemes; no page draft is "
|
|
939
|
+
+ "needed to preview a presentation change. When a requested handoff can't be minted the result carries "
|
|
940
|
+
+ "stagingPreview.reason "
|
|
921
941
|
+ "(staging.host_unavailable, staging.authority_denied, staging.surface_refused, or staging.handoff_unavailable) "
|
|
922
942
|
+ "and a recovery step; run staging review to mint another (it works on every surface). Never verify a theme "
|
|
923
943
|
+ "on production instead.",
|
|
@@ -1514,7 +1534,8 @@ const BACKGROUND_REFERENCE = backgroundReference();
|
|
|
1514
1534
|
const FREE_FORM_REFERENCE = Object.freeze({
|
|
1515
1535
|
type: "free-form",
|
|
1516
1536
|
displayName: "Free-form page",
|
|
1517
|
-
summary:
|
|
1537
|
+
summary:
|
|
1538
|
+
"A page whose body is authored as validated ProseMirror, directly or through the supported Markdown subset. It renders no automatic heading: open the body with a '# ' title line.",
|
|
1518
1539
|
workspace: Object.freeze({
|
|
1519
1540
|
directory: "pages/",
|
|
1520
1541
|
sourceRule: Object.freeze({
|
|
@@ -1674,6 +1695,27 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1674
1695
|
`Any other rel token, opener included, is refused (${CONTENT_ERROR_CODES.linkRel}); so is another target `
|
|
1675
1696
|
+ `(${CONTENT_ERROR_CODES.linkTarget}). The editor's old default, "noopener noreferrer nofollow", reads as no choice.`,
|
|
1676
1697
|
}),
|
|
1698
|
+
// TR01220: a free-form page prints no title, so its first level-1 heading is the h1.
|
|
1699
|
+
headings: Object.freeze({
|
|
1700
|
+
rule:
|
|
1701
|
+
"A free-form page renders no automatic heading: its title is not printed on the page. "
|
|
1702
|
+
+ "The first level-1 heading in the body becomes the page's h1, so open the Markdown with '# Page title'. A "
|
|
1703
|
+
+ "structured page (article, recipe, album, place review) prints its title as the h1, so its body starts at '## '.",
|
|
1704
|
+
claimedFirst:
|
|
1705
|
+
"A hero-section with a title claims the h1, and so does an image-banner with overlay text that opens the page; "
|
|
1706
|
+
+ "a '# ' heading after either is then not a second h1. A titled hero-section renders its own h1 wherever it sits, so a '# ' line before one gives the page two h1s: use one or the other. Without a hero or banner, write the '# ' heading yourself.",
|
|
1707
|
+
laterHeadings:
|
|
1708
|
+
"Any later '# ' heading renders as an h2, like the other top-level sections, never as a second h1, "
|
|
1709
|
+
+ "and the other headings are renumbered so none skips a level.",
|
|
1710
|
+
depth: `The Markdown subset accepts levels 1 to 4; a deeper heading is refused (${CONTENT_ERROR_CODES.headingDepth}).`,
|
|
1711
|
+
example: "# Visit the studio\n\nWe are open every day.\n\n## Hours\n\nMonday to Friday, 6 AM to 7:30 PM.",
|
|
1712
|
+
}),
|
|
1713
|
+
// TR01224: the default for a policy written as a free-form page.
|
|
1714
|
+
legalAndPrivacyPages: Object.freeze({
|
|
1715
|
+
...LEGAL_PLATFORM_LINK_GUIDANCE,
|
|
1716
|
+
choosing:
|
|
1717
|
+
`A policy that needs a version and an effective date is a legal page, which the CLI cannot author: see '${CLI_BINARY_NAME} help page legal'.`,
|
|
1718
|
+
}),
|
|
1677
1719
|
componentNode: Object.freeze({
|
|
1678
1720
|
type: "componentBlock",
|
|
1679
1721
|
attrs: Object.freeze({
|
|
@@ -1889,7 +1931,7 @@ export function formatReferenceResult(result) {
|
|
|
1889
1931
|
result.topics.map((topic) => ` ${topic.usage.padEnd(48)} ${topic.summary}`).join("\n")
|
|
1890
1932
|
}\n\nAdd --json for stable machine-readable output.\n`;
|
|
1891
1933
|
case "page-types":
|
|
1892
|
-
return `
|
|
1934
|
+
return `Page types:\n${
|
|
1893
1935
|
result.pageTypes.map((page) =>
|
|
1894
1936
|
` ${page.type.padEnd(16)} ${page.summary}\n${" ".repeat(19)}Help: ${page.helpCommand}`
|
|
1895
1937
|
).join("\n")
|
|
@@ -1933,7 +1975,9 @@ export function formatReferenceResult(result) {
|
|
|
1933
1975
|
page.document.marks.join(", ")
|
|
1934
1976
|
}\n\nLinks:\n markdown ${page.document.links.markdown}\n rel ${
|
|
1935
1977
|
page.document.links.rel.join(", ")
|
|
1936
|
-
}\n default ${page.document.links.default}\n in Markdown ${page.document.links.markdownRule}\n in .pm.json ${page.document.links.pmJson}\n refusals ${page.document.links.refusals}\n\
|
|
1978
|
+
}\n default ${page.document.links.default}\n in Markdown ${page.document.links.markdownRule}\n in .pm.json ${page.document.links.pmJson}\n refusals ${page.document.links.refusals}\n\nHeadings and the h1:\n rule ${page.document.headings.rule}\n banner and hero ${page.document.headings.claimedFirst}\n later headings ${page.document.headings.laterHeadings}\n depth ${page.document.headings.depth}\n example\n${
|
|
1979
|
+
page.document.headings.example.split("\n").map((line) => ` ${line}`).join("\n")
|
|
1980
|
+
}\n\nLegal and privacy pages:\n linking Taproot ${page.document.legalAndPrivacyPages.rule}\n example ${page.document.legalAndPrivacyPages.example}\n versioned policies ${page.document.legalAndPrivacyPages.choosing}\n\nTop-level section container:\n${page.sections.example}\n\nSection attributes:\n${
|
|
1937
1981
|
page.document.sectionNode.attrs.map(formatSectionAttr).join("\n")
|
|
1938
1982
|
}\n\nSection photo background (closed object):\n${
|
|
1939
1983
|
page.sections.background.fields.map((field) => formatRegistryField(field)).join("\n")
|
package/src/reserved-paths.js
CHANGED
|
@@ -5,17 +5,11 @@
|
|
|
5
5
|
* `/taproot/<semver>/...` (TR01144), and Cloudflare answers a matching request
|
|
6
6
|
* before the Worker runs, so a page or redirect authored there could never be
|
|
7
7
|
* reached. This is the one place the package owns that prefix; page push and
|
|
8
|
-
* the redirect map both ask here. The API is the authority
|
|
9
|
-
*
|
|
10
|
-
* test vectors.
|
|
8
|
+
* the redirect map both ask here. The API is the authority
|
|
9
|
+
* (`PublishedOutputPaths.IsRuntimeMirrorPath`); both pass the site-check corpus.
|
|
11
10
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/** Whether a site-relative path (no leading slash) is `taproot/<semver>` or under it. */
|
|
16
|
-
export function isRuntimeMirrorPath(path) {
|
|
17
|
-
return RUNTIME_MIRROR_PATH.test(path);
|
|
18
|
-
}
|
|
11
|
+
// The rule itself lives in the shared site-check source, which the redirect source rules also read.
|
|
12
|
+
export { isRuntimeMirrorPath } from "./site-checks.js";
|
|
19
13
|
|
|
20
14
|
export const RUNTIME_MIRROR_PATH_REASON =
|
|
21
15
|
"'/taproot/<version>/' is reserved for the published-site runtime, which the platform answers before any page "
|