@wxxb789/anc 0.0.1

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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. package/tsconfig.json +25 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * One resolver for every link a note can write, and one typed outcome per link.
3
+ *
4
+ * Five spellings reach this module — a bare wikilink, a vault-root wikilink, a
5
+ * relative wikilink, a standard Markdown link, and a wikilink carrying display
6
+ * text — and there is exactly one function under them. Obsidian itself works
7
+ * this way: its internal/external test (`WE`) routes a Markdown href through
8
+ * `decodeURI` and then hands it to the *same* `getLinkpathDest` a wikilink gets.
9
+ * Building a second resolver is how two answers drift apart, and the drift is
10
+ * invisible until a reader clicks something.
11
+ *
12
+ * ## The order is reverse-engineered, not documented, and this is its shape
13
+ *
14
+ * `.tmp/ssg-research.md` recovered it by unpacking `obsidian.asar` and executing
15
+ * the shipped `MetadataCache.prototype.getLinkpathDest` against synthetic
16
+ * vaults. Obsidian is closed source and both the help site and the public API
17
+ * reference decline to state precedence — the reference says only "Get the best
18
+ * match for a linkpath." So the tiers below are transcribed from that recovery,
19
+ * and the gates that hold them source their expected values from a *separate*
20
+ * transcription of the same function rather than from this file.
21
+ *
22
+ * `getFirstLinkpathDest` is `getLinkpathDest(...)[0] ?? null`, and
23
+ * `uniqueFileLookup` is a multimap keyed by lowercase filename **with**
24
+ * extension. From there:
25
+ *
26
+ * - **Tier 0, the candidate set.** If the linkpath's basename contains a `.`,
27
+ * probe it as written; if that misses, or there was no dot, append `.md` and
28
+ * probe again. No basename match ends resolution — there is no alias, title,
29
+ * or permalink fallback. This is what makes `[[Figure 1]]` against a real
30
+ * `Figure 1.png` resolve to nothing, which is documented behaviour: a
31
+ * non-Markdown target must carry its extension.
32
+ * - **Tier 1.** A bare name with exactly one candidate. The only tier requiring
33
+ * uniqueness, so *any* collision skips it — which is why a collision does not
34
+ * simply pick the near one here.
35
+ * - **Tier 2.** An explicit `./` or `../` joined onto the source's directory,
36
+ * exact full-path match. On failure it does **not** return; it falls through
37
+ * to the later tiers carrying the rewritten path.
38
+ * - **Tier 3.** Exact corpus-root path, with a leading `/` stripped first. This
39
+ * is why a root-level `items.md` is **not** shadowed by `archive/items.md`:
40
+ * tier 1 was skipped by the collision, and the bare name happens to *be* a
41
+ * root path.
42
+ * - **Tier 4.** Tier 3 missed and the link was *written* with a leading `/` —
43
+ * resolution ends. A leading slash is a strict root anchor, not a dead end;
44
+ * widely-copied third-party notes claim such a link never resolves at all, and
45
+ * the research executed it and found the slash is stripped before tier 3.
46
+ * - **Tier 5.** Suffix match over candidate paths, bucketed by proximity to the
47
+ * source, shortest first.
48
+ *
49
+ * ## Where this deliberately differs, and why each difference is not a taste
50
+ *
51
+ * **Tier 5's matching is hardened to be segment-aware.** Obsidian's own test is
52
+ * a raw-string `endsWith`/`startsWith`, and executed, `[[ary/index]]` resolves
53
+ * to `knowledge/glossary/index.md` while a source in `proj/` prefers
54
+ * `projects/note.md` over `zzz/note.md`. Those are defects rather than
55
+ * semantics: nobody writes `ary/index` meaning that file. Tier 5 cannot simply
56
+ * be deleted, because Obsidian's own `relative` link format emits prefix-less
57
+ * multi-segment paths (`[[b/note]]`) that reach nothing else.
58
+ *
59
+ * **Tier 5's ranking is made total.** Obsidian sorts by path length and lets
60
+ * equal lengths fall to `uniqueFileLookup` insertion order, which is filesystem
61
+ * scan order — executed, reversing the input order reverses the winner. A build
62
+ * promising byte-identical output cannot rank on that, so the tiebreak here is
63
+ * (length, then the path itself), which depends on nothing but the two paths.
64
+ *
65
+ * **Ambiguity is an outcome, not a build failure.** It warns: the link is
66
+ * resolved by the order above, it renders, and every candidate is recorded.
67
+ * Failing was considered and rejected as unbounded and unoverridable — it is
68
+ * worst exactly where it is most likely, two notes sharing a basename, and a
69
+ * stranger running this tool on their own repository cannot always act on it.
70
+ * Silent resolution was rejected just as firmly: that is the defect this
71
+ * resolver exists to beat, where zero matches and five matches fall through
72
+ * identically.
73
+ *
74
+ * **Aliases are not link targets.** Obsidian desktop's `getLinkpathDest` reads
75
+ * `uniqueFileLookup` and nothing else, and a detailed bug report asking for
76
+ * alias resolution was closed as intentional. Obsidian *Publish* disagrees — its
77
+ * resolver has an alias tier and a permalink map, and the differential was
78
+ * executed: `[[alias-name]]` is `null` on desktop and `Real.md` on Publish.
79
+ * "Obsidian-compatible" is therefore ambiguous, and this module names which
80
+ * implementation it reproduces.
81
+ *
82
+ * **Unicode is NFC-normalised on both sides.** Obsidian normalises the link text
83
+ * only; filenames come from the OS unnormalised, so an NFC link against an NFD
84
+ * filename resolves to nothing. Normalising both makes the answer identical on
85
+ * every runner, which a build needs and an editor does not.
86
+ *
87
+ * ## Case, and why it is the one place `markdown-to-artifact.ts` does not apply
88
+ *
89
+ * Discovery compares names byte-exactly and never calls `globSync`, because that
90
+ * call answers differently per platform and can return a name that is not on
91
+ * disk. That rule decides **which files exist**, and nothing here weakens it:
92
+ * this module never touches the filesystem and is handed the paths the walk
93
+ * already found.
94
+ *
95
+ * Resolution is a different question — *which of those files did the author
96
+ * mean* — and there the researched answer is unambiguous: keys are
97
+ * `file.name.toLowerCase()`, the linkpath is lowercased, and every `===`,
98
+ * `endsWith`, and `startsWith` runs on lowercased strings. Executed,
99
+ * `[[projects/THREE laws]]` resolves to `Projects/Three Laws.md`. A byte-exact
100
+ * resolver answers nothing there, and hand-written links disagreeing in case
101
+ * with the filename are ordinary in a real vault.
102
+ *
103
+ * The hazard case-insensitivity carries is that `Note.md` and `note.md` share
104
+ * one key, so on a case-sensitive filesystem tier 3's exact test matches *both*
105
+ * and Obsidian returns whichever the scan saw first. Here that pair is simply
106
+ * ambiguity — reported, with both candidates named — and the winner comes from
107
+ * the total tiebreak rather than from scan order.
108
+ */
109
+ import Slugger from 'github-slugger';
110
+ /** POSIX basename, on the `/`-joined relative paths the walk produces. */
111
+ function basename(path) {
112
+ return path.slice(path.lastIndexOf('/') + 1);
113
+ }
114
+ /** POSIX dirname; `''` for a path at the corpus root, as Obsidian's `Pl` gives. */
115
+ function dirname(path) {
116
+ const cut = path.lastIndexOf('/');
117
+ return cut === -1 ? '' : path.slice(0, cut);
118
+ }
119
+ /**
120
+ * NFC, matching what `obsidian-export` does to both sides and what Obsidian does
121
+ * to only one.
122
+ *
123
+ * The divergence is deliberate and named in this module's header: a filename
124
+ * arrives from the OS in whatever form that OS stores, macOS hands back
125
+ * decomposed forms, and a link normalised against a filename that is not
126
+ * normalised resolves to nothing on one machine and correctly on another.
127
+ */
128
+ function normalise(text) {
129
+ return text.normalize('NFC');
130
+ }
131
+ /** Build the index a whole build resolves against. */
132
+ export function indexCorpus(files) {
133
+ const byName = new Map();
134
+ for (const file of files) {
135
+ // **The whole path is normalised, not only the key.** Normalising the
136
+ // basename alone was measured to leave the divergence intact for every
137
+ // multi-segment form: with `nfd/café.md` on disk in decomposed form,
138
+ // `[[café]]` resolved through the key and `[[nfd/café]]` written in NFC did
139
+ // not, because tiers 2, 3, and 5 compare `file.path` against a normalised
140
+ // linkpath. `path` keeps its authored form for the report and the artifact;
141
+ // `comparable` is what the tiers read.
142
+ const comparable = normalise(file.path);
143
+ const key = basename(comparable).toLowerCase();
144
+ const entry = { file, comparable };
145
+ const bucket = byName.get(key);
146
+ if (bucket === undefined)
147
+ byName.set(key, [entry]);
148
+ else
149
+ bucket.push(entry);
150
+ }
151
+ return { byName };
152
+ }
153
+ /**
154
+ * A link's path half and its subpath, split at the **first** `#`.
155
+ *
156
+ * First rather than last, and this is Obsidian's `parseLinktext`: `note#a#b` is
157
+ * the path `note` with the subpath `#a#b`, which is a nested-heading reference
158
+ * rather than a file called `note#a`. A leading `#` gives an empty path, which
159
+ * is a reference into the page the link was written on.
160
+ */
161
+ function splitLinktext(link) {
162
+ const hash = link.indexOf('#');
163
+ return hash === -1
164
+ ? { path: link, subpath: '' }
165
+ : { path: link.slice(0, hash), subpath: link.slice(hash) };
166
+ }
167
+ /**
168
+ * The `#id` this site can actually serve for a subpath, or `''`.
169
+ *
170
+ * **A deliberate approximation, and the direction of its error is the reason it
171
+ * is acceptable.** Obsidian matches a subpath against the target's own headings
172
+ * with a comparator that is not a slugger — it strips a fixed punctuation class,
173
+ * collapses whitespace, trims, and lowercases, leaving `-`, `_`, and `'` to
174
+ * match literally. Reproducing that needs every target's heading list at
175
+ * resolution time, and then a second mapping from the matched heading to the id
176
+ * `src/lib/markdown.ts` mints — which is a second model of heading identity, and
177
+ * the first place the two would silently disagree is the place nobody looks.
178
+ *
179
+ * What is done instead is to run the subpath through the *same* slugger the
180
+ * renderer uses. That agrees exactly whenever the author wrote the heading's
181
+ * text verbatim, which is what Obsidian's own autocomplete inserts. It
182
+ * disagrees only when the author wrote something Obsidian's comparator forgives
183
+ * and the slugger does not — and there the link lands at the top of the right
184
+ * page rather than at a wrong one. Never a wrong page, and never a dangling
185
+ * anchor.
186
+ *
187
+ * A block reference (`#^id`) gets no anchor at all: this site emits no block
188
+ * anchors, so any id minted for one would point at nothing. Obsidian's own docs
189
+ * warn that block references do not work outside Obsidian.
190
+ *
191
+ * A nested reference (`#a#b`) uses the last part, which is the heading actually
192
+ * being pointed at; the intermediate parts only constrain depth.
193
+ *
194
+ * A fresh slugger per call, deliberately: `github-slugger` deduplicates across
195
+ * a document, and this is not walking one. The undeduplicated form is the id a
196
+ * heading gets on its first occurrence, which is the one a reader links to.
197
+ */
198
+ function anchorFor(subpath) {
199
+ const parts = subpath.split('#').filter((part) => part !== '');
200
+ const last = parts.at(-1);
201
+ if (last === undefined || last.startsWith('^'))
202
+ return '';
203
+ const id = new Slugger().slug(last);
204
+ return id === '' ? '' : `#${id}`;
205
+ }
206
+ /**
207
+ * Segment-aware suffix match, replacing tier 5's raw-string `endsWith`.
208
+ *
209
+ * `'knowledge/glossary/index.md'.endsWith('ary/index.md')` is true, and that is
210
+ * the executed defect: `[[ary/index]]` resolves to a file nobody named.
211
+ */
212
+ function matchesSuffix(path, link) {
213
+ return path === link || path.endsWith(`/${link}`);
214
+ }
215
+ /**
216
+ * Segment-aware containment, replacing tier 5's raw-string `startsWith`.
217
+ *
218
+ * `'projects/note.md'.startsWith('proj')` is true, so a source in `proj/`
219
+ * executed as preferring `projects/note.md` over `zzz/note.md`. A corpus-root
220
+ * source has `dirname === ''`, under which every candidate is "near" — that is
221
+ * Obsidian's behaviour too, and it is why the bucket split is inert for
222
+ * root-level notes rather than being a special case here.
223
+ */
224
+ function inSourceSubtree(path, directory) {
225
+ return directory === '' || path === directory || path.startsWith(`${directory}/`);
226
+ }
227
+ /**
228
+ * Join a `./` or `../` link onto the source's directory.
229
+ *
230
+ * Written out rather than delegated to `path.posix.join`, for one reason worth
231
+ * the eight lines: `join` is separator-aware in a way that differs by platform
232
+ * on win32 for the *other* `path` namespace, and every path here is already a
233
+ * `/`-joined relative path the walk produced. A `..` that would climb above the
234
+ * corpus root is dropped, which is the only definition that keeps the result a
235
+ * corpus-relative path.
236
+ */
237
+ function joinRelative(directory, link) {
238
+ const parts = directory === '' ? [] : directory.split('/');
239
+ for (const segment of link.split('/')) {
240
+ if (segment === '' || segment === '.')
241
+ continue;
242
+ if (segment === '..')
243
+ parts.pop();
244
+ else
245
+ parts.push(segment);
246
+ }
247
+ return parts.join('/');
248
+ }
249
+ /** Total, and depending on nothing but the two paths. See the header. */
250
+ function rank(a, b) {
251
+ const left = a.file.path;
252
+ const right = b.file.path;
253
+ if (left.length !== right.length)
254
+ return left.length - right.length;
255
+ return left < right ? -1 : left > right ? 1 : 0;
256
+ }
257
+ /**
258
+ * Whether a link points out of the corpus, by Obsidian's own test (`WE`).
259
+ *
260
+ * `startsWith('./') || startsWith('../') || indexOf(':') === -1`. The colon test
261
+ * is what makes `https://example.com` and `mailto:x` external while
262
+ * `notes/a.md` is not, and the two explicit relative prefixes are what keep
263
+ * `./a:b` internal despite carrying one.
264
+ */
265
+ function isExternal(href) {
266
+ if (href.startsWith('./') || href.startsWith('../'))
267
+ return false;
268
+ return href.includes(':');
269
+ }
270
+ /**
271
+ * Percent-decoding for a Markdown href, and `decodeURI` rather than
272
+ * `decodeURIComponent`.
273
+ *
274
+ * The difference is load-bearing rather than incidental: `decodeURI` decodes
275
+ * `%20` and leaves `%23` encoded, so a filename containing a literal `#` stays
276
+ * distinguishable from the subpath separator. `decodeURIComponent` would decode
277
+ * both and turn one into the other.
278
+ *
279
+ * A malformed sequence throws, and the honest answer is the href as written: a
280
+ * link nobody can decode is a link that resolves to nothing, and it will be
281
+ * reported as such rather than crashing the build over a typo.
282
+ */
283
+ function decodeHref(href) {
284
+ try {
285
+ return decodeURI(href);
286
+ }
287
+ catch {
288
+ return href;
289
+ }
290
+ }
291
+ /**
292
+ * Resolve one link written in one file.
293
+ *
294
+ * @param link The link exactly as authored — a wikilink's target, or a Markdown
295
+ * href. Both reach the same tiers; the href is percent-decoded first, which is
296
+ * the only difference between the two syntaxes anywhere in this module.
297
+ * @param sourcePath The writing file's path, relative to the corpus root. Tiers
298
+ * 2 and 5 are the only ones that read it.
299
+ * @param index Built once per build by {@link indexCorpus}.
300
+ * @param isWikilink Wikilinks are not percent-encoded, so they are not decoded.
301
+ */
302
+ export function resolveLink(link, sourcePath, index, isWikilink) {
303
+ const written = isWikilink ? link.trim() : decodeHref(link.trim());
304
+ // A bare fragment is a reference into the page being written, which is a real
305
+ // link and never an edge. Tested before the external check, which would
306
+ // otherwise call it internal and send an empty path into the tiers.
307
+ if (written === '' || written.startsWith('#'))
308
+ return { kind: 'external' };
309
+ if (isExternal(written))
310
+ return { kind: 'external' };
311
+ const { path: linkPath, subpath } = splitLinktext(written);
312
+ if (linkPath === '')
313
+ return { kind: 'external' };
314
+ const anchor = anchorFor(subpath);
315
+ const candidates = candidatesFor(normalise(linkPath), normalise(sourcePath), index);
316
+ if (candidates.length === 0)
317
+ return { kind: 'unresolved' };
318
+ const sorted = [...candidates].sort(rank);
319
+ const winner = sorted[0].file;
320
+ const names = sorted.map((candidate) => candidate.file.path).sort();
321
+ if (winner.slug === undefined)
322
+ return { kind: 'unpublished', path: winner.path, candidates: names };
323
+ if (sorted.length > 1) {
324
+ return { kind: 'ambiguous', path: winner.path, slug: winner.slug, anchor, candidates: names };
325
+ }
326
+ return { kind: 'resolved', path: winner.path, slug: winner.slug, anchor };
327
+ }
328
+ /**
329
+ * The tiers, in order, returning every file the winning tier admits.
330
+ *
331
+ * Returning a *set* rather than a winner is what makes ambiguity expressible:
332
+ * the researched function returns an array and its caller takes `[0]`, which is
333
+ * precisely where the information that there were five is thrown away.
334
+ *
335
+ * Each tier that fires returns; tier 2 is the documented exception and falls
336
+ * through with the path it rewrote.
337
+ */
338
+ function candidatesFor(linkPath, sourcePath, index) {
339
+ // Tier 0. The extension probe, in the researched order: as written when the
340
+ // basename carries a dot, then with `.md` appended. Two probes rather than
341
+ // parsing, which is why `[[Note.1]]` finds `Note.1.md` and why an
342
+ // extensionless file literally named `Node.js` beats `Node.js.md`.
343
+ let name = linkPath.toLowerCase();
344
+ let key = basename(name);
345
+ let found = key.includes('.') ? index.byName.get(key) : undefined;
346
+ if (found === undefined) {
347
+ name = `${linkPath}.md`.toLowerCase();
348
+ key = basename(name);
349
+ found = index.byName.get(key);
350
+ }
351
+ if (found === undefined || found.length === 0)
352
+ return [];
353
+ // Tier 1. A bare name — the probe did not change the path shape — with
354
+ // exactly one candidate. The only tier requiring uniqueness, so a collision
355
+ // falls to the tiers below rather than picking here.
356
+ if (key === name && found.length === 1)
357
+ return found;
358
+ const sourceDirectory = dirname(sourcePath).toLowerCase();
359
+ // Tier 2. Explicit `./` or `../`, joined onto the source's directory and
360
+ // matched exactly. It does not return on failure: the rewritten path carries
361
+ // into tiers 3 and 5, which is how `[[../x]]` from a nested file can still
362
+ // find a suffix match.
363
+ if (name.startsWith('./') || name.startsWith('../')) {
364
+ name = joinRelative(sourceDirectory, name);
365
+ const exact = found.filter((candidate) => candidate.comparable.toLowerCase() === name);
366
+ if (exact.length > 0)
367
+ return exact;
368
+ }
369
+ const anchored = name.startsWith('/');
370
+ if (anchored)
371
+ name = name.slice(1);
372
+ // Tier 3. Exact corpus-root path. Every candidate matching is returned rather
373
+ // than the first, which is the case-only collision: `Note.md` and `note.md`
374
+ // both lowercase to one string and Obsidian hands back whichever the scan saw
375
+ // first.
376
+ const exact = found.filter((candidate) => candidate.comparable.toLowerCase() === name);
377
+ if (exact.length > 0)
378
+ return exact;
379
+ // Tier 4. A link written with a leading `/` is a strict root anchor: it asked
380
+ // for one exact path and did not get it, so it declines the fuzzy fallback
381
+ // rather than guessing.
382
+ if (anchored)
383
+ return [];
384
+ // Tier 5, with both string tests made segment-aware. See the header for the
385
+ // two executed defects that decides.
386
+ const survivors = found.filter((candidate) => matchesSuffix(candidate.comparable.toLowerCase(), name));
387
+ const near = survivors.filter((candidate) => inSourceSubtree(candidate.comparable.toLowerCase(), sourceDirectory));
388
+ return near.length > 0 ? near : survivors;
389
+ }