@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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- 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
|
+
}
|