@gmickel/gno 2.8.0 → 2.8.2

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 (49) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +7 -2
  3. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  4. package/assets/spa-production.json.gz +0 -0
  5. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.0.zip → gno-browser-clipper-v2.8.2.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +52 -6
  10. package/spec/mcp.md +8 -4
  11. package/spec/output-schemas/memory-recall.schema.json +1 -1
  12. package/spec/output-schemas/status.schema.json +3 -3
  13. package/src/cli/commands/graph.ts +3 -1
  14. package/src/cli/commands/links.ts +27 -49
  15. package/src/cli/commands/status.ts +1 -0
  16. package/src/converters/native/markdown.ts +3 -1
  17. package/src/core/audit-links.ts +57 -4
  18. package/src/core/audit-outside-index.ts +243 -0
  19. package/src/core/audit-workspace.ts +13 -7
  20. package/src/core/audit.ts +9 -1
  21. package/src/core/change-diff.ts +3 -3
  22. package/src/core/link-inventory-markdown.ts +2 -3
  23. package/src/core/link-inventory-opaque.ts +3 -1
  24. package/src/core/links.ts +40 -17
  25. package/src/core/memory-recall.ts +254 -15
  26. package/src/core/memory-types.ts +12 -0
  27. package/src/core/memory.ts +2 -0
  28. package/src/ingestion/strip.ts +89 -136
  29. package/src/ingestion/sync.ts +34 -16
  30. package/src/mcp/tools/links.ts +71 -93
  31. package/src/mcp/tools/status.ts +1 -0
  32. package/src/pipeline/search.ts +2 -0
  33. package/src/pipeline/types.ts +4 -0
  34. package/src/sdk/client.ts +1 -0
  35. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  36. package/src/serve/routes/graph.ts +3 -1
  37. package/src/serve/routes/links.ts +32 -50
  38. package/src/serve/server.ts +2 -1
  39. package/src/serve/status.ts +1 -0
  40. package/src/store/sqlite/adapter.ts +87 -106
  41. package/src/store/sqlite/graph-link-resolver.ts +7 -0
  42. package/src/store/sqlite/graph-similarity.ts +96 -0
  43. package/src/store/sqlite/workspace-link-resolver.ts +119 -31
  44. package/src/store/types.ts +15 -2
  45. package/src/store/vector/status.ts +27 -0
  46. package/src/store/vector/stored-vectors.ts +158 -0
  47. package/src/store/vector/types.ts +6 -0
  48. package/src/store/vector/variant-search.ts +30 -14
  49. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +0 -1
@@ -30,6 +30,9 @@ import {
30
30
  } from "./memory-fence";
