@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,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
|
+
* `data-rendered="math"`. 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 `` 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
|
+
}
|