@taprootio/site-authoring 0.19.0 → 0.21.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 +132 -28
- package/examples/riverbend-wellness/nav.json +3 -6
- package/examples/riverbend-wellness/pages/visit.md +1 -1
- package/package.json +2 -2
- package/src/api.js +118 -31
- package/src/changelog.js +48 -0
- package/src/changelog.json +71 -0
- package/src/cli-release.js +9 -1
- package/src/cli.js +128 -15
- package/src/constants.js +19 -0
- package/src/content/components.js +8 -5
- package/src/errors.js +3 -0
- package/src/footer-contract.js +5 -3
- package/src/form-references.js +103 -0
- package/src/forms-workspace.js +24 -0
- package/src/index.js +1 -0
- package/src/output.js +9 -0
- package/src/presentation-reference.js +41 -6
- package/src/problems.js +6 -1
- package/src/redirects-contract.js +50 -140
- package/src/reference-help.js +83 -36
- package/src/reserved-paths.js +4 -10
- package/src/setting-reference.js +438 -0
- package/src/site-check-findings.js +99 -0
- package/src/site-check-rules.json +22 -0
- package/src/site-checks.js +337 -0
- package/src/staging-check.js +48 -9
- package/src/staging-served.js +320 -0
- package/src/transport.js +13 -0
- package/src/typed-page-reference.js +128 -4
- package/src/typed-pages.js +1 -1
- package/src/verbs/changelog.js +62 -0
- package/src/verbs/deploy.js +38 -10
- package/src/verbs/forms-push.js +22 -1
- package/src/verbs/index.js +3 -0
- package/src/verbs/nav-push.js +49 -1
- package/src/verbs/pages-push.js +45 -1
- package/src/verbs/plan.js +4 -2
- package/src/verbs/pull.js +17 -3
- package/src/verbs/redirects-check.js +4 -1
- package/src/verbs/staging-review.js +1 -1
- package/src/verbs/status.js +35 -28
- package/src/verbs/validate.js +21 -0
|
@@ -44,6 +44,12 @@ import {
|
|
|
44
44
|
REQUIRED_THEME_PROPERTIES,
|
|
45
45
|
validateAndLintThemePair,
|
|
46
46
|
} from "./theme-validation.js";
|
|
47
|
+
import {
|
|
48
|
+
describeSettingField,
|
|
49
|
+
formatSettingsReference,
|
|
50
|
+
PAGE_BACKGROUND_IMAGE_RENDERING,
|
|
51
|
+
PAGE_BACKGROUND_VERSUS_CANVAS,
|
|
52
|
+
} from "./setting-reference.js";
|
|
47
53
|
import {
|
|
48
54
|
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
49
55
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
@@ -273,14 +279,23 @@ const THEME_GROUPS = Object.freeze([
|
|
|
273
279
|
}),
|
|
274
280
|
Object.freeze({
|
|
275
281
|
name: "surface-assets",
|
|
276
|
-
summary:
|
|
282
|
+
summary:
|
|
283
|
+
"Preserve pulled asset fields; use appearance image ids for the logos, favicon and canvas image. The page "
|
|
284
|
+
+ "background image is a theme field behind the page content, unlike the canvas image, which paints only the "
|
|
285
|
+
+ "side margins (help settings has both).",
|
|
277
286
|
fields: Object.freeze([
|
|
278
287
|
themeField("stylesheets", "Agent-authored site themes must keep this exact array empty.", { exact: [] }),
|
|
279
288
|
themeField(
|
|
280
289
|
"pageBackgroundImage",
|
|
281
|
-
"CSS background-image
|
|
290
|
+
"CSS background-image (the app's Page style > Background image). " + PAGE_BACKGROUND_IMAGE_RENDERING
|
|
291
|
+
+ " Today it is a CSS url(...) of a delivered image, the src that .taproot-site-media.json records; it tiles "
|
|
292
|
+
+ "at the image's natural pixel size from the top-left. The complete Taproot workspace includes an empty value.",
|
|
293
|
+
),
|
|
294
|
+
themeField(
|
|
295
|
+
"pageBackgroundImageOpacity",
|
|
296
|
+
"How strongly the page background image shows, from 0 (invisible) to 1 (fully opaque).",
|
|
297
|
+
{ minimum: 0, maximum: 1 },
|
|
282
298
|
),
|
|
283
|
-
themeField("pageBackgroundImageOpacity", "Page background opacity.", { minimum: 0, maximum: 1 }),
|
|
284
299
|
themeField("boxBackgroundImage", "Optional inherited box-surface background image."),
|
|
285
300
|
themeField("boxBackgroundImageOpacity", "Optional box background opacity.", { minimum: 0, maximum: 1 }),
|
|
286
301
|
themeField("vellumOpacity", "Optional modal-vellum opacity.", { minimum: 0, maximum: 1 }),
|
|
@@ -418,6 +433,9 @@ export function getThemeReference() {
|
|
|
418
433
|
};
|
|
419
434
|
}
|
|
420
435
|
|
|
436
|
+
// "linkHoverColor" reads "link hover color", so a new footer color token needs no second table.
|
|
437
|
+
const footerColorLabel = (name) => name.replace(/([A-Z])/gu, " $1").toLowerCase();
|
|
438
|
+
|
|
421
439
|
function appearanceColorReference(definition) {
|
|
422
440
|
return definition.type === "appearance-color"
|
|
423
441
|
? {
|
|
@@ -432,6 +450,7 @@ export function getAppearanceReference() {
|
|
|
432
450
|
const scalarFields = APPEARANCE_SCALAR_FIELDS.map((field) => ({
|
|
433
451
|
file: APPEARANCE_FILES[field.settingsType],
|
|
434
452
|
path: "settings." + field.name,
|
|
453
|
+
...describeSettingField(field.settingsType, field.name),
|
|
435
454
|
type: field.type,
|
|
436
455
|
...(field.values ? { values: field.values } : {}),
|
|
437
456
|
...(field.minimum !== undefined ? { minimum: field.minimum, maximum: field.maximum } : {}),
|
|
@@ -447,27 +466,36 @@ export function getAppearanceReference() {
|
|
|
447
466
|
{
|
|
448
467
|
file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
|
|
449
468
|
path: "settings.lightTheme",
|
|
469
|
+
...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "lightTheme"),
|
|
450
470
|
type: "complete-espalier-theme",
|
|
451
471
|
},
|
|
452
472
|
{
|
|
453
473
|
file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
|
|
454
474
|
path: "settings.darkTheme",
|
|
475
|
+
...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "darkTheme"),
|
|
455
476
|
type: "complete-espalier-theme",
|
|
456
477
|
},
|
|
457
478
|
...scalarFields,
|
|
458
479
|
...APPEARANCE_FOOTER_COLOR_FIELDS.map((field) => ({
|
|
459
480
|
file: field.file,
|
|
460
481
|
path: field.path,
|
|
482
|
+
appLabel: "Footer",
|
|
483
|
+
description: `The footer's ${footerColorLabel(field.name)} in the ${field.scheme} scheme. footer push owns the rest of the footer.`,
|
|
484
|
+
renders: "The footer, once footerSettings.enabled is true (default false).",
|
|
461
485
|
type: field.type,
|
|
462
486
|
...appearanceColorReference(field),
|
|
463
487
|
default: field.default,
|
|
464
488
|
})),
|
|
465
489
|
],
|
|
466
490
|
readOnlyProjections: APPEARANCE_READ_ONLY_FIELDS,
|
|
491
|
+
pageBackgroundVersusCanvas: PAGE_BACKGROUND_VERSUS_CANVAS,
|
|
492
|
+
fieldMap: CLI_BINARY_NAME + " help settings maps every pulled field, including the read-only ones, to its app control.",
|
|
467
493
|
imageReferences:
|
|
468
494
|
"Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
|
|
469
495
|
logoContract:
|
|
470
496
|
"lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting. "
|
|
497
|
+
+ "Each takes one image id: upload the highest-resolution file you have and assign it. An @2x or @3x suffix is only "
|
|
498
|
+
+ "part of a filename, nothing pairs it with another file, and the platform generates the sizes. "
|
|
471
499
|
+ "Prefer transparent PNG or WebP artwork with genuine alpha so the header background shows through, including "
|
|
472
500
|
+ "inside letter counters. Avoid baked-in background rectangles unless the brand intentionally uses a badge. "
|
|
473
501
|
+ "Keep padding tight and verify the delivered image preserves transparency. Logo style and coloration are "
|
|
@@ -776,11 +804,17 @@ export function formatPresentationReference(reference) {
|
|
|
776
804
|
}
|
|
777
805
|
if (reference.referenceKind === "appearance") {
|
|
778
806
|
return reference.title + "\nUsage: " + reference.usage + "\n\nWritable fields:\n"
|
|
779
|
-
+ reference.fields.map((field) =>
|
|
780
|
-
"
|
|
781
|
-
|
|
807
|
+
+ reference.fields.map((field) =>
|
|
808
|
+
" " + field.file + " :: " + field.path + " — " + fieldConstraint(field)
|
|
809
|
+
+ "\n app: " + field.appLabel + ". " + field.description
|
|
810
|
+
+ (field.renders === undefined ? "" : "\n renders: " + field.renders)
|
|
811
|
+
).join("\n")
|
|
782
812
|
+ "\n\nRead-only server projections:\n"
|
|
783
813
|
+ reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
|
|
814
|
+
+ "\n\nPage background image versus canvas image:\n - " + reference.pageBackgroundVersusCanvas.pageBackground
|
|
815
|
+
+ "\n - " + reference.pageBackgroundVersusCanvas.canvas
|
|
816
|
+
+ "\n - " + reference.pageBackgroundVersusCanvas.shared
|
|
817
|
+
+ "\n\nField map: " + reference.fieldMap
|
|
784
818
|
+ "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
|
|
785
819
|
+ "\nLogo contrast: " + reference.logoContrastContract
|
|
786
820
|
+ "\nFavicon: " + reference.faviconContract
|
|
@@ -794,6 +828,7 @@ export function formatPresentationReference(reference) {
|
|
|
794
828
|
+ "\nRetry: " + reference.retry
|
|
795
829
|
+ "\nRecovery: " + reference.recovery + "\n";
|
|
796
830
|
}
|
|
831
|
+
if (reference.referenceKind === "settings") return formatSettingsReference(reference);
|
|
797
832
|
if (reference.referenceKind === "footer") {
|
|
798
833
|
const messages = reference.messages.map((message) =>
|
|
799
834
|
message.name + " (unlisted fields rejected)\n"
|
package/src/problems.js
CHANGED
|
@@ -39,7 +39,12 @@ function withContext(problem, context, error) {
|
|
|
39
39
|
export function problemsOf(error, context = {}) {
|
|
40
40
|
const own = Array.isArray(error.problems) && error.problems.length > 0
|
|
41
41
|
? error.problems
|
|
42
|
-
: [{
|
|
42
|
+
: [{
|
|
43
|
+
code: error.code,
|
|
44
|
+
...(error.field === undefined ? {} : { field: error.field }),
|
|
45
|
+
...(error.rule === undefined ? {} : { rule: error.rule }),
|
|
46
|
+
message: error.message,
|
|
47
|
+
}];
|
|
43
48
|
return own.map((problem) => withContext(problem, context, error));
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { SiteAuthoringError } from "./errors.js";
|
|
2
|
-
import { RUNTIME_MIRROR_PATH_REASON
|
|
2
|
+
import { RUNTIME_MIRROR_PATH_REASON } from "./reserved-paths.js";
|
|
3
|
+
import {
|
|
4
|
+
isRequestSpelling,
|
|
5
|
+
normalizeRedirectPath,
|
|
6
|
+
redirectSourceRule,
|
|
7
|
+
resolvesAway,
|
|
8
|
+
RULE_REDIRECT_SOURCE_GENERATED_FILE,
|
|
9
|
+
RULE_REDIRECT_SOURCE_INDEX_HTML,
|
|
10
|
+
RULE_REDIRECT_SOURCE_INTEGRATION_DATA,
|
|
11
|
+
RULE_REDIRECT_SOURCE_INVALID,
|
|
12
|
+
RULE_REDIRECT_SOURCE_NOT_STORABLE,
|
|
13
|
+
RULE_REDIRECT_SOURCE_RUNTIME_MIRROR,
|
|
14
|
+
RULE_REDIRECT_SOURCE_SHARE_LINK,
|
|
15
|
+
} from "./site-checks.js";
|
|
16
|
+
|
|
17
|
+
export { normalizeRedirectPath };
|
|
3
18
|
|
|
4
19
|
/**
|
|
5
20
|
* The redirect map's workspace contract (TR00702).
|
|
@@ -64,64 +79,14 @@ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
|
64
79
|
const ENTRY_KEYS = new Set(["path", "kind", "target", "status", "origin"]);
|
|
65
80
|
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
66
81
|
|
|
67
|
-
// An empty interior segment, or a dot or slash hidden behind percent-encoding.
|
|
68
|
-
// '//' is also where a protocol-relative URL hides, which is why the leading
|
|
69
|
-
// case is covered by this rule rather than by a separate prefix check.
|
|
70
|
-
const RESOLVABLE_SPELLING = /\/\/|%2e|%2f/iu;
|
|
71
|
-
|
|
72
|
-
// A spelling no browser would send as a pathname: anything outside printable
|
|
73
|
-
// ASCII, one of the characters a URL parser escapes on sight, or a '%' that is
|
|
74
|
-
// not an escape.
|
|
75
|
-
// No `i` flag: under Unicode case folding it would let U+212A (KELVIN SIGN)
|
|
76
|
-
// and U+017F (LONG S) through `[^!-~]`, which the site refuses.
|
|
77
|
-
const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Whether a path is already written the way a browser sends a pathname.
|
|
81
|
-
*
|
|
82
|
-
* The published-site edge keys on `url.pathname`, which the URL parser has
|
|
83
|
-
* already percent-encoded, and its `normalizePathname` decodes nothing — while
|
|
84
|
-
* every rule here compares the string as written. So '/old%20page -> /old page'
|
|
85
|
-
* is two different strings to loop detection and one path to the edge, which
|
|
86
|
-
* matches '/old%20page', resolves the target with `new URL` back to
|
|
87
|
-
* '/old%20page', and serves a permanent self-loop. A source written with a
|
|
88
|
-
* literal space or a non-ASCII character is the same mismatch the other way,
|
|
89
|
-
* stored under a key no request pathname ever matches.
|
|
90
|
-
*
|
|
91
|
-
* Refused rather than encoded here. URL escaping is not reproducibly canonical
|
|
92
|
-
* between the API's .NET and the edge's WHATWG parser, so a spelling this pass
|
|
93
|
-
* encoded would still not be the one the Worker computes and the layers would
|
|
94
|
-
* go on comparing different strings. Write what a browser sends and every layer
|
|
95
|
-
* compares it unchanged — and the byte bounds then measure that same spelling.
|
|
96
|
-
*/
|
|
97
|
-
function isRequestSpelling(value) {
|
|
98
|
-
return !UNSENDABLE_SPELLING.test(value);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Whether a path names one thing to this pass and another wherever it is
|
|
103
|
-
* resolved.
|
|
104
|
-
*
|
|
105
|
-
* Every rule here compares the string as written, and nothing downstream does:
|
|
106
|
-
* the generator resolves a source with path.resolve, so '/x/../visit' writes
|
|
107
|
-
* over the page rendered at '/visit', and the published-site edge resolves a
|
|
108
|
-
* target with `new URL`, so '/a -> /x/../a' is the self-loop that loop
|
|
109
|
-
* detection just passed. Percent-encoded dots and slashes are the same trick
|
|
110
|
-
* spelled so a segment scan cannot see it, so they are refused wherever they
|
|
111
|
-
* appear rather than decoded and re-examined.
|
|
112
|
-
*/
|
|
113
|
-
function resolvesAway(value) {
|
|
114
|
-
if (RESOLVABLE_SPELLING.test(value)) return true;
|
|
115
|
-
return value.split("/").some((segment) => segment === "." || segment === "..");
|
|
116
|
-
}
|
|
117
82
|
|
|
118
83
|
/** Whether the value is a redirect-map revision as the server mints one. */
|
|
119
84
|
export function isRedirectMapRevision(value) {
|
|
120
85
|
return typeof value === "string" && SHA256.test(value);
|
|
121
86
|
}
|
|
122
87
|
|
|
123
|
-
function entryError(code, message, field) {
|
|
124
|
-
return new SiteAuthoringError(code, `${field}: ${message}`, { field });
|
|
88
|
+
function entryError(code, message, field, rule) {
|
|
89
|
+
return new SiteAuthoringError(code, `${field}: ${message}`, { field, rule });
|
|
125
90
|
}
|
|
126
91
|
|
|
127
92
|
function isPlainObject(value) {
|
|
@@ -133,93 +98,32 @@ function utf8Length(value) {
|
|
|
133
98
|
}
|
|
134
99
|
|
|
135
100
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* some other layer would resolve away is refused rather than repaired (see
|
|
141
|
-
* `resolvesAway`), and so is one no browser would send as a pathname (see
|
|
142
|
-
* `isRequestSpelling`, which subsumes the control-character refusal).
|
|
101
|
+
* Why a source rule refuses a path, in the words the CLI shows. The rule ids are
|
|
102
|
+
* the site checks' (shared/site-check-rules.json); the decisions themselves are
|
|
103
|
+
* made by `redirectSourceRule` in the shared source, which the API's C#
|
|
104
|
+
* implementation also passes the same corpus for.
|
|
143
105
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// The root segment the published output reserves for integration data; the release
|
|
161
|
-
// inventory leaves everything under it out.
|
|
162
|
-
const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
|
|
163
|
-
|
|
164
|
-
// Root-level files the site generates. A redirect is a marker file inside the
|
|
165
|
-
// published site (TR00968), so a source that names one has nowhere to live.
|
|
166
|
-
const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
|
|
167
|
-
// The longest decoded path segment a marker file name may have; the API rule and the
|
|
168
|
-
// generator use the same 200 bytes.
|
|
169
|
-
const MAX_MARKER_SEGMENT_BYTES = 200;
|
|
170
|
-
const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
|
|
171
|
-
|
|
172
|
-
function isWindowsDeviceSegment(segment) {
|
|
173
|
-
const stem = segment.split(".", 1)[0].toLowerCase();
|
|
174
|
-
return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
|
|
175
|
-
}
|
|
106
|
+
const SOURCE_RULE_REASONS = Object.freeze({
|
|
107
|
+
[RULE_REDIRECT_SOURCE_INDEX_HTML]: () =>
|
|
108
|
+
"the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
|
|
109
|
+
+ "a redirect source, and the page or home file there is generated.",
|
|
110
|
+
[RULE_REDIRECT_SOURCE_INTEGRATION_DATA]: (first) =>
|
|
111
|
+
`'/${first}' is reserved for integration data, so nothing under it can be a redirect source.`,
|
|
112
|
+
[RULE_REDIRECT_SOURCE_SHARE_LINK]: () =>
|
|
113
|
+
"'/s/' is reserved for share links, so nothing under it can be a redirect source.",
|
|
114
|
+
[RULE_REDIRECT_SOURCE_RUNTIME_MIRROR]: () => RUNTIME_MIRROR_PATH_REASON,
|
|
115
|
+
[RULE_REDIRECT_SOURCE_GENERATED_FILE]: (first) =>
|
|
116
|
+
`'/${first}' is a file the site generates, so neither it nor anything under it can be a redirect source.`,
|
|
117
|
+
[RULE_REDIRECT_SOURCE_NOT_STORABLE]: () =>
|
|
118
|
+
"a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
|
|
119
|
+
+ "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).",
|
|
120
|
+
});
|
|
176
121
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
* named above, and the marker is stored under the decoded spelling, so a path
|
|
183
|
-
* that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
|
|
184
|
-
* segment, a reserved device name) is refused. The site root is left to the
|
|
185
|
-
* occupancy rule.
|
|
186
|
-
*/
|
|
187
|
-
export function redirectSourceRefusalReason(path) {
|
|
188
|
-
if (path === "/") return undefined;
|
|
189
|
-
// Every rule reads the decoded spelling: it is the name the marker is stored
|
|
190
|
-
// under, and the name the generator's own files use (`/%69ndex.html` is
|
|
191
|
-
// `/index.html`).
|
|
192
|
-
let decoded;
|
|
193
|
-
try {
|
|
194
|
-
decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
|
|
195
|
-
} catch {
|
|
196
|
-
return "it holds a percent escape that does not decode.";
|
|
197
|
-
}
|
|
198
|
-
const segments = decoded.split("/");
|
|
199
|
-
const first = segments[0].toLowerCase();
|
|
200
|
-
if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
|
|
201
|
-
return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
|
|
202
|
-
+ "a redirect source, and the page or home file there is generated.";
|
|
203
|
-
}
|
|
204
|
-
if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
|
|
205
|
-
return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
|
|
206
|
-
}
|
|
207
|
-
if (isRuntimeMirrorPath(decoded)) return RUNTIME_MIRROR_PATH_REASON;
|
|
208
|
-
if (GENERATED_ROOT_FILES.includes(first)) {
|
|
209
|
-
return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
|
|
210
|
-
}
|
|
211
|
-
if (
|
|
212
|
-
decoded.includes("..")
|
|
213
|
-
|| decoded !== decoded.normalize("NFC")
|
|
214
|
-
|| segments.some((segment) =>
|
|
215
|
-
segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
|
|
216
|
-
|| isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
|
|
217
|
-
)
|
|
218
|
-
) {
|
|
219
|
-
return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
|
|
220
|
-
+ "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
|
|
221
|
-
}
|
|
222
|
-
return undefined;
|
|
122
|
+
/** The refusal for a normalized source: its site-check rule id and the reason, or undefined when it can be a redirect. */
|
|
123
|
+
export function redirectSourceRefusal(path) {
|
|
124
|
+
const refusal = redirectSourceRule(path);
|
|
125
|
+
if (refusal === undefined) return undefined;
|
|
126
|
+
return { ruleId: refusal.ruleId, reason: SOURCE_RULE_REASONS[refusal.ruleId](refusal.detail) };
|
|
223
127
|
}
|
|
224
128
|
|
|
225
129
|
function looksAbsolute(value) {
|
|
@@ -281,6 +185,7 @@ function validateEntry(value, index, seen) {
|
|
|
281
185
|
+ "or '..' segment, no empty segment, and no percent-encoded dot or slash. Write the percent-encoded "
|
|
282
186
|
+ "spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
|
|
283
187
|
`${field}.path`,
|
|
188
|
+
RULE_REDIRECT_SOURCE_INVALID,
|
|
284
189
|
);
|
|
285
190
|
}
|
|
286
191
|
// The site root is not refused here. A home page rename records an entry at
|
|
@@ -303,9 +208,14 @@ function validateEntry(value, index, seen) {
|
|
|
303
208
|
`${field}.path`,
|
|
304
209
|
);
|
|
305
210
|
}
|
|
306
|
-
const sourceRefusal =
|
|
211
|
+
const sourceRefusal = redirectSourceRefusal(path);
|
|
307
212
|
if (sourceRefusal !== undefined) {
|
|
308
|
-
throw entryError(
|
|
213
|
+
throw entryError(
|
|
214
|
+
"redirects.path_unpublishable",
|
|
215
|
+
`'${path}': ${sourceRefusal.reason}`,
|
|
216
|
+
`${field}.path`,
|
|
217
|
+
sourceRefusal.ruleId,
|
|
218
|
+
);
|
|
309
219
|
}
|
|
310
220
|
// The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
|
|
311
221
|
// file, and the second would silently replace the first.
|