@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,96 @@
1
+ /**
2
+ * The pure half of the hover preview: what may be shown, and where it goes.
3
+ *
4
+ * Split out of `src/scripts/link-preview.ts` for one reason — the placement
5
+ * arithmetic is the part most likely to be subtly wrong, and a browser test can
6
+ * only prove it for the link positions a corpus happens to contain. Nothing
7
+ * here touches the DOM, so every edge, every flip, and every malformed fragment
8
+ * is reachable from a unit test at `tests/preview-model.test.ts`.
9
+ *
10
+ * This module is imported into the browser bundle, so it keeps the same
11
+ * discipline as `route-path.ts`: no artifact import, no site map, nothing a
12
+ * reader would download and never use.
13
+ */
14
+ export const PREVIEW_TITLE_LIMIT = 400;
15
+ /** A bounded title plus its public aliases, all rendered as text. */
16
+ export function previewTitle(entry) {
17
+ const text = entry.aliases === undefined || entry.aliases.length === 0
18
+ ? entry.title
19
+ : `${entry.title} (${entry.aliases.join(', ')})`;
20
+ return text.length <= PREVIEW_TITLE_LIMIT
21
+ ? text
22
+ : `${text.slice(0, PREVIEW_TITLE_LIMIT - 1)}…`;
23
+ }
24
+ /** Between the link and the panel, and between the panel and the viewport edge. */
25
+ export const PREVIEW_GAP_PX = 8;
26
+ export const VIEWPORT_MARGIN_PX = 8;
27
+ /**
28
+ * How much of a `#heading` fragment the panel will show.
29
+ *
30
+ * A fragment is a heading slug this pipeline generated, so it is already short;
31
+ * the bound exists because nothing in the artifact contract constrains it and an
32
+ * unbounded string in a fixed-width panel is a layout defect waiting for one
33
+ * long heading.
34
+ */
35
+ export const FRAGMENT_LIMIT = 80;
36
+ /**
37
+ * The section a `/notes/<slug>/#heading` link points at, as the panel shows it.
38
+ *
39
+ * Shown **verbatim**, decoded and bounded, rather than turned back into prose.
40
+ * The payload carries a note's title and excerpt and nothing per-heading, so the
41
+ * heading's real text is not available here; de-slugging `reading-a-build-log`
42
+ * into something title-like would be a guess rendered in the shape of a fact.
43
+ * The fragment is what the address bar will show on arrival, so it is the one
44
+ * thing that is certainly true about the target.
45
+ *
46
+ * `decodeURIComponent` throws on a malformed escape, which a hand-written href
47
+ * can contain. That is a "fail silently" case, not a broken preview: the raw
48
+ * fragment is still an honest label.
49
+ */
50
+ export function previewFragment(hash) {
51
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
52
+ if (raw === '')
53
+ return undefined;
54
+ let text;
55
+ try {
56
+ text = decodeURIComponent(raw);
57
+ }
58
+ catch {
59
+ text = raw;
60
+ }
61
+ return `#${text.length > FRAGMENT_LIMIT ? `${text.slice(0, FRAGMENT_LIMIT)}…` : text}`;
62
+ }
63
+ /**
64
+ * Keep a coordinate inside its bounds, with the low edge winning a tie.
65
+ *
66
+ * `lowest` is applied last deliberately. When the panel is larger than the
67
+ * space available, `highest` falls below `lowest` and one of the two edges has
68
+ * to be given up; giving up the far edge keeps the panel's top-left corner —
69
+ * where the title is, and where reading starts — on screen.
70
+ */
71
+ function clamp(desired, lowest, highest) {
72
+ return Math.max(lowest, Math.min(desired, highest));
73
+ }
74
+ /**
75
+ * Where the panel goes, in viewport coordinates, for a link at `anchor`.
76
+ *
77
+ * Below the link by default, flipped above it when the panel does not fit below
78
+ * *and* there is more room above — the second condition matters, because a link
79
+ * near the top of a tall page has no room either way and flipping it there
80
+ * would trade a clipped bottom for a clipped top. Both coordinates are then
81
+ * clamped, so all four viewport edges hold whatever the link's position and the
82
+ * panel's size turn out to be.
83
+ *
84
+ * Viewport coordinates because the panel is `position: fixed`; that is the same
85
+ * space `getBoundingClientRect` reports in, so no scroll offset enters the
86
+ * arithmetic and there is nothing to keep in sync while the page scrolls.
87
+ */
88
+ export function placePreview(anchor, panel, viewport) {
89
+ const roomBelow = viewport.height - VIEWPORT_MARGIN_PX - (anchor.bottom + PREVIEW_GAP_PX);
90
+ const roomAbove = anchor.top - PREVIEW_GAP_PX - VIEWPORT_MARGIN_PX;
91
+ const isAbove = panel.height > roomBelow && roomAbove > roomBelow;
92
+ return {
93
+ left: clamp(anchor.left, VIEWPORT_MARGIN_PX, viewport.width - VIEWPORT_MARGIN_PX - panel.width),
94
+ top: clamp(isAbove ? anchor.top - PREVIEW_GAP_PX - panel.height : anchor.bottom + PREVIEW_GAP_PX, VIEWPORT_MARGIN_PX, viewport.height - VIEWPORT_MARGIN_PX - panel.height),
95
+ };
96
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Derived relationships: what a note is *near*, when nothing links it there.
3
+ *
4
+ * The artifact carries two authored edge sets — `outgoing` and `backlinks` —
5
+ * and `checkCorpus` proves they are exact inverses of each other. Everything in
6
+ * this module is the other kind of relationship: not an edge somebody wrote,
7
+ * but an adjacency computed from public metadata. The two must never be
8
+ * confused on the page, which is why they render under separate headings and
9
+ * why the derived list says on its face that it is derived.
10
+ *
11
+ * Pure, like `routes.ts`: nothing here imports the artifact. Callers pass the
12
+ * corpus in, so every rule below is exercisable against synthetic fixtures
13
+ * without building the site.
14
+ */
15
+ import { byTitleThenSlug, collectionFacets, tagFacets } from "./routes.js";
16
+ /**
17
+ * The shared title-then-slug comparator, owned by `routes.ts` and re-exported so
18
+ * this module's callers keep one import. It used to be restated here and held
19
+ * to agreement by a test; one definition makes the agreement structural.
20
+ */
21
+ export { byTitleThenSlug };
22
+ /**
23
+ * Resolve an edge list to the notes it names, in a stable reader order.
24
+ *
25
+ * Requirements section 13.1 asks for a stable sort order. The artifact stores
26
+ * both edge lists sorted by slug — which is what makes the corpus invariant
27
+ * cheap to check and is meaningless to a reader, since a slug is a URL segment
28
+ * and the reader is looking at titles. Sorting by title here is the display
29
+ * decision; the artifact's order is unchanged.
30
+ *
31
+ * An unresolvable slug is skipped rather than rendered as a dead link.
32
+ * `checkCorpus` already proves every edge resolves to a published entry, so
33
+ * this is unreachable through the validated loader — but this function is pure
34
+ * and fixture-driven, and a list of links must not depend on its caller having
35
+ * validated first.
36
+ */
37
+ export function notesForSlugs(slugs, lookup) {
38
+ return slugs
39
+ .map(lookup)
40
+ .filter((entry) => entry !== undefined)
41
+ .sort(byTitleThenSlug);
42
+ }
43
+ /**
44
+ * How many related notes a page may list.
45
+ *
46
+ * Requirements section 9.2 item 10 requires the list to be bounded, and the
47
+ * number is chosen for the reader rather than for the data: a suggestion list
48
+ * longer than this stops being a suggestion and becomes a second index of the
49
+ * site, which the tag pages already are. Five also keeps the section inside one
50
+ * screen at 320 px.
51
+ */
52
+ export const RELATED_LIMIT = 5;
53
+ /**
54
+ * `tagFacets` for one corpus, computed once.
55
+ *
56
+ * Both functions below group the whole corpus by tag, and a static build calls
57
+ * them once per note — so without this the grouping runs twice per page, which
58
+ * measured **8.9 s** across a 900-entry corpus. With it, 224 ms.
59
+ *
60
+ * That 900 was `MAX_ENTRIES`, a ceiling this project no longer has: it refused
61
+ * an ordinary vault at 957 notes and `src/lib/schema.ts` records why it went.
62
+ * The measurement stands as a measurement *at 900 entries* and is no longer a
63
+ * measurement at the largest corpus this tool accepts — which is now unbounded,
64
+ * so the quadratic this cache removes matters more than it did, not less.
65
+ *
66
+ * That is a build-time cost only and nothing ships to a reader, but
67
+ * seconds of build for a value that cannot change between two calls in the same
68
+ * build is not a trade worth taking.
69
+ *
70
+ * A `WeakMap` keyed on the array a caller passed, rather than a module-level
71
+ * variable: the fixture build, the published build, and the tests all evaluate
72
+ * this module and pass *different* corpora, and a single cached value would
73
+ * serve one build's grouping to another. Keying on the array means a distinct
74
+ * corpus is a distinct entry, and a corpus nobody holds any more is collected.
75
+ * This module stays pure — the same input still gives the same answer.
76
+ */
77
+ const facetsByCorpus = new WeakMap();
78
+ function facetsFor(entries) {
79
+ const cached = facetsByCorpus.get(entries);
80
+ if (cached !== undefined)
81
+ return cached;
82
+ const computed = tagFacets(entries);
83
+ facetsByCorpus.set(entries, computed);
84
+ return computed;
85
+ }
86
+ /**
87
+ * The published tag groups this note belongs to.
88
+ *
89
+ * `facets` is the same index the tag routes render — the snapshot's own rows on
90
+ * the build path (`content.ts`), the producer normalization otherwise — so
91
+ * "shares a tag" here names exactly the pages the site publishes, and grouping
92
+ * never independently renormalizes what those pages show.
93
+ */
94
+ function sharedFacets(entry, facets) {
95
+ return facets.filter((facet) => facet.entries.some((member) => member.slug === entry.slug));
96
+ }
97
+ /**
98
+ * Notes related to this one, most related first.
99
+ *
100
+ * **The rule.** A candidate is related when it shares at least one tag. It is
101
+ * ranked by its **rarest shared tag**: the tag that groups the fewest notes,
102
+ * with the smaller group ranking higher. Two candidates whose rarest shared tag
103
+ * is the same size are ordered by how many tags they share, then by a shared
104
+ * collection, then title, then slug.
105
+ *
106
+ * **Why the rarest tag rather than a sum of weights.** Summing per-tag rarity
107
+ * was the first version and it is not the rule anyone would state: two tags of
108
+ * 3 notes each outrank one tag of 2, so a candidate sharing several
109
+ * middle-frequency tags beats the one candidate sharing the tag that actually
110
+ * distinguishes this note. Taking the minimum makes the ranking say exactly
111
+ * what the page says it says — a claim in prose that the code does not
112
+ * implement is worse than a cruder rule.
113
+ *
114
+ * **Why rarity at all.** On the fixture corpus the tag `Field Notes` is on 20
115
+ * of 32 notes. Ranking by a plain count of shared tags makes "related" mean
116
+ * "also a note": measured on that corpus, `alias-heavy` and `undated-note-b`
117
+ * take 21 of the 89 top-three placements between them — a quarter of every
118
+ * suggestion on the site is one of two notes — because those two carry the two
119
+ * commonest tags and nothing else. A tag that groups three notes should count
120
+ * for more than one that groups two thirds of the site.
121
+ *
122
+ * **Why tags and not shared neighbours.** Shared neighbours were the other
123
+ * candidate in the ticket. They are already on the page: the two sections above
124
+ * this one render exactly the notes this one links to and is linked from, so a
125
+ * derivation over the same edges would mostly restate them. Tags are the only
126
+ * public grouping the artifact carries that the authored edges do not already
127
+ * express. Shared collection is kept, but as a tiebreak rather than as a source
128
+ * of candidates — a collection is coarse (three of them cover 28 of the 32
129
+ * fixture notes), so as a source it would list arbitrary collection members as
130
+ * "related".
131
+ *
132
+ * **What is excluded.** The note itself, and every note already named by
133
+ * `outgoing` or `backlinks`. Those render immediately above, and suggesting a
134
+ * page the reader can see two lines up is noise. That exclusion is what makes
135
+ * this a *fallback* list rather than a third edge list — and it is why the
136
+ * empty state must not claim nothing shares a tag: everything that does may
137
+ * simply be linked already. {@link hasTagPeer} answers that question so the
138
+ * page can say which of the two it is.
139
+ *
140
+ * Grouping comes from the facet index the caller passes — the snapshot's own
141
+ * tag rows on the build path, `content.ts`'s `tagFacets()`, and the producer
142
+ * normalization otherwise — rather than from a second pass over `entry.tags`,
143
+ * so "shares a tag" means exactly what "appears on the same tag page" means:
144
+ * `Gardening` and `gardening` are one tag here because they are one tag there.
145
+ * A private second normalization would let a note be listed as related on a tag
146
+ * the site never publishes.
147
+ *
148
+ * **The rule below is also stated to the reader, and that prose is not here.**
149
+ * It lives in `Translation.relatedDerivation`, in both languages, because it is
150
+ * chrome and chrome is per document since TK-16. It used to sit beside this
151
+ * comparator because the first version of this module shipped a page stating a
152
+ * rule the ranking did not implement — the prose said "rarest tag first" while
153
+ * the code summed weights, so two middling tags beat one rare one.
154
+ *
155
+ * So: **if you change the ranking below, change `relatedDerivation` in both
156
+ * locales of `src/lib/translations.ts`.** Proximity was never proof the two
157
+ * agreed — `tests/relations.test.ts` proves the rule and the gate over `dist/`
158
+ * asserts the page renders exactly the resolved sentence — but it was the
159
+ * reminder, and this paragraph is what replaces it.
160
+ *
161
+ * ponytail: `sharedFacets` still scans the whole index per call, so a build
162
+ * that asks for every note's related list is notes × tags. The `WeakMap` above
163
+ * removed the quadratic grouping, and the build path now also shares one index
164
+ * instead of rebuilding it. A per-note membership lookup would be the next
165
+ * step only if a corpus makes the remaining scan measurable.
166
+ */
167
+ export function relatedNotes(entry, entries, facets = facetsFor(entries)) {
168
+ const excluded = new Set([entry.slug, ...entry.outgoing, ...entry.backlinks]);
169
+ const scored = new Map();
170
+ for (const facet of sharedFacets(entry, facets)) {
171
+ for (const candidate of facet.entries) {
172
+ if (excluded.has(candidate.slug))
173
+ continue;
174
+ const current = scored.get(candidate.slug);
175
+ if (current === undefined) {
176
+ scored.set(candidate.slug, { entry: candidate, rarest: facet.entries.length, shared: 1 });
177
+ }
178
+ else {
179
+ current.rarest = Math.min(current.rarest, facet.entries.length);
180
+ current.shared += 1;
181
+ }
182
+ }
183
+ }
184
+ const sharesCollection = (other) => entry.collection !== undefined && other.collection === entry.collection;
185
+ return [...scored.values()]
186
+ .sort((a, b) => {
187
+ if (a.rarest !== b.rarest)
188
+ return a.rarest - b.rarest;
189
+ if (a.shared !== b.shared)
190
+ return b.shared - a.shared;
191
+ const left = sharesCollection(a.entry);
192
+ const right = sharesCollection(b.entry);
193
+ if (left !== right)
194
+ return left ? -1 : 1;
195
+ return byTitleThenSlug(a.entry, b.entry);
196
+ })
197
+ .slice(0, RELATED_LIMIT)
198
+ .map((candidate) => candidate.entry);
199
+ }
200
+ /**
201
+ * Whether any other published note shares a tag with this one, linked or not.
202
+ *
203
+ * This is what separates the two reasons {@link relatedNotes} returns nothing.
204
+ * A note with no tag-sharer at all genuinely has no neighbourhood; a note whose
205
+ * every tag-sharer is already in `outgoing` or `backlinks` has one, and it is
206
+ * rendered in the two sections above. Saying "nothing shares a tag" in the
207
+ * second case would be a statement the artifact contradicts, which is exactly
208
+ * what the empty states exist to avoid.
209
+ */
210
+ export function hasTagPeer(entry, entries, facets = facetsFor(entries)) {
211
+ return sharedFacets(entry, facets).some((facet) => facet.entries.some((member) => member.slug !== entry.slug));
212
+ }
213
+ /**
214
+ * The previous and next note in this note's collection.
215
+ *
216
+ * The sequence is {@link collectionFacets}' own order, which is the order
217
+ * `/collections/<key>/` lists them in — title, then slug. Any other order would
218
+ * put the pager and the collection index in disagreement, so a reader following
219
+ * "Next" repeatedly would walk the collection in an order its own index does
220
+ * not show. The artifact carries no authored sequence, so ordering by date
221
+ * would be inventing a reading order the owner never expressed, and `collection`
222
+ * is a flat slug with no nesting behind it — TK-05c owns hierarchy.
223
+ *
224
+ * A note with no collection, or the only note in one, has no sequence: both
225
+ * fields are absent and the page renders no pager. That is the "where
226
+ * meaningful" clause of requirements section 9.2 item 12 — a pager with two
227
+ * dead ends is chrome describing nothing.
228
+ */
229
+ export function collectionNeighbours(entry, entries) {
230
+ if (entry.collection === undefined)
231
+ return {};
232
+ const facet = collectionFacets(entries).find((candidate) => candidate.key === entry.collection);
233
+ if (facet === undefined)
234
+ return {};
235
+ const at = facet.entries.findIndex((member) => member.slug === entry.slug);
236
+ if (at < 0)
237
+ return {};
238
+ return {
239
+ previous: at > 0 ? facet.entries[at - 1] : undefined,
240
+ next: at + 1 < facet.entries.length ? facet.entries[at + 1] : undefined,
241
+ };
242
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The mark that says "a renderer produced this region", and nothing else.
3
+ *
4
+ * A leaf module with no imports, and that is the whole reason it exists rather
5
+ * than living beside the code that writes it. `scripts/scan-residue.ts` needs
6
+ * this vocabulary and its predicates, and importing them from
7
+ * `src/lib/markdown.ts` pulls in `mermaid-render.ts` and a top-level pass that
8
+ * loads every Prism grammar:
9
+ * measured, the residue scan went from **4.1 s to 25.5 s**, on a step that is a
10
+ * link of `pnpm run build` and of the shipped binary's chain. Every user's build
11
+ * would pay twenty seconds to read two literals.
12
+ *
13
+ * `src/lib/markdown.ts` documents what the mark *means*, where it is minted, and
14
+ * why that point is unforgeable. This file is the shared marker interface.
15
+ */
16
+ /**
17
+ * The attribute a renderer's output carries.
18
+ *
19
+ * **Unbranded, deliberately.** A first version spelled this project's name into
20
+ * the attribute and `tests/site-identity.test.ts` went red on it: every stranger's note
21
+ * containing an expression or a diagram shipped this project's name into their
22
+ * own `dist/`, which is the headline TK-31 criterion. The attribute has to be
23
+ * unforgeable, not branded — and unforgeability comes from *where* it is
24
+ * written, not from what it is called.
25
+ */
26
+ export const RENDERED_MARKER = 'data-rendered';
27
+ /** The two kinds of region {@link RENDERED_MARKER} distinguishes. */
28
+ export const RENDERED_MATH = 'math';
29
+ export const RENDERED_DIAGRAM = 'diagram';
30
+ /**
31
+ * The marker in an attribute position, wherever it occurs in an opening tag.
32
+ *
33
+ * Kept beside {@link MARKED_ROOT} so the residue scanner and script-budget gate
34
+ * cannot silently grow different definitions of the same security-sensitive
35
+ * grammar. The leading whitespace is load-bearing: without it, a marker-looking
36
+ * string inside another attribute's value would satisfy the predicate.
37
+ */
38
+ export const MARKED_ATTRIBUTE = (kind) => new RegExp(`\\s${RENDERED_MARKER}="${kind}"`);
39
+ /**
40
+ * A marked element's opening tag — the shape a consumer must look for.
41
+ *
42
+ * **Presence in the page is not the property either consumer wants.** The
43
+ * marker string has no character an HTML escaper rewrites, so a note body typing
44
+ * it in a paragraph puts it in the output as ordinary text — measured, and also
45
+ * through numeric character references such as
46
+ * `&#100;&#97;&#116;&#97;-rendered=&#34;math&#34;`. What no body can produce is
47
+ * the marker in an *attribute position on an element*, because it is written
48
+ * after the sanitizer.
49
+ *
50
+ * A first version of the script-budget gate tested the bare string against the
51
+ * whole page, so a note could mint itself 220 KB of allowance by typing it in
52
+ * prose — strictly worse than the author-writable class it replaced. This
53
+ * function exists so that mistake is not available by accident.
54
+ */
55
+ export const MARKED_ROOT = (kind) => new RegExp(`<[a-zA-Z][a-zA-Z0-9-]*${MARKED_ATTRIBUTE(kind).source}`);
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The note-route path shape, split out so a browser script can import it.
3
+ *
4
+ * `routes.ts` holds the site's navigation, its facet grouping, and its redirect
5
+ * map — none of which a client script needs. Importing it into one shipped a
6
+ * dead copy of the whole site map to every reader: Rolldown could not drop the
7
+ * `SITE_MAP` array literal, so it landed in the bundle as a no-op expression.
8
+ *
9
+ * This module is the part `link-preview.ts` actually needs, and nothing else.
10
+ * `routes.ts` re-exports from here, so the route shape is still defined once.
11
+ */
12
+ /** The top-level segment notes live under. Reserved by TK-01's slug validation. */
13
+ export const NOTES_SEGMENT = 'notes';
14
+ /** The canonical route for a published note. */
15
+ export function noteRoute(slug) {
16
+ return `/${NOTES_SEGMENT}/${slug}/`;
17
+ }
18
+ /**
19
+ * Where a link to a note this build did not publish points.
20
+ *
21
+ * A single page, identical for every withheld target, saying that the note is
22
+ * not published. It says nothing about *which* note — it cannot, because it is
23
+ * one static page — so the only thing a reader learns here is what the link they
24
+ * clicked already told them.
25
+ *
26
+ * **The segment is reserved, and that is not tidiness.** `markdown.ts`'s
27
+ * `INTERNAL_HREF` rewrites any single-segment `/<slug>/` href through
28
+ * `routeForSlug`, so without the reservation a user publishing a note named
29
+ * `private` would have every withheld link on their site silently redirected to
30
+ * *that note*. Measured against the regex: `/private/` matches it and
31
+ * `/notes/x/` does not. `src/lib/schema.ts` carries the reservation and
32
+ * `tests/route-model.test.ts` holds the pairing.
33
+ *
34
+ * Defined here rather than in `routes.ts` for the same reason {@link noteRoute}
35
+ * is: `scripts/resolve-links.ts` needs it while writing a body, and importing
36
+ * the site map into a traversal would tie the producer to the navigation.
37
+ */
38
+ export const WITHHELD_ROUTE = '/private/';
39
+ /**
40
+ * The accessible name a withheld link gets when the author wrote none.
41
+ *
42
+ * `[](note.md)` and `![](chart.png)` have no label at all, and under the rule
43
+ * this replaced they degraded to nothing, so the case did not arise. Now they
44
+ * become anchors, and an anchor with no text is one a screen reader announces as
45
+ * its URL and a pointer user cannot see. The resolved branch has had the same
46
+ * guard since TK-27, falling back to the target's slug; a withheld target has no
47
+ * slug to fall back to, so the fallback is what the destination says about
48
+ * itself.
49
+ *
50
+ * **Not resolved from `translations.ts`, and that is the same boundary
51
+ * `resolve-links.ts` already keeps.** This text is written into the author's
52
+ * *Markdown body*, which has one language — theirs — while chrome is resolved
53
+ * per document at render time. Threading a locale into the traversal would make
54
+ * the producer's output depend on a page's language, which is a different thing
55
+ * from the label a body carries. English matches `NAV_LANGUAGE`, the fallback
56
+ * every route that is not one document already renders.
57
+ */
58
+ export const WITHHELD_LINK_TEXT = 'a note that is not published';
59
+ /**
60
+ * The characters a note slug is made of: letters, numbers, and combining marks,
61
+ * in any script.
62
+ *
63
+ * One definition, shared with `routes.ts`'s tag route-key vocabulary (which adds
64
+ * `_`) and with `schema.ts`'s `isSlug`, so the producer, the contract, the
65
+ * renderer's href rewrite, the Worker's lookup guard, and the publish-set
66
+ * ledger cannot disagree about what a slug is. Kept in this browser-safe module
67
+ * because `link-preview.ts` parses a slug out of `location.pathname`.
68
+ *
69
+ * Underscore is deliberately **not** a slug character, unlike in a tag key: a
70
+ * slug is derived from a filename by replacing every run of anything else with
71
+ * one hyphen, so `first_note.md` becomes `first-note` and `first_note` is not a
72
+ * slug a producer could emit.
73
+ */
74
+ export const SLUG_CHARACTERS = String.raw `\p{L}\p{N}\p{M}`;
75
+ const SLUG_SHAPE = new RegExp(`^[${SLUG_CHARACTERS}]+(?:-[${SLUG_CHARACTERS}]+)*$`, 'u');
76
+ /** At least one visible letter or digit; a slug of only combining marks is an invisible URL. */
77
+ const SLUG_SUBSTANCE = /[\p{L}\p{N}]/u;
78
+ /**
79
+ * Code points a renderer draws as nothing. U+FE0F is a nonspacing mark, so
80
+ * `\p{M}` alone would admit it; the rule is stated separately for that reason.
81
+ */
82
+ export const INVISIBLE_CODE_POINTS = /\p{Default_Ignorable_Code_Point}/gu;
83
+ const INVISIBLE_CODE_POINT = /\p{Default_Ignorable_Code_Point}/u;
84
+ /**
85
+ * The longest slug, in **UTF-8 bytes**, not UTF-16 units.
86
+ *
87
+ * A slug is a directory name in the output (`notes/<slug>/index.html`), and the
88
+ * filesystems a site is built or served from cap a name at 255 bytes (ext4,
89
+ * APFS) or 255 UTF-16 units (NTFS). Counted in UTF-16 units, a 128-character CJK
90
+ * slug is 384 UTF-8 bytes and fails to write on Linux. 128 bytes is 128 ASCII
91
+ * characters or about 42 CJK characters, and its percent-encoded URL form is at
92
+ * most 384 characters.
93
+ */
94
+ export const SLUG_MAX_BYTES = 128;
95
+ const UTF8 = new TextEncoder();
96
+ /** UTF-8 length, the unit {@link SLUG_MAX_BYTES} is stated in. */
97
+ export function utf8Length(value) {
98
+ return UTF8.encode(value).length;
99
+ }
100
+ /**
101
+ * Whether a string is a canonical note slug.
102
+ *
103
+ * Lowercase (`toLowerCase` is the identity on it), NFC, free of invisible code
104
+ * points, hyphen-separated runs of {@link SLUG_CHARACTERS}, at least one letter
105
+ * or digit, and at most {@link SLUG_MAX_BYTES} bytes. The byte bound is part of
106
+ * the grammar rather than a separate limit so that a browser-side parse and the
107
+ * build agree on the same set.
108
+ */
109
+ export function isNoteSlug(value) {
110
+ return (SLUG_SHAPE.test(value) &&
111
+ SLUG_SUBSTANCE.test(value) &&
112
+ !INVISIBLE_CODE_POINT.test(value) &&
113
+ value === value.normalize('NFC') &&
114
+ value === value.toLowerCase() &&
115
+ utf8Length(value) <= SLUG_MAX_BYTES);
116
+ }
117
+ /**
118
+ * One path segment (a folder or a filename stem) as slug text.
119
+ *
120
+ * NFC, Unicode lowercase, invisible code points removed, and every run of
121
+ * anything that is not a letter, number, or mark replaced with one hyphen,
122
+ * trimmed at both ends. `今天` stays `今天`, `Projects` becomes `projects`,
123
+ * `Three laws` becomes `three-laws`, and `___` or `🌱` becomes `''` — which
124
+ * the producer answers with a hash slug rather than inventing text.
125
+ */
126
+ export function slugSegment(text) {
127
+ return text
128
+ .normalize('NFC')
129
+ .toLowerCase()
130
+ .normalize('NFC')
131
+ .replace(INVISIBLE_CODE_POINTS, '')
132
+ .replace(new RegExp(`[^${SLUG_CHARACTERS}]+`, 'gu'), '-')
133
+ .replace(/^-+|-+$/g, '');
134
+ }
135
+ /**
136
+ * The canonical slug order: Unicode code point order.
137
+ *
138
+ * Not JavaScript's `<`, which compares UTF-16 code units and so sorts an astral
139
+ * character (`𠀀`, U+20000, stored as surrogates D840 DC00) *before* U+FF41.
140
+ * SQLite's `BINARY` collation compares UTF-8 bytes, which is code point order,
141
+ * and every `ORDER BY slug` and `slug > :after` cursor in the snapshot uses it.
142
+ * IDs are assigned in this order so that `ORDER BY id` and `ORDER BY slug`
143
+ * agree; the publish-set ledger and the artifact's edge lists use it too.
144
+ */
145
+ export function compareSlugs(a, b) {
146
+ if (a === b)
147
+ return 0;
148
+ const left = a[Symbol.iterator]();
149
+ const right = b[Symbol.iterator]();
150
+ for (;;) {
151
+ const x = left.next();
152
+ const y = right.next();
153
+ if (x.done === true)
154
+ return y.done === true ? 0 : -1;
155
+ if (y.done === true)
156
+ return 1;
157
+ const difference = x.value.codePointAt(0) - y.value.codePointAt(0);
158
+ if (difference !== 0)
159
+ return difference < 0 ? -1 : 1;
160
+ }
161
+ }
162
+ /**
163
+ * The slug a single URL path segment names, or `undefined`.
164
+ *
165
+ * A browser presents `location.pathname` and `HTMLAnchorElement.pathname`
166
+ * percent-encoded (`/notes/%E4%BB%8A%E5%A4%A9/`), while a build-time href may
167
+ * carry the raw characters. Both are accepted: the segment is decoded (a
168
+ * malformed escape is not a slug), NFC-normalised, and then validated, so an
169
+ * encoded `/` or anything outside the grammar is refused.
170
+ */
171
+ export function slugFromSegment(segment) {
172
+ let decoded;
173
+ try {
174
+ decoded = decodeURIComponent(segment);
175
+ }
176
+ catch {
177
+ return undefined;
178
+ }
179
+ const slug = decoded.normalize('NFC');
180
+ return isNoteSlug(slug) ? slug : undefined;
181
+ }
182
+ /**
183
+ * A path that only looks like a note route is not treated as one. The trailing
184
+ * slash is optional because a browser may present either form before the
185
+ * host's own normalization runs.
186
+ */
187
+ const NOTE_PATH = new RegExp(`^/${NOTES_SEGMENT}/([^/]+)/?$`);
188
+ /** The slug in a `/notes/<slug>/` path, raw or percent-encoded, or `undefined` for any other path. */
189
+ export function noteSlugFromPath(pathname) {
190
+ const segment = NOTE_PATH.exec(pathname)?.[1];
191
+ return segment === undefined ? undefined : slugFromSegment(segment);
192
+ }