@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,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the public SQLite snapshot from a validated content artifact.
|
|
3
|
+
*
|
|
4
|
+
* This is the producer side of `docs/core-design/sqlite-contract.md`. It writes a
|
|
5
|
+
* fresh file (never copies and deletes from a previous snapshot, which would
|
|
6
|
+
* leave removed text in free pages), finalizes it, runs the integrity and
|
|
7
|
+
* foreign-key checks, validates the exact schema, and returns the binding the
|
|
8
|
+
* rest of the build threads through: the digest-named URL, the full digest, and
|
|
9
|
+
* the format constants.
|
|
10
|
+
*
|
|
11
|
+
* `node:sqlite` is the accepted build-time driver
|
|
12
|
+
* (`docs/core-design/architecture.md`). Determinism is a contract: fixed page
|
|
13
|
+
* size, explicit IDs in canonical slug/key order, primary-key insert order, no
|
|
14
|
+
* timestamps and no counters, so two builds of the same corpus are byte-equal.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
18
|
+
import { dirname } from 'node:path';
|
|
19
|
+
import { DatabaseSync } from "../src/lib/sqlite.js";
|
|
20
|
+
import { tagFacets } from "../src/lib/routes.js";
|
|
21
|
+
import { NAV_LANGUAGE } from "../src/lib/translations.js";
|
|
22
|
+
import { compareSlugs } from "../src/lib/route-path.js";
|
|
23
|
+
import { SNAPSHOT_APPLICATION_ID, SNAPSHOT_PAGE_SIZE, SNAPSHOT_SCHEMA_SQL, SNAPSHOT_USER_VERSION, snapshotRoute, } from "../src/lib/snapshot.js";
|
|
24
|
+
import { assertSnapshotRows } from "../src/lib/snapshot-contract.js";
|
|
25
|
+
/**
|
|
26
|
+
* Compare slugs in the canonical order IDs are assigned in: Unicode code point
|
|
27
|
+
* order, which is what SQLite's BINARY collation gives `ORDER BY slug` and the
|
|
28
|
+
* `slug > ?` cursor. JavaScript `<` (UTF-16 units) disagrees for astral
|
|
29
|
+
* characters, and would make `ORDER BY id` and `ORDER BY slug` diverge.
|
|
30
|
+
*/
|
|
31
|
+
const bySlug = compareSlugs;
|
|
32
|
+
/**
|
|
33
|
+
* Fail unless the file carries exactly the accepted schema.
|
|
34
|
+
*
|
|
35
|
+
* The contract itself lives in `src/lib/snapshot-contract.ts`, so the native and
|
|
36
|
+
* browser drivers validate the same rules; this adapts it to a `node:sqlite`
|
|
37
|
+
* handle. Called after finalization, before the file is hashed or copied.
|
|
38
|
+
*/
|
|
39
|
+
export function assertSnapshotContract(database) {
|
|
40
|
+
assertSnapshotRows((sql) => database.prepare(sql).all());
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Write the snapshot for one artifact and return its binding.
|
|
44
|
+
*
|
|
45
|
+
* @throws if a relation names an unknown or self target, if SQLite rejects a row,
|
|
46
|
+
* or if the finalized file fails integrity, foreign keys, or schema shape.
|
|
47
|
+
*/
|
|
48
|
+
export function writeSnapshot(artifact, destination) {
|
|
49
|
+
const entries = [...artifact.entries].sort((a, b) => bySlug(a.slug, b.slug));
|
|
50
|
+
const idBySlug = new Map(entries.map((entry, index) => [entry.slug, index + 1]));
|
|
51
|
+
// `tagFacets` owns the accepted normalization, collision failure, and
|
|
52
|
+
// representative-label rule; the snapshot must not re-derive it. Facets are
|
|
53
|
+
// already in canonical key order, so their position is the tag id.
|
|
54
|
+
const facets = tagFacets(artifact.entries);
|
|
55
|
+
const tagIdByKey = new Map(facets.map((facet, index) => [facet.key, index + 1]));
|
|
56
|
+
rmSync(destination, { force: true });
|
|
57
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
58
|
+
const database = new DatabaseSync(destination);
|
|
59
|
+
try {
|
|
60
|
+
// Fixed before the first page is allocated, so byte layout is reproducible.
|
|
61
|
+
database.exec(`PRAGMA page_size = ${SNAPSHOT_PAGE_SIZE}`);
|
|
62
|
+
database.exec('PRAGMA journal_mode = DELETE');
|
|
63
|
+
database.exec(SNAPSHOT_SCHEMA_SQL);
|
|
64
|
+
database.exec('PRAGMA foreign_keys = ON');
|
|
65
|
+
const enforced = database.prepare('PRAGMA foreign_keys').get();
|
|
66
|
+
if (enforced?.foreign_keys !== 1)
|
|
67
|
+
throw new Error('snapshot could not enable foreign-key enforcement');
|
|
68
|
+
const insertNode = database.prepare('INSERT INTO nodes (id, slug, title, excerpt, language) VALUES (?, ?, ?, ?, ?)');
|
|
69
|
+
const insertTag = database.prepare('INSERT INTO tags (id, key, label) VALUES (?, ?, ?)');
|
|
70
|
+
const insertAlias = database.prepare('INSERT INTO aliases (node_id, ordinal, alias) VALUES (?, ?, ?)');
|
|
71
|
+
const insertEdge = database.prepare('INSERT INTO edges (source_id, target_id) VALUES (?, ?)');
|
|
72
|
+
const insertMembership = database.prepare('INSERT INTO node_tags (tag_id, node_id) VALUES (?, ?)');
|
|
73
|
+
database.exec('BEGIN IMMEDIATE');
|
|
74
|
+
try {
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const id = idBySlug.get(entry.slug);
|
|
77
|
+
insertNode.run(id, entry.slug, entry.title, entry.excerpt, entry.language ?? NAV_LANGUAGE);
|
|
78
|
+
}
|
|
79
|
+
for (const [index, facet] of facets.entries())
|
|
80
|
+
insertTag.run(index + 1, facet.key, facet.label);
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const id = idBySlug.get(entry.slug);
|
|
83
|
+
for (const [ordinal, alias] of (entry.aliases ?? []).entries())
|
|
84
|
+
insertAlias.run(id, ordinal, alias);
|
|
85
|
+
}
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const sourceId = idBySlug.get(entry.slug);
|
|
88
|
+
for (const target of [...entry.outgoing].sort(bySlug)) {
|
|
89
|
+
const targetId = idBySlug.get(target);
|
|
90
|
+
if (targetId === undefined) {
|
|
91
|
+
throw new Error(`snapshot edge from "${entry.slug}" names unpublished target "${target}"`);
|
|
92
|
+
}
|
|
93
|
+
if (targetId === sourceId) {
|
|
94
|
+
throw new Error(`snapshot edge from "${entry.slug}" targets itself`);
|
|
95
|
+
}
|
|
96
|
+
insertEdge.run(sourceId, targetId);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const facet of facets) {
|
|
100
|
+
const tagId = tagIdByKey.get(facet.key);
|
|
101
|
+
const memberIds = facet.entries
|
|
102
|
+
.map((entry) => idBySlug.get(entry.slug))
|
|
103
|
+
.sort((a, b) => a - b);
|
|
104
|
+
for (const nodeId of memberIds)
|
|
105
|
+
insertMembership.run(tagId, nodeId);
|
|
106
|
+
}
|
|
107
|
+
database.exec('COMMIT');
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
database.exec('ROLLBACK');
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
const integrity = database.prepare('PRAGMA integrity_check').all();
|
|
114
|
+
if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok') {
|
|
115
|
+
throw new Error(`snapshot integrity_check is not ok: ${JSON.stringify(integrity)}`);
|
|
116
|
+
}
|
|
117
|
+
const foreignKeys = database.prepare('PRAGMA foreign_key_check').all();
|
|
118
|
+
if (foreignKeys.length > 0) {
|
|
119
|
+
throw new Error(`snapshot foreign_key_check returned ${foreignKeys.length} row(s)`);
|
|
120
|
+
}
|
|
121
|
+
assertSnapshotContract(database);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
database.close();
|
|
125
|
+
}
|
|
126
|
+
const bytes = readFileSync(destination);
|
|
127
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
128
|
+
return {
|
|
129
|
+
url: snapshotRoute(digest),
|
|
130
|
+
digest,
|
|
131
|
+
applicationId: SNAPSHOT_APPLICATION_ID,
|
|
132
|
+
userVersion: SNAPSHOT_USER_VERSION,
|
|
133
|
+
path: destination,
|
|
134
|
+
size: bytes.length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The collection explorer: the corpus, grouped, as a real `<nav>`.
|
|
4
|
+
*
|
|
5
|
+
* Four properties are load-bearing, and each is a decision the parity plan's L5
|
|
6
|
+
* records against Quartz's explorer:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Complete in the server-rendered HTML.** Quartz rebuilds a trie in the
|
|
9
|
+
* browser from `contentIndex.json` and renders an empty `<ul>` with
|
|
10
|
+
* JavaScript disabled — the whole navigation is gone. Every group and every
|
|
11
|
+
* title here is written by the build.
|
|
12
|
+
* 2. **Collapse costs zero JavaScript.** `<details>`/`<summary>` is the native
|
|
13
|
+
* disclosure: keyboard-operable, announced as one, and remembered by the
|
|
14
|
+
* browser across a back-navigation. No script is loaded for it, and none
|
|
15
|
+
* exists to load.
|
|
16
|
+
* 3. **The open group is the reader's own.** `collectionNavigation` marks the
|
|
17
|
+
* group holding the current page and only that one is `open`, so the rail is
|
|
18
|
+
* contextual without a preference to store or a state to sync. Quartz needs
|
|
19
|
+
* `localStorage` and a `new Function()` comparator to reach the same place.
|
|
20
|
+
* 4. **It is chrome, not content.** `data-pagefind-ignore`: it renders on every
|
|
21
|
+
* page, so without it every note's search record would carry the title of
|
|
22
|
+
* every other note. This is the defect TK-12 fixed for the back link and the
|
|
23
|
+
* heading anchors, and a rail is the largest possible instance of it.
|
|
24
|
+
*
|
|
25
|
+
* **Every group is listed; a large group's notes are windowed.** The rail used
|
|
26
|
+
* to render every published note on every page, which is quadratic in the
|
|
27
|
+
* corpus — `GROUP_WINDOW` in `collection-navigation.ts` carries the measurement
|
|
28
|
+
* and the reason the bound is what it is. What this component owes the bound is
|
|
29
|
+
* two things: the count beside a label is `group.total` rather than the size of
|
|
30
|
+
* the drawn slice, and the collection index link is rendered where a group has
|
|
31
|
+
* one, because on a windowed group that link is the expansion action.
|
|
32
|
+
*
|
|
33
|
+
* The heading is visually hidden rather than absent. A `<nav>` needs an
|
|
34
|
+
* accessible name — two unnamed navigations on a page are indistinguishable in a
|
|
35
|
+
* screen reader's landmark list, and this page has four — but a visible "Browse"
|
|
36
|
+
* heading above a list that already announces itself is chrome restating chrome.
|
|
37
|
+
*/
|
|
38
|
+
import type { ExplorerGroup } from '../lib/collection-navigation.js';
|
|
39
|
+
import { partLanguage, translate } from '../lib/translations.js';
|
|
40
|
+
|
|
41
|
+
interface Props {
|
|
42
|
+
groups: readonly ExplorerGroup[];
|
|
43
|
+
/** The page's own language. The group labels are artifact text and are not. */
|
|
44
|
+
lang: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { groups, lang } = Astro.props;
|
|
48
|
+
const t = translate(lang);
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
{/* Nothing to browse is not an empty rail: a corpus with one note in one group
|
|
52
|
+
would render a disclosure whose only content is the page already open. The
|
|
53
|
+
threshold is two notes, below which the site map in the footer already says
|
|
54
|
+
everything this would.
|
|
55
|
+
|
|
56
|
+
Summed over `total` rather than over `notes.length`, so the decision is about
|
|
57
|
+
how many notes the corpus has and not how many this page happens to draw. */}
|
|
58
|
+
{groups.reduce((count, group) => count + group.total, 0) > 1 && (
|
|
59
|
+
<nav class="explorer" aria-labelledby="explorer-title" data-pagefind-ignore>
|
|
60
|
+
<h2 id="explorer-title" class="sr-only">{t.explorerHeading}</h2>
|
|
61
|
+
<ul class="explorer-groups">
|
|
62
|
+
{groups.map((group) => (
|
|
63
|
+
<li>
|
|
64
|
+
{/* `open` on the reader's own group only. A `<details>` carrying the
|
|
65
|
+
current page must never start closed — that is the one state where
|
|
66
|
+
the reader would have to open a disclosure to find where they
|
|
67
|
+
already are. */}
|
|
68
|
+
<details class="explorer-group" open={group.isCurrent}>
|
|
69
|
+
<summary>
|
|
70
|
+
<span class="explorer-label">{group.label}</span>
|
|
71
|
+
{/* Text, not a badge: nothing here is carried by colour or shape
|
|
72
|
+
alone, and the count tells a reader whether opening the group
|
|
73
|
+
costs them three lines or thirty.
|
|
74
|
+
|
|
75
|
+
`group.total`, never `group.notes.length`: past
|
|
76
|
+
`GROUP_WINDOW` the two differ, and the number a reader needs is
|
|
77
|
+
how large the collection is rather than how much of it this
|
|
78
|
+
page drew. */}
|
|
79
|
+
<span class="explorer-count">{group.total}</span>
|
|
80
|
+
</summary>
|
|
81
|
+
{/* The collection's own index page, where it has one. The
|
|
82
|
+
uncollected group has no route — see `ExplorerGroup.route` — and
|
|
83
|
+
links nothing rather than inventing a page for it.
|
|
84
|
+
|
|
85
|
+
The label is interpolated by the locale rather than concatenated
|
|
86
|
+
here: Chinese puts the modifier before the head noun with `的`,
|
|
87
|
+
so `All X notes` and `X 的全部笔记` cannot share a template. */}
|
|
88
|
+
{group.route !== undefined && (
|
|
89
|
+
<a class="explorer-index" href={group.route}>{t.explorerIndex(group.label)}</a>
|
|
90
|
+
)}
|
|
91
|
+
<ul class="explorer-notes">
|
|
92
|
+
{group.notes.map((note) => (
|
|
93
|
+
<li>
|
|
94
|
+
{/* `aria-current="page"` on the note being read, and the
|
|
95
|
+
stylesheet marks it with a weight and a rule as well as a
|
|
96
|
+
colour. A reader who cannot see the colour still knows
|
|
97
|
+
where they are.
|
|
98
|
+
|
|
99
|
+
The window is what makes that mark *reachable*: past the
|
|
100
|
+
rail's own row budget the marker rendered below the fold of
|
|
101
|
+
the rail's scroll container, so on a 300-note site 40 of 40
|
|
102
|
+
sampled pages showed a wall of titles that never included
|
|
103
|
+
the reader's own. `windowAround` centres the slice on them.
|
|
104
|
+
|
|
105
|
+
`lang` on a title whose language differs from the page's.
|
|
106
|
+
The rail is still the largest concentration of foreign-
|
|
107
|
+
language text on any page on a bilingual site. Every group
|
|
108
|
+
of the 32-note fixture corpus is under the bound, so both
|
|
109
|
+
directions render whole there — 4 Chinese titles on every
|
|
110
|
+
English page and 28 English ones on every Chinese page. */}
|
|
111
|
+
<a
|
|
112
|
+
href={note.route}
|
|
113
|
+
aria-current={note.isCurrent ? 'page' : undefined}
|
|
114
|
+
lang={partLanguage(note.language, lang)}
|
|
115
|
+
>{note.title}</a>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
{/* A windowed group says so, and says how to see the rest.
|
|
120
|
+
Rendered only where the window actually dropped something, so a
|
|
121
|
+
group drawing whole does not claim to be partial — the same rule
|
|
122
|
+
`NoteGraph.astro` applies to its own bound sentence, and the
|
|
123
|
+
same shown-of-total shape.
|
|
124
|
+
|
|
125
|
+
Without this the group is a contradiction a reader has to
|
|
126
|
+
resolve: a count of 376 beside twelve rows, with nothing saying
|
|
127
|
+
the twelve are a window. A screen reader hears "Uncollected,
|
|
128
|
+
376", walks twelve items, and reaches the end of the list.
|
|
129
|
+
|
|
130
|
+
The expansion link matters on the uncollected group, which
|
|
131
|
+
still contains every root-level note because only a first folder
|
|
132
|
+
produces a collection. The home page lists every published note,
|
|
133
|
+
so that is where it goes;
|
|
134
|
+
a group with its own index already links it above and does not
|
|
135
|
+
need a second route to the same idea. */}
|
|
136
|
+
{group.total > group.notes.length && (
|
|
137
|
+
<p class="explorer-bound">
|
|
138
|
+
<span>{t.explorerBounded(group.notes.length, group.total)}</span>
|
|
139
|
+
{group.route === undefined && (
|
|
140
|
+
<a href="/" aria-label={t.explorerExpandLabel}>{t.explorerExpand}</a>
|
|
141
|
+
)}
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
</details>
|
|
145
|
+
</li>
|
|
146
|
+
))}
|
|
147
|
+
</ul>
|
|
148
|
+
</nav>
|
|
149
|
+
)}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A tag or collection index: one link per group, or an honest empty state.
|
|
4
|
+
*
|
|
5
|
+
* The empty state is the normal case today, not an edge case. The artifact
|
|
6
|
+
* carries neither `tags` nor `collection`, and the ticket forbids both a 404
|
|
7
|
+
* and a fabricated grouping — so the page explains that the projection carries
|
|
8
|
+
* no such field yet, and lights up on its own as soon as one appears.
|
|
9
|
+
*/
|
|
10
|
+
import type { Facet } from '../lib/routes.js';
|
|
11
|
+
import { translate } from '../lib/translations.js';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
facets: readonly Facet[];
|
|
15
|
+
/** Builds the route for one facet key. */
|
|
16
|
+
route: (key: string) => string;
|
|
17
|
+
/** Shown in place of the list when the artifact carries no such field. */
|
|
18
|
+
empty: string;
|
|
19
|
+
/** The page's own language, which decides the count's grammar. */
|
|
20
|
+
lang: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { facets, route, empty, lang } = Astro.props;
|
|
24
|
+
const t = translate(lang);
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
{facets.length === 0 ? (
|
|
28
|
+
<p class="empty-state">{empty}</p>
|
|
29
|
+
) : (
|
|
30
|
+
<ul class="facet-list">
|
|
31
|
+
{facets.map((facet) => (
|
|
32
|
+
<li>
|
|
33
|
+
{/* The label is the artifact's own text — a tag reading `security` stays
|
|
34
|
+
`security` on a Chinese page, and `笔记` stays `笔记` on an English
|
|
35
|
+
one. Only the count beside it is chrome. */}
|
|
36
|
+
<a href={route(facet.key)}>{facet.label}</a>
|
|
37
|
+
{/* The count is text, not a colour or an icon: no information here is
|
|
38
|
+
carried by colour alone. Both callers count *notes* — a tag row says
|
|
39
|
+
how many notes carry the tag — so this is `noteCount` rather than a
|
|
40
|
+
per-facet noun. */}
|
|
41
|
+
<span class="facet-count">{t.noteCount(facet.entries.length)}</span>
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
)}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A named list of notes related to this one, or an honest statement that there
|
|
4
|
+
* are none.
|
|
5
|
+
*
|
|
6
|
+
* Three of requirements section 9.2's regions are the same shape — outgoing
|
|
7
|
+
* links (item 8), backlinks (item 9), and the related-note fallback (item 10) —
|
|
8
|
+
* so they are one component rendered three times rather than three components
|
|
9
|
+
* that would drift apart. What differs is the heading, the empty message, and
|
|
10
|
+
* whether the derivation needs stating; all three are props.
|
|
11
|
+
*
|
|
12
|
+
* Two properties are load-bearing:
|
|
13
|
+
*
|
|
14
|
+
* 1. **The empty state is rendered, not omitted.** Ticket scope item 6 requires
|
|
15
|
+
* it, and the reason is that a missing section and a section with nothing in
|
|
16
|
+
* it are indistinguishable to a reader who cannot know the site has such a
|
|
17
|
+
* section at all. An orphan note now says it is an orphan.
|
|
18
|
+
* 2. **No JavaScript, and no fetch.** Requirements section 13.1 states that
|
|
19
|
+
* static backlinks need neither script nor a database round trip, and every
|
|
20
|
+
* list here is resolved from the validated artifact during the build. This is
|
|
21
|
+
* a capability where the site is already at parity with Quartz; moving it
|
|
22
|
+
* into a query would give that away for nothing.
|
|
23
|
+
*
|
|
24
|
+
* The section is located by its `aria-labelledby` id rather than by a per-
|
|
25
|
+
* surface class: the three look identical by design, so a class that existed
|
|
26
|
+
* only to tell them apart would be an unstyled hook.
|
|
27
|
+
*/
|
|
28
|
+
import type { ContentEntry } from '../lib/content.js';
|
|
29
|
+
import { noteRoute } from '../lib/routes.js';
|
|
30
|
+
import { partLanguage, translate } from '../lib/translations.js';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
/** Identifies the region: the heading takes `<region>-title`, the aside points at it. */
|
|
34
|
+
region: string;
|
|
35
|
+
heading: string;
|
|
36
|
+
entries: readonly ContentEntry[];
|
|
37
|
+
/** Rendered in place of the list. Never omitted — see above. */
|
|
38
|
+
empty: string;
|
|
39
|
+
/** How the list was derived, where the heading does not already say. */
|
|
40
|
+
derivation?: string;
|
|
41
|
+
/** The page's own language, which decides the count's grammar. */
|
|
42
|
+
lang: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { region, heading, entries, empty, derivation, lang } = Astro.props;
|
|
46
|
+
const t = translate(lang);
|
|
47
|
+
const titleId = `${region}-title`;
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
<aside class="relations" aria-labelledby={titleId}>
|
|
51
|
+
<div class="relations-heading">
|
|
52
|
+
<h2 id={titleId}>{heading}</h2>
|
|
53
|
+
{/* Text, not a badge: nothing here is carried by colour or shape alone. It
|
|
54
|
+
is also what makes a long list legible before it is read — a reader
|
|
55
|
+
meeting "Linked from" sees whether it is two notes or forty.
|
|
56
|
+
|
|
57
|
+
`noteCount` rather than a count and a noun assembled here: English
|
|
58
|
+
pluralises and Chinese does not, so the whole phrase is the locale's to
|
|
59
|
+
form. A shared `n === 1` branch would put an English rule on a language
|
|
60
|
+
that has none. */}
|
|
61
|
+
{entries.length > 0 && (
|
|
62
|
+
<span class="relations-count">{t.noteCount(entries.length)}</span>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Only alongside a list. The empty message already states the operative
|
|
67
|
+
half of the rule, and printing both makes the empty case read as two
|
|
68
|
+
sentences saying one thing. */}
|
|
69
|
+
{derivation !== undefined && entries.length > 0 && (
|
|
70
|
+
<p class="relations-derivation">{derivation}</p>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{entries.length === 0 ? (
|
|
74
|
+
<p class="empty-state">{empty}</p>
|
|
75
|
+
) : (
|
|
76
|
+
/*
|
|
77
|
+
* A grid, which is the whole of the large-list treatment: one column at
|
|
78
|
+
* 320 px, two or three once there is width, so forty backlinks are a block
|
|
79
|
+
* a reader takes in rather than a screen of scrolling. It stays one `<ul>`,
|
|
80
|
+
* so the count and the reading order are unchanged and a screen reader
|
|
81
|
+
* announces "list, 40 items" either way.
|
|
82
|
+
*/
|
|
83
|
+
<ul class="relations-list">
|
|
84
|
+
{entries.map((entry) => (
|
|
85
|
+
<li>
|
|
86
|
+
{/* The linked note's own language, when it differs from this page's.
|
|
87
|
+
A bilingual corpus links across languages — three fixture notes
|
|
88
|
+
do — and an unmarked foreign title is read in the wrong voice. */}
|
|
89
|
+
<a href={noteRoute(entry.slug)} lang={partLanguage(entry.language, lang)}>{entry.title}</a>
|
|
90
|
+
</li>
|
|
91
|
+
))}
|
|
92
|
+
</ul>
|
|
93
|
+
)}
|
|
94
|
+
</aside>
|