@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,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.** `[![logo](logo.png)](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 `![chart](private/chart.png)` correctly degraded to
76
+ * `chart` while the nested form shipped `[![chart](private/chart.png)](/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 — `[![build](badge.png)][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
+ * `[![logo](assets/logo.png)](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 &lt;img src=…&gt;](t) -> <a>label <img src=…></a>
282
+ * ```
283
+ *
284
+ * The third is the serious one: an author who wrote `&lt;img&gt;` 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 `[![logo](logo.png)](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 `![](x.png)` 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
+ }