@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,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One walk of one note's Markdown, producing its rewritten body and its edges
|
|
3
|
+
* from the same values.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is one function and not two passes
|
|
6
|
+
*
|
|
7
|
+
* `outgoing` and the hrefs a reader clicks are the same fact stated twice, and
|
|
8
|
+
* two passes are how the two statements drift. The producer this replaces
|
|
9
|
+
* derived `outgoing` from wikilinks only, so
|
|
10
|
+
* `{markdown: 'See [B](/b/).', outgoing: []}` was a valid entry: a live anchor
|
|
11
|
+
* on the page, no backlink on B, and no gate anywhere that could see the
|
|
12
|
+
* disagreement — `checkCorpus` proves `backlinks` is the exact inverse of
|
|
13
|
+
* `outgoing`, which says nothing at all about the page.
|
|
14
|
+
*
|
|
15
|
+
* So each `link` and `image` node, and each `linkReference` and `imageReference`
|
|
16
|
+
* through its definition, is resolved **exactly once**, and that single
|
|
17
|
+
* {@link LinkResolution} does both jobs: it decides the text written back into
|
|
18
|
+
* the body, and, when it resolved to a published note, it appends to `outgoing`.
|
|
19
|
+
* The two cannot disagree because one value produced both. That is a property of
|
|
20
|
+
* the shape rather than of a test — there is no second source for either.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the body is edited by source span rather than re-serialized
|
|
23
|
+
*
|
|
24
|
+
* The obvious alternative is to mutate `node.url` and stringify the tree back to
|
|
25
|
+
* Markdown, and it is wrong here for a reason that is not about effort:
|
|
26
|
+
* serializing an mdast tree normalises everything it touches — emphasis markers,
|
|
27
|
+
* list bullets, escaping, hard breaks, table padding — so every note in a user's
|
|
28
|
+
* repository would come back subtly rewritten, and the diff between what they
|
|
29
|
+
* wrote and what was published would be full of changes nobody made. It also
|
|
30
|
+
* needs a serializer this package does not have.
|
|
31
|
+
*
|
|
32
|
+
* Editing spans keeps every byte the walk did not decide to change. satteri's
|
|
33
|
+
* node positions are offsets into the exact string handed to the parser —
|
|
34
|
+
* measured: `input.slice(node.position.start.offset, node.position.end.offset)`
|
|
35
|
+
* round-trips to the link's own source text, for wikilinks, Markdown links,
|
|
36
|
+
* images, and CJK bodies alike — and the edits are applied last-to-first so an
|
|
37
|
+
* earlier replacement cannot move a later offset.
|
|
38
|
+
*
|
|
39
|
+
* ## Fence-blindness is unrepresentable rather than tested away
|
|
40
|
+
*
|
|
41
|
+
* The producer this replaces ran a regex over raw Markdown, so a note
|
|
42
|
+
* *documenting* wikilink syntax had the links inside its own code fences
|
|
43
|
+
* rewritten and a phantom edge injected into `outgoing` — which `checkCorpus`
|
|
44
|
+
* then proved symmetric and passed. Here the walk visits `link` and `image`
|
|
45
|
+
* nodes; a `[[X]]` inside a fence is a `code` node and inside backticks is an
|
|
46
|
+
* `inlineCode` node, and neither is in the set the walk visits. There is no
|
|
47
|
+
* branch to get wrong.
|
|
48
|
+
*/
|
|
49
|
+
import { markdownToMdast } from 'satteri';
|
|
50
|
+
import { resolveLink } from "../src/lib/link-resolution.js";
|
|
51
|
+
import { WITHHELD_LINK_TEXT, WITHHELD_ROUTE } from "../src/lib/route-path.js";
|
|
52
|
+
/**
|
|
53
|
+
* Node types that render as an anchor, so nothing inside one may open another.
|
|
54
|
+
*
|
|
55
|
+
* Wider than the two types this walk rewrites, deliberately — see
|
|
56
|
+
* {@link collectLinkNodes}. `footnoteReference` renders as an anchor too and is
|
|
57
|
+
* childless, so it can contain nothing and costs nothing to omit; every
|
|
58
|
+
* *container* HTML forbids an `<a>` inside is here.
|
|
59
|
+
*/
|
|
60
|
+
const CONTAINS_LINK = new Set([
|
|
61
|
+
'link',
|
|
62
|
+
'image',
|
|
63
|
+
'linkReference',
|
|
64
|
+
'imageReference',
|
|
65
|
+
]);
|
|
66
|
+
/**
|
|
67
|
+
* Every link-bearing node, in document order, including nested ones, and every
|
|
68
|
+
* `definition` node beside them.
|
|
69
|
+
*
|
|
70
|
+
* **Nesting is real and both halves of it matter.** `[](index.md)`
|
|
71
|
+
* — the badge idiom every README opens with — is an image inside a link, and
|
|
72
|
+
* the two spans overlap. An early draft edited both as whole-node replacements
|
|
73
|
+
* and threw; the draft after that stopped visiting the inner node at all, and
|
|
74
|
+
* that traded a build failure for a leak. Measured: with `private/chart.png`
|
|
75
|
+
* excluded, the standalone `` correctly degraded to
|
|
76
|
+
* `chart` while the nested form shipped `[](/index/)`
|
|
77
|
+
* — the excluded folder, in the published body.
|
|
78
|
+
*
|
|
79
|
+
* So every node is visited, resolved, and edited. What makes that safe is that
|
|
80
|
+
* an outer link is rewritten as **two edits around its label** rather than one
|
|
81
|
+
* over its whole span (see {@link labelSpan}), so the region a nested node
|
|
82
|
+
* writes into is one neither edit touches.
|
|
83
|
+
*
|
|
84
|
+
* `nested` receives every node that has a link-bearing ancestor, because the one
|
|
85
|
+
* rewrite that cannot be split is the one that would put an anchor inside an
|
|
86
|
+
* anchor — see the `unpublished` branch in {@link resolveLinksIn}. Recorded
|
|
87
|
+
* during the walk rather than recovered from spans afterwards: the tree states
|
|
88
|
+
* containment directly, and a span comparison would restate it.
|
|
89
|
+
*
|
|
90
|
+
* **The ancestor test is {@link CONTAINS_LINK} and not the two types this walk
|
|
91
|
+
* rewrites**, which is a distinction that cost a defect. `linkReference` is
|
|
92
|
+
* mdast's reference-style link — `[][ci]` with `[ci]:` on its
|
|
93
|
+
* own line, the badge idiom half of every README uses — and it is neither `link`
|
|
94
|
+
* nor `image`, so a version testing only those two left a withheld image inside
|
|
95
|
+
* one unmarked. Measured through the shipped renderer: it emitted
|
|
96
|
+
* `<p>Badge: [<a href="/private/">build</a>]<a href="https://ci.example/">ci</a></p>`
|
|
97
|
+
* — the nested anchor this set exists to prevent, the outer reference link
|
|
98
|
+
* destroyed, and its label `ci` rendered as prose.
|
|
99
|
+
*
|
|
100
|
+
* **The reference forms are now resolved as well, and were not until
|
|
101
|
+
* 2026-09-23.** Skipping them was a bypass rather than a neutral choice: with
|
|
102
|
+
* `[x]: drafts/secret.md` a withheld note, `[s][x]` rendered
|
|
103
|
+
* `<a href="drafts/secret.md">s</a>` — neither `/private/` nor text — and with
|
|
104
|
+
* `[y]: b.md` published, `[b][y]` rendered a live anchor that contributed no
|
|
105
|
+
* edge, breaking "article hrefs equal DB edges". Each reference is now resolved
|
|
106
|
+
* through the first definition for its identifier and rewritten inline, exactly
|
|
107
|
+
* as `[s](drafts/secret.md)` would be; `tests/reference-links.test.ts` gates it.
|
|
108
|
+
*/
|
|
109
|
+
function collectLinkNodes(node, into, nested, definitions, inside = false) {
|
|
110
|
+
if (CONTAINS_LINK.has(node.type)) {
|
|
111
|
+
into.push(node);
|
|
112
|
+
if (inside)
|
|
113
|
+
nested.add(node);
|
|
114
|
+
}
|
|
115
|
+
else if (node.type === 'definition') {
|
|
116
|
+
definitions.push(node);
|
|
117
|
+
}
|
|
118
|
+
for (const child of ('children' in node ? node.children : [])) {
|
|
119
|
+
collectLinkNodes(child, into, nested, definitions, inside || CONTAINS_LINK.has(node.type));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The first definition for each identifier, which is the one CommonMark uses.
|
|
124
|
+
*
|
|
125
|
+
* Measured against the installed parser: `[b]: first.md` followed by
|
|
126
|
+
* `[B]: second.md` renders `[t][b]` as `href="first.md"`, and both nodes carry
|
|
127
|
+
* the normalised identifier `b`. The walk is in document order, so the first
|
|
128
|
+
* one seen is the one kept.
|
|
129
|
+
*/
|
|
130
|
+
function definitionsById(definitions) {
|
|
131
|
+
const byId = new Map();
|
|
132
|
+
for (const definition of definitions) {
|
|
133
|
+
if (definition.type !== 'definition')
|
|
134
|
+
continue;
|
|
135
|
+
if (!byId.has(definition.identifier))
|
|
136
|
+
byId.set(definition.identifier, definition);
|
|
137
|
+
}
|
|
138
|
+
return byId;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A definition rewritten to point at `destination`, its label bytes kept.
|
|
142
|
+
*
|
|
143
|
+
* The label ends at its first unescaped `]` — a link label cannot contain one —
|
|
144
|
+
* and everything after it (the destination, an optional title, and whatever
|
|
145
|
+
* line breaks or container prefixes sit between them) is replaced. The title is
|
|
146
|
+
* dropped for the same reason an inline link's is: every reference to this
|
|
147
|
+
* definition has already been rewritten inline, so nothing reads it.
|
|
148
|
+
*/
|
|
149
|
+
function rewriteDefinition(written, destination) {
|
|
150
|
+
let slashes = 0;
|
|
151
|
+
for (let index = 1; index < written.length; index += 1) {
|
|
152
|
+
const character = written[index];
|
|
153
|
+
if (character === ']' && slashes % 2 === 0)
|
|
154
|
+
return `${written.slice(0, index + 1)}: ${destination}`;
|
|
155
|
+
slashes = character === '\\' ? slashes + 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The display text a link with no live target leaves behind.
|
|
161
|
+
*
|
|
162
|
+
* An `unresolved` link is not rendered as an anchor — a live anchor to nothing
|
|
163
|
+
* is a lie to the reader. What remains is what the author wrote for a human to
|
|
164
|
+
* read.
|
|
165
|
+
*
|
|
166
|
+
* Taken from the node's own child span rather than reconstructed, so inline
|
|
167
|
+
* markup inside the label survives: `[**bold** text](gone.md)` degrades to
|
|
168
|
+
* `**bold** text` and not to `bold text`. A wikilink with no display text has
|
|
169
|
+
* its target as its label, which is what the parser gives and what a reader
|
|
170
|
+
* would have seen.
|
|
171
|
+
*
|
|
172
|
+
* An image degrades to its alt text, which is the same rule applied to the
|
|
173
|
+
* field images carry it in. An empty label degrades to nothing at all: `[](x)`
|
|
174
|
+
* had nothing for a reader in the first place.
|
|
175
|
+
*
|
|
176
|
+
* **`unpublished` no longer routes through here**, and did until 2026-08-17.
|
|
177
|
+
* See the `unpublished` branch in {@link resolveLinksIn} for the decision that
|
|
178
|
+
* changed and what it cost.
|
|
179
|
+
*/
|
|
180
|
+
function displayText(node, source) {
|
|
181
|
+
if (node.type === 'image' || node.type === 'imageReference')
|
|
182
|
+
return node.alt ?? '';
|
|
183
|
+
const children = 'children' in node ? node.children : [];
|
|
184
|
+
const first = spanOf(children[0]);
|
|
185
|
+
const last = spanOf(children.at(-1));
|
|
186
|
+
if (first === undefined || last === undefined)
|
|
187
|
+
return '';
|
|
188
|
+
return source.slice(first.start, last.end);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The span a node's label occupies in the source, or `undefined` if it has none.
|
|
192
|
+
*
|
|
193
|
+
* The label is a **separate editable region** from the node itself, and that is
|
|
194
|
+
* what makes a nested link safe to rewrite without deleting anything. Measured:
|
|
195
|
+
* `[](index.md)` has the outer link at `[0,36)` and its
|
|
196
|
+
* label — the image — at `[1,25)`; the two do not overlap once the outer node's
|
|
197
|
+
* edit is split into "before the label" and "after the label".
|
|
198
|
+
*
|
|
199
|
+
* Without this, an outer link containing an unpublished image had two possible
|
|
200
|
+
* treatments and both were wrong: rewrite the outer node whole and ship the
|
|
201
|
+
* excluded path inside the anchor text, or drop the outer node whole and delete
|
|
202
|
+
* prose the author wrote. Editing the label separately is the third option, and
|
|
203
|
+
* it is the one that keeps both properties.
|
|
204
|
+
*/
|
|
205
|
+
function labelSpan(node) {
|
|
206
|
+
const children = 'children' in node ? node.children : [];
|
|
207
|
+
const first = spanOf(children[0]);
|
|
208
|
+
const last = spanOf(children.at(-1));
|
|
209
|
+
return first === undefined || last === undefined
|
|
210
|
+
? undefined
|
|
211
|
+
: { start: first.start, end: last.end };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A label safe to place inside `[…]`, with the two delimiters escaped.
|
|
215
|
+
*
|
|
216
|
+
* Text taken from one construct and written into another has to be re-escaped
|
|
217
|
+
* for the construct it lands in, and this is the one place that happens: a
|
|
218
|
+
* wikilink's display half and an image's `alt` are *not* link-label syntax, so
|
|
219
|
+
* a `[` or `]` inside either is ordinary text there and a delimiter here.
|
|
220
|
+
* Measured, all three from the same defect:
|
|
221
|
+
*
|
|
222
|
+
* ```
|
|
223
|
+
* ![[t|before ] after]] -> [before ] after](/t/) the link ends early
|
|
224
|
+
* [![[a.png]]](t.md) -> [[](t.md)](/t/) and the rest is prose
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* The second is the parser's own reading of an embed inside a link — it hands
|
|
228
|
+
* back one image whose `alt` is `](t.md)` — and it is not a shape worth
|
|
229
|
+
* special-casing, because escaping the delimiters fixes it and every other
|
|
230
|
+
* spelling at once.
|
|
231
|
+
*
|
|
232
|
+
* **Only `[` and `]`, and the set is measured rather than assumed.** Every
|
|
233
|
+
* candidate was run through both rewrite paths and the shipped renderer
|
|
234
|
+
* (`.tmp/probe-labels.mjs`); `(`, `)`, a backtick, and a lone backslash all
|
|
235
|
+
* round-trip intact, because the parser closes a label at its `]` before a
|
|
236
|
+
* destination begins and nothing else in a label is delimiter syntax. Escaping
|
|
237
|
+
* more than the two characters that can break the construct would put visible
|
|
238
|
+
* backslashes into a reader's text.
|
|
239
|
+
*/
|
|
240
|
+
function escapeLabel(text) {
|
|
241
|
+
return text.replace(/[[\]]/g, (character) => `\\${character}`);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The edits that make a label's own text safe to sit inside `[…]`.
|
|
245
|
+
*
|
|
246
|
+
* **This is the split rewrite's half of {@link escapeLabel}, and it existed as a
|
|
247
|
+
* defect for as long as the split rewrite has.** An outer link is rewritten as
|
|
248
|
+
* two edits *around* its label so a nested node can write into the middle
|
|
249
|
+
* (see {@link labelSpan}) — which means the label's bytes are copied through
|
|
250
|
+
* untouched, escaper included. Measured on `2405cb2` and still live at
|
|
251
|
+
* `ebd53ff`:
|
|
252
|
+
*
|
|
253
|
+
* ```
|
|
254
|
+
* [[beta|has ] bracket]] -> [has ] bracket](/beta/) no anchor at all
|
|
255
|
+
* [[beta|has [ bracket]] -> [has [ bracket](/beta/) anchor text " bracket"
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* The first renders as literal text — the label closes at the author's `]` and
|
|
259
|
+
* `](/beta/)` becomes prose. The second silently loses the first half of the
|
|
260
|
+
* author's own words. Both are what a *reader* gets, which is why the gates
|
|
261
|
+
* assert rendered HTML rather than the intermediate Markdown.
|
|
262
|
+
*
|
|
263
|
+
* **The escape operates on the source bytes and touches nothing else**, which is
|
|
264
|
+
* the property the first two attempts both got wrong and is worth stating as a
|
|
265
|
+
* rule: *a label with no bracket in it must come out byte-identical to what the
|
|
266
|
+
* author wrote.*
|
|
267
|
+
*
|
|
268
|
+
* The first attempt escaped the source slice with a plain `[` / `]` replace, so
|
|
269
|
+
* an author's own `\]` became `\\]` — a literal backslash and then an unescaped
|
|
270
|
+
* delimiter, which renders a backslash and closes the label. Worse than the
|
|
271
|
+
* defect, because the input was already correct.
|
|
272
|
+
*
|
|
273
|
+
* The second attempt escaped the parser's `value` instead, which fixes that case
|
|
274
|
+
* and breaks three others, because `value` is not the source with escapes
|
|
275
|
+
* resolved — it is the source with *everything* resolved. Measured, and found by
|
|
276
|
+
* review rather than by any gate:
|
|
277
|
+
*
|
|
278
|
+
* ```
|
|
279
|
+
* [[t|ends with \]] -> [ends with \](/t/) zero anchors
|
|
280
|
+
* [the \*star\* file](t.md) -> <a>the <em>star</em> file</a>
|
|
281
|
+
* [label <img src=…>](t) -> <a>label <img src=…></a>
|
|
282
|
+
* ```
|
|
283
|
+
*
|
|
284
|
+
* The third is the serious one: an author who wrote `<img>` as *displayed
|
|
285
|
+
* text* would have shipped a live third-party request out of a build whose whole
|
|
286
|
+
* subject is not making requests the author did not ask for. The predicate was
|
|
287
|
+
* "did the parser decode anything", which is a far larger set than "does this
|
|
288
|
+
* need escaping".
|
|
289
|
+
*
|
|
290
|
+
* So: escape the **unescaped** brackets in the source slice, and leave every
|
|
291
|
+
* other byte alone. A bracket is already escaped exactly when an odd number of
|
|
292
|
+
* backslashes precedes it, which is what the capture group counts.
|
|
293
|
+
*
|
|
294
|
+
* **Only `text` descendants are escaped, and the exclusions are each measured.**
|
|
295
|
+
*
|
|
296
|
+
* - An `inlineCode` node is skipped: a `]` inside backticks cannot close a label
|
|
297
|
+
* (measured — `[code \`has ] bracket\` here](/t/)` renders as one anchor
|
|
298
|
+
* containing a `<code>`), and escaping there would put a visible backslash
|
|
299
|
+
* inside code the author wrote.
|
|
300
|
+
* - A `text` node under a **nested** `link` or `image` is skipped, because those
|
|
301
|
+
* bytes are the region that node's own rewrite writes into.
|
|
302
|
+
*
|
|
303
|
+
* **The nested exclusion is reachable, and a first version of this comment said
|
|
304
|
+
* it was not.** A GFM autolink is a `link` node *inside* a link's children —
|
|
305
|
+
* `[[t|see <https://example.invalid/a> b]]` parses with `link[8,35)` under
|
|
306
|
+
* `link[0,39)` — so a bracket inside an autolink URL does reach here. Without
|
|
307
|
+
* the guard, `[[t|a ] b <https://x.invalid/p]q> c]]` ships
|
|
308
|
+
* `href="https://x.invalid/p%5C%5Dq"`: a backslash spliced into somebody's URL.
|
|
309
|
+
* The probe that concluded "unreachable" enumerated only `[…](…)` and ``
|
|
310
|
+
* shapes and never an autolink, which is `docs/gate-reading.md` case 5 — a
|
|
311
|
+
* fixture encoding what its author believed the parser emits.
|
|
312
|
+
*
|
|
313
|
+
* Emitted as one edit per text node rather than one over the whole label, for
|
|
314
|
+
* the same reason the outer rewrite is split: any span a nested node owns has to
|
|
315
|
+
* be left alone.
|
|
316
|
+
*/
|
|
317
|
+
function escapeLabelEdits(node, source, into) {
|
|
318
|
+
const walk = (current, insideNested) => {
|
|
319
|
+
for (const child of ('children' in current ? current.children : [])) {
|
|
320
|
+
const nested = insideNested || CONTAINS_LINK.has(child.type);
|
|
321
|
+
if (child.type === 'text' && !nested) {
|
|
322
|
+
const span = spanOf(child);
|
|
323
|
+
if (span !== undefined) {
|
|
324
|
+
const written = source.slice(span.start, span.end);
|
|
325
|
+
const escaped = written.replace(/(\\*)([[\]])/g, (_, slashes, bracket) => slashes.length % 2 === 1 ? `${slashes}${bracket}` : `${slashes}\\${bracket}`);
|
|
326
|
+
if (escaped !== written)
|
|
327
|
+
into.push({ start: span.start, end: span.end, text: escaped });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
walk(child, nested);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
walk(node, false);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Whether a node was written as a wikilink, decided from its own source span.
|
|
337
|
+
*
|
|
338
|
+
* The parser produces the identical node type for both syntaxes, so this is the
|
|
339
|
+
* only place the distinction survives — and it matters twice: a wikilink is not
|
|
340
|
+
* percent-encoded, so decoding one would corrupt a `%` an author typed, and the
|
|
341
|
+
* rewritten text has to be the syntax the author used.
|
|
342
|
+
*
|
|
343
|
+
* Read from the source rather than inferred from the url, because a Markdown
|
|
344
|
+
* link and a wikilink can carry byte-identical urls.
|
|
345
|
+
*/
|
|
346
|
+
function isWikilinkNode(node, source) {
|
|
347
|
+
const span = spanOf(node);
|
|
348
|
+
if (span === undefined)
|
|
349
|
+
return false;
|
|
350
|
+
const text = source.slice(span.start, span.end);
|
|
351
|
+
return text.startsWith('[[') || text.startsWith('![[');
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Replace spans, last first, refusing any overlap.
|
|
355
|
+
*
|
|
356
|
+
* Last-first so an earlier replacement cannot shift a later offset.
|
|
357
|
+
*
|
|
358
|
+
* **The overlap check no longer has a cause anyone can name, and that is the
|
|
359
|
+
* point of keeping it.** It used to: a link containing an image yielded two
|
|
360
|
+
* whole-node replacements over nested spans, and this threw — which meant a
|
|
361
|
+
* repository containing a README badge could not build. That is fixed at the
|
|
362
|
+
* source, by writing each rewrite as the two regions *around* a node's label
|
|
363
|
+
* rather than over it, so nothing this module emits overlaps.
|
|
364
|
+
*
|
|
365
|
+
* What remains is an invariant with no known way to violate it, kept because
|
|
366
|
+
* the cost of being wrong is asymmetric: a silent overlap splices one
|
|
367
|
+
* replacement into text another already replaced, and ships a body that is
|
|
368
|
+
* neither of the two things it was built from. A build that stops is
|
|
369
|
+
* recoverable; a published note nobody can trace back to its source is not.
|
|
370
|
+
*/
|
|
371
|
+
function applyEdits(source, edits) {
|
|
372
|
+
const ordered = [...edits].sort((a, b) => b.start - a.start);
|
|
373
|
+
let result = source;
|
|
374
|
+
let boundary = source.length;
|
|
375
|
+
for (const edit of ordered) {
|
|
376
|
+
if (edit.end > boundary) {
|
|
377
|
+
throw new Error('two link nodes claimed overlapping source spans, so the rewritten body would be ' +
|
|
378
|
+
'neither. Every rewrite is written around a node label rather than over it, so no ' +
|
|
379
|
+
'input should reach this — the note that did is worth reporting.');
|
|
380
|
+
}
|
|
381
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
382
|
+
boundary = edit.start;
|
|
383
|
+
}
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* A node's source span and its line, or `undefined` if the parser gave none.
|
|
388
|
+
*
|
|
389
|
+
* `offset` is **optional** in unist — `line` and `column` are the required pair
|
|
390
|
+
* — so the three values are narrowed together here rather than asserted at four
|
|
391
|
+
* call sites. Every construct measured against the installed parser carries
|
|
392
|
+
* one; a parser that did not, and a fabricated `0`, would splice a replacement
|
|
393
|
+
* at the top of the document instead of at the link.
|
|
394
|
+
*/
|
|
395
|
+
function spanOf(node) {
|
|
396
|
+
const position = node?.position;
|
|
397
|
+
if (position === undefined)
|
|
398
|
+
return undefined;
|
|
399
|
+
const { start, end } = position;
|
|
400
|
+
if (start.offset === undefined || end.offset === undefined)
|
|
401
|
+
return undefined;
|
|
402
|
+
return { start: start.offset, end: end.offset, line: start.line };
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Resolve every link in one note, rewriting the body and collecting the edges.
|
|
406
|
+
*
|
|
407
|
+
* @param markdown The note's body, frontmatter already stripped.
|
|
408
|
+
* @param sourcePath The note's path relative to the content root, which tiers 2
|
|
409
|
+
* and 5 resolve against.
|
|
410
|
+
* @param index The corpus index, built once for the whole build.
|
|
411
|
+
* @param selfSlug The writing note's own slug, so a link to its own heading
|
|
412
|
+
* renders without producing the self-edge `checkCorpus` rejects.
|
|
413
|
+
* @param routeFor The public route for a published slug. Injected rather than
|
|
414
|
+
* hardcoded for the same reason `renderMarkdown` injects it: this module does
|
|
415
|
+
* not own the route shape, and a later ticket moving notes under a prefix must
|
|
416
|
+
* not have to edit a traversal.
|
|
417
|
+
*/
|
|
418
|
+
export function resolveLinksIn(markdown, sourcePath, index, selfSlug, routeFor) {
|
|
419
|
+
const tree = markdownToMdast(markdown, { features: { wikilinks: true, gfm: true } });
|
|
420
|
+
const nodes = [];
|
|
421
|
+
const nestedNodes = new Set();
|
|
422
|
+
const definitionNodes = [];
|
|
423
|
+
collectLinkNodes(tree, nodes, nestedNodes, definitionNodes);
|
|
424
|
+
const definitions = definitionsById(definitionNodes);
|
|
425
|
+
const edits = [];
|
|
426
|
+
const findings = [];
|
|
427
|
+
const outgoing = [];
|
|
428
|
+
const seen = new Set();
|
|
429
|
+
for (const node of nodes) {
|
|
430
|
+
// A reference resolves through its definition's destination, and is then
|
|
431
|
+
// rewritten exactly as the inline form with that destination would be — see
|
|
432
|
+
// {@link rewriteDefinition} for the definition's own half.
|
|
433
|
+
let url;
|
|
434
|
+
if (node.type === 'link' || node.type === 'image')
|
|
435
|
+
url = node.url;
|
|
436
|
+
else if (node.type === 'linkReference' || node.type === 'imageReference') {
|
|
437
|
+
const definition = definitions.get(node.identifier);
|
|
438
|
+
// The parser only emits a reference node when a matching definition
|
|
439
|
+
// exists, so this is unreachable; a reference with nothing to resolve is
|
|
440
|
+
// left as authored rather than guessed at.
|
|
441
|
+
if (definition?.type !== 'definition')
|
|
442
|
+
continue;
|
|
443
|
+
url = definition.url;
|
|
444
|
+
}
|
|
445
|
+
else
|
|
446
|
+
continue;
|
|
447
|
+
// A node with no span cannot be edited and cannot be located in a report,
|
|
448
|
+
// so it is left exactly as authored. `offset` is optional in unist —
|
|
449
|
+
// `column` and `line` are the required pair — and every construct measured
|
|
450
|
+
// here carries one, but a parser is free not to and a fabricated `0` would
|
|
451
|
+
// splice text at the top of the document.
|
|
452
|
+
const span = spanOf(node);
|
|
453
|
+
if (span === undefined)
|
|
454
|
+
continue;
|
|
455
|
+
// A reference's destination is a definition's, which is always Markdown
|
|
456
|
+
// syntax — and `[[a] b][x]` starts with `[[` without being a wikilink.
|
|
457
|
+
const isWikilink = (node.type === 'link' || node.type === 'image') && isWikilinkNode(node, markdown);
|
|
458
|
+
const written = markdown.slice(span.start, span.end);
|
|
459
|
+
const label = displayText(node, markdown);
|
|
460
|
+
const nested = nestedNodes.has(node);
|
|
461
|
+
// **The single resolution.** Everything below reads this one value: the
|
|
462
|
+
// replacement text, the edge, and the finding. There is no second call and
|
|
463
|
+
// no second decision.
|
|
464
|
+
const resolution = resolveLink(url, sourcePath, index, isWikilink);
|
|
465
|
+
if (resolution.kind === 'external') {
|
|
466
|
+
// Left byte-for-byte, including a wikilink that turned out to be a bare
|
|
467
|
+
// fragment — rewriting one would change a link this build has no opinion
|
|
468
|
+
// about.
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
// **Every edit is written around the label, never over it.** A node's label
|
|
472
|
+
// is where a nested node writes, so an edit spanning it would overwrite
|
|
473
|
+
// whatever that nested node produced — measured, `applyEdits` refused the
|
|
474
|
+
// overlap and the build failed on `[](gone.md)`. Splitting
|
|
475
|
+
// each rewrite into the two regions on either side of the label is what
|
|
476
|
+
// makes an outer node and a nested one both writable in one pass. A node
|
|
477
|
+
// with no label span — an image, or a link the parser gave none — has
|
|
478
|
+
// nothing nested to protect and is replaced whole.
|
|
479
|
+
//
|
|
480
|
+
// The whole-span form is also the only one that could escape its label
|
|
481
|
+
// *through* {@link escapeLabel}: the split form leaves the label's own bytes
|
|
482
|
+
// in place, which is what preserves a nested node's rewrite. So the split
|
|
483
|
+
// form escapes its label separately, in edits scoped to the label's own text
|
|
484
|
+
// nodes — see {@link escapeLabelEdits}, which is where that had been missing
|
|
485
|
+
// for as long as the split form existed.
|
|
486
|
+
const inner = node.type === 'link' || node.type === 'linkReference' ? labelSpan(node) : undefined;
|
|
487
|
+
const rewrite = (before, after, fallbackLabel = label) => {
|
|
488
|
+
if (inner === undefined) {
|
|
489
|
+
edits.push({
|
|
490
|
+
start: span.start,
|
|
491
|
+
end: span.end,
|
|
492
|
+
text: before + escapeLabel(fallbackLabel) + after,
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
edits.push({ start: span.start, end: inner.start, text: before });
|
|
497
|
+
edits.push({ start: inner.end, end: span.end, text: after });
|
|
498
|
+
// Only where an anchor is actually opened. An `unresolved` link degrades
|
|
499
|
+
// to plain text, and text is not a construct a `]` can break — escaping
|
|
500
|
+
// there would ship the author a visible backslash in prose.
|
|
501
|
+
if (before !== '')
|
|
502
|
+
escapeLabelEdits(node, markdown, edits);
|
|
503
|
+
};
|
|
504
|
+
if (resolution.kind === 'unresolved' || resolution.kind === 'unpublished') {
|
|
505
|
+
// **`unpublished` keeps the author's label whole and becomes a live link
|
|
506
|
+
// to {@link WITHHELD_ROUTE}.** Owner decision, 2026-08-17, reversing the
|
|
507
|
+
// 2026-08-14 rule that reduced the label to the target's last segment.
|
|
508
|
+
//
|
|
509
|
+
// What changed, measured on the same input:
|
|
510
|
+
//
|
|
511
|
+
// See [[clients/acme/2026-renewal]] for the numbers.
|
|
512
|
+
// before: See 2026-renewal for the numbers.
|
|
513
|
+
// after: See [clients/acme/2026-renewal](/private/) for the numbers.
|
|
514
|
+
//
|
|
515
|
+
// **What it costs, stated because the code cannot state it later.** A
|
|
516
|
+
// wikilink's label *is* its target, so the full path of every withheld
|
|
517
|
+
// note a published note links now enters `dist/`. One `grep` over the
|
|
518
|
+
// built site lists the directory structure the author excluded. That is
|
|
519
|
+
// the intended consequence rather than an oversight; the owner heard it
|
|
520
|
+
// and kept the rule. The withheld note's *body* still never ships, which
|
|
521
|
+
// is a different fact and is gated separately.
|
|
522
|
+
//
|
|
523
|
+
// `unresolved` still degrades to text: there is no honest destination for
|
|
524
|
+
// a link to nothing, and `/private/` would claim the target exists.
|
|
525
|
+
//
|
|
526
|
+
// **A withheld node nested inside a link stays text**, and that is forced
|
|
527
|
+
// rather than chosen: CommonMark has no nested anchor, so emitting one
|
|
528
|
+
// produces `[[chart](/private/)](/target/)` — measured through the shipped
|
|
529
|
+
// renderer as `[<a href="/private/">chart</a>](/notes-beta/)`, an anchor
|
|
530
|
+
// to the withheld page with the outer link destroyed and its closing
|
|
531
|
+
// syntax spilled into the prose. A `link` inside a `link` is
|
|
532
|
+
// unrepresentable in mdast (measured), so the containers this has to
|
|
533
|
+
// survive are the image and the two *reference* forms — see
|
|
534
|
+
// {@link CONTAINS_LINK}, which is where omitting `linkReference` put the
|
|
535
|
+
// nested anchor back.
|
|
536
|
+
//
|
|
537
|
+
// The label a nested node keeps is its own text, which for an image is
|
|
538
|
+
// the alt the author wrote. That is usually not the path — but
|
|
539
|
+
// `![[private/chart.png]]` has an alt *equal to* the path, so this drops
|
|
540
|
+
// the path in some spellings and keeps it in others. Neither is a privacy
|
|
541
|
+
// property any more; what decides it is only whether an anchor can open
|
|
542
|
+
// here.
|
|
543
|
+
const live = resolution.kind === 'unpublished' && !nested;
|
|
544
|
+
// The slug is not available on an `unpublished` resolution — there is no
|
|
545
|
+
// published note to name — so an empty label falls back to the withheld
|
|
546
|
+
// page's own title. `[](x.md)` and `` otherwise emit
|
|
547
|
+
// `<a href="/private/"></a>`, an anchor with no accessible name, which is
|
|
548
|
+
// the defect the resolved branch below already guards against with
|
|
549
|
+
// `label || resolution.slug`. Under the previous rule these degraded to
|
|
550
|
+
// nothing at all, so the shape did not exist to guard.
|
|
551
|
+
if (live)
|
|
552
|
+
rewrite('[', `](${WITHHELD_ROUTE})`, label || WITHHELD_LINK_TEXT);
|
|
553
|
+
else
|
|
554
|
+
rewrite('', '');
|
|
555
|
+
findings.push({
|
|
556
|
+
source: sourcePath,
|
|
557
|
+
line: span.line,
|
|
558
|
+
link: written,
|
|
559
|
+
outcome: resolution.kind,
|
|
560
|
+
...(resolution.kind === 'unpublished' ? { candidates: [...resolution.candidates] } : {}),
|
|
561
|
+
});
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
// Resolved or ambiguous: both render, and both contribute an edge. An
|
|
565
|
+
// ambiguous link is a link that *worked* — decision D1 makes ambiguity a
|
|
566
|
+
// warning, because failing is worst exactly where it is most likely and a
|
|
567
|
+
// stranger running this tool cannot always act on it. What separates the two
|
|
568
|
+
// is that one is reported.
|
|
569
|
+
//
|
|
570
|
+
// **An `![[note]]` embed becomes a link, not an image.** Obsidian
|
|
571
|
+
// transcludes it — the target note's content is rendered inline — and
|
|
572
|
+
// nothing here does that: transclusion is a content decision (which
|
|
573
|
+
// headings, how deep, what happens to a cycle) that no ticket has made. An
|
|
574
|
+
// `<img src>` pointing at an HTML page is the one outcome that is certainly
|
|
575
|
+
// wrong, so the embed degrades to an ordinary link to the note, and the
|
|
576
|
+
// report says an embed was demoted rather than leaving the author to
|
|
577
|
+
// discover a missing image. An embed of a *file* — the ordinary
|
|
578
|
+
// `![[diagram.png]]` — never reaches here: an image is not a published note,
|
|
579
|
+
// so it left as `unpublished` above.
|
|
580
|
+
const embedDemoted = node.type === 'image' || node.type === 'imageReference';
|
|
581
|
+
const href = `${routeFor(resolution.slug)}${resolution.anchor}`;
|
|
582
|
+
// The slug is the fallback name for a node with no label at all — `[](x)`
|
|
583
|
+
// and an image whose alt is empty — because `[](/route/)` renders an anchor
|
|
584
|
+
// with no accessible name. A node that *has* a label keeps it, whatever it
|
|
585
|
+
// contains.
|
|
586
|
+
rewrite('[', `](${href})`, label || resolution.slug);
|
|
587
|
+
if (embedDemoted) {
|
|
588
|
+
findings.push({
|
|
589
|
+
source: sourcePath,
|
|
590
|
+
line: span.line,
|
|
591
|
+
link: written,
|
|
592
|
+
outcome: 'embed-not-transcluded',
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
// A note linking to its own heading is ordinary, and `checkCorpus` rejects
|
|
596
|
+
// an entry that lists itself in `outgoing`. The link still renders — the
|
|
597
|
+
// anchor is real — and simply contributes no edge.
|
|
598
|
+
if (resolution.slug !== selfSlug && !seen.has(resolution.slug)) {
|
|
599
|
+
seen.add(resolution.slug);
|
|
600
|
+
outgoing.push(resolution.slug);
|
|
601
|
+
}
|
|
602
|
+
if (resolution.kind === 'ambiguous') {
|
|
603
|
+
findings.push({
|
|
604
|
+
source: sourcePath,
|
|
605
|
+
line: span.line,
|
|
606
|
+
link: written,
|
|
607
|
+
outcome: 'ambiguous',
|
|
608
|
+
candidates: [...resolution.candidates],
|
|
609
|
+
resolvedTo: resolution.path,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// **Every internal definition is rewritten too, used or not.** No reference
|
|
614
|
+
// reads it any more — each one was rewritten inline above — so this changes
|
|
615
|
+
// nothing a reader sees; it keeps the author's withheld destination out of the
|
|
616
|
+
// body handed onward, which has no reason to carry a path nothing renders.
|
|
617
|
+
// It contributes no edge and no finding: a definition renders nothing, and an
|
|
618
|
+
// edge exists exactly where a rendered anchor does. An unresolved destination
|
|
619
|
+
// names no file this build knows of, so it is left as written.
|
|
620
|
+
for (const definition of definitionNodes) {
|
|
621
|
+
if (definition.type !== 'definition')
|
|
622
|
+
continue;
|
|
623
|
+
const span = spanOf(definition);
|
|
624
|
+
if (span === undefined)
|
|
625
|
+
continue;
|
|
626
|
+
const resolution = resolveLink(definition.url, sourcePath, index, false);
|
|
627
|
+
let destination;
|
|
628
|
+
if (resolution.kind === 'resolved' || resolution.kind === 'ambiguous') {
|
|
629
|
+
destination = `${routeFor(resolution.slug)}${resolution.anchor}`;
|
|
630
|
+
}
|
|
631
|
+
else if (resolution.kind === 'unpublished')
|
|
632
|
+
destination = WITHHELD_ROUTE;
|
|
633
|
+
else
|
|
634
|
+
continue;
|
|
635
|
+
const text = rewriteDefinition(markdown.slice(span.start, span.end), destination);
|
|
636
|
+
if (text !== undefined)
|
|
637
|
+
edits.push({ start: span.start, end: span.end, text });
|
|
638
|
+
}
|
|
639
|
+
return { markdown: applyEdits(markdown, edits), outgoing, findings };
|
|
640
|
+
}
|