@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,434 @@
1
+ ---
2
+ /**
3
+ * The graph, as real HTML.
4
+ *
5
+ * This is the component the whole ticket is about, and its shape is the
6
+ * argument: **inline SVG containing real `<a>` elements, laid out at build
7
+ * time**. It is crawlable, keyboard-navigable, and complete with scripting
8
+ * disabled — none of which Quartz's graph is, because `Graph.tsx` emits three
9
+ * empty `<div>`s and everything else arrives as roughly 525 KB brotli of d3 and
10
+ * PixiJS from a third-party CDN.
11
+ *
12
+ * Six properties are load-bearing:
13
+ *
14
+ * 1. **No JavaScript, and none to load.** `src/lib/graph.ts` computed every
15
+ * coordinate during the build. TK-21's interactive layer enhances this and
16
+ * must not be stubbed in here — what ships must be what survives when that
17
+ * enhancement fails.
18
+ * 2. **No inline `style`, anywhere.** An SVG is exactly where they creep in, and
19
+ * the CSP is `style-src 'self'`. Every visual property is a class in
20
+ * `src/styles/graph.css` or an SVG presentation attribute; geometry travels
21
+ * in `cx`, `cy`, `x1`, and the rest, which are attributes rather than styles.
22
+ * 3. **Direction is never carried by colour.** Requirements section 17 forbids
23
+ * it and section 13.2 requires the distinction. An outgoing edge is solid, an
24
+ * incoming one dashed, a mutual one solid with an arrowhead at both ends —
25
+ * three different *shapes*, legible in monochrome — and each node's
26
+ * accessible name says the relationship in words.
27
+ * 4. **Every node is a link with a real accessible name.** The circle and the
28
+ * label are `aria-hidden`, because a screen reader reading a truncated label
29
+ * beside a full one is reading the same note twice; the `<a>` carries the
30
+ * untruncated title, the relationship, and the drawn degree through
31
+ * `graphNodeLabel`. Decorative parts hidden, content parts named.
32
+ * 5. **The table is the equivalent representation**, not a fallback. It carries
33
+ * the same notes, the same relationships, and the same drawn degrees, in the
34
+ * same order, and it is present whether or not the SVG rendered.
35
+ * 6. **Chrome is per document.** Every string comes from `translate()`, so a
36
+ * zh-CN note's graph is in Chinese — including the accessible names, which
37
+ * are the strings a reader never sees and a screen reader always does.
38
+ *
39
+ * `prefers-reduced-motion` needs no rule here: a static layout has nothing to
40
+ * animate, which is the requirement satisfied by construction rather than by a
41
+ * media query.
42
+ */
43
+ import {
44
+ NODE_RADIUS,
45
+ SUBJECT_RADIUS,
46
+ boundCounts,
47
+ drawnNeighbours,
48
+ hasDrawableGraph,
49
+ type Graph,
50
+ type GraphNode,
51
+ } from '../lib/graph.js';
52
+ import { relationKey, type RelationKey } from '../lib/graph-layout.js';
53
+ import { noteRoute } from '../lib/routes.js';
54
+ import { partLanguage, translate } from '../lib/translations.js';
55
+
56
+ interface Props {
57
+ graph: Graph;
58
+ /** Identifies the region: the heading takes `<region>-title`. */
59
+ region: string;
60
+ heading: string;
61
+ /** How the layout was derived, since a reader cannot see a layout rule. */
62
+ derivation: string;
63
+ /** Rendered in place of the figure when there is nothing to draw. */
64
+ empty: string;
65
+ /** Where "show me the rest" goes, and what it says. */
66
+ expand: { href: string; text: string; label: string };
67
+ /**
68
+ * Stated when the bound dropped notes. Takes the drawn and total *neighbour*
69
+ * counts, which `boundCounts` computes — the subject is in neither.
70
+ */
71
+ bounded: (shown: number, total: number) => string;
72
+ /** The page's own language, which decides every string's grammar. */
73
+ lang: string;
74
+ /** Heading level, so the region sits correctly in each page's outline. */
75
+ level?: 1 | 2;
76
+ /**
77
+ * When present, a live explorer enhances this figure over the shared snapshot
78
+ * Worker. Absent means the static figure is the whole experience.
79
+ */
80
+ explore?: {
81
+ scope: 'local' | 'global';
82
+ centerSlug: string | null;
83
+ /** An initial tag filter for a global scope, if any. */
84
+ initialTag?: string | null;
85
+ /** Tag choices for a global scope; absent for a local one. */
86
+ tags?: readonly { key: string; label: string }[];
87
+ };
88
+ }
89
+
90
+ const {
91
+ graph,
92
+ region,
93
+ heading,
94
+ derivation,
95
+ empty,
96
+ expand,
97
+ bounded,
98
+ lang,
99
+ level = 2,
100
+ explore,
101
+ } = Astro.props;
102
+ const t = translate(lang);
103
+
104
+ const titleId = `${region}-title`;
105
+ const Heading = `h${level}` as 'h1' | 'h2';
106
+
107
+ /**
108
+ * The relationship word each state carries, in this document's language.
109
+ *
110
+ * Keyed by the shared `relationKey` classifier, so a sixth state or a rename
111
+ * fails this record instead of falling through to a wrong word — and the
112
+ * `data-graph-relation-<key>` attributes the client reads are derived from the
113
+ * same keys below rather than re-spelled.
114
+ */
115
+ const relationLabels: Record<RelationKey, string> = {
116
+ subject: t.graphSubjectRelation,
117
+ outgoing: t.graphOutgoingRelation,
118
+ incoming: t.graphIncomingRelation,
119
+ mutual: t.graphMutualRelation,
120
+ linked: t.graphLinkedRelation,
121
+ };
122
+
123
+ /** The relationship word a node's accessible name and the table both use. */
124
+ function relationOf(node: Graph['nodes'][number]): string {
125
+ return relationLabels[relationKey(node)];
126
+ }
127
+
128
+ /** The class carrying a node's role. Shape and weight, never colour alone. */
129
+ function nodeClass(node: GraphNode): string {
130
+ return node.isSubject ? 'graph-node graph-node-subject' : 'graph-node';
131
+ }
132
+
133
+ const isDrawable = hasDrawableGraph(graph);
134
+ const drawn = graph.nodes.length;
135
+
136
+ /**
137
+ * The two numbers the bound sentence interpolates, from the model.
138
+ *
139
+ * Computed by `boundCounts` rather than here: the sentence counts *neighbouring
140
+ * notes* and a note is not its own neighbour, which two shipped versions got
141
+ * wrong — and a test restating the same arithmetic did not catch the second,
142
+ * because it compared the model against a copy of this expression rather than
143
+ * against this component. Neither corpus reaches the bound, so no built page
144
+ * renders the sentence and no gate over `dist/` can see it either.
145
+ */
146
+ const { shown, total } = boundCounts(graph);
147
+
148
+ /** Which notes each drawn node is joined to, for the table's fourth column. */
149
+ const joined = drawnNeighbours(graph);
150
+
151
+ /**
152
+ * The client's chrome, carried as data attributes so the script bundle holds no
153
+ * locale table. Only the attributes the explorer reads are emitted.
154
+ */
155
+ const exploreData = explore
156
+ ? Object.fromEntries([
157
+ ['data-graph-scope', explore.scope],
158
+ ['data-graph-center', explore.centerSlug ?? ''],
159
+ ['data-graph-initial-tag', explore.initialTag ?? ''],
160
+ ['data-graph-recenter', t.graphExplorerRecenter],
161
+ // The same visible text, but named for the note it re-centers on: a table
162
+ // holds one of these per row, so twelve identical "Re-center" names say
163
+ // nothing about which control does what.
164
+ ['data-graph-recenter-label', t.graphExplorerRecenterLabel],
165
+ ['data-graph-status-local', t.graphExplorerStatusLocal],
166
+ ['data-graph-status-global', t.graphExplorerStatusGlobal],
167
+ ['data-graph-status-filtered', t.graphExplorerStatusFiltered],
168
+ ['data-graph-loading', t.graphExplorerLoading],
169
+ ['data-graph-failed', t.graphExplorerFailed],
170
+ ['data-graph-unknown-center', t.graphExplorerUnknownCenter],
171
+ ['data-graph-empty-filter', t.graphExplorerEmptyFilter],
172
+ ['data-graph-node-label', t.graphExplorerNodeLabel],
173
+ ['data-graph-node-label-one', t.graphExplorerNodeLabelOne],
174
+ // The live figure's accessible name, count-free on purpose: the count
175
+ // sentence is the status line's job.
176
+ ['data-graph-figure-name', t.graphDescription],
177
+ ...Object.entries(relationLabels).map(([key, label]) => [
178
+ `data-graph-relation-${key}`,
179
+ label,
180
+ ]),
181
+ ['data-graph-no-links', t.graphNoLinks],
182
+ ])
183
+ : undefined;
184
+ ---
185
+
186
+ <section class="graph-region" aria-labelledby={titleId} data-graph-region={region}>
187
+ <div class="section-heading">
188
+ <Heading id={titleId}>{heading}</Heading>
189
+ {isDrawable && (
190
+ <span data-graph-figure-label>{t.graphFigureLabel(drawn, graph.edges.length)}</span>
191
+ )}
192
+ </div>
193
+
194
+ {/* Progressive enhancement, offered only where a figure exists to enhance:
195
+ on an edge-less corpus an explorer would draw nothing while its live
196
+ status claimed a graph. Hidden until the client runs; every string is a
197
+ `data-` attribute above, so the script carries no locale table. */}
198
+ {explore && isDrawable && (
199
+ <div class="graph-explorer" hidden data-graph-controls {...exploreData}>
200
+ <button type="button" data-graph-activate>{t.graphExplorerOpen}</button>
201
+ <button type="button" data-graph-reset-control hidden>{t.graphExplorerReset}</button>
202
+ {explore.tags && explore.tags.length > 0 && (
203
+ <label>
204
+ <span>{t.graphExplorerFilterLabel}</span>
205
+ <select data-graph-tag>
206
+ <option value="">{t.graphExplorerFilterAll}</option>
207
+ {explore.tags.map((tag) => (
208
+ <option value={tag.key}>{tag.label}</option>
209
+ ))}
210
+ </select>
211
+ </label>
212
+ )}
213
+ </div>
214
+ )}
215
+ {/* The live status line belongs with the controls and the figure they
216
+ enhance; with nothing drawable the empty state below is the whole
217
+ statement, so there is no second sentence for the client to fill. */}
218
+ {isDrawable && <p class="graph-status" role="status" aria-live="polite" data-graph-status></p>}
219
+
220
+ {!isDrawable ? (
221
+ <p class="empty-state">{empty}</p>
222
+ ) : (
223
+ <>
224
+ {/* The scroll container is a keyboard stop, for the reason a `<pre>` and a
225
+ diagram canvas are: a graph wider than the measure scrolls
226
+ horizontally at 320 px, and a scrollable region must be reachable by
227
+ keyboard (axe `scrollable-region-focusable`, WCAG 2.1.1).
228
+
229
+ The `<figure>` takes no `aria-labelledby`: a figure with a figcaption
230
+ is already named by it, and naming it twice is how an element ends up
231
+ announced twice. */}
232
+ <figure class="graph">
233
+ <div class="graph-canvas" tabindex="0" data-graph-canvas>
234
+ {/*
235
+ `role="group"`, **not** `role="img"`.
236
+
237
+ An `img` is a leaf: the accessibility API presents it as a single
238
+ graphic, so a focusable descendant inside one is a contradiction —
239
+ axe reports it as `nested-interactive` (serious), 56 times across
240
+ the fixture corpus in both themes, which is how this was found. And
241
+ it is not a lint technicality: under `role="img"` the node links are
242
+ in the tab order while being inside a subtree a screen reader
243
+ announces as one image, so a reader can focus links their own screen
244
+ reader has flattened away.
245
+
246
+ `group` is what this actually is — a named set of related elements
247
+ that may contain interactive descendants. The name still states the
248
+ counts, because the figure's size is the first thing a sighted
249
+ reader takes in and the one thing a non-visual reader cannot.
250
+
251
+ `focusable="false"` keeps the `<svg>` element itself out of the tab
252
+ order, so the group is a container rather than one more stop before
253
+ its first node.
254
+
255
+ `width` and `height` are attributes rather than CSS so the figure has
256
+ an intrinsic size and an aspect ratio before the stylesheet arrives —
257
+ no layout shift, and the `viewBox` scales it down to fit.
258
+ */}
259
+ <svg
260
+ class="graph-svg"
261
+ viewBox={graph.viewBox}
262
+ width={graph.width}
263
+ height={graph.height}
264
+ role="group"
265
+ focusable="false"
266
+ aria-label={t.graphFigureLabel(drawn, graph.edges.length)}
267
+ >
268
+ {/* The arrowhead, declared once and referenced by every edge. It is
269
+ pure decoration — direction is stated in words on each node's
270
+ accessible name — so the whole `<defs>` is hidden. */}
271
+ <defs aria-hidden="true">
272
+ <marker
273
+ id={`${region}-arrow`}
274
+ class="graph-arrow"
275
+ viewBox="0 0 10 10"
276
+ refX="9"
277
+ refY="5"
278
+ markerWidth="5"
279
+ markerHeight="5"
280
+ orient="auto-start-reverse"
281
+ >
282
+ <path d="M 0 1 L 9 5 L 0 9 z" />
283
+ </marker>
284
+ </defs>
285
+
286
+ {/* Edges first, so a node's circle paints over the line that reaches
287
+ it. `aria-hidden` on the whole group: an edge is not separately
288
+ reachable, and the relationship it draws is already in the name
289
+ of the node at its end and in the table below. */}
290
+ <g class="graph-edges" aria-hidden="true">
291
+ {graph.edges.map((edge) => (
292
+ <line
293
+ class={[
294
+ 'graph-edge',
295
+ edge.isMutual && 'graph-edge-mutual',
296
+ // An edge the subject *receives* is dashed and one it sends
297
+ // is solid, so the distinction is a shape rather than a
298
+ // colour. `/graph/` has no subject, so its edges carry no
299
+ // direction and are drawn plain — there is no "incoming"
300
+ // without a somewhere to come in to.
301
+ edge.direction === 'incoming' && 'graph-edge-incoming',
302
+ ]
303
+ .filter(Boolean)
304
+ .join(' ')}
305
+ x1={edge.x1}
306
+ y1={edge.y1}
307
+ x2={edge.x2}
308
+ y2={edge.y2}
309
+ marker-end={`url(#${region}-arrow)`}
310
+ marker-start={edge.isMutual ? `url(#${region}-arrow)` : undefined}
311
+ />
312
+ ))}
313
+ </g>
314
+
315
+ <g class="graph-nodes">
316
+ {graph.nodes.map((node) => (
317
+ /*
318
+ * A real `<a href>`, which is the whole point: this is a link in
319
+ * the HTML, so a crawler follows it, the keyboard reaches it, and
320
+ * it works with scripting disabled.
321
+ *
322
+ * `aria-label` on the anchor and `aria-hidden` on everything
323
+ * inside it: the circle is decoration and the visible label is a
324
+ * truncation of the title the anchor already states. Without the
325
+ * hiding, a screen reader announces the shortened title and then
326
+ * the full one.
327
+ */
328
+ <a
329
+ class={nodeClass(node)}
330
+ href={noteRoute(node.entry.slug)}
331
+ aria-label={t.graphNodeLabel(node.entry.title, relationOf(node), node.degree)}
332
+ lang={partLanguage(node.entry.language, lang)}
333
+ >
334
+ <circle
335
+ class="graph-dot"
336
+ cx={node.x}
337
+ cy={node.y}
338
+ r={node.isSubject ? SUBJECT_RADIUS : NODE_RADIUS}
339
+ aria-hidden="true"
340
+ />
341
+ {/* `dy` places the label below the circle by an em rather than
342
+ by a pixel count, so it follows the reader's own text size. */}
343
+ <text class="graph-label" x={node.x} y={node.y} dy="1.9em" aria-hidden="true">{node.label}</text>
344
+ </a>
345
+ ))}
346
+ </g>
347
+ </svg>
348
+ </div>
349
+ <figcaption>{derivation}</figcaption>
350
+ </figure>
351
+
352
+ {/*
353
+ Requirements section 17: "graph data available as an equivalent
354
+ list/table". **Equivalent**, so the fourth column exists: it names the
355
+ notes each row is joined to, which is the edge set itself. A table of
356
+ node, relationship, and a *degree* states how many lines touch a node
357
+ while withholding which notes they run to — and on `/graph/`, where no
358
+ node has a relationship to a subject, that would leave the whole edge
359
+ set unavailable to a non-visual reader. A count is the summary section
360
+ 17 excludes.
361
+
362
+ It is a `<details>` rather than an always-open table: the same data in
363
+ two forms one after the other is a long page, and native disclosure
364
+ costs no script, is keyboard-operable, and is announced as one. The
365
+ summary states what is inside, so nobody has to open it to find out.
366
+ */}
367
+ <details class="graph-table">
368
+ <summary>{t.graphTableCaption}</summary>
369
+ {/* The scroll container the article's own Markdown tables still lack:
370
+ four columns do not fit 320 px, and a wrapper is what lets the table
371
+ scroll instead of widening the page while keeping its semantics —
372
+ `display: block` on the `<table>` itself would strip them. It is a
373
+ keyboard stop for the same reason the figure's canvas is (axe
374
+ `scrollable-region-focusable`, WCAG 2.1.1). */}
375
+ <div class="graph-table-scroll" tabindex="0">
376
+ <table>
377
+ <caption class="sr-only">{t.graphTableCaption}</caption>
378
+ <thead>
379
+ <tr>
380
+ <th scope="col">{t.graphColumnNote}</th>
381
+ <th scope="col">{t.graphColumnRelation}</th>
382
+ <th scope="col">{t.graphColumnLinks}</th>
383
+ <th scope="col">{t.graphColumnLinkedTo}</th>
384
+ </tr>
385
+ </thead>
386
+ <tbody data-graph-body>
387
+ {graph.nodes.map((node) => (
388
+ <tr>
389
+ <th scope="row">
390
+ <a href={noteRoute(node.entry.slug)} lang={partLanguage(node.entry.language, lang)}
391
+ >{node.entry.title}</a>
392
+ </th>
393
+ <td>{relationOf(node)}</td>
394
+ <td>{node.degree}</td>
395
+ <td>
396
+ {/* The notes at the other end of every line this node
397
+ carries, each a link — so the table is a way *through* the
398
+ graph and not only a description of it. A node the figure
399
+ draws with no line to another drawn node says so rather
400
+ than rendering an empty cell, which reads as missing data. */}
401
+ {(joined.get(node.entry.slug) ?? []).length === 0 ? (
402
+ <span class="graph-none">{t.graphNoLinks}</span>
403
+ ) : (
404
+ <ul class="graph-joined">
405
+ {(joined.get(node.entry.slug) ?? []).map((other) => (
406
+ <li>
407
+ <a
408
+ href={noteRoute(other.slug)}
409
+ lang={partLanguage(other.language, lang)}
410
+ >{other.title}</a>
411
+ </li>
412
+ ))}
413
+ </ul>
414
+ )}
415
+ </td>
416
+ </tr>
417
+ ))}
418
+ </tbody>
419
+ </table>
420
+ </div>
421
+ </details>
422
+
423
+ {/* The bound, and the expansion action beside it — requirements section
424
+ 13.2. Rendered only when the bound actually dropped something, so a
425
+ graph drawing everything does not claim to be partial. The link is
426
+ unconditional: "see the whole graph" is useful whether or not this one
427
+ was truncated. */}
428
+ <p class="graph-bound">
429
+ {graph.omitted > 0 && <span data-graph-bound>{bounded(shown, total)} </span>}
430
+ <a href={expand.href} aria-label={expand.label}>{expand.text}</a>
431
+ </p>
432
+ </>
433
+ )}
434
+ </section>
@@ -0,0 +1,54 @@
1
+ ---
2
+ /**
3
+ * A list of published notes, used by every route that shows more than one.
4
+ *
5
+ * The heading level is a prop because these lists appear at two depths: under a
6
+ * section heading on the home page, and directly under the page `h1` on a
7
+ * facet or recent page. Hardcoding one level would make the other route either
8
+ * skip a heading level or carry a heading that restates its own title.
9
+ */
10
+ import type { ContentEntry } from '../lib/content.js';
11
+ import { noteRoute } from '../lib/routes.js';
12
+ import { partLanguage, translate } from '../lib/translations.js';
13
+
14
+ interface Props {
15
+ entries: readonly ContentEntry[];
16
+ /** Heading level for each card title, chosen so the page never skips a level. */
17
+ level?: 2 | 3;
18
+ /** The page's own language, which decides the chrome around these cards. */
19
+ lang: string;
20
+ }
21
+
22
+ const { entries, level = 3, lang } = Astro.props;
23
+ const t = translate(lang);
24
+ const Heading = `h${level}` as 'h2' | 'h3';
25
+ ---
26
+
27
+ {/* Only the home page and `/recent/` can reach the empty state: a facet page
28
+ exists because an entry produced it, so its list is never empty. The message
29
+ is the locale's rather than a caller's prop — every caller passed the same
30
+ string, and a per-caller override nobody used is a string nobody maintains. */}
31
+ {entries.length === 0 ? (
32
+ <p class="empty-state">{t.noNotesYet}</p>
33
+ ) : (
34
+ <div class="note-grid">
35
+ {entries.map((entry) => (
36
+ <article class="note-card">
37
+ {/*
38
+ The title and the excerpt are the author's words, so they are rendered
39
+ verbatim — and marked with their own `lang` when it differs from the
40
+ page's. Every route lists the whole corpus, so on a bilingual site most
41
+ of these grids are mixed; without the attribute a screen reader reads a
42
+ Chinese title in an English voice, which is WCAG 2.2 AA 3.1.2. The
43
+ attribute is emitted only on the cards that need it, so a single-
44
+ language corpus carries none.
45
+ */}
46
+ <Heading lang={partLanguage(entry.language, lang)}>
47
+ <a href={noteRoute(entry.slug)}>{entry.title}</a>
48
+ </Heading>
49
+ <p lang={partLanguage(entry.language, lang)}>{entry.excerpt}</p>
50
+ <a class="read-more" href={noteRoute(entry.slug)}>{t.readNote}</a>
51
+ </article>
52
+ ))}
53
+ </div>
54
+ )}
@@ -0,0 +1,143 @@
1
+ ---
2
+ /**
3
+ * Everything a route says about itself to something that is not a browser: the
4
+ * canonical URL, the Open Graph and Twitter card metadata, and the feed
5
+ * discovery link.
6
+ *
7
+ * It renders into `Layout.astro`'s `<slot name="head" />`, which TK-05a left for
8
+ * exactly this, and the layout renders it once — so every route carries this
9
+ * metadata by construction rather than because eight page files each remembered
10
+ * to ask for it.
11
+ *
12
+ * **The article facts are looked up from the route, not passed down.** A note
13
+ * page would otherwise have to thread `type`, `published`, and `updated` through
14
+ * `Layout`'s props, widening the interface TK-05a's slot exists to keep narrow,
15
+ * and every future page would have to remember to pass them. Instead this reads
16
+ * the same `Astro.url.pathname` the canonical URL is already derived from: if
17
+ * that path is a note route, the artifact has the note's dates, and if it is
18
+ * not, there are no article facts to state. The two answers cannot disagree
19
+ * because they come from one source.
20
+ *
21
+ * Every value is derived from `Astro.site`, `Astro.url`, or an artifact field.
22
+ * Nothing here reads a clock and nothing invents a default, so two builds of one
23
+ * artifact emit identical bytes.
24
+ */
25
+ import { getEntry } from '../lib/content.js';
26
+ import { noteSlugFromPath } from '../lib/route-path.js';
27
+ import {
28
+ FEED_PATH,
29
+ SITE_NAME,
30
+ SOCIAL_CARD_PATH,
31
+ canonicalUrl,
32
+ isIndexable,
33
+ openGraphLocale,
34
+ rfc3339,
35
+ socialTitle,
36
+ } from '../lib/site.js';
37
+ import { translate } from '../lib/translations.js';
38
+
39
+ interface Props {
40
+ title: string;
41
+ description: string;
42
+ /** The document's own BCP 47 tag, as `<html lang>` declares it. */
43
+ lang: string;
44
+ }
45
+
46
+ const { title, description, lang } = Astro.props;
47
+
48
+ /**
49
+ * This page's canonical URL.
50
+ *
51
+ * `Astro.url.pathname` rather than a route the caller passes: the canonical URL
52
+ * of a page is the address it is served at, and deriving it from anything else
53
+ * is how a page comes to declare a canonical URL that is not its own. With
54
+ * `trailingSlash: 'always'` this is already the normalized form.
55
+ */
56
+ const canonical = canonicalUrl(Astro.site, Astro.url.pathname);
57
+ /**
58
+ * The absolute URL of the social card, or `undefined` when there is none.
59
+ *
60
+ * No card is the shipped state — `src/lib/site.ts` records why — so this is
61
+ * normally `undefined` and the three card tags below are simply absent.
62
+ */
63
+ const card = SOCIAL_CARD_PATH === undefined ? undefined : canonicalUrl(Astro.site, SOCIAL_CARD_PATH);
64
+
65
+ const slug = noteSlugFromPath(Astro.url.pathname);
66
+ const note = slug === undefined ? undefined : getEntry(slug);
67
+ const locale = openGraphLocale(lang);
68
+ // This component renders into the head of whichever document the layout is
69
+ // building, so its one reader-facing string follows that document like every
70
+ // other chrome string. A feed client shows the title when subscribing.
71
+ const t = translate(lang);
72
+ ---
73
+
74
+ {/* The 404 document has no address of its own — the host serves its body for
75
+ every unmatched request — so it claims no canonical URL. Declaring one would
76
+ tell a crawler that every mistyped address on this site is canonically
77
+ `/404/`, which is an instruction to index the error page under arbitrary
78
+ names. `noindex, follow` says the true thing instead, and `follow` keeps the
79
+ site map in its body useful. */}
80
+ {isIndexable(Astro.url.pathname) ? (
81
+ <link rel="canonical" href={canonical} />
82
+ ) : (
83
+ <meta name="robots" content="noindex, follow" />
84
+ )}
85
+
86
+ {/* Open Graph. `og:type` is `article` only on a note page — the index, facet,
87
+ and static pages are not articles, and typing them as one would ask a
88
+ consumer to look for an author and a publication date that do not exist. */}
89
+ <meta property="og:type" content={note === undefined ? 'website' : 'article'} />
90
+ <meta property="og:site_name" content={SITE_NAME} />
91
+ {/* Without the ` · anc` suffix the `<title>` carries: a card renders
92
+ this line directly above `og:site_name`, so keeping it prints the site's
93
+ name twice on every card. */}
94
+ <meta property="og:title" content={socialTitle(title)} />
95
+ <meta property="og:description" content={description} />
96
+ <meta property="og:url" content={canonical} />
97
+ {/* Only when the document's language names a territory: Open Graph's value is
98
+ `language_TERRITORY`, and a bare `en` is not a valid one. `<html lang>`
99
+ carries the exact BCP 47 tag on every page regardless. */}
100
+ {locale !== undefined && <meta property="og:locale" content={locale} />}
101
+ {/* The card, and all three tags stand or fall together.
102
+
103
+ A site with no configured card emits none of them. `og:image` pointing at a
104
+ file that does not exist is worse than silence — a consumer renders a broken
105
+ image rather than falling back to the title and description — and
106
+ `og:image:alt` describing an absent image is a label for nothing.
107
+
108
+ `twitter:card` goes with them, which is the one that looks separable and is
109
+ not. `summary_large_image` is a promise *about an image*: it tells a client
110
+ to reserve a large image region, so with no `og:image` to fill it the card
111
+ renders as an empty box above the text. The honest answer with no image is to
112
+ state no card type and let the consumer choose its own text treatment.
113
+
114
+ No `twitter:site` or `twitter:creator` either: this tool names no account,
115
+ and a metadata tag is not the place to invent one. */}
116
+ {card !== undefined && (
117
+ <>
118
+ <meta property="og:image" content={card} />
119
+ {/* The card is a rendering of the site's own mark, identical on every route,
120
+ so the alternative text says what it is rather than describing the page. */}
121
+ <meta property="og:image:alt" content={t.socialCardAlt(SITE_NAME)} />
122
+ <meta name="twitter:card" content="summary_large_image" />
123
+ </>
124
+ )}
125
+
126
+ {/* Widened to the full RFC 3339 form Open Graph's DateTime type requires; the
127
+ artifact may carry a bare calendar date. The `<time datetime>` on the page
128
+ itself keeps the artifact's own precision. Each is emitted only when the
129
+ artifact carries it — this projection states no date it was not given. */}
130
+ {note?.created !== undefined && (
131
+ <meta property="article:published_time" content={rfc3339(note.created)} />
132
+ )}
133
+ {note?.updated !== undefined && (
134
+ <meta property="article:modified_time" content={rfc3339(note.updated)} />
135
+ )}
136
+
137
+ {/* Feed discovery. Absolute, so it is unambiguous wherever the markup is read. */}
138
+ <link
139
+ rel="alternate"
140
+ type="application/atom+xml"
141
+ title={t.feedTitle(SITE_NAME)}
142
+ href={canonicalUrl(Astro.site, FEED_PATH)}
143
+ />