@gmickel/gno 1.27.0 → 1.28.0

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 (83) hide show
  1. package/README.md +2 -2
  2. package/assets/skill/SKILL.md +26 -1
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +2 -2
  7. package/spec/cli.md +41 -4
  8. package/spec/db/schema.sql +12 -0
  9. package/spec/mcp.md +5 -0
  10. package/spec/output-schemas/ask.schema.json +125 -0
  11. package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
  12. package/spec/output-schemas/get.schema.json +94 -0
  13. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  14. package/spec/output-schemas/multi-get.schema.json +128 -0
  15. package/spec/output-schemas/record-import.schema.json +193 -0
  16. package/spec/output-schemas/search-result.schema.json +94 -0
  17. package/spec/output-schemas/search-results.schema.json +125 -0
  18. package/spec/project-profile.schema.json +66 -0
  19. package/src/app/context-format.ts +1 -1
  20. package/src/cli/commands/get.ts +12 -2
  21. package/src/cli/commands/index-cmd.ts +13 -0
  22. package/src/cli/commands/multi-get.ts +9 -2
  23. package/src/cli/commands/shared.ts +28 -0
  24. package/src/cli/commands/update.ts +5 -0
  25. package/src/cli/program.ts +4 -0
  26. package/src/config/project-profile.ts +2 -0
  27. package/src/config/types.ts +16 -0
  28. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  29. package/src/converters/adapters/browser-export/formats.ts +358 -0
  30. package/src/converters/adapters/email/adapter.ts +429 -0
  31. package/src/converters/adapters/email/html.ts +162 -0
  32. package/src/converters/adapters/email/mime.ts +454 -0
  33. package/src/converters/adapters/email/parameters.ts +77 -0
  34. package/src/converters/adapters/ical/adapter.ts +475 -0
  35. package/src/converters/adapters/ical/recurrence.ts +186 -0
  36. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  37. package/src/converters/adapters/jsonl/config.ts +105 -0
  38. package/src/converters/adapters/shared/html-text.ts +165 -0
  39. package/src/converters/adapters/shared/record-utils.ts +79 -0
  40. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  41. package/src/converters/adapters/transcript/adapter.ts +295 -0
  42. package/src/converters/adapters/transcript/json.ts +184 -0
  43. package/src/converters/adapters/transcript/model.ts +171 -0
  44. package/src/converters/adapters/transcript/text.ts +58 -0
  45. package/src/converters/adapters/transcript/timed.ts +152 -0
  46. package/src/converters/index.ts +11 -1
  47. package/src/converters/mime.ts +8 -0
  48. package/src/converters/pipeline.ts +15 -1
  49. package/src/converters/registry.ts +37 -1
  50. package/src/converters/types.ts +136 -0
  51. package/src/core/context-capsule-schema.ts +87 -0
  52. package/src/core/context-capsule.ts +20 -0
  53. package/src/core/context-evidence.ts +7 -1
  54. package/src/core/document-capabilities.ts +12 -0
  55. package/src/core/project-profile-apply-state.ts +5 -0
  56. package/src/core/project-profile.ts +4 -0
  57. package/src/core/record-metadata.ts +49 -0
  58. package/src/ingestion/record-adapter-canonical.ts +433 -0
  59. package/src/ingestion/record-adapter.ts +437 -0
  60. package/src/ingestion/record-container.ts +688 -0
  61. package/src/ingestion/record-path.ts +20 -0
  62. package/src/ingestion/record-sync.ts +70 -0
  63. package/src/ingestion/sync.ts +228 -36
  64. package/src/ingestion/types.ts +69 -1
  65. package/src/ingestion/walker.ts +31 -11
  66. package/src/mcp/tools/get.ts +10 -2
  67. package/src/mcp/tools/multi-get.ts +9 -2
  68. package/src/mcp/tools/workspace-write.ts +2 -0
  69. package/src/pipeline/filters.ts +1 -1
  70. package/src/pipeline/graph-retrieval.ts +7 -4
  71. package/src/pipeline/hybrid.ts +7 -4
  72. package/src/pipeline/result-context.ts +12 -4
  73. package/src/pipeline/search.ts +11 -4
  74. package/src/pipeline/types.ts +3 -0
  75. package/src/pipeline/vsearch.ts +26 -8
  76. package/src/sdk/documents.ts +13 -5
  77. package/src/serve/browse-tree.ts +4 -2
  78. package/src/serve/routes/api.ts +63 -65
  79. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  80. package/src/store/migrations/index.ts +2 -0
  81. package/src/store/sqlite/adapter.ts +114 -7
  82. package/src/store/types.ts +40 -0
  83. package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +0 -1
