@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.
- package/README.md +1 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.4.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.4.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +9 -1
- package/spec/cli.md +77 -6
- package/spec/mcp.md +5 -6
- package/spec/output-schemas/doctor.schema.json +10 -4
- package/spec/output-schemas/status.schema.json +4 -2
- package/src/cli/commands/daemon.ts +7 -2
- package/src/cli/commands/doctor-activation.ts +38 -11
- package/src/cli/commands/doctor.ts +46 -25
- 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 +76 -4
- 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/activation-empty.ts +16 -0
- package/src/core/activation-status.ts +23 -5
- 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 +127 -31
- 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 +136 -31
- 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/activation-health.ts +25 -7
- package/src/serve/public/components/BootstrapStatus.tsx +9 -4
- 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/serve/status.ts +27 -8
- 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,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,
|
package/src/ingestion/strip.ts
CHANGED
|
@@ -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
|
-
/**
|
|
49
|
-
|
|
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(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
|
|
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 ??
|