@gmickel/gno 2.8.1 → 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/assets/skill/SKILL.md +4 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.1.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 +81 -8
- 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/native/markdown.ts +3 -1
- 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-links.ts +2 -1
- package/src/core/audit-outside-index.ts +91 -18
- package/src/core/change-diff.ts +18 -6
- package/src/core/link-destination-parse.ts +21 -4
- package/src/core/link-inventory-markdown.ts +23 -3
- package/src/core/link-inventory-opaque.ts +3 -1
- 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 +149 -133
- package/src/ingestion/sync-options.ts +7 -1
- package/src/ingestion/sync.ts +208 -385
- 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.1.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
|
@@ -4,15 +4,34 @@
|
|
|
4
4
|
* Identifies regions to exclude from link/tag extraction:
|
|
5
5
|
* - YAML frontmatter
|
|
6
6
|
* - Fenced code blocks (CommonMark backtick and tilde fences)
|
|
7
|
+
* - Indented code blocks
|
|
7
8
|
* - Inline code
|
|
8
9
|
* - HTML comments
|
|
9
10
|
*
|
|
10
11
|
* Returns EXCLUDED RANGES on the original string - does NOT modify content.
|
|
11
12
|
* This preserves position information for accurate line/column tracking.
|
|
12
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
|
+
*
|
|
13
20
|
* @module src/ingestion/strip
|
|
14
21
|
*/
|
|
15
22
|
|
|
23
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
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";
|
|
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";
|
|
34
|
+
|
|
16
35
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
36
|
// Types
|
|
18
37
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -20,6 +39,7 @@
|
|
|
20
39
|
export type ExcludedRangeKind =
|
|
21
40
|
| "frontmatter"
|
|
22
41
|
| "fenced_code"
|
|
42
|
+
| "indented_code"
|
|
23
43
|
| "inline_code"
|
|
24
44
|
| "html_comment";
|
|
25
45
|
|
|
@@ -39,150 +59,148 @@ export interface ExcludedRange {
|
|
|
39
59
|
/** Frontmatter at start of file (YAML between --- delimiters) */
|
|
40
60
|
const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
|
|
41
61
|
|
|
42
|
-
/** CommonMark fence opener: 0–3 spaces, then 3+ backticks or tildes + info. */
|
|
43
|
-
const FENCE_OPEN_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
|
|
44
|
-
|
|
45
|
-
/** CommonMark fence closer: matching character, length ≥ opener, trailing space/tabs only. */
|
|
46
|
-
const FENCE_CLOSE_REGEX = /^ {0,3}(`{3,}|~{3,})[\t ]*$/u;
|
|
47
|
-
|
|
48
62
|
/** HTML comments */
|
|
49
63
|
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
|
|
50
64
|
|
|
51
|
-
interface OpenFence {
|
|
52
|
-
marker: "`" | "~";
|
|
53
|
-
length: number;
|
|
54
|
-
start: number;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
65
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
61
70
|
*/
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
export const MAX_PARSED_TABLE_CELLS = 5000;
|
|
72
|
+
|
|
73
|
+
/** Backtick (code span or fence) or tilde fence anywhere in the text. */
|
|
74
|
+
const INLINE_OR_FENCE_TRIGGER_REGEX = /`|~~~/;
|
|
75
|
+
/** Blockquote markers and indentation before a line's content. */
|
|
76
|
+
const CONTAINER_PREFIX_REGEX = /^[\t >]*/;
|
|
77
|
+
/** Four columns of indentation after any blockquote markers. */
|
|
78
|
+
const INDENTED_LINE_REGEX = /^(?:>[\t ]?)*(?:\t| {4})/;
|
|
79
|
+
/**
|
|
80
|
+
* Lines after which an indented line may start an indented code block: an
|
|
81
|
+
* ATX heading, table row, HTML or definition line, or a thematic break or
|
|
82
|
+
* setext underline. After prose or a list item it continues the paragraph.
|
|
83
|
+
*/
|
|
84
|
+
const BLOCK_END_LINE_REGEX =
|
|
85
|
+
/^(?:[#|<[]|([-*_=])[\t ]*(?:\1[\t ]*){2,}$)|>[\t ]*$/u;
|
|
72
86
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
length: run.length,
|
|
93
|
-
start: offset,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
87
|
+
/**
|
|
88
|
+
* Cheap superset test for code: a backtick, a tilde fence, or an indented
|
|
89
|
+
* line that can open an indented code block (not a list continuation). When
|
|
90
|
+
* it fails the note has no code and the parser is skipped.
|
|
91
|
+
*/
|
|
92
|
+
const mayContainCode = (markdown: string): boolean => {
|
|
93
|
+
if (INLINE_OR_FENCE_TRIGGER_REGEX.test(markdown)) return true;
|
|
94
|
+
let previous: string | null = null;
|
|
95
|
+
for (const rawLine of markdown.split("\n")) {
|
|
96
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
97
|
+
const content = line.replace(CONTAINER_PREFIX_REGEX, "");
|
|
98
|
+
if (
|
|
99
|
+
content !== "" &&
|
|
100
|
+
INDENTED_LINE_REGEX.test(line) &&
|
|
101
|
+
(previous === null ||
|
|
102
|
+
previous === "" ||
|
|
103
|
+
BLOCK_END_LINE_REGEX.test(previous))
|
|
104
|
+
) {
|
|
105
|
+
return true;
|
|
96
106
|
}
|
|
97
|
-
|
|
98
|
-
if (nextNl === -1) break;
|
|
99
|
-
offset = nextNl + 1;
|
|
107
|
+
previous = content;
|
|
100
108
|
}
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
101
111
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
}
|
|
108
131
|
}
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
109
134
|
|
|
110
|
-
|
|
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
|
+
],
|
|
111
149
|
};
|
|
112
150
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
/** Opening fence at the start of a code node: backtick or tilde fence. */
|
|
152
|
+
const FENCE_START_REGEX = /^[\t ]*(?:`{3,}|~{3,})/;
|
|
153
|
+
|
|
154
|
+
interface CodeNode {
|
|
155
|
+
children?: CodeNode[];
|
|
156
|
+
position?: { start: { offset?: number }; end: { offset?: number } };
|
|
157
|
+
type: string;
|
|
117
158
|
}
|
|
118
159
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Code regions as the CommonMark + GFM parser sees them: inline code spans,
|
|
162
|
+
* fenced code blocks and indented code blocks, with their block context
|
|
163
|
+
* (paragraphs, blockquotes, list items) handled by the parser. Frontmatter is
|
|
164
|
+
* blanked first (same length, newlines kept) so it cannot open a fence.
|
|
165
|
+
*/
|
|
166
|
+
const collectCodeRanges = (
|
|
121
167
|
markdown: string,
|
|
122
|
-
|
|
168
|
+
frontmatterEnd: number
|
|
123
169
|
): ExcludedRange[] => {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (excluded && cursor >= excluded.start && cursor < excluded.end) {
|
|
136
|
-
cursor = excluded.end;
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
if (markdown[cursor] !== "`") {
|
|
140
|
-
cursor += 1;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
const start = cursor;
|
|
144
|
-
while (markdown[cursor] === "`") cursor += 1;
|
|
145
|
-
let backslashes = 0;
|
|
146
|
-
for (let i = start - 1; i >= 0 && markdown[i] === "\\"; i -= 1) {
|
|
147
|
-
backslashes += 1;
|
|
148
|
-
}
|
|
149
|
-
if (backslashes % 2 === 0) {
|
|
150
|
-
runs.push({ start, end: cursor, length: cursor - start });
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const nextMatchingRun = Array.from<number | undefined>({
|
|
155
|
-
length: runs.length,
|
|
156
|
-
});
|
|
157
|
-
const latestByLength = new Map<number, number>();
|
|
158
|
-
for (let index = runs.length - 1; index >= 0; index -= 1) {
|
|
159
|
-
const run = runs[index];
|
|
160
|
-
if (!run) continue;
|
|
161
|
-
nextMatchingRun[index] = latestByLength.get(run.length);
|
|
162
|
-
latestByLength.set(run.length, index);
|
|
163
|
-
}
|
|
164
|
-
|
|
170
|
+
const source =
|
|
171
|
+
frontmatterEnd > 0
|
|
172
|
+
? markdown.slice(0, frontmatterEnd).replace(/[^\r\n]/gu, " ") +
|
|
173
|
+
markdown.slice(frontmatterEnd)
|
|
174
|
+
: markdown;
|
|
175
|
+
const root = fromMarkdown(
|
|
176
|
+
source,
|
|
177
|
+
hasOversizedTable(source)
|
|
178
|
+
? GFM_WITHOUT_TABLES
|
|
179
|
+
: { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }
|
|
180
|
+
) as CodeNode;
|
|
165
181
|
const ranges: ExcludedRange[] = [];
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
const visit = (node: CodeNode): void => {
|
|
183
|
+
const start = node.position?.start.offset;
|
|
184
|
+
const end = node.position?.end.offset;
|
|
185
|
+
if (start !== undefined && end !== undefined && end > start) {
|
|
186
|
+
if (node.type === "inlineCode") {
|
|
187
|
+
ranges.push({ start, end, kind: "inline_code" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (node.type === "code") {
|
|
191
|
+
ranges.push({
|
|
192
|
+
start,
|
|
193
|
+
end,
|
|
194
|
+
kind: FENCE_START_REGEX.test(source.slice(start, end))
|
|
195
|
+
? "fenced_code"
|
|
196
|
+
: "indented_code",
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
178
200
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
kind: "inline_code",
|
|
183
|
-
});
|
|
184
|
-
index = closeIndex + 1;
|
|
185
|
-
}
|
|
201
|
+
for (const child of node.children ?? []) visit(child);
|
|
202
|
+
};
|
|
203
|
+
visit(root);
|
|
186
204
|
return ranges;
|
|
187
205
|
};
|
|
188
206
|
|
|
@@ -208,10 +226,7 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
|
|
|
208
226
|
});
|
|
209
227
|
}
|
|
210
228
|
|
|
211
|
-
// 2.
|
|
212
|
-
ranges.push(...collectFencedCodeRanges(markdown));
|
|
213
|
-
|
|
214
|
-
// 3. HTML comments
|
|
229
|
+
// 2. HTML comments
|
|
215
230
|
HTML_COMMENT_REGEX.lastIndex = 0;
|
|
216
231
|
let match: RegExpExecArray | null;
|
|
217
232
|
while ((match = HTML_COMMENT_REGEX.exec(markdown)) !== null) {
|
|
@@ -222,10 +237,11 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
|
|
|
222
237
|
});
|
|
223
238
|
}
|
|
224
239
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
// 3. Code spans and fenced or indented code blocks, from the parser.
|
|
241
|
+
const frontmatterEnd = frontmatterMatch?.[0].length ?? 0;
|
|
242
|
+
if (mayContainCode(markdown.slice(frontmatterEnd))) {
|
|
243
|
+
ranges.push(...collectCodeRanges(markdown, frontmatterEnd));
|
|
244
|
+
}
|
|
229
245
|
|
|
230
246
|
// Sort by start position for efficient lookup
|
|
231
247
|
ranges.sort((a, b) => a.start - b.start);
|