@@ -0,0 +1,429 @@
1
+ import type {
2
+ RecordAdapter,
3
+ RecordAdapterEvent,
4
+ RecordAdapterInput,
5
+ RecordAdapterRecord,
6
+ } from "../../types";
7
+
8
+ import { RECORD_METADATA_LIMITS } from "../../types";
9
+ import {
10
+ MailParseError,
11
+ type ParsedAttachment,
12
+ type ParsedEmail,
13
+ parseEmail,
14
+ } from "./mime";
15
+
16
+ const ADAPTER_ID = "native/email-export";
17
+ const ADAPTER_VERSION = "1.0.0";
18
+ const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
19
+ const BINARY_STRING_CHUNK = 8_192;
20
+ const CONTROL_CHAR_PATTERN = new RegExp(
21
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`,
22
+ "g"
23
+ );
24
+
25
+ interface RawMessage {
26
+ raw?: string;
27
+ index: number;
28
+ oversized: boolean;
29
+ }
30
+
31
+ interface BinaryLine {
32
+ rawLength: number;
33
+ value?: string;
34
+ oversized: boolean;
35
+ }
36
+
37
+ const hashText = (value: string): string =>
38
+ new Bun.CryptoHasher("sha256").update(value).digest("hex");
39
+
40
+ const bytesToBinary = (bytes: Uint8Array): string => {
41
+ const pieces: string[] = [];
42
+ for (let offset = 0; offset < bytes.length; offset += BINARY_STRING_CHUNK) {
43
+ pieces.push(
44
+ String.fromCharCode(
45
+ ...bytes.subarray(
46
+ offset,
47
+ Math.min(offset + BINARY_STRING_CHUNK, bytes.length)
48
+ )
49
+ )
50
+ );
51
+ }
52
+ return pieces.join("");
53
+ };
54
+
55
+ const rawMessageLimit = (input: RecordAdapterInput): number => {
56
+ const attachmentLimit = Math.max(
57
+ 1_024,
58
+ Math.min(MAX_ATTACHMENT_BYTES, input.limits.maxRecordChars * 2)
59
+ );
60
+ const desired = Math.max(
61
+ input.limits.maxRecordChars * 4,
62
+ input.limits.maxMetadataChars * 4,
63
+ attachmentLimit * 2
64
+ );
65
+ return Math.max(4_096, Math.min(input.limits.maxSourceBytes, desired));
66
+ };
67
+
68
+ async function* binaryLines(
69
+ input: RecordAdapterInput,
70
+ maxLineChars: number
71
+ ): AsyncGenerator<BinaryLine> {
72
+ let pendingParts: string[] = [];
73
+ let pendingChars = 0;
74
+ let rawLength = 0;
75
+ let oversized = false;
76
+ for await (const chunk of input.open()) {
77
+ let start = 0;
78
+ for (let index = 0; index < chunk.length; index += 1) {
79
+ if (chunk[index] !== 0x0a) continue;
80
+ const segment = bytesToBinary(chunk.subarray(start, index));
81
+ rawLength += segment.length + 1;
82
+ if (!oversized) {
83
+ if (pendingChars + segment.length > maxLineChars) {
84
+ oversized = true;
85
+ pendingParts = [];
86
+ pendingChars = 0;
87
+ } else {
88
+ pendingParts.push(segment);
89
+ pendingChars += segment.length;
90
+ }
91
+ }
92
+ yield oversized
93
+ ? { rawLength, oversized: true }
94
+ : {
95
+ rawLength,
96
+ value: pendingParts.join("").replace(/\r$/, ""),
97
+ oversized: false,
98
+ };
99
+ pendingParts = [];
100
+ pendingChars = 0;
101
+ rawLength = 0;
102
+ oversized = false;
103
+ start = index + 1;
104
+ }
105
+ if (start < chunk.length) {
106
+ const segment = bytesToBinary(chunk.subarray(start));
107
+ rawLength += segment.length;
108
+ if (!oversized) {
109
+ if (pendingChars + segment.length > maxLineChars) {
110
+ oversized = true;
111
+ pendingParts = [];
112
+ pendingChars = 0;
113
+ } else {
114
+ pendingParts.push(segment);
115
+ pendingChars += segment.length;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ if (rawLength > 0 || oversized) {
121
+ yield oversized
122
+ ? { rawLength, oversized: true }
123
+ : {
124
+ rawLength,
125
+ value: pendingParts.join("").replace(/\r$/, ""),
126
+ oversized: false,
127
+ };
128
+ }
129
+ }
130
+
131
+ const readEml = async (
132
+ input: RecordAdapterInput,
133
+ maxChars: number
134
+ ): Promise<RawMessage> => {
135
+ const pieces: string[] = [];
136
+ let chars = 0;
137
+ for await (const chunk of input.open()) {
138
+ chars += chunk.length;
139
+ if (chars > maxChars) return { index: 1, oversized: true };
140
+ pieces.push(bytesToBinary(chunk));
141
+ }
142
+ return { raw: pieces.join(""), index: 1, oversized: false };
143
+ };
144
+
145
+ async function* readMbox(
146
+ input: RecordAdapterInput,
147
+ maxChars: number
148
+ ): AsyncGenerator<RawMessage> {
149
+ let messageParts: string[] = [];
150
+ let messageChars = 0;
151
+ let messageIndex = 0;
152
+ let malformed = false;
153
+ let oversized = false;
154
+ let sawEnvelope = false;
155
+ let contentLength: number | undefined;
156
+ let bodyBytesRemaining: number | undefined;
157
+ let inHeaders = false;
158
+
159
+ const finishMessage = (): RawMessage | undefined => {
160
+ if (messageParts.length === 0 && !(malformed || oversized))
161
+ return undefined;
162
+ messageIndex += 1;
163
+ const incompleteBody =
164
+ bodyBytesRemaining !== undefined && bodyBytesRemaining > 0;
165
+ const result: RawMessage = {
166
+ raw:
167
+ oversized || malformed || incompleteBody
168
+ ? undefined
169
+ : messageParts.join("\n"),
170
+ index: messageIndex,
171
+ oversized,
172
+ };
173
+ messageParts = [];
174
+ messageChars = 0;
175
+ malformed = false;
176
+ oversized = false;
177
+ contentLength = undefined;
178
+ bodyBytesRemaining = undefined;
179
+ inHeaders = false;
180
+ return result;
181
+ };
182
+
183
+ for await (const line of binaryLines(input, maxChars)) {
184
+ if (bodyBytesRemaining !== undefined && bodyBytesRemaining > 0) {
185
+ if (line.rawLength > bodyBytesRemaining) {
186
+ malformed = true;
187
+ bodyBytesRemaining = 0;
188
+ messageParts = [];
189
+ continue;
190
+ }
191
+ bodyBytesRemaining -= line.rawLength;
192
+ } else {
193
+ const isEnvelope = !line.oversized && isMboxEnvelope(line.value ?? "");
194
+ if (isEnvelope) {
195
+ const finished = finishMessage();
196
+ if (finished) yield finished;
197
+ sawEnvelope = true;
198
+ inHeaders = true;
199
+ continue;
200
+ }
201
+ }
202
+ if (!sawEnvelope && messageParts.length === 0 && line.value === "") {
203
+ continue;
204
+ }
205
+ if (line.oversized) {
206
+ oversized = true;
207
+ messageParts = [];
208
+ continue;
209
+ }
210
+ const rawLineValue = line.value ?? "";
211
+ const lineValue = inHeaders
212
+ ? rawLineValue
213
+ : rawLineValue.replace(/^>(?=>*From )/, "");
214
+ if (inHeaders) {
215
+ if (lineValue === "") {
216
+ inHeaders = false;
217
+ bodyBytesRemaining = contentLength;
218
+ } else if (/^content-length\s*:/i.test(lineValue)) {
219
+ const rawLength = lineValue.slice(lineValue.indexOf(":") + 1).trim();
220
+ if (!/^\d+$/.test(rawLength)) {
221
+ malformed = true;
222
+ } else {
223
+ contentLength = Number.parseInt(rawLength, 10);
224
+ if (!Number.isSafeInteger(contentLength)) {
225
+ malformed = true;
226
+ } else if (contentLength > maxChars) {
227
+ oversized = true;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ if (oversized) continue;
233
+ messageChars += lineValue.length + 1;
234
+ if (messageChars > maxChars) {
235
+ oversized = true;
236
+ messageParts = [];
237
+ } else {
238
+ messageParts.push(lineValue);
239
+ }
240
+ }
241
+ const finished = finishMessage();
242
+ if (finished) yield finished;
243
+ }
244
+
245
+ const isMboxEnvelope = (line: string): boolean =>
246
+ /^From \S+ (?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) {1,2}\d{1,2} \d{2}:\d{2}(?::\d{2})?(?: [A-Za-z]{1,5}| [+-]\d{4})? \d{4}\s*$/.test(
247
+ line
248
+ );
249
+
250
+ const markdownInline = (value: string): string =>
251
+ value
252
+ .replace(CONTROL_CHAR_PATTERN, " ")
253
+ .replace(/([\\`*_[\]<>#])/g, "\\$1")
254
+ .replace(/\s+/g, " ")
255
+ .trim();
256
+
257
+ const BODY_MARKDOWN_ESCAPE_CHARS = new Set([
258
+ "\\",
259
+ "`",
260
+ "*",
261
+ "_",
262
+ "[",
263
+ "]",
264
+ "<",
265
+ ">",
266
+ ]);
267
+
268
+ const safeBodyMarkdown = (value: string): string => {
269
+ const output: string[] = [];
270
+ for (const character of value) {
271
+ if (character === "\r") continue;
272
+ if (BODY_MARKDOWN_ESCAPE_CHARS.has(character)) output.push("\\");
273
+ output.push(character);
274
+ }
275
+ return output.join("");
276
+ };
277
+
278
+ const renderAttachments = (attachments: ParsedAttachment[]): string => {
279
+ if (attachments.length === 0) return "";
280
+ const lines = attachments.map(
281
+ (attachment) =>
282
+ `- ${markdownInline(attachment.name)} — ${markdownInline(
283
+ attachment.mime ?? "application/octet-stream"
284
+ )} — ${attachment.bytes ?? 0} bytes — sha256:${attachment.sha256}`
285
+ );
286
+ return `\n\n## Attachments\n\n${lines.join("\n")}`;
287
+ };
288
+
289
+ const renderEmail = (parsed: ParsedEmail, missingId: boolean): string => {
290
+ const title = markdownInline(parsed.subject ?? "(no subject)");
291
+ const fields = [
292
+ parsed.author ? `From: ${markdownInline(parsed.author)}` : undefined,
293
+ parsed.participants.length > 1
294
+ ? `Participants: ${parsed.participants.map(markdownInline).join("; ")}`
295
+ : undefined,
296
+ parsed.sentAt ? `Date: ${parsed.sentAt}` : undefined,
297
+ parsed.messageId
298
+ ? `Message-ID: ${markdownInline(parsed.messageId)}`
299
+ : "Message-ID: missing",
300
+ parsed.inReplyTo
301
+ ? `In-Reply-To: ${markdownInline(parsed.inReplyTo)}`
302
+ : undefined,
303
+ parsed.references.length > 0
304
+ ? `References: ${parsed.references.map(markdownInline).join("; ")}`
305
+ : undefined,
306
+ missingId ? "Identity: content-derived" : undefined,
307
+ ].filter((value): value is string => Boolean(value));
308
+ const body = parsed.body
309
+ ? safeBodyMarkdown(parsed.body)
310
+ : "(No safe text body.)";
311
+ return `# ${title}\n\n${fields.join("\n")}\n\n${body}${renderAttachments(
312
+ parsed.attachments
313
+ )}`;
314
+ };
315
+
316
+ const recordFor = (
317
+ parsed: ParsedEmail,
318
+ raw: string,
319
+ index: number
320
+ ): RecordAdapterRecord => {
321
+ const canonicalRaw = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
322
+ const sourceHash = hashText(`gno-email-source-v1\0${canonicalRaw}`);
323
+ const identity = parsed.messageId
324
+ ? `message:${hashText(parsed.messageId)}:variant:${sourceHash}`
325
+ : `missing:${sourceHash}`;
326
+ return {
327
+ stableId: identity,
328
+ sourceLocator: `message:${index}`,
329
+ sourceHash,
330
+ markdown: renderEmail(parsed, !parsed.messageId),
331
+ title: parsed.subject,
332
+ metadata: parsed.metadata,
333
+ anchors: [
334
+ {
335
+ kind: "message",
336
+ value:
337
+ parsed.messageId?.slice(0, RECORD_METADATA_LIMITS.maxAnchorChars) ??
338
+ `message:${index}`,
339
+ },
340
+ ],
341
+ };
342
+ };
343
+
344
+ const parseLimits = (input: RecordAdapterInput) => ({
345
+ maxBodyChars: input.limits.maxRecordChars,
346
+ maxMetadataChars: input.limits.maxMetadataChars,
347
+ maxAttachmentBytes: Math.max(
348
+ 1_024,
349
+ Math.min(MAX_ATTACHMENT_BYTES, input.limits.maxRecordChars * 2)
350
+ ),
351
+ });
352
+
353
+ const failure = (
354
+ index: number,
355
+ code: "MALFORMED_RECORD" | "RECORD_TOO_LARGE"
356
+ ): RecordAdapterEvent => ({
357
+ type: "failure",
358
+ failure: {
359
+ code,
360
+ message:
361
+ code === "RECORD_TOO_LARGE"
362
+ ? "Mail message exceeded its bounded parse limit."
363
+ : "Mail message could not be parsed safely.",
364
+ retryable: false,
365
+ sourceLocator: `message:${index}`,
366
+ },
367
+ });
368
+
369
+ export const emailRecordAdapter: RecordAdapter = {
370
+ id: ADAPTER_ID,
371
+ version: ADAPTER_VERSION,
372
+
373
+ canHandle(mime, ext) {
374
+ const normalizedMime = mime.toLowerCase();
375
+ const normalizedExt = ext.toLowerCase();
376
+ return (
377
+ normalizedExt === ".eml" ||
378
+ normalizedExt === ".mbox" ||
379
+ normalizedMime === "message/rfc822" ||
380
+ normalizedMime === "application/mbox"
381
+ );
382
+ },
383
+
384
+ async *records(input) {
385
+ const isMbox =
386
+ input.ext.toLowerCase() === ".mbox" ||
387
+ input.mime.toLowerCase() === "application/mbox";
388
+ const messages: AsyncIterable<RawMessage> = isMbox
389
+ ? readMbox(input, rawMessageLimit(input))
390
+ : {
391
+ async *[Symbol.asyncIterator]() {
392
+ yield await readEml(input, rawMessageLimit(input));
393
+ },
394
+ };
395
+ let partial = false;
396
+ let messageCount = 0;
397
+ for await (const message of messages) {
398
+ messageCount += 1;
399
+ if (message.oversized || message.raw === undefined) {
400
+ partial = true;
401
+ yield failure(
402
+ message.index,
403
+ message.oversized ? "RECORD_TOO_LARGE" : "MALFORMED_RECORD"
404
+ );
405
+ continue;
406
+ }
407
+ try {
408
+ const parsed = parseEmail(message.raw, parseLimits(input));
409
+ yield {
410
+ type: "record",
411
+ record: recordFor(parsed, message.raw, message.index),
412
+ };
413
+ } catch (error) {
414
+ partial = true;
415
+ yield failure(
416
+ message.index,
417
+ error instanceof MailParseError && error.kind === "limit"
418
+ ? "RECORD_TOO_LARGE"
419
+ : "MALFORMED_RECORD"
420
+ );
421
+ }
422
+ }
423
+ if (messageCount === 0) {
424
+ partial = true;
425
+ yield failure(1, "MALFORMED_RECORD");
426
+ }
427
+ yield { type: "snapshot", state: partial ? "partial" : "complete" };
428
+ },
429
+ };
@@ -0,0 +1,162 @@
1
+ const DANGEROUS_HTML_ELEMENTS = new Set([
2
+ "script",
3
+ "style",
4
+ "iframe",
5
+ "object",
6
+ "embed",
7
+ "form",
8
+ "svg",
9
+ "math",
10
+ "head",
11
+ ]);
12
+ const HTML_LINE_BREAK_ELEMENTS = new Set([
13
+ "br",
14
+ "hr",
15
+ "p",
16
+ "div",
17
+ "section",
18
+ "article",
19
+ "header",
20
+ "footer",
21
+ "li",
22
+ "tr",
23
+ "h1",
24
+ "h2",
25
+ "h3",
26
+ "h4",
27
+ "h5",
28
+ "h6",
29
+ ]);
30
+
31
+ const decodeHtmlEntities = (value: string): string => {
32
+ const named: Record<string, string> = {
33
+ amp: "&",
34
+ apos: "'",
35
+ gt: ">",
36
+ lt: "<",
37
+ nbsp: " ",
38
+ quot: '"',
39
+ };
40
+ return value.replace(
41
+ /&(?:#(\d+)|#x([\da-f]+)|([a-z]+));/gi,
42
+ (_match, decimal: string, hexadecimal: string, name: string) => {
43
+ const code = decimal
44
+ ? Number.parseInt(decimal, 10)
45
+ : hexadecimal
46
+ ? Number.parseInt(hexadecimal, 16)
47
+ : undefined;
48
+ if (code !== undefined) {
49
+ return Number.isSafeInteger(code) && code > 0 && code <= 0x10ffff
50
+ ? String.fromCodePoint(code)
51
+ : "";
52
+ }
53
+ return named[name.toLowerCase()] ?? "";
54
+ }
55
+ );
56
+ };
57
+
58
+ const findTagEnd = (value: string, start: number): number => {
59
+ let quote: '"' | "'" | undefined;
60
+ for (let index = start + 1; index < value.length; index += 1) {
61
+ const character = value[index];
62
+ if (quote) {
63
+ if (character === quote) quote = undefined;
64
+ } else if (character === '"' || character === "'") {
65
+ quote = character;
66
+ } else if (character === ">") {
67
+ return index;
68
+ }
69
+ }
70
+ return -1;
71
+ };
72
+
73
+ interface ParsedHtmlTag {
74
+ name: string;
75
+ closing: boolean;
76
+ selfClosing: boolean;
77
+ }
78
+
79
+ const parseHtmlTag = (raw: string): ParsedHtmlTag | undefined => {
80
+ const inner = raw.slice(1, -1).trim();
81
+ const closing = inner.startsWith("/");
82
+ const nameStart = closing ? 1 : 0;
83
+ let nameEnd = nameStart;
84
+ while (nameEnd < inner.length && /[a-z\d-]/i.test(inner[nameEnd] ?? "")) {
85
+ nameEnd += 1;
86
+ }
87
+ if (nameEnd === nameStart) return undefined;
88
+ return {
89
+ name: inner.slice(nameStart, nameEnd).toLowerCase(),
90
+ closing,
91
+ selfClosing: inner.endsWith("/"),
92
+ };
93
+ };
94
+
95
+ const embeddedDangerousTag = (
96
+ raw: string
97
+ ): Pick<ParsedHtmlTag, "name" | "closing"> | undefined => {
98
+ const lower = raw.toLowerCase();
99
+ for (const name of DANGEROUS_HTML_ELEMENTS) {
100
+ for (const closing of [true, false]) {
101
+ const marker = closing ? `</${name}` : `<${name}`;
102
+ let offset = lower.indexOf(marker);
103
+ while (offset >= 0) {
104
+ const boundary = lower[offset + marker.length];
105
+ if (!(boundary && /[a-z\d-]/.test(boundary))) {
106
+ return { name, closing };
107
+ }
108
+ offset = lower.indexOf(marker, offset + 1);
109
+ }
110
+ }
111
+ }
112
+ return undefined;
113
+ };
114
+
115
+ const htmlToPlainText = (value: string): string => {
116
+ const output: string[] = [];
117
+ const suppressedElements: string[] = [];
118
+ let index = 0;
119
+ while (index < value.length) {
120
+ if (value.startsWith("<!--", index)) {
121
+ const commentEnd = value.indexOf("-->", index + 4);
122
+ if (commentEnd < 0) break;
123
+ index = commentEnd + 3;
124
+ continue;
125
+ }
126
+ if (value[index] !== "<") {
127
+ if (suppressedElements.length === 0) output.push(value[index] ?? "");
128
+ index += 1;
129
+ continue;
130
+ }
131
+ const tagEnd = findTagEnd(value, index);
132
+ if (tagEnd < 0) break;
133
+ const rawTag = value.slice(index, tagEnd + 1);
134
+ const parsed = embeddedDangerousTag(rawTag) ?? parseHtmlTag(rawTag);
135
+ if (parsed && DANGEROUS_HTML_ELEMENTS.has(parsed.name)) {
136
+ if (parsed.closing) {
137
+ const openIndex = suppressedElements.lastIndexOf(parsed.name);
138
+ if (openIndex >= 0) suppressedElements.splice(openIndex, 1);
139
+ } else if (!("selfClosing" in parsed && parsed.selfClosing)) {
140
+ suppressedElements.push(parsed.name);
141
+ }
142
+ } else if (
143
+ parsed &&
144
+ suppressedElements.length === 0 &&
145
+ HTML_LINE_BREAK_ELEMENTS.has(parsed.name) &&
146
+ (parsed.closing || parsed.name === "br" || parsed.name === "hr")
147
+ ) {
148
+ output.push("\n");
149
+ }
150
+ index = tagEnd + 1;
151
+ }
152
+ return output.join("");
153
+ };
154
+
155
+ export const sanitizeHtmlToText = (html: string): string =>
156
+ htmlToPlainText(decodeHtmlEntities(html))
157
+ .replaceAll("\r", "")
158
+ .split("\n")
159
+ .map((line) => line.trim())
160
+ .filter((line, index, lines) => line || lines[index - 1])
161
+ .join("\n")
162
+ .trim();