@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
package/src/lib/site.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's absolute public identity, and the two XML artifacts derived from it.
|
|
3
|
+
*
|
|
4
|
+
* Everything above this module speaks in routes — `/notes/<slug>/`. A canonical
|
|
5
|
+
* link, a feed entry, and a sitemap entry all need the same thing instead: the
|
|
6
|
+
* one absolute URL that names a page for the rest of the internet. That
|
|
7
|
+
* conversion, and the escaping every consumer of it needs, live here once.
|
|
8
|
+
*
|
|
9
|
+
* Three properties are load-bearing:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Neither the origin nor the site name is written here.** The origin is
|
|
12
|
+
* `site:` in `astro.config.mjs`, and it arrives as the `URL` Astro derives
|
|
13
|
+
* from it; the name arrives through {@link SITE_TITLE_VARIABLE}. Both are the
|
|
14
|
+
* *user's*, not this project's — a stranger builds their own notes with this
|
|
15
|
+
* tool, and a module that hardcoded either would put one owner's identity on
|
|
16
|
+
* every site built with it. That is plan decision D2, and it is why
|
|
17
|
+
* {@link SITE_NAME} is a lookup rather than a literal.
|
|
18
|
+
* 2. **Output is deterministic.** Nothing here reads a clock. Every timestamp
|
|
19
|
+
* comes from an artifact field, and the one case the artifact cannot supply
|
|
20
|
+
* is a named sentinel rather than "now" — see {@link UNDATED}.
|
|
21
|
+
* 3. **Escaping is total.** {@link escapeXml} is the only path text takes into
|
|
22
|
+
* an XML document, and it handles the characters XML cannot represent at all
|
|
23
|
+
* as well as the five it represents with entities.
|
|
24
|
+
*/
|
|
25
|
+
import { WITHHELD_ROUTE, compareSlugs, noteRoute } from "./route-path.js";
|
|
26
|
+
import { NAV_LANGUAGE, translate } from "./translations.js";
|
|
27
|
+
import { FIXED_ROUTES, collectionFacets, collectionRoute, recentFirst, tagFacets, tagRoute, } from "./routes.js";
|
|
28
|
+
/**
|
|
29
|
+
* The environment variable carrying the configured site title into the build.
|
|
30
|
+
*
|
|
31
|
+
* **A seam rather than an import, and the import was tried and measured to be
|
|
32
|
+
* impossible.** The obvious shape is
|
|
33
|
+
* `export const SITE_NAME = configForBuild().title`, reading
|
|
34
|
+
* `scripts/load-config.ts` directly. It fails the build, and not on style:
|
|
35
|
+
* Astro bundles this module into `dist/.prerender/`, so every module it pulls
|
|
36
|
+
* in is evaluated from *there*. `load-config.ts` imports `write-report.ts`,
|
|
37
|
+
* which reads `../package.json` at module scope through `import.meta.url` —
|
|
38
|
+
* and from inside the prerender directory that resolves to
|
|
39
|
+
* `dist/.prerender/package.json`, which does not exist. Measured: the build
|
|
40
|
+
* reaches "generating static routes" and dies with
|
|
41
|
+
* `ENOENT: … dist\.prerender\package.json`.
|
|
42
|
+
*
|
|
43
|
+
* That is the same hazard `src/lib/artifact-source.ts` documents for the
|
|
44
|
+
* artifact path and answers the same way, which is why this is the existing
|
|
45
|
+
* pattern rather than a new one: `CONTENT_ARTIFACT` crosses this boundary
|
|
46
|
+
* already, is read at module scope exactly like this, and is set by whoever
|
|
47
|
+
* starts the build. `astro.config.mjs` is where that happens, because it is the
|
|
48
|
+
* one place that has both the loaded configuration and a guarantee of running
|
|
49
|
+
* before any page module is evaluated.
|
|
50
|
+
*/
|
|
51
|
+
export const SITE_TITLE_VARIABLE = 'PUBLISH_SITE_TITLE';
|
|
52
|
+
/**
|
|
53
|
+
* The site name a build gets when nothing has configured one.
|
|
54
|
+
*
|
|
55
|
+
* **This is a second copy of `scripts/load-config.ts`'s `DEFAULT_TITLE`, and
|
|
56
|
+
* the duplication is deliberate, measured, and gated.** That module owns the
|
|
57
|
+
* default for a *configured* build and cannot be imported here for the reason
|
|
58
|
+
* {@link SITE_TITLE_VARIABLE} records. The repository's own answer to that
|
|
59
|
+
* situation is already written down one directory away: `theme-init.js` and
|
|
60
|
+
* `preferences.ts` duplicate their storage keys because one of them cannot
|
|
61
|
+
* import, and `tests/design-tokens.test.ts` asserts the two agree. This follows
|
|
62
|
+
* it — `tests/config.test.ts` reads this constant out of this file and fails if
|
|
63
|
+
* it ever differs from the loader's, so a rename cannot be half-applied.
|
|
64
|
+
*
|
|
65
|
+
* **A generic noun, and it belongs to nobody.** It is not this project's name,
|
|
66
|
+
* which is the whole of plan decision D2: a stranger who has written notes and
|
|
67
|
+
* configured nothing gets a site that is honest about being unnamed rather than
|
|
68
|
+
* one that is confidently wrong about whose it is. `Notes` is legible, is
|
|
69
|
+
* obviously a placeholder to anyone who sees it in a browser tab, and names the
|
|
70
|
+
* thing on the page.
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_SITE_TITLE = 'Notes';
|
|
73
|
+
/**
|
|
74
|
+
* The site's own name: the user's configured `title`, or the neutral default.
|
|
75
|
+
*
|
|
76
|
+
* A proper noun of the *user's* site rather than of this tool, so it is not
|
|
77
|
+
* chrome TK-16 translates — a site called `Notes` is called that on its Chinese
|
|
78
|
+
* pages too, exactly as a site called `Foundry` would be.
|
|
79
|
+
*
|
|
80
|
+
* `||` rather than `??`, and `src/lib/artifact-source.ts` records the same
|
|
81
|
+
* choice for the same measured reason: an *empty* environment variable is how a
|
|
82
|
+
* shell unsets one for a single command, and `??` would accept `''` as a title —
|
|
83
|
+
* producing `<title> · </title>` and an Atom feed whose required `atom:title`
|
|
84
|
+
* is blank. The loader has already refused an empty configured title; this
|
|
85
|
+
* refuses an empty *seam*, which is a different failure with the same output.
|
|
86
|
+
*/
|
|
87
|
+
export const SITE_NAME = process.env[SITE_TITLE_VARIABLE] || DEFAULT_SITE_TITLE;
|
|
88
|
+
/**
|
|
89
|
+
* A page's description, or the fallback its own language gives it.
|
|
90
|
+
*
|
|
91
|
+
* The fallback is passed in rather than read from a module constant because it
|
|
92
|
+
* is chrome, and chrome is per document since TK-16: a Chinese note with an
|
|
93
|
+
* empty excerpt must describe itself in Chinese. `Layout.astro` passes
|
|
94
|
+
* `t.siteDescription`, resolved from the document's own `language`.
|
|
95
|
+
*
|
|
96
|
+
* Reachable, not defensive. `excerpt` is the one required string the content
|
|
97
|
+
* contract admits empty (`src/lib/schema.ts` exempts it from the non-empty
|
|
98
|
+
* check), the note page passes it straight through as the page description, and
|
|
99
|
+
* a default parameter only fires on `undefined` — so a note with an empty
|
|
100
|
+
* excerpt shipped `<meta name="description" content="">` and would have shipped
|
|
101
|
+
* an empty `og:description` beside it. The fixture corpus has exactly one such
|
|
102
|
+
* note, `minimal-note`, and no gate had ever looked.
|
|
103
|
+
*
|
|
104
|
+
* An empty description is worse than a generic one: a search engine and a social
|
|
105
|
+
* card both fall back to scraping the page when the tag is absent, and both
|
|
106
|
+
* render nothing when it is present and empty.
|
|
107
|
+
*/
|
|
108
|
+
export function describe(description, fallback) {
|
|
109
|
+
return description === undefined || description.trim() === '' ? fallback : description;
|
|
110
|
+
}
|
|
111
|
+
/** Where the Atom feed is served. `rss` is a reserved route segment (TK-01). */
|
|
112
|
+
export const FEED_PATH = '/rss.xml';
|
|
113
|
+
/** Where the sitemap is served. `sitemap` is a reserved route segment (TK-01). */
|
|
114
|
+
export const SITEMAP_PATH = '/sitemap.xml';
|
|
115
|
+
/**
|
|
116
|
+
* The social card a site serves, when it has one — and no site has one yet.
|
|
117
|
+
*
|
|
118
|
+
* **`undefined` is the shipped state, and it is a decision rather than a gap.**
|
|
119
|
+
* A card lived at `/og-card.png` and shipped inside the package, so every site
|
|
120
|
+
* built with this tool served *one owner's* wordmark as its `og:image`. TK-24
|
|
121
|
+
* measured that and kept it, reasoning that excluding the file would make every
|
|
122
|
+
* build 404 its own card and that a broken card is worse than a borrowed one.
|
|
123
|
+
* The first half of that is right and the second does not follow: the choice is
|
|
124
|
+
* not between a broken card and a bland one, it is between a broken card and
|
|
125
|
+
* **no card**, and an absent `og:image` is a well-defined thing every consumer
|
|
126
|
+
* already handles by falling back to the page's title and description — both of
|
|
127
|
+
* which now carry the user's own configured name.
|
|
128
|
+
*
|
|
129
|
+
* So the tags are emitted only when this is a path, and a build with no card
|
|
130
|
+
* emits no `og:image`, no `og:image:alt`, and no `twitter:card`. See
|
|
131
|
+
* `src/components/SiteMetadata.astro` for why the third goes with the other two.
|
|
132
|
+
*
|
|
133
|
+
* The optional type keeps the metadata branch honest and gated, but version 1
|
|
134
|
+
* intentionally provides no input for it. Accepting a user-owned image would
|
|
135
|
+
* widen the product from Markdown into an asset publication pipeline, including
|
|
136
|
+
* path containment, media validation, output inventory, and privacy review. That
|
|
137
|
+
* is a future versioned design, not unfinished configuration in this release.
|
|
138
|
+
*/
|
|
139
|
+
export const SOCIAL_CARD_PATH = undefined;
|
|
140
|
+
/**
|
|
141
|
+
* Said whenever the origin is needed and `site:` is not configured.
|
|
142
|
+
*
|
|
143
|
+
* A missing origin is not a degradation to route-relative output: a canonical
|
|
144
|
+
* link, a feed id, and a `<loc>` are absolute by definition, and emitting a
|
|
145
|
+
* relative one produces a document that is valid and wrong. Failing the build
|
|
146
|
+
* is the only honest answer, and it names the file to edit.
|
|
147
|
+
*/
|
|
148
|
+
const NO_SITE = 'astro.config.mjs declares no `site:`, so no canonical URL can be formed. ' +
|
|
149
|
+
'Set it to the origin this projection is served from.';
|
|
150
|
+
/**
|
|
151
|
+
* The absolute URL of a site-relative route.
|
|
152
|
+
*
|
|
153
|
+
* `new URL` rather than string concatenation, and that is not a style
|
|
154
|
+
* preference: a route key may be any script the artifact carries, so
|
|
155
|
+
* `/tags/笔记/` is an ordinary public route here. `URL` percent-encodes the path
|
|
156
|
+
* to RFC 3986, which is what a `<loc>`, an `<id>`, and a canonical `<link>` all
|
|
157
|
+
* require, and it resolves the origin's own trailing slash correctly whether
|
|
158
|
+
* `site:` was written with one or without.
|
|
159
|
+
*/
|
|
160
|
+
export function canonicalUrl(site, path) {
|
|
161
|
+
if (site === undefined)
|
|
162
|
+
throw new Error(NO_SITE);
|
|
163
|
+
return new URL(path, site).href;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Characters XML 1.0 cannot carry at all, under any escaping.
|
|
167
|
+
*
|
|
168
|
+
* The `Char` production admits `#x9 | #xA | #xD | [#x20-#xD7FF] |
|
|
169
|
+
* [#xE000-#xFFFD] | [#x10000-#x10FFFF]`. Everything outside it — the C0
|
|
170
|
+
* controls other than tab, newline, and carriage return, the two BMP
|
|
171
|
+
* noncharacters, and any unpaired surrogate — makes a document ill-formed. There
|
|
172
|
+
* is no entity for them: `` is itself illegal in XML 1.0. They can only be
|
|
173
|
+
* removed.
|
|
174
|
+
*
|
|
175
|
+
* This is reachable rather than theoretical. `src/lib/schema.ts` rejects control
|
|
176
|
+
* characters in `tags` and `aliases` only; `title`, `excerpt`, and `description`
|
|
177
|
+
* are checked for emptiness and for privacy markers, so a title carrying U+0001
|
|
178
|
+
* validates cleanly and would then produce a feed no reader can parse.
|
|
179
|
+
*
|
|
180
|
+
* Written with `\u` escapes rather than the literal characters, deliberately: a
|
|
181
|
+
* source file containing a literal NUL is *binary* to `grep`, to `git diff`, and
|
|
182
|
+
* to a reviewer's editor — so the one file that most needs reading during a
|
|
183
|
+
* security review would be the one nobody can read. This was not hypothetical;
|
|
184
|
+
* the first draft of this file had exactly that defect.
|
|
185
|
+
*
|
|
186
|
+
* The surrogate clauses match a high surrogate not followed by a low one, and a
|
|
187
|
+
* low surrogate not preceded by a high one — a well-formed pair encodes a
|
|
188
|
+
* character in `[#x10000-#x10FFFF]`, which is admitted, and must survive.
|
|
189
|
+
*/
|
|
190
|
+
// oxlint-disable-next-line no-control-regex -- removing them is the entire point
|
|
191
|
+
const XML_FORBIDDEN = new RegExp([
|
|
192
|
+
// C0 controls, minus the tab, line feed, and carriage return XML admits.
|
|
193
|
+
'[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]',
|
|
194
|
+
// The two BMP noncharacters the `Char` production excludes.
|
|
195
|
+
'[\\uFFFE\\uFFFF]',
|
|
196
|
+
// A high surrogate with no low surrogate following it.
|
|
197
|
+
'[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])',
|
|
198
|
+
// A low surrogate with no high surrogate preceding it.
|
|
199
|
+
'(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]',
|
|
200
|
+
].join('|'), 'g');
|
|
201
|
+
/**
|
|
202
|
+
* Text, as an XML document may carry it.
|
|
203
|
+
*
|
|
204
|
+
* One function for element content and for attribute values rather than two.
|
|
205
|
+
* The split is a real distinction in the specification — `"` only has to be
|
|
206
|
+
* escaped inside an attribute — but a codebase with two escapers has a place to
|
|
207
|
+
* call the wrong one, and the extra four characters cost nothing.
|
|
208
|
+
*
|
|
209
|
+
* `&` is replaced first. Replacing it after `<` would rewrite the `&` of the
|
|
210
|
+
* `<` just produced into `&lt;`, which renders the literal text `<`.
|
|
211
|
+
* The removal pass runs before all of them, so a forbidden character cannot
|
|
212
|
+
* survive inside an entity this function wrote.
|
|
213
|
+
*
|
|
214
|
+
* `]]>` needs no special case: `>` is escaped unconditionally, so the sequence
|
|
215
|
+
* cannot appear in output and no CDATA section can be closed early. Nothing here
|
|
216
|
+
* emits CDATA either.
|
|
217
|
+
*
|
|
218
|
+
* The carriage return is escaped rather than passed through, and it is the one
|
|
219
|
+
* character here that is not about markup injection. XML 1.0 §2.11 requires a
|
|
220
|
+
* parser to normalize line endings *before* the application sees them: a literal
|
|
221
|
+
* `\r\n` and a literal `\r` both arrive as `\n`. So a title containing a
|
|
222
|
+
* carriage return would produce a perfectly well-formed document that parses
|
|
223
|
+
* back to **different text than the artifact holds** — silent corruption rather
|
|
224
|
+
* than a loud failure. ` ` survives normalization, because a character
|
|
225
|
+
* reference is resolved after it. `\n` and `\t` need no such treatment in
|
|
226
|
+
* element content, which is where they are already the thing they mean.
|
|
227
|
+
*/
|
|
228
|
+
export function escapeXml(value) {
|
|
229
|
+
return value
|
|
230
|
+
.replace(XML_FORBIDDEN, '')
|
|
231
|
+
.replaceAll('&', '&')
|
|
232
|
+
.replaceAll('<', '<')
|
|
233
|
+
.replaceAll('>', '>')
|
|
234
|
+
.replaceAll('"', '"')
|
|
235
|
+
.replaceAll("'", ''')
|
|
236
|
+
.replaceAll('\r', ' ');
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The timestamp for a note the artifact carries no date for.
|
|
240
|
+
*
|
|
241
|
+
* Atom requires `atom:updated` on every entry and on the feed, so there is no
|
|
242
|
+
* option to omit it. The build clock would destroy determinism, while dropping
|
|
243
|
+
* undated notes would make previews from non-git directories and untracked
|
|
244
|
+
* notes disappear from an otherwise complete feed.
|
|
245
|
+
*
|
|
246
|
+
* The sentinel is both deterministic and complete. The epoch is chosen precisely
|
|
247
|
+
* because no reader will mistake it for a real publication date. Tracked notes
|
|
248
|
+
* carry git dates; the honest undated fallback still sorts last in
|
|
249
|
+
* {@link recentFirst}, so it appears at the end of the feed as well.
|
|
250
|
+
*/
|
|
251
|
+
export const UNDATED = '1970-01-01T00:00:00Z';
|
|
252
|
+
/**
|
|
253
|
+
* An artifact date as the RFC 3339 timestamp Atom requires.
|
|
254
|
+
*
|
|
255
|
+
* A value carrying a time is already RFC 3339 — `src/lib/schema.ts` validates
|
|
256
|
+
* exactly that shape — and is passed through byte for byte. A date-only value is
|
|
257
|
+
* widened to midnight UTC.
|
|
258
|
+
*
|
|
259
|
+
* Deliberately not parsed and reformatted. Round-tripping through `Date` renders
|
|
260
|
+
* `2026-05-18T22:40:00+08:00` in the build machine's zone, so the same artifact
|
|
261
|
+
* would produce different bytes on two machines; and a date-only value parsed as
|
|
262
|
+
* UTC midnight and formatted locally shifts to the previous day west of
|
|
263
|
+
* Greenwich. Taking the artifact's own bytes is both deterministic and correct.
|
|
264
|
+
*/
|
|
265
|
+
export function rfc3339(value) {
|
|
266
|
+
return value.length === 10 ? `${value}T00:00:00Z` : value;
|
|
267
|
+
}
|
|
268
|
+
/** The instant a feed entry is stamped with: `updated`, else `created`, else the sentinel. */
|
|
269
|
+
function entryTimestamp(entry) {
|
|
270
|
+
const value = entry.updated ?? entry.created;
|
|
271
|
+
return value === undefined ? UNDATED : rfc3339(value);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* One XML element with text content.
|
|
275
|
+
*
|
|
276
|
+
* `required` distinguishes the two cases Atom actually has, and getting it wrong
|
|
277
|
+
* is how a hand-written feed becomes non-conformant. `atom:title` has cardinality
|
|
278
|
+
* exactly 1 on both the feed and every entry, so omitting it on empty text would
|
|
279
|
+
* emit a document no conforming reader accepts; `atom:summary` is optional, so an
|
|
280
|
+
* entry with an empty excerpt is better off with no element than with an empty
|
|
281
|
+
* one. The default is `false` because omission is the safe direction for
|
|
282
|
+
* everything optional.
|
|
283
|
+
*
|
|
284
|
+
* The empty-text case for a required element falls back to a single space rather
|
|
285
|
+
* than to an empty element: `<title></title>` is legal XML but an empty Atom
|
|
286
|
+
* title, which readers render as a blank row. This is unreachable through the
|
|
287
|
+
* real loader — `src/lib/schema.ts` rejects an empty `title` — and it is handled
|
|
288
|
+
* anyway because this module is pure and fixture-driven, and `src/lib/routes.ts`
|
|
289
|
+
* states the governing principle: it must not depend on a caller having
|
|
290
|
+
* validated first.
|
|
291
|
+
*/
|
|
292
|
+
function element(name, text, attributes = '', required = false) {
|
|
293
|
+
if (text === '')
|
|
294
|
+
return required ? [`<${name}${attributes}> </${name}>`] : [];
|
|
295
|
+
return [`<${name}${attributes}>${escapeXml(text)}</${name}>`];
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* A timestamp, escaped like every other value this module writes.
|
|
299
|
+
*
|
|
300
|
+
* The module's own contract is that {@link escapeXml} is the *only* path text
|
|
301
|
+
* takes into an XML document, and a date is text: `src/lib/schema.ts` validates
|
|
302
|
+
* the shape, but this module is pure and fixture-driven and must not depend on a
|
|
303
|
+
* caller having validated first. `renderSitemap` already escapes the same
|
|
304
|
+
* primitive, and one artifact date receiving two treatments in one file is
|
|
305
|
+
* exactly the inconsistency that outlives the reasoning behind it.
|
|
306
|
+
*/
|
|
307
|
+
function timestamp(value) {
|
|
308
|
+
return escapeXml(value);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Whether an entry is published, as opposed to withdrawn.
|
|
312
|
+
*
|
|
313
|
+
* `status` is optional in the contract and absent from the artifact the producer
|
|
314
|
+
* emits today, so "no status" means published — which is what every entry in
|
|
315
|
+
* `src/data/content.json` relies on.
|
|
316
|
+
*
|
|
317
|
+
* **This filter is deliberately narrow, and the narrowness is the honest part.**
|
|
318
|
+
* TK-08's acceptance criterion is that the feed and the sitemap "contain only
|
|
319
|
+
* published routes", so those two artifacts are filtered here. A withdrawn note's
|
|
320
|
+
* *page* is still built and is still listed on `/` and `/recent/`, because that
|
|
321
|
+
* is open owner decision D4 — "does a published-but-unlisted state exist?" — and
|
|
322
|
+
* `docs/plans/quartz-parity-plan.md` recommends answering it by making withdrawal
|
|
323
|
+
* remove the entry from the manifest entirely rather than by adding a
|
|
324
|
+
* hide-from-some-surfaces state, which is the security-by-obscurity pattern it
|
|
325
|
+
* criticizes in Quartz.
|
|
326
|
+
*
|
|
327
|
+
* So this is not access control and must not be mistaken for it. A sitemap is a
|
|
328
|
+
* crawl *instruction* and a feed is a broadcast; not advertising withdrawn
|
|
329
|
+
* content through either is correct regardless of how D4 lands. Suppressing the
|
|
330
|
+
* page itself is a corpus-wide question with one answer, and it belongs to
|
|
331
|
+
* whoever closes D4.
|
|
332
|
+
*/
|
|
333
|
+
export function isPublished(entry) {
|
|
334
|
+
return entry.status !== 'tombstone';
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A feed entry's permanent identity.
|
|
338
|
+
*
|
|
339
|
+
* `public_id` when the artifact carries one, the canonical URL otherwise.
|
|
340
|
+
*
|
|
341
|
+
* This is the difference between a rename and a republication. RFC 4287 requires
|
|
342
|
+
* an entry id that never changes for the life of the entry, and requirements
|
|
343
|
+
* §9.3 states the two halves that make the URL unsuitable: "`public_id` is
|
|
344
|
+
* immutable. `slug` is unique but **mutable** through an explicit redirect
|
|
345
|
+
* record." So a slug rename — which owner decision Q4 explicitly permits —
|
|
346
|
+
* changes the URL, and every reader that keys on the id shows the note a second
|
|
347
|
+
* time as though it were new.
|
|
348
|
+
*
|
|
349
|
+
* A `tag:` URI rather than the bare identifier, because an Atom id must be an
|
|
350
|
+
* IRI. The host is the site's own, so the identifier stays scoped to this
|
|
351
|
+
* projection, and `public_id` is already a public field.
|
|
352
|
+
*
|
|
353
|
+
* The URL fallback keeps the feed working for the artifact as it stands today,
|
|
354
|
+
* which carries no `public_id` at all — at the cost this comment names, for
|
|
355
|
+
* exactly the entries the producer has not yet given a stable identity.
|
|
356
|
+
*/
|
|
357
|
+
function entryId(entry, url) {
|
|
358
|
+
if (entry.public_id === undefined)
|
|
359
|
+
return url;
|
|
360
|
+
return `tag:${new URL(url).host},2026:${entry.public_id}`;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The Atom 1.0 feed of every published note.
|
|
364
|
+
*
|
|
365
|
+
* **Atom rather than RSS 2.0**, for one reason that matters to this corpus and
|
|
366
|
+
* one that matters to this artifact. Atom carries `xml:lang` per entry, so a
|
|
367
|
+
* bilingual projection can declare each note's own language — RSS 2.0 has a
|
|
368
|
+
* single channel-level `<language>` and would have to pick one. And Atom's
|
|
369
|
+
* timestamps are RFC 3339, which is the format the artifact already stores,
|
|
370
|
+
* while RSS 2.0 requires RFC 822 with English month and day abbreviations: a
|
|
371
|
+
* formatting step, with a locale hazard, for no gain.
|
|
372
|
+
*
|
|
373
|
+
* **Hand-written rather than a dependency**, per the ticket. The whole document
|
|
374
|
+
* is forty lines. What makes that safe rather than the correctness hazard it
|
|
375
|
+
* usually is: every piece of text goes through {@link escapeXml}, and
|
|
376
|
+
* `tests/metadata.test.ts` proves the property directly — every `&` in the
|
|
377
|
+
* output opens a known entity, no character outside XML's `Char` production
|
|
378
|
+
* survives, and each escaped title decodes back to the artifact's title exactly.
|
|
379
|
+
*
|
|
380
|
+
* **`<summary>`, never `<content>`.** The summary is the artifact's `excerpt`,
|
|
381
|
+
* which is the same public projection already served in
|
|
382
|
+
* the public snapshot. Putting the rendered body in a feed would publish
|
|
383
|
+
* the article through a second channel with none of the page's sanitization
|
|
384
|
+
* context, and the requirement is public-safe summaries only. An entry whose
|
|
385
|
+
* excerpt is empty gets no `<summary>` at all rather than an empty one.
|
|
386
|
+
*/
|
|
387
|
+
export function renderFeed(site, entries) {
|
|
388
|
+
// Withdrawn notes are not broadcast. See `isPublished` for why this filter is
|
|
389
|
+
// here and not also over the page itself.
|
|
390
|
+
const ordered = recentFirst(entries.filter(isPublished));
|
|
391
|
+
const home = canonicalUrl(site, '/');
|
|
392
|
+
const self = canonicalUrl(site, FEED_PATH);
|
|
393
|
+
// `recentFirst` sorts by parsed instant with dated notes first, so the head of
|
|
394
|
+
// the list carries the latest instant in the corpus. Reading it back out is
|
|
395
|
+
// what keeps the feed's own `<updated>` off the build clock.
|
|
396
|
+
const updated = ordered[0] === undefined ? UNDATED : entryTimestamp(ordered[0]);
|
|
397
|
+
const lines = [
|
|
398
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
399
|
+
'<feed xmlns="http://www.w3.org/2005/Atom">',
|
|
400
|
+
...element('title', SITE_NAME, '', true),
|
|
401
|
+
// The feed is the whole corpus rather than one document, so its own chrome
|
|
402
|
+
// is the navigation language — the same rule every non-document route
|
|
403
|
+
// follows. Each `<entry>` still carries its own `xml:lang` below, so a
|
|
404
|
+
// reader's client knows which language each note is in.
|
|
405
|
+
...element('subtitle', translate(NAV_LANGUAGE).siteSubtitle),
|
|
406
|
+
`<id>${escapeXml(home)}</id>`,
|
|
407
|
+
`<link rel="alternate" type="text/html" href="${escapeXml(home)}"/>`,
|
|
408
|
+
`<link rel="self" type="application/atom+xml" href="${escapeXml(self)}"/>`,
|
|
409
|
+
`<updated>${timestamp(updated)}</updated>`,
|
|
410
|
+
// Atom requires an author on every entry unless the feed declares one. The
|
|
411
|
+
// site is the author: this projection names no person, and a feed is not the
|
|
412
|
+
// place to start.
|
|
413
|
+
`<author><name>${escapeXml(SITE_NAME)}</name></author>`,
|
|
414
|
+
];
|
|
415
|
+
for (const entry of ordered) {
|
|
416
|
+
const url = canonicalUrl(site, noteRoute(entry.slug));
|
|
417
|
+
lines.push(entry.language === undefined ? '<entry>' : `<entry xml:lang="${escapeXml(entry.language)}">`,
|
|
418
|
+
// Required with cardinality exactly 1 on an entry, so never omitted.
|
|
419
|
+
...element('title', entry.title, '', true), `<id>${escapeXml(entryId(entry, url))}</id>`, `<link rel="alternate" type="text/html" href="${escapeXml(url)}"/>`, `<updated>${timestamp(entryTimestamp(entry))}</updated>`, ...(entry.created === undefined
|
|
420
|
+
? []
|
|
421
|
+
: [`<published>${timestamp(rfc3339(entry.created))}</published>`]), ...element('summary', entry.excerpt, ' type="text"'), '</entry>');
|
|
422
|
+
}
|
|
423
|
+
lines.push('</feed>', '');
|
|
424
|
+
return lines.join('\n');
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Every route a search engine should know about.
|
|
428
|
+
*
|
|
429
|
+
* The fixed routes, then notes in slug order, then tag and collection facets —
|
|
430
|
+
* which {@link tagFacets} already returns in key order. Constructed rather than
|
|
431
|
+
* sorted afterwards, so the order is a property of this function rather than of
|
|
432
|
+
* a comparator, and two builds of one artifact list the same URLs in the same
|
|
433
|
+
* places.
|
|
434
|
+
*
|
|
435
|
+
* `/404.html` is absent: it is the host's fallback for an unmatched request, not
|
|
436
|
+
* a destination, and listing it invites a search engine to index it. `/rss.xml`
|
|
437
|
+
* and `/sitemap.xml` are absent because a sitemap enumerates pages.
|
|
438
|
+
*
|
|
439
|
+
* `lastmod` is emitted for note routes only. A facet page changes whenever any
|
|
440
|
+
* of its members does, and a fixed route changes when this repository does —
|
|
441
|
+
* neither has a date in the artifact, and inventing one from the corpus would be
|
|
442
|
+
* a guess a crawler would then trust. The element is optional; omitting it says
|
|
443
|
+
* nothing, which is what is actually known.
|
|
444
|
+
*
|
|
445
|
+
* Notes are ordered by slug here while {@link renderFeed} orders by recency, and
|
|
446
|
+
* the divergence is intentional: a sitemap is an unordered set a crawler reads
|
|
447
|
+
* whole, so a stable alphabetical order keeps the diff between two builds
|
|
448
|
+
* readable, while a feed is a timeline a reader consumes from the top. Both are
|
|
449
|
+
* total orders, so both are deterministic.
|
|
450
|
+
*
|
|
451
|
+
* Facet routes are computed from the *unfiltered* corpus, deliberately. A tag
|
|
452
|
+
* page lists only its published members, but the page itself exists as long as
|
|
453
|
+
* any note carries the tag — deriving facets from the filtered set would drop a
|
|
454
|
+
* live page from the sitemap whenever its last remaining member was withdrawn.
|
|
455
|
+
*
|
|
456
|
+
* `facets` defaults to the producer normalization so fixture-driven callers and
|
|
457
|
+
* tests keep the pure signature. The build path passes `content.ts`'s
|
|
458
|
+
* `tagFacets()`, whose keys are the snapshot's own `tags.key` rows. A caller
|
|
459
|
+
* that supplies `facets` must supply the unfiltered corpus's index: the
|
|
460
|
+
* withdrawal property above depends on the index covering every tag the corpus
|
|
461
|
+
* carries, not on the default.
|
|
462
|
+
*/
|
|
463
|
+
export function publicRoutes(entries, facets = tagFacets(entries)) {
|
|
464
|
+
// Withdrawn notes are not advertised to crawlers; their pages are still built.
|
|
465
|
+
// See `isPublished`.
|
|
466
|
+
const notes = entries
|
|
467
|
+
.filter(isPublished)
|
|
468
|
+
// Canonical slug order (code points), the order the snapshot assigns IDs in.
|
|
469
|
+
.sort((a, b) => compareSlugs(a.slug, b.slug))
|
|
470
|
+
.map((entry) => ({ path: noteRoute(entry.slug), lastmod: entry.updated ?? entry.created }));
|
|
471
|
+
return [
|
|
472
|
+
...FIXED_ROUTES.map((path) => ({ path })),
|
|
473
|
+
...notes,
|
|
474
|
+
...facets.map((facet) => ({ path: tagRoute(facet.key) })),
|
|
475
|
+
...collectionFacets(entries).map((facet) => ({ path: collectionRoute(facet.key) })),
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The sitemap, in the `sitemaps.org` 0.9 schema.
|
|
480
|
+
*
|
|
481
|
+
* `<lastmod>` takes the artifact's date verbatim: the W3C Datetime profile the
|
|
482
|
+
* schema names admits a bare `YYYY-MM-DD`, so unlike Atom there is nothing to
|
|
483
|
+
* widen, and widening would state a precision the artifact does not have.
|
|
484
|
+
*
|
|
485
|
+
* `<changefreq>` and `<priority>` are deliberately absent. Google has stated it
|
|
486
|
+
* ignores both, and a number nobody reads is a number that goes stale silently.
|
|
487
|
+
*/
|
|
488
|
+
export function renderSitemap(site, routes) {
|
|
489
|
+
const lines = [
|
|
490
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
491
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
492
|
+
];
|
|
493
|
+
for (const route of routes) {
|
|
494
|
+
lines.push('<url>', `<loc>${escapeXml(canonicalUrl(site, route.path))}</loc>`, ...(route.lastmod === undefined ? [] : [`<lastmod>${escapeXml(route.lastmod)}</lastmod>`]), '</url>');
|
|
495
|
+
}
|
|
496
|
+
lines.push('</urlset>', '');
|
|
497
|
+
return lines.join('\n');
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* `robots.txt`, generated rather than committed under `public/`.
|
|
501
|
+
*
|
|
502
|
+
* The `Sitemap:` directive takes an absolute URL, so a committed file would be
|
|
503
|
+
* a second place the origin is written down — and the origin is the *user's*,
|
|
504
|
+
* arriving from their configuration, which makes "the origin lives in exactly
|
|
505
|
+
* one place" the difference between a build that follows the config and one
|
|
506
|
+
* that ships a stale literal to every reader.
|
|
507
|
+
*
|
|
508
|
+
* The policy is to allow everything. Every page here is one the build was asked
|
|
509
|
+
* to publish: the corpus is what discovery found minus what the user excluded,
|
|
510
|
+
* so a page that exists is a page meant to be read, and a `Disallow` would be
|
|
511
|
+
* theatre — it withholds nothing, since a rule naming a route also names it.
|
|
512
|
+
* `/pagefind/` is left crawlable for the same reason: it is a search index
|
|
513
|
+
* built from pages that are already public.
|
|
514
|
+
*/
|
|
515
|
+
export function renderRobots(site) {
|
|
516
|
+
return ['User-agent: *', 'Allow: /', '', `Sitemap: ${canonicalUrl(site, SITEMAP_PATH)}`, ''].join('\n');
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The Open Graph locale for a BCP 47 language tag, or `undefined`.
|
|
520
|
+
*
|
|
521
|
+
* Open Graph defines the value as `language_TERRITORY`, so `zh-CN` becomes
|
|
522
|
+
* `zh_CN`. A tag carrying no territory has **no valid Open Graph form**, and the
|
|
523
|
+
* two honest options are to invent one or to say nothing.
|
|
524
|
+
*
|
|
525
|
+
* Saying nothing wins. Inventing `en_US` would assert a territory the artifact
|
|
526
|
+
* never stated — this projection is bilingual zh-CN and English and is not
|
|
527
|
+
* American — and emitting a bare `en` produces a value consumers discard, which
|
|
528
|
+
* is the same outcome as omission plus a tag that looks answered. `<html lang>`
|
|
529
|
+
* carries the exact BCP 47 tag on every page either way, and it is the
|
|
530
|
+
* standardized signal a consumer should be reading.
|
|
531
|
+
*/
|
|
532
|
+
export function openGraphLocale(language) {
|
|
533
|
+
return language.includes('-') ? language.replaceAll('-', '_') : undefined;
|
|
534
|
+
}
|
|
535
|
+
/** The suffix every page's `<title>` carries, so a browser tab names the site. */
|
|
536
|
+
export const TITLE_SUFFIX = ` · ${SITE_NAME}`;
|
|
537
|
+
/**
|
|
538
|
+
* A page's `<title>` as `og:title` should state it: without the site suffix.
|
|
539
|
+
*
|
|
540
|
+
* A social card renders `og:title` directly above `og:site_name`, so a page
|
|
541
|
+
* whose `og:title` is "About · Notes" produces a card reading "About · Notes"
|
|
542
|
+
* over "Notes". The suffix belongs in the `<title>`, where it names the browser
|
|
543
|
+
* tab, and not in the card, where a separate field already carries it.
|
|
544
|
+
*
|
|
545
|
+
* Stripped rather than threaded through as a second prop. Every page composes
|
|
546
|
+
* its title from the same literal, so the suffix is a property of the convention
|
|
547
|
+
* rather than a guess — and the alternative widens `Layout`'s props and makes
|
|
548
|
+
* every future page responsible for remembering a second title.
|
|
549
|
+
*
|
|
550
|
+
* The home page's title is the bare site name and carries no suffix, so it is
|
|
551
|
+
* returned unchanged.
|
|
552
|
+
*/
|
|
553
|
+
export function socialTitle(title) {
|
|
554
|
+
return title.endsWith(TITLE_SUFFIX) ? title.slice(0, -TITLE_SUFFIX.length) : title;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Whether a route may be indexed, and may therefore claim a canonical URL.
|
|
558
|
+
*
|
|
559
|
+
* Two exceptions, and they are excluded for different reasons.
|
|
560
|
+
*
|
|
561
|
+
* `/404/`: the host serves that document's *body* in response to any unmatched
|
|
562
|
+
* address, so the page has no address of its own. A `rel="canonical"` on it
|
|
563
|
+
* would tell a crawler that every mistyped URL on this site is canonically
|
|
564
|
+
* `/404/`, which is an instruction to index the error page under arbitrary
|
|
565
|
+
* names. `noindex` says the true thing instead.
|
|
566
|
+
*
|
|
567
|
+
* {@link WITHHELD_ROUTE}: it has an address of its own and is served at it, so
|
|
568
|
+
* this is a privacy exclusion rather than a correctness one. Every link to a
|
|
569
|
+
* note the build did not publish points here, and those links carry the target's
|
|
570
|
+
* path as their text — so an indexed copy of this page would be listed in a
|
|
571
|
+
* search engine under the text of every withheld link on the site, gathering in
|
|
572
|
+
* one crawlable record what is otherwise scattered across the bodies that wrote
|
|
573
|
+
* it. The page itself names nothing; what `noindex` withholds is the
|
|
574
|
+
* *aggregation* an external index would build around it.
|
|
575
|
+
*/
|
|
576
|
+
export function isIndexable(path) {
|
|
577
|
+
return path !== '/404/' && path !== '/404.html' && path !== WITHHELD_ROUTE;
|
|
578
|
+
}
|