@caelo-cms/shared 0.10.48 → 0.10.50
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/dist/ai-tools.d.ts +4 -3
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +5 -4
- package/dist/ai-tools.js.map +1 -1
- package/dist/auto-update.d.ts +124 -0
- package/dist/auto-update.d.ts.map +1 -0
- package/dist/auto-update.js +228 -0
- package/dist/auto-update.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/platform-updates.d.ts +13 -7
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +24 -8
- package/dist/platform-updates.js.map +1 -1
- package/dist/redirect-emit-caddy.d.ts +44 -0
- package/dist/redirect-emit-caddy.d.ts.map +1 -0
- package/dist/redirect-emit-caddy.js +76 -0
- package/dist/redirect-emit-caddy.js.map +1 -0
- package/dist/redirect-emit-cloudfront.d.ts +55 -0
- package/dist/redirect-emit-cloudfront.d.ts.map +1 -0
- package/dist/redirect-emit-cloudfront.js +99 -0
- package/dist/redirect-emit-cloudfront.js.map +1 -0
- package/dist/redirect-emit-front-door.d.ts +63 -0
- package/dist/redirect-emit-front-door.d.ts.map +1 -0
- package/dist/redirect-emit-front-door.js +79 -0
- package/dist/redirect-emit-front-door.js.map +1 -0
- package/dist/redirect-emit-gcp.d.ts +89 -0
- package/dist/redirect-emit-gcp.d.ts.map +1 -0
- package/dist/redirect-emit-gcp.js +91 -0
- package/dist/redirect-emit-gcp.js.map +1 -0
- package/dist/redirect-plan.d.ts +69 -0
- package/dist/redirect-plan.d.ts.map +1 -0
- package/dist/redirect-plan.js +218 -0
- package/dist/redirect-plan.js.map +1 -0
- package/dist/release-manifest.d.ts +99 -0
- package/dist/release-manifest.d.ts.map +1 -0
- package/dist/release-manifest.js +161 -0
- package/dist/release-manifest.js.map +1 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +4 -1
- package/dist/seo.js.map +1 -1
- package/dist/static-cache-policy.d.ts +7 -4
- package/dist/static-cache-policy.d.ts.map +1 -1
- package/dist/static-cache-policy.js +7 -4
- package/dist/static-cache-policy.js.map +1 -1
- package/dist/subagents.d.ts +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +5 -4
- package/src/auto-update.test.ts +321 -0
- package/src/auto-update.ts +315 -0
- package/src/index.ts +7 -0
- package/src/platform-updates.test.ts +5 -0
- package/src/platform-updates.ts +31 -7
- package/src/redirect-emit-caddy.ts +103 -0
- package/src/redirect-emit-cloudfront.ts +145 -0
- package/src/redirect-emit-front-door.ts +140 -0
- package/src/redirect-emit-gcp.ts +165 -0
- package/src/redirect-emit.test.ts +291 -0
- package/src/redirect-plan.test.ts +143 -0
- package/src/redirect-plan.ts +298 -0
- package/src/release-manifest.ts +212 -0
- package/src/seo.ts +4 -1
- package/src/static-cache-policy.test.ts +6 -2
- package/src/static-cache-policy.ts +7 -4
- package/src/version.ts +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #587 — the per-provider emitters of the redirect plan: Caddy snippet,
|
|
5
|
+
* GCP URL-map path rules, CloudFront KeyValueStore + function, Front Door
|
|
6
|
+
* rules. (The Caddy snippet also runs through a real Caddy in
|
|
7
|
+
* packages/provisioning/src/caddy-redirects.docker.test.ts.)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import { caddyRedirectsImport, emitCaddyRedirects } from "./redirect-emit-caddy.js";
|
|
12
|
+
import {
|
|
13
|
+
CLOUDFRONT_KVS_MAX_KEY_BYTES,
|
|
14
|
+
CloudFrontKvsLimitError,
|
|
15
|
+
cloudFrontKeyValueEntries,
|
|
16
|
+
cloudFrontRedirectFunctionCode,
|
|
17
|
+
} from "./redirect-emit-cloudfront.js";
|
|
18
|
+
import {
|
|
19
|
+
FRONT_DOOR_MAX_RULES,
|
|
20
|
+
FrontDoorRuleLimitError,
|
|
21
|
+
frontDoorRedirectRules,
|
|
22
|
+
} from "./redirect-emit-front-door.js";
|
|
23
|
+
import {
|
|
24
|
+
assertGcpRedirectBudget,
|
|
25
|
+
GCP_PUBLIC_BASE_PATHS,
|
|
26
|
+
GcpUrlMapBudgetError,
|
|
27
|
+
gcpRedirectPathRules,
|
|
28
|
+
withGcpRedirects,
|
|
29
|
+
} from "./redirect-emit-gcp.js";
|
|
30
|
+
import { buildRedirectPlan, type RedirectPlan, type RedirectTableRow } from "./redirect-plan.js";
|
|
31
|
+
|
|
32
|
+
const planOf = (rows: RedirectTableRow[]): RedirectPlan =>
|
|
33
|
+
buildRedirectPlan({ rows, pageUrlStyle: "directory", servedFiles: new Set() });
|
|
34
|
+
const row = (fromPath: string, toPath: string, statusCode = 301): RedirectTableRow => ({
|
|
35
|
+
fromPath,
|
|
36
|
+
toPath,
|
|
37
|
+
statusCode,
|
|
38
|
+
});
|
|
39
|
+
const PLAN = planOf([
|
|
40
|
+
row("/old", "/new"),
|
|
41
|
+
row("/promo.html", "/sale", 302),
|
|
42
|
+
row("/q", "/t?a=1", 308),
|
|
43
|
+
row("/gone", "/410", 410),
|
|
44
|
+
]);
|
|
45
|
+
const EMPTY = planOf([]);
|
|
46
|
+
|
|
47
|
+
describe("emitCaddyRedirects", () => {
|
|
48
|
+
it("maps every match path to target + status and redirects per status", () => {
|
|
49
|
+
expect(emitCaddyRedirects(PLAN)).toBe(
|
|
50
|
+
[
|
|
51
|
+
"# Auto-generated by Caelo static-generator from the redirects table. Do not edit.",
|
|
52
|
+
"map {path} {caelo_redirect_to} {caelo_redirect_status} {",
|
|
53
|
+
"\t/old /new/{?query} 301",
|
|
54
|
+
"\t/old/ /new/{?query} 301",
|
|
55
|
+
"\t/promo.html /sale/{?query} 302",
|
|
56
|
+
"\t/q /t?a=1 308",
|
|
57
|
+
"\t/q/ /t?a=1 308",
|
|
58
|
+
"\t/gone - 410",
|
|
59
|
+
"\t/gone/ - 410",
|
|
60
|
+
"}",
|
|
61
|
+
"@caelo_redirect_301 vars {caelo_redirect_status} 301",
|
|
62
|
+
"redir @caelo_redirect_301 {caelo_redirect_to} 301",
|
|
63
|
+
"@caelo_redirect_302 vars {caelo_redirect_status} 302",
|
|
64
|
+
"redir @caelo_redirect_302 {caelo_redirect_to} 302",
|
|
65
|
+
"@caelo_redirect_308 vars {caelo_redirect_status} 308",
|
|
66
|
+
"redir @caelo_redirect_308 {caelo_redirect_to} 308",
|
|
67
|
+
"@caelo_gone vars {caelo_redirect_status} 410",
|
|
68
|
+
"handle @caelo_gone {",
|
|
69
|
+
"\trespond 410",
|
|
70
|
+
"}",
|
|
71
|
+
"",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("emits only the header for an empty plan (a valid empty import)", () => {
|
|
77
|
+
expect(emitCaddyRedirects(EMPTY)).toBe(
|
|
78
|
+
"# Auto-generated by Caelo static-generator from the redirects table. Do not edit.\n",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("refuses characters a Caddyfile token cannot carry", () => {
|
|
83
|
+
expect(() => emitCaddyRedirects(planOf([row("/a{b}", "/c")]))).toThrow(/'\{' is '%7B'/);
|
|
84
|
+
expect(() => emitCaddyRedirects(planOf([row("/a", '/c"d')]))).toThrow(/Caddyfile/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("imports the snippet with a wildcard so a site root without a build still starts", () => {
|
|
88
|
+
expect(caddyRedirectsImport("/srv/caelo/output/production/current/")).toBe(
|
|
89
|
+
"import /srv/caelo/output/production/current/_redirects.caddy*",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("GCP URL-map rules", () => {
|
|
95
|
+
it("emits one urlRedirect path rule per redirect, keeping the query", () => {
|
|
96
|
+
const plan = planOf([
|
|
97
|
+
row("/old", "/new"),
|
|
98
|
+
row("/promo.html", "/sale", 302),
|
|
99
|
+
row("/x", "/y", 307),
|
|
100
|
+
]);
|
|
101
|
+
expect(gcpRedirectPathRules(plan)).toEqual([
|
|
102
|
+
{
|
|
103
|
+
paths: ["/old", "/old/"],
|
|
104
|
+
urlRedirect: {
|
|
105
|
+
pathRedirect: "/new/",
|
|
106
|
+
redirectResponseCode: "MOVED_PERMANENTLY_DEFAULT",
|
|
107
|
+
stripQuery: false,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
paths: ["/promo.html"],
|
|
112
|
+
urlRedirect: { pathRedirect: "/sale/", redirectResponseCode: "FOUND", stripQuery: false },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
paths: ["/x", "/x/"],
|
|
116
|
+
urlRedirect: {
|
|
117
|
+
pathRedirect: "/y/",
|
|
118
|
+
redirectResponseCode: "TEMPORARY_REDIRECT",
|
|
119
|
+
stripQuery: false,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("refuses what a URL map cannot express", () => {
|
|
126
|
+
expect(() => gcpRedirectPathRules(planOf([row("/a*", "/b")]))).toThrow(/wildcard/);
|
|
127
|
+
expect(() => gcpRedirectPathRules(planOf([row("/a", "/b?x=1")]))).toThrow(/query string/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("replaces earlier redirect rules and keeps the stack's routing first", () => {
|
|
131
|
+
const api = { paths: ["/api/*"], service: "gateway" };
|
|
132
|
+
const stale = { paths: ["/stale"], urlRedirect: { pathRedirect: "/x/" } };
|
|
133
|
+
const out = withGcpRedirects(
|
|
134
|
+
{ name: "public", defaultService: "bucket", pathRules: [api, stale] },
|
|
135
|
+
planOf([row("/old", "/new")]),
|
|
136
|
+
);
|
|
137
|
+
expect(out.defaultService).toBe("bucket");
|
|
138
|
+
expect(out.pathRules?.map((r) => r.paths)).toEqual([["/api/*"], ["/old", "/old/"]]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("an empty plan removes every redirect rule", () => {
|
|
142
|
+
const out = withGcpRedirects(
|
|
143
|
+
{
|
|
144
|
+
name: "public",
|
|
145
|
+
pathRules: [
|
|
146
|
+
{ paths: ["/api/*"], service: "g" },
|
|
147
|
+
{ paths: ["/a"], urlRedirect: {} },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
EMPTY,
|
|
151
|
+
);
|
|
152
|
+
expect(out.pathRules).toEqual([{ paths: ["/api/*"], service: "g" }]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("refuses a plan over the 1,000-predicate quota with the counts, before any change", () => {
|
|
156
|
+
const base = [{ paths: [...GCP_PUBLIC_BASE_PATHS] }];
|
|
157
|
+
const rows = (n: number) => Array.from({ length: n }, (_, i) => row(`/p${i}`, "/x"));
|
|
158
|
+
expect(() => assertGcpRedirectBudget(planOf(rows(499)), base)).not.toThrow();
|
|
159
|
+
try {
|
|
160
|
+
assertGcpRedirectBudget(planOf(rows(500)), base);
|
|
161
|
+
throw new Error("expected GcpUrlMapBudgetError");
|
|
162
|
+
} catch (e) {
|
|
163
|
+
expect(e).toBeInstanceOf(GcpUrlMapBudgetError);
|
|
164
|
+
expect((e as GcpUrlMapBudgetError).predicatesNeeded).toBe(1001);
|
|
165
|
+
expect((e as Error).message).toContain("500 redirect(s) need 1001 paths");
|
|
166
|
+
expect((e as Error).message).toContain("bulk_delete_redirects");
|
|
167
|
+
}
|
|
168
|
+
// File-like paths cost one predicate each.
|
|
169
|
+
const files = Array.from({ length: 999 }, (_, i) => row(`/p${i}.html`, "/x"));
|
|
170
|
+
expect(() => assertGcpRedirectBudget(planOf(files), base)).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("refuses a path matcher using routeRules (changed outside the stack)", () => {
|
|
174
|
+
expect(() => withGcpRedirects({ name: "public", routeRules: [{}] }, EMPTY)).toThrow(
|
|
175
|
+
/routeRules/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("CloudFront KeyValueStore + function", () => {
|
|
181
|
+
it("stores one entry per match path, sorted", () => {
|
|
182
|
+
expect(cloudFrontKeyValueEntries(PLAN)).toEqual([
|
|
183
|
+
{ key: "/gone", value: '{"s":410}' },
|
|
184
|
+
{ key: "/gone/", value: '{"s":410}' },
|
|
185
|
+
{ key: "/old", value: '{"s":301,"l":"/new/","q":1}' },
|
|
186
|
+
{ key: "/old/", value: '{"s":301,"l":"/new/","q":1}' },
|
|
187
|
+
{ key: "/promo.html", value: '{"s":302,"l":"/sale/","q":1}' },
|
|
188
|
+
{ key: "/q", value: '{"s":308,"l":"/t?a=1"}' },
|
|
189
|
+
{ key: "/q/", value: '{"s":308,"l":"/t?a=1"}' },
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("refuses a plan over the store limits", () => {
|
|
194
|
+
const long = `/${"a".repeat(CLOUDFRONT_KVS_MAX_KEY_BYTES)}`;
|
|
195
|
+
expect(() => cloudFrontKeyValueEntries(planOf([row(long, "/x")]))).toThrow(
|
|
196
|
+
CloudFrontKvsLimitError,
|
|
197
|
+
);
|
|
198
|
+
const many = Array.from({ length: 30_000 }, (_, i) =>
|
|
199
|
+
row(`/${String(i).padStart(60, "0")}`, `/${"t".repeat(60)}`),
|
|
200
|
+
);
|
|
201
|
+
expect(() => cloudFrontKeyValueEntries(planOf(many))).toThrow(/a store holds 5242880/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("the function answers stored paths and passes the rest through", async () => {
|
|
205
|
+
const entries = new Map(cloudFrontKeyValueEntries(PLAN).map((e) => [e.key, e.value]));
|
|
206
|
+
const kvsIds: string[] = [];
|
|
207
|
+
const cf = {
|
|
208
|
+
kvs(id: string) {
|
|
209
|
+
kvsIds.push(id);
|
|
210
|
+
return {
|
|
211
|
+
async get(key: string, opts: { format: string }) {
|
|
212
|
+
expect(opts.format).toBe("json");
|
|
213
|
+
const v = entries.get(key);
|
|
214
|
+
if (v === undefined) throw new Error(`The key ${key} does not exist`);
|
|
215
|
+
return JSON.parse(v);
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
const code = cloudFrontRedirectFunctionCode("abc-123");
|
|
221
|
+
expect(code.startsWith("import cf from 'cloudfront';")).toBe(true);
|
|
222
|
+
const handler = new Function(
|
|
223
|
+
"cf",
|
|
224
|
+
`${code.replace("import cf from 'cloudfront';", "")}\nreturn handler;`,
|
|
225
|
+
)(cf) as (e: unknown) => Promise<Record<string, unknown>>;
|
|
226
|
+
expect(kvsIds).toEqual(["abc-123"]);
|
|
227
|
+
const req = (uri: string, query = "") => ({
|
|
228
|
+
request: { uri, rawQueryString: () => query },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(await handler(req("/old/", "utm=x&y=1"))).toEqual({
|
|
232
|
+
statusCode: 301,
|
|
233
|
+
statusDescription: "Moved Permanently",
|
|
234
|
+
headers: { location: { value: "/new/?utm=x&y=1" } },
|
|
235
|
+
});
|
|
236
|
+
expect(await handler(req("/q", "dropped=1"))).toEqual({
|
|
237
|
+
statusCode: 308,
|
|
238
|
+
statusDescription: "Permanent Redirect",
|
|
239
|
+
headers: { location: { value: "/t?a=1" } },
|
|
240
|
+
});
|
|
241
|
+
expect(await handler(req("/promo.html"))).toMatchObject({
|
|
242
|
+
statusCode: 302,
|
|
243
|
+
headers: { location: { value: "/sale/" } },
|
|
244
|
+
});
|
|
245
|
+
expect(await handler(req("/gone"))).toEqual({ statusCode: 410, statusDescription: "Gone" });
|
|
246
|
+
const passthrough = req("/live/");
|
|
247
|
+
expect(await handler(passthrough)).toBe(passthrough.request);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("refuses something that is not a store id in the generated code", () => {
|
|
251
|
+
expect(() => cloudFrontRedirectFunctionCode("x'); evil('")).toThrow(/not a KeyValueStore id/);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("Front Door rules", () => {
|
|
256
|
+
it("emits one UrlPath + UrlRedirect rule per redirect", () => {
|
|
257
|
+
const rules = frontDoorRedirectRules(PLAN);
|
|
258
|
+
expect(rules.map((r) => [r.ruleName, r.order])).toEqual([
|
|
259
|
+
["caeloRedirect1", 1],
|
|
260
|
+
["caeloRedirect2", 2],
|
|
261
|
+
["caeloRedirect3", 3],
|
|
262
|
+
]);
|
|
263
|
+
expect(rules[0]?.conditions[0].parameters.matchValues).toEqual(["old", "old/"]);
|
|
264
|
+
expect(rules[0]?.actions[0].parameters).toEqual({
|
|
265
|
+
typeName: "DeliveryRuleUrlRedirectActionParameters",
|
|
266
|
+
redirectType: "Moved",
|
|
267
|
+
destinationProtocol: "MatchRequest",
|
|
268
|
+
customPath: "/new/",
|
|
269
|
+
});
|
|
270
|
+
expect(rules[1]?.actions[0].parameters.redirectType).toBe("Found");
|
|
271
|
+
expect(rules[2]?.actions[0].parameters).toMatchObject({
|
|
272
|
+
redirectType: "PermanentRedirect",
|
|
273
|
+
customPath: "/t",
|
|
274
|
+
customQueryString: "a=1",
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("replaces the visitor's query for a fragment-only target", () => {
|
|
279
|
+
const [rule] = frontDoorRedirectRules(planOf([row("/a", "/b#top")]));
|
|
280
|
+
expect(rule?.actions[0].parameters).toMatchObject({
|
|
281
|
+
customPath: "/b",
|
|
282
|
+
customQueryString: "",
|
|
283
|
+
customFragment: "top",
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("refuses more redirects than a route holds", () => {
|
|
288
|
+
const rows = Array.from({ length: FRONT_DOOR_MAX_RULES + 1 }, (_, i) => row(`/p${i}`, "/x"));
|
|
289
|
+
expect(() => frontDoorRedirectRules(planOf(rows))).toThrow(FrontDoorRuleLimitError);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #587 — the redirect plan every provider serves: exact paths, the build
|
|
5
|
+
* wins, chains collapse, loops and unservable rows fail loudly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
buildRedirectPlan,
|
|
11
|
+
formatRedirectsFile,
|
|
12
|
+
parseRedirectsFile,
|
|
13
|
+
type RedirectTableRow,
|
|
14
|
+
} from "./redirect-plan.js";
|
|
15
|
+
|
|
16
|
+
const NO_FILES: ReadonlySet<string> = new Set();
|
|
17
|
+
const plan = (rows: RedirectTableRow[], servedFiles = NO_FILES, style = "directory" as const) =>
|
|
18
|
+
buildRedirectPlan({ rows, pageUrlStyle: style, servedFiles });
|
|
19
|
+
const row = (fromPath: string, toPath: string, statusCode = 301): RedirectTableRow => ({
|
|
20
|
+
fromPath,
|
|
21
|
+
toPath,
|
|
22
|
+
statusCode,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("parseRedirectsFile / formatRedirectsFile", () => {
|
|
26
|
+
it("round-trips the generator's file", () => {
|
|
27
|
+
const rows = [row("/a", "/b"), row("/gone", "/410", 410)];
|
|
28
|
+
expect(parseRedirectsFile(formatRedirectsFile(rows))).toEqual(rows);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("skips comments and blank lines, fails loudly on a malformed line", () => {
|
|
32
|
+
expect(parseRedirectsFile("# x\n\n/a /b 302\n")).toEqual([row("/a", "/b", 302)]);
|
|
33
|
+
expect(() => parseRedirectsFile("/a /b\n")).toThrow(/line 1 is not/);
|
|
34
|
+
expect(() => parseRedirectsFile("/a /b x\n")).toThrow(/line 1 is not/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("buildRedirectPlan", () => {
|
|
39
|
+
it("matches a page path with and without the slash, a file-like path and the root as written", () => {
|
|
40
|
+
const p = plan([row("/old", "/new"), row("/old.html", "/new.pdf"), row("/", "/de", 302)]);
|
|
41
|
+
expect(p.redirects.map((r) => r.matchPaths)).toEqual([["/old", "/old/"], ["/old.html"], ["/"]]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("points page targets at the directory URL in the directory style only", () => {
|
|
45
|
+
const rows = [row("/a", "/b"), row("/c", "/d.pdf"), row("/e", "/f?x=1"), row("/g", "/h/")];
|
|
46
|
+
expect(plan(rows).redirects.map((r) => r.location)).toEqual(["/b/", "/d.pdf", "/f?x=1", "/h/"]);
|
|
47
|
+
expect(
|
|
48
|
+
buildRedirectPlan({
|
|
49
|
+
rows,
|
|
50
|
+
pageUrlStyle: "no-extension",
|
|
51
|
+
servedFiles: NO_FILES,
|
|
52
|
+
}).redirects.map((r) => r.location),
|
|
53
|
+
).toEqual(["/b", "/d.pdf", "/f?x=1", "/h/"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps the visitor's query only when the target has no query or fragment", () => {
|
|
57
|
+
const p = plan([row("/a", "/b"), row("/c", "/d?x"), row("/e", "/f#top")]);
|
|
58
|
+
expect(p.redirects.map((r) => r.preserveQuery)).toEqual([true, false, false]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("normalises a stored trailing slash", () => {
|
|
62
|
+
expect(plan([row("/old/", "/new")]).redirects[0]?.fromPath).toBe("/old");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves out rows whose from path a build file serves (#575)", () => {
|
|
66
|
+
const p = plan(
|
|
67
|
+
[row("/preise", "/de/preise"), row("/de/preise", "/preise"), row("/", "/de", 302)],
|
|
68
|
+
new Set(["index.html", "preise/index.html"]),
|
|
69
|
+
);
|
|
70
|
+
expect(p.shadowedByBuild).toEqual(["/preise", "/"]);
|
|
71
|
+
expect(p.redirects.map((r) => [r.fromPath, r.location])).toEqual([["/de/preise", "/preise/"]]);
|
|
72
|
+
const noExt = buildRedirectPlan({
|
|
73
|
+
rows: [row("/preise", "/x")],
|
|
74
|
+
pageUrlStyle: "no-extension",
|
|
75
|
+
servedFiles: new Set(["preise"]),
|
|
76
|
+
});
|
|
77
|
+
expect(noExt.redirects).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("collapses chains and combines the statuses", () => {
|
|
81
|
+
const p = plan([
|
|
82
|
+
row("/a", "/b", 301),
|
|
83
|
+
row("/b", "/c", 308),
|
|
84
|
+
row("/c", "/d", 308),
|
|
85
|
+
row("/x", "/y", 302),
|
|
86
|
+
row("/y", "/z", 301),
|
|
87
|
+
row("/m", "/n", 307),
|
|
88
|
+
row("/n", "/o", 308),
|
|
89
|
+
]);
|
|
90
|
+
const byFrom = Object.fromEntries(p.redirects.map((r) => [r.fromPath, r]));
|
|
91
|
+
expect([byFrom["/a"]?.location, byFrom["/a"]?.statusCode]).toEqual(["/d/", 301]);
|
|
92
|
+
expect([byFrom["/b"]?.location, byFrom["/b"]?.statusCode]).toEqual(["/d/", 308]);
|
|
93
|
+
expect([byFrom["/x"]?.location, byFrom["/x"]?.statusCode]).toEqual(["/z/", 302]);
|
|
94
|
+
expect([byFrom["/m"]?.location, byFrom["/m"]?.statusCode]).toEqual(["/o/", 307]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("follows a chain through a target written with a trailing slash", () => {
|
|
98
|
+
expect(plan([row("/a", "/b/"), row("/b", "/c")]).redirects[0]?.location).toBe("/c/");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("stops a chain at a row the build shadows, at a gone row and at a target with a query", () => {
|
|
102
|
+
const p = plan(
|
|
103
|
+
[row("/a", "/b"), row("/b", "/c"), row("/g", "/gone"), row("/gone", "/410", 410)],
|
|
104
|
+
new Set(["b/index.html"]),
|
|
105
|
+
);
|
|
106
|
+
expect(p.redirects.map((r) => [r.fromPath, r.location])).toEqual([
|
|
107
|
+
["/a", "/b/"],
|
|
108
|
+
["/g", "/gone/"],
|
|
109
|
+
]);
|
|
110
|
+
expect(plan([row("/a", "/b?x=1"), row("/b", "/c")]).redirects[0]?.location).toBe("/b?x=1");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("turns 410 rows into gone entries", () => {
|
|
114
|
+
const p = plan([row("/gone", "/410", 410)]);
|
|
115
|
+
expect(p.redirects).toEqual([]);
|
|
116
|
+
expect(p.gone).toEqual([{ fromPath: "/gone", matchPaths: ["/gone", "/gone/"] }]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("fails loudly on a loop and names it", () => {
|
|
120
|
+
expect(() => plan([row("/a", "/b"), row("/b", "/c"), row("/c", "/a")])).toThrow(
|
|
121
|
+
/\/a → \/b → \/c → \/a form a loop/,
|
|
122
|
+
);
|
|
123
|
+
expect(() => plan([row("/a", "/a/")])).toThrow(/loop/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("fails loudly on rows no host can serve", () => {
|
|
127
|
+
expect(() => plan([row("/a?x=1", "/b")])).toThrow(/'\?' or '#'.*'\/a'/);
|
|
128
|
+
expect(() => plan([row("/a b", "/b")])).toThrow(/whitespace/);
|
|
129
|
+
expect(() => plan([row("/a", "/b", 200)])).toThrow(/301, 302, 307, 308 or 410/);
|
|
130
|
+
expect(() => plan([row("/a", "//evil.example")])).toThrow(/another host/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("dedupes identical spellings and refuses conflicting ones", () => {
|
|
134
|
+
expect(plan([row("/a", "/b"), row("/a/", "/b")]).redirects).toHaveLength(1);
|
|
135
|
+
expect(() => plan([row("/a", "/b"), row("/a/", "/c")])).toThrow(/two redirects answer "\/a"/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("keeps the rows' order (deterministic output)", () => {
|
|
139
|
+
const rows = [row("/z", "/1"), row("/a", "/2")];
|
|
140
|
+
expect(plan(rows).redirects.map((r) => r.fromPath)).toEqual(["/z", "/a"]);
|
|
141
|
+
expect(plan(rows)).toEqual(plan([...rows]));
|
|
142
|
+
});
|
|
143
|
+
});
|