@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ANC contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # anc — a privacy-preserving static site generator for Markdown notes and digital gardens
2
+
3
+ [![status: pre-release](https://img.shields.io/badge/status-pre--release-orange)](#status)
4
+ [![node: >=22.18](https://img.shields.io/badge/node-%E2%89%A522.18-brightgreen)](#working-on-the-tool)
5
+ [![verify](https://github.com/wxxb789/anc/actions/workflows/verify.yml/badge.svg)](https://github.com/wxxb789/anc/actions/workflows/verify.yml)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
+ [![docs: core design](https://img.shields.io/badge/docs-core%20design-blue)](docs/core-design/README.md)
8
+
9
+ [English](README.md) · [简体中文](README_zh-cn.md)
10
+
11
+ ![anc, a privacy-preserving static site generator: publish your Markdown notes from git as a static digital garden with wikilinks, backlinks, a link graph, and Pagefind full-text search](docs/assets/readme-banner.webp)
12
+
13
+ **anc turns a git repository of Markdown into a self-hosted static knowledge
14
+ garden**: articles, backlinks, a link graph, breadcrumbs, tags, collections, a table
15
+ of contents, full-text search, feeds, and a sitemap — all rendered ahead of time into
16
+ plain static files that any host can serve.
17
+
18
+ It reads the notes you already have, including Obsidian-style `[[wikilinks]]`, and
19
+ needs no database, server, or account. Every note publishes unless you exclude it.
20
+ Ordinary reading needs no JavaScript, and nothing is sent to a third party.
21
+
22
+ ![A note page built by anc: breadcrumbs, published and updated dates from git, tags, aliases, a table of contents, and a callout, in the dark theme](docs/assets/screenshot-note.webp)
23
+
24
+ *anc* is short for **A**ctive **N**oise **C**ancelling. The command it installs is
25
+ `anc`; the package is named `@wxxb789/anc` and is not yet published to npm.
26
+
27
+ ## Contents
28
+
29
+ - [Status](#status)
30
+ - [Why anc](#why-anc)
31
+ - [Features](#features)
32
+ - [How it works](#how-it-works)
33
+ - [Screenshots](#screenshots)
34
+ - [Quick start](#quick-start)
35
+ - [How publication is decided](#how-publication-is-decided)
36
+ - [Links, tags, and metadata](#links-tags-and-metadata)
37
+ - [Architecture](#architecture)
38
+ - [Working on the tool](#working-on-the-tool)
39
+ - [Adoption and hosting](#adoption-and-hosting)
40
+ - [FAQ](#faq)
41
+ - [Documentation](#documentation)
42
+ - [License](#license)
43
+
44
+ ## Status
45
+
46
+ **Pre-release.** The tool builds, previews, and ships the GitHub Action and the `init`
47
+ command. It is named `@wxxb789/anc`, because the unscoped `anc` is already taken on
48
+ npm, and it is not on the registry yet; install from a checkout or from the tarball the
49
+ repository builds. [`docs/adoption.md`](docs/adoption.md) gives both, along with
50
+ configuration, exclusion, links, and hosting. ANC is not yet 0.1.0-ready or
51
+ 1.0.0-ready and makes no backward-compatibility promise.
52
+
53
+ ## Why anc
54
+
55
+ - **Your notes stay yours.** Markdown in git is the source of truth. There is no
56
+ import step, no proprietary vault format, and no hosted service to subscribe to.
57
+ - **Privacy is decided at publication.** `publish: false` and exclusion globs withhold
58
+ a note, a typo in a pattern fails the build instead of leaking drafts, and the build
59
+ log never prints a withheld file's name.
60
+ - **A digital garden, not a blog.** Backlinks and the link graph are computed at
61
+ build time from how your notes link; hover previews and graph exploration read the
62
+ same public snapshot lazily.
63
+ - **Static and portable.** The output is a `dist/` folder of HTML, CSS, a Pagefind
64
+ index, and one SQLite file. GitHub Pages, Cloudflare Pages, Netlify, or any web
65
+ server can host it at the root of a domain.
66
+
67
+ ## Features
68
+
69
+ | Surface | What you get |
70
+ | --- | --- |
71
+ | Markdown | CommonMark plus GFM: tables, task lists, footnotes, callouts, highlighted code |
72
+ | Links | `[[wikilinks]]`, relative and root-anchored links, Markdown links, and note embeds |
73
+ | Relationships | Backlinks, outgoing links, and a link graph, rendered to static HTML |
74
+ | Navigation | Tags, collections, breadcrumbs, table of contents, and recent changes |
75
+ | Search | Pagefind full-text search, with a per-document language |
76
+ | Rich content | Client-rendered math and Mermaid diagrams, each with a source fallback |
77
+ | Discovery | Atom feed, sitemap, `robots.txt`, canonical URLs, and Open Graph metadata |
78
+ | Languages | Per-document chrome in English and Simplified Chinese (`zh-CN`) |
79
+ | Privacy | Default-publish with explicit withholding; released names stay out of logs |
80
+ | Delivery | Static HTML by default; a public SQLite/WASM snapshot for lazy previews |
81
+ | Security | A strict Content-Security-Policy in `dist/_headers`; no trackers or analytics |
82
+
83
+ ## How it works
84
+
85
+ ![How anc works: Markdown notes in a git repository go through anc build, which applies exclusion gates, resolves wikilinks and backlinks, and renders every page ahead of time into a dist/ folder of static HTML, a Pagefind index, and one SQLite snapshot, which any static host serves](docs/assets/build-pipeline.webp)
86
+
87
+ 1. **Write** Markdown in the git repository you already keep, in any editor —
88
+ Obsidian, VS Code, or plain `vim`.
89
+ 2. **Build** with `anc build`. Exclusions are applied first, then every link is
90
+ resolved and every page is rendered ahead of time.
91
+ 3. **Host** the resulting `dist/` folder anywhere that serves static files.
92
+
93
+ ## Screenshots
94
+
95
+ Every image below is a real page built by anc from a small synthetic garden;
96
+ [`docs/assets/make-screenshots.mjs`](docs/assets/make-screenshots.mjs) regenerates them.
97
+
98
+ | Backlinks and outgoing links | Local link graph |
99
+ | --- | --- |
100
+ | ![Links to and Linked from lists on a note page, each rendered as static HTML](docs/assets/screenshot-relationships.webp) | ![The Nearby notes graph around one note, laid out at build time with a table fallback](docs/assets/screenshot-graph.webp) |
101
+ | **Hover preview** | **Full-text search** |
102
+ | ![A link preview card showing the target note's title and excerpt on hover](docs/assets/screenshot-preview.webp) | ![The Pagefind search dialog with highlighted matches across notes](docs/assets/screenshot-search.webp) |
103
+
104
+ ## Quick start
105
+
106
+ From a checkout of this repository, against your own notes:
107
+
108
+ ```bash
109
+ node /path/to/anc/bin/anc.mjs build --content ~/notes --out ~/notes/dist
110
+ node /path/to/anc/bin/anc.mjs preview --dist ~/notes/dist
111
+ ```
112
+
113
+ Or install the tarball the repository builds:
114
+
115
+ ```bash
116
+ cd /path/to/anc && pnpm run pack:tarball
117
+ cd ~/notes
118
+ pnpm add -D /path/to/anc/wxxb789-anc-*.tgz # or: npm install -D /path/to/anc/wxxb789-anc-*.tgz
119
+ ```
120
+
121
+ Then name the commands in your notes repository's `package.json`; the same scripts
122
+ work under pnpm and npm:
123
+
124
+ ```json
125
+ {
126
+ "scripts": {
127
+ "build": "anc build",
128
+ "preview": "anc preview",
129
+ "review": "anc review",
130
+ "release": "anc build --release"
131
+ }
132
+ }
133
+ ```
134
+
135
+ ```bash
136
+ pnpm run build # or: npm run build
137
+ pnpm run preview # serves dist/ at http://localhost:4321/
138
+ pnpm run review # write .publish-set.json for inspection
139
+ pnpm run release # exact publish set + pinned Gitleaks on PATH
140
+ ```
141
+
142
+ To call the binary directly, use `pnpm exec anc build`, or `npx --no anc build` under
143
+ npm: `--no` makes `npx` refuse rather than fetch from the registry when the local
144
+ install is missing. After publication the registry form will be
145
+ `npx @wxxb789/anc build`. **Do not type bare `npx anc`:** outside a project with the
146
+ tarball installed it falls back to the registry, where the unscoped `anc` is an
147
+ unrelated third-party package that `npx` would offer to download and run.
148
+
149
+ ## How publication is decided
150
+
151
+ **Everything publishes unless you exclude it.** There is no allowlist and no
152
+ `publish: true` to opt in with. Two mechanisms withhold a note, and when they
153
+ disagree the one pointing at *not publishing* wins:
154
+
155
+ ![How anc decides publication: every Markdown file in the repository publishes by default, publish: false and exclude globs withhold notes, a glob matching nothing fails the build, the public dist/ receives the site, and a private content-report.json under .git/ receives the withheld names](docs/assets/publication-flow.webp)
156
+
157
+ ```markdown
158
+ ---
159
+ publish: false
160
+ ---
161
+ ```
162
+
163
+ ```yaml
164
+ # publish.config.yaml
165
+ title: Field Notes
166
+ origin: https://notes.example.org/
167
+ exclude:
168
+ - "drafts/**"
169
+ - "clients/**"
170
+ ```
171
+
172
+ A pattern that matches nothing stops the build. That is deliberate: `draft/**` typed
173
+ for `drafts/**` would otherwise build green while the drafts went live. A link to a
174
+ withheld note keeps the full label and path you wrote, resolves to `/private/`, and is
175
+ reported; the withheld note's own body, title, and excerpt reach nothing.
176
+
177
+ The build prints counts and no filenames. The list of dropped files goes to
178
+ `content-report.json` under `<git-dir>/publish-report/` (or your user state directory
179
+ outside git), which `git add -A` cannot reach and which is never copied into `dist/`.
180
+ A workflow log on a public repository is world-readable, so a withheld file's path is
181
+ a disclosure there; that is why the names and the counts are split.
182
+
183
+ ## Links, tags, and metadata
184
+
185
+ - Five link spellings resolve in one pass, following Obsidian's own order: `[[note]]`,
186
+ `[[./sibling]]`, `[[folder/note]]`, `[text](../other.md)`, and `[[note|shown]]`.
187
+ - Tags come from a frontmatter list, and each one gets a `/tags/<key>/` page. The
188
+ first folder under the content root becomes the flat collection.
189
+ - Git history supplies `created` and `updated`; frontmatter does not.
190
+ - Frontmatter can set `slug`, `language`, `description`, `tags`, and `aliases`.
191
+ Aliases are public, searchable metadata with no route of their own — deliberately
192
+ not alternate link targets.
193
+ - Non-Markdown files are never published. There is no asset pipeline, so an embedded
194
+ image degrades to text and is reported.
195
+
196
+ ## Architecture
197
+
198
+ Long-term architecture is documented in [`docs/core-design/`](docs/core-design/README.md);
199
+ bounded development outcomes and their completion evidence live in
200
+ [`docs/goals/`](docs/goals/README.md). Markdown stays canonical, the compiler IR stays
201
+ private, static HTML delivers pages, and Pagefind owns full-text search.
202
+
203
+ - Astro `output: "static"`; the build output is `dist/`.
204
+ - Content is produced by `scripts/markdown-to-artifact.ts` and `scripts/resolve-links.ts`,
205
+ then validated against `src/lib/schema.ts` before anything renders.
206
+ - The one public relational and preview index is `data/site.<sha256>.sqlite`, holding
207
+ `nodes`, `edges`, `aliases`, `tags`, and `node_tags`. It contains no page body and no
208
+ SQLite FTS; the browser fetches it lazily in a read-only Worker for hover previews,
209
+ tag browsing, and graph exploration.
210
+ - Base interactive surfaces are a few KB gzip of vanilla script. Pages containing math
211
+ or diagrams lazy-load the accepted client renderers; both have source fallbacks.
212
+ - No D1, R2, Functions, analytics, comments, or build-time data service. A runtime
213
+ application server is not required.
214
+
215
+ ## Working on the tool
216
+
217
+ ```bash
218
+ pnpm install
219
+ pnpm run verify # lint, check, build, inventory, secret/residue scans, tests
220
+ pnpm run build # the build chain alone
221
+ pnpm run build:fixture # rebuild against the 32-note corpus
222
+ pnpm run build:example # build example/ into .tmp/example-dist
223
+ pnpm run preview:example # serve the example build locally
224
+ pnpm run pack:tarball # compile TypeScript and pack the installable tarball
225
+ pnpm run smoke:tarball # install that tarball in a foreign repo and read it
226
+ ```
227
+
228
+ `pnpm run verify` requires the exact Gitleaks version exported by
229
+ `scripts/scan-secrets.ts` on `PATH`; an ordinary `pnpm run build` does not.
230
+ `packageManager` in `package.json` pins the pnpm version, which `corepack enable`
231
+ honours. Dependencies install into a symlinked `node_modules`, so a package not
232
+ declared in `package.json` does not resolve — a boundary rather than a preference.
233
+
234
+ Read [`AGENTS.md`](AGENTS.md) before changing anything. It carries the verification
235
+ contract, what runs where, and what is known stale and whose it is. The synthetic
236
+ corpus under [`example/`](example/) is the smallest useful feature tour, with public
237
+ notes, both exclusion mechanisms, links and backlinks, rich Markdown, math, and Mermaid.
238
+
239
+ ## Adoption and hosting
240
+
241
+ The GitHub Action at the repository root runs a release build on a supported Linux
242
+ runner, installs the checksum-pinned secret scanner, and writes a static `dist/`. Any
243
+ static host serves that directory, at the root of a domain: a site under a path such
244
+ as `https://<user>.github.io/<repo>/` is not supported. `dist/_headers` carries a
245
+ Content-Security-Policy and three other security headers in Cloudflare Pages' format;
246
+ a host that ignores that file still gets the policy from each page's `<meta>` tag,
247
+ except `frame-ancestors`, and serves the other headers only if configured to.
248
+
249
+ Publication is an explicit external side effect. Building does not deploy, and nothing
250
+ in this repository can. [`docs/adoption.md`](docs/adoption.md) covers both the Action
251
+ and a hand-assembled GitHub Pages example, with no secrets required.
252
+
253
+ ## FAQ
254
+
255
+ **Can anc publish an Obsidian vault?**
256
+ Yes: point `--content` at the vault. anc reads Obsidian-style `[[wikilinks]]`,
257
+ `[[note|shown text]]`, note embeds, and callouts; keeping the vault in git also gives
258
+ each page its created and updated dates. Plugins and non-Markdown attachments are not
259
+ processed.
260
+
261
+ **Do I need a server, a database, or an account?**
262
+ No. The output is static files. Search runs from a Pagefind index and relationships
263
+ from one SQLite file, both fetched by the reader's browser only when used.
264
+
265
+ **How do I keep a note private?**
266
+ Add `publish: false` to its frontmatter, or match its path with an `exclude:` glob in
267
+ `publish.config.yaml`. See [How publication is decided](#how-publication-is-decided).
268
+
269
+ **Does the site work without JavaScript?**
270
+ Reading, navigation, backlinks, and the link graph's table fallback are plain HTML.
271
+ Search, hover previews, math, and diagrams need JavaScript.
272
+
273
+ **Where can I host it?**
274
+ Any static host at the root of a domain: GitHub Pages, Cloudflare Pages, Netlify, or
275
+ your own web server. See [Adoption and hosting](#adoption-and-hosting).
276
+
277
+ ## Documentation
278
+
279
+ - [`docs/adoption.md`](docs/adoption.md) — what a stranger with a notes repository does.
280
+ - [`docs/core-design/`](docs/core-design/README.md) — authoritative long-term architecture.
281
+ - [`docs/goals/`](docs/goals/README.md) — active goals and the completed-goal archive.
282
+ - [`docs/gate-reading.md`](docs/gate-reading.md) — the seven ways an instrument lies
283
+ about itself.
284
+ - [`AGENTS.md`](AGENTS.md) — contributor workflow and the verification contract.
285
+ - [`example/`](example/) — a synthetic corpus demonstrating every public surface.
286
+
287
+ [Astro documentation](https://docs.astro.build) · [Pagefind documentation](https://pagefind.app/docs/)
288
+
289
+ ## License
290
+
291
+ Released under the [MIT License](LICENSE).
292
+
293
+ ---
294
+
295
+ [English](README.md) · [简体中文](README_zh-cn.md)
@@ -0,0 +1,255 @@
1
+ // @ts-check
2
+ import { defineConfig } from 'astro/config';
3
+ import { readFileSync } from 'node:fs';
4
+ import { wasmModuleUrl } from './src/lib/wasm-asset.js';
5
+ import { configuredSnapshotWorkspace } from './src/lib/snapshot.js';
6
+ import { resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { DIAGRAM_MODE } from './src/lib/diagram-mode.js';
9
+ import { MATH_MODE } from './src/lib/math-mode.js';
10
+ import { configForBuild } from './scripts/load-config.js';
11
+ import { vendorProvenancePlugin } from './scripts/vendor-provenance.js';
12
+
13
+ /**
14
+ * The private snapshot workspace this build staged into.
15
+ *
16
+ * The empty-as-unset choice lives in `configuredSnapshotWorkspace`, shared with
17
+ * `snapshot-reader.ts`; the `??` this line used to carry resolved an empty
18
+ * value to the working directory while the reader fell back to the default, so
19
+ * the binding below was substituted as `null` and the Worker silently never
20
+ * started.
21
+ */
22
+ const SNAPSHOT_WORKSPACE = resolve(process.cwd(), configuredSnapshotWorkspace(process.env['SNAPSHOT_WORKSPACE']));
23
+
24
+ /** Read once: the binding and its module URL come from the same file. */
25
+ const STAGED_WASM = readStaged('wasm.json');
26
+
27
+ /**
28
+ * Read one staged binding from the private snapshot workspace.
29
+ *
30
+ * The design calls for a private generated binding rather than a public manifest
31
+ * (`docs/core-design/build-and-runtime.md`). `vite.define` substitutes the
32
+ * digest-named same-origin URLs into the hashed client/worker bundles at build
33
+ * time: it carries no local path and no corpus data, and a build with no staging
34
+ * (dev, unit tests) substitutes `null`, so the Worker fails closed and fetches
35
+ * nothing. A virtual module was tried first and rejected: Vite's worker
36
+ * environment did not run the plugin's resolver for it.
37
+ *
38
+ * @param {string} name
39
+ * @returns {Record<string, any> | null}
40
+ */
41
+ function readStaged(name) {
42
+ try {
43
+ return JSON.parse(readFileSync(resolve(SNAPSHOT_WORKSPACE, name), 'utf8'));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Keep a client runtime out of the build unless client mode wants it.
51
+ *
52
+ * `src/pages/notes/[slug].astro` has Astro `<script>` tags importing
53
+ * `src/scripts/diagram.ts` and `src/scripts/math.ts`, and Astro bundles such a
54
+ * script **eagerly**: wrapping the tag in a condition or guarding the import at
55
+ * runtime does not keep it out of `dist/`. Measured, not assumed — the guarded
56
+ * version emitted 99 Mermaid chunks on a build whose mode was `build-time`,
57
+ * orphaned and referenced by no page, and tripped the privacy residue scan on
58
+ * `[[` byte sequences inside the parser.
59
+ *
60
+ * A plugin rather than `resolve.alias`, because an alias is matched against the
61
+ * *raw specifier* — here `../../scripts/diagram.ts` — before resolution, so a
62
+ * pattern written against the real path never fires. `resolveId` runs after the
63
+ * path is known, which is the only place the question "is this that module" can
64
+ * be asked reliably.
65
+ *
66
+ * Typed structurally rather than as `import('vite').Plugin`: `vite` reaches this
67
+ * tree through Astro and is not a declared dependency, and under pnpm an
68
+ * undeclared package does not resolve — which is the boundary `AGENTS.md` names
69
+ * as a feature. Adding `vite` to `package.json` to annotate one function would
70
+ * spend a dependency on a type.
71
+ *
72
+ * @param {'diagram' | 'math'} runtime
73
+ * @param {'build-time' | 'client'} mode
74
+ * @returns {{
75
+ * name: string,
76
+ * enforce: 'pre',
77
+ * resolveId: (
78
+ * this: { resolve: (source: string, importer: string | undefined, options: object) =>
79
+ * Promise<{ id: string } | null> },
80
+ * source: string,
81
+ * importer: string | undefined,
82
+ * options: object,
83
+ * ) => Promise<string | null>,
84
+ * }}
85
+ */
86
+ function clientRuntimePlugin(runtime, mode) {
87
+ const stub = fileURLToPath(new URL(`src/scripts/${runtime}-disabled.ts`, import.meta.url));
88
+ // The extension is matched loosely because it is not the same in both trees:
89
+ // this repository runs `diagram.ts` directly, while the packaged tarball ships
90
+ // it compiled to `diagram.js` (`scripts/compile-package.ts`). Pinning `.ts`
91
+ // here meant the redirect silently stopped firing once installed — Mermaid's
92
+ // whole runtime was then bundled into a `build-time` site, which the residue
93
+ // scan caught on `[[` byte sequences inside the parser. Matching either
94
+ // extension keeps one rule true of both trees.
95
+ //
96
+ // **Parameterised over the runtime rather than duplicated**, because math
97
+ // needs exactly this arrangement for exactly this reason and a second copy is
98
+ // a second place for the `.ts`/`.js` lesson above to be relearned. The two
99
+ // modes are separate constants (`DIAGRAM_MODE`, `MATH_MODE`) — the costs and
100
+ // the evidence differ, so one switch must not carry the other's consequences —
101
+ // but the mechanism that keeps an unselected runtime out of `dist/` is one.
102
+ const target = new RegExp(String.raw`[\\/]src[\\/]scripts[\\/]${runtime}\.(?:ts|js)$`);
103
+ return {
104
+ name: `anc:${runtime}-mode`,
105
+ // Ahead of Vite's own resolver, so the redirect happens before the module
106
+ // is loaded and its imports crawled.
107
+ enforce: 'pre',
108
+ async resolveId(source, importer, options) {
109
+ if (mode === 'client') return null;
110
+ const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
111
+ return resolved !== null && target.test(resolved.id) ? stub : null;
112
+ },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * THE ONE PLACE A SITE'S PUBLIC ORIGIN IS WRITTEN DOWN.
118
+ *
119
+ * **`.localhost` rather than `.invalid`, and the change is the point of
120
+ * TK-31.** RFC 6761 reserves `.localhost` and requires resolvers to map the
121
+ * name *and all its subdomains* to loopback, so this is a name that cannot
122
+ * resolve to anybody else's server — the property `.invalid` was chosen for —
123
+ * and that additionally *works*: a preview build's canonical links, feed, and
124
+ * sitemap all point at a local origin a browser can actually follow.
125
+ *
126
+ * The value it replaced was this project's own name under `.invalid`, which was safe and
127
+ * was still one owner's name on every stranger's site. That is the whole of
128
+ * plan decision D2. `.invalid` was never wrong about *safety* — TK-24's gate
129
+ * was right that a reserved name cannot misdirect a crawler — it was wrong
130
+ * about *whose*, and a default that belongs to nobody is what this ticket owes
131
+ * every user who configures nothing.
132
+ *
133
+ * **Deliberately no port**, per plan §5.5: a canonical URL, a feed id, and a
134
+ * `<loc>` should not carry a development port, and they are not deployable
135
+ * anyway. `astro dev` serves `http://localhost:4321/` regardless; what this
136
+ * governs is the absolute URLs written *into* the built documents.
137
+ *
138
+ * **This is a preview origin, not a publishable one, and nothing here can tell
139
+ * the difference.** A site built with it is complete and correct and obviously
140
+ * local at a glance — which is a property `.invalid` gave up by producing URLs
141
+ * that looked plausible. Refusing to *deploy* it belongs to the deploy step,
142
+ * which this repository does not have and does not perform.
143
+ *
144
+ * **A user's own `publish.config.yaml` overrides it, and this stays the
145
+ * fallback rather than moving into the loader.** That is the point of the
146
+ * arrangement rather than an accident of it: a build of *this* repository, and
147
+ * a stranger's build before they have configured anything, both need an origin,
148
+ * and there is exactly one place to read it from. `scripts/load-config.ts`
149
+ * deliberately returns `undefined` for an unconfigured origin — a default there
150
+ * would be a second home for this value. `tests/metadata.test.ts` fails if the
151
+ * host appears in any file under `src/`, `scripts/`, `tests/`, or `public/`
152
+ * other than this one.
153
+ */
154
+ const DEFAULT_ORIGIN = 'http://publish.localhost/';
155
+
156
+ /**
157
+ * The user's configuration, read once at config evaluation.
158
+ *
159
+ * `configForBuild` resolves the directory from `PUBLISH_CONFIG_DIR`, falling
160
+ * back to the working directory. A packaged build has already changed its
161
+ * working directory to the installed package by the time this runs, which is
162
+ * why the variable exists at all; `bin/anc.mjs` documents the
163
+ * same boundary for `CONTENT_ARTIFACT`. For a build of *this* repository the
164
+ * fallback is the repository root — so a `publish.config.yaml` committed here
165
+ * would reconfigure this repository's own build, which is the honest
166
+ * description and not a bug: this repository is the tool, and there is no such
167
+ * file in it.
168
+ *
169
+ * A malformed config stops the build before a single page is generated — the
170
+ * ordering `docs/plans/ssg-generalisation-plan.md` §4.3 asks for, where a config
171
+ * error cannot be masked by a build that otherwise succeeded.
172
+ *
173
+ * **Caught rather than thrown, because this module scope has no boundary above
174
+ * it that knows how to print one.** `bin/anc.mjs` prints a
175
+ * `BuildFailure`'s own message and nothing else; a `astro build` or `astro dev`
176
+ * run directly has only Astro's config loader, which was measured printing the
177
+ * composed message *plus* a stack trace carrying four absolute host paths —
178
+ * `at refuse (Q:/…/scripts/load-config.ts:313:8)` and three more. TK-25 §2.3
179
+ * forbids a discovered filesystem path on that stream, and `astro dev`
180
+ * evaluates this same scope, so the leak is not confined to the packaged path.
181
+ * Printing the message and exiting keeps the diagnostic and drops the trace.
182
+ */
183
+ function loadUserConfig() {
184
+ try {
185
+ return configForBuild();
186
+ } catch (error) {
187
+ // Only a message composed under the disclosure rule may be printed. Anything
188
+ // else is a string this project did not write, on a world-readable stream.
189
+ if (error instanceof Error && error.name === 'BuildFailure') {
190
+ console.error(error.message);
191
+ } else {
192
+ console.error('the configuration file could not be read');
193
+ }
194
+ process.exit(1);
195
+ }
196
+ }
197
+
198
+ const config = loadUserConfig();
199
+
200
+ /**
201
+ * Hand the configured title to the page modules.
202
+ *
203
+ * `src/lib/site.ts` reads it from here and cannot read it from anywhere else:
204
+ * importing `scripts/load-config.ts` from `src/lib/` breaks the build, because
205
+ * Astro evaluates that module out of `dist/.prerender/` and the loader's
206
+ * dependency reads `../package.json` relative to its own URL — measured,
207
+ * `ENOENT: … dist\.prerender\package.json` at "generating static routes". The
208
+ * variable is the same seam `CONTENT_ARTIFACT` already uses across the same
209
+ * boundary, and that module documents the mechanism at length.
210
+ *
211
+ * Set here rather than in `bin/anc.mjs` because this is the
212
+ * one scope that runs for *every* way a build starts — the packaged binary,
213
+ * `astro build` in a checkout, and `astro dev` — and each of them needs the
214
+ * title. `config.title` always holds a value: `loadConfig` applies its own
215
+ * default when the user configured nothing, which is why there is no `??` here
216
+ * and why an unconfigured build still gets a legible name.
217
+ *
218
+ * **The name is spelled out rather than imported, deliberately.** Importing
219
+ * `SITE_TITLE_VARIABLE` from `src/lib/site.ts` would evaluate that module here,
220
+ * at config time, *before* this line runs — so its `SITE_NAME` would be
221
+ * computed from an unset variable, giving one process two module instances that
222
+ * disagree about the site's name. A literal has no such ordering. The cost is a
223
+ * second copy of the spelling, and it is paid the way this repository already
224
+ * pays it for `theme-init.js`: `tests/site-identity.test.ts` reads both files
225
+ * and fails if they name different variables, so a rename cannot half-apply.
226
+ */
227
+ process.env['PUBLISH_SITE_TITLE'] = config.title;
228
+
229
+ // https://astro.build/config
230
+ export default defineConfig({
231
+ site: config.origin ?? DEFAULT_ORIGIN,
232
+ output: 'static',
233
+ trailingSlash: 'always',
234
+ vite: {
235
+ define: {
236
+ __ANC_SNAPSHOT_BINDING__: JSON.stringify(readStaged('binding.json')),
237
+ __ANC_WASM_BINDING__: JSON.stringify(STAGED_WASM),
238
+ __ANC_WASM_MODULE_URL__: JSON.stringify(STAGED_WASM?.['moduleUrl'] ?? wasmModuleUrl()),
239
+ },
240
+ plugins: [
241
+ clientRuntimePlugin('diagram', DIAGRAM_MODE),
242
+ clientRuntimePlugin('math', MATH_MODE),
243
+ vendorProvenancePlugin(fileURLToPath(new URL('.', import.meta.url))),
244
+ ],
245
+ build: {
246
+ // Load-bearing for the CSP, not a size preference. Above 0, Vite inlines
247
+ // a small `?url` asset as a `data:` URL — and `src/scripts/theme-init.js`
248
+ // is exactly that, imported by `Layout.astro`. A `data:` script violates
249
+ // `script-src 'self'`, so the theme would never apply. The
250
+ // `every script is loaded from this origin` test in
251
+ // `tests/built-output.test.ts` fails if this is ever raised.
252
+ assetsInlineLimit: 0,
253
+ },
254
+ },
255
+ });