@gmickel/gno 0.15.1 → 0.17.0
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 +36 -1
- package/package.json +7 -4
- package/src/cli/commands/ask.ts +9 -0
- package/src/cli/commands/query.ts +3 -2
- package/src/cli/pager.ts +1 -1
- package/src/cli/program.ts +89 -0
- package/src/core/links.ts +92 -20
- package/src/ingestion/sync.ts +267 -23
- package/src/ingestion/types.ts +2 -0
- package/src/ingestion/walker.ts +2 -1
- package/src/llm/nodeLlamaCpp/embedding.ts +53 -10
- package/src/mcp/tools/index.ts +30 -1
- package/src/mcp/tools/query.ts +22 -2
- package/src/mcp/tools/search.ts +8 -0
- package/src/mcp/tools/vsearch.ts +8 -0
- package/src/pipeline/answer.ts +324 -7
- package/src/pipeline/expansion.ts +243 -7
- package/src/pipeline/explain.ts +93 -5
- package/src/pipeline/hybrid.ts +240 -57
- package/src/pipeline/query-modes.ts +125 -0
- package/src/pipeline/rerank.ts +34 -13
- package/src/pipeline/search.ts +41 -3
- package/src/pipeline/temporal.ts +257 -0
- package/src/pipeline/types.ts +58 -0
- package/src/pipeline/vsearch.ts +107 -9
- package/src/serve/public/app.tsx +1 -3
- package/src/serve/public/globals.built.css +2 -2
- package/src/serve/public/lib/retrieval-filters.ts +167 -0
- package/src/serve/public/pages/Ask.tsx +339 -109
- package/src/serve/public/pages/Browse.tsx +71 -5
- package/src/serve/public/pages/DocView.tsx +2 -21
- package/src/serve/public/pages/Search.tsx +507 -120
- package/src/serve/routes/api.ts +202 -2
- package/src/store/migrations/006-document-metadata.ts +104 -0
- package/src/store/migrations/007-document-date-fields.ts +24 -0
- package/src/store/migrations/index.ts +3 -1
- package/src/store/sqlite/adapter.ts +218 -5
- package/src/store/types.ts +46 -0
package/README.md
CHANGED
|
@@ -139,16 +139,38 @@ gno skill install --target all # Both Claude + Codex
|
|
|
139
139
|
|
|
140
140
|
**BM25** indexes full documents (not chunks) with Snowball stemming, so "running" matches "run".
|
|
141
141
|
**Vector** embeds chunks with document titles for context awareness.
|
|
142
|
+
All retrieval modes also support metadata filters: `--since`, `--until`, `--category`, `--author`, `--tags-all`, `--tags-any`.
|
|
142
143
|
|
|
143
144
|
```bash
|
|
144
145
|
gno search "handleAuth" # Find exact matches
|
|
145
146
|
gno vsearch "error handling patterns" # Semantic similarity
|
|
146
147
|
gno query "database optimization" # Full pipeline
|
|
148
|
+
gno query "meeting decisions" --since "last month" --category "meeting,notes" --author "gordon"
|
|
147
149
|
gno ask "what did we decide" --answer # AI synthesis
|
|
148
150
|
```
|
|
149
151
|
|
|
150
152
|
Output formats: `--json`, `--files`, `--csv`, `--md`, `--xml`
|
|
151
153
|
|
|
154
|
+
### Retrieval V2 Controls
|
|
155
|
+
|
|
156
|
+
Existing query calls still work. Retrieval v2 adds optional structured intent control and deeper explain output.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Existing call (unchanged)
|
|
160
|
+
gno query "auth flow" --thorough
|
|
161
|
+
|
|
162
|
+
# Structured retrieval intent
|
|
163
|
+
gno query "auth flow" \
|
|
164
|
+
--query-mode term:"jwt refresh token -oauth1" \
|
|
165
|
+
--query-mode intent:"how refresh token rotation works" \
|
|
166
|
+
--query-mode hyde:"Refresh tokens rotate on each use and previous tokens are revoked." \
|
|
167
|
+
--explain
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- Modes: `term` (BM25-focused), `intent` (semantic-focused), `hyde` (single hypothetical passage)
|
|
171
|
+
- Explain includes stage timings, fallback/cache counters, and per-result score components
|
|
172
|
+
- `gno ask --json` includes `meta.answerContext` for adaptive source selection traces
|
|
173
|
+
|
|
152
174
|
---
|
|
153
175
|
|
|
154
176
|
## Agent Integration
|
|
@@ -372,7 +394,7 @@ graph TD
|
|
|
372
394
|
|
|
373
395
|
## Local Models
|
|
374
396
|
|
|
375
|
-
Models auto-download on first use to `~/.cache/gno/models/`. Alternatively, offload to a GPU server on your network using HTTP backends.
|
|
397
|
+
Models auto-download on first use to `~/.cache/gno/models/`. For deterministic startup, set `GNO_NO_AUTO_DOWNLOAD=1` and use `gno models pull` explicitly. Alternatively, offload to a GPU server on your network using HTTP backends.
|
|
376
398
|
|
|
377
399
|
| Model | Purpose | Size |
|
|
378
400
|
| :------------------ | :------------------------------------ | :----------- |
|
|
@@ -444,6 +466,19 @@ bun run lint && bun run typecheck
|
|
|
444
466
|
|
|
445
467
|
> **Contributing**: [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
|
446
468
|
|
|
469
|
+
### Evals and Benchmark Deltas
|
|
470
|
+
|
|
471
|
+
Use retrieval benchmark commands to track quality and latency over time:
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
bun run eval:hybrid
|
|
475
|
+
bun run eval:hybrid:baseline
|
|
476
|
+
bun run eval:hybrid:delta
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
- Benchmark guide: [evals/README.md](./evals/README.md)
|
|
480
|
+
- Latest baseline snapshot: [evals/fixtures/hybrid-baseline/latest.json](./evals/fixtures/hybrid-baseline/latest.json)
|
|
481
|
+
|
|
447
482
|
---
|
|
448
483
|
|
|
449
484
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gmickel/gno",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"embeddings",
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
"test:fixtures": "bun scripts/generate-test-fixtures.ts",
|
|
52
52
|
"evals": "bun scripts/update-eval-scores.ts",
|
|
53
53
|
"eval": "bun --bun evalite",
|
|
54
|
+
"eval:hybrid": "bun --bun evalite evals/hybrid.eval.ts",
|
|
55
|
+
"eval:hybrid:baseline": "bun scripts/hybrid-benchmark.ts --write",
|
|
56
|
+
"eval:hybrid:delta": "bun scripts/hybrid-benchmark.ts --delta",
|
|
54
57
|
"eval:watch": "bun --bun evalite watch",
|
|
55
58
|
"reset": "bun run src/index.ts reset --confirm",
|
|
56
59
|
"docs:verify": "bun run scripts/docs-verify.ts",
|
|
@@ -129,13 +132,13 @@
|
|
|
129
132
|
"evalite": "^1.0.0-beta.15",
|
|
130
133
|
"exceljs": "^4.4.0",
|
|
131
134
|
"lefthook": "^2.0.13",
|
|
132
|
-
"oxfmt": "^0.
|
|
133
|
-
"oxlint": "^1.
|
|
135
|
+
"oxfmt": "^0.28.0",
|
|
136
|
+
"oxlint": "^1.42.0",
|
|
134
137
|
"oxlint-tsgolint": "^0.11.4",
|
|
135
138
|
"pdf-lib": "^1.17.1",
|
|
136
139
|
"playwright": "^1.52.0",
|
|
137
140
|
"pptxgenjs": "^4.0.1",
|
|
138
|
-
"ultracite": "7.1.
|
|
141
|
+
"ultracite": "7.1.3",
|
|
139
142
|
"vitest": "^4.0.16"
|
|
140
143
|
},
|
|
141
144
|
"peerDependencies": {
|
package/src/cli/commands/ask.ts
CHANGED
|
@@ -192,6 +192,12 @@ export async function ask(
|
|
|
192
192
|
limit,
|
|
193
193
|
collection: options.collection,
|
|
194
194
|
lang: options.lang,
|
|
195
|
+
since: options.since,
|
|
196
|
+
until: options.until,
|
|
197
|
+
categories: options.categories,
|
|
198
|
+
author: options.author,
|
|
199
|
+
tagsAll: options.tagsAll,
|
|
200
|
+
tagsAny: options.tagsAny,
|
|
195
201
|
noExpand: options.noExpand,
|
|
196
202
|
noRerank: options.noRerank,
|
|
197
203
|
});
|
|
@@ -205,6 +211,7 @@ export async function ask(
|
|
|
205
211
|
// Generate grounded answer if requested
|
|
206
212
|
let answer: string | undefined;
|
|
207
213
|
let citations: Citation[] | undefined;
|
|
214
|
+
let answerContext: AskResult["meta"]["answerContext"] | undefined;
|
|
208
215
|
let answerGenerated = false;
|
|
209
216
|
|
|
210
217
|
// Only generate answer if:
|
|
@@ -236,6 +243,7 @@ export async function ask(
|
|
|
236
243
|
const processed = processAnswerResult(rawResult);
|
|
237
244
|
answer = processed.answer;
|
|
238
245
|
citations = processed.citations;
|
|
246
|
+
answerContext = processed.answerContext;
|
|
239
247
|
answerGenerated = true;
|
|
240
248
|
}
|
|
241
249
|
|
|
@@ -252,6 +260,7 @@ export async function ask(
|
|
|
252
260
|
vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
|
|
253
261
|
answerGenerated,
|
|
254
262
|
totalResults: results.length,
|
|
263
|
+
answerContext,
|
|
255
264
|
},
|
|
256
265
|
};
|
|
257
266
|
|
|
@@ -121,8 +121,9 @@ export async function query(
|
|
|
121
121
|
embedPort = embedResult.value;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// Create generation port (for expansion) - optional
|
|
125
|
-
|
|
124
|
+
// Create generation port (for expansion) - optional.
|
|
125
|
+
// Skip when structured query modes are provided.
|
|
126
|
+
if (!options.noExpand && !options.queryModes?.length) {
|
|
126
127
|
const genUri = options.genModel ?? preset.gen;
|
|
127
128
|
const genResult = await llm.createGenerationPort(genUri, {
|
|
128
129
|
policy,
|
package/src/cli/pager.ts
CHANGED
package/src/cli/program.ts
CHANGED
|
@@ -130,6 +130,17 @@ async function writeOutput(
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
function parseCsvValues(raw: unknown): string[] | undefined {
|
|
134
|
+
if (typeof raw !== "string") {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const values = raw
|
|
138
|
+
.split(",")
|
|
139
|
+
.map((v) => v.trim().toLowerCase())
|
|
140
|
+
.filter((v) => v.length > 0);
|
|
141
|
+
return values.length > 0 ? values : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
145
|
// Program Factory
|
|
135
146
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -203,6 +214,16 @@ function wireSearchCommands(program: Command): void {
|
|
|
203
214
|
.option("--min-score <num>", "minimum score threshold")
|
|
204
215
|
.option("-c, --collection <name>", "filter by collection")
|
|
205
216
|
.option("--lang <code>", "language filter/hint (BCP-47)")
|
|
217
|
+
.option(
|
|
218
|
+
"--since <date>",
|
|
219
|
+
"modified-at lower bound (ISO date/time or token)"
|
|
220
|
+
)
|
|
221
|
+
.option(
|
|
222
|
+
"--until <date>",
|
|
223
|
+
"modified-at upper bound (ISO date/time or token)"
|
|
224
|
+
)
|
|
225
|
+
.option("--category <values>", "require category match (comma-separated)")
|
|
226
|
+
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
206
227
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
207
228
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
208
229
|
.option("--full", "include full content")
|
|
@@ -247,6 +268,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
247
268
|
const limit = cmdOpts.limit
|
|
248
269
|
? parsePositiveInt("limit", cmdOpts.limit)
|
|
249
270
|
: getDefaultLimit(format);
|
|
271
|
+
const categories = parseCsvValues(cmdOpts.category);
|
|
250
272
|
|
|
251
273
|
const { search, formatSearch } = await import("./commands/search");
|
|
252
274
|
const result = await search(queryText, {
|
|
@@ -254,6 +276,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
254
276
|
minScore,
|
|
255
277
|
collection: cmdOpts.collection as string | undefined,
|
|
256
278
|
lang: cmdOpts.lang as string | undefined,
|
|
279
|
+
since: cmdOpts.since as string | undefined,
|
|
280
|
+
until: cmdOpts.until as string | undefined,
|
|
281
|
+
categories,
|
|
282
|
+
author: cmdOpts.author as string | undefined,
|
|
257
283
|
tagsAll,
|
|
258
284
|
tagsAny,
|
|
259
285
|
full: Boolean(cmdOpts.full),
|
|
@@ -293,6 +319,16 @@ function wireSearchCommands(program: Command): void {
|
|
|
293
319
|
.option("--min-score <num>", "minimum score threshold")
|
|
294
320
|
.option("-c, --collection <name>", "filter by collection")
|
|
295
321
|
.option("--lang <code>", "language filter/hint (BCP-47)")
|
|
322
|
+
.option(
|
|
323
|
+
"--since <date>",
|
|
324
|
+
"modified-at lower bound (ISO date/time or token)"
|
|
325
|
+
)
|
|
326
|
+
.option(
|
|
327
|
+
"--until <date>",
|
|
328
|
+
"modified-at upper bound (ISO date/time or token)"
|
|
329
|
+
)
|
|
330
|
+
.option("--category <values>", "require category match (comma-separated)")
|
|
331
|
+
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
296
332
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
297
333
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
298
334
|
.option("--full", "include full content")
|
|
@@ -337,6 +373,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
337
373
|
const limit = cmdOpts.limit
|
|
338
374
|
? parsePositiveInt("limit", cmdOpts.limit)
|
|
339
375
|
: getDefaultLimit(format);
|
|
376
|
+
const categories = parseCsvValues(cmdOpts.category);
|
|
340
377
|
|
|
341
378
|
const { vsearch, formatVsearch } = await import("./commands/vsearch");
|
|
342
379
|
const result = await vsearch(queryText, {
|
|
@@ -344,6 +381,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
344
381
|
minScore,
|
|
345
382
|
collection: cmdOpts.collection as string | undefined,
|
|
346
383
|
lang: cmdOpts.lang as string | undefined,
|
|
384
|
+
since: cmdOpts.since as string | undefined,
|
|
385
|
+
until: cmdOpts.until as string | undefined,
|
|
386
|
+
categories,
|
|
387
|
+
author: cmdOpts.author as string | undefined,
|
|
347
388
|
tagsAll,
|
|
348
389
|
tagsAny,
|
|
349
390
|
full: Boolean(cmdOpts.full),
|
|
@@ -378,6 +419,16 @@ function wireSearchCommands(program: Command): void {
|
|
|
378
419
|
.option("--min-score <num>", "minimum score threshold")
|
|
379
420
|
.option("-c, --collection <name>", "filter by collection")
|
|
380
421
|
.option("--lang <code>", "language hint (BCP-47)")
|
|
422
|
+
.option(
|
|
423
|
+
"--since <date>",
|
|
424
|
+
"modified-at lower bound (ISO date/time or token)"
|
|
425
|
+
)
|
|
426
|
+
.option(
|
|
427
|
+
"--until <date>",
|
|
428
|
+
"modified-at upper bound (ISO date/time or token)"
|
|
429
|
+
)
|
|
430
|
+
.option("--category <values>", "require category match (comma-separated)")
|
|
431
|
+
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
381
432
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
382
433
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
383
434
|
.option("--full", "include full content")
|
|
@@ -386,6 +437,12 @@ function wireSearchCommands(program: Command): void {
|
|
|
386
437
|
.option("--thorough", "enable query expansion (slower, ~5-8s)")
|
|
387
438
|
.option("--no-expand", "disable query expansion")
|
|
388
439
|
.option("--no-rerank", "disable reranking")
|
|
440
|
+
.option(
|
|
441
|
+
"--query-mode <mode:text>",
|
|
442
|
+
"structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
|
|
443
|
+
(value: string, previous: string[] = []) => [...previous, value],
|
|
444
|
+
[]
|
|
445
|
+
)
|
|
389
446
|
.option("--explain", "include scoring explanation")
|
|
390
447
|
.option("--json", "JSON output")
|
|
391
448
|
.option("--md", "Markdown output")
|
|
@@ -407,6 +464,17 @@ function wireSearchCommands(program: Command): void {
|
|
|
407
464
|
throw new CliError("VALIDATION", "--min-score must be between 0 and 1");
|
|
408
465
|
}
|
|
409
466
|
|
|
467
|
+
// Parse optional structured query modes
|
|
468
|
+
let queryModes: import("../pipeline/types").QueryModeInput[] | undefined;
|
|
469
|
+
if (Array.isArray(cmdOpts.queryMode) && cmdOpts.queryMode.length > 0) {
|
|
470
|
+
const { parseQueryModeSpecs } = await import("../pipeline/query-modes");
|
|
471
|
+
const parsed = parseQueryModeSpecs(cmdOpts.queryMode as string[]);
|
|
472
|
+
if (!parsed.ok) {
|
|
473
|
+
throw new CliError("VALIDATION", parsed.error.message);
|
|
474
|
+
}
|
|
475
|
+
queryModes = parsed.value;
|
|
476
|
+
}
|
|
477
|
+
|
|
410
478
|
// Parse and validate tag filters
|
|
411
479
|
let tagsAll: string[] | undefined;
|
|
412
480
|
let tagsAny: string[] | undefined;
|
|
@@ -427,6 +495,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
427
495
|
const limit = cmdOpts.limit
|
|
428
496
|
? parsePositiveInt("limit", cmdOpts.limit)
|
|
429
497
|
: getDefaultLimit(format);
|
|
498
|
+
const categories = parseCsvValues(cmdOpts.category);
|
|
430
499
|
|
|
431
500
|
// Determine expansion/rerank settings based on flags
|
|
432
501
|
// Priority: --fast > --thorough > --no-expand/--no-rerank > default
|
|
@@ -458,12 +527,17 @@ function wireSearchCommands(program: Command): void {
|
|
|
458
527
|
minScore,
|
|
459
528
|
collection: cmdOpts.collection as string | undefined,
|
|
460
529
|
lang: cmdOpts.lang as string | undefined,
|
|
530
|
+
since: cmdOpts.since as string | undefined,
|
|
531
|
+
until: cmdOpts.until as string | undefined,
|
|
532
|
+
categories,
|
|
533
|
+
author: cmdOpts.author as string | undefined,
|
|
461
534
|
tagsAll,
|
|
462
535
|
tagsAny,
|
|
463
536
|
full: Boolean(cmdOpts.full),
|
|
464
537
|
lineNumbers: Boolean(cmdOpts.lineNumbers),
|
|
465
538
|
noExpand,
|
|
466
539
|
noRerank,
|
|
540
|
+
queryModes,
|
|
467
541
|
explain: Boolean(cmdOpts.explain),
|
|
468
542
|
json: format === "json",
|
|
469
543
|
md: format === "md",
|
|
@@ -490,6 +564,16 @@ function wireSearchCommands(program: Command): void {
|
|
|
490
564
|
.option("-n, --limit <num>", "max source results")
|
|
491
565
|
.option("-c, --collection <name>", "filter by collection")
|
|
492
566
|
.option("--lang <code>", "language hint (BCP-47)")
|
|
567
|
+
.option(
|
|
568
|
+
"--since <date>",
|
|
569
|
+
"modified-at lower bound (ISO date/time or token)"
|
|
570
|
+
)
|
|
571
|
+
.option(
|
|
572
|
+
"--until <date>",
|
|
573
|
+
"modified-at upper bound (ISO date/time or token)"
|
|
574
|
+
)
|
|
575
|
+
.option("--category <values>", "require category match (comma-separated)")
|
|
576
|
+
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
493
577
|
.option("--fast", "skip expansion and reranking (fastest)")
|
|
494
578
|
.option("--thorough", "enable query expansion (slower)")
|
|
495
579
|
.option("--answer", "generate short grounded answer")
|
|
@@ -515,6 +599,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
515
599
|
const maxAnswerTokens = cmdOpts.maxAnswerTokens
|
|
516
600
|
? parsePositiveInt("max-answer-tokens", cmdOpts.maxAnswerTokens)
|
|
517
601
|
: undefined;
|
|
602
|
+
const categories = parseCsvValues(cmdOpts.category);
|
|
518
603
|
|
|
519
604
|
// Determine expansion/rerank settings based on flags
|
|
520
605
|
// Default: skip expansion (balanced mode)
|
|
@@ -535,6 +620,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
535
620
|
limit,
|
|
536
621
|
collection: cmdOpts.collection as string | undefined,
|
|
537
622
|
lang: cmdOpts.lang as string | undefined,
|
|
623
|
+
since: cmdOpts.since as string | undefined,
|
|
624
|
+
until: cmdOpts.until as string | undefined,
|
|
625
|
+
categories,
|
|
626
|
+
author: cmdOpts.author as string | undefined,
|
|
538
627
|
noExpand,
|
|
539
628
|
noRerank,
|
|
540
629
|
// Per spec: --answer defaults to false, --no-answer forces retrieval-only
|
package/src/core/links.ts
CHANGED
|
@@ -80,6 +80,10 @@ const UNSAFE_PERCENT_CODES = new Set(["%2F", "%5C", "%00", "%2f", "%5c"]);
|
|
|
80
80
|
*/
|
|
81
81
|
const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g;
|
|
82
82
|
|
|
83
|
+
/** Logseq embed: {{embed [[Page]]}} or {{embed ((block-id))}} */
|
|
84
|
+
const LOGSEQ_EMBED_REGEX =
|
|
85
|
+
/\{\{\s*embed\s+(\[\[[^\]]+\]\]|\(\([^)]+\)\))\s*\}\}/gi;
|
|
86
|
+
|
|
83
87
|
/**
|
|
84
88
|
* Markdown inline link: [text](url)
|
|
85
89
|
* Captures: 1=text, 2=url (path and optional anchor)
|
|
@@ -273,6 +277,43 @@ export function parseLinks(
|
|
|
273
277
|
): ParsedLink[] {
|
|
274
278
|
const links: ParsedLink[] = [];
|
|
275
279
|
|
|
280
|
+
const pushWikiLink = (
|
|
281
|
+
raw: string,
|
|
282
|
+
target: string,
|
|
283
|
+
startOffset: number,
|
|
284
|
+
endOffset: number,
|
|
285
|
+
displayText?: string
|
|
286
|
+
): void => {
|
|
287
|
+
const trimmedTarget = target.trim();
|
|
288
|
+
const hasScheme =
|
|
289
|
+
/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedTarget) ||
|
|
290
|
+
trimmedTarget.startsWith("mailto:");
|
|
291
|
+
if (hasScheme || trimmedTarget.startsWith("//")) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const parts = parseTargetParts(trimmedTarget);
|
|
296
|
+
if (!parts.ref) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const startPos = offsetToPosition(startOffset, lineOffsets);
|
|
301
|
+
const endPos = offsetToPosition(endOffset, lineOffsets);
|
|
302
|
+
|
|
303
|
+
links.push({
|
|
304
|
+
kind: "wiki",
|
|
305
|
+
raw,
|
|
306
|
+
targetRef: parts.ref,
|
|
307
|
+
targetAnchor: parts.anchor,
|
|
308
|
+
targetCollection: parts.collection,
|
|
309
|
+
displayText,
|
|
310
|
+
startLine: startPos.line,
|
|
311
|
+
startCol: startPos.col,
|
|
312
|
+
endLine: endPos.line,
|
|
313
|
+
endCol: endPos.col,
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
276
317
|
// Parse wiki links
|
|
277
318
|
WIKI_LINK_REGEX.lastIndex = 0;
|
|
278
319
|
let match: RegExpExecArray | null;
|
|
@@ -281,6 +322,14 @@ export function parseLinks(
|
|
|
281
322
|
const startOffset = match.index;
|
|
282
323
|
const endOffset = startOffset + match[0].length;
|
|
283
324
|
|
|
325
|
+
// Skip [[target]] nested in Logseq alias syntax: [Display]([[target]])
|
|
326
|
+
if (
|
|
327
|
+
markdown.slice(Math.max(0, startOffset - 2), startOffset) === "](" &&
|
|
328
|
+
markdown.slice(endOffset, endOffset + 1) === ")"
|
|
329
|
+
) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
284
333
|
// Skip if inside excluded range
|
|
285
334
|
if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) {
|
|
286
335
|
continue;
|
|
@@ -307,31 +356,35 @@ export function parseLinks(
|
|
|
307
356
|
}
|
|
308
357
|
|
|
309
358
|
const trimmedTarget = targetPart.trim();
|
|
310
|
-
|
|
311
|
-
/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedTarget) ||
|
|
312
|
-
trimmedTarget.startsWith("mailto:");
|
|
313
|
-
if (hasScheme || trimmedTarget.startsWith("//")) {
|
|
359
|
+
if (!trimmedTarget) {
|
|
314
360
|
continue;
|
|
315
361
|
}
|
|
362
|
+
pushWikiLink(match[0], trimmedTarget, startOffset, endOffset, displayText);
|
|
363
|
+
}
|
|
316
364
|
|
|
317
|
-
|
|
318
|
-
|
|
365
|
+
// Parse Logseq embeds as links
|
|
366
|
+
LOGSEQ_EMBED_REGEX.lastIndex = 0;
|
|
319
367
|
|
|
320
|
-
|
|
321
|
-
const
|
|
368
|
+
while ((match = LOGSEQ_EMBED_REGEX.exec(markdown)) !== null) {
|
|
369
|
+
const startOffset = match.index;
|
|
370
|
+
const endOffset = startOffset + match[0].length;
|
|
322
371
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
372
|
+
if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const embedTarget = match[1]?.trim();
|
|
377
|
+
if (!embedTarget) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (embedTarget.startsWith("((") && embedTarget.endsWith("))")) {
|
|
382
|
+
const blockId = embedTarget.slice(2, -2).trim();
|
|
383
|
+
if (blockId.length === 0) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
pushWikiLink(match[0], blockId, startOffset, endOffset);
|
|
387
|
+
}
|
|
335
388
|
}
|
|
336
389
|
|
|
337
390
|
// Parse markdown links
|
|
@@ -350,6 +403,25 @@ export function parseLinks(
|
|
|
350
403
|
const url = match[2];
|
|
351
404
|
if (!url) continue;
|
|
352
405
|
|
|
406
|
+
// Logseq alias syntax: [Display]([[Target]])
|
|
407
|
+
if (url.startsWith("[[") && url.endsWith("]]")) {
|
|
408
|
+
const innerTarget = url.slice(2, -2).trim();
|
|
409
|
+
if (innerTarget.length > 0) {
|
|
410
|
+
const displayText =
|
|
411
|
+
linkText && linkText !== innerTarget
|
|
412
|
+
? truncateText(linkText, MAX_DISPLAY_TEXT_GRAPHEMES)
|
|
413
|
+
: undefined;
|
|
414
|
+
pushWikiLink(
|
|
415
|
+
match[0],
|
|
416
|
+
innerTarget,
|
|
417
|
+
startOffset,
|
|
418
|
+
endOffset,
|
|
419
|
+
displayText
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
353
425
|
// Skip external URLs
|
|
354
426
|
if (EXTERNAL_URL_REGEX.test(url)) {
|
|
355
427
|
continue;
|