31
31
  import {
32
32
  MEMORY_EMPTY_RECALL_HINT,
33
+ MEMORY_NO_MATCH_RECALL_HINT,
34
+ MEMORY_OVER_BUDGET_RECALL_HINT,
35
+ MEMORY_RECALL_ANY_TERM_MIN_RELATIVE_SCORE,
33
36
  MEMORY_RECALL_MAX_FACTS,
34
37
  MEMORY_RECALL_MAX_TOKENS,
35
38
  MEMORY_RECALL_RETRIEVAL_LIMIT,
@@ -40,6 +43,218 @@ import {
40
43
 
41
44
  type RetrievalLeg = { source: "bm25" | "vector"; results: SearchResult[] };
42
45
 
46
+ /**
47
+ * Function words dropped from the lexical leg so a question-shaped turn
48
+ * retrieves on its content terms. English plus the common German, French,
49
+ * and Italian question and function words.
50
+ */
51
+ const RECALL_STOPWORDS = new Set([
52
+ // English
53
+ "a",
54
+ "about",
55
+ "am",
56
+ "an",
57
+ "and",
58
+ "any",
59
+ "anything",
60
+ "are",
61
+ "as",
62
+ "at",
63
+ "be",
64
+ "been",
65
+ "but",
66
+ "by",
67
+ "can",
68
+ "could",
69
+ "did",
70
+ "do",
71
+ "does",
72
+ "for",
73
+ "from",
74
+ "had",
75
+ "has",
76
+ "have",
77
+ "how",
78
+ "how's",
79
+ "i",
80
+ "if",
81
+ "in",
82
+ "into",
83
+ "is",
84
+ "it",
85
+ "it's",
86
+ "its",
87
+ "know",
88
+ "me",
89
+ "my",
90
+ "of",
91
+ "on",
92
+ "or",
93
+ "our",
94
+ "please",
95
+ "should",
96
+ "so",
97
+ "tell",
98
+ "that",
99
+ "the",
100
+ "their",
101
+ "them",
102
+ "there",
103
+ "these",
104
+ "they",
105
+ "this",
106
+ "those",
107
+ "to",
108
+ "us",
109
+ "was",
110
+ "we",
111
+ "were",
112
+ "what",
113
+ "what's",
114
+ "when",
115
+ "where",
116
+ "where's",
117
+ "which",
118
+ "who",
119
+ "who's",
120
+ "whom",
121
+ "whose",
122
+ "why",
123
+ "will",
124
+ "with",
125
+ "would",
126
+ "you",
127
+ "your",
128
+ // German
129
+ "das",
130
+ "dem",
131
+ "den",
132
+ "der",
133
+ "des",
134
+ "ein",
135
+ "eine",
136
+ "ist",
137
+ "mit",
138
+ "oder",
139
+ "sind",
140
+ "und",
141
+ "uns",
142
+ "von",
143
+ "wann",
144
+ "warum",
145
+ "welche",
146
+ "welcher",
147
+ "welches",
148
+ "wer",
149
+ "wie",
150
+ "wir",
151
+ "wo",
152
+ "zu",
153
+ // French
154
+ "avec",
155
+ "comment",
156
+ "dans",
157
+ "de",
158
+ "du",
159
+ "est",
160
+ "et",
161
+ "la",
162
+ "le",
163
+ "les",
164
+ "nous",
165
+ "ou",
166
+ "où",
167
+ "pour",
168
+ "pourquoi",
169
+ "quand",
170
+ "que",
171
+ "quel",
172
+ "quelle",
173
+ "qui",
174
+ "quoi",
175
+ "sont",
176
+ "sur",
177
+ "un",
178
+ "une",
179
+ "vous",
180
+ // Italian
181
+ "che",
182
+ "chi",
183
+ "come",
184
+ "con",
185
+ "cosa",
186
+ "da",
187
+ "del",
188
+ "della",
189
+ "di",
190
+ "dove",
191
+ "il",
192
+ "per",
193
+ "perché",
194
+ "quale",
195
+ "quali",
196
+ "sono",
197
+ ]);
198
+
199
+ /** Whitespace tokens, keeping a quoted phrase (optionally negated) whole. */
200
+ const QUERY_TOKEN_PATTERN = /-?"[^"]*"|\S+/g;
201
+ /** Same character class the FTS term sanitizer keeps. */
202
+ const NON_TERM_CHARS = /[^\p{L}\p{N}'_]/gu;
203
+
204
+ /**
205
+ * Content terms of a recall query: bare stopword tokens are dropped; quoted
206
+ * phrases, negations, and compounds pass through. A query with no positive
207
+ * content term left is returned unchanged.
208
+ */
209
+ function recallContentQuery(query: string): string {
210
+ const tokens = query.match(QUERY_TOKEN_PATTERN) ?? [];
211
+ const kept = tokens.filter((token) => {
212
+ if (token.startsWith("-") || token.includes('"')) return true;
213
+ return !RECALL_STOPWORDS.has(
214
+ token.replace(NON_TERM_CHARS, "").toLowerCase()
215
+ );
216
+ });
217
+ const hasPositiveTerm = kept.some(
218
+ (token) =>
219
+ !token.startsWith("-") && token.replace(NON_TERM_CHARS, "").length > 0
220
+ );
221
+ return hasPositiveTerm ? kept.join(" ") : query;
222
+ }
223
+
224
+ /**
225
+ * Lexical leg: BM25 over the content terms, every term required first. When
226
+ * no fact carries all of them (a question-shaped turn), fall back to
227
+ * any-term matching so facts sharing a content term still rank, best BM25
228
+ * first, above a relative score floor. `null` means the query has no
229
+ * searchable terms.
230
+ */
231
+ async function searchLexical(
232
+ deps: MemoryServiceDeps,
233
+ input: { query: string; collection: string; scopes: string[] }
234
+ ): Promise<SearchResult[] | null> {
235
+ const contentQuery = recallContentQuery(input.query);
236
+ const run = async (anyTerm: boolean) => {
237
+ const bm25 = await searchBm25(deps.store, contentQuery, {
238
+ collection: input.collection,
239
+ limit: MEMORY_RECALL_RETRIEVAL_LIMIT,
240
+ memoryFilter: { scopes: input.scopes, excludeSuperseded: true },
241
+ ...(anyTerm
242
+ ? {
243
+ anyTerm,
244
+ minRelativeScore: MEMORY_RECALL_ANY_TERM_MIN_RELATIVE_SCORE,
245
+ }
246
+ : {}),
247
+ });
248
+ if (bm25.ok) return bm25.value.results;
249
+ if (bm25.error.code !== "INVALID_INPUT") {
250
+ throw new MemoryError("MEMORY_QUERY_FAILED", bm25.error.message);
251
+ }
252
+ return null;
253
+ };
254
+ const allTerms = await run(false);
255
+ return allTerms?.length === 0 ? run(true) : allTerms;
256
+ }
257
+
43
258
  /**
44
259
  * Retrieval legs: BM25 always; vectors when an embedding port and a searchable
45
260
  * vector index are present. The eligible set is one unbounded in-query
@@ -52,17 +267,9 @@ async function retrieveLegs(
52
267
  const { store, config } = deps;
53
268
  const { query, collection, scopes } = input;
54
269
  const legs: RetrievalLeg[] = [];
55
- const bm25 = await searchBm25(store, query, {
56
- collection,
57
- limit: MEMORY_RECALL_RETRIEVAL_LIMIT,
58
- memoryFilter: { scopes, excludeSuperseded: true },
59
- });
60
- if (!bm25.ok) {
61
- if (bm25.error.code !== "INVALID_INPUT") {
62
- throw new MemoryError("MEMORY_QUERY_FAILED", bm25.error.message);
63
- }
64
- } else {
65
- legs.push({ source: "bm25", results: bm25.value.results });
270
+ const lexical = await searchLexical(deps, input);
271
+ if (lexical) {
272
+ legs.push({ source: "bm25", results: lexical });
66
273
  }
67
274
 
68
275
  const retrieval: RecallResult["retrieval"] = { mode: "lexical" };
@@ -159,7 +366,11 @@ async function materializeFacts(
159
366
  return materialized;
160
367
  }
161
368
 
162
- /** Token budget via the shared context-evidence selector, then the fact cap. */
369
+ /**
370
+ * Token budget via the shared context-evidence selector, then the fact cap.
371
+ * Facts carry no facets, so the selector fills the budget in retrieval-rank
372
+ * order (per-fact facets would make it prefer the shortest facts).
373
+ */
163
374
  function selectWithinBudget(
164
375
  materialized: Array<{ fact: RecalledFact; rank: number }>,
165
376
  maxFacts: number,
@@ -176,11 +387,11 @@ function selectWithinBudget(
176
387
  sourceHash: fact.contentHash,
177
388
  mirrorHash: fact.contentHash,
178
389
  text: fact.text,
179
- facets: [fact.uri],
390
+ facets: [],
180
391
  retrievalRank: rank,
181
392
  value: fact,
182
393
  })),
183
- requestedFacets: materialized.map(({ fact }) => fact.uri),
394
+ requestedFacets: [],
184
395
  limits: {
185
396
  requestedBytes: maxTokens * MEMORY_TOKEN_BYTES_ESTIMATE,
186
397
  requestedTokens: maxTokens,
@@ -201,6 +412,28 @@ function selectWithinBudget(
201
412
  return selection.selected.slice(0, maxFacts).map((item) => item.value);
202
413
  }
203
414
 
415
+ /**
416
+ * Why nothing came back: facts matched but none fit the budget, the scope
417
+ * holds facts but none matched, or the scope holds no current fact at all.
418
+ */
419
+ async function emptyRecallHint(
420
+ deps: MemoryServiceDeps,
421
+ input: { collection: string; scopes: string[]; matched: number }
422
+ ): Promise<string> {
423
+ if (input.matched > 0) return MEMORY_OVER_BUDGET_RECALL_HINT;
424
+ const eligible = await deps.store.listMemoryEligibleDocuments({
425
+ collection: input.collection,
426
+ scopes: input.scopes,
427
+ excludeSuperseded: true,
428
+ });
429
+ if (!eligible.ok) {
430
+ throw new MemoryError("MEMORY_QUERY_FAILED", eligible.error.message);
431
+ }
432
+ return eligible.value.length > 0
433
+ ? MEMORY_NO_MATCH_RECALL_HINT
434
+ : MEMORY_EMPTY_RECALL_HINT;
435
+ }
436
+
204
437
  export async function recallFacts(
205
438
  deps: MemoryServiceDeps,
206
439
  rawInput: RecallInput
@@ -264,6 +497,12 @@ export async function recallFacts(
264
497
  facts.map((fact) => fact.egressLineage)
265
498
  ),
266
499
  }
267
- : { hint: MEMORY_EMPTY_RECALL_HINT }),
500
+ : {
501
+ hint: await emptyRecallHint(deps, {
502
+ collection: collection.name,
503
+ scopes,
504
+ matched: materialized.length,
505
+ }),
506
+ }),
268
507
  };
