@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.
- package/README.md +1 -1
- package/assets/skill/SKILL.md +7 -2
- package/assets/skill/recipes/memory-scoped-recall.md +10 -5
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.0.zip → gno-browser-clipper-v2.8.2.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +52 -6
- package/spec/mcp.md +8 -4
- package/spec/output-schemas/memory-recall.schema.json +1 -1
- package/spec/output-schemas/status.schema.json +3 -3
- package/src/cli/commands/graph.ts +3 -1
- package/src/cli/commands/links.ts +27 -49
- package/src/cli/commands/status.ts +1 -0
- package/src/converters/native/markdown.ts +3 -1
- package/src/core/audit-links.ts +57 -4
- package/src/core/audit-outside-index.ts +243 -0
- package/src/core/audit-workspace.ts +13 -7
- package/src/core/audit.ts +9 -1
- package/src/core/change-diff.ts +3 -3
- package/src/core/link-inventory-markdown.ts +2 -3
- package/src/core/link-inventory-opaque.ts +3 -1
- package/src/core/links.ts +40 -17
- package/src/core/memory-recall.ts +254 -15
- package/src/core/memory-types.ts +12 -0
- package/src/core/memory.ts +2 -0
- package/src/ingestion/strip.ts +89 -136
- package/src/ingestion/sync.ts +34 -16
- package/src/mcp/tools/links.ts +71 -93
- package/src/mcp/tools/status.ts +1 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +4 -0
- package/src/sdk/client.ts +1 -0
- package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
- package/src/serve/routes/graph.ts +3 -1
- package/src/serve/routes/links.ts +32 -50
- package/src/serve/server.ts +2 -1
- package/src/serve/status.ts +1 -0
- package/src/store/sqlite/adapter.ts +87 -106
- package/src/store/sqlite/graph-link-resolver.ts +7 -0
- package/src/store/sqlite/graph-similarity.ts +96 -0
- package/src/store/sqlite/workspace-link-resolver.ts +119 -31
- package/src/store/types.ts +15 -2
- package/src/store/vector/status.ts +27 -0
- package/src/store/vector/stored-vectors.ts +158 -0
- package/src/store/vector/types.ts +6 -0
- package/src/store/vector/variant-search.ts +30 -14
- 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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
/**
|
|
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: [
|
|
390
|
+
facets: [],
|
|
180
391
|
retrievalRank: rank,
|
|
181
392
|
value: fact,
|
|
182
393
|
})),
|
|
183
|
-
requestedFacets:
|
|
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
|
-
: {
|
|
500
|
+
: {
|
|
501
|
+
hint: await emptyRecallHint(deps, {
|
|
502
|
+
collection: collection.name,
|
|
503
|
+
scopes,
|
|
504
|
+
matched: materialized.length,
|
|
505
|
+
}),
|
|
506
|
+
}),
|
|
268
507
|
};
|
|
269
508
|
}
|
package/src/core/memory-types.ts
CHANGED
|
@@ -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
|
package/src/core/memory.ts
CHANGED
|
@@ -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,
|
package/src/ingestion/strip.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
/**
|
|
120
|
-
|
|
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
|
-
|
|
107
|
+
frontmatterEnd: number
|
|
123
108
|
): ExcludedRange[] => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
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
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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);
|