shelving 1.267.4 → 1.267.6
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/package.json +1 -1
- package/ui/router/Router.js +1 -0
- package/ui/router/Router.tsx +1 -0
- package/util/template.d.ts +15 -4
- package/util/template.js +80 -13
package/package.json
CHANGED
package/ui/router/Router.js
CHANGED
|
@@ -37,6 +37,7 @@ function _matchRoute(routes, fallback, meta, path = meta.path, depth = 0) {
|
|
|
37
37
|
if (typeof Route === "string") {
|
|
38
38
|
if (depth > 10)
|
|
39
39
|
throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
|
|
40
|
+
// `placeholders` are already decoded by `matchPathTemplate`, so re-encode them into the redirect path with `renderPathTemplate` — the symmetric partner of the match above.
|
|
40
41
|
return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
|
|
41
42
|
}
|
|
42
43
|
// React element — render as-is.
|
package/ui/router/Router.tsx
CHANGED
|
@@ -61,6 +61,7 @@ function _matchRoute(
|
|
|
61
61
|
// String value is a redirect; re-run matching with the new path. Guard against infinite redirect loops by limiting depth.
|
|
62
62
|
if (typeof Route === "string") {
|
|
63
63
|
if (depth > 10) throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
|
|
64
|
+
// `placeholders` are already decoded by `matchPathTemplate`, so re-encode them into the redirect path with `renderPathTemplate` — the symmetric partner of the match above.
|
|
64
65
|
return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
|
|
65
66
|
}
|
|
66
67
|
|
package/util/template.d.ts
CHANGED
|
@@ -53,11 +53,13 @@ export declare function matchTemplate(template: string, target: string, caller?:
|
|
|
53
53
|
* Match a path-shaped template against a target path.
|
|
54
54
|
* - Like `matchTemplate()`, but with `/` segment semantics: non-catchall placeholders cannot span path segments; catchall placeholders can.
|
|
55
55
|
* - A trailing catchall (e.g. `/files/{...path}`) also matches when the trailing separator is absent (e.g. `/files`), with the catchall value as `""`.
|
|
56
|
+
* - Matched values are **percent-decoded** (the inverse of `renderPathTemplate()`'s encoding), so a round trip is lossless: `matchPathTemplate(t, renderPathTemplate(t, values))` returns the original values. Catchall values keep their `/` separators (each segment is decoded).
|
|
57
|
+
* - Returns `undefined` if `target` is not validly percent-encoded (e.g. a bare `%` or `%zz`), treating a malformed path as a non-match.
|
|
56
58
|
*
|
|
57
59
|
* @param template The path-shaped template string, e.g. `/users/{name}`.
|
|
58
60
|
* @param target The path containing values, e.g. `/users/Dave`.
|
|
59
61
|
* @param caller Function to attribute a thrown error to (defaults to `matchPathTemplate` itself).
|
|
60
|
-
* @returns An object containing values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
62
|
+
* @returns An object containing decoded values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
61
63
|
* @throws {ValueError} If two template placeholders are not separated by at least one character.
|
|
62
64
|
* @example matchPathTemplate("/users/{name}", "/users/Dave") // { name: "Dave" }
|
|
63
65
|
* @see https://shelving.cc/util/template/matchPathTemplate
|
|
@@ -96,14 +98,23 @@ export declare function matchPathTemplates(templates: Iterable<AbsolutePath> & N
|
|
|
96
98
|
*/
|
|
97
99
|
export declare function renderTemplate(template: string, values: TemplateValues, caller?: AnyCaller): string;
|
|
98
100
|
/**
|
|
99
|
-
* Render a path-shaped template
|
|
101
|
+
* Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
|
|
102
|
+
*
|
|
103
|
+
* Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
|
|
104
|
+
* This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
|
|
105
|
+
* altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
|
|
106
|
+
* the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
|
|
107
|
+
* `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
|
|
108
|
+
*
|
|
109
|
+
* Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
|
|
110
|
+
* `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
|
|
100
111
|
*
|
|
101
112
|
* @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
|
|
102
113
|
* @param values An object containing values (functions are called, everything else converted to string), or a function or string to use for all placeholders.
|
|
103
114
|
* @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
|
|
104
|
-
* @returns The rendered path, e.g. `/users/Dave`.
|
|
115
|
+
* @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
|
|
105
116
|
* @throws {RequiredError} If a placeholder in the template string is not specified in values.
|
|
106
|
-
* @example renderPathTemplate("/users/{name}", { name: "
|
|
117
|
+
* @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
|
|
107
118
|
* @see https://shelving.cc/util/template/renderPathTemplate
|
|
108
119
|
*/
|
|
109
120
|
export declare function renderPathTemplate(template: AbsolutePath, values: TemplateValues, caller?: AnyCaller): AbsolutePath;
|
package/util/template.js
CHANGED
|
@@ -83,25 +83,40 @@ function _getPlaceholder({ name }) {
|
|
|
83
83
|
* @see https://shelving.cc/util/template/matchTemplate
|
|
84
84
|
*/
|
|
85
85
|
export function matchTemplate(template, target, caller = matchTemplate) {
|
|
86
|
-
return _matchTemplate(template, target, "", caller);
|
|
86
|
+
return _matchTemplate(template, target, "", false, caller);
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
89
|
* Match a path-shaped template against a target path.
|
|
90
90
|
* - Like `matchTemplate()`, but with `/` segment semantics: non-catchall placeholders cannot span path segments; catchall placeholders can.
|
|
91
91
|
* - A trailing catchall (e.g. `/files/{...path}`) also matches when the trailing separator is absent (e.g. `/files`), with the catchall value as `""`.
|
|
92
|
+
* - Matched values are **percent-decoded** (the inverse of `renderPathTemplate()`'s encoding), so a round trip is lossless: `matchPathTemplate(t, renderPathTemplate(t, values))` returns the original values. Catchall values keep their `/` separators (each segment is decoded).
|
|
93
|
+
* - Returns `undefined` if `target` is not validly percent-encoded (e.g. a bare `%` or `%zz`), treating a malformed path as a non-match.
|
|
92
94
|
*
|
|
93
95
|
* @param template The path-shaped template string, e.g. `/users/{name}`.
|
|
94
96
|
* @param target The path containing values, e.g. `/users/Dave`.
|
|
95
97
|
* @param caller Function to attribute a thrown error to (defaults to `matchPathTemplate` itself).
|
|
96
|
-
* @returns An object containing values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
98
|
+
* @returns An object containing decoded values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
97
99
|
* @throws {ValueError} If two template placeholders are not separated by at least one character.
|
|
98
100
|
* @example matchPathTemplate("/users/{name}", "/users/Dave") // { name: "Dave" }
|
|
99
101
|
* @see https://shelving.cc/util/template/matchPathTemplate
|
|
100
102
|
*/
|
|
101
103
|
export function matchPathTemplate(template, target, caller = matchPathTemplate) {
|
|
102
|
-
return _matchTemplate(template, target, "/", caller);
|
|
104
|
+
return _matchTemplate(template, target, "/", true, caller);
|
|
103
105
|
}
|
|
104
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Decode a matched path value — the inverse of `renderPathTemplate()`'s `encodeURIComponent`.
|
|
108
|
+
* - Catchall values keep their `/` separators (each segment is decoded independently, mirroring the per-segment encode).
|
|
109
|
+
* - Returns `undefined` for malformed percent-encoding (e.g. a bare `%` or `%zz`), so the caller can reject the whole match.
|
|
110
|
+
*/
|
|
111
|
+
function _decodePathValue(value, catchall) {
|
|
112
|
+
try {
|
|
113
|
+
return catchall ? value.split("/").map(decodeURIComponent).join("/") : decodeURIComponent(value);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function _matchTemplate(template, target, separator, decode, caller) {
|
|
105
120
|
// Get separators and placeholders from template.
|
|
106
121
|
const chunks = _splitTemplateCached(template, caller);
|
|
107
122
|
const firstChunk = chunks[0];
|
|
@@ -134,7 +149,18 @@ function _matchTemplate(template, target, separator, caller) {
|
|
|
134
149
|
if (separator && value.includes(separator))
|
|
135
150
|
return undefined; // Non-catchall placeholders can't span separators (when one is configured).
|
|
136
151
|
}
|
|
137
|
-
|
|
152
|
+
// In path mode, percent-decode the (still-encoded) value so it round-trips with `renderPathTemplate()`; a
|
|
153
|
+
// malformed encoding rejects the whole match. The separator check above runs on the encoded value first,
|
|
154
|
+
// so an encoded `%2F` can't smuggle a separator into a non-catchall segment.
|
|
155
|
+
if (decode) {
|
|
156
|
+
const decoded = _decodePathValue(value, catchall);
|
|
157
|
+
if (decoded === undefined)
|
|
158
|
+
return undefined;
|
|
159
|
+
values[name] = decoded;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
values[name] = value;
|
|
163
|
+
}
|
|
138
164
|
startIndex = stopIndex + post.length;
|
|
139
165
|
}
|
|
140
166
|
if (startIndex < target.length)
|
|
@@ -191,14 +217,14 @@ export function renderTemplate(template, values, caller = renderTemplate) {
|
|
|
191
217
|
let output = template;
|
|
192
218
|
if (isFunction(values)) {
|
|
193
219
|
for (const { name, placeholder } of chunks)
|
|
194
|
-
output = output.replace(placeholder, values(name));
|
|
220
|
+
output = output.replace(placeholder, _escapeReplacement(values(name)));
|
|
195
221
|
}
|
|
196
222
|
else if (isData(values)) {
|
|
197
223
|
for (const { name, placeholder } of chunks) {
|
|
198
224
|
const v = getString(getDataProp(values, name));
|
|
199
225
|
if (v === undefined)
|
|
200
226
|
throw new RequiredError(`Template placeholder "${name}" not found in object`, { received: values, name, caller });
|
|
201
|
-
output = output.replace(placeholder, v);
|
|
227
|
+
output = output.replace(placeholder, _escapeReplacement(v));
|
|
202
228
|
}
|
|
203
229
|
}
|
|
204
230
|
else if (isArray(values)) {
|
|
@@ -206,7 +232,7 @@ export function renderTemplate(template, values, caller = renderTemplate) {
|
|
|
206
232
|
const v = getString(values[Number(name)]);
|
|
207
233
|
if (v === undefined)
|
|
208
234
|
throw new RequiredError(`Template placeholder "${name}" not found in array`, { received: values, name, caller });
|
|
209
|
-
output = output.replace(placeholder, v);
|
|
235
|
+
output = output.replace(placeholder, _escapeReplacement(v));
|
|
210
236
|
}
|
|
211
237
|
}
|
|
212
238
|
else {
|
|
@@ -214,21 +240,62 @@ export function renderTemplate(template, values, caller = renderTemplate) {
|
|
|
214
240
|
if (v === undefined)
|
|
215
241
|
throw new RequiredError(`Template value must be string`, { received: values, caller });
|
|
216
242
|
for (const { placeholder } of chunks)
|
|
217
|
-
output = output.replace(placeholder, v);
|
|
243
|
+
output = output.replace(placeholder, _escapeReplacement(v));
|
|
218
244
|
}
|
|
219
245
|
return output;
|
|
220
246
|
}
|
|
221
247
|
/**
|
|
222
|
-
*
|
|
248
|
+
* Escape `$` in a `String.prototype.replace()` replacement value so it's inserted literally.
|
|
249
|
+
* - Without this, `$&`, `$'`, `` $` ``, `$1`, `$$` in a (often user-supplied) value are interpreted as replacement
|
|
250
|
+
* patterns and pull surrounding template text into the value's slot.
|
|
251
|
+
* - Cheap: only rewrites when a `$` is actually present, so the common case is a single scan with no allocation.
|
|
252
|
+
*/
|
|
253
|
+
function _escapeReplacement(value) {
|
|
254
|
+
return value.includes("$") ? value.replaceAll("$", "$$$$") : value;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
|
|
258
|
+
*
|
|
259
|
+
* Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
|
|
260
|
+
* This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
|
|
261
|
+
* altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
|
|
262
|
+
* the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
|
|
263
|
+
* `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
|
|
264
|
+
*
|
|
265
|
+
* Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
|
|
266
|
+
* `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
|
|
223
267
|
*
|
|
224
268
|
* @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
|
|
225
269
|
* @param values An object containing values (functions are called, everything else converted to string), or a function or string to use for all placeholders.
|
|
226
270
|
* @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
|
|
227
|
-
* @returns The rendered path, e.g. `/users/Dave`.
|
|
271
|
+
* @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
|
|
228
272
|
* @throws {RequiredError} If a placeholder in the template string is not specified in values.
|
|
229
|
-
* @example renderPathTemplate("/users/{name}", { name: "
|
|
273
|
+
* @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
|
|
230
274
|
* @see https://shelving.cc/util/template/renderPathTemplate
|
|
231
275
|
*/
|
|
232
276
|
export function renderPathTemplate(template, values, caller = renderPathTemplate) {
|
|
233
|
-
|
|
277
|
+
const chunks = _splitTemplateCached(template, caller);
|
|
278
|
+
if (!chunks.length)
|
|
279
|
+
return template;
|
|
280
|
+
let output = template;
|
|
281
|
+
for (const { name, placeholder, catchall } of chunks) {
|
|
282
|
+
const value = _getTemplateValue(values, name, caller);
|
|
283
|
+
const encoded = catchall ? value.split("/").map(encodeURIComponent).join("/") : encodeURIComponent(value);
|
|
284
|
+
// Use a replacer function so `$` sequences in the (already-encoded) value are never treated as special replacement patterns.
|
|
285
|
+
output = output.replace(placeholder, () => encoded);
|
|
286
|
+
}
|
|
287
|
+
return output;
|
|
288
|
+
}
|
|
289
|
+
/** Resolve the string value for a single template placeholder from `TemplateValues` (mirrors `renderTemplate()`'s per-name resolution). */
|
|
290
|
+
function _getTemplateValue(values, name, caller) {
|
|
291
|
+
const value = isFunction(values)
|
|
292
|
+
? values(name)
|
|
293
|
+
: isData(values)
|
|
294
|
+
? getString(getDataProp(values, name))
|
|
295
|
+
: isArray(values)
|
|
296
|
+
? getString(values[Number(name)])
|
|
297
|
+
: getString(values);
|
|
298
|
+
if (value === undefined)
|
|
299
|
+
throw new RequiredError(`Template placeholder "${name}" not found`, { received: values, name, caller });
|
|
300
|
+
return value;
|
|
234
301
|
}
|