269
508
  }
@@ -33,11 +33,23 @@ export const MEMORY_RECALL_MAX_TOKENS = 512;
33
33
  /** Retrieval depth per leg before fusion and budgeting. */
34
34
  export const MEMORY_RECALL_RETRIEVAL_LIMIT = 32;
35
35
  export const MEMORY_RRF_K = 60;
36
+ /**
37
+ * Any-term fallback floor: a fact must score at least this fraction of the
38
+ * best fact's raw BM25, so a term shared by most facts cannot pull them in.
39
+ */
40
+ export const MEMORY_RECALL_ANY_TERM_MIN_RELATIVE_SCORE = 0.1;
36
41
  export const MEMORY_DEFAULT_LOCK_WAIT_MS = 120_000;
37
42
  export const MEMORY_TOKEN_BYTES_ESTIMATE = 4;
38
43
 
44
+ /** Recall hint when the scope holds no current fact at all. */
39
45
  export const MEMORY_EMPTY_RECALL_HINT =
40
46
  'No memories in scope yet. Store one with: gno remember "<fact>" --scope <scope> --decision add';
47
+ /** Recall hint when the scope holds facts but none matched the query. */
48
+ export const MEMORY_NO_MATCH_RECALL_HINT =
49
+ 'No memories in scope matched this query. Rephrase with words the fact uses, or store one with: gno remember "<fact>" --scope <scope> --decision add';
50
+ /** Recall hint when facts matched but none fit the token budget. */
51
+ export const MEMORY_OVER_BUDGET_RECALL_HINT =
52
+ "Matching memories did not fit the token budget. Raise --max-tokens (maxTokens) to return them.";
41
53
 
