@zudojs/docs 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/dist/document/documentBuilder.core.js +2 -1
- package/dist/document/documentBuilder.normalize.d.ts +12 -0
- package/dist/document/documentBuilder.normalize.js +18 -0
- package/dist/frontmatter/frontmatter.parser.d.ts +2 -1
- package/dist/frontmatter/frontmatter.parser.js +19 -23
- package/dist/frontmatter/frontmatter.serializer.js +13 -2
- package/dist/frontmatter/frontmatter.values.d.ts +25 -0
- package/dist/frontmatter/frontmatter.values.js +70 -0
- package/dist/generator/generatorMarkdown.core.js +8 -3
- package/dist/generator/generatorMarkdownNodes.d.ts +4 -1
- package/dist/generator/generatorMarkdownNodes.js +19 -10
- package/dist/registry/registry.core.d.ts +7 -2
- package/dist/registry/registry.core.js +7 -3
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/utils.helper.d.ts +0 -4
- package/dist/utils/utils.helper.js +7 -52
- package/dist/utils/utils.href.d.ts +26 -0
- package/dist/utils/utils.href.js +45 -0
- package/dist/utils/utils.markdownText.d.ts +22 -0
- package/dist/utils/utils.markdownText.js +88 -0
- package/dist/validator/validatorLinks.core.d.ts +3 -1
- package/dist/validator/validatorLinks.core.js +26 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Documentation infrastructure with structured document model, registry, validation, navigation, frontmatter parsing, and markdown/JSON generation.
|
|
4
4
|
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-docs](https://zudojs.oyinlola.site/docs/packages-docs) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-docs.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
10
|
+
|
|
5
11
|
## Installation
|
|
6
12
|
|
|
7
13
|
```bash
|
|
@@ -46,11 +52,13 @@ const index = generateIndex(registry.getAll()); // SERVER-only docs excluded
|
|
|
46
52
|
|
|
47
53
|
## Features
|
|
48
54
|
|
|
49
|
-
- Document model with frontmatter (`parseFrontmatter` / `serializeFrontmatter` round-trip safely)
|
|
55
|
+
- Document model with frontmatter (`parseFrontmatter` / `serializeFrontmatter` round-trip safely; `tags` always parses to a string array, and the body keeps its indentation)
|
|
50
56
|
- Document registry and discovery (`DuplicateDocumentError` on duplicate IDs, deep-frozen copies)
|
|
51
57
|
- Navigation tree helpers (breadcrumbs, siblings, previous/next, cycle-safe walkers)
|
|
52
|
-
- Markdown and JSON generation with escaping for untrusted content and
|
|
53
|
-
-
|
|
58
|
+
- Markdown and JSON generation with escaping for untrusted content: structured text is HTML-escaped and links are limited to http, https, mailto, tel, ftp and ftps (others are written as plain text)
|
|
59
|
+
- Fail-closed `visibility` filtering: only an unset or exactly `"CLIENT"` visibility reaches a client index
|
|
60
|
+
- `stripMarkdown` and link validation run in linear time on untrusted input
|
|
61
|
+
- Document, link and navigation validation with a single `valid` rule (errors only); `javascript:` and other non-allow-listed link schemes are `UNSAFE_LINK` errors
|
|
54
62
|
- Documentation error classes re-exported from `@zudojs/errors`
|
|
55
63
|
|
|
56
64
|
## Use Cases
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { DocumentValidationError } from "@zudojs/errors";
|
|
8
8
|
import { deepFreezeClone } from "../utils/utils.freeze.js";
|
|
9
9
|
import { isValidDocumentId } from "../utils/utils.helper.js";
|
|
10
|
+
import { toTagList } from "./documentBuilder.normalize.js";
|
|
10
11
|
/**
|
|
11
12
|
* Creates a documentation document from structured options.
|
|
12
13
|
*
|
|
@@ -36,7 +37,7 @@ export function createDocument(options) {
|
|
|
36
37
|
description: options.description,
|
|
37
38
|
content: options.content,
|
|
38
39
|
category: options.category,
|
|
39
|
-
tags: options.tags
|
|
40
|
+
tags: toTagList(options.tags),
|
|
40
41
|
version: options.version,
|
|
41
42
|
status: options.status,
|
|
42
43
|
metadata: options.metadata,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input normalization for `createDocument`.
|
|
3
|
+
*
|
|
4
|
+
* @module document/documentBuilder.normalize
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Copies a tag list. A lone string (from untyped callers or older parsed
|
|
8
|
+
* frontmatter) becomes a one-item list instead of being spread into
|
|
9
|
+
* one-character tags.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toTagList(tags: readonly string[] | string | undefined): string[] | undefined;
|
|
12
|
+
//# sourceMappingURL=documentBuilder.normalize.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input normalization for `createDocument`.
|
|
3
|
+
*
|
|
4
|
+
* @module document/documentBuilder.normalize
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Copies a tag list. A lone string (from untyped callers or older parsed
|
|
8
|
+
* frontmatter) becomes a one-item list instead of being spread into
|
|
9
|
+
* one-character tags.
|
|
10
|
+
*/
|
|
11
|
+
export function toTagList(tags) {
|
|
12
|
+
if (tags === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (typeof tags === "string")
|
|
15
|
+
return tags === "" ? [] : [tags];
|
|
16
|
+
return [...tags];
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=documentBuilder.normalize.js.map
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* and extracts metadata alongside the remaining content.
|
|
6
6
|
*
|
|
7
7
|
* Supported subset:
|
|
8
|
-
* - `key: scalar` (strings,
|
|
8
|
+
* - `key: scalar` (strings, lossless numbers, booleans, null)
|
|
9
|
+
* - `[]` / `{}` as empty collections; `tags` always parses to a string list
|
|
9
10
|
* - single- and double-quoted strings with escapes
|
|
10
11
|
* - `key:` followed by `- item` lines (list of scalars)
|
|
11
12
|
* - one level of nested mapping (`key:` followed by indented `sub: value`)
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* and extracts metadata alongside the remaining content.
|
|
6
6
|
*
|
|
7
7
|
* Supported subset:
|
|
8
|
-
* - `key: scalar` (strings,
|
|
8
|
+
* - `key: scalar` (strings, lossless numbers, booleans, null)
|
|
9
|
+
* - `[]` / `{}` as empty collections; `tags` always parses to a string list
|
|
9
10
|
* - single- and double-quoted strings with escapes
|
|
10
11
|
* - `key:` followed by `- item` lines (list of scalars)
|
|
11
12
|
* - one level of nested mapping (`key:` followed by indented `sub: value`)
|
|
@@ -15,13 +16,12 @@
|
|
|
15
16
|
* result object's prototype (`__proto__`, `constructor`, `prototype`)
|
|
16
17
|
* are rejected.
|
|
17
18
|
*/
|
|
19
|
+
import { STRING_LIST_KEYS, emptyCollection, losslessNumber, parseInlineList, } from "./frontmatter.values.js";
|
|
18
20
|
const FRONTMATTER_DELIMITER = "---";
|
|
19
21
|
/** Keys that must never be assigned onto a plain object. */
|
|
20
22
|
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
21
23
|
/** Valid frontmatter key syntax. */
|
|
22
24
|
const KEY_PATTERN = /^[A-Za-z_][\w.-]*$/;
|
|
23
|
-
/** Canonical number: optional sign, no leading zeros, optional fraction. */
|
|
24
|
-
const CANONICAL_NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?$/;
|
|
25
25
|
/**
|
|
26
26
|
* Keys whose values are declared as strings in `FrontmatterMetadata`.
|
|
27
27
|
* Their scalars are never coerced, so `version: 1.0` stays `"1.0"`.
|
|
@@ -35,8 +35,6 @@ const STRING_KEYS = new Set([
|
|
|
35
35
|
"deprecatedMessage",
|
|
36
36
|
"visibility",
|
|
37
37
|
]);
|
|
38
|
-
/** Keys whose list items are always strings. */
|
|
39
|
-
const STRING_LIST_KEYS = new Set(["tags"]);
|
|
40
38
|
/**
|
|
41
39
|
* Parses YAML-like frontmatter from a markdown string.
|
|
42
40
|
*
|
|
@@ -65,11 +63,10 @@ export function parseFrontmatter(raw) {
|
|
|
65
63
|
return { metadata: Object.freeze({}), content: raw };
|
|
66
64
|
}
|
|
67
65
|
const yamlLines = lines.slice(start + 1, end);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.trimStart();
|
|
66
|
+
// Drop only the one blank separator line that serializeFrontmatter writes.
|
|
67
|
+
// Trimming more stripped the indentation of a leading code block.
|
|
68
|
+
const bodyStart = lines[end + 1]?.trim() === "" ? end + 2 : end + 1;
|
|
69
|
+
const remainingContent = lines.slice(bodyStart).join("\n");
|
|
73
70
|
const metadata = parseYamlLike(yamlLines);
|
|
74
71
|
return { metadata, content: remainingContent };
|
|
75
72
|
}
|
|
@@ -91,7 +88,9 @@ function parseYamlLike(lines) {
|
|
|
91
88
|
result[currentKey] = Object.freeze(currentMap);
|
|
92
89
|
}
|
|
93
90
|
else {
|
|
94
|
-
result[currentKey] =
|
|
91
|
+
result[currentKey] = STRING_LIST_KEYS.has(currentKey)
|
|
92
|
+
? Object.freeze([])
|
|
93
|
+
: "";
|
|
95
94
|
}
|
|
96
95
|
currentKey = null;
|
|
97
96
|
currentArray = null;
|
|
@@ -141,9 +140,12 @@ function parseYamlLike(lines) {
|
|
|
141
140
|
currentKey = entry.key;
|
|
142
141
|
continue;
|
|
143
142
|
}
|
|
144
|
-
result[entry.key] =
|
|
145
|
-
?
|
|
146
|
-
:
|
|
143
|
+
result[entry.key] = STRING_LIST_KEYS.has(entry.key)
|
|
144
|
+
? parseInlineList(entry.value, parseStringScalar)
|
|
145
|
+
: (emptyCollection(entry.value) ??
|
|
146
|
+
(STRING_KEYS.has(entry.key)
|
|
147
|
+
? parseStringScalar(entry.value)
|
|
148
|
+
: parseScalar(entry.value)));
|
|
147
149
|
}
|
|
148
150
|
flush();
|
|
149
151
|
return Object.freeze({ ...result });
|
|
@@ -190,8 +192,8 @@ function stripComment(line) {
|
|
|
190
192
|
* Parses a scalar YAML value into its appropriate JS type.
|
|
191
193
|
*
|
|
192
194
|
* Quoted strings are unquoted (with escape handling) and never coerced.
|
|
193
|
-
*
|
|
194
|
-
* anything else stays a string.
|
|
195
|
+
* A numeric literal becomes a number only when the conversion is lossless
|
|
196
|
+
* (`String(Number(text)) === text`); anything else stays a string.
|
|
195
197
|
*/
|
|
196
198
|
function parseScalar(value) {
|
|
197
199
|
if (value.length >= 2) {
|
|
@@ -210,13 +212,7 @@ function parseScalar(value) {
|
|
|
210
212
|
return false;
|
|
211
213
|
if (value === "null" || value === "~")
|
|
212
214
|
return null;
|
|
213
|
-
|
|
214
|
-
const parsed = Number(value);
|
|
215
|
-
if (Number.isFinite(parsed) && Math.abs(parsed) <= Number.MAX_SAFE_INTEGER) {
|
|
216
|
-
return parsed;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return value;
|
|
215
|
+
return losslessNumber(value) ?? value;
|
|
220
216
|
}
|
|
221
217
|
/** Parses a scalar that must remain a string (unquotes, never coerces). */
|
|
222
218
|
function parseStringScalar(value) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* whitespace, or text that looks like a number/boolean/null), so
|
|
7
7
|
* `parseFrontmatter(serializeFrontmatter(m, c))` round-trips `m`.
|
|
8
8
|
*/
|
|
9
|
+
import { losslessNumber } from "./frontmatter.values.js";
|
|
9
10
|
const FRONTMATTER_DELIMITER = "---";
|
|
10
11
|
/** Keys that are never written. */
|
|
11
12
|
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
@@ -26,7 +27,13 @@ export function serializeFrontmatter(metadata, content) {
|
|
|
26
27
|
continue;
|
|
27
28
|
if (!isSerializableKey(key))
|
|
28
29
|
continue;
|
|
29
|
-
if (Array.isArray(value)) {
|
|
30
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
31
|
+
lines.push(`${key}: []`);
|
|
32
|
+
}
|
|
33
|
+
else if (isPlainObject(value) && !hasSerializableEntry(value)) {
|
|
34
|
+
lines.push(`${key}: {}`);
|
|
35
|
+
}
|
|
36
|
+
else if (Array.isArray(value)) {
|
|
30
37
|
lines.push(`${key}:`);
|
|
31
38
|
for (const item of value) {
|
|
32
39
|
lines.push(` - ${formatScalar(item)}`);
|
|
@@ -90,7 +97,7 @@ function needsQuotes(value) {
|
|
|
90
97
|
return true;
|
|
91
98
|
if (value === "null" || value === "~")
|
|
92
99
|
return true;
|
|
93
|
-
if (
|
|
100
|
+
if (losslessNumber(value) !== undefined)
|
|
94
101
|
return true;
|
|
95
102
|
return false;
|
|
96
103
|
}
|
|
@@ -104,6 +111,10 @@ function quote(value) {
|
|
|
104
111
|
.replace(/\t/g, "\\t") +
|
|
105
112
|
'"');
|
|
106
113
|
}
|
|
114
|
+
/** Whether a mapping has at least one entry the serializer would write. */
|
|
115
|
+
function hasSerializableEntry(value) {
|
|
116
|
+
return Object.entries(value).some(([key, entry]) => entry !== undefined && entry !== null && isSerializableKey(key));
|
|
117
|
+
}
|
|
107
118
|
function isSerializableKey(key) {
|
|
108
119
|
return !FORBIDDEN_KEYS.has(key) && KEY_PATTERN.test(key);
|
|
109
120
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value rules shared by the frontmatter parser and serializer.
|
|
3
|
+
*
|
|
4
|
+
* - List keys (`tags`) always parse to a string array: `tags: http` is
|
|
5
|
+
* `["http"]`, `tags:` and `tags: []` are `[]`, and `tags: [a, b]` is
|
|
6
|
+
* `["a", "b"]`. A string here used to reach `createDocument`, which spread
|
|
7
|
+
* it into one-character tags.
|
|
8
|
+
* - `[]` and `{}` are the empty collections, so the serializer can write an
|
|
9
|
+
* empty array or object and get it back (they used to come back as `""`).
|
|
10
|
+
* - A bare numeric literal becomes a number only when `String(Number(text))`
|
|
11
|
+
* gives the text back, so the conversion is lossless both ways (`1e+21`
|
|
12
|
+
* and `1.5e-7` round-trip; `007` and 20-digit IDs stay strings).
|
|
13
|
+
*/
|
|
14
|
+
/** Keys whose value is always a list of strings. */
|
|
15
|
+
export declare const STRING_LIST_KEYS: ReadonlySet<string>;
|
|
16
|
+
/** The number a literal denotes, when converting it loses nothing. */
|
|
17
|
+
export declare function losslessNumber(text: string): number | undefined;
|
|
18
|
+
/** `[]` or `{}` written as a value: the empty collection it denotes. */
|
|
19
|
+
export declare function emptyCollection(value: string): readonly never[] | Readonly<Record<string, never>> | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Parses the inline value of a list key into items. `[a, "b, c"]` is a flow
|
|
22
|
+
* list; anything else is a single item. Items go through `parseItem`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseInlineList(value: string, parseItem: (item: string) => string): readonly string[];
|
|
25
|
+
//# sourceMappingURL=frontmatter.values.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value rules shared by the frontmatter parser and serializer.
|
|
3
|
+
*
|
|
4
|
+
* - List keys (`tags`) always parse to a string array: `tags: http` is
|
|
5
|
+
* `["http"]`, `tags:` and `tags: []` are `[]`, and `tags: [a, b]` is
|
|
6
|
+
* `["a", "b"]`. A string here used to reach `createDocument`, which spread
|
|
7
|
+
* it into one-character tags.
|
|
8
|
+
* - `[]` and `{}` are the empty collections, so the serializer can write an
|
|
9
|
+
* empty array or object and get it back (they used to come back as `""`).
|
|
10
|
+
* - A bare numeric literal becomes a number only when `String(Number(text))`
|
|
11
|
+
* gives the text back, so the conversion is lossless both ways (`1e+21`
|
|
12
|
+
* and `1.5e-7` round-trip; `007` and 20-digit IDs stay strings).
|
|
13
|
+
*/
|
|
14
|
+
/** Keys whose value is always a list of strings. */
|
|
15
|
+
export const STRING_LIST_KEYS = new Set(["tags"]);
|
|
16
|
+
/** Plain decimal or exponent notation; the text round-trip decides the rest. */
|
|
17
|
+
const NUMERIC_LITERAL = /^-?(?:\d+)(?:\.\d+)?(?:e[+-]?\d+)?$/;
|
|
18
|
+
/** The number a literal denotes, when converting it loses nothing. */
|
|
19
|
+
export function losslessNumber(text) {
|
|
20
|
+
if (!NUMERIC_LITERAL.test(text))
|
|
21
|
+
return undefined;
|
|
22
|
+
const parsed = Number(text);
|
|
23
|
+
return Number.isFinite(parsed) && String(parsed) === text
|
|
24
|
+
? parsed
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
/** `[]` or `{}` written as a value: the empty collection it denotes. */
|
|
28
|
+
export function emptyCollection(value) {
|
|
29
|
+
if (value === "[]")
|
|
30
|
+
return Object.freeze([]);
|
|
31
|
+
if (value === "{}")
|
|
32
|
+
return Object.freeze(Object.create(null));
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parses the inline value of a list key into items. `[a, "b, c"]` is a flow
|
|
37
|
+
* list; anything else is a single item. Items go through `parseItem`.
|
|
38
|
+
*/
|
|
39
|
+
export function parseInlineList(value, parseItem) {
|
|
40
|
+
if (!(value.startsWith("[") && value.endsWith("]"))) {
|
|
41
|
+
return Object.freeze([parseItem(value)]);
|
|
42
|
+
}
|
|
43
|
+
const items = [];
|
|
44
|
+
let current = "";
|
|
45
|
+
let quote;
|
|
46
|
+
for (const ch of value.slice(1, -1)) {
|
|
47
|
+
if (quote) {
|
|
48
|
+
if (ch === quote)
|
|
49
|
+
quote = undefined;
|
|
50
|
+
current += ch;
|
|
51
|
+
}
|
|
52
|
+
else if (ch === '"' || ch === "'") {
|
|
53
|
+
quote = ch;
|
|
54
|
+
current += ch;
|
|
55
|
+
}
|
|
56
|
+
else if (ch === ",") {
|
|
57
|
+
items.push(current);
|
|
58
|
+
current = "";
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
current += ch;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
items.push(current);
|
|
65
|
+
return Object.freeze(items
|
|
66
|
+
.map((item) => item.trim())
|
|
67
|
+
.filter((item) => item !== "")
|
|
68
|
+
.map(parseItem));
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=frontmatter.values.js.map
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Markdown output generation for documentation documents.
|
|
3
3
|
*/
|
|
4
4
|
import { formatScalar } from "../frontmatter/frontmatter.serializer.js";
|
|
5
|
+
import { escapeHtmlText } from "../utils/utils.href.js";
|
|
5
6
|
import { nodesToMarkdown } from "./generatorMarkdownNodes.js";
|
|
6
7
|
/**
|
|
7
8
|
* Generates a markdown string from a document.
|
|
@@ -51,7 +52,7 @@ export function generateMarkdown(document, options = {}) {
|
|
|
51
52
|
if (document.deprecatedMessage) {
|
|
52
53
|
lines.push(">");
|
|
53
54
|
for (const line of document.deprecatedMessage.split(/\r?\n/)) {
|
|
54
|
-
lines.push(`> ${line}`);
|
|
55
|
+
lines.push(`> ${escapeHtmlText(line)}`);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
lines.push("");
|
|
@@ -84,8 +85,12 @@ function contentToMarkdown(content, sanitizer) {
|
|
|
84
85
|
return nodesToMarkdown(content.nodes);
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Collapses newlines so a value cannot break out of its line, then HTML-escapes
|
|
90
|
+
* it for the same reason the structured `quote` and `callout` nodes do: the
|
|
91
|
+
* value can come from untrusted document JSON.
|
|
92
|
+
*/
|
|
88
93
|
function escapeInline(value) {
|
|
89
|
-
return value.replace(/\r?\n/g, " ");
|
|
94
|
+
return escapeHtmlText(value.replace(/\r?\n/g, " "));
|
|
90
95
|
}
|
|
91
96
|
//# sourceMappingURL=generatorMarkdown.core.js.map
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every value is escaped for the position it is written to, so
|
|
5
5
|
* untrusted node content cannot break out of a table, code fence,
|
|
6
|
-
* heading or link.
|
|
6
|
+
* heading or link. Text outside code blocks is HTML-escaped (`&`, `<`,
|
|
7
|
+
* `>`), and a link whose href uses a scheme outside `SAFE_LINK_SCHEMES`
|
|
8
|
+
* (http, https, mailto, tel, ftp, ftps) is written as plain text, so a structured document from
|
|
9
|
+
* untrusted JSON cannot produce live HTML or a `javascript:` link.
|
|
7
10
|
*/
|
|
8
11
|
import type { DocumentationNode } from "../docsTypes/index.js";
|
|
9
12
|
/**
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every value is escaped for the position it is written to, so
|
|
5
5
|
* untrusted node content cannot break out of a table, code fence,
|
|
6
|
-
* heading or link.
|
|
6
|
+
* heading or link. Text outside code blocks is HTML-escaped (`&`, `<`,
|
|
7
|
+
* `>`), and a link whose href uses a scheme outside `SAFE_LINK_SCHEMES`
|
|
8
|
+
* (http, https, mailto, tel, ftp, ftps) is written as plain text, so a structured document from
|
|
9
|
+
* untrusted JSON cannot produce live HTML or a `javascript:` link.
|
|
7
10
|
*/
|
|
11
|
+
import { escapeHtmlText, isSafeLinkHref } from "../utils/utils.href.js";
|
|
8
12
|
const CALLOUT_LABELS = new Map([
|
|
9
13
|
["note", "NOTE"],
|
|
10
14
|
["warning", "WARNING"],
|
|
@@ -27,7 +31,7 @@ export function nodesToMarkdown(nodes) {
|
|
|
27
31
|
break;
|
|
28
32
|
}
|
|
29
33
|
case "paragraph":
|
|
30
|
-
lines.push(node.value);
|
|
34
|
+
lines.push(escapeHtmlText(node.value));
|
|
31
35
|
lines.push("");
|
|
32
36
|
break;
|
|
33
37
|
case "code": {
|
|
@@ -46,7 +50,9 @@ export function nodesToMarkdown(nodes) {
|
|
|
46
50
|
lines.push("");
|
|
47
51
|
break;
|
|
48
52
|
case "link":
|
|
49
|
-
lines.push(
|
|
53
|
+
lines.push(isSafeLinkHref(node.href)
|
|
54
|
+
? `[${escapeLinkText(node.value)}](${escapeLinkHref(node.href)})`
|
|
55
|
+
: escapeLinkText(node.value));
|
|
50
56
|
lines.push("");
|
|
51
57
|
break;
|
|
52
58
|
case "table": {
|
|
@@ -60,7 +66,7 @@ export function nodesToMarkdown(nodes) {
|
|
|
60
66
|
}
|
|
61
67
|
case "quote":
|
|
62
68
|
for (const line of node.value.split(/\r?\n/)) {
|
|
63
|
-
lines.push(`> ${line}`);
|
|
69
|
+
lines.push(`> ${escapeHtmlText(line)}`);
|
|
64
70
|
}
|
|
65
71
|
lines.push("");
|
|
66
72
|
break;
|
|
@@ -71,9 +77,9 @@ export function nodesToMarkdown(nodes) {
|
|
|
71
77
|
const label = CALLOUT_LABELS.get(node.kind) ??
|
|
72
78
|
singleLine(String(node.kind)).toUpperCase();
|
|
73
79
|
const [first = "", ...rest] = node.value.split(/\r?\n/);
|
|
74
|
-
lines.push(`> **${label}:** ${first}`);
|
|
80
|
+
lines.push(`> **${label}:** ${escapeHtmlText(first)}`);
|
|
75
81
|
for (const line of rest) {
|
|
76
|
-
lines.push(`> ${line}`);
|
|
82
|
+
lines.push(`> ${escapeHtmlText(line)}`);
|
|
77
83
|
}
|
|
78
84
|
lines.push("");
|
|
79
85
|
break;
|
|
@@ -115,13 +121,14 @@ export function sanitizeLanguage(language) {
|
|
|
115
121
|
}
|
|
116
122
|
/** Escapes `|` and newlines so a value stays inside its table cell. */
|
|
117
123
|
export function tableCell(value) {
|
|
118
|
-
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|")
|
|
124
|
+
return escapeHtmlText(value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"))
|
|
125
|
+
.replace(/\r?\n/g, "<br>");
|
|
119
126
|
}
|
|
120
127
|
function singleLine(value) {
|
|
121
|
-
return value.replace(/\r?\n/g, " ");
|
|
128
|
+
return escapeHtmlText(value.replace(/\r?\n/g, " "));
|
|
122
129
|
}
|
|
123
130
|
function listItem(value) {
|
|
124
|
-
return value.replace(/\r?\n/g, "\n ");
|
|
131
|
+
return escapeHtmlText(value).replace(/\r?\n/g, "\n ");
|
|
125
132
|
}
|
|
126
133
|
function escapeLinkText(value) {
|
|
127
134
|
// Backslash first: escaping it after the brackets would turn the escapes we
|
|
@@ -132,7 +139,9 @@ function escapeLinkText(value) {
|
|
|
132
139
|
.replace(/\]/g, "\\]");
|
|
133
140
|
}
|
|
134
141
|
function escapeLinkHref(href) {
|
|
135
|
-
|
|
142
|
+
// `&` is escaped so a character reference cannot spell out a scheme
|
|
143
|
+
// (`javascript:`) once the renderer decodes it.
|
|
144
|
+
const clean = href.replace(/[\r\n]/g, "").replace(/&/g, "&");
|
|
136
145
|
return /[\s()]/.test(clean) ? `<${clean.replace(/[<>]/g, "")}>` : clean;
|
|
137
146
|
}
|
|
138
147
|
//# sourceMappingURL=generatorMarkdownNodes.js.map
|
|
@@ -11,13 +11,18 @@ export type DocumentVisibilityFilter = "SERVER" | "CLIENT" | "ALL";
|
|
|
11
11
|
export interface GetAllOptions {
|
|
12
12
|
/**
|
|
13
13
|
* Which documents to return. `"CLIENT"` returns documents whose
|
|
14
|
-
* `visibility` is `"CLIENT"` or unset; `"SERVER"` returns
|
|
15
|
-
*
|
|
14
|
+
* `visibility` is exactly `"CLIENT"` or unset; `"SERVER"` returns every
|
|
15
|
+
* other document, including unrecognised values (fail closed); `"ALL"`
|
|
16
|
+
* (default) returns everything.
|
|
16
17
|
*/
|
|
17
18
|
readonly visibility?: DocumentVisibilityFilter;
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
21
|
* Returns true when `document` should be included for the given filter.
|
|
22
|
+
*
|
|
23
|
+
* Fails closed: only an unset `visibility` or exactly `"CLIENT"` is client
|
|
24
|
+
* visible. Any other value (`"server"`, a typo, a non-string from parsed
|
|
25
|
+
* frontmatter) is treated as server-only, so it never reaches a client index.
|
|
21
26
|
*/
|
|
22
27
|
export declare function matchesVisibility(document: DocumentationDocument, filter?: DocumentVisibilityFilter): boolean;
|
|
23
28
|
/**
|
|
@@ -8,13 +8,17 @@ import { DuplicateDocumentError } from "@zudojs/errors";
|
|
|
8
8
|
import { deepFreezeClone } from "../utils/utils.freeze.js";
|
|
9
9
|
/**
|
|
10
10
|
* Returns true when `document` should be included for the given filter.
|
|
11
|
+
*
|
|
12
|
+
* Fails closed: only an unset `visibility` or exactly `"CLIENT"` is client
|
|
13
|
+
* visible. Any other value (`"server"`, a typo, a non-string from parsed
|
|
14
|
+
* frontmatter) is treated as server-only, so it never reaches a client index.
|
|
11
15
|
*/
|
|
12
16
|
export function matchesVisibility(document, filter = "ALL") {
|
|
13
17
|
if (filter === "ALL")
|
|
14
18
|
return true;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return
|
|
19
|
+
const visibility = document.visibility;
|
|
20
|
+
const clientVisible = visibility === undefined || visibility === "CLIENT";
|
|
21
|
+
return filter === "CLIENT" ? clientVisible : !clientVisible;
|
|
18
22
|
}
|
|
19
23
|
/**
|
|
20
24
|
* Registry for managing documentation documents.
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Utility helpers for document ID normalization, link resolution, and markdown parsing.
|
|
5
5
|
*/
|
|
6
|
-
export { isValidDocumentId, normalizeDocumentId, documentIdFromPath, resolveDocumentLink, stripLinkDecorations, stripFencedCodeBlocks, extractTitleFromMarkdown, extractHeadings,
|
|
6
|
+
export { isValidDocumentId, normalizeDocumentId, documentIdFromPath, resolveDocumentLink, stripLinkDecorations, stripFencedCodeBlocks, extractTitleFromMarkdown, extractHeadings, } from "./utils.helper.js";
|
|
7
|
+
export { stripMarkdown } from "./utils.markdownText.js";
|
|
7
8
|
export { deepFreeze, deepFreezeClone } from "./utils.freeze.js";
|
|
8
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/utils/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Utility helpers for document ID normalization, link resolution, and markdown parsing.
|
|
5
5
|
*/
|
|
6
|
-
export { isValidDocumentId, normalizeDocumentId, documentIdFromPath, resolveDocumentLink, stripLinkDecorations, stripFencedCodeBlocks, extractTitleFromMarkdown, extractHeadings,
|
|
6
|
+
export { isValidDocumentId, normalizeDocumentId, documentIdFromPath, resolveDocumentLink, stripLinkDecorations, stripFencedCodeBlocks, extractTitleFromMarkdown, extractHeadings, } from "./utils.helper.js";
|
|
7
|
+
export { stripMarkdown } from "./utils.markdownText.js";
|
|
7
8
|
export { deepFreeze, deepFreezeClone } from "./utils.freeze.js";
|
|
8
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -50,8 +50,4 @@ export declare function extractHeadings(markdown: string): readonly {
|
|
|
50
50
|
level: number;
|
|
51
51
|
text: string;
|
|
52
52
|
}[];
|
|
53
|
-
/**
|
|
54
|
-
* Strips markdown formatting to plain text.
|
|
55
|
-
*/
|
|
56
|
-
export declare function stripMarkdown(markdown: string): string;
|
|
57
53
|
//# sourceMappingURL=utils.helper.d.ts.map
|
|
@@ -82,7 +82,7 @@ export function stripLinkDecorations(link) {
|
|
|
82
82
|
* headings and links inside them are not interpreted.
|
|
83
83
|
*/
|
|
84
84
|
export function stripFencedCodeBlocks(markdown) {
|
|
85
|
-
return markdown.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, "");
|
|
85
|
+
return markdown.replace(/^(`{3,}(?!`)|~{3,}(?!~))[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, "");
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
88
|
* Extracts the title from markdown content (first level-1 heading
|
|
@@ -114,56 +114,11 @@ export function extractHeadings(markdown) {
|
|
|
114
114
|
}
|
|
115
115
|
/** Trims a heading and removes ATX closing hashes (`# Title #`). */
|
|
116
116
|
function cleanHeadingText(text) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
* can leave a tag behind in text that is later rendered as HTML.
|
|
124
|
-
*/
|
|
125
|
-
function stripHtmlTags(text) {
|
|
126
|
-
let current = text;
|
|
127
|
-
let previous;
|
|
128
|
-
do {
|
|
129
|
-
previous = current;
|
|
130
|
-
current = current.replace(HTML_TAG, "");
|
|
131
|
-
} while (current !== previous);
|
|
132
|
-
return current;
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Strips markdown formatting to plain text.
|
|
136
|
-
*/
|
|
137
|
-
export function stripMarkdown(markdown) {
|
|
138
|
-
return (stripHtmlTags(
|
|
139
|
-
// fenced code blocks: keep the code, drop the fences
|
|
140
|
-
markdown.replace(/^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)^\1[^\n]*$/gm, "$2"))
|
|
141
|
-
// images before links
|
|
142
|
-
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
143
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
144
|
-
// headings
|
|
145
|
-
.replace(/^#{1,6}\s+/gm, "")
|
|
146
|
-
.replace(/\s+#+\s*$/gm, "")
|
|
147
|
-
// emphasis (bold before italic, non-greedy)
|
|
148
|
-
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
149
|
-
.replace(/__(.+?)__/g, "$1")
|
|
150
|
-
.replace(/\*(.+?)\*/g, "$1")
|
|
151
|
-
.replace(/(^|[^\w])_(.+?)_(?=[^\w]|$)/g, "$1$2")
|
|
152
|
-
.replace(/~~(.+?)~~/g, "$1")
|
|
153
|
-
// inline code
|
|
154
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
155
|
-
// blockquotes, list markers
|
|
156
|
-
.replace(/^\s*>\s?/gm, "")
|
|
157
|
-
.replace(/^\s*[-*+]\s+/gm, "")
|
|
158
|
-
.replace(/^\s*\d+\.\s+/gm, "")
|
|
159
|
-
// tables: drop separator rows, unpipe cells
|
|
160
|
-
.replace(/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/gm, "")
|
|
161
|
-
.replace(/^\s*\|/gm, "")
|
|
162
|
-
.replace(/\|\s*$/gm, "")
|
|
163
|
-
.replace(/\s*\|\s*/g, " ")
|
|
164
|
-
// horizontal rules
|
|
165
|
-
.replace(/^\s*([-*_]\s*){3,}$/gm, "")
|
|
166
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
167
|
-
.trim());
|
|
117
|
+
const trimmed = text.trim();
|
|
118
|
+
const withoutHashes = trimmed.replace(/(?<!#)#+$/, "");
|
|
119
|
+
const last = withoutHashes.at(-1);
|
|
120
|
+
return withoutHashes !== trimmed && (last === " " || last === "\t")
|
|
121
|
+
? withoutHashes.trim()
|
|
122
|
+
: trimmed;
|
|
168
123
|
}
|
|
169
124
|
//# sourceMappingURL=utils.helper.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-target and inline-text safety for generated markdown.
|
|
3
|
+
*
|
|
4
|
+
* Markdown renderers turn `[x](javascript:…)` into a live link and pass raw
|
|
5
|
+
* `<tag>` text through as HTML. Structured documents can come from untrusted
|
|
6
|
+
* JSON, so generated hrefs are limited to an allow-list of schemes and text
|
|
7
|
+
* is HTML-escaped.
|
|
8
|
+
*
|
|
9
|
+
* @module utils/utils.href
|
|
10
|
+
*/
|
|
11
|
+
/** Schemes a generated or validated link may use. Relative and `#` links need none. */
|
|
12
|
+
export declare const SAFE_LINK_SCHEMES: ReadonlySet<string>;
|
|
13
|
+
/**
|
|
14
|
+
* The scheme of a link target, lower-cased, or undefined for a relative,
|
|
15
|
+
* protocol-relative or fragment link. Whitespace and control characters are
|
|
16
|
+
* ignored first, as browsers do (`java\tscript:`, ` JavaScript:`).
|
|
17
|
+
*/
|
|
18
|
+
export declare function linkScheme(href: string): string | undefined;
|
|
19
|
+
/** Whether a link target is relative, a fragment, or uses an allow-listed scheme. */
|
|
20
|
+
export declare function isSafeLinkHref(href: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Escapes `&`, `<` and `>` so text renders literally instead of as HTML or
|
|
23
|
+
* as a character reference.
|
|
24
|
+
*/
|
|
25
|
+
export declare function escapeHtmlText(value: string): string;
|
|
26
|
+
//# sourceMappingURL=utils.href.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-target and inline-text safety for generated markdown.
|
|
3
|
+
*
|
|
4
|
+
* Markdown renderers turn `[x](javascript:…)` into a live link and pass raw
|
|
5
|
+
* `<tag>` text through as HTML. Structured documents can come from untrusted
|
|
6
|
+
* JSON, so generated hrefs are limited to an allow-list of schemes and text
|
|
7
|
+
* is HTML-escaped.
|
|
8
|
+
*
|
|
9
|
+
* @module utils/utils.href
|
|
10
|
+
*/
|
|
11
|
+
/** Schemes a generated or validated link may use. Relative and `#` links need none. */
|
|
12
|
+
export const SAFE_LINK_SCHEMES = new Set([
|
|
13
|
+
"http",
|
|
14
|
+
"https",
|
|
15
|
+
"mailto",
|
|
16
|
+
"tel",
|
|
17
|
+
"ftp",
|
|
18
|
+
"ftps",
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* The scheme of a link target, lower-cased, or undefined for a relative,
|
|
22
|
+
* protocol-relative or fragment link. Whitespace and control characters are
|
|
23
|
+
* ignored first, as browsers do (`java\tscript:`, ` JavaScript:`).
|
|
24
|
+
*/
|
|
25
|
+
export function linkScheme(href) {
|
|
26
|
+
const compact = href.replace(/[\u0000- \u007f]/g, "");
|
|
27
|
+
const match = /^([^/?#]*?):/.exec(compact);
|
|
28
|
+
return match ? (match[1] ?? "").toLowerCase() : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Whether a link target is relative, a fragment, or uses an allow-listed scheme. */
|
|
31
|
+
export function isSafeLinkHref(href) {
|
|
32
|
+
const scheme = linkScheme(href);
|
|
33
|
+
return scheme === undefined || SAFE_LINK_SCHEMES.has(scheme);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Escapes `&`, `<` and `>` so text renders literally instead of as HTML or
|
|
37
|
+
* as a character reference.
|
|
38
|
+
*/
|
|
39
|
+
export function escapeHtmlText(value) {
|
|
40
|
+
return value
|
|
41
|
+
.replace(/&/g, "&")
|
|
42
|
+
.replace(/</g, "<")
|
|
43
|
+
.replace(/>/g, ">");
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=utils.href.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → plain text, in linear time.
|
|
3
|
+
*
|
|
4
|
+
* `stripMarkdown` is exported for building search excerpts, so it sees
|
|
5
|
+
* untrusted input. The previous version used `\s` inside `m`-flag line
|
|
6
|
+
* regexes; there `\s` also matches `\n`, and adjacent `\s*` runs let the
|
|
7
|
+
* engine split one whitespace run in O(n²) ways from each of n line starts
|
|
8
|
+
* (2 KB of newlines took ~6 s). Every pattern here is unambiguous: line
|
|
9
|
+
* patterns use `[ \t]` only, no two quantifiers compete for the same
|
|
10
|
+
* characters, and bracketed spans cannot contain their own opener, so each
|
|
11
|
+
* start position scans only up to the next opener.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Removes HTML tags until the result stops changing. A single pass is not
|
|
15
|
+
* enough: stripping the inner tag of `<<b>b>x` re-forms `<b>x`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function stripHtmlTags(text: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Strips markdown formatting to plain text.
|
|
20
|
+
*/
|
|
21
|
+
export declare function stripMarkdown(markdown: string): string;
|
|
22
|
+
//# sourceMappingURL=utils.markdownText.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → plain text, in linear time.
|
|
3
|
+
*
|
|
4
|
+
* `stripMarkdown` is exported for building search excerpts, so it sees
|
|
5
|
+
* untrusted input. The previous version used `\s` inside `m`-flag line
|
|
6
|
+
* regexes; there `\s` also matches `\n`, and adjacent `\s*` runs let the
|
|
7
|
+
* engine split one whitespace run in O(n²) ways from each of n line starts
|
|
8
|
+
* (2 KB of newlines took ~6 s). Every pattern here is unambiguous: line
|
|
9
|
+
* patterns use `[ \t]` only, no two quantifiers compete for the same
|
|
10
|
+
* characters, and bracketed spans cannot contain their own opener, so each
|
|
11
|
+
* start position scans only up to the next opener.
|
|
12
|
+
*/
|
|
13
|
+
/** HTML tags. `[^<>\n]` stops at the next `<`, so `<a<a<a…` stays linear. */
|
|
14
|
+
const HTML_TAG = /<\/?[a-zA-Z][^<>\n]*>/g;
|
|
15
|
+
/**
|
|
16
|
+
* Removes HTML tags until the result stops changing. A single pass is not
|
|
17
|
+
* enough: stripping the inner tag of `<<b>b>x` re-forms `<b>x`.
|
|
18
|
+
*/
|
|
19
|
+
export function stripHtmlTags(text) {
|
|
20
|
+
let current = text;
|
|
21
|
+
let previous;
|
|
22
|
+
do {
|
|
23
|
+
previous = current;
|
|
24
|
+
current = current.replace(HTML_TAG, "");
|
|
25
|
+
} while (current !== previous);
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Drops the outer pipes of table rows, then turns each inner separator (a
|
|
30
|
+
* run of spaces/tabs/pipes that holds a pipe) into one space.
|
|
31
|
+
*/
|
|
32
|
+
function unpipeCells(text) {
|
|
33
|
+
return text
|
|
34
|
+
.replace(/^[ \t]*\|[ \t]*/gm, "")
|
|
35
|
+
.replace(/\|[ \t]*$/gm, "")
|
|
36
|
+
.replace(/[ \t|]+/g, (run) => (run.includes("|") ? " " : run));
|
|
37
|
+
}
|
|
38
|
+
/** A table separator row such as `| --- | :-: |`. */
|
|
39
|
+
const TABLE_SEPARATOR = /^[ \t]*(?:\|[ \t]*)?:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)*(?:\|[ \t]*)?$/gm;
|
|
40
|
+
/**
|
|
41
|
+
* Closing ATX hashes (`# Title ##`). The lookbehind anchors each attempt at
|
|
42
|
+
* the start of a hash run, so a long run not at line end is scanned once.
|
|
43
|
+
*/
|
|
44
|
+
function stripClosingHashes(text) {
|
|
45
|
+
return text.replace(/(?<!#)#+[ \t]*$/gm, (hashes, offset, whole) => {
|
|
46
|
+
const before = whole[offset - 1];
|
|
47
|
+
return before === " " || before === "\t" ? "" : hashes;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Trailing spaces/tabs on each line, removed with a single linear pass. */
|
|
51
|
+
function trimLineEnds(text) {
|
|
52
|
+
return text
|
|
53
|
+
.split("\n")
|
|
54
|
+
.map((line) => line.trimEnd())
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Strips markdown formatting to plain text.
|
|
59
|
+
*/
|
|
60
|
+
export function stripMarkdown(markdown) {
|
|
61
|
+
const withoutFences = markdown.replace(/^(`{3,}(?!`)|~{3,}(?!~))[^\n]*\n([\s\S]*?)^\1[^\n]*$/gm, "$2");
|
|
62
|
+
const text = stripHtmlTags(withoutFences)
|
|
63
|
+
// images before links; text and target cannot contain their own opener
|
|
64
|
+
.replace(/!\[([^[\]\n]*)\]\([^()\n]*\)/g, "$1")
|
|
65
|
+
.replace(/\[([^[\]\n]+)\]\([^()\n]+\)/g, "$1")
|
|
66
|
+
// headings
|
|
67
|
+
.replace(/^#{1,6}[ \t]+/gm, "");
|
|
68
|
+
return trimLineEnds(unpipeCells(stripClosingHashes(text)
|
|
69
|
+
// emphasis (bold before italic); spans cannot hold their delimiter
|
|
70
|
+
.replace(/\*\*((?:[^*\n]|\*(?!\*))+)\*\*/g, "$1")
|
|
71
|
+
.replace(/__((?:[^_\n]|_(?!_))+)__/g, "$1")
|
|
72
|
+
.replace(/\*([^*\n]+)\*/g, "$1")
|
|
73
|
+
.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/gm, "$1$2")
|
|
74
|
+
.replace(/~~([^~\n]+)~~/g, "$1")
|
|
75
|
+
// inline code
|
|
76
|
+
.replace(/`([^`\n]+)`/g, "$1")
|
|
77
|
+
// blockquotes, list markers
|
|
78
|
+
.replace(/^[ \t]*>[ \t]?/gm, "")
|
|
79
|
+
.replace(/^[ \t]*[-*+][ \t]+/gm, "")
|
|
80
|
+
.replace(/^[ \t]*\d+\.[ \t]+/gm, "")
|
|
81
|
+
// tables: drop separator rows, then unpipe cells
|
|
82
|
+
.replace(TABLE_SEPARATOR, "")
|
|
83
|
+
// horizontal rules
|
|
84
|
+
.replace(/^[ \t]*(?:[-*_][ \t]*){3,}$/gm, "")))
|
|
85
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
86
|
+
.trim();
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=utils.markdownText.js.map
|
|
@@ -18,7 +18,9 @@ export interface ValidateLinksOptions {
|
|
|
18
18
|
* Validates internal links in a document's markdown content and in
|
|
19
19
|
* structured `link` nodes.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* A target whose scheme is not allowed (http, https, mailto, tel, ftp, ftps).(`javascript:`,
|
|
22
|
+
* `data:`, `vbscript:` …) is reported as an `UNSAFE_LINK` error.
|
|
23
|
+
* Skipped (never reported): images, anchors (`#…`), other targets with a
|
|
22
24
|
* URL scheme or `//` prefix, links inside fenced or inline code.
|
|
23
25
|
* Relative targets (`./x`, `../x`, `/x`) are resolved against the
|
|
24
26
|
* document ID with `resolveDocumentLink`; bare targets are looked up
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Validates internal links within markdown and structured documentation.
|
|
3
3
|
*/
|
|
4
4
|
import { resolveDocumentLink, stripFencedCodeBlocks, stripLinkDecorations, } from "../utils/utils.helper.js";
|
|
5
|
+
import { isSafeLinkHref } from "../utils/utils.href.js";
|
|
5
6
|
import { toValidationResult, } from "./validator.types.js";
|
|
6
7
|
/** Default maximum markdown length that is scanned for links. */
|
|
7
8
|
export const DEFAULT_MAX_LINK_SCAN_LENGTH = 100_000;
|
|
@@ -9,15 +10,23 @@ export const DEFAULT_MAX_LINK_SCAN_LENGTH = 100_000;
|
|
|
9
10
|
* Matches `[text](target)` and `` links. Group 1 is the
|
|
10
11
|
* optional image bang, group 2 the target (optionally `<…>` wrapped
|
|
11
12
|
* and followed by a `"title"`).
|
|
13
|
+
*
|
|
14
|
+
* Linear by construction: link text cannot contain `[`/`]` and a target
|
|
15
|
+
* cannot contain `(`/`)`, so each start scans only to the next opener; the
|
|
16
|
+
* target is non-empty, so leading whitespace is never split between two
|
|
17
|
+
* quantifiers; and whitespace is `[ \t]` (the old `\s` ran across lines).
|
|
18
|
+
* The old pattern took ~30 s on a 99 KB run of `[`.
|
|
12
19
|
*/
|
|
13
|
-
const LINK_PATTERN = /(!?)\[[
|
|
20
|
+
const LINK_PATTERN = /(!?)\[[^[\]\n]*\]\((?:[ \t]*(<[^<>\n]*>|[^()\s<>]+)(?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'))?)?[ \t]*\)/g;
|
|
14
21
|
/** `scheme:` (mailto:, ftp:, http:) or protocol-relative `//`. */
|
|
15
22
|
const EXTERNAL_PATTERN = /^([a-zA-Z][a-zA-Z0-9+.-]*:|\/\/)/;
|
|
16
23
|
/**
|
|
17
24
|
* Validates internal links in a document's markdown content and in
|
|
18
25
|
* structured `link` nodes.
|
|
19
26
|
*
|
|
20
|
-
*
|
|
27
|
+
* A target whose scheme is not allowed (http, https, mailto, tel, ftp, ftps).(`javascript:`,
|
|
28
|
+
* `data:`, `vbscript:` …) is reported as an `UNSAFE_LINK` error.
|
|
29
|
+
* Skipped (never reported): images, anchors (`#…`), other targets with a
|
|
21
30
|
* URL scheme or `//` prefix, links inside fenced or inline code.
|
|
22
31
|
* Relative targets (`./x`, `../x`, `/x`) are resolved against the
|
|
23
32
|
* document ID with `resolveDocumentLink`; bare targets are looked up
|
|
@@ -64,6 +73,15 @@ function checkTarget(rawTarget, document, registeredIds, issues) {
|
|
|
64
73
|
const target = rawTarget.trim();
|
|
65
74
|
if (target === "" || target.startsWith("#"))
|
|
66
75
|
return;
|
|
76
|
+
if (!isSafeLinkHref(target)) {
|
|
77
|
+
issues.push({
|
|
78
|
+
severity: "error",
|
|
79
|
+
code: "UNSAFE_LINK",
|
|
80
|
+
message: `Document "${document.id}" links to "${rawTarget}", whose scheme is not allowed (http, https, mailto, tel, ftp, ftps).`,
|
|
81
|
+
documentId: document.id,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
67
85
|
if (EXTERNAL_PATTERN.test(target))
|
|
68
86
|
return;
|
|
69
87
|
const stripped = stripLinkDecorations(target);
|
|
@@ -85,8 +103,12 @@ function checkTarget(rawTarget, document, registeredIds, issues) {
|
|
|
85
103
|
documentId: document.id,
|
|
86
104
|
});
|
|
87
105
|
}
|
|
88
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Blanks out inline code spans so links inside them are ignored. The
|
|
108
|
+
* lookarounds anchor each attempt at a whole backtick run, so a long run is
|
|
109
|
+
* not re-scanned from every position inside it.
|
|
110
|
+
*/
|
|
89
111
|
function stripInlineCode(markdown) {
|
|
90
|
-
return markdown.replace(/(`+)[
|
|
112
|
+
return markdown.replace(/(?<!`)(`+)(?![`\n])[\s\S]*?(?<!`)\1(?!`)/g, (m) => " ".repeat(m.length));
|
|
91
113
|
}
|
|
92
114
|
//# sourceMappingURL=validatorLinks.core.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/docs",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Documentation infrastructure with structured document model, registry, validation, navigation, and generation.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@zudojs/errors": "1.0
|
|
14
|
+
"@zudojs/errors": "1.2.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"typescript": "7.0.2",
|