@gmickel/gno 2.8.2 → 2.8.4

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 (66) hide show
  1. package/README.md +1 -1
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.4.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.8.4.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +9 -1
  7. package/spec/cli.md +77 -6
  8. package/spec/mcp.md +5 -6
  9. package/spec/output-schemas/doctor.schema.json +10 -4
  10. package/spec/output-schemas/status.schema.json +4 -2
  11. package/src/cli/commands/daemon.ts +7 -2
  12. package/src/cli/commands/doctor-activation.ts +38 -11
  13. package/src/cli/commands/doctor.ts +46 -25
  14. package/src/cli/commands/index-cmd.ts +10 -2
  15. package/src/cli/commands/links.ts +5 -22
  16. package/src/cli/commands/shared.ts +26 -1
  17. package/src/cli/commands/status.ts +76 -4
  18. package/src/cli/commands/update.ts +6 -1
  19. package/src/cli/commands/vec.ts +42 -2
  20. package/src/cli/program.ts +2 -2
  21. package/src/config/types.ts +19 -0
  22. package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
  23. package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
  24. package/src/converters/adapters/xlsx/adapter.ts +132 -0
  25. package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
  26. package/src/converters/registry.ts +62 -6
  27. package/src/converters/versions.ts +2 -0
  28. package/src/core/activation-empty.ts +16 -0
  29. package/src/core/activation-status.ts +23 -5
  30. package/src/core/audit-freshness.ts +1 -1
  31. package/src/core/audit-outside-index.ts +48 -3
  32. package/src/core/change-diff.ts +16 -4
  33. package/src/core/link-destination-parse.ts +21 -4
  34. package/src/core/link-inventory-markdown.ts +23 -3
  35. package/src/core/links.ts +127 -31
  36. package/src/core/network-boundary-inventory.ts +18 -0
  37. package/src/index.ts +10 -0
  38. package/src/ingestion/document-metadata.ts +256 -0
  39. package/src/ingestion/file-child-env.ts +9 -0
  40. package/src/ingestion/file-child.ts +106 -0
  41. package/src/ingestion/file-processor.ts +546 -0
  42. package/src/ingestion/frontmatter.ts +6 -2
  43. package/src/ingestion/index.ts +1 -0
  44. package/src/ingestion/prepare-convert.ts +32 -0
  45. package/src/ingestion/prepare-file.ts +272 -0
  46. package/src/ingestion/prepare-worker.ts +43 -0
  47. package/src/ingestion/record-container.ts +7 -2
  48. package/src/ingestion/strip.ts +136 -31
  49. package/src/ingestion/sync-options.ts +7 -1
  50. package/src/ingestion/sync.ts +207 -400
  51. package/src/ingestion/types.ts +18 -0
  52. package/src/llm/native-worker/embedding-identity.ts +15 -3
  53. package/src/mcp/http-transport.ts +11 -0
  54. package/src/mcp/tools/links.ts +10 -38
  55. package/src/serve/activation-health.ts +25 -7
  56. package/src/serve/public/components/BootstrapStatus.tsx +9 -4
  57. package/src/serve/resident-request.ts +19 -0
  58. package/src/serve/resident-runtime.ts +84 -8
  59. package/src/serve/routes/links.ts +6 -24
  60. package/src/serve/server.ts +3 -2
  61. package/src/serve/status.ts +27 -8
  62. package/src/sessions/config-refresh.ts +3 -2
  63. package/src/store/sqlite/graph-similarity.ts +5 -13
  64. package/src/store/vector/partition-index.ts +100 -0
  65. package/src/store/vector/stored-vectors.ts +20 -15
  66. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Conversion used by the file processor backends (fn-198).