42
54
  // ─────────────────────────────────────────────────────────────────────────────
43
55
  // Errors
@@ -34,6 +34,8 @@ export {
34
34
  MEMORY_CANDIDATE_POOL,
35
35
  MEMORY_EMPTY_RECALL_HINT,
36
36
  MEMORY_LEXICAL_LIKELY_THRESHOLD,
37
+ MEMORY_NO_MATCH_RECALL_HINT,
38
+ MEMORY_OVER_BUDGET_RECALL_HINT,
37
39
  MEMORY_RECALL_MAX_FACTS,
38
40
  MEMORY_RECALL_MAX_TOKENS,
39
41
  MEMORY_SEMANTIC_LIKELY_THRESHOLD,
@@ -4,6 +4,7 @@
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
  *
@@ -13,6 +14,10 @@
13
14
  * @module src/ingestion/strip
14
15
  */
15
16
 
17
+ import { fromMarkdown } from "mdast-util-from-markdown";
18
+ import { gfmFromMarkdown } from "mdast-util-gfm";
19
+ import { gfm } from "micromark-extension-gfm";
20
+
16
21
  // ─────────────────────────────────────────────────────────────────────────────
17
22
  // Types
18
23
  // ─────────────────────────────────────────────────────────────────────────────
@@ -20,6 +25,7 @@
20
25
  export type ExcludedRangeKind =
21
26
  | "frontmatter"
22
27
  | "fenced_code"
28
+ | "indented_code"
23
29
  | "inline_code"
24
30
  | "html_comment";
25
31
 
@@ -39,150 +45,100 @@ export interface ExcludedRange {
39
45
  /** Frontmatter at start of file (YAML between --- delimiters) */
40
46
  const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
41
47
 
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
48
  /** HTML comments */
49
49
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
50
50
 
51
- interface OpenFence {
52
- marker: "`" | "~";
53
- length: number;
54
- start: number;
55
- }
56
-
51
+ /** Backtick (code span or fence) or tilde fence anywhere in the text. */
52
+ const INLINE_OR_FENCE_TRIGGER_REGEX = /`|~~~/;
53
+ /** Blockquote markers and indentation before a line's content. */
54
+ const CONTAINER_PREFIX_REGEX = /^[\t >]*/;
55
+ /** Four columns of indentation after any blockquote markers. */
56
+ const INDENTED_LINE_REGEX = /^(?:>[\t ]?)*(?:\t| {4})/;
57
57
  /**
58
- * Collect CommonMark fenced code ranges (backtick and tilde). A closer must
59
- * use the same character and be at least as long as the opener; when omitted,
60
- * CommonMark extends the fenced block through end of input.
58
+ * Lines after which an indented line may start an indented code block: an
59
+ * ATX heading, table row, HTML or definition line, or a thematic break or
60
+ * setext underline. After prose or a list item it continues the paragraph.
61
61
  */
62
- const collectFencedCodeRanges = (markdown: string): ExcludedRange[] => {
63
- const ranges: ExcludedRange[] = [];
64
- let offset = 0;
65
- let open: OpenFence | null = null;
66
-
67
- while (offset <= markdown.length) {
68
- const nextNl = markdown.indexOf("\n", offset);
69
- const lineEnd = nextNl === -1 ? markdown.length : nextNl;
70
- const rawLine = markdown.slice(offset, lineEnd);
71
- const logical = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
62
+ const BLOCK_END_LINE_REGEX =
63
+ /^(?:[#|<[]|([-*_=])[\t ]*(?:\1[\t ]*){2,}$)|>[\t ]*$/u;
72
64
 
73
- if (open) {
74
- const closeRun = FENCE_CLOSE_REGEX.exec(logical)?.[1];
75
- if (
76
- closeRun &&
77
- closeRun[0] === open.marker &&
78
- closeRun.length >= open.length
79
- ) {
80
- const end = nextNl === -1 ? markdown.length : nextNl + 1;
81
- ranges.push({ start: open.start, end, kind: "fenced_code" });
82
- open = null;
83
- }
84
- } else {
85
- const openMatch = FENCE_OPEN_REGEX.exec(logical);
86
- const run = openMatch?.[1];
87
- const suffix = openMatch?.[2] ?? "";
88
- // Backtick info strings cannot contain backticks (CommonMark).
89
- if (run && !(run[0] === "`" && suffix.includes("`"))) {
90
- open = {
91
- marker: run[0] as OpenFence["marker"],
92
- length: run.length,
93
- start: offset,
94
- };
95
- }
65
+ /**
66
+ * Cheap superset test for code: a backtick, a tilde fence, or an indented
67
+ * line that can open an indented code block (not a list continuation). When
68
+ * it fails the note has no code and the parser is skipped.
69
+ */
70
+ const mayContainCode = (markdown: string): boolean => {
71
+ if (INLINE_OR_FENCE_TRIGGER_REGEX.test(markdown)) return true;
72
+ let previous: string | null = null;
73
+ for (const rawLine of markdown.split("\n")) {
74
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
75
+ const content = line.replace(CONTAINER_PREFIX_REGEX, "");
76
+ if (
77
+ content !== "" &&
78
+ INDENTED_LINE_REGEX.test(line) &&
79
+ (previous === null ||
80
+ previous === "" ||
81
+ BLOCK_END_LINE_REGEX.test(previous))
82
+ ) {
83
+ return true;
96
84
  }
97
-
98
- if (nextNl === -1) break;
99
- offset = nextNl + 1;
100
- }
101
-
102
- if (open) {
103
- ranges.push({
104
- start: open.start,
105
- end: markdown.length,
106
- kind: "fenced_code",
107
- });
85
+ previous = content;
108
86
  }
109
-
110
- return ranges;
87
+ return false;
111
88
  };
112
89
 
113
- interface BacktickRun {
114
- end: number;
115
- length: number;
116
- start: number;
90
+ /** Opening fence at the start of a code node: backtick or tilde fence. */
91
+ const FENCE_START_REGEX = /^[\t ]*(?:`{3,}|~{3,})/;
92
+
93
+ interface CodeNode {
94
+ children?: CodeNode[];
95
+ position?: { start: { offset?: number }; end: { offset?: number } };
96
+ type: string;
117
97
  }
118
98
 
119
- /** CommonMark code spans close only on a backtick run of equal length. */
120
- const collectInlineCodeRanges = (
99
+ /**
100
+ * Code regions as the CommonMark + GFM parser sees them: inline code spans,
101
+ * fenced code blocks and indented code blocks, with their block context
102
+ * (paragraphs, blockquotes, list items) handled by the parser. Frontmatter is
103
+ * blanked first (same length, newlines kept) so it cannot open a fence.
104
+ */
105
+ const collectCodeRanges = (
121
106
  markdown: string,
122
- excludedRanges: ExcludedRange[]
107
+ frontmatterEnd: number
123
108
  ): ExcludedRange[] => {
124
- const runs: BacktickRun[] = [];
125
- let cursor = 0;
126
- let excludedIndex = 0;
127
- while (cursor < markdown.length) {
128
- while (
129
- excludedRanges[excludedIndex] &&
130
- excludedRanges[excludedIndex]!.end <= cursor
131
- ) {
132
- excludedIndex += 1;
133
- }
134
- const excluded = excludedRanges[excludedIndex];
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
-
109
+ if (!mayContainCode(markdown.slice(frontmatterEnd))) return [];
110
+ const source =
111
+ frontmatterEnd > 0
112
+ ? markdown.slice(0, frontmatterEnd).replace(/[^\r\n]/gu, " ") +
113
+ markdown.slice(frontmatterEnd)
114
+ : markdown;
115
+ const root = fromMarkdown(source, {
116
+ extensions: [gfm()],
117
+ mdastExtensions: [gfmFromMarkdown()],
118
+ }) as CodeNode;
165
119
  const ranges: ExcludedRange[] = [];
166
- let index = 0;
167
- while (index < runs.length) {
168
- const closeIndex = nextMatchingRun[index];
169
- const opener = runs[index];
170
- if (closeIndex === undefined || !opener) {
171
- index += 1;
172
- continue;
173
- }
174
- const closer = runs[closeIndex];
175
- if (!closer) {
176
- index += 1;
177
- continue;
120
+ const visit = (node: CodeNode): void => {
121
+ const start = node.position?.start.offset;
122
+ const end = node.position?.end.offset;
123
+ if (start !== undefined && end !== undefined && end > start) {
124
+ if (node.type === "inlineCode") {
125
+ ranges.push({ start, end, kind: "inline_code" });
126
+ return;
127
+ }
128
+ if (node.type === "code") {
129
+ ranges.push({
130
+ start,
131
+ end,
132
+ kind: FENCE_START_REGEX.test(source.slice(start, end))
133
+ ? "fenced_code"
134
+ : "indented_code",
135
+ });
136
+ return;
137
+ }
178
138
  }
179
- ranges.push({
180
- start: opener.start,
181
- end: closer.end,
182
- kind: "inline_code",
183
- });
184
- index = closeIndex + 1;
185
- }
139
+ for (const child of node.children ?? []) visit(child);
140
+ };
141
+ visit(root);
186
142
  return ranges;
187
143
  };
188
144
 
@@ -208,10 +164,7 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
208
164
  });
209
165
  }
210
166
 
211
- // 2. Fenced code blocks (backtick + tilde, CommonMark matching rules)
212
- ranges.push(...collectFencedCodeRanges(markdown));
213
-
214
- // 3. HTML comments
167
+ // 2. HTML comments
215
168
  HTML_COMMENT_REGEX.lastIndex = 0;
216
169
  let match: RegExpExecArray | null;
217
170
  while ((match = HTML_COMMENT_REGEX.exec(markdown)) !== null) {
@@ -222,10 +175,10 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
222
175
  });
223
176
  }
224
177
 
225
- // 4. Inline code. Pair delimiters only in visible prose so unmatched
226
- // backticks inside already-excluded blocks cannot consume later content.
227
- ranges.sort((a, b) => a.start - b.start);
228
- ranges.push(...collectInlineCodeRanges(markdown, ranges));
178
+ // 3. Code spans and fenced or indented code blocks, from the parser.
179
+ ranges.push(
180
+ ...collectCodeRanges(markdown, frontmatterMatch?.[0].length ?? 0)
181
+ );
229
182
 
230
183
  // Sort by start position for efficient lookup
231
184
  ranges.sort((a, b) => a.start - b.start);