@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,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
|
+
/>
|