@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,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: `&#1;` 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
+ * `&lt;` just produced into `&amp;lt;`, which renders the literal text `&lt;`.
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. `&#13;` 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('&', '&amp;')
232
+ .replaceAll('<', '&lt;')
233
+ .replaceAll('>', '&gt;')
234
+ .replaceAll('"', '&quot;')
235
+ .replaceAll("'", '&apos;')
236
+ .replaceAll('\r', '&#13;');
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
+ }