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.
@@ -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
- constructor({ rules, rel, url, root, schemes, context }?: MarkupOptions);
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).
@@ -48,7 +48,13 @@ export class MarkupParser {
48
48
  * @see https://shelving.cc/markup/MarkupParser/context
49
49
  */
50
50
  context;
51
- constructor({ rules = MARKUP_RULES, rel, url, root, schemes = HTTP_SCHEMES, context = "block" } = {}) {
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) => _jsx("blockquote", { children: parser.parse(quote.replace(INDENT, ""), "block") }, key), ["block", "list"]);
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"]);
@@ -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(getRegExp(`(?<fence>\`+)(?<code>${BLOCK_CONTENT_REGEXP})\\k<fence>`), (key, { code }) => _jsx("code", { children: code }, key), ["inline", "list", "link"], 10);
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);
@@ -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(`(?<wrap>(?<char>[${Object.keys(INLINE_CHARS).join("")}])+)(?<text>(?!\\k<char>)\\S(?:[\\s\\S]*?(?!\\k<char>)\\S)?)\\k<wrap>`), (key, { char, text }, parser) => {
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"]);
@@ -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(getRegExp(/(?<href>[a-z]{3,}?:\S+)(?: +(?:\((?<title>[^)\n]*?)\)))?/), //
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"]);
@@ -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(createLineRegExp("([-*•+_=])(?: *\\1){2,}"), //
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"]);
@@ -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}*`; // Run of line whitespace (never crosses a newline).
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.
@@ -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*\\n|^)";
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*(?:$|\\n\\s*\\n)";
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*\\n|^)";
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]*(?:\\s*\\n|$)";
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
@@ -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*\\n|^)"; // Start of block (start of string, or one linebreak).
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*(?:$|\\n\\s*\\n)"; // End of block (end of string, or two linebreaks, trimmed whitespace).
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}*(?:\\s*\\n|$)`; // End of line (end of string, or one linebreak, trimmed whitespace).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.267.3",
3
+ "version": "1.267.5",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
 
@@ -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.
@@ -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.
@@ -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
 
@@ -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.
@@ -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.
@@ -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
@@ -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.
@@ -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. Behaviourally identical to `renderTemplate()` substitution doesn't need separator awareness — but provided as a sibling to `matchPathTemplate()` so callers can pair them.
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: "Dave" }) // "/users/Dave"
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
- function _matchTemplate(template, target, separator, caller) {
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
- values[name] = value;
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. Behaviourally identical to `renderTemplate()` substitution doesn't need separator awareness — but provided as a sibling to `matchPathTemplate()` so callers can pair them.
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: "Dave" }) // "/users/Dave"
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
- return renderTemplate(template, values, caller);
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
  }