shelving 1.267.3 → 1.267.5
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/markup/MarkupParser.d.ts +27 -1
- package/markup/MarkupParser.js +31 -1
- package/markup/rule/blockquote.d.ts +1 -0
- package/markup/rule/blockquote.js +11 -1
- package/markup/rule/code.js +3 -1
- package/markup/rule/inline.js +4 -1
- package/markup/rule/link.js +3 -1
- package/markup/rule/separator.js +3 -1
- package/markup/rule/table.js +1 -1
- package/markup/util/regexp.d.ts +4 -4
- package/markup/util/regexp.js +3 -3
- package/package.json +1 -1
- package/ui/misc/Markup.d.ts +1 -0
- package/ui/misc/Markup.js +1 -0
- package/ui/misc/Markup.md +1 -0
- package/ui/misc/Markup.tsx +1 -0
- package/ui/router/Router.js +1 -0
- package/ui/router/Router.tsx +1 -0
- package/ui/tree/TreeMarkup.d.ts +1 -0
- package/ui/tree/TreeMarkup.js +1 -0
- package/ui/tree/TreeMarkup.md +1 -1
- package/ui/tree/TreeMarkup.tsx +1 -0
- package/util/template.d.ts +15 -4
- package/util/template.js +67 -9
package/markup/MarkupParser.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export type MarkupOptions = {
|
|
|
42
42
|
* Default context to use if one isn't set. Defaults to `"block"`
|
|
43
43
|
*/
|
|
44
44
|
readonly context?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Current nesting depth — used to cap self-recursive rules (e.g. blockquotes) so pathological input can't
|
|
47
|
+
* overflow the stack. Internal: set automatically as rules recurse via `MarkupParser.nested()`; callers
|
|
48
|
+
* normally leave it at the default `0`.
|
|
49
|
+
* @default 0
|
|
50
|
+
*/
|
|
51
|
+
readonly depth?: number | undefined;
|
|
45
52
|
};
|
|
46
53
|
/**
|
|
47
54
|
* Parses a Markdownish markup string and renders it as a React node using a tiered, masking rule engine.
|
|
@@ -90,10 +97,29 @@ export declare class MarkupParser implements Parser<string, ReactNode> {
|
|
|
90
97
|
* @see https://shelving.cc/markup/MarkupParser/context
|
|
91
98
|
*/
|
|
92
99
|
readonly context: string;
|
|
93
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Current nesting depth, incremented by `nested()` as self-recursive rules descend. Used to cap recursion so
|
|
102
|
+
* pathological input (e.g. a long `>>>>…` blockquote) can't overflow the stack.
|
|
103
|
+
* @see https://shelving.cc/markup/MarkupParser/depth
|
|
104
|
+
*/
|
|
105
|
+
readonly depth: number;
|
|
106
|
+
constructor({ rules, rel, url, root, schemes, context, depth }?: MarkupOptions);
|
|
107
|
+
/**
|
|
108
|
+
* Return a copy of this parser with `depth` incremented by one, sharing all other options.
|
|
109
|
+
* - Self-recursive rules (e.g. blockquotes) parse their nested content through `nested()` so `depth` tracks how
|
|
110
|
+
* deep the recursion has gone, letting them stop before the call stack overflows.
|
|
111
|
+
*
|
|
112
|
+
* @returns A new `MarkupParser` identical to this one but one level deeper.
|
|
113
|
+
* @see https://shelving.cc/markup/MarkupParser/nested
|
|
114
|
+
*/
|
|
115
|
+
nested(): MarkupParser;
|
|
94
116
|
/**
|
|
95
117
|
* Parse a text string as Markdownish markup syntax and render it as a React node.
|
|
96
118
|
* - Syntax is not defined by this code, but by the rules supplied to it.
|
|
119
|
+
* - **Untrusted input:** parsing is linear for normal content, but a few rules can still degrade on adversarial
|
|
120
|
+
* input — long unbroken runs of backtick code fences, and deeply-nested `>` blockquotes (each leading `>`
|
|
121
|
+
* recurses one level, so a pathological line can overflow the stack). When `input` is user-generated, cap its
|
|
122
|
+
* length first; a sane maximum for your use case (for typical user content, tens of kilobytes) bounds worst-case work.
|
|
97
123
|
*
|
|
98
124
|
* @param input The string content possibly containing markup syntax, e.g. `"This is a *bold* string."`.
|
|
99
125
|
* @param context The context to render in (defaults to this parser's `context`, i.e. `"block"` unless overridden).
|
package/markup/MarkupParser.js
CHANGED
|
@@ -48,7 +48,13 @@ export class MarkupParser {
|
|
|
48
48
|
* @see https://shelving.cc/markup/MarkupParser/context
|
|
49
49
|
*/
|
|
50
50
|
context;
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Current nesting depth, incremented by `nested()` as self-recursive rules descend. Used to cap recursion so
|
|
53
|
+
* pathological input (e.g. a long `>>>>…` blockquote) can't overflow the stack.
|
|
54
|
+
* @see https://shelving.cc/markup/MarkupParser/depth
|
|
55
|
+
*/
|
|
56
|
+
depth;
|
|
57
|
+
constructor({ rules = MARKUP_RULES, rel, url, root, schemes = HTTP_SCHEMES, context = "block", depth = 0 } = {}) {
|
|
52
58
|
this.rules = rules;
|
|
53
59
|
this.priorities = _getPriorities(rules);
|
|
54
60
|
this.rel = rel;
|
|
@@ -56,10 +62,34 @@ export class MarkupParser {
|
|
|
56
62
|
this.root = root;
|
|
57
63
|
this.schemes = schemes;
|
|
58
64
|
this.context = context;
|
|
65
|
+
this.depth = depth;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Return a copy of this parser with `depth` incremented by one, sharing all other options.
|
|
69
|
+
* - Self-recursive rules (e.g. blockquotes) parse their nested content through `nested()` so `depth` tracks how
|
|
70
|
+
* deep the recursion has gone, letting them stop before the call stack overflows.
|
|
71
|
+
*
|
|
72
|
+
* @returns A new `MarkupParser` identical to this one but one level deeper.
|
|
73
|
+
* @see https://shelving.cc/markup/MarkupParser/nested
|
|
74
|
+
*/
|
|
75
|
+
nested() {
|
|
76
|
+
return new MarkupParser({
|
|
77
|
+
rules: this.rules,
|
|
78
|
+
rel: this.rel,
|
|
79
|
+
url: this.url,
|
|
80
|
+
root: this.root,
|
|
81
|
+
schemes: this.schemes,
|
|
82
|
+
context: this.context,
|
|
83
|
+
depth: this.depth + 1,
|
|
84
|
+
});
|
|
59
85
|
}
|
|
60
86
|
/**
|
|
61
87
|
* Parse a text string as Markdownish markup syntax and render it as a React node.
|
|
62
88
|
* - Syntax is not defined by this code, but by the rules supplied to it.
|
|
89
|
+
* - **Untrusted input:** parsing is linear for normal content, but a few rules can still degrade on adversarial
|
|
90
|
+
* input — long unbroken runs of backtick code fences, and deeply-nested `>` blockquotes (each leading `>`
|
|
91
|
+
* recurses one level, so a pathological line can overflow the stack). When `input` is user-generated, cap its
|
|
92
|
+
* length first; a sane maximum for your use case (for typical user content, tens of kilobytes) bounds worst-case work.
|
|
63
93
|
*
|
|
64
94
|
* @param input The string content possibly containing markup syntax, e.g. `"This is a *bold* string."`.
|
|
65
95
|
* @param context The context to render in (defaults to this parser's `context`, i.e. `"block"` unless overridden).
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* - `>` quote character followed by zero or more spaces.
|
|
4
4
|
* - No spaces can appear before the `>` quote character.
|
|
5
5
|
* - Quote block is only broken by `\n\n` two newline characters.
|
|
6
|
+
* - Nesting is capped at `MAX_DEPTH` (10) levels; deeper `>` prefixes are rendered as plain text rather than recursed, so pathological input can't overflow the stack.
|
|
6
7
|
*
|
|
7
8
|
* @example new MarkupParser({ rules: [BLOCKQUOTE_RULE] }).parse("> Quoted text")
|
|
8
9
|
* @see https://shelving.cc/markup/BLOCKQUOTE_RULE
|
|
@@ -3,13 +3,23 @@ import { createMarkupRule } from "../MarkupRule.js";
|
|
|
3
3
|
import { BLOCK_CONTENT_REGEXP, createBlockRegExp } from "../util/regexp.js";
|
|
4
4
|
const PREFIX = ">";
|
|
5
5
|
const INDENT = new RegExp(`^${PREFIX}`, "gm");
|
|
6
|
+
/** Maximum blockquote nesting depth. Each `>` level recurses once, so an unbounded `>>>>…` line would overflow the stack; beyond this the remaining source is left as plain text. */
|
|
7
|
+
const MAX_DEPTH = 10;
|
|
6
8
|
/**
|
|
7
9
|
* Blockquote block.
|
|
8
10
|
* - `>` quote character followed by zero or more spaces.
|
|
9
11
|
* - No spaces can appear before the `>` quote character.
|
|
10
12
|
* - Quote block is only broken by `\n\n` two newline characters.
|
|
13
|
+
* - Nesting is capped at `MAX_DEPTH` (10) levels; deeper `>` prefixes are rendered as plain text rather than recursed, so pathological input can't overflow the stack.
|
|
11
14
|
*
|
|
12
15
|
* @example new MarkupParser({ rules: [BLOCKQUOTE_RULE] }).parse("> Quoted text")
|
|
13
16
|
* @see https://shelving.cc/markup/BLOCKQUOTE_RULE
|
|
14
17
|
*/
|
|
15
|
-
export const BLOCKQUOTE_RULE = createMarkupRule(createBlockRegExp(`(?<quote>${PREFIX}${BLOCK_CONTENT_REGEXP})`), (key, { quote }, parser) =>
|
|
18
|
+
export const BLOCKQUOTE_RULE = createMarkupRule(createBlockRegExp(`(?<quote>${PREFIX}${BLOCK_CONTENT_REGEXP})`), (key, { quote }, parser) => {
|
|
19
|
+
const inner = quote.replace(INDENT, "");
|
|
20
|
+
// This blockquote is level `depth + 1`; once it reaches the cap, render the (still `>`-prefixed) remainder as
|
|
21
|
+
// plain text instead of recursing, so nesting stops at exactly MAX_DEPTH levels and can't overflow the stack.
|
|
22
|
+
if (parser.depth + 1 >= MAX_DEPTH)
|
|
23
|
+
return _jsx("blockquote", { children: inner }, key);
|
|
24
|
+
return _jsx("blockquote", { children: parser.nested().parse(inner, "block") }, key);
|
|
25
|
+
}, ["block", "list"]);
|
package/markup/rule/code.js
CHANGED
|
@@ -15,4 +15,6 @@ import { BLOCK_CONTENT_REGEXP } from "../util/regexp.js";
|
|
|
15
15
|
*/
|
|
16
16
|
// Priority 10: code is a higher-precedence tier, resolved and masked before links/emphasis, so a
|
|
17
17
|
// code span that straddles a link delimiter wins and the link cannot form across it.
|
|
18
|
-
export const CODE_RULE = createMarkupRule(
|
|
18
|
+
export const CODE_RULE = createMarkupRule(
|
|
19
|
+
// Fence length is bounded (`{1,64}`) so a long run of backticks can't force O(n²) backtracking against the closing backreference.
|
|
20
|
+
getRegExp(`(?<fence>\`{1,64})(?<code>${BLOCK_CONTENT_REGEXP})\\k<fence>`), (key, { code }) => _jsx("code", { children: code }, key), ["inline", "list", "link"], 10);
|
package/markup/rule/inline.js
CHANGED
|
@@ -19,7 +19,10 @@ const INLINE_CHARS = { "~": "del", "+": "ins", "*": "strong", _: "em", "=": "mar
|
|
|
19
19
|
* @example new MarkupParser({ rules: [INLINE_RULE] }).parse("This is *bold* and _italic_")
|
|
20
20
|
* @see https://shelving.cc/markup/INLINE_RULE
|
|
21
21
|
*/
|
|
22
|
-
export const INLINE_RULE = createMarkupRule(createWordRegExp(
|
|
22
|
+
export const INLINE_RULE = createMarkupRule(createWordRegExp(
|
|
23
|
+
// Marker run is bounded (`{1,8}`) and the inner span is bounded (`{0,2000}?`) so a long run of
|
|
24
|
+
// unclosed markers can't force O(n²) backtracking — real emphasis never approaches these limits.
|
|
25
|
+
`(?<wrap>(?<char>[${Object.keys(INLINE_CHARS).join("")}]){1,8})(?<text>(?!\\k<char>)\\S(?:[\\s\\S]{0,2000}?(?!\\k<char>)\\S)?)\\k<wrap>`), (key, { char, text }, parser) => {
|
|
23
26
|
const Inline = INLINE_CHARS[char];
|
|
24
27
|
return _jsx(Inline, { children: parser.parse(text, "inline") }, key);
|
|
25
28
|
}, ["inline", "list", "link"]);
|
package/markup/rule/link.js
CHANGED
|
@@ -30,5 +30,7 @@ _renderLink, ["inline", "list"]);
|
|
|
30
30
|
* @example new MarkupParser({ rules: [AUTOLINK_RULE] }).parse("http://google.com/maps (Google Maps)")
|
|
31
31
|
* @see https://shelving.cc/markup/AUTOLINK_RULE
|
|
32
32
|
*/
|
|
33
|
-
export const AUTOLINK_RULE = createMarkupRule(
|
|
33
|
+
export const AUTOLINK_RULE = createMarkupRule(
|
|
34
|
+
// Scheme length is bounded (`{3,64}?`) so a long run of letters with no `:` can't force O(n²) backtracking.
|
|
35
|
+
getRegExp(/(?<href>[a-z]{3,64}?:\S+)(?: +(?:\((?<title>[^)\n]*?)\)))?/), //
|
|
34
36
|
_renderLink, ["inline", "list"]);
|
package/markup/rule/separator.js
CHANGED
|
@@ -11,6 +11,8 @@ import { createLineRegExp } from "../util/regexp.js";
|
|
|
11
11
|
* @example new MarkupParser({ rules: [SEPARATOR_RULE] }).parse("---")
|
|
12
12
|
* @see https://shelving.cc/markup/SEPARATOR_RULE
|
|
13
13
|
*/
|
|
14
|
-
export const SEPARATOR_RULE = createMarkupRule(
|
|
14
|
+
export const SEPARATOR_RULE = createMarkupRule(
|
|
15
|
+
// Inter-character whitespace is bounded (`{0,2000}`) so a long run of spaces can't force O(n²) backtracking.
|
|
16
|
+
createLineRegExp("([-*•+_=])(?: {0,2000}\\1){2,}"), //
|
|
15
17
|
//
|
|
16
18
|
key => _jsx("hr", {}, key), ["block"]);
|
package/markup/rule/table.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { createMarkupRule } from "../MarkupRule.js";
|
|
3
3
|
import { createBlockRegExp, LINE_SPACE_REGEXP } from "../util/regexp.js";
|
|
4
4
|
// Constants.
|
|
5
|
-
const _SPACE = `${LINE_SPACE_REGEXP}
|
|
5
|
+
const _SPACE = `${LINE_SPACE_REGEXP}{0,2000}`; // Run of line whitespace (never crosses a newline); bounded so a long whitespace line can't force O(n²) backtracking in the delimiter row.
|
|
6
6
|
const _CELL = `${_SPACE}:?-+:?${_SPACE}`; // Delimiter-row cell: one or more dashes with optional `:` alignment markers.
|
|
7
7
|
const _DELIMITER_SOURCE = `${_SPACE}\\|?(?:${_CELL}\\|)+(?:${_CELL})?${_SPACE}`; // Delimiter row: pipe-separated dash cells.
|
|
8
8
|
const _DELIMITER = new RegExp(`^${_DELIMITER_SOURCE}$`, "u"); // Tests whether a single line is a delimiter row.
|
package/markup/util/regexp.d.ts
CHANGED
|
@@ -13,12 +13,12 @@ export declare const BLOCK_SPACE_REGEXP = "\\s";
|
|
|
13
13
|
* Regular expression source matching the start of a block — the start of the string, or one linebreak.
|
|
14
14
|
* @see https://shelving.cc/markup/BLOCK_START_REGEXP
|
|
15
15
|
*/
|
|
16
|
-
export declare const BLOCK_START_REGEXP = "(?:\\s
|
|
16
|
+
export declare const BLOCK_START_REGEXP = "(?:\\s{0,2000}\\n|^)";
|
|
17
17
|
/**
|
|
18
18
|
* Regular expression source matching the end of a block — the end of the string, or two linebreaks, with trailing whitespace trimmed.
|
|
19
19
|
* @see https://shelving.cc/markup/BLOCK_END_REGEXP
|
|
20
20
|
*/
|
|
21
|
-
export declare const BLOCK_END_REGEXP = "\\s
|
|
21
|
+
export declare const BLOCK_END_REGEXP = "\\s{0,2000}(?:$|\\n\\s{0,2000}\\n)";
|
|
22
22
|
/**
|
|
23
23
|
* Regular expression source matching line content — the shortest run of any character except newline.
|
|
24
24
|
* @see https://shelving.cc/markup/LINE_CONTENT_REGEXP
|
|
@@ -33,12 +33,12 @@ export declare const LINE_SPACE_REGEXP = "[^\\n\\S]";
|
|
|
33
33
|
* Regular expression source matching the start of a line — the start of the string, or one linebreak.
|
|
34
34
|
* @see https://shelving.cc/markup/LINE_START_REGEXP
|
|
35
35
|
*/
|
|
36
|
-
export declare const LINE_START_REGEXP = "(?:\\s
|
|
36
|
+
export declare const LINE_START_REGEXP = "(?:\\s{0,2000}\\n|^)";
|
|
37
37
|
/**
|
|
38
38
|
* Regular expression source matching the end of a line — the end of the string, or one linebreak, with trailing whitespace trimmed.
|
|
39
39
|
* @see https://shelving.cc/markup/LINE_END_REGEXP
|
|
40
40
|
*/
|
|
41
|
-
export declare const LINE_END_REGEXP = "[^\\n\\S]
|
|
41
|
+
export declare const LINE_END_REGEXP = "[^\\n\\S]{0,2000}(?:\\s{0,2000}\\n|$)";
|
|
42
42
|
/**
|
|
43
43
|
* Regular expression source matching word content — at least one letter or number character.
|
|
44
44
|
* @see https://shelving.cc/markup/WORD_CONTENT_REGEXP
|
package/markup/util/regexp.js
CHANGED
|
@@ -13,12 +13,12 @@ export const BLOCK_SPACE_REGEXP = "\\s"; // Block whitespace (run of any whitesp
|
|
|
13
13
|
* Regular expression source matching the start of a block — the start of the string, or one linebreak.
|
|
14
14
|
* @see https://shelving.cc/markup/BLOCK_START_REGEXP
|
|
15
15
|
*/
|
|
16
|
-
export const BLOCK_START_REGEXP = "(?:\\s
|
|
16
|
+
export const BLOCK_START_REGEXP = "(?:\\s{0,2000}\\n|^)"; // Start of block (start of string, or one linebreak). Whitespace is bounded so a long whitespace run can't force O(n²) backtracking at every offset.
|
|
17
17
|
/**
|
|
18
18
|
* Regular expression source matching the end of a block — the end of the string, or two linebreaks, with trailing whitespace trimmed.
|
|
19
19
|
* @see https://shelving.cc/markup/BLOCK_END_REGEXP
|
|
20
20
|
*/
|
|
21
|
-
export const BLOCK_END_REGEXP = "\\s
|
|
21
|
+
export const BLOCK_END_REGEXP = "\\s{0,2000}(?:$|\\n\\s{0,2000}\\n)"; // End of block (end of string, or two linebreaks, trimmed whitespace). Whitespace runs bounded to avoid O(n²) backtracking.
|
|
22
22
|
/**
|
|
23
23
|
* Regular expression source matching line content — the shortest run of any character except newline.
|
|
24
24
|
* @see https://shelving.cc/markup/LINE_CONTENT_REGEXP
|
|
@@ -38,7 +38,7 @@ export const LINE_START_REGEXP = BLOCK_START_REGEXP; // Start of line (start of
|
|
|
38
38
|
* Regular expression source matching the end of a line — the end of the string, or one linebreak, with trailing whitespace trimmed.
|
|
39
39
|
* @see https://shelving.cc/markup/LINE_END_REGEXP
|
|
40
40
|
*/
|
|
41
|
-
export const LINE_END_REGEXP = `${LINE_SPACE_REGEXP}
|
|
41
|
+
export const LINE_END_REGEXP = `${LINE_SPACE_REGEXP}{0,2000}(?:\\s{0,2000}\\n|$)`; // End of line (end of string, or one linebreak, trimmed whitespace). Whitespace runs bounded to avoid O(n²) backtracking.
|
|
42
42
|
/**
|
|
43
43
|
* Regular expression source matching word content — at least one letter or number character.
|
|
44
44
|
* @see https://shelving.cc/markup/WORD_CONTENT_REGEXP
|
package/package.json
CHANGED
package/ui/misc/Markup.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface MarkupProps extends Partial<MarkupOptions> {
|
|
|
14
14
|
* - Defaults to `MARKUP_OPTIONS` (full block + inline rule set). Pass `rules`, `rel`, `url`, `root`, or `schemes` as props to override.
|
|
15
15
|
* - `url`/`root` default to the current `<Meta>` context so link rules resolve site-absolute and relative hrefs.
|
|
16
16
|
* - Renders inside whatever ancestor element the caller provides — wrap in `<Prose>` to get the standard prose typography for the produced `<p>` / `<ul>` / `<pre>` / etc.
|
|
17
|
+
* - **Untrusted input:** when `children` comes from users, cap its length before rendering — parsing is linear for normal content but a few rules can degrade on adversarial input (a sane maximum for your use case, typically tens of kilobytes, bounds worst-case work). See `MarkupParser.parse()`.
|
|
17
18
|
*
|
|
18
19
|
* @param children The source markup string to parse and render (renders `null` when empty).
|
|
19
20
|
* @returns The parsed markup as React nodes, or `null` when `children` is empty.
|
package/ui/misc/Markup.js
CHANGED
|
@@ -5,6 +5,7 @@ import { requireMeta } from "./MetaContext.js";
|
|
|
5
5
|
* - Defaults to `MARKUP_OPTIONS` (full block + inline rule set). Pass `rules`, `rel`, `url`, `root`, or `schemes` as props to override.
|
|
6
6
|
* - `url`/`root` default to the current `<Meta>` context so link rules resolve site-absolute and relative hrefs.
|
|
7
7
|
* - Renders inside whatever ancestor element the caller provides — wrap in `<Prose>` to get the standard prose typography for the produced `<p>` / `<ul>` / `<pre>` / etc.
|
|
8
|
+
* - **Untrusted input:** when `children` comes from users, cap its length before rendering — parsing is linear for normal content but a few rules can degrade on adversarial input (a sane maximum for your use case, typically tens of kilobytes, bounds worst-case work). See `MarkupParser.parse()`.
|
|
8
9
|
*
|
|
9
10
|
* @param children The source markup string to parse and render (renders `null` when empty).
|
|
10
11
|
* @returns The parsed markup as React nodes, or `null` when `children` is empty.
|
package/ui/misc/Markup.md
CHANGED
|
@@ -7,6 +7,7 @@ Parses a markup string and renders the resulting React nodes inline. Defaults to
|
|
|
7
7
|
- `url` and `root` default to the current `<Meta>` context, so link rules resolve site-absolute and relative hrefs correctly. Override any `MarkupOptions` prop (`rules`, `rel`, `url`, `root`, `schemes`) directly on `<Markup>` for a custom rule set or base URL.
|
|
8
8
|
- Renders `null` when `children` is empty.
|
|
9
9
|
- Wrap in `<Prose>` to give the produced `<p>` / `<ul>` / `<pre>` / etc. the standard prose typography.
|
|
10
|
+
- When `children` comes from users, cap its length before rendering: parsing is linear for normal content, but a few rules can degrade on adversarial input (long backtick runs, deeply-nested `>` blockquotes). A sane maximum for your use case — tens of kilobytes for typical user content — keeps worst-case work bounded.
|
|
10
11
|
|
|
11
12
|
## Usage
|
|
12
13
|
|
package/ui/misc/Markup.tsx
CHANGED
|
@@ -17,6 +17,7 @@ export interface MarkupProps extends Partial<MarkupOptions> {
|
|
|
17
17
|
* - Defaults to `MARKUP_OPTIONS` (full block + inline rule set). Pass `rules`, `rel`, `url`, `root`, or `schemes` as props to override.
|
|
18
18
|
* - `url`/`root` default to the current `<Meta>` context so link rules resolve site-absolute and relative hrefs.
|
|
19
19
|
* - Renders inside whatever ancestor element the caller provides — wrap in `<Prose>` to get the standard prose typography for the produced `<p>` / `<ul>` / `<pre>` / etc.
|
|
20
|
+
* - **Untrusted input:** when `children` comes from users, cap its length before rendering — parsing is linear for normal content but a few rules can degrade on adversarial input (a sane maximum for your use case, typically tens of kilobytes, bounds worst-case work). See `MarkupParser.parse()`.
|
|
20
21
|
*
|
|
21
22
|
* @param children The source markup string to parse and render (renders `null` when empty).
|
|
22
23
|
* @returns The parsed markup as React nodes, or `null` when `children` is empty.
|
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/ui/tree/TreeMarkup.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export declare const TREE_MARKUP_RULES: MarkupRules;
|
|
|
21
21
|
* - Like `<Markup>` but defaults to `TREE_MARKUP_RULES`, so each backtick span resolves through `TreeLink` against the surrounding `<TreeProvider>` — a known token links to its canonical page, an unknown one stays plain code.
|
|
22
22
|
* - The standard way to render documentation-site content (READMEs, docblocks, per-symbol pages); plain `<Markup>` stays the choice for general user content that shouldn't cross-link.
|
|
23
23
|
* - Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere. Pass any `MarkupOptions` prop (including `rules`) to override.
|
|
24
|
+
* - Inherits `<Markup>`'s input-length guidance: when `children` is untrusted, cap its length before rendering (see `MarkupParser.parse()`).
|
|
24
25
|
*
|
|
25
26
|
* @param children The source markup string to parse and render (renders `null` when empty).
|
|
26
27
|
* @returns The parsed markup as React nodes, or `null` when `children` is empty.
|
package/ui/tree/TreeMarkup.js
CHANGED
|
@@ -26,6 +26,7 @@ export const TREE_MARKUP_RULES = MARKUP_RULES.map(rule => (rule === CODE_RULE ?
|
|
|
26
26
|
* - Like `<Markup>` but defaults to `TREE_MARKUP_RULES`, so each backtick span resolves through `TreeLink` against the surrounding `<TreeProvider>` — a known token links to its canonical page, an unknown one stays plain code.
|
|
27
27
|
* - The standard way to render documentation-site content (READMEs, docblocks, per-symbol pages); plain `<Markup>` stays the choice for general user content that shouldn't cross-link.
|
|
28
28
|
* - Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere. Pass any `MarkupOptions` prop (including `rules`) to override.
|
|
29
|
+
* - Inherits `<Markup>`'s input-length guidance: when `children` is untrusted, cap its length before rendering (see `MarkupParser.parse()`).
|
|
29
30
|
*
|
|
30
31
|
* @param children The source markup string to parse and render (renders `null` when empty).
|
|
31
32
|
* @returns The parsed markup as React nodes, or `null` when `children` is empty.
|
package/ui/tree/TreeMarkup.md
CHANGED
|
@@ -7,7 +7,7 @@ Renders a markup string just like `<Markup>`, but auto-links every inline code s
|
|
|
7
7
|
- Defaults to `TREE_MARKUP_RULES` — the full default rule set with the inline-code rule swapped for `TREE_CODE_RULE`, which renders each span through `TreeLink`.
|
|
8
8
|
- Resolution is exact-match against the tree map: a hit (`` `BooleanSchema` ``, `` `Store.get` ``) links; a miss (`` `bun run fix` ``, `` `string` ``) falls back to a plain code token, so unknown spans never produce a broken link.
|
|
9
9
|
- Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere — it simply stops linking.
|
|
10
|
-
- Inherits everything else from `<Markup>`: `url` / `root` default to the current `<Meta>` context, and any `MarkupOptions` prop (`rules`, `rel`, `url`, `root`, `schemes`) can be overridden directly.
|
|
10
|
+
- Inherits everything else from `<Markup>`: `url` / `root` default to the current `<Meta>` context, and any `MarkupOptions` prop (`rules`, `rel`, `url`, `root`, `schemes`) can be overridden directly. That includes the input-length guidance — cap untrusted `children` length before rendering.
|
|
11
11
|
- Wrap in `<Prose>` to give the produced `<p>` / `<ul>` / `<pre>` / etc. the standard prose typography.
|
|
12
12
|
|
|
13
13
|
## Usage
|
package/ui/tree/TreeMarkup.tsx
CHANGED
|
@@ -30,6 +30,7 @@ export const TREE_MARKUP_RULES: MarkupRules = MARKUP_RULES.map(rule => (rule ===
|
|
|
30
30
|
* - Like `<Markup>` but defaults to `TREE_MARKUP_RULES`, so each backtick span resolves through `TreeLink` against the surrounding `<TreeProvider>` — a known token links to its canonical page, an unknown one stays plain code.
|
|
31
31
|
* - The standard way to render documentation-site content (READMEs, docblocks, per-symbol pages); plain `<Markup>` stays the choice for general user content that shouldn't cross-link.
|
|
32
32
|
* - Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere. Pass any `MarkupOptions` prop (including `rules`) to override.
|
|
33
|
+
* - Inherits `<Markup>`'s input-length guidance: when `children` is untrusted, cap its length before rendering (see `MarkupParser.parse()`).
|
|
33
34
|
*
|
|
34
35
|
* @param children The source markup string to parse and render (renders `null` when empty).
|
|
35
36
|
* @returns The parsed markup as React nodes, or `null` when `children` is empty.
|
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)
|
|
@@ -219,16 +245,48 @@ export function renderTemplate(template, values, caller = renderTemplate) {
|
|
|
219
245
|
return output;
|
|
220
246
|
}
|
|
221
247
|
/**
|
|
222
|
-
* Render a path-shaped template
|
|
248
|
+
* Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
|
|
249
|
+
*
|
|
250
|
+
* Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
|
|
251
|
+
* This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
|
|
252
|
+
* altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
|
|
253
|
+
* the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
|
|
254
|
+
* `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
|
|
255
|
+
*
|
|
256
|
+
* Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
|
|
257
|
+
* `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
|
|
223
258
|
*
|
|
224
259
|
* @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
|
|
225
260
|
* @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
261
|
* @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
|
|
227
|
-
* @returns The rendered path, e.g. `/users/Dave`.
|
|
262
|
+
* @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
|
|
228
263
|
* @throws {RequiredError} If a placeholder in the template string is not specified in values.
|
|
229
|
-
* @example renderPathTemplate("/users/{name}", { name: "
|
|
264
|
+
* @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
|
|
230
265
|
* @see https://shelving.cc/util/template/renderPathTemplate
|
|
231
266
|
*/
|
|
232
267
|
export function renderPathTemplate(template, values, caller = renderPathTemplate) {
|
|
233
|
-
|
|
268
|
+
const chunks = _splitTemplateCached(template, caller);
|
|
269
|
+
if (!chunks.length)
|
|
270
|
+
return template;
|
|
271
|
+
let output = template;
|
|
272
|
+
for (const { name, placeholder, catchall } of chunks) {
|
|
273
|
+
const value = _getTemplateValue(values, name, caller);
|
|
274
|
+
const encoded = catchall ? value.split("/").map(encodeURIComponent).join("/") : encodeURIComponent(value);
|
|
275
|
+
// Use a replacer function so `$` sequences in the (already-encoded) value are never treated as special replacement patterns.
|
|
276
|
+
output = output.replace(placeholder, () => encoded);
|
|
277
|
+
}
|
|
278
|
+
return output;
|
|
279
|
+
}
|
|
280
|
+
/** Resolve the string value for a single template placeholder from `TemplateValues` (mirrors `renderTemplate()`'s per-name resolution). */
|
|
281
|
+
function _getTemplateValue(values, name, caller) {
|
|
282
|
+
const value = isFunction(values)
|
|
283
|
+
? values(name)
|
|
284
|
+
: isData(values)
|
|
285
|
+
? getString(getDataProp(values, name))
|
|
286
|
+
: isArray(values)
|
|
287
|
+
? getString(values[Number(name)])
|
|
288
|
+
: getString(values);
|
|
289
|
+
if (value === undefined)
|
|
290
|
+
throw new RequiredError(`Template placeholder "${name}" not found`, { received: values, name, caller });
|
|
291
|
+
return value;
|
|
234
292
|
}
|