3
+ *
4
+ * Native files (Markdown, plain text) skip the default registry, whose
5
+ * PDF/Office adapters take about half a second to load. The default
6
+ * registry tries these two converters first, so the output is the same.
7
+ *
8
+ * @module src/ingestion/prepare-convert
9
+ */
10
+
11
+ import type { ConvertInput, PipelineResult } from "../converters/types";
12
+
13
+ import { markdownConverter } from "../converters/native/markdown";
14
+ import { plaintextConverter } from "../converters/native/plaintext";
15
+ import { ConversionPipeline, getDefaultPipeline } from "../converters/pipeline";
16
+ import { ConverterRegistry } from "../converters/registry";
17
+
18
+ const nativeRegistry = new ConverterRegistry();
19
+ nativeRegistry.register(markdownConverter);
20
+ nativeRegistry.register(plaintextConverter);
21
+ const nativePipeline = new ConversionPipeline(nativeRegistry);
22
+
23
+ /** Convert one file, loading the heavy adapters only when needed. */
24
+ export function convertForPreparation(
25
+ input: ConvertInput
26
+ ): Promise<PipelineResult> {
27
+ return (
28
+ nativeRegistry.select(input.mime, input.ext)
29
+ ? nativePipeline
30
+ : getDefaultPipeline()
31
+ ).convert(input);
32
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Per-file preparation: everything CPU-bound between a file's bytes and its
3
+ * database write (fn-198).
4
+ *
5
+ * Conversion, metadata, tags, memory scopes, code regions, change-journal
6
+ * structure (previous and next), chunking and link parsing run here as one
7
+ * step. Sync runs it in the terminable file worker, so a file that overruns
8
+ * its budget is stopped mid-step; where no worker can start (a compiled
9
+ * executable, or injected test doubles) it runs in-process and the budget is
10
+ * checked between steps. The main thread keeps every database read and write.
11
+ *
12
+ * @module src/ingestion/prepare-file
13
+ */
14
+
15
+ import type { NormalizedContentTypeRule } from "../config";
16
+ import type {
17
+ ConversionArtifact,
18
+ ConvertInput,
19
+ PipelineResult,
20
+ } from "../converters/types";
21
+ import type { DocumentStructureSnapshot } from "../core/change-diff";
22
+ import type { ChunkInput, DocLinkInput } from "../store/types";
23
+ import type { DocumentMetadata } from "./document-metadata";
24
+ import type { ChunkerPort, ChunkParams } from "./types";
25
+
26
+ import { MARKDOWN_CONVERTER_ID } from "../converters/native/markdown";
27
+ import { extractDocumentStructure } from "../core/change-diff";
28
+ import {
29
+ normalizeMarkdownPath,
30
+ normalizeWikiName,
31
+ parseLinks,
32
+ } from "../core/links";
33
+ import { extractMemoryScopes } from "../core/memory-record";
34
+ import { extractDocumentMetadata, extractTags } from "./document-metadata";
35
+ import { buildLineOffsets } from "./position";
36
+ import { type ExcludedRange, getExcludedRanges } from "./strip";
37
+
38
+ /** The previously indexed revision, for the change journal. */
39
+ export interface PreviousRevision {
40
+ markdown: string;
41
+ relPath: string;
42
+ dateFields: Record<string, string> | null;
43
+ converterId: string | null;
44
+ }
45
+
46
+ export interface PrepareFileRequest {
47
+ input: ConvertInput;
48
+ /** Extension used for path-based content-type inference. */
49
+ metadataExt: string;
50
+ contentTypeRules: NormalizedContentTypeRule[];
51
+ chunkParams: ChunkParams;
52
+ collectionLanguageHint?: string;
53
+ memoryManaged: boolean;
54
+ /**
55
+ * Whether this file is a Markdown source (the Markdown converter handles
56
+ * it). The previous revision's links are read under this rule too, so a
57
+ * non-Markdown source never journals link additions or removals.
58
+ */
59
+ markdownSource: boolean;
60
+ previous: PreviousRevision | null;
61
+ }
62
+
63
+ export interface PreparedFile {
64
+ artifact: ConversionArtifact;
65
+ metadata: DocumentMetadata;
66
+ nextStructure: DocumentStructureSnapshot;
67
+ chunkInputs: ChunkInput[];
68
+ linkInputs: DocLinkInput[];
69
+ tags: string[];
70
+ /** Managed-memory scopes; null outside memory-managed collections. */
71
+ memoryScopes: string[] | null;
72
+ }
73
+
74
+ /** Why a file was not prepared: a conversion error or a budget overrun. */
75
+ export interface PrepareFailure {
76
+ code: string;
77
+ message: string;
78
+ details?: Record<string, unknown>;
79
+ }
80
+
81
+ export type PrepareOutcome =
82
+ | { ok: true; value: PreparedFile }
83
+ | { ok: false; error: PrepareFailure };
84
+
85
+ /** Named steps, in order; reported as progress and in overrun messages. */
86
+ export const PREPARE_PHASES = [
87
+ "previous revision",
88
+ "conversion",
89
+ "metadata extraction",
90
+ "code-region detection",
91
+ "change-journal structure",
92
+ "chunking",
93
+ "link extraction",
94
+ ] as const;
95
+
96
+ export type PreparePhase = (typeof PREPARE_PHASES)[number];
97
+
98
+ export interface PrepareHooks {
99
+ convert: (input: ConvertInput) => Promise<PipelineResult>;
100
+ chunker: ChunkerPort;
101
+ /** Called once the previous revision's structure is known (if any). */
102
+ onPrevious: (structure: DocumentStructureSnapshot) => void;
103
+ /** Called as each step starts. */
104
+ onPhase: (phase: PreparePhase) => void;
105
+ /** In-process budget checkpoint after a step; null to continue. */
106
+ check: (phase: PreparePhase) => PrepareFailure | null;
107
+ }
108
+
109
+ const toLinkInputs = (
110
+ markdown: string,
111
+ relPath: string,
112
+ excludedRanges: ExcludedRange[] | null
113
+ ): DocLinkInput[] => {
114
+ // Only Markdown sources carry links, as in Obsidian: link-shaped text in
115
+ // code, plain text or converted documents stays searchable prose.
116
+ if (!excludedRanges) return [];
117
+ const linkInputs: DocLinkInput[] = [];
118
+ for (const link of parseLinks(
119
+ markdown,
120
+ buildLineOffsets(markdown),
121
+ excludedRanges
122
+ )) {
123
+ let targetRefNorm: string;
124
+ if (link.kind === "wiki") {
125
+ targetRefNorm = normalizeWikiName(link.targetRef);
126
+ } else {
127
+ // Markdown links with a collection prefix are not supported, and a
128
+ // link that escapes the collection root is skipped.
129
+ if (link.targetCollection) continue;
130
+ const resolved = normalizeMarkdownPath(link.targetRef, relPath);
131
+ if (!resolved) continue;
132
+ targetRefNorm = resolved;
133
+ }
134
+ linkInputs.push({
135
+ targetRef: link.targetRef,
136
+ targetRefNorm,
137
+ targetAnchor: link.targetAnchor,
138
+ targetCollection: link.targetCollection,
139
+ linkType: link.kind,
140
+ linkText: link.displayText,
141
+ startLine: link.startLine,
142
+ startCol: link.startCol,
143
+ endLine: link.endLine,
144
+ endCol: link.endCol,
145
+ });
146
+ }
147
+ return linkInputs;
148
+ };
149
+
150
+ /** Prepare one file for persistence; see the module comment. */
151
+ export async function prepareFile(
152
+ request: PrepareFileRequest,
153
+ hooks: PrepareHooks
154
+ ): Promise<PrepareOutcome> {
155
+ const { input, previous } = request;
156
+ const relPath = input.relativePath;
157
+ const step = (phase: PreparePhase): PrepareFailure | null =>
158
+ hooks.check(phase);
159
+
160
+ // The previous revision's structure comes first, so a file stopped later
161
+ // still journals the evidence it had.
162
+ let previousRanges: ExcludedRange[] | null = null;
163
+ if (previous) {
164
+ hooks.onPhase("previous revision");
165
+ previousRanges = request.markdownSource
166
+ ? getExcludedRanges(previous.markdown)
167
+ : null;
168
+ hooks.onPrevious(
169
+ extractDocumentStructure(
170
+ previous.markdown,
171
+ previous.relPath,
172
+ previous.dateFields,
173
+ {
174
+ markdownSource: request.markdownSource,
175
+ excludedRanges: previousRanges ?? undefined,
176
+ }
177
+ )
178
+ );
179
+ const stop = step("previous revision");
180
+ if (stop) return { ok: false, error: stop };
181
+ }
182
+
183
+ hooks.onPhase("conversion");
184
+ const converted = await hooks.convert(input);
185
+ if (!converted.ok) {
186
+ return {
187
+ ok: false,
188
+ error: {
189
+ code: converted.error.code,
190
+ message: converted.error.message,
191
+ details: converted.error.details,
192
+ },
193
+ };
194
+ }
195
+ const artifact = converted.value;
196
+ const markdown = artifact.markdown;
197
+ const stopAfterConversion = step("conversion");
198
+ if (stopAfterConversion) return { ok: false, error: stopAfterConversion };
199
+
200
+ hooks.onPhase("metadata extraction");
201
+ const metadata = extractDocumentMetadata(
202
+ markdown,
203
+ relPath,
204
+ request.metadataExt,
205
+ request.contentTypeRules
206
+ );
207
+ const tags = extractTags(markdown);
208
+ const memoryScopes = request.memoryManaged
209
+ ? extractMemoryScopes(markdown)
210
+ : null;
211
+ const stopAfterMetadata = step("metadata extraction");
212
+ if (stopAfterMetadata) return { ok: false, error: stopAfterMetadata };
213
+
214
+ hooks.onPhase("code-region detection");
215
+ const markdownSource = artifact.meta.converterId === MARKDOWN_CONVERTER_ID;
216
+ // Unchanged content (a re-ingest) reuses the previous revision's parse.
217
+ const excludedRanges = !markdownSource
218
+ ? null
219
+ : previous && previousRanges && previous.markdown === markdown
220
+ ? previousRanges
221
+ : getExcludedRanges(markdown);
222
+ const stopAfterRanges = step("code-region detection");
223
+ if (stopAfterRanges) return { ok: false, error: stopAfterRanges };
224
+
225
+ hooks.onPhase("change-journal structure");
226
+ const nextStructure = extractDocumentStructure(
227
+ markdown,
228
+ relPath,
229
+ metadata.dateFields,
230
+ { markdownSource, excludedRanges: excludedRanges ?? undefined }
231
+ );
232
+ const stopAfterStructure = step("change-journal structure");
233
+ if (stopAfterStructure) return { ok: false, error: stopAfterStructure };
234
+
235
+ hooks.onPhase("chunking");
236
+ const chunkInputs: ChunkInput[] = hooks.chunker
237
+ .chunk(
238
+ markdown,
239
+ request.chunkParams,
240
+ artifact.languageHint ?? request.collectionLanguageHint,
241
+ relPath
242
+ )
243
+ .map((c) => ({
244
+ seq: c.seq,
245
+ pos: c.pos,
246
+ text: c.text,
247
+ startLine: c.startLine,
248
+ endLine: c.endLine,
249
+ language: c.language ?? undefined,
250
+ tokenCount: c.tokenCount ?? undefined,
251
+ }));
252
+ const stopAfterChunking = step("chunking");
253
+ if (stopAfterChunking) return { ok: false, error: stopAfterChunking };
254
+
255
+ hooks.onPhase("link extraction");
256
+ const linkInputs = toLinkInputs(markdown, relPath, excludedRanges);
257
+ const stopAfterLinks = step("link extraction");
258
+ if (stopAfterLinks) return { ok: false, error: stopAfterLinks };
259
+
260
+ return {
261
+ ok: true,
262
+ value: {
263
+ artifact,
264
+ metadata,
265
+ nextStructure,
266
+ chunkInputs,
267
+ linkInputs,
268
+ tags,
269
+ memoryScopes,
270
+ },
271
+ };
272
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Worker entry for per-file preparation (fn-198). See `file-processor.ts`.
3
+ *
4
+ * @module src/ingestion/prepare-worker
5
+ */
6
+
7
+ import type {
8
+ FileWorkerMessage,
9
+ FileWorkerRequest,
10
+ ReadyMessage,
11
+ } from "./file-processor";
12
+
13
+ import { defaultChunker } from "./chunker";
14
+ import { convertForPreparation } from "./prepare-convert";
15
+ import { PREPARE_PHASES, prepareFile } from "./prepare-file";
16
+
17
+ declare const self: Worker;
18
+
19
+ const post = (message: FileWorkerMessage | ReadyMessage): void => {
20
+ self.postMessage(message);
21
+ };
22
+
23
+ self.onmessage = async (event: MessageEvent<FileWorkerRequest>) => {
24
+ // Only the parent that created this worker may drive it; a Bun Worker sees
25
+ // its parent's messages with an empty origin.
26
+ if (event.origin !== "") return;
27
+ const { request, phaseSlot } = event.data;
28
+ const outcome = await prepareFile(request, {
29
+ convert: convertForPreparation,
30
+ chunker: defaultChunker,
31
+ onPrevious: (structure) => post({ type: "previous", structure }),
32
+ // Progress goes through shared memory, not messages: the parent only
33
+ // reads it when it stops a file.
34
+ onPhase: (phase) => {
35
+ Atomics.store(phaseSlot, 0, PREPARE_PHASES.indexOf(phase));
36
+ },
37
+ // The parent enforces the budget and terminates this worker.
38
+ check: () => null,
39
+ });
40
+ post({ type: "done", outcome });
41
+ };
42
+
43
+ post({ type: "ready" });
@@ -320,7 +320,10 @@ const loadPreviousStructure = async (
320
320
  return extractDocumentStructure(
321
321
  content,
322
322
  existing.relPath,
323
- existing.dateFields
323
+ existing.dateFields,
324
+ {
325
+ markdownSource: false,
326
+ }
324
327
  );
325
328
  };
326
329
 
@@ -347,10 +350,12 @@ const persistRecord = async (
347
350
  contentType
348
351
  );
349
352
  const previousStructure = await loadPreviousStructure(input.store, existing);
353
+ // Records are not Markdown sources: they store and journal no links.
350
354
  const nextStructure = extractDocumentStructure(
351
355
  record.markdown,
352
356
  virtualPath,
353
- dateFields
357
+ dateFields,
358
+ { markdownSource: false }
354
359
  );
355
360
  const structureDelta = diffDocumentStructure(
356
361
  previousStructure,
@@ -11,12 +11,28 @@
11
11
  * Returns EXCLUDED RANGES on the original string - does NOT modify content.
12
12
  * This preserves position information for accurate line/column tracking.
13
13
  *
14
+ * Code regions come from the CommonMark + GFM parser. GFM table parsing is
15
+ * quadratic in the cells of one table (a 20,000-row table took minutes), so
16
+ * text with a table over the cell budget is parsed without the table
17
+ * extension and without inline links and inline HTML, which rescan a
18
+ * table-sized paragraph once per row: code spans, fences, indented code,
19
+ * blockquotes and lists keep their CommonMark meaning; table-cell
20
+ * boundaries, link destinations and HTML tags no longer hide a backtick.
21
+ *
14
22
  * @module src/ingestion/strip
15
23
  */
16
24
 
17
25
  import { fromMarkdown } from "mdast-util-from-markdown";
18
26
  import { gfmFromMarkdown } from "mdast-util-gfm";
27
+ import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal";
28
+ import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote";
29
+ import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
30
+ import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item";
19
31
  import { gfm } from "micromark-extension-gfm";
32
+ import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal";
33
+ import { gfmFootnote } from "micromark-extension-gfm-footnote";
34
+ import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
35
+ import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item";
20
36
 
21
37
  // ─────────────────────────────────────────────────────────────────────────────
22
38
  // Types
@@ -45,8 +61,13 @@ export interface ExcludedRange {
45
61
  /** Frontmatter at start of file (YAML between --- delimiters) */
46
62
  const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
47
63
 
48
- /** HTML comments */
49
- const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
64
+ /**
65
+ * Most `|` characters one blank-line-delimited block may hold before the
66
+ * GFM table extension is left out of the parse. A GFM table only ends at a
67
+ * blank line (or another block), and its parse cost grows with the square
68
+ * of its cells: 5,000 cells take about 0.3 s, 26,000 about 8 s.
69
+ */
70
+ export const MAX_PARSED_TABLE_CELLS = 5000;
50
71
 
51
72
  /** Backtick (code span or fence) or tilde fence anywhere in the text. */
52
73
  const INLINE_OR_FENCE_TRIGGER_REGEX = /`|~~~/;
@@ -87,6 +108,65 @@ const mayContainCode = (markdown: string): boolean => {
87
108
  return false;
88
109
  };
89
110
 
111
+ /**
112
+ * Whether any blank-line-delimited block holds more table-cell pipes than
113
+ * the table budget. One linear pass, stopping as soon as the budget trips.
114
+ */
115
+ const hasOversizedTable = (markdown: string): boolean => {
116
+ let blockPipes = 0;
117
+ let lineHasContent = false;
118
+ for (let index = 0; index < markdown.length; index += 1) {
119
+ const code = markdown.charCodeAt(index);
120
+ if (code === 10 /* \n */) {
121
+ if (!lineHasContent) blockPipes = 0;
122
+ lineHasContent = false;
123
+ } else if (code === 124 /* | */) {
124
+ blockPipes += 1;
125
+ lineHasContent = true;
126
+ if (blockPipes > MAX_PARSED_TABLE_CELLS) return true;
127
+ } else if (code !== 32 && code !== 9 && code !== 13) {
128
+ lineHasContent = true;
129
+ }
130
+ }
131
+ return false;
132
+ };
133
+
134
+ /**
135
+ * Inline constructs left out with the table extension. Without table rules a
136
+ * large table is one paragraph, and these rescan it once per row: on every
137
+ * `]` link and image ends walk back through all the paragraph's earlier
138
+ * events looking for an opening `[` (an unmatched `]` per row made 8,000
139
+ * rows take 66 s), and inline HTML reads forward from every unclosed `<!--`
140
+ * to the paragraph's end. They only matter to code detection through a
141
+ * backtick inside a link destination or title or an inline HTML tag, which
142
+ * then reads as a code-span delimiter.
143
+ */
144
+ const PARAGRAPH_RESCANNING_CONSTRUCTS = [
145
+ "labelEnd",
146
+ "gfmPotentialFootnoteCall",
147
+ "htmlText",
148
+ ];
149
+
150
+ /**
151
+ * GFM without tables: every GFM construct except table rows and cells, and
152
+ * without the constructs in `PARAGRAPH_RESCANNING_CONSTRUCTS`.
153
+ */
154
+ const GFM_WITHOUT_TABLES = {
155
+ extensions: [
156
+ gfmAutolinkLiteral(),
157
+ gfmFootnote(),
158
+ gfmStrikethrough(),
159
+ gfmTaskListItem(),
160
+ { disable: { null: PARAGRAPH_RESCANNING_CONSTRUCTS } },
161
+ ],
162
+ mdastExtensions: [
163
+ gfmAutolinkLiteralFromMarkdown(),
164
+ gfmFootnoteFromMarkdown(),
165
+ gfmStrikethroughFromMarkdown(),
166
+ gfmTaskListItemFromMarkdown(),
167
+ ],
168
+ };
169
+
90
170
  /** Opening fence at the start of a code node: backtick or tilde fence. */
91
171
  const FENCE_START_REGEX = /^[\t ]*(?:`{3,}|~{3,})/;
92
172
 
@@ -106,16 +186,17 @@ const collectCodeRanges = (
106
186
  markdown: string,
107
187
  frontmatterEnd: number
108
188
  ): ExcludedRange[] => {
109
- if (!mayContainCode(markdown.slice(frontmatterEnd))) return [];
110
189
  const source =
111
190
  frontmatterEnd > 0
112
191
  ? markdown.slice(0, frontmatterEnd).replace(/[^\r\n]/gu, " ") +
113
192
  markdown.slice(frontmatterEnd)
114
193
  : markdown;
115
- const root = fromMarkdown(source, {
116
- extensions: [gfm()],
117
- mdastExtensions: [gfmFromMarkdown()],
118
- }) as CodeNode;
194
+ const root = fromMarkdown(
195
+ source,
196
+ hasOversizedTable(source)
197
+ ? GFM_WITHOUT_TABLES
198
+ : { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }
199
+ ) as CodeNode;
119
200
  const ranges: ExcludedRange[] = [];
120
201
  const visit = (node: CodeNode): void => {
121
202
  const start = node.position?.start.offset;
@@ -164,21 +245,22 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
164
245
  });
165
246
  }
166
247
 
167
- // 2. HTML comments
168
- HTML_COMMENT_REGEX.lastIndex = 0;
169
- let match: RegExpExecArray | null;
170
- while ((match = HTML_COMMENT_REGEX.exec(markdown)) !== null) {
171
- ranges.push({
172
- start: match.index,
173
- end: match.index + match[0].length,
174
- kind: "html_comment",
175
- });
248
+ // 2. HTML comments: each `<!--` to the first `-->` after it. Once no
249
+ // `-->` follows, no later comment can close either, so the scan stops;
250
+ // the regex /<!--[\s\S]*?-->/g rescanned to the end from every opener.
251
+ let open = markdown.indexOf("<!--");
252
+ while (open !== -1) {
253
+ const close = markdown.indexOf("-->", open + 4);
254
+ if (close === -1) break;
255
+ ranges.push({ start: open, end: close + 3, kind: "html_comment" });
256
+ open = markdown.indexOf("<!--", close + 3);
176
257
  }
177
258
 
178
259
  // 3. Code spans and fenced or indented code blocks, from the parser.
179
- ranges.push(
180
- ...collectCodeRanges(markdown, frontmatterMatch?.[0].length ?? 0)
181
- );
260
+ const frontmatterEnd = frontmatterMatch?.[0].length ?? 0;
261
+ if (mayContainCode(markdown.slice(frontmatterEnd))) {
262
+ ranges.push(...collectCodeRanges(markdown, frontmatterEnd));
263
+ }
182
264
 
183
265
  // Sort by start position for efficient lookup
184
266
  ranges.sort((a, b) => a.start - b.start);
@@ -218,24 +300,47 @@ export function isExcluded(
218
300
  return false;
219
301
  }
220
302
 
303
+ /**
304
+ * Largest `end` among each prefix of a start-sorted range list, built once
305
+ * per list. Ranges can overlap (a comment around a code span), so a range
306
+ * list's ends are not sorted and the prefix maximum stands in for them.
307
+ */
308
+ const prefixMaxEnds = new WeakMap<readonly ExcludedRange[], Int32Array>();
309
+
310
+ const getPrefixMaxEnds = (ranges: readonly ExcludedRange[]): Int32Array => {
311
+ let maxEnds = prefixMaxEnds.get(ranges);
312
+ if (maxEnds?.length !== ranges.length) {
313
+ maxEnds = new Int32Array(ranges.length);
314
+ let maxEnd = -1;
315
+ for (const [index, range] of ranges.entries()) {
316
+ maxEnd = Math.max(maxEnd, range.end);
317
+ maxEnds[index] = maxEnd;
318
+ }
319
+ prefixMaxEnds.set(ranges, maxEnds);
320
+ }
321
+ return maxEnds;
322
+ };
323
+
221
324
  /**
222
325
  * Check if a range [start, end) intersects any excluded range.
223
- * More precise than isExcluded for multi-character matches.
326
+ * More precise than isExcluded for multi-character matches. Ranges must be
327
+ * sorted by start, as getExcludedRanges returns them; one lookup is a binary
328
+ * search, so checking every link of a large note stays linear.
224
329
  */
225
330
  export function rangeIntersectsExcluded(
226
331
  start: number,
227
332
  end: number,
228
- excludedRanges: ExcludedRange[]
333
+ excludedRanges: readonly ExcludedRange[]
229
334
  ): boolean {
230
- for (const range of excludedRanges) {
231
- // Two ranges [a, b) and [c, d) intersect if a < d && c < b
232
- if (start < range.end && range.start < end) {
233
- return true;
234
- }
235
- // Early exit if we've passed the range (ranges are sorted)
236
- if (range.start >= end) {
237
- break;
238
- }
335
+ // Ranges starting before `end` form a prefix; one of them intersects
336
+ // [start, end) exactly when the largest end among them passes `start`.
337
+ let low = 0;
338
+ let high = excludedRanges.length;
339
+ while (low < high) {
340
+ const mid = (low + high) >>> 1;
341
+ if ((excludedRanges[mid]?.start ?? end) < end) low = mid + 1;
342
+ else high = mid;
239
343
  }
240
- return false;
344
+ if (low === 0) return false;
345
+ return (getPrefixMaxEnds(excludedRanges)[low - 1] ?? -1) > start;
241
346
  }
@@ -20,13 +20,19 @@ export function resolveContentTypeRules(
20
20
 
21
21
  export function withContentTypeRules(
22
22
  options: SyncOptions = {},
23
- config?: Pick<Config, "contentTypes" | "chunking">
23
+ config?: Pick<Config, "contentTypes" | "chunking" | "conversion">
24
24
  ): SyncOptions {
25
25
  const rules = options.contentTypeRules ?? resolveContentTypeRules(config);
26
26
  const chunking = options.chunking ?? config?.chunking;
27
+ const conversion = config?.conversion;
28
+ // Explicit per-call limits win over the configured conversion budget.
29
+ const limits = conversion
30
+ ? { ...conversion, ...options.limits }
31
+ : options.limits;
27
32
  return {
28
33
  ...options,
29
34
  ...(chunking ? { chunking } : {}),
35
+ ...(limits ? { limits } : {}),
30
36
  contentTypeRules: rules,
31
37
  contentTypeRulesFingerprint:
32
38
  options.contentTypeRulesFingerprint ??