@gmickel/gno 2.8.2 → 2.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.3.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.3.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +9 -1
- package/spec/cli.md +66 -4
- package/spec/mcp.md +5 -6
- package/src/cli/commands/daemon.ts +7 -2
- package/src/cli/commands/doctor.ts +42 -24
- package/src/cli/commands/index-cmd.ts +10 -2
- package/src/cli/commands/links.ts +5 -22
- package/src/cli/commands/shared.ts +26 -1
- package/src/cli/commands/status.ts +60 -0
- package/src/cli/commands/update.ts +6 -1
- package/src/cli/commands/vec.ts +42 -2
- package/src/cli/program.ts +2 -2
- package/src/config/types.ts +19 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
- package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
- package/src/converters/adapters/xlsx/adapter.ts +132 -0
- package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
- package/src/converters/registry.ts +62 -6
- package/src/converters/versions.ts +2 -0
- package/src/core/audit-freshness.ts +1 -1
- package/src/core/audit-outside-index.ts +48 -3
- package/src/core/change-diff.ts +16 -4
- package/src/core/link-destination-parse.ts +21 -4
- package/src/core/link-inventory-markdown.ts +23 -3
- package/src/core/links.ts +74 -16
- package/src/core/network-boundary-inventory.ts +18 -0
- package/src/index.ts +10 -0
- package/src/ingestion/document-metadata.ts +256 -0
- package/src/ingestion/file-child-env.ts +9 -0
- package/src/ingestion/file-child.ts +106 -0
- package/src/ingestion/file-processor.ts +546 -0
- package/src/ingestion/frontmatter.ts +6 -2
- package/src/ingestion/index.ts +1 -0
- package/src/ingestion/prepare-convert.ts +32 -0
- package/src/ingestion/prepare-file.ts +272 -0
- package/src/ingestion/prepare-worker.ts +43 -0
- package/src/ingestion/record-container.ts +7 -2
- package/src/ingestion/strip.ts +71 -8
- package/src/ingestion/sync-options.ts +7 -1
- package/src/ingestion/sync.ts +207 -400
- package/src/ingestion/types.ts +18 -0
- package/src/llm/native-worker/embedding-identity.ts +15 -3
- package/src/mcp/http-transport.ts +11 -0
- package/src/mcp/tools/links.ts +10 -38
- package/src/serve/resident-request.ts +19 -0
- package/src/serve/resident-runtime.ts +84 -8
- package/src/serve/routes/links.ts +6 -24
- package/src/serve/server.ts +3 -2
- package/src/sessions/config-refresh.ts +3 -2
- package/src/store/sqlite/graph-similarity.ts +5 -13
- package/src/store/vector/partition-index.ts +100 -0
- package/src/store/vector/stored-vectors.ts +20 -15
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
|
@@ -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,
|
package/src/ingestion/strip.ts
CHANGED
|
@@ -11,12 +11,26 @@
|
|
|
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: code spans, fences, indented code, blockquotes and lists keep
|
|
18
|
+
* their CommonMark meaning, and only table-cell boundaries are lost.
|
|
19
|
+
*
|
|
14
20
|
* @module src/ingestion/strip
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
18
24
|
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
25
|
+
import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal";
|
|
26
|
+
import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote";
|
|
27
|
+
import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
|
|
28
|
+
import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item";
|
|
19
29
|
import { gfm } from "micromark-extension-gfm";
|
|
30
|
+
import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal";
|
|
31
|
+
import { gfmFootnote } from "micromark-extension-gfm-footnote";
|
|
32
|
+
import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
|
|
33
|
+
import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item";
|
|
20
34
|
|
|
21
35
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
36
|
// Types
|
|
@@ -48,6 +62,14 @@ const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
|
|
|
48
62
|
/** HTML comments */
|
|
49
63
|
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
|
|
50
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Most `|` characters one blank-line-delimited block may hold before the
|
|
67
|
+
* GFM table extension is left out of the parse. A GFM table only ends at a
|
|
68
|
+
* blank line (or another block), and its parse cost grows with the square
|
|
69
|
+
* of its cells: 5,000 cells take about 0.3 s, 26,000 about 8 s.
|
|
70
|
+
*/
|
|
71
|
+
export const MAX_PARSED_TABLE_CELLS = 5000;
|
|
72
|
+
|
|
51
73
|
/** Backtick (code span or fence) or tilde fence anywhere in the text. */
|
|
52
74
|
const INLINE_OR_FENCE_TRIGGER_REGEX = /`|~~~/;
|
|
53
75
|
/** Blockquote markers and indentation before a line's content. */
|
|
@@ -87,6 +109,45 @@ const mayContainCode = (markdown: string): boolean => {
|
|
|
87
109
|
return false;
|
|
88
110
|
};
|
|
89
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Whether any blank-line-delimited block holds more table-cell pipes than
|
|
114
|
+
* the table budget. One linear pass, stopping as soon as the budget trips.
|
|
115
|
+
*/
|
|
116
|
+
const hasOversizedTable = (markdown: string): boolean => {
|
|
117
|
+
let blockPipes = 0;
|
|
118
|
+
let lineHasContent = false;
|
|
119
|
+
for (let index = 0; index < markdown.length; index += 1) {
|
|
120
|
+
const code = markdown.charCodeAt(index);
|
|
121
|
+
if (code === 10 /* \n */) {
|
|
122
|
+
if (!lineHasContent) blockPipes = 0;
|
|
123
|
+
lineHasContent = false;
|
|
124
|
+
} else if (code === 124 /* | */) {
|
|
125
|
+
blockPipes += 1;
|
|
126
|
+
lineHasContent = true;
|
|
127
|
+
if (blockPipes > MAX_PARSED_TABLE_CELLS) return true;
|
|
128
|
+
} else if (code !== 32 && code !== 9 && code !== 13) {
|
|
129
|
+
lineHasContent = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** GFM without tables: every GFM construct except table rows and cells. */
|
|
136
|
+
const GFM_WITHOUT_TABLES = {
|
|
137
|
+
extensions: [
|
|
138
|
+
gfmAutolinkLiteral(),
|
|
139
|
+
gfmFootnote(),
|
|
140
|
+
gfmStrikethrough(),
|
|
141
|
+
gfmTaskListItem(),
|
|
142
|
+
],
|
|
143
|
+
mdastExtensions: [
|
|
144
|
+
gfmAutolinkLiteralFromMarkdown(),
|
|
145
|
+
gfmFootnoteFromMarkdown(),
|
|
146
|
+
gfmStrikethroughFromMarkdown(),
|
|
147
|
+
gfmTaskListItemFromMarkdown(),
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
90
151
|
/** Opening fence at the start of a code node: backtick or tilde fence. */
|
|
91
152
|
const FENCE_START_REGEX = /^[\t ]*(?:`{3,}|~{3,})/;
|
|
92
153
|
|
|
@@ -106,16 +167,17 @@ const collectCodeRanges = (
|
|
|
106
167
|
markdown: string,
|
|
107
168
|
frontmatterEnd: number
|
|
108
169
|
): ExcludedRange[] => {
|
|
109
|
-
if (!mayContainCode(markdown.slice(frontmatterEnd))) return [];
|
|
110
170
|
const source =
|
|
111
171
|
frontmatterEnd > 0
|
|
112
172
|
? markdown.slice(0, frontmatterEnd).replace(/[^\r\n]/gu, " ") +
|
|
113
173
|
markdown.slice(frontmatterEnd)
|
|
114
174
|
: markdown;
|
|
115
|
-
const root = fromMarkdown(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
175
|
+
const root = fromMarkdown(
|
|
176
|
+
source,
|
|
177
|
+
hasOversizedTable(source)
|
|
178
|
+
? GFM_WITHOUT_TABLES
|
|
179
|
+
: { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }
|
|
180
|
+
) as CodeNode;
|
|
119
181
|
const ranges: ExcludedRange[] = [];
|
|
120
182
|
const visit = (node: CodeNode): void => {
|
|
121
183
|
const start = node.position?.start.offset;
|
|
@@ -176,9 +238,10 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
|
|
|
176
238
|
}
|
|
177
239
|
|
|
178
240
|
// 3. Code spans and fenced or indented code blocks, from the parser.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
241
|
+
const frontmatterEnd = frontmatterMatch?.[0].length ?? 0;
|
|
242
|
+
if (mayContainCode(markdown.slice(frontmatterEnd))) {
|
|
243
|
+
ranges.push(...collectCodeRanges(markdown, frontmatterEnd));
|
|
244
|
+
}
|
|
182
245
|
|
|
183
246
|
// Sort by start position for efficient lookup
|
|
184
247
|
ranges.sort((a, b) => a.start - b.start);
|
|
@@ -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 ??
|