@taprootio/site-authoring 0.12.0 → 0.12.1
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 +1 -1
- package/package.json +1 -1
- package/src/cli.js +4 -2
- package/src/redirects-contract.js +101 -9
- package/src/reference-help.js +11 -6
- package/src/verbs/deploy.js +13 -19
- package/src/verbs/redirects-check.js +1 -1
package/README.md
CHANGED
|
@@ -501,7 +501,7 @@ fingerprint supply no gating evidence. Typed preview failure diagnostics contain
|
|
|
501
501
|
only error class and known page/node/attribute labels, never renderer messages.
|
|
502
502
|
|
|
503
503
|
Staging deploys check the current redirect map through the real staging edge.
|
|
504
|
-
Run `taproot-site redirects check` to repeat
|
|
504
|
+
Run `taproot-site redirects check` to repeat once the deployment has completed; a redirect is a file inside the release, so it is live exactly when its release is served.
|
|
505
505
|
The Node CLI consumes a separate handoff, retains its short staging cookie in
|
|
506
506
|
memory, and requests each map path without following redirects. Results report
|
|
507
507
|
path, HTTP status and Location; `verified` requires all entries to match and the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
package/src/cli.js
CHANGED
|
@@ -251,7 +251,8 @@ const VERBS = Object.freeze([
|
|
|
251
251
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
252
252
|
tokens: ["redirects", "check"],
|
|
253
253
|
summary: "Check the current redirect map through the authenticated staging edge.",
|
|
254
|
-
note: "Reports path, HTTP status and Location without following redirects.
|
|
254
|
+
note: "Reports path, HTTP status and Location without following redirects. A redirect is live only with the release "
|
|
255
|
+
+ "that carries it, so this reads the staged release. "
|
|
255
256
|
+ "The result includes a fresh single-use staging handoff; keep it private.",
|
|
256
257
|
},
|
|
257
258
|
{
|
|
@@ -275,7 +276,8 @@ const VERBS = Object.freeze([
|
|
|
275
276
|
summary: "Validate redirects.json and replace the whole redirect map.",
|
|
276
277
|
note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
|
|
277
278
|
+ "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
|
|
278
|
-
+ "source a live page occupies.
|
|
279
|
+
+ "source a live page occupies or a file the site generates. An entry is a file inside the next release, so it "
|
|
280
|
+
+ "goes live with that deploy: staging first, production when promoted. "
|
|
279
281
|
+ "See 'taproot-site help redirects'.",
|
|
280
282
|
},
|
|
281
283
|
{
|
|
@@ -42,13 +42,16 @@ export const REDIRECT_STATUSES = Object.freeze([301, 302, 307, 308]);
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Bounds, mirrored from `SiteRedirectMapContract` on the server. The path bound
|
|
45
|
-
* is
|
|
46
|
-
*
|
|
45
|
+
* is the map's own; the target bound is the edge's own, and a target must also
|
|
46
|
+
* fit as metadata on its redirect file once encoded.
|
|
47
47
|
*/
|
|
48
48
|
export const REDIRECT_LIMITS = Object.freeze({
|
|
49
49
|
entries: 2_000,
|
|
50
50
|
pathBytes: 400,
|
|
51
51
|
targetBytes: 2_048,
|
|
52
|
+
// Once percent-encoded, a target rides on its redirect file as object
|
|
53
|
+
// metadata; the server pins the same number (MaxMarkerTargetEncodedLength).
|
|
54
|
+
targetEncodedCharacters: 1_810,
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
// C0, DEL, and C1 — every code point the server's own `char.IsControl`
|
|
@@ -88,8 +91,7 @@ const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
|
|
|
88
91
|
* between the API's .NET and the edge's WHATWG parser, so a spelling this pass
|
|
89
92
|
* encoded would still not be the one the Worker computes and the layers would
|
|
90
93
|
* go on comparing different strings. Write what a browser sends and every layer
|
|
91
|
-
* compares it unchanged — and the byte bounds then measure
|
|
92
|
-
* key actually costs.
|
|
94
|
+
* compares it unchanged — and the byte bounds then measure that same spelling.
|
|
93
95
|
*/
|
|
94
96
|
function isRequestSpelling(value) {
|
|
95
97
|
return !UNSENDABLE_SPELLING.test(value);
|
|
@@ -154,6 +156,70 @@ export function normalizeRedirectPath(value) {
|
|
|
154
156
|
return trimmed === "" ? "/" : trimmed;
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
// The root segment the published output reserves for integration data; the release
|
|
160
|
+
// inventory leaves everything under it out.
|
|
161
|
+
const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
|
|
162
|
+
|
|
163
|
+
// Root-level files the site generates. A redirect is a marker file inside the
|
|
164
|
+
// published site (TR00968), so a source that names one has nowhere to live.
|
|
165
|
+
const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
|
|
166
|
+
// The longest decoded path segment a marker file name may have; the API rule and the
|
|
167
|
+
// generator use the same 200 bytes.
|
|
168
|
+
const MAX_MARKER_SEGMENT_BYTES = 200;
|
|
169
|
+
const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
|
|
170
|
+
|
|
171
|
+
function isWindowsDeviceSegment(segment) {
|
|
172
|
+
const stem = segment.split(".", 1)[0].toLowerCase();
|
|
173
|
+
return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Why a normalized source path cannot be a redirect, or undefined when it can.
|
|
178
|
+
* A mirror of `SiteRedirectMapContract.SourceRefusalReason` on the server (the
|
|
179
|
+
* API is the authority; both run the same test vectors): `…/index.html` is
|
|
180
|
+
* answered by the platform for every page, the generator writes the files
|
|
181
|
+
* named above, and the marker is stored under the decoded spelling, so a path
|
|
182
|
+
* that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
|
|
183
|
+
* segment, a reserved device name) is refused. The site root is left to the
|
|
184
|
+
* occupancy rule.
|
|
185
|
+
*/
|
|
186
|
+
export function redirectSourceRefusalReason(path) {
|
|
187
|
+
if (path === "/") return undefined;
|
|
188
|
+
// Every rule reads the decoded spelling: it is the name the marker is stored
|
|
189
|
+
// under, and the name the generator's own files use (`/%69ndex.html` is
|
|
190
|
+
// `/index.html`).
|
|
191
|
+
let decoded;
|
|
192
|
+
try {
|
|
193
|
+
decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
|
|
194
|
+
} catch {
|
|
195
|
+
return "it holds a percent escape that does not decode.";
|
|
196
|
+
}
|
|
197
|
+
const segments = decoded.split("/");
|
|
198
|
+
const first = segments[0].toLowerCase();
|
|
199
|
+
if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
|
|
200
|
+
return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
|
|
201
|
+
+ "a redirect source, and the page or home file there is generated.";
|
|
202
|
+
}
|
|
203
|
+
if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
|
|
204
|
+
return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
|
|
205
|
+
}
|
|
206
|
+
if (GENERATED_ROOT_FILES.includes(first)) {
|
|
207
|
+
return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
decoded.includes("..")
|
|
211
|
+
|| decoded !== decoded.normalize("NFC")
|
|
212
|
+
|| segments.some((segment) =>
|
|
213
|
+
segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
|
|
214
|
+
|| isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
|
|
215
|
+
)
|
|
216
|
+
) {
|
|
217
|
+
return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
|
|
218
|
+
+ "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
157
223
|
function looksAbsolute(value) {
|
|
158
224
|
return !value.startsWith("/") && value.includes("://");
|
|
159
225
|
}
|
|
@@ -235,14 +301,21 @@ function validateEntry(value, index, seen) {
|
|
|
235
301
|
`${field}.path`,
|
|
236
302
|
);
|
|
237
303
|
}
|
|
238
|
-
|
|
304
|
+
const sourceRefusal = redirectSourceRefusalReason(path);
|
|
305
|
+
if (sourceRefusal !== undefined) {
|
|
306
|
+
throw entryError("redirects.path_unpublishable", `'${path}': ${sourceRefusal}`, `${field}.path`);
|
|
307
|
+
}
|
|
308
|
+
// The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
|
|
309
|
+
// file, and the second would silently replace the first.
|
|
310
|
+
const markerKey = decodeURIComponent(path).toLowerCase();
|
|
311
|
+
if (seen.has(markerKey)) {
|
|
239
312
|
throw entryError(
|
|
240
313
|
"redirects.path_duplicate",
|
|
241
314
|
`'${path}' appears more than once; one path has one entry.`,
|
|
242
315
|
`${field}.path`,
|
|
243
316
|
);
|
|
244
317
|
}
|
|
245
|
-
seen.add(
|
|
318
|
+
seen.add(markerKey);
|
|
246
319
|
|
|
247
320
|
const kind = value.kind === undefined ? REDIRECT_KIND_REDIRECT : value.kind;
|
|
248
321
|
if (!REDIRECT_KINDS.includes(kind)) {
|
|
@@ -296,6 +369,15 @@ function validateEntry(value, index, seen) {
|
|
|
296
369
|
);
|
|
297
370
|
}
|
|
298
371
|
|
|
372
|
+
if (encodeURIComponent(rawTarget).length > REDIRECT_LIMITS.targetEncodedCharacters) {
|
|
373
|
+
throw entryError(
|
|
374
|
+
"redirects.target_too_long_to_store",
|
|
375
|
+
"'target' is stored as metadata on the published site's redirect file and is too long to fit "
|
|
376
|
+
+ `(${REDIRECT_LIMITS.targetEncodedCharacters} characters once percent-encoded).`,
|
|
377
|
+
`${field}.target`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
299
381
|
let target;
|
|
300
382
|
if (looksAbsolute(rawTarget)) {
|
|
301
383
|
if (!isAbsoluteRedirectTarget(rawTarget)) {
|
|
@@ -341,13 +423,23 @@ function validateEntry(value, index, seen) {
|
|
|
341
423
|
return { path, kind: REDIRECT_KIND_REDIRECT, target, status };
|
|
342
424
|
}
|
|
343
425
|
|
|
426
|
+
// The marker is stored under the decoded spelling, so `/%61` and `/a` are one route
|
|
427
|
+
// and `/%61 -> /a` is a loop. A target's escape may not decode; it then compares raw.
|
|
428
|
+
function routeIdentity(path) {
|
|
429
|
+
try {
|
|
430
|
+
return decodeURIComponent(path).toLowerCase();
|
|
431
|
+
} catch {
|
|
432
|
+
return path.toLowerCase();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
344
436
|
function assertNoChains(entries) {
|
|
345
|
-
const sources = new Set(entries.map((entry) => entry.path
|
|
437
|
+
const sources = new Set(entries.map((entry) => routeIdentity(entry.path)));
|
|
346
438
|
entries.forEach((entry, index) => {
|
|
347
439
|
const targetPath = internalRedirectTargetPath(entry);
|
|
348
|
-
if (targetPath === undefined || !sources.has(targetPath
|
|
440
|
+
if (targetPath === undefined || !sources.has(routeIdentity(targetPath))) return;
|
|
349
441
|
const field = `entries[${index}].target`;
|
|
350
|
-
if (targetPath
|
|
442
|
+
if (routeIdentity(targetPath) === routeIdentity(entry.path)) {
|
|
351
443
|
throw entryError("redirects.loop", `'${entry.path}' redirects to itself.`, field);
|
|
352
444
|
}
|
|
353
445
|
throw entryError(
|
package/src/reference-help.js
CHANGED
|
@@ -602,8 +602,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
602
602
|
+ "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
|
|
603
603
|
+ "manual redirects. It reports each path, HTTP status and Location without following targets.",
|
|
604
604
|
"verified is true only if every entry matched and the map revision stayed unchanged during the check. "
|
|
605
|
-
+ "The check proves the current map at staging, not a historical deployment. A mismatch
|
|
606
|
-
+ "
|
|
605
|
+
+ "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
|
|
606
|
+
+ "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; "
|
|
607
607
|
+ "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
|
|
608
608
|
"stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
|
|
609
609
|
+ "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
|
|
@@ -654,10 +654,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
654
654
|
"Converting an engagement's CSV into this file is the agent's job. The CLI's contract is JSON, on "
|
|
655
655
|
+ "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
|
|
656
656
|
+ "which are simply gone.",
|
|
657
|
-
"Entries take effect on the next deploy, staging first.
|
|
658
|
-
+ "when the
|
|
659
|
-
+ "
|
|
660
|
-
+ "
|
|
657
|
+
"Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
|
|
658
|
+
+ "exactly when the release does: staging with the staged release, production when it is promoted. There is "
|
|
659
|
+
+ "no separate propagation delay; an entry a check does not see belongs to a release that is not being "
|
|
660
|
+
+ "served yet.",
|
|
661
|
+
"A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
|
|
662
|
+
+ "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. The platform "
|
|
663
|
+
+ "already redirects every '…/index.html' address to its directory, so no entry is needed for one. A source "
|
|
664
|
+
+ "that could not be stored as a file (a dot-prefixed segment, a reserved device name, an escaped percent "
|
|
665
|
+
+ "sign) is refused too. 'validate' names the entry and the reason.",
|
|
661
666
|
]),
|
|
662
667
|
example: Object.freeze({
|
|
663
668
|
siteId: "<site-uuid>",
|
package/src/verbs/deploy.js
CHANGED
|
@@ -27,24 +27,18 @@ import { ApiError } from "../transport.js";
|
|
|
27
27
|
import { readManifest, writeManifest } from "../workspace.js";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* When a redirect goes live (TR00702, TR00968).
|
|
31
31
|
*
|
|
32
|
-
* A
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* DNS over.
|
|
38
|
-
*
|
|
39
|
-
* No number is quoted, because there is none to quote: the coordinator holds
|
|
40
|
-
* nothing back for a standard site's redirects. Its propagation grace governs
|
|
41
|
-
* only the *deletion* of a superseded Docs pointer namespace; a standard site's
|
|
42
|
-
* rows are written and removed immediately.
|
|
32
|
+
* A redirect or gone entry is a file inside the release this deploy publishes.
|
|
33
|
+
* It goes live with that release and no other way: staging shows it as soon as
|
|
34
|
+
* the staged release is served, production shows it when the release is
|
|
35
|
+
* promoted. There is no separate store to wait on, so a check that does not see
|
|
36
|
+
* an entry is looking at a release that does not carry it yet.
|
|
43
37
|
*/
|
|
44
|
-
const
|
|
45
|
-
"Redirect and gone entries
|
|
46
|
-
+ "
|
|
47
|
-
+ "
|
|
38
|
+
const REDIRECT_ACTIVATION_NOTE =
|
|
39
|
+
"Redirect and gone entries live inside the release this deploy publishes: staging serves them with the staged "
|
|
40
|
+
+ "release and production serves them once that release is promoted. There is no separate propagation delay; "
|
|
41
|
+
+ "an entry a check does not see belongs to a release that is not being served yet.";
|
|
48
42
|
|
|
49
43
|
/**
|
|
50
44
|
* `deploy --staging` / `deploy --production` — readiness, deploy, poll to
|
|
@@ -158,7 +152,7 @@ async function inspectStagingPreview(client, siteId, onProgress, now) {
|
|
|
158
152
|
checks = await checkStagingRedirects(client, siteId, onProgress, now);
|
|
159
153
|
if (!checks.redirects.verified) {
|
|
160
154
|
onProgress(
|
|
161
|
-
"Warning: staging redirects are not all verified.
|
|
155
|
+
"Warning: staging redirects are not all verified. Check the reported mismatches against the staged release; run 'taproot-site redirects check' again once the deployment has completed.",
|
|
162
156
|
);
|
|
163
157
|
}
|
|
164
158
|
} catch {
|
|
@@ -288,7 +282,7 @@ export async function deploy(invocation) {
|
|
|
288
282
|
now,
|
|
289
283
|
});
|
|
290
284
|
await recordDeployment(config, manifest, "production", completed);
|
|
291
|
-
if (!presentationOnly) onProgress(
|
|
285
|
+
if (!presentationOnly) onProgress(REDIRECT_ACTIVATION_NOTE);
|
|
292
286
|
return successResult(VERB_DEPLOY, siteId, {
|
|
293
287
|
target,
|
|
294
288
|
environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
|
|
@@ -398,7 +392,7 @@ export async function deploy(invocation) {
|
|
|
398
392
|
});
|
|
399
393
|
}
|
|
400
394
|
const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
|
|
401
|
-
onProgress(
|
|
395
|
+
onProgress(REDIRECT_ACTIVATION_NOTE);
|
|
402
396
|
return successResult(VERB_DEPLOY, siteId, {
|
|
403
397
|
target,
|
|
404
398
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
@@ -9,7 +9,7 @@ export async function redirectsCheck(invocation) {
|
|
|
9
9
|
const checks = await checkStagingRedirects(client, siteId, onProgress, now);
|
|
10
10
|
if (!checks.redirects.verified) {
|
|
11
11
|
onProgress(
|
|
12
|
-
"Warning: staging redirects are not verified. Check the reported mismatches and retry
|
|
12
|
+
"Warning: staging redirects are not verified. Check the reported mismatches against the staged release and retry once its deployment has completed.",
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
15
|
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|