@taprootio/site-authoring 0.3.0 → 0.4.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 +81 -9
- package/examples/riverbend-wellness/manifest.fixture.json +6 -1
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/package.json +1 -1
- package/src/api.js +175 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +70 -5
- package/src/constants.js +37 -1
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +1 -0
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +122 -5
- package/src/session.js +161 -7
- package/src/transport.js +23 -5
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +7 -2
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +350 -18
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +74 -0
- package/src/verbs/whoami.js +79 -1
- package/src/workspace.js +153 -15
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The redirect map's workspace contract (TR00702).
|
|
5
|
+
*
|
|
6
|
+
* A migrated site's old URLs are the only thing standing between its search
|
|
7
|
+
* history and a wall of 404s, so this file is authored deliberately rather than
|
|
8
|
+
* inferred: `redirects pull` writes it, `redirects push` replaces the whole map
|
|
9
|
+
* from it, and `validate` proves it offline. Every rule the API enforces is
|
|
10
|
+
* enforced here first, by entry index, because a hundred-row import that comes
|
|
11
|
+
* back as one unnamed 400 is a hunt rather than a fix.
|
|
12
|
+
*
|
|
13
|
+
* Where a rule depends on what the site actually serves — path occupancy, and
|
|
14
|
+
* the path bound for an entry the site itself recorded — the site is the
|
|
15
|
+
* authority and this pass defers to it rather than refusing a map the site
|
|
16
|
+
* would have accepted.
|
|
17
|
+
*
|
|
18
|
+
* Converting an engagement's spreadsheet into this file is the agent's job. The
|
|
19
|
+
* CLI's contract is JSON; it deliberately does not parse CSV, because a real
|
|
20
|
+
* migration list needs judgement about which old URLs still deserve a
|
|
21
|
+
* destination and which are simply gone.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const REDIRECTS_FILE_NAME = "redirects.json";
|
|
25
|
+
|
|
26
|
+
export const REDIRECT_KIND_REDIRECT = "redirect";
|
|
27
|
+
export const REDIRECT_KIND_GONE = "gone";
|
|
28
|
+
export const REDIRECT_KINDS = Object.freeze([REDIRECT_KIND_REDIRECT, REDIRECT_KIND_GONE]);
|
|
29
|
+
|
|
30
|
+
export const REDIRECT_ORIGIN_AUTHORED = "authored";
|
|
31
|
+
export const REDIRECT_ORIGIN_PATH_HISTORY = "path_history";
|
|
32
|
+
export const REDIRECT_ORIGINS = Object.freeze([REDIRECT_ORIGIN_AUTHORED, REDIRECT_ORIGIN_PATH_HISTORY]);
|
|
33
|
+
|
|
34
|
+
/** The status a redirect entry serves when it declares none. */
|
|
35
|
+
export const DEFAULT_REDIRECT_STATUS = 301;
|
|
36
|
+
|
|
37
|
+
/** The status a gone entry serves. It is not declarable as anything else. */
|
|
38
|
+
export const GONE_STATUS = 410;
|
|
39
|
+
|
|
40
|
+
/** The statuses the published-site edge value contract accepts. */
|
|
41
|
+
export const REDIRECT_STATUSES = Object.freeze([301, 302, 307, 308]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Bounds, mirrored from `SiteRedirectMapContract` on the server. The path bound
|
|
45
|
+
* is what fits a Workers KV key beside its `redirect:{environment}:{site}:`
|
|
46
|
+
* envelope; the target bound is the edge's own.
|
|
47
|
+
*/
|
|
48
|
+
export const REDIRECT_LIMITS = Object.freeze({
|
|
49
|
+
entries: 2_000,
|
|
50
|
+
pathBytes: 400,
|
|
51
|
+
targetBytes: 2_048,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// C0, DEL, and C1 — every code point the server's own `char.IsControl`
|
|
55
|
+
// refuses, so a value the site would reject is named here by entry index
|
|
56
|
+
// rather than surviving the offline pass. A path or target carrying one is
|
|
57
|
+
// not a URL a browser could have requested, and normalizing it away would
|
|
58
|
+
// hide the mistake.
|
|
59
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
60
|
+
const ENTRY_KEYS = new Set(["path", "kind", "target", "status", "origin"]);
|
|
61
|
+
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
62
|
+
|
|
63
|
+
// An empty interior segment, or a dot or slash hidden behind percent-encoding.
|
|
64
|
+
// '//' is also where a protocol-relative URL hides, which is why the leading
|
|
65
|
+
// case is covered by this rule rather than by a separate prefix check.
|
|
66
|
+
const RESOLVABLE_SPELLING = /\/\/|%2e|%2f/iu;
|
|
67
|
+
|
|
68
|
+
// A spelling no browser would send as a pathname: anything outside printable
|
|
69
|
+
// ASCII, one of the characters a URL parser escapes on sight, or a '%' that is
|
|
70
|
+
// not an escape.
|
|
71
|
+
// No `i` flag: under Unicode case folding it would let U+212A (KELVIN SIGN)
|
|
72
|
+
// and U+017F (LONG S) through `[^!-~]`, which the site refuses.
|
|
73
|
+
const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a path is already written the way a browser sends a pathname.
|
|
77
|
+
*
|
|
78
|
+
* The published-site edge keys on `url.pathname`, which the URL parser has
|
|
79
|
+
* already percent-encoded, and its `normalizePathname` decodes nothing — while
|
|
80
|
+
* every rule here compares the string as written. So '/old%20page -> /old page'
|
|
81
|
+
* is two different strings to loop detection and one path to the edge, which
|
|
82
|
+
* matches '/old%20page', resolves the target with `new URL` back to
|
|
83
|
+
* '/old%20page', and serves a permanent self-loop. A source written with a
|
|
84
|
+
* literal space or a non-ASCII character is the same mismatch the other way,
|
|
85
|
+
* stored under a key no request pathname ever matches.
|
|
86
|
+
*
|
|
87
|
+
* Refused rather than encoded here. URL escaping is not reproducibly canonical
|
|
88
|
+
* between the API's .NET and the edge's WHATWG parser, so a spelling this pass
|
|
89
|
+
* encoded would still not be the one the Worker computes and the layers would
|
|
90
|
+
* go on comparing different strings. Write what a browser sends and every layer
|
|
91
|
+
* compares it unchanged — and the byte bounds then measure the spelling the KV
|
|
92
|
+
* key actually costs.
|
|
93
|
+
*/
|
|
94
|
+
function isRequestSpelling(value) {
|
|
95
|
+
return !UNSENDABLE_SPELLING.test(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a path names one thing to this pass and another wherever it is
|
|
100
|
+
* resolved.
|
|
101
|
+
*
|
|
102
|
+
* Every rule here compares the string as written, and nothing downstream does:
|
|
103
|
+
* the generator resolves a source with path.resolve, so '/x/../visit' writes
|
|
104
|
+
* over the page rendered at '/visit', and the published-site edge resolves a
|
|
105
|
+
* target with `new URL`, so '/a -> /x/../a' is the self-loop that loop
|
|
106
|
+
* detection just passed. Percent-encoded dots and slashes are the same trick
|
|
107
|
+
* spelled so a segment scan cannot see it, so they are refused wherever they
|
|
108
|
+
* appear rather than decoded and re-examined.
|
|
109
|
+
*/
|
|
110
|
+
function resolvesAway(value) {
|
|
111
|
+
if (RESOLVABLE_SPELLING.test(value)) return true;
|
|
112
|
+
return value.split("/").some((segment) => segment === "." || segment === "..");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether the value is a redirect-map revision as the server mints one. */
|
|
116
|
+
export function isRedirectMapRevision(value) {
|
|
117
|
+
return typeof value === "string" && SHA256.test(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function entryError(code, message, field) {
|
|
121
|
+
return new SiteAuthoringError(code, `${field}: ${message}`, { field });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isPlainObject(value) {
|
|
125
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function utf8Length(value) {
|
|
129
|
+
return Buffer.byteLength(value, "utf8");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Normalizes a source path exactly as the published-site Worker's
|
|
134
|
+
* `normalizePathname` does: require the leading slash, strip trailing slashes,
|
|
135
|
+
* change nothing else. A `.html` suffix survives, which is the whole point —
|
|
136
|
+
* `/faqs.html` is a real legacy URL and has to be representable. A spelling
|
|
137
|
+
* some other layer would resolve away is refused rather than repaired (see
|
|
138
|
+
* `resolvesAway`), and so is one no browser would send as a pathname (see
|
|
139
|
+
* `isRequestSpelling`, which subsumes the control-character refusal).
|
|
140
|
+
*/
|
|
141
|
+
export function normalizeRedirectPath(value) {
|
|
142
|
+
if (typeof value !== "string") return undefined;
|
|
143
|
+
const candidate = value.trim();
|
|
144
|
+
if (
|
|
145
|
+
candidate === ""
|
|
146
|
+
|| /[?#\\]/u.test(candidate)
|
|
147
|
+
|| !isRequestSpelling(candidate)
|
|
148
|
+
|| resolvesAway(candidate)
|
|
149
|
+
) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const withSlash = candidate.startsWith("/") ? candidate : `/${candidate}`;
|
|
153
|
+
const trimmed = withSlash.replace(/\/+$/u, "");
|
|
154
|
+
return trimmed === "" ? "/" : trimmed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function looksAbsolute(value) {
|
|
158
|
+
return !value.startsWith("/") && value.includes("://");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether the value is an absolute, credential-free http(s) URL. */
|
|
162
|
+
export function isAbsoluteRedirectTarget(value) {
|
|
163
|
+
let url;
|
|
164
|
+
try {
|
|
165
|
+
url = new URL(value);
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return (url.protocol === "https:" || url.protocol === "http:")
|
|
170
|
+
&& url.username === ""
|
|
171
|
+
&& url.password === "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The site path an internal target names, or undefined when the target leaves
|
|
176
|
+
* the site. Only internal targets take part in chain and loop detection: an
|
|
177
|
+
* absolute URL is another origin's business even when it happens to spell this
|
|
178
|
+
* site's own domain.
|
|
179
|
+
*
|
|
180
|
+
* The query string and fragment are cut before the comparison, never before
|
|
181
|
+
* storage. The edge keys a redirect on the pathname alone, so '/b?x=1' and
|
|
182
|
+
* '/b' are one route to it; normalizing the whole target would return
|
|
183
|
+
* undefined for either (a source may carry no query, a target may) and
|
|
184
|
+
* '/a -> /b?x=1' beside '/b -> /a?y=2' would pass as a pair of unrelated
|
|
185
|
+
* entries and loop at the edge.
|
|
186
|
+
*/
|
|
187
|
+
export function internalRedirectTargetPath(entry) {
|
|
188
|
+
if (entry.kind === REDIRECT_KIND_GONE || isAbsoluteRedirectTarget(entry.target)) return undefined;
|
|
189
|
+
if (typeof entry.target !== "string") return undefined;
|
|
190
|
+
const cut = entry.target.search(/[?#]/u);
|
|
191
|
+
return normalizeRedirectPath(cut === -1 ? entry.target : entry.target.slice(0, cut));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateEntry(value, index, seen) {
|
|
195
|
+
const field = `entries[${index}]`;
|
|
196
|
+
if (!isPlainObject(value)) {
|
|
197
|
+
throw entryError("redirects.entry_invalid", "a redirect entry must be an object.", field);
|
|
198
|
+
}
|
|
199
|
+
const unknown = Object.keys(value).filter((key) => !ENTRY_KEYS.has(key)).sort();
|
|
200
|
+
if (unknown.length > 0) {
|
|
201
|
+
throw entryError(
|
|
202
|
+
"redirects.unknown_field",
|
|
203
|
+
`unsupported redirect field '${unknown[0]}'; an entry carries ${[...ENTRY_KEYS].join(", ")}.`,
|
|
204
|
+
field,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const path = normalizeRedirectPath(value.path);
|
|
209
|
+
if (path === undefined) {
|
|
210
|
+
throw entryError(
|
|
211
|
+
"redirects.path_invalid",
|
|
212
|
+
"'path' must be an absolute site path such as /faqs.html, with no query string, fragment, or host, no '.' "
|
|
213
|
+
+ "or '..' segment, no empty segment, and no percent-encoded dot or slash. Write the percent-encoded "
|
|
214
|
+
+ "spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
|
|
215
|
+
`${field}.path`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
// The site root is not refused here. A home page rename records an entry at
|
|
219
|
+
// '/', so refusing it offline would make a pulled map unpushable for a site
|
|
220
|
+
// whose home page moved. Occupancy is the rule that belongs, and it is the
|
|
221
|
+
// site's to enforce: '/' is refused for exactly as long as a home page is
|
|
222
|
+
// served there.
|
|
223
|
+
//
|
|
224
|
+
// The path bound is the same shape of problem. A page may sit at a path
|
|
225
|
+
// longer than the map allows a source to be, and renaming it records a
|
|
226
|
+
// path-history entry at that length which 'redirects pull' returns. Refusing
|
|
227
|
+
// it here would make the site's own map fail 'validate' and unpushable
|
|
228
|
+
// without dropping a live redirect, so an entry the site reports as
|
|
229
|
+
// path_history is exempt and the site decides: it alone knows whether the
|
|
230
|
+
// push is introducing that path or merely carrying it back unchanged.
|
|
231
|
+
if (value.origin !== REDIRECT_ORIGIN_PATH_HISTORY && utf8Length(path) > REDIRECT_LIMITS.pathBytes) {
|
|
232
|
+
throw entryError(
|
|
233
|
+
"redirects.path_too_long",
|
|
234
|
+
`'path' cannot exceed ${REDIRECT_LIMITS.pathBytes} bytes.`,
|
|
235
|
+
`${field}.path`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (seen.has(path.toLowerCase())) {
|
|
239
|
+
throw entryError(
|
|
240
|
+
"redirects.path_duplicate",
|
|
241
|
+
`'${path}' appears more than once; one path has one entry.`,
|
|
242
|
+
`${field}.path`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
seen.add(path.toLowerCase());
|
|
246
|
+
|
|
247
|
+
const kind = value.kind === undefined ? REDIRECT_KIND_REDIRECT : value.kind;
|
|
248
|
+
if (!REDIRECT_KINDS.includes(kind)) {
|
|
249
|
+
throw entryError(
|
|
250
|
+
"redirects.kind_invalid",
|
|
251
|
+
`'kind' must be one of ${REDIRECT_KINDS.join(", ")}.`,
|
|
252
|
+
`${field}.kind`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (value.origin !== undefined && !REDIRECT_ORIGINS.includes(value.origin)) {
|
|
257
|
+
throw entryError(
|
|
258
|
+
"redirects.origin_invalid",
|
|
259
|
+
`'origin' must be one of ${REDIRECT_ORIGINS.join(", ")} when present. It is what the site reports, `
|
|
260
|
+
+ "not something a push can set.",
|
|
261
|
+
`${field}.origin`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (kind === REDIRECT_KIND_GONE) {
|
|
266
|
+
if (value.target !== undefined && value.target !== "") {
|
|
267
|
+
throw entryError(
|
|
268
|
+
"redirects.gone_target",
|
|
269
|
+
"a gone entry answers 410 and carries no target.",
|
|
270
|
+
`${field}.target`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (value.status !== undefined && value.status !== GONE_STATUS) {
|
|
274
|
+
throw entryError(
|
|
275
|
+
"redirects.gone_status",
|
|
276
|
+
`a gone entry serves ${GONE_STATUS}; no other status can be declared for it.`,
|
|
277
|
+
`${field}.status`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return { path, kind: REDIRECT_KIND_GONE, target: "", status: GONE_STATUS };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (typeof value.target !== "string" || value.target.trim() === "") {
|
|
284
|
+
throw entryError(
|
|
285
|
+
"redirects.target_missing",
|
|
286
|
+
"a redirect entry needs a 'target': a site-relative path such as /classes, or an absolute http(s) URL.",
|
|
287
|
+
`${field}.target`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const rawTarget = value.target.trim();
|
|
291
|
+
if (utf8Length(rawTarget) > REDIRECT_LIMITS.targetBytes) {
|
|
292
|
+
throw entryError(
|
|
293
|
+
"redirects.target_too_long",
|
|
294
|
+
`'target' cannot exceed ${REDIRECT_LIMITS.targetBytes} bytes.`,
|
|
295
|
+
`${field}.target`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let target;
|
|
300
|
+
if (looksAbsolute(rawTarget)) {
|
|
301
|
+
if (!isAbsoluteRedirectTarget(rawTarget)) {
|
|
302
|
+
throw entryError(
|
|
303
|
+
"redirects.target_invalid",
|
|
304
|
+
"an absolute 'target' must be an http or https URL carrying no credentials.",
|
|
305
|
+
`${field}.target`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
target = rawTarget;
|
|
309
|
+
} else {
|
|
310
|
+
// Only the path component is held to the alias and spelling rules: a query
|
|
311
|
+
// string or fragment legitimately carries percent-encoded slashes and whole
|
|
312
|
+
// URLs, and the edge keys on the pathname alone, as
|
|
313
|
+
// internalRedirectTargetPath cuts it.
|
|
314
|
+
const targetPath = rawTarget.split(/[?#]/u)[0];
|
|
315
|
+
if (
|
|
316
|
+
rawTarget.includes("\\")
|
|
317
|
+
|| CONTROL_CHARACTERS.test(rawTarget)
|
|
318
|
+
|| !isRequestSpelling(targetPath)
|
|
319
|
+
|| resolvesAway(targetPath)
|
|
320
|
+
) {
|
|
321
|
+
throw entryError(
|
|
322
|
+
"redirects.target_invalid",
|
|
323
|
+
"a site-relative 'target' must be a single-slash absolute path such as /classes, whose path carries no "
|
|
324
|
+
+ "'.' or '..' segment and no percent-encoded dot or slash (a query string may). Write its path in the "
|
|
325
|
+
+ "percent-encoded spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
|
|
326
|
+
`${field}.target`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
target = rawTarget.startsWith("/") ? rawTarget : `/${rawTarget}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const status = value.status === undefined ? DEFAULT_REDIRECT_STATUS : value.status;
|
|
333
|
+
if (!REDIRECT_STATUSES.includes(status)) {
|
|
334
|
+
throw entryError(
|
|
335
|
+
"redirects.status_invalid",
|
|
336
|
+
`'status' must be one of ${REDIRECT_STATUSES.join(", ")}.`,
|
|
337
|
+
`${field}.status`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { path, kind: REDIRECT_KIND_REDIRECT, target, status };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function assertNoChains(entries) {
|
|
345
|
+
const sources = new Set(entries.map((entry) => entry.path.toLowerCase()));
|
|
346
|
+
entries.forEach((entry, index) => {
|
|
347
|
+
const targetPath = internalRedirectTargetPath(entry);
|
|
348
|
+
if (targetPath === undefined || !sources.has(targetPath.toLowerCase())) return;
|
|
349
|
+
const field = `entries[${index}].target`;
|
|
350
|
+
if (targetPath.toLowerCase() === entry.path.toLowerCase()) {
|
|
351
|
+
throw entryError("redirects.loop", `'${entry.path}' redirects to itself.`, field);
|
|
352
|
+
}
|
|
353
|
+
throw entryError(
|
|
354
|
+
"redirects.chain",
|
|
355
|
+
`'${entry.path}' targets '${targetPath}', which is itself a redirect source. Point it at the final `
|
|
356
|
+
+ "destination instead; Taproot refuses chains rather than following them.",
|
|
357
|
+
field,
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Validates the whole `redirects.json` document and returns the normalized map
|
|
364
|
+
* a push sends. The document is `{ siteId, revision, entries }` — the same
|
|
365
|
+
* shape `redirects pull` writes — so a workspace file always says which site
|
|
366
|
+
* and which read it belongs to.
|
|
367
|
+
*
|
|
368
|
+
* `baselineEntries` is how many entries the last pull recorded in the manifest,
|
|
369
|
+
* and it scopes the entry-count bound exactly as the site scopes it: a map that
|
|
370
|
+
* was already over the cap when it was pulled validates and pushes back, so
|
|
371
|
+
* accumulated path history never has to be deleted to make a push land, while a
|
|
372
|
+
* document authored past the cap is still refused offline. A caller with no
|
|
373
|
+
* baseline — a fixture, whose entries are all authored — passes none and gets
|
|
374
|
+
* the bare cap.
|
|
375
|
+
*/
|
|
376
|
+
export function validateRedirectsDocument(
|
|
377
|
+
document_,
|
|
378
|
+
siteId,
|
|
379
|
+
{ requireRevision = true, baselineEntries = 0 } = {},
|
|
380
|
+
) {
|
|
381
|
+
if (!isPlainObject(document_)) {
|
|
382
|
+
throw new SiteAuthoringError(
|
|
383
|
+
"redirects.document_invalid",
|
|
384
|
+
`${REDIRECTS_FILE_NAME} must be the redirect document written by pull: { siteId, revision, entries }.`,
|
|
385
|
+
{ field: REDIRECTS_FILE_NAME },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (document_.siteId !== siteId) {
|
|
389
|
+
throw new SiteAuthoringError(
|
|
390
|
+
"redirects.site_mismatch",
|
|
391
|
+
`${REDIRECTS_FILE_NAME} is not bound to this site. Run 'taproot-site pull' again.`,
|
|
392
|
+
{ field: "siteId" },
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (requireRevision && !isRedirectMapRevision(document_.revision)) {
|
|
396
|
+
throw new SiteAuthoringError(
|
|
397
|
+
"redirects.revision_invalid",
|
|
398
|
+
`${REDIRECTS_FILE_NAME} does not record the map revision it was read at. Run 'taproot-site redirects pull' `
|
|
399
|
+
+ "again before pushing; without it a push could delete an entry a page rename recorded since.",
|
|
400
|
+
{ field: "revision" },
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (!Array.isArray(document_.entries)) {
|
|
404
|
+
throw new SiteAuthoringError(
|
|
405
|
+
"redirects.entries_invalid",
|
|
406
|
+
`${REDIRECTS_FILE_NAME} must carry an 'entries' array.`,
|
|
407
|
+
{ field: "entries" },
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const baseline = Number.isInteger(baselineEntries) && baselineEntries > 0 ? baselineEntries : 0;
|
|
411
|
+
if (document_.entries.length > Math.max(REDIRECT_LIMITS.entries, baseline)) {
|
|
412
|
+
throw new SiteAuthoringError(
|
|
413
|
+
"redirects.too_many_entries",
|
|
414
|
+
`A site's redirect map cannot hold more than ${REDIRECT_LIMITS.entries} entries, and the last pull `
|
|
415
|
+
+ `recorded ${baseline}, so a push may carry that many back or fewer; this document holds `
|
|
416
|
+
+ `${document_.entries.length}.`,
|
|
417
|
+
{ field: "entries" },
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const seen = new Set();
|
|
422
|
+
const entries = document_.entries.map((entry, index) => validateEntry(entry, index, seen));
|
|
423
|
+
assertNoChains(entries);
|
|
424
|
+
return {
|
|
425
|
+
siteId,
|
|
426
|
+
revision: document_.revision,
|
|
427
|
+
entries: entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Projects a map read from the API into the document shape pull writes. */
|
|
432
|
+
export function projectRedirectMapForWorkspace(siteId, map) {
|
|
433
|
+
return {
|
|
434
|
+
siteId,
|
|
435
|
+
revision: map.revision,
|
|
436
|
+
entries: map.entries.map((entry) => ({
|
|
437
|
+
path: entry.path,
|
|
438
|
+
kind: entry.kind,
|
|
439
|
+
...(entry.kind === REDIRECT_KIND_GONE ? {} : { target: entry.target }),
|
|
440
|
+
status: entry.status,
|
|
441
|
+
origin: entry.origin,
|
|
442
|
+
})),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
2
|
+
import { isRedirectMapRevision, REDIRECTS_FILE_NAME } from "./redirects-contract.js";
|
|
3
|
+
import { readManifest, writeManifest } from "./workspace.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The redirect map's workspace baseline (TR00702).
|
|
7
|
+
*
|
|
8
|
+
* `nav.json` needs no baseline because navigation is last-write-wins; the
|
|
9
|
+
* redirect map cannot be, because it holds entries nobody typed. Every page and
|
|
10
|
+
* tag rename records one, so a whole-map push built on a stale read would
|
|
11
|
+
* delete the entry that keeps the renamed page's old URL alive. The manifest
|
|
12
|
+
* therefore records the revision the map was read at, exactly as it records the
|
|
13
|
+
* footer's draft hash, and a push that cannot produce one refuses toward pull
|
|
14
|
+
* rather than guessing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const REDIRECTS_MANIFEST_KEY = "redirects";
|
|
18
|
+
|
|
19
|
+
function pullRequired(message, field) {
|
|
20
|
+
return new SiteAuthoringError("redirects.pull_required", message, { field });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function requireRedirectsMetadata(manifest) {
|
|
24
|
+
const redirects = manifest[REDIRECTS_MANIFEST_KEY];
|
|
25
|
+
if (redirects === null || typeof redirects !== "object" || Array.isArray(redirects)) {
|
|
26
|
+
throw pullRequired(
|
|
27
|
+
"The pull manifest does not record a redirect-map baseline. Run 'taproot-site redirects pull' "
|
|
28
|
+
+ "(or 'taproot-site pull') again.",
|
|
29
|
+
REDIRECTS_MANIFEST_KEY,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (redirects.file !== REDIRECTS_FILE_NAME || !isRedirectMapRevision(redirects.revision)) {
|
|
33
|
+
throw pullRequired(
|
|
34
|
+
"The pull manifest has an invalid redirect-map baseline. Run 'taproot-site redirects pull' again.",
|
|
35
|
+
`${REDIRECTS_MANIFEST_KEY}.revision`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return redirects;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readRedirectsWorkspaceContext(workspaceDir, siteId) {
|
|
42
|
+
const manifest = await readManifest(workspaceDir, siteId);
|
|
43
|
+
return { manifest, redirects: requireRedirectsMetadata(manifest) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The manifest entry `pull` writes for a freshly read map. */
|
|
47
|
+
export function redirectsManifestEntry(map) {
|
|
48
|
+
return {
|
|
49
|
+
file: REDIRECTS_FILE_NAME,
|
|
50
|
+
revision: map.revision,
|
|
51
|
+
entries: map.entries.length,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Records the revision the site reported after a successful replace, so the
|
|
57
|
+
* next push is fenced against that state rather than the one before it.
|
|
58
|
+
*/
|
|
59
|
+
export async function advanceRedirectsManifest(workspaceDir, siteId, map) {
|
|
60
|
+
const manifest = await readManifest(workspaceDir, siteId);
|
|
61
|
+
requireRedirectsMetadata(manifest);
|
|
62
|
+
if (!isRedirectMapRevision(map.revision)) {
|
|
63
|
+
throw new SiteAuthoringError(
|
|
64
|
+
"api.redirects_contract",
|
|
65
|
+
"Taproot returned a redirect map whose revision could not be recorded.",
|
|
66
|
+
{ field: "revision" },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
manifest[REDIRECTS_MANIFEST_KEY] = redirectsManifestEntry(map);
|
|
70
|
+
await writeManifest(workspaceDir, manifest);
|
|
71
|
+
return map.revision;
|
|
72
|
+
}
|