@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 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 `visibility` filtering
53
- - Document, link and navigation validation with a single `valid` rule (errors only)
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 ? [...options.tags] : undefined,
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, canonical numbers, booleans, null)
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, canonical numbers, booleans, null)
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
- const remainingContent = lines
69
- .slice(end + 1)
70
- .join("\n")
71
- .replace(/^\s*\n/, "")
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] = STRING_KEYS.has(entry.key)
145
- ? parseStringScalar(entry.value)
146
- : parseScalar(entry.value);
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
- * Only canonical numbers within the safe-integer range become numbers;
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
- if (CANONICAL_NUMBER.test(value)) {
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 (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(value))
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
- /** Collapses newlines so a value cannot break out of its line. */
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(`[${escapeLinkText(node.value)}](${escapeLinkHref(node.href)})`);
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, "\\|").replace(/\r?\n/g, "<br>");
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
- const clean = href.replace(/[\r\n]/g, "");
142
+ // `&` is escaped so a character reference cannot spell out a scheme
143
+ // (`&#106;avascript:`) once the renderer decodes it.
144
+ const clean = href.replace(/[\r\n]/g, "").replace(/&/g, "&amp;");
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 only
15
- * server-only documents; `"ALL"` (default) returns everything.
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
- if (filter === "SERVER")
16
- return document.visibility === "SERVER";
17
- return document.visibility !== "SERVER";
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.
@@ -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, stripMarkdown, } from "./utils.helper.js";
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
@@ -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, stripMarkdown, } from "./utils.helper.js";
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
- return text.replace(/\s+#+\s*$/, "").trim();
118
- }
119
- const HTML_TAG = /<\/?[a-zA-Z][^>\n]*>/g;
120
- /**
121
- * Removes HTML tags until the result stops changing. A single pass is not
122
- * enough: stripping the inner tag of `<<b>b>x` re-forms `<b>x`, so a lone pass
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, "&amp;")
42
+ .replace(/</g, "&lt;")
43
+ .replace(/>/g, "&gt;");
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
- * Skipped (never reported): images, anchors (`#…`), any target with a
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 `![alt](target)` 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 = /(!?)\[[^\]\n]*\]\(\s*(<[^>\n]*>|[^)\s]*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
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
- * Skipped (never reported): images, anchors (`#…`), any target with a
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
- /** Blanks out inline code spans so links inside them are ignored. */
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(/(`+)[^`\n][\s\S]*?\1/g, (m) => " ".repeat(m.length));
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.1",
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.1"
14
+ "@zudojs/errors": "1.2.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "typescript": "7.0.2",