@hraness/kb 0.17.1

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.
Files changed (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,861 @@
1
+ import { sanitizeTerminalText } from "./terminal.js";
2
+
3
+ /** Defuddle fields consumed after narrowing its JSON output. */
4
+ export type Article = {
5
+ content: string;
6
+ title: string | null;
7
+ author: string | null;
8
+ published: string | null;
9
+ description: string | null;
10
+ };
11
+
12
+ export const articleMetadataLimits = {
13
+ title: 2_048,
14
+ author: 1_024,
15
+ published: 256,
16
+ description: 8_192,
17
+ } as const;
18
+
19
+ const MAX_SLUG_INPUT_CODE_UNITS = 4_096;
20
+ const MAX_YAML_SCALAR_CODE_UNITS = 16_384;
21
+
22
+ function boundedPrefix(value: string, maxCodeUnits: number, marker = ""): string {
23
+ if (value.length <= maxCodeUnits) return value;
24
+ const markerLength = Math.min(marker.length, maxCodeUnits);
25
+ let end = maxCodeUnits - markerLength;
26
+ const finalCode = value.charCodeAt(end - 1);
27
+ if (finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1;
28
+ return value.slice(0, Math.max(0, end)) + marker.slice(0, markerLength);
29
+ }
30
+
31
+ function boundedMetadata(value: string | null, maxCodeUnits: number): string | null {
32
+ return value === null ? null : boundedPrefix(value, maxCodeUnits, "…");
33
+ }
34
+
35
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
36
+ typeof value === "object" && value !== null && !Array.isArray(value);
37
+ const nonEmptyString = (value: unknown, maxCodeUnits?: number): string | null => {
38
+ if (typeof value !== "string" || !/\S/u.test(value)) return null;
39
+ return maxCodeUnits === undefined ? value : boundedPrefix(value, maxCodeUnits, "…");
40
+ };
41
+
42
+ /** Narrow Defuddle JSON; return null when it contains no usable Markdown. */
43
+ export function parseArticle(parsed: unknown): Article | null {
44
+ if (!isRecord(parsed)) return null;
45
+ const content = nonEmptyString(parsed.content);
46
+ if (content === null) return null;
47
+ return {
48
+ content,
49
+ title: nonEmptyString(parsed.title, articleMetadataLimits.title),
50
+ author: nonEmptyString(parsed.author, articleMetadataLimits.author),
51
+ published: nonEmptyString(parsed.published, articleMetadataLimits.published),
52
+ description: nonEmptyString(parsed.description, articleMetadataLimits.description),
53
+ };
54
+ }
55
+
56
+ /** Collapse input to one lowercase, filesystem-safe path segment of at most 80 characters. */
57
+ export function slugify(value: string): string {
58
+ const normalized = boundedPrefix(value, MAX_SLUG_INPUT_CODE_UNITS)
59
+ .normalize("NFKC")
60
+ .toLowerCase()
61
+ .replace(/['’]/g, "")
62
+ .replace(/[^\p{Letter}\p{Number}]+/gu, "-")
63
+ .replace(/^-+|-+$/g, "")
64
+ .replace(/-+$/g, "");
65
+ let end = 0;
66
+ let characters = 0;
67
+ for (const character of normalized) {
68
+ if (characters === 80) break;
69
+ end += character.length;
70
+ characters += 1;
71
+ }
72
+ return normalized.slice(0, end).replace(/-+$/g, "");
73
+ }
74
+
75
+ /** Encode a valid double-quoted, single-line YAML scalar. */
76
+ export function yamlString(value: string): string {
77
+ const sanitized = sanitizeTerminalText(boundedPrefix(value, MAX_YAML_SCALAR_CODE_UNITS, "…"));
78
+ const chunks: string[] = ['"'];
79
+ let unchangedStart = 0;
80
+ for (let cursor = 0; cursor < sanitized.length; cursor += 1) {
81
+ const character = sanitized[cursor] ?? "";
82
+ const codePoint = sanitized.charCodeAt(cursor);
83
+ let replacement: string | null = null;
84
+ if (character === "\\") replacement = "\\\\";
85
+ else if (character === '"') replacement = '\\"';
86
+ else if (character === "\n") replacement = "\\n";
87
+ else if (character === "\r") replacement = "\\r";
88
+ else if (character === "\t") replacement = "\\t";
89
+ else if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
90
+ replacement = `\\x${codePoint.toString(16).padStart(2, "0")}`;
91
+ } else if (codePoint === 0x2028 || codePoint === 0x2029 || codePoint === 0xfeff) {
92
+ replacement = `\\u${codePoint.toString(16).padStart(4, "0")}`;
93
+ }
94
+ if (replacement === null) continue;
95
+ chunks.push(sanitized.slice(unchangedStart, cursor), replacement);
96
+ unchangedStart = cursor + 1;
97
+ }
98
+ chunks.push(sanitized.slice(unchangedStart), '"');
99
+ return chunks.join("");
100
+ }
101
+
102
+ /** Resolve an HTTP target and reject data, fragment, non-web, and invalid values. */
103
+ export function resolveRemote(source: string, base: URL): URL | null {
104
+ if (
105
+ source.length > MAX_REMOTE_SOURCE_CODE_UNITS
106
+ || source.startsWith("data:")
107
+ || source.startsWith("#")
108
+ ) return null;
109
+ try {
110
+ const url = new URL(source, base);
111
+ return (url.protocol === "http:" || url.protocol === "https:")
112
+ && url.href.length <= MAX_RESOLVED_URL_CODE_UNITS
113
+ ? url
114
+ : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function inertRemoteImageHref(url: URL): string {
121
+ const inert = new URL(url);
122
+ inert.username = "";
123
+ inert.password = "";
124
+ inert.search = "";
125
+ inert.hash = "";
126
+ return inert.href.replace(/\(/g, "%28").replace(/\)/g, "%29");
127
+ }
128
+
129
+ const balancedParentheses = /[^()\s]*(?:\([^()\s]*(?:\([^()\s]*(?:\([^()\s]*\)[^()\s]*)?\)[^()\s]*)?\)[^()\s]*)*/;
130
+ const markdownImage = new RegExp(
131
+ `!\\[([^\\]]*)\\]\\((?:<([^<>]*)>|(${balancedParentheses.source}))((?:\\s+"[^"]*")?)\\)`,
132
+ "g",
133
+ );
134
+ const htmlImage = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
135
+ const plainLink = new RegExp(
136
+ `(\\]\\()(?:<([^<>]*)>|(${balancedParentheses.source}))((?:\\s+"[^"]*")?)(\\))`,
137
+ "g",
138
+ );
139
+ const referenceImage = /!\[([^\]\r\n]*)\]\[([^\]\r\n]*)\]/g;
140
+ const referenceDefinition = /^([ \t]{0,3}\[([^\]\r\n]+)\]:[ \t]*)(?:<([^<>\r\n]*)>|(\S+))([^\r\n]*)$/gm;
141
+ const obsidianEmbed = /!\[\[([^\]\r\n]+)\]\]/g;
142
+
143
+ const safeMarkdownHtmlElements = new Set([
144
+ "abbr", "b", "bdi", "bdo", "blockquote", "br", "caption", "cite", "code", "col", "colgroup",
145
+ "dd", "del", "details", "dfn", "div", "dl", "dt", "em", "figcaption", "figure", "h1", "h2",
146
+ "h3", "h4", "h5", "h6", "hr", "i", "kbd", "li", "mark", "ol", "p", "pre", "q", "rp",
147
+ "rt", "ruby", "s", "samp", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody",
148
+ "td", "tfoot", "th", "thead", "time", "tr", "u", "ul", "var", "wbr",
149
+ ]);
150
+ const rawHtmlTag = /<\s*(?!https?:\/\/)(\/?)\s*([a-zA-Z][a-zA-Z0-9:-]*)\b[^>]*>/gi;
151
+ const unsafeMarkdownImage = /!\[([^\]]*)\]\(\s*<?\s*(?:data|javascript|vbscript|file|blob):[^\r\n]*\)/gi;
152
+ const protectedPlaceholder = /\0PROTECTED(\d+)\0/g;
153
+ const MAX_PROTECTED_MARKDOWN_SPANS = 4_096;
154
+ const MAX_INLINE_CODE_RUNS_PER_LINE = 4_096;
155
+ const MAX_IMAGE_CANDIDATES = 250_000;
156
+ const MAX_MARKUP_CANDIDATES = 50_000;
157
+ const MAX_INERT_OVERFLOW_PREVIEW_CODE_UNITS = 256 * 1024;
158
+ const MAX_IMAGE_SOURCES = 10_001;
159
+ const MAX_REFERENCE_LABELS = 10_001;
160
+ const MAX_REFERENCE_LABEL_CODE_UNITS = 1_024;
161
+ const MAX_REMOTE_SOURCE_CODE_UNITS = 8_192;
162
+ const MAX_RESOLVED_URL_CODE_UNITS = 16_384;
163
+ const MAX_IMAGE_ALT_CODE_UNITS = 2_048;
164
+
165
+ type FenceDelimiter = {
166
+ readonly marker: "`" | "~";
167
+ readonly length: number;
168
+ };
169
+
170
+ function openingFence(content: string, lineStart: number, lineEnd: number): FenceDelimiter | null {
171
+ let cursor = lineStart;
172
+ let indentation = 0;
173
+ while (indentation < 3 && content[cursor] === " ") {
174
+ cursor += 1;
175
+ indentation += 1;
176
+ }
177
+ const marker = content[cursor];
178
+ if (marker !== "`" && marker !== "~") return null;
179
+ const runStart = cursor;
180
+ while (cursor < lineEnd && content[cursor] === marker) cursor += 1;
181
+ const length = cursor - runStart;
182
+ if (length < 3) return null;
183
+ // CommonMark forbids a backtick in a backtick fence's info string.
184
+ const laterBacktick = marker === "`" ? content.indexOf("`", cursor) : -1;
185
+ if (laterBacktick !== -1 && laterBacktick < lineEnd) return null;
186
+ return { marker, length };
187
+ }
188
+
189
+ function isClosingFence(
190
+ content: string,
191
+ lineStart: number,
192
+ lineEnd: number,
193
+ delimiter: FenceDelimiter,
194
+ ): boolean {
195
+ let cursor = lineStart;
196
+ let indentation = 0;
197
+ while (indentation < 3 && content[cursor] === " ") {
198
+ cursor += 1;
199
+ indentation += 1;
200
+ }
201
+ const runStart = cursor;
202
+ while (cursor < lineEnd && content[cursor] === delimiter.marker) cursor += 1;
203
+ if (cursor - runStart < delimiter.length) return false;
204
+ while (cursor < lineEnd && (content[cursor] === " " || content[cursor] === "\t" || content[cursor] === "\r")) {
205
+ cursor += 1;
206
+ }
207
+ return cursor === lineEnd;
208
+ }
209
+
210
+ /** Protect fenced blocks with one forward line scan; malformed open fences fail closed. */
211
+ function protectMarkdownFences(content: string, protectedSpans: string[]): string | null {
212
+ const chunks: string[] = [];
213
+ let unchangedStart = 0;
214
+ let lineStart = 0;
215
+ let active: { readonly start: number; readonly delimiter: FenceDelimiter } | null = null;
216
+ while (lineStart < content.length) {
217
+ const newline = content.indexOf("\n", lineStart);
218
+ const lineEnd = newline === -1 ? content.length : newline;
219
+ if (active === null) {
220
+ // The regex predecessor also required a newline after an opening fence.
221
+ const delimiter = newline === -1 ? null : openingFence(content, lineStart, lineEnd);
222
+ if (delimiter !== null) active = { start: lineStart, delimiter };
223
+ } else if (isClosingFence(content, lineStart, lineEnd, active.delimiter)) {
224
+ if (protectedSpans.length >= MAX_PROTECTED_MARKDOWN_SPANS) return null;
225
+ chunks.push(content.slice(unchangedStart, active.start), `\0PROTECTED${protectedSpans.length}\0`);
226
+ protectedSpans.push(content.slice(active.start, lineEnd));
227
+ unchangedStart = lineEnd;
228
+ active = null;
229
+ }
230
+ if (newline === -1) break;
231
+ lineStart = newline + 1;
232
+ }
233
+ if (active !== null) return null;
234
+ if (chunks.length === 0) return content;
235
+ chunks.push(content.slice(unchangedStart));
236
+ return chunks.join("");
237
+ }
238
+
239
+ type BacktickRun = { readonly start: number; readonly end: number; readonly length: number };
240
+
241
+ /** Protect inline code with bounded delimiter tables rather than a backtracking backreference. */
242
+ function protectInlineCodeSpans(content: string, protectedSpans: string[]): string | null {
243
+ if (!content.includes("`")) return content;
244
+ const chunks: string[] = [];
245
+ let unchangedStart = 0;
246
+ let lineStart = 0;
247
+ while (lineStart < content.length) {
248
+ const newline = content.indexOf("\n", lineStart);
249
+ const lineEnd = newline === -1 ? content.length : newline;
250
+ const runs: BacktickRun[] = [];
251
+ let cursor = lineStart;
252
+ for (;;) {
253
+ const start = content.indexOf("`", cursor);
254
+ if (start === -1 || start >= lineEnd) break;
255
+ let end = start + 1;
256
+ while (end < lineEnd && content[end] === "`") end += 1;
257
+ if (runs.length >= MAX_INLINE_CODE_RUNS_PER_LINE) return null;
258
+ runs.push({ start, end, length: end - start });
259
+ cursor = end;
260
+ }
261
+
262
+ const nextSameLength: Array<number | undefined> = [];
263
+ nextSameLength.length = runs.length;
264
+ const laterByLength = new Map<number, number>();
265
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
266
+ const run = runs[index];
267
+ if (run === undefined) continue;
268
+ nextSameLength[index] = laterByLength.get(run.length);
269
+ laterByLength.set(run.length, index);
270
+ }
271
+ for (let index = 0; index < runs.length;) {
272
+ const closingIndex = nextSameLength[index];
273
+ if (closingIndex === undefined) {
274
+ index += 1;
275
+ continue;
276
+ }
277
+ const opening = runs[index];
278
+ const closing = runs[closingIndex];
279
+ if (opening === undefined || closing === undefined) {
280
+ index += 1;
281
+ continue;
282
+ }
283
+ if (protectedSpans.length >= MAX_PROTECTED_MARKDOWN_SPANS) return null;
284
+ chunks.push(content.slice(unchangedStart, opening.start), `\0PROTECTED${protectedSpans.length}\0`);
285
+ protectedSpans.push(content.slice(opening.start, closing.end));
286
+ unchangedStart = closing.end;
287
+ index = closingIndex + 1;
288
+ }
289
+
290
+ if (newline === -1) break;
291
+ lineStart = newline + 1;
292
+ }
293
+ if (chunks.length === 0) return content;
294
+ chunks.push(content.slice(unchangedStart));
295
+ return chunks.join("");
296
+ }
297
+
298
+ function restoreMarkdownSpans(content: string, protectedSpans: readonly string[]): string {
299
+ return content.replace(protectedPlaceholder, (_whole, index: string) => protectedSpans[Number(index)] ?? "");
300
+ }
301
+
302
+ function inertProtectedOverflow(content: string): string {
303
+ let previewEnd = Math.min(content.length, MAX_INERT_OVERFLOW_PREVIEW_CODE_UNITS);
304
+ const finalCode = content.charCodeAt(previewEnd - 1);
305
+ if (finalCode >= 0xd800 && finalCode <= 0xdbff) previewEnd -= 1;
306
+ const escaped = content.slice(0, previewEnd).replace(
307
+ /[&<>]/g,
308
+ (character) => character === "&" ? "&amp;" : character === "<" ? "&lt;" : "&gt;",
309
+ );
310
+ const omitted = content.length - previewEnd;
311
+ const omission = omitted === 0 ? "" : `\n\n[${omitted} source code unit(s) omitted]`;
312
+ return `*[Content rendered inert because the protected Markdown span limit was exceeded.]*\n\n<pre>\n${escaped}${omission}\n</pre>`;
313
+ }
314
+
315
+ function inertCandidateOverflow(content: string): string {
316
+ let previewEnd = Math.min(content.length, MAX_INERT_OVERFLOW_PREVIEW_CODE_UNITS);
317
+ const finalCode = content.charCodeAt(previewEnd - 1);
318
+ if (finalCode >= 0xd800 && finalCode <= 0xdbff) previewEnd -= 1;
319
+ const escaped = content.slice(0, previewEnd).replace(
320
+ /[&<>]/g,
321
+ (character) => character === "&" ? "&amp;" : character === "<" ? "&lt;" : "&gt;",
322
+ );
323
+ const omitted = content.length - previewEnd;
324
+ const omission = omitted === 0 ? "" : `\n\n[${omitted} source code unit(s) omitted]`;
325
+ return `*[Content rendered inert because a markup/image-candidate safety limit was exceeded.]*\n\n<pre>\n${escaped}${omission}\n</pre>`;
326
+ }
327
+
328
+ function escapeMarkdownLabel(value: string): string {
329
+ return boundedPrefix(value, MAX_IMAGE_ALT_CODE_UNITS, "…")
330
+ .replace(/\\/g, "\\\\")
331
+ .replace(/[[\]`]/g, "\\$&")
332
+ .replace(/</g, "&lt;")
333
+ .replace(/>/g, "&gt;")
334
+ .replace(/[\r\n]+/g, " ")
335
+ .trim();
336
+ }
337
+
338
+ function normalizedReferenceLabel(value: string): string | null {
339
+ if (value.length > MAX_REFERENCE_LABEL_CODE_UNITS) return null;
340
+ return value.replace(/\\([\\[\]])/g, "$1").replace(/\s+/g, " ").trim().toLowerCase();
341
+ }
342
+
343
+ type ReferenceLabelScan = {
344
+ readonly labels: ReadonlySet<string>;
345
+ readonly truncated: boolean;
346
+ readonly cardinalityExceeded: boolean;
347
+ };
348
+
349
+ function referenceLabels(content: string): ReferenceLabelScan {
350
+ const labels = new Set<string>();
351
+ let truncated = false;
352
+ let cardinalityExceeded = false;
353
+ referenceImage.lastIndex = 0;
354
+ try {
355
+ for (;;) {
356
+ const match = referenceImage.exec(content);
357
+ if (match === null) break;
358
+ const alt = match[1] ?? "";
359
+ const rawLabel = match[2] === "" ? alt : (match[2] ?? "");
360
+ const label = normalizedReferenceLabel(rawLabel);
361
+ if (label === null) {
362
+ truncated = true;
363
+ continue;
364
+ }
365
+ if (labels.has(label)) continue;
366
+ if (labels.size >= MAX_REFERENCE_LABELS) {
367
+ truncated = true;
368
+ cardinalityExceeded = true;
369
+ break;
370
+ }
371
+ labels.add(label);
372
+ }
373
+ } finally {
374
+ referenceImage.lastIndex = 0;
375
+ }
376
+ return { labels, truncated, cardinalityExceeded };
377
+ }
378
+
379
+ type ReferenceTargetScan = {
380
+ readonly targets: ReadonlyMap<string, string>;
381
+ readonly truncated: boolean;
382
+ };
383
+
384
+ /** Resolve only labels that an image reference actually uses; definitions elsewhere never enter the map. */
385
+ function referenceTargets(content: string, labels: ReadonlySet<string>): ReferenceTargetScan {
386
+ const targets = new Map<string, string>();
387
+ let truncated = false;
388
+ if (labels.size === 0) return { targets, truncated };
389
+ referenceDefinition.lastIndex = 0;
390
+ try {
391
+ for (;;) {
392
+ const match = referenceDefinition.exec(content);
393
+ if (match === null) break;
394
+ const rawLabel = match[2];
395
+ const target = match[3] ?? match[4];
396
+ if (rawLabel === undefined || target === undefined) continue;
397
+ const label = normalizedReferenceLabel(rawLabel);
398
+ if (label === null || !labels.has(label)) continue;
399
+ if (target.length > MAX_REMOTE_SOURCE_CODE_UNITS) {
400
+ // A later invalid duplicate must not leave an earlier definition active.
401
+ targets.delete(label);
402
+ truncated = true;
403
+ continue;
404
+ }
405
+ targets.set(label, target);
406
+ }
407
+ } finally {
408
+ referenceDefinition.lastIndex = 0;
409
+ }
410
+ return { targets, truncated };
411
+ }
412
+
413
+ /** Keep benign structural HTML but discard every attribute; expose unsupported tags as inert text. */
414
+ function sanitizeMarkdownHtml(content: string): string {
415
+ return content
416
+ .replace(/<!--[\s\S]*?-->/g, "")
417
+ .replace(rawHtmlTag, (_whole, closing: string, rawName: string) => {
418
+ const name = rawName.toLowerCase();
419
+ if (!safeMarkdownHtmlElements.has(name)) {
420
+ return `&lt;${closing === "/" ? "/" : ""}${name}&gt;`;
421
+ }
422
+ return `<${closing === "/" ? "/" : ""}${name}>`;
423
+ });
424
+ }
425
+
426
+ type ImageCandidateStructure = {
427
+ readonly safeForRegexScan: boolean;
428
+ readonly cardinalityExceeded: boolean;
429
+ };
430
+
431
+ /** Reject shared-suffix malformed image syntax before any regex can rescan that suffix. */
432
+ function imageCandidateStructure(content: string): ImageCandidateStructure {
433
+ let cursor = 0;
434
+ let count = 0;
435
+ for (;;) {
436
+ const start = content.indexOf("![", cursor);
437
+ if (start === -1) return { safeForRegexScan: true, cardinalityExceeded: false };
438
+ count += 1;
439
+ if (count > MAX_IMAGE_CANDIDATES) {
440
+ return { safeForRegexScan: false, cardinalityExceeded: true };
441
+ }
442
+ const altEnd = content.indexOf("]", start + 2);
443
+ const nestedImage = content.indexOf("![", start + 2);
444
+ if (altEnd === -1 || (nestedImage !== -1 && nestedImage < altEnd)) {
445
+ return { safeForRegexScan: false, cardinalityExceeded: false };
446
+ }
447
+
448
+ const targetMarker = content[altEnd + 1];
449
+ if (targetMarker === "(" || targetMarker === "[") {
450
+ const targetEnd = content.indexOf(targetMarker === "(" ? ")" : "]", altEnd + 2);
451
+ const nestedTargetImage = content.indexOf("![", altEnd + 2);
452
+ if (targetEnd === -1 || (nestedTargetImage !== -1 && nestedTargetImage < targetEnd)) {
453
+ return { safeForRegexScan: false, cardinalityExceeded: false };
454
+ }
455
+ cursor = targetEnd + 1;
456
+ continue;
457
+ }
458
+ cursor = targetMarker === "]" ? altEnd + 2 : altEnd + 1;
459
+ }
460
+ }
461
+
462
+ /** Bound malformed/nested tag and comment prefixes before HTML regex sanitization. */
463
+ function markupCandidateStructure(content: string): ImageCandidateStructure {
464
+ let cursor = 0;
465
+ let count = 0;
466
+ for (;;) {
467
+ const start = content.indexOf("<", cursor);
468
+ if (start === -1) return { safeForRegexScan: true, cardinalityExceeded: false };
469
+
470
+ if (content.startsWith("<!--", start)) {
471
+ count += 1;
472
+ if (count > MAX_MARKUP_CANDIDATES) {
473
+ return { safeForRegexScan: false, cardinalityExceeded: true };
474
+ }
475
+ const end = content.indexOf("-->", start + 4);
476
+ const nested = content.indexOf("<!--", start + 4);
477
+ if (end === -1) {
478
+ return nested === -1
479
+ ? { safeForRegexScan: true, cardinalityExceeded: false }
480
+ : { safeForRegexScan: false, cardinalityExceeded: false };
481
+ }
482
+ if (nested !== -1 && nested < end) {
483
+ return { safeForRegexScan: false, cardinalityExceeded: false };
484
+ }
485
+ cursor = end + 3;
486
+ continue;
487
+ }
488
+
489
+ let nameStart = start + 1;
490
+ while (content[nameStart] === " " || content[nameStart] === "\t") nameStart += 1;
491
+ if (content[nameStart] === "/") nameStart += 1;
492
+ while (content[nameStart] === " " || content[nameStart] === "\t") nameStart += 1;
493
+ const first = content.charCodeAt(nameStart);
494
+ if (!((first >= 0x41 && first <= 0x5a) || (first >= 0x61 && first <= 0x7a))) {
495
+ cursor = start + 1;
496
+ continue;
497
+ }
498
+ const schemePrefix = content.slice(nameStart, nameStart + 8).toLowerCase();
499
+ if (schemePrefix.startsWith("http://") || schemePrefix.startsWith("https://")) {
500
+ cursor = start + 1;
501
+ continue;
502
+ }
503
+
504
+ count += 1;
505
+ if (count > MAX_MARKUP_CANDIDATES) {
506
+ return { safeForRegexScan: false, cardinalityExceeded: true };
507
+ }
508
+ const end = content.indexOf(">", nameStart + 1);
509
+ const nested = content.indexOf("<", nameStart + 1);
510
+ if (end === -1) {
511
+ return nested === -1
512
+ ? { safeForRegexScan: true, cardinalityExceeded: false }
513
+ : { safeForRegexScan: false, cardinalityExceeded: false };
514
+ }
515
+ if (nested !== -1 && nested < end) {
516
+ return { safeForRegexScan: false, cardinalityExceeded: false };
517
+ }
518
+ cursor = end + 1;
519
+ }
520
+ }
521
+
522
+ export type ImageSourceScan = {
523
+ readonly sources: Set<string>;
524
+ /** True when a cardinality, code-span, label, or source-length safety limit was reached. */
525
+ readonly truncated: boolean;
526
+ /** True when continuing would require an attacker-controlled collection to grow further. */
527
+ readonly cardinalityExceeded: boolean;
528
+ /** True when regex rewriting must be replaced by a bounded inert preview. */
529
+ readonly requiresInertFallback: boolean;
530
+ };
531
+
532
+ /** Collect a bounded set of Markdown and residual HTML image targets in source-text form. */
533
+ export function scanImageSources(content: string, requestedMaximum = MAX_IMAGE_SOURCES): ImageSourceScan {
534
+ const maximum = Number.isSafeInteger(requestedMaximum)
535
+ ? Math.max(0, Math.min(requestedMaximum, MAX_IMAGE_SOURCES))
536
+ : MAX_IMAGE_SOURCES;
537
+ const protectedSpans: string[] = [];
538
+ const fenced = protectMarkdownFences(content, protectedSpans);
539
+ if (fenced === null) {
540
+ return { sources: new Set(), truncated: true, cardinalityExceeded: true, requiresInertFallback: true };
541
+ }
542
+ const searchable = protectInlineCodeSpans(fenced, protectedSpans);
543
+ if (searchable === null) {
544
+ return { sources: new Set(), truncated: true, cardinalityExceeded: true, requiresInertFallback: true };
545
+ }
546
+ const candidateStructure = imageCandidateStructure(searchable);
547
+ if (!candidateStructure.safeForRegexScan) {
548
+ return {
549
+ sources: new Set(),
550
+ truncated: true,
551
+ cardinalityExceeded: candidateStructure.cardinalityExceeded,
552
+ requiresInertFallback: true,
553
+ };
554
+ }
555
+ const markupStructure = markupCandidateStructure(searchable);
556
+ if (!markupStructure.safeForRegexScan) {
557
+ return {
558
+ sources: new Set(),
559
+ truncated: true,
560
+ cardinalityExceeded: markupStructure.cardinalityExceeded,
561
+ requiresInertFallback: true,
562
+ };
563
+ }
564
+ const sources = new Set<string>();
565
+ let truncated = false;
566
+ let cardinalityExceeded = false;
567
+ const addSource = (source: string | undefined): boolean => {
568
+ if (source === undefined || source === "") return true;
569
+ if (source.length > MAX_REMOTE_SOURCE_CODE_UNITS) {
570
+ truncated = true;
571
+ return true;
572
+ }
573
+ if (sources.has(source)) return true;
574
+ if (sources.size >= maximum) {
575
+ truncated = true;
576
+ cardinalityExceeded = true;
577
+ return false;
578
+ }
579
+ sources.add(source);
580
+ return true;
581
+ };
582
+
583
+ markdownImage.lastIndex = 0;
584
+ try {
585
+ for (;;) {
586
+ const match = markdownImage.exec(searchable);
587
+ if (match === null) break;
588
+ if (!addSource(match[2] ?? match[3])) break;
589
+ }
590
+ } finally {
591
+ markdownImage.lastIndex = 0;
592
+ }
593
+
594
+ if (!truncated) {
595
+ htmlImage.lastIndex = 0;
596
+ try {
597
+ for (;;) {
598
+ const match = htmlImage.exec(searchable);
599
+ if (match === null) break;
600
+ if (!addSource(match[1])) break;
601
+ }
602
+ } finally {
603
+ htmlImage.lastIndex = 0;
604
+ }
605
+ }
606
+
607
+ if (!truncated) {
608
+ const labelScan = referenceLabels(searchable);
609
+ const definitionScan = referenceTargets(searchable, labelScan.labels);
610
+ truncated ||= labelScan.truncated || definitionScan.truncated;
611
+ cardinalityExceeded ||= labelScan.cardinalityExceeded;
612
+ for (const label of labelScan.labels) {
613
+ if (!addSource(definitionScan.targets.get(label))) break;
614
+ }
615
+ }
616
+ return {
617
+ sources,
618
+ truncated,
619
+ cardinalityExceeded,
620
+ requiresInertFallback: cardinalityExceeded,
621
+ };
622
+ }
623
+
624
+ /** Compatibility wrapper with a hard 10,001-source ceiling. */
625
+ export function collectImageSources(content: string): Set<string> {
626
+ return scanImageSources(content).sources;
627
+ }
628
+
629
+ const extensionByContentType: Readonly<Record<string, string>> = {
630
+ "image/png": "png",
631
+ "image/jpeg": "jpg",
632
+ "image/gif": "gif",
633
+ "image/webp": "webp",
634
+ "image/svg+xml": "svg",
635
+ "image/avif": "avif",
636
+ };
637
+
638
+ /** Pick a sanitized, collision-free local filename and add it to `taken`. */
639
+ export function pickAssetName(url: URL, contentType: string | null, taken: Set<string>): string {
640
+ const encodedBase = url.pathname.split("/").filter(Boolean).pop() ?? "image";
641
+ let base: string;
642
+ try {
643
+ base = decodeURIComponent(encodedBase);
644
+ } catch {
645
+ base = encodedBase;
646
+ }
647
+ base = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^[-.]+|-+$/g, "") || "image";
648
+ const dot = base.lastIndexOf(".");
649
+ const stem = (dot > 0 ? base.slice(0, dot) : base).slice(0, 64);
650
+ const mediaType = contentType?.split(";")[0]?.trim();
651
+ const contentExtension = mediaType === undefined ? undefined : extensionByContentType[mediaType];
652
+ let extension = contentExtension ?? (dot > 0 ? base.slice(dot + 1).toLowerCase() : "");
653
+ if (extension === "" || !/^[a-z0-9]{1,8}$/.test(extension)) extension = "bin";
654
+
655
+ let name = `${stem}.${extension}`;
656
+ for (let suffix = 2; taken.has(name); suffix += 1) name = `${stem}-${suffix}.${extension}`;
657
+ taken.add(name);
658
+ return name;
659
+ }
660
+
661
+ export type RewriteContentResult = {
662
+ readonly content: string;
663
+ /** True when a safety bound omitted source content or replaced it with a bounded inert preview. */
664
+ readonly truncated: boolean;
665
+ };
666
+
667
+ export const CONTENT_REWRITE_TRUNCATION_WARNING =
668
+ "Content rewriting reached a safety limit; the final Markdown is truncated, so a complete extraction is reported as partial.";
669
+
670
+ /**
671
+ * Localize downloaded images, absolutize web targets, neutralize active/non-web
672
+ * targets and raw HTML attributes, and leave fenced examples untouched.
673
+ */
674
+ export function rewriteContentWithStatus(
675
+ content: string,
676
+ base: URL,
677
+ localBySource: ReadonlyMap<string, string>,
678
+ options: {
679
+ readonly remoteImages?: "embed" | "link";
680
+ readonly maxImageSources?: number;
681
+ } = {},
682
+ ): RewriteContentResult {
683
+ const sanitizedContent = sanitizeTerminalText(content);
684
+ const protectedSpans: string[] = [];
685
+ const fenced = protectMarkdownFences(sanitizedContent, protectedSpans);
686
+ if (fenced === null) return { content: inertProtectedOverflow(sanitizedContent), truncated: true };
687
+ let output = protectInlineCodeSpans(fenced, protectedSpans);
688
+ if (output === null) return { content: inertProtectedOverflow(sanitizedContent), truncated: true };
689
+ const imageSafety = scanImageSources(output, options.maxImageSources ?? MAX_IMAGE_SOURCES);
690
+ if (imageSafety.requiresInertFallback) {
691
+ return { content: inertCandidateOverflow(sanitizedContent), truncated: true };
692
+ }
693
+ let truncated = imageSafety.truncated;
694
+
695
+ output = output.replace(unsafeMarkdownImage, (_whole, alt: string) =>
696
+ `*[omitted unsafe image: ${escapeMarkdownLabel(alt) || "image"}]*`);
697
+ output = output.replace(obsidianEmbed, (_whole, target: string) =>
698
+ `*[omitted local embed: ${escapeMarkdownLabel(target) || "attachment"}]*`);
699
+
700
+ const labelScan = referenceLabels(output);
701
+ const definitionScan = referenceTargets(output, labelScan.labels);
702
+ const definitions = definitionScan.targets;
703
+ const referenceScanTruncated = labelScan.truncated || definitionScan.truncated;
704
+ output = output.replace(referenceImage, (_whole, alt: string, rawLabel: string) => {
705
+ const label = normalizedReferenceLabel(rawLabel === "" ? alt : rawLabel);
706
+ if (label === null) {
707
+ truncated = true;
708
+ return `*[omitted over-limit image reference: ${escapeMarkdownLabel(alt) || "image"}]*`;
709
+ }
710
+ const source = definitions.get(label);
711
+ if (source === undefined) return `*[omitted unresolved image reference: ${escapeMarkdownLabel(alt) || "image"}]*`;
712
+ const local = localBySource.get(source);
713
+ if (local !== undefined) return `![${alt}](${local})`;
714
+ const absolute = resolveRemote(source, base);
715
+ if (absolute === null) return `*[omitted unsafe image: ${escapeMarkdownLabel(alt) || "image"}]*`;
716
+ const target = inertRemoteImageHref(absolute);
717
+ return options.remoteImages === "embed"
718
+ ? `![${alt}](${target})`
719
+ : `[remote image: ${escapeMarkdownLabel(alt) || "image"}](${target})`;
720
+ });
721
+
722
+ const localPaths = new Set<string>();
723
+ for (const localPath of localBySource.values()) {
724
+ if (localPaths.size >= MAX_IMAGE_SOURCES) break;
725
+ localPaths.add(localPath);
726
+ }
727
+ output = output.replace(
728
+ markdownImage,
729
+ (whole, alt: string, bracketed: string | undefined, bare: string | undefined, title: string) => {
730
+ const source = bracketed ?? bare ?? "";
731
+ if (source.length > MAX_REMOTE_SOURCE_CODE_UNITS) {
732
+ truncated = true;
733
+ return `*[omitted over-limit image: ${escapeMarkdownLabel(alt) || "image"}]*`;
734
+ }
735
+ const local = localBySource.get(source);
736
+ if (local !== undefined) return `![${alt}](${local}${title})`;
737
+ if (localPaths.has(source)) return whole;
738
+ const absolute = resolveRemote(source, base);
739
+ return absolute === null
740
+ ? `*[omitted unsafe image: ${escapeMarkdownLabel(alt) || "image"}]*`
741
+ : options.remoteImages === "embed"
742
+ ? `![${alt}](${inertRemoteImageHref(absolute)}${title})`
743
+ : `[remote image: ${escapeMarkdownLabel(alt) || "image"}](${inertRemoteImageHref(absolute)}${title})`;
744
+ },
745
+ );
746
+ output = output.replace(htmlImage, (_whole, source: string) => {
747
+ if (source.length > MAX_REMOTE_SOURCE_CODE_UNITS) {
748
+ truncated = true;
749
+ return "*[omitted over-limit image]*";
750
+ }
751
+ const local = localBySource.get(source);
752
+ if (local !== undefined) return `![](${local})`;
753
+ if (localPaths.has(source)) return `![](${source})`;
754
+ const absolute = resolveRemote(source, base);
755
+ return absolute === null
756
+ ? "*[omitted unsafe image]*"
757
+ : options.remoteImages === "embed"
758
+ ? `![](${inertRemoteImageHref(absolute)})`
759
+ : `[remote image](${inertRemoteImageHref(absolute)})`;
760
+ });
761
+ output = output.replace(
762
+ plainLink,
763
+ (
764
+ whole,
765
+ open: string,
766
+ bracketed: string | undefined,
767
+ bare: string | undefined,
768
+ title: string,
769
+ close: string,
770
+ ) => {
771
+ const target = bracketed ?? bare ?? "";
772
+ if (target.length > MAX_REMOTE_SOURCE_CODE_UNITS) {
773
+ truncated = true;
774
+ return `${open}#${title}${close}`;
775
+ }
776
+ if (/^(https?:|mailto:|#)/i.test(target) || localPaths.has(target)) return whole;
777
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return `${open}#${title}${close}`;
778
+ const absolute = resolveRemote(target, base);
779
+ return absolute === null
780
+ ? `${open}#${title}${close}`
781
+ : `${open}${absolute.href.replace(/\(/g, "%28").replace(/\)/g, "%29")}${title}${close}`;
782
+ },
783
+ );
784
+ output = output.replace(
785
+ referenceDefinition,
786
+ (_whole, prefix: string, label: string, bracketed: string | undefined, bare: string | undefined, title: string) => {
787
+ const target = bracketed ?? bare ?? "";
788
+ if (label.length > MAX_REFERENCE_LABEL_CODE_UNITS || target.length > MAX_REMOTE_SOURCE_CODE_UNITS) {
789
+ truncated = true;
790
+ return `${prefix}#${title}`;
791
+ }
792
+ if (/^(?:https?:|mailto:|#)/i.test(target) || localPaths.has(target)) {
793
+ return `${prefix}${bracketed === undefined ? target : `<${target}>`}${title}`;
794
+ }
795
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return `${prefix}#${title}`;
796
+ const absolute = resolveRemote(target, base);
797
+ return `${prefix}${absolute === null ? "#" : absolute.href}${title}`;
798
+ },
799
+ );
800
+
801
+ output = sanitizeMarkdownHtml(output);
802
+ if (referenceScanTruncated) {
803
+ truncated = true;
804
+ output = "*[Some image references were omitted because capture safety limits were exceeded.]*\n\n" + output;
805
+ }
806
+
807
+ return { content: restoreMarkdownSpans(output, protectedSpans), truncated };
808
+ }
809
+
810
+ /** Compatibility wrapper for callers that only need rewritten Markdown. */
811
+ export function rewriteContent(
812
+ content: string,
813
+ base: URL,
814
+ localBySource: ReadonlyMap<string, string>,
815
+ options: {
816
+ readonly remoteImages?: "embed" | "link";
817
+ readonly maxImageSources?: number;
818
+ } = {},
819
+ ): string {
820
+ return rewriteContentWithStatus(content, base, localBySource, options).content;
821
+ }
822
+
823
+ /** Build one complete clipped article with YAML metadata and optional title heading. */
824
+ export function buildClipMarkdown(
825
+ article: Article,
826
+ options: {
827
+ slug: string;
828
+ sourceHref: string;
829
+ clipped: string;
830
+ content: string;
831
+ platform?: string;
832
+ captureStatus?: string;
833
+ captureMethod?: string;
834
+ captureScope?: string;
835
+ },
836
+ ): string {
837
+ const title = boundedMetadata(article.title, articleMetadataLimits.title);
838
+ const author = boundedMetadata(article.author, articleMetadataLimits.author);
839
+ const published = boundedMetadata(article.published, articleMetadataLimits.published);
840
+ const description = boundedMetadata(article.description, articleMetadataLimits.description);
841
+ const frontmatter = [
842
+ "---",
843
+ `title: ${yamlString(title ?? options.slug)}`,
844
+ `source: ${yamlString(options.sourceHref)}`,
845
+ ...(author === null ? [] : [`author: ${yamlString(author)}`]),
846
+ ...(published === null ? [] : [`published: ${yamlString(published)}`]),
847
+ ...(description === null ? [] : [`description: ${yamlString(description)}`]),
848
+ `clipped: ${yamlString(options.clipped)}`,
849
+ ...(options.platform === undefined ? [] : [`platform: ${yamlString(options.platform)}`]),
850
+ ...(options.captureStatus === undefined ? [] : [`capture_status: ${yamlString(options.captureStatus)}`]),
851
+ ...(options.captureMethod === undefined ? [] : [`capture_method: ${yamlString(options.captureMethod)}`]),
852
+ ...(options.captureScope === undefined ? [] : [`capture_scope: ${yamlString(options.captureScope)}`]),
853
+ "---",
854
+ "",
855
+ ].join("\n");
856
+ const headingTitle = title === null
857
+ ? null
858
+ : escapeMarkdownLabel(title).replace(/\s+/g, " ").trim();
859
+ const heading = headingTitle === null || headingTitle === "" ? "" : `# ${headingTitle}\n\n`;
860
+ return sanitizeTerminalText(frontmatter + heading + options.content.trimEnd() + "\n");
861
+ }