@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existence-only classification of unresolved workspace wiki links and
|
|
3
|
+
* relative Markdown links whose target is a file inside the link workspace
|
|
4
|
+
* that is not an indexed document (an attachment, a script beside a note, or
|
|
5
|
+
* a note in an unindexed or excluded folder). Obsidian
|
|
6
|
+
* resolves such links, so the link audit reports them as `outside-index`
|
|
7
|
+
* instead of unresolved. Only file names are listed: no file is opened,
|
|
8
|
+
* indexed, or returned, and no graph edge is created.
|
|
9
|
+
*
|
|
10
|
+
* @module src/core/audit-outside-index
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Database } from "bun:sqlite";
|
|
14
|
+
|
|
15
|
+
// node:fs/promises readdir/realpath/stat: directory enumeration and symlink
|
|
16
|
+
// resolution; Bun has no equivalent that can skip an unreadable folder
|
|
17
|
+
// instead of failing the scan.
|
|
18
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
19
|
+
// node:path join: platform path algebra; no Bun equivalent.
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import type { AuditLinkSnapshot } from "../store/sqlite/graph-link-resolver";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
createWorkspaceFileMatcher,
|
|
26
|
+
loadLinkWorkspaceMemberships,
|
|
27
|
+
} from "../store/sqlite/workspace-link-resolver";
|
|
28
|
+
import { pathContains, placeDocument } from "./link-workspace";
|
|
29
|
+
|
|
30
|
+
/** Path identity for exact-path matching: NFC, case-insensitive. */
|
|
31
|
+
const pathKey = (path: string): string => path.normalize("NFC").toLowerCase();
|
|
32
|
+
|
|
33
|
+
/** Upper bound of files listed per workspace; beyond it the listing is partial. */
|
|
34
|
+
export const WORKSPACE_FILE_LISTING_MAX_FILES = 200_000;
|
|
35
|
+
|
|
36
|
+
export interface WorkspaceFileListing {
|
|
37
|
+
/** Workspace-relative POSIX paths (NFC) of every non-hidden file. */
|
|
38
|
+
files: string[];
|
|
39
|
+
/** False when a folder could not be read or the file bound was reached. */
|
|
40
|
+
complete: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface WorkspaceDirectoryEntry {
|
|
44
|
+
name: string;
|
|
45
|
+
isFile(): boolean;
|
|
46
|
+
isDirectory(): boolean;
|
|
47
|
+
isSymbolicLink(): boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Filesystem reads the listing needs; injectable for tests. */
|
|
51
|
+
export interface WorkspaceFileSystem {
|
|
52
|
+
readDirectory(path: string): Promise<WorkspaceDirectoryEntry[]>;
|
|
53
|
+
realPath(path: string): Promise<string>;
|
|
54
|
+
isRegularFile(path: string): Promise<boolean>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const nodeWorkspaceFileSystem: WorkspaceFileSystem = {
|
|
58
|
+
readDirectory: (path) => readdir(path, { withFileTypes: true }),
|
|
59
|
+
realPath: (path) => realpath(path),
|
|
60
|
+
isRegularFile: async (path) => (await stat(path)).isFile(),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A symlink target that does not exist (or loops) is not a file, not an error. */
|
|
64
|
+
const MISSING_TARGET_CODES = new Set(["ENOENT", "ENOTDIR", "ELOOP"]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A symlink counts as a file only when it resolves to an existing regular
|
|
68
|
+
* file inside the workspace. Dangling links, links to folders and links that
|
|
69
|
+
* leave the workspace are not files. Other errors are reported as such.
|
|
70
|
+
*/
|
|
71
|
+
const symlinkIsWorkspaceFile = async (
|
|
72
|
+
fs: WorkspaceFileSystem,
|
|
73
|
+
realRoot: string,
|
|
74
|
+
path: string
|
|
75
|
+
): Promise<"file" | "not-file" | "error"> => {
|
|
76
|
+
try {
|
|
77
|
+
const target = await fs.realPath(path);
|
|
78
|
+
if (!pathContains(realRoot, target)) return "not-file";
|
|
79
|
+
return (await fs.isRegularFile(target)) ? "file" : "not-file";
|
|
80
|
+
} catch (cause) {
|
|
81
|
+
const code = (cause as NodeJS.ErrnoException | undefined)?.code;
|
|
82
|
+
return code !== undefined && MISSING_TARGET_CODES.has(code)
|
|
83
|
+
? "not-file"
|
|
84
|
+
: "error";
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List every file below a workspace root once. Hidden files and folders
|
|
90
|
+
* (`.obsidian`, `.trash`, `.git`) are skipped, as Obsidian does; symlinked
|
|
91
|
+
* folders are not descended. Filesystem calls use the names as stored on
|
|
92
|
+
* disk; only the returned paths are NFC-normalized for matching. An
|
|
93
|
+
* unreadable folder is skipped and marks the listing incomplete, so links
|
|
94
|
+
* into it stay unresolved.
|
|
95
|
+
*/
|
|
96
|
+
export const listWorkspaceFiles = async (
|
|
97
|
+
root: string,
|
|
98
|
+
options: {
|
|
99
|
+
maxFiles?: number;
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
fileSystem?: WorkspaceFileSystem;
|
|
102
|
+
} = {}
|
|
103
|
+
): Promise<WorkspaceFileListing> => {
|
|
104
|
+
const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
|
|
105
|
+
const fs = options.fileSystem ?? nodeWorkspaceFileSystem;
|
|
106
|
+
const files: string[] = [];
|
|
107
|
+
let complete = true;
|
|
108
|
+
let realRoot: string;
|
|
109
|
+
try {
|
|
110
|
+
realRoot = await fs.realPath(root);
|
|
111
|
+
} catch {
|
|
112
|
+
return { files, complete: false };
|
|
113
|
+
}
|
|
114
|
+
// Each folder keeps its on-disk segments; matching uses the NFC path.
|
|
115
|
+
const queue: string[][] = [[]];
|
|
116
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
117
|
+
options.signal?.throwIfAborted();
|
|
118
|
+
const segments = queue[head] as string[];
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = await fs.readDirectory(join(root, ...segments));
|
|
122
|
+
} catch {
|
|
123
|
+
complete = false;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.name.startsWith(".")) continue;
|
|
128
|
+
const entrySegments = [...segments, entry.name];
|
|
129
|
+
let isFile = entry.isFile();
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
queue.push(entrySegments);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (entry.isSymbolicLink()) {
|
|
135
|
+
const kind = await symlinkIsWorkspaceFile(
|
|
136
|
+
fs,
|
|
137
|
+
realRoot,
|
|
138
|
+
join(root, ...entrySegments)
|
|
139
|
+
);
|
|
140
|
+
if (kind === "error") complete = false;
|
|
141
|
+
isFile = kind === "file";
|
|
142
|
+
}
|
|
143
|
+
if (!isFile) continue;
|
|
144
|
+
if (files.length >= maxFiles) return { files, complete: false };
|
|
145
|
+
files.push(entrySegments.join("/").normalize("NFC"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { files, complete };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Mark unresolved plain wiki links and relative Markdown links of audited
|
|
153
|
+
* documents inside a link workspace whose target exists as a workspace file.
|
|
154
|
+
* A wiki link matches under workspace resolution rules; a Markdown link must
|
|
155
|
+
* name the file's exact path (NFC, case-insensitive). Each involved workspace
|
|
156
|
+
* is listed once per call. A failed or partial listing never marks a link
|
|
157
|
+
* that is not in it; the snapshot then carries one diagnostic.
|
|
158
|
+
*/
|
|
159
|
+
export const markOutsideIndexLinks = async (
|
|
160
|
+
db: Database,
|
|
161
|
+
snapshot: AuditLinkSnapshot,
|
|
162
|
+
options: { maxFiles?: number; signal?: AbortSignal } = {}
|
|
163
|
+
): Promise<AuditLinkSnapshot> => {
|
|
164
|
+
const audited = snapshot.auditedDocumentIds
|
|
165
|
+
? new Set(snapshot.auditedDocumentIds)
|
|
166
|
+
: null;
|
|
167
|
+
const candidates = snapshot.links
|
|
168
|
+
.map((link, index) => ({ link, index }))
|
|
169
|
+
.filter(
|
|
170
|
+
({ link }) =>
|
|
171
|
+
link.resolved === null &&
|
|
172
|
+
(link.linkType === "markdown" ||
|
|
173
|
+
(link.linkType === "wiki" && link.explicitCollection !== true)) &&
|
|
174
|
+
(audited === null || audited.has(link.sourceId))
|
|
175
|
+
);
|
|
176
|
+
if (candidates.length === 0) return snapshot;
|
|
177
|
+
const memberships = loadLinkWorkspaceMemberships(db);
|
|
178
|
+
const documentRelPaths = new Map(
|
|
179
|
+
snapshot.documents.map((document) => [document.id, document.relPath])
|
|
180
|
+
);
|
|
181
|
+
type PlacedLink = {
|
|
182
|
+
link: AuditLinkSnapshot["links"][number];
|
|
183
|
+
index: number;
|
|
184
|
+
key: string;
|
|
185
|
+
} & ({ sourcePath: string } | { targetPath: string });
|
|
186
|
+
const placed = candidates.flatMap(({ link, index }): PlacedLink[] => {
|
|
187
|
+
const membership = memberships.get(link.sourceCollection);
|
|
188
|
+
const placement = placeDocument(
|
|
189
|
+
membership,
|
|
190
|
+
documentRelPaths.get(link.sourceId) ?? link.sourceRelPath
|
|
191
|
+
);
|
|
192
|
+
if (placement.key === null) return [];
|
|
193
|
+
if (link.linkType === "wiki") {
|
|
194
|
+
return [{ link, index, key: placement.key, sourcePath: placement.path }];
|
|
195
|
+
}
|
|
196
|
+
// A Markdown target is stored collection-relative; place it the same way.
|
|
197
|
+
const target = placeDocument(membership, link.targetRefNorm);
|
|
198
|
+
return target.key === placement.key
|
|
199
|
+
? [{ link, index, key: placement.key, targetPath: target.path }]
|
|
200
|
+
: [];
|
|
201
|
+
});
|
|
202
|
+
if (placed.length === 0) return snapshot;
|
|
203
|
+
const listings = new Map<
|
|
204
|
+
string,
|
|
205
|
+
{
|
|
206
|
+
matches: ReturnType<typeof createWorkspaceFileMatcher>;
|
|
207
|
+
paths: Set<string>;
|
|
208
|
+
}
|
|
209
|
+
>();
|
|
210
|
+
const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
|
|
211
|
+
let incomplete = 0;
|
|
212
|
+
for (const key of new Set(placed.map((entry) => entry.key))) {
|
|
213
|
+
const listing = await listWorkspaceFiles(key, {
|
|
214
|
+
maxFiles,
|
|
215
|
+
signal: options.signal,
|
|
216
|
+
});
|
|
217
|
+
if (!listing.complete) incomplete += 1;
|
|
218
|
+
listings.set(key, {
|
|
219
|
+
matches: createWorkspaceFileMatcher(listing.files),
|
|
220
|
+
paths: new Set(listing.files.map(pathKey)),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const links = [...snapshot.links];
|
|
224
|
+
for (const entry of placed) {
|
|
225
|
+
const listing = listings.get(entry.key);
|
|
226
|
+
const exists =
|
|
227
|
+
"targetPath" in entry
|
|
228
|
+
? listing?.paths.has(pathKey(entry.targetPath)) === true
|
|
229
|
+
: listing?.matches(entry.link.targetRefNorm, entry.sourcePath) === true;
|
|
230
|
+
if (exists) {
|
|
231
|
+
links[entry.index] = { ...entry.link, outsideIndex: true };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
...snapshot,
|
|
236
|
+
links,
|
|
237
|
+
...(incomplete > 0
|
|
238
|
+
? {
|
|
239
|
+
outsideIndexDiagnostic: `The file listing of ${incomplete} link workspace${incomplete === 1 ? " was" : "s were"} incomplete (an unreadable folder or link, or more than ${maxFiles} files); links to files it missed stay unresolved`,
|
|
240
|
+
}
|
|
241
|
+
: {}),
|
|
242
|
+
};
|
|
243
|
+
};
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "./audit";
|
|
33
33
|
import { evaluateFreshnessAudit } from "./audit-freshness";
|
|
34
34
|
import { evaluateLinkAudit } from "./audit-links";
|
|
35
|
+
import { markOutsideIndexLinks } from "./audit-outside-index";
|
|
35
36
|
import { evaluateProvenanceAudit } from "./audit-provenance";
|
|
36
37
|
import {
|
|
37
38
|
extractCaptureSourceFromFrontmatter,
|
|
@@ -429,15 +430,20 @@ const loadWorkspaceSnapshot = async (
|
|
|
429
430
|
? captureAuditLinkSnapshot(options.store.getRawDb())
|
|
430
431
|
: emptyLinkSnapshot();
|
|
431
432
|
const selectedIds = new Set(selected.documents.map(({ id }) => id));
|
|
433
|
+
const links = filterLinkSnapshot(
|
|
434
|
+
rawLinks,
|
|
435
|
+
selectedIds,
|
|
436
|
+
selected.documents,
|
|
437
|
+
selected.truncated,
|
|
438
|
+
filters.collections
|
|
439
|
+
);
|
|
432
440
|
return {
|
|
433
441
|
documents: observed,
|
|
434
|
-
links:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
filters.collections
|
|
440
|
-
),
|
|
442
|
+
links: options.categories.includes("links")
|
|
443
|
+
? await markOutsideIndexLinks(options.store.getRawDb(), links, {
|
|
444
|
+
signal: options.signal,
|
|
445
|
+
})
|
|
446
|
+
: links,
|
|
441
447
|
truncated: selected.truncated,
|
|
442
448
|
};
|
|
443
449
|
};
|
package/src/core/audit.ts
CHANGED
|
@@ -226,8 +226,16 @@ const materializeRule = (
|
|
|
226
226
|
)
|
|
227
227
|
.sort(compareAuditFindings);
|
|
228
228
|
|
|
229
|
+
// Only warning/error findings fail a passing rule; info findings of an
|
|
230
|
+
// informational rule (links.outside-index) leave it passing.
|
|
229
231
|
let status = contribution.status;
|
|
230
|
-
if (
|
|
232
|
+
if (
|
|
233
|
+
status === "pass" &&
|
|
234
|
+
findings.some(
|
|
235
|
+
(finding) =>
|
|
236
|
+
finding.severity === "error" || finding.severity === "warning"
|
|
237
|
+
)
|
|
238
|
+
) {
|
|
231
239
|
status = "fail";
|
|
232
240
|
}
|
|
233
241
|
|
package/src/core/change-diff.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
|
|
13
13
|
import { parseFrontmatter } from "../ingestion/frontmatter";
|
|
14
14
|
import { buildLineOffsets } from "../ingestion/position";
|
|
15
|
-
import { getExcludedRanges } from "../ingestion/strip";
|
|
15
|
+
import { type ExcludedRange, getExcludedRanges } from "../ingestion/strip";
|
|
16
16
|
import { normalizeMarkdownPath, normalizeWikiName, parseLinks } from "./links";
|
|
17
17
|
import { extractSections } from "./sections";
|
|
18
18
|
|
|
@@ -107,9 +107,9 @@ const extractTypedEdges = (markdown: string): string[] => {
|
|
|
107
107
|
export const extractDocumentStructure = (
|
|
108
108
|
markdown: string,
|
|
109
109
|
relPath: string,
|
|
110
|
-
dateFields: Readonly<Record<string, string>> | null | undefined
|
|
110
|
+
dateFields: Readonly<Record<string, string>> | null | undefined,
|
|
111
|
+
excludedRanges: ExcludedRange[] = getExcludedRanges(markdown)
|
|
111
112
|
): DocumentStructureSnapshot => {
|
|
112
|
-
const excludedRanges = getExcludedRanges(markdown);
|
|
113
113
|
const links = parseLinks(markdown, buildLineOffsets(markdown), excludedRanges)
|
|
114
114
|
.map((link) => linkSummary(link, relPath))
|
|
115
115
|
.filter((value): value is string => value !== null);
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
pushInventoryToken,
|
|
24
24
|
} from "./link-inventory-opaque";
|
|
25
25
|
import { isRelevantDestination } from "./link-relevance";
|
|
26
|
-
import { parseTargetParts } from "./links";
|
|
26
|
+
import { parseTargetParts, splitWikiLinkContent } from "./links";
|
|
27
27
|
|
|
28
28
|
const EXTERNAL_URL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
29
29
|
|
|
@@ -51,8 +51,7 @@ export function inventoryWikiLinks(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const content = match[1] ?? "";
|
|
54
|
-
const
|
|
55
|
-
const targetPart = pipeIndex >= 0 ? content.slice(0, pipeIndex) : content;
|
|
54
|
+
const targetPart = splitWikiLinkContent(content).target;
|
|
56
55
|
const trimmedTarget = targetPart.trim();
|
|
57
56
|
if (!trimmedTarget) continue;
|
|
58
57
|
const leadingWs = targetPart.length - targetPart.trimStart().length;
|
|
@@ -81,7 +81,9 @@ export function findExcludedKind(
|
|
|
81
81
|
export function codeContextReason(
|
|
82
82
|
kind: ExcludedRange["kind"] | null
|
|
83
83
|
): "code_fence_context" | "inline_code_context" | null {
|
|
84
|
-
if (kind === "fenced_code"
|
|
84
|
+
if (kind === "fenced_code" || kind === "indented_code") {
|
|
85
|
+
return "code_fence_context";
|
|
86
|
+
}
|
|
85
87
|
if (kind === "inline_code") return "inline_code_context";
|
|
86
88
|
return null;
|
|
87
89
|
}
|
package/src/core/links.ts
CHANGED
|
@@ -88,6 +88,8 @@ const LOGSEQ_EMBED_REGEX =
|
|
|
88
88
|
* Markdown inline link: [text](url)
|
|
89
89
|
* Captures: 1=text, 2=url (path and optional anchor)
|
|
90
90
|
* Negative lookbehind to avoid image links ![]()
|
|
91
|
+
* Link text may contain balanced square brackets one level deep
|
|
92
|
+
* (`[see [1]](note.md)`), as CommonMark allows.
|
|
91
93
|
*
|
|
92
94
|
* SCOPE LIMITATIONS:
|
|
93
95
|
* - Only matches simple inline links [text](url)
|
|
@@ -95,7 +97,10 @@ const LOGSEQ_EMBED_REGEX =
|
|
|
95
97
|
* - Does NOT match autolinks <url> or bare URLs
|
|
96
98
|
* - Parens in URLs not supported (use %28 %29 encoding)
|
|
97
99
|
*/
|
|
98
|
-
const MARKDOWN_LINK_REGEX = /(?<!!)\[([
|
|
100
|
+
const MARKDOWN_LINK_REGEX = /(?<!!)\[((?:[^[\]]|\[[^[\]]*\])*)\]\(([^)]+)\)/g;
|
|
101
|
+
|
|
102
|
+
/** Square brackets in a destination mean the text was split, not a path. */
|
|
103
|
+
const BRACKET_IN_DESTINATION_REGEX = /[[\]]/;
|
|
99
104
|
|
|
100
105
|
/** External URL pattern (http:// https:// mailto: etc.) */
|
|
101
106
|
const EXTERNAL_URL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
@@ -144,6 +149,26 @@ export function extractWikiBasename(ref: string): string {
|
|
|
144
149
|
return stripWikiMdExt(base);
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Split the content of a wiki link into target and alias. Obsidian also reads
|
|
154
|
+
* `\|` (the pipe escaped inside a Markdown table) as the alias separator,
|
|
155
|
+
* so `[[Note\|Alias]]` targets `Note`, not `Note\`.
|
|
156
|
+
*/
|
|
157
|
+
export function splitWikiLinkContent(content: string): {
|
|
158
|
+
target: string;
|
|
159
|
+
alias?: string;
|
|
160
|
+
} {
|
|
161
|
+
const pipeIndex = content.indexOf("|");
|
|
162
|
+
if (pipeIndex < 0) {
|
|
163
|
+
return { target: content };
|
|
164
|
+
}
|
|
165
|
+
const escaped = pipeIndex > 0 && content[pipeIndex - 1] === "\\";
|
|
166
|
+
return {
|
|
167
|
+
target: content.slice(0, escaped ? pipeIndex - 1 : pipeIndex),
|
|
168
|
+
alias: content.slice(pipeIndex + 1),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
173
|
// Path Normalization
|
|
149
174
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -338,22 +363,14 @@ export function parseLinks(
|
|
|
338
363
|
const content = match[1];
|
|
339
364
|
if (!content) continue;
|
|
340
365
|
|
|
341
|
-
// Parse [[target|alias]] format
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// Only set displayText if different from target
|
|
350
|
-
displayText =
|
|
351
|
-
aliasText !== targetPart
|
|
352
|
-
? truncateText(aliasText, MAX_DISPLAY_TEXT_GRAPHEMES)
|
|
353
|
-
: undefined;
|
|
354
|
-
} else {
|
|
355
|
-
targetPart = content;
|
|
356
|
-
}
|
|
366
|
+
// Parse [[target|alias]] (and table-escaped [[target\|alias]]) format
|
|
367
|
+
const { target: targetPart, alias: aliasText } =
|
|
368
|
+
splitWikiLinkContent(content);
|
|
369
|
+
// Only set displayText if different from target
|
|
370
|
+
const displayText =
|
|
371
|
+
aliasText !== undefined && aliasText !== targetPart
|
|
372
|
+
? truncateText(aliasText, MAX_DISPLAY_TEXT_GRAPHEMES)
|
|
373
|
+
: undefined;
|
|
357
374
|
|
|
358
375
|
const trimmedTarget = targetPart.trim();
|
|
359
376
|
if (!trimmedTarget) {
|
|
@@ -427,6 +444,12 @@ export function parseLinks(
|
|
|
427
444
|
continue;
|
|
428
445
|
}
|
|
429
446
|
|
|
447
|
+
// Brackets in the destination come from link text split at an
|
|
448
|
+
// unbalanced bracket (`[a [b](c](d)`): unparseable, not a missing target.
|
|
449
|
+
if (BRACKET_IN_DESTINATION_REGEX.test(url)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
430
453
|
// Skip URLs that look like protocol-relative (//example.com)
|
|
431
454
|
if (url.startsWith("//")) {
|
|
432
455
|
continue;
|