@taprootio/site-authoring 0.11.1 → 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 +16 -4
- package/examples/riverbend-wellness/manifest.fixture.json +56 -0
- package/examples/riverbend-wellness/pages/green-smoothie.md +28 -0
- package/examples/riverbend-wellness/pages/journal-welcome.md +13 -0
- package/examples/riverbend-wellness/pages/juice-bar.md +11 -0
- package/examples/riverbend-wellness/pages/studio-tour.pm.json +34 -0
- package/package.json +1 -1
- package/src/api.js +3 -9
- package/src/cli.js +6 -2
- package/src/content/components.js +0 -6
- package/src/redirects-contract.js +101 -9
- package/src/reference-help.js +100 -21
- package/src/typed-page-reference.js +339 -0
- package/src/typed-pages.js +952 -0
- package/src/verbs/delivery-check.js +23 -3
- package/src/verbs/deploy.js +13 -19
- package/src/verbs/pages-push.js +200 -61
- package/src/verbs/pull.js +47 -20
- package/src/verbs/redirects-check.js +1 -1
- package/src/verbs/validate.js +24 -3
- package/src/workspace.js +42 -8
package/README.md
CHANGED
|
@@ -99,6 +99,7 @@ taproot-site help
|
|
|
99
99
|
taproot-site help designs
|
|
100
100
|
taproot-site help design professional-portfolio
|
|
101
101
|
taproot-site help page free-form
|
|
102
|
+
taproot-site help page recipe
|
|
102
103
|
taproot-site help component hero-section --json
|
|
103
104
|
```
|
|
104
105
|
|
|
@@ -141,8 +142,8 @@ taproot-site validate "$(npm root --global)/@taprootio/site-authoring/examples/r
|
|
|
141
142
|
|
|
142
143
|
The fixture is a fictional wellness studio — `example.test` hostnames, a
|
|
143
144
|
reserved `555-01xx` telephone number, an invented street and town — with two
|
|
144
|
-
free-form pages
|
|
145
|
-
documents. Nothing in it describes a real business. Copy the directory
|
|
145
|
+
free-form pages and one each of the article, recipe, album and place-review
|
|
146
|
+
templates, a three-item navigation tree, and all four settings documents. Nothing in it describes a real business. Copy the directory
|
|
146
147
|
somewhere writable before you edit it; `validate` never writes to the fixture
|
|
147
148
|
it reads.
|
|
148
149
|
|
|
@@ -338,6 +339,17 @@ the site's own document is kept as internal state under
|
|
|
338
339
|
edited on the site since the last pull is a `pages.pull_conflict` refusal
|
|
339
340
|
before anything under `pages/` changes, with both recovery paths named.
|
|
340
341
|
|
|
342
|
+
Pages are not only free-form. A source chooses its template with `template:` in
|
|
343
|
+
its front matter — `article`, `recipe`, `album` or `place-review`; free-form is
|
|
344
|
+
the default — and `pull` writes the other four as a `.pm.json` wrapper
|
|
345
|
+
(`{ "template", "displayDate", "coverImageId", "data" }`) that `pages push`
|
|
346
|
+
sends back unchanged. Typed pages also carry `displayDate` (so an import keeps
|
|
347
|
+
its original publication date) and `coverImage`, which the site requires to be an
|
|
348
|
+
image the page itself uses (place it in the body too; `pages.cover_image_unused`). A page's template is fixed when
|
|
349
|
+
it is created: a source for another template is refused with
|
|
350
|
+
`pages.template_immutable`. `taproot-site help page <type>` gives each
|
|
351
|
+
template's fields and one worked example.
|
|
352
|
+
|
|
341
353
|
`pages push [page-path...]` validates and sends the selected pages, or every
|
|
342
354
|
workspace page when none is named; the homepage is addressed as `/`. It fails
|
|
343
355
|
closed with `pages.push_conflict` when a page's stored-state revision has
|
|
@@ -489,7 +501,7 @@ fingerprint supply no gating evidence. Typed preview failure diagnostics contain
|
|
|
489
501
|
only error class and known page/node/attribute labels, never renderer messages.
|
|
490
502
|
|
|
491
503
|
Staging deploys check the current redirect map through the real staging edge.
|
|
492
|
-
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.
|
|
493
505
|
The Node CLI consumes a separate handoff, retains its short staging cookie in
|
|
494
506
|
memory, and requests each map path without following redirects. Results report
|
|
495
507
|
path, HTTP status and Location; `verified` requires all entries to match and the
|
|
@@ -549,7 +561,7 @@ taproot-site validate ../authoring-fixture
|
|
|
549
561
|
The destination must be new, outside the source workspace, and its parent must exist. Use
|
|
550
562
|
`taproot-site --config path/to/taproot-site.json validate --init ../authoring-fixture`
|
|
551
563
|
to select another source configuration. Initialization needs no credential or
|
|
552
|
-
network. It exports editable
|
|
564
|
+
network. It exports editable pages of every template as workspace documents with navigation,
|
|
553
565
|
redirects, settings, and version-7 appearance/footer metadata, validates the
|
|
554
566
|
result, and leaves the source unchanged. Excluded metadata-only pages are
|
|
555
567
|
counted; unresolved references to them fail validation.
|
|
@@ -30,6 +30,62 @@
|
|
|
30
30
|
"file": "pages/visit.md",
|
|
31
31
|
"sourceFormat": "markdown",
|
|
32
32
|
"workspaceMode": "editable"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"pageId": "a0000000-0000-4000-8000-000000000040",
|
|
36
|
+
"resourceId": "a0000000-0000-4000-8000-000000000041",
|
|
37
|
+
"path": "journal/welcome",
|
|
38
|
+
"title": "Welcome to the Riverbend journal",
|
|
39
|
+
"status": "PAGE_STATUS_DRAFT",
|
|
40
|
+
"templateType": "TEMPLATE_TYPE_ARTICLE",
|
|
41
|
+
"hasDraft": true,
|
|
42
|
+
"isGenerated": false,
|
|
43
|
+
"description": "An example article for the offline authoring fixture.",
|
|
44
|
+
"file": "pages/journal-welcome.md",
|
|
45
|
+
"sourceFormat": "markdown",
|
|
46
|
+
"workspaceMode": "editable"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"pageId": "a0000000-0000-4000-8000-000000000042",
|
|
50
|
+
"resourceId": "a0000000-0000-4000-8000-000000000043",
|
|
51
|
+
"path": "recipes/green-smoothie",
|
|
52
|
+
"title": "Green morning smoothie",
|
|
53
|
+
"status": "PAGE_STATUS_DRAFT",
|
|
54
|
+
"templateType": "TEMPLATE_TYPE_RECIPE",
|
|
55
|
+
"hasDraft": true,
|
|
56
|
+
"isGenerated": false,
|
|
57
|
+
"description": "An example recipe for the offline authoring fixture.",
|
|
58
|
+
"file": "pages/green-smoothie.md",
|
|
59
|
+
"sourceFormat": "markdown",
|
|
60
|
+
"workspaceMode": "editable"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"pageId": "a0000000-0000-4000-8000-000000000044",
|
|
64
|
+
"resourceId": "a0000000-0000-4000-8000-000000000045",
|
|
65
|
+
"path": "albums/studio-tour",
|
|
66
|
+
"title": "A tour of the studio",
|
|
67
|
+
"status": "PAGE_STATUS_DRAFT",
|
|
68
|
+
"templateType": "TEMPLATE_TYPE_ALBUM",
|
|
69
|
+
"hasDraft": true,
|
|
70
|
+
"isGenerated": false,
|
|
71
|
+
"description": "An example album for the offline authoring fixture.",
|
|
72
|
+
"file": "pages/studio-tour.pm.json",
|
|
73
|
+
"sourceFormat": "prosemirror",
|
|
74
|
+
"workspaceMode": "editable"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"pageId": "a0000000-0000-4000-8000-000000000046",
|
|
78
|
+
"resourceId": "a0000000-0000-4000-8000-000000000047",
|
|
79
|
+
"path": "reviews/juice-bar",
|
|
80
|
+
"title": "The corner juice bar",
|
|
81
|
+
"status": "PAGE_STATUS_DRAFT",
|
|
82
|
+
"templateType": "TEMPLATE_TYPE_PLACE_REVIEW",
|
|
83
|
+
"hasDraft": true,
|
|
84
|
+
"isGenerated": false,
|
|
85
|
+
"description": "An example place review for the offline authoring fixture.",
|
|
86
|
+
"file": "pages/juice-bar.md",
|
|
87
|
+
"sourceFormat": "markdown",
|
|
88
|
+
"workspaceMode": "editable"
|
|
33
89
|
}
|
|
34
90
|
],
|
|
35
91
|
"pagesTruncated": false,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Green morning smoothie
|
|
3
|
+
path: recipes/green-smoothie
|
|
4
|
+
description: An example recipe for the offline authoring fixture.
|
|
5
|
+
template: recipe
|
|
6
|
+
prepTimeMinutes: 5
|
|
7
|
+
servings: 2
|
|
8
|
+
recipeCategory: Drink
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
A fictional studio favorite that takes five minutes.
|
|
12
|
+
|
|
13
|
+
## Ingredients
|
|
14
|
+
|
|
15
|
+
### Base
|
|
16
|
+
|
|
17
|
+
- 1 cup spinach
|
|
18
|
+
- 1 banana
|
|
19
|
+
- 1 cup oat milk
|
|
20
|
+
|
|
21
|
+
### Finish
|
|
22
|
+
|
|
23
|
+
- 1 tablespoon chia seeds
|
|
24
|
+
|
|
25
|
+
## Instructions
|
|
26
|
+
|
|
27
|
+
1. Blend the base until smooth.
|
|
28
|
+
2. Stir in the chia seeds and rest for two minutes.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Welcome to the Riverbend journal
|
|
3
|
+
path: journal/welcome
|
|
4
|
+
description: An example article for the offline authoring fixture.
|
|
5
|
+
template: article
|
|
6
|
+
displayDate: 2024-03-01
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
The journal is where the fictional Riverbend Wellness studio shares notes from
|
|
10
|
+
the studio floor. This page is an **article**: a title, a body, and an
|
|
11
|
+
original publication date kept with `displayDate`.
|
|
12
|
+
|
|
13
|
+
A page's template is chosen once, when it is created, and never changes.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The corner juice bar
|
|
3
|
+
path: reviews/juice-bar
|
|
4
|
+
description: An example place review for the offline authoring fixture.
|
|
5
|
+
template: place-review
|
|
6
|
+
placeId: a0000000-0000-4000-8000-0000000000b0
|
|
7
|
+
rating: will-return
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
An invented juice bar in the fictional town of Elm Harbor. Nothing here
|
|
11
|
+
describes a real business.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"template": "album",
|
|
3
|
+
"coverImageId": "a0000000-0000-4000-8000-0000000000a1",
|
|
4
|
+
"data": {
|
|
5
|
+
"introductionBody": {
|
|
6
|
+
"type": "doc",
|
|
7
|
+
"content": [
|
|
8
|
+
{
|
|
9
|
+
"type": "paragraph",
|
|
10
|
+
"content": [
|
|
11
|
+
{
|
|
12
|
+
"type": "text",
|
|
13
|
+
"text": "A fictional tour of the studio. An album keeps its images by id, so its source is a JSON document."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"images": [
|
|
20
|
+
{
|
|
21
|
+
"imageId": "a0000000-0000-4000-8000-0000000000a1",
|
|
22
|
+
"caption": "The main room",
|
|
23
|
+
"width": 1920,
|
|
24
|
+
"height": 1080
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"imageId": "a0000000-0000-4000-8000-0000000000a2",
|
|
28
|
+
"caption": "The tea corner",
|
|
29
|
+
"width": 1200,
|
|
30
|
+
"height": 1600
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
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/api.js
CHANGED
|
@@ -490,6 +490,9 @@ export function normalizePageSummary(value) {
|
|
|
490
490
|
// at" — and both are carried as `undefined` rather than as a sentinel that
|
|
491
491
|
// would compare unequal to every recorded baseline and refuse every push.
|
|
492
492
|
bodyRevision: normalizePageBodyRevision(summary.bodyRevision),
|
|
493
|
+
// The page's calendar date, empty when it has none. It is not part of
|
|
494
|
+
// `bodyRevision`, so a typed page's push guard compares it separately.
|
|
495
|
+
displayDate: typeof summary.displayDate === "string" ? summary.displayDate : "",
|
|
493
496
|
};
|
|
494
497
|
}
|
|
495
498
|
|
|
@@ -526,14 +529,6 @@ export async function getPage(client, pageId, status) {
|
|
|
526
529
|
);
|
|
527
530
|
}
|
|
528
531
|
|
|
529
|
-
export function freeFormTemplate(body) {
|
|
530
|
-
return {
|
|
531
|
-
templateType: TEMPLATE_TYPE_FREE_FORM,
|
|
532
|
-
templateVersion: FREE_FORM_TEMPLATE_VERSION,
|
|
533
|
-
freeFormData: { body },
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
|
|
537
532
|
export async function createPage(client, body) {
|
|
538
533
|
return normalizePageSummary(await client.request("v1/pages", { method: "POST", body }));
|
|
539
534
|
}
|
|
@@ -1118,7 +1113,6 @@ export async function listDeployments(
|
|
|
1118
1113
|
`deployments${
|
|
1119
1114
|
query([
|
|
1120
1115
|
["pageSize", pageSize],
|
|
1121
|
-
["includeStaging", true],
|
|
1122
1116
|
["environment", environment],
|
|
1123
1117
|
])
|
|
1124
1118
|
}`,
|
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
|
{
|
|
@@ -705,6 +707,7 @@ function parseReferenceArguments(arguments_) {
|
|
|
705
707
|
|| topic === "redirects"
|
|
706
708
|
|| topic === "walkthrough"
|
|
707
709
|
|| topic === "delivery"
|
|
710
|
+
|| topic === "import"
|
|
708
711
|
)
|
|
709
712
|
&& (subject !== undefined || extra.length > 0)
|
|
710
713
|
) {
|
|
@@ -774,6 +777,7 @@ function referenceResult(parsed) {
|
|
|
774
777
|
case "fixture":
|
|
775
778
|
case "walkthrough":
|
|
776
779
|
case "delivery":
|
|
780
|
+
case "import":
|
|
777
781
|
return {
|
|
778
782
|
...result,
|
|
779
783
|
topic: "workflow",
|
|
@@ -157,10 +157,6 @@ const CTA = Object.freeze({
|
|
|
157
157
|
}),
|
|
158
158
|
imagePosition: enumOf("top", "left", "right"),
|
|
159
159
|
mediaImage: image(),
|
|
160
|
-
// TR00890: "waitlist" replaces the link button with an email-capture form
|
|
161
|
-
// that posts to the platform's public waitlist endpoint. buttonUrl is
|
|
162
|
-
// ignored in that mode; buttonText becomes the submit button's label.
|
|
163
|
-
formMode: enumOf("none", "waitlist"),
|
|
164
160
|
});
|
|
165
161
|
|
|
166
162
|
/** `FeatureGridData`. */
|
|
@@ -363,12 +359,10 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
363
359
|
borderWidth: 0,
|
|
364
360
|
imagePosition: "top",
|
|
365
361
|
mediaImage: null,
|
|
366
|
-
formMode: "none",
|
|
367
362
|
},
|
|
368
363
|
[
|
|
369
364
|
"Button text must describe its action or destination.",
|
|
370
365
|
"Give meaningful media a descriptive alt value and decorative media an empty alt value.",
|
|
371
|
-
"formMode \"waitlist\" renders an email-capture form instead of the link button; leave buttonUrl empty and use buttonText for the submit label.",
|
|
372
366
|
],
|
|
373
367
|
{
|
|
374
368
|
heading: "Ready to begin?",
|
|
@@ -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(
|