@wxxb789/anc 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's configuration file, and the one place its defaults are written
|
|
3
|
+
* down.
|
|
4
|
+
*
|
|
5
|
+
* A stranger runs this tool on their own notes repository. Everything here is
|
|
6
|
+
* therefore judged by one question — *what happens to something you typed
|
|
7
|
+
* wrong* — because the alternative is a user who believes they configured an
|
|
8
|
+
* exclusion and did not, and whose next push publishes the note they withheld.
|
|
9
|
+
*
|
|
10
|
+
* ## The format: YAML, and the measurement that chose it
|
|
11
|
+
*
|
|
12
|
+
* Four candidates, each measured on this host (Node 24.18.1, `yaml` 2.9.0)
|
|
13
|
+
* rather than ranked by taste.
|
|
14
|
+
*
|
|
15
|
+
* **A `.ts` module — the plan's choice at §4.1 — is rejected on three
|
|
16
|
+
* measurements, and the third is disqualifying.**
|
|
17
|
+
*
|
|
18
|
+
* 1. It loads in every repository shape, as the plan says: measured, a
|
|
19
|
+
* `publish.config.ts` importing a type from an unresolvable specifier loads
|
|
20
|
+
* with no `package.json`, with a CommonJS one, and with `"type": "module"`.
|
|
21
|
+
* So the plan's own supporting measurement reproduces. The problems are
|
|
22
|
+
* elsewhere.
|
|
23
|
+
* 2. **It prints the user's absolute path to stderr, and the print cannot be
|
|
24
|
+
* intercepted.** Measured, in the middle case above — a notes repository
|
|
25
|
+
* holding a `package.json` with no `"type"`, which is what `npm i` for a
|
|
26
|
+
* local preview leaves behind — Node emits
|
|
27
|
+
* `[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of
|
|
28
|
+
* file:///…/publish.config.ts is not specified`, containing the user's own
|
|
29
|
+
* directory. A `process.on('warning')` listener saw **zero** warnings while
|
|
30
|
+
* stderr carried it, so it is not interceptable; only the process-wide
|
|
31
|
+
* `NODE_NO_WARNINGS=1` suppresses it, which silences every other warning
|
|
32
|
+
* too. TK-25 §2.3 forbids any discovered filesystem path on that stream, and
|
|
33
|
+
* a format whose mere loading violates it cannot be made to comply.
|
|
34
|
+
* 3. **Validating it is not possible, because the value can change after it is
|
|
35
|
+
* validated.** Measured: a config whose `exclude` is a getter returned
|
|
36
|
+
* `['drafts/**']` to the validator and `['nothing-at-all/**']` to the next
|
|
37
|
+
* reader. A `Proxy` reports whatever key set it likes to `Object.keys`. So
|
|
38
|
+
* the unknown-key check — the thing this ticket exists to provide — is
|
|
39
|
+
* advisory against a hostile or merely clever file, and the failure is
|
|
40
|
+
* silent in the publishing direction. Executing the config also means an
|
|
41
|
+
* arbitrary throw: measured, a config calling `readFileSync` on a missing
|
|
42
|
+
* path produced `ENOENT: … open 'Q:\etc\hostname'`, a host path in a message
|
|
43
|
+
* nothing here composed.
|
|
44
|
+
*
|
|
45
|
+
* **TOML is rejected on cost.** Node has no TOML parser — measured,
|
|
46
|
+
* `import('node:toml')` fails `ERR_UNKNOWN_BUILTIN_MODULE` — so it is a new
|
|
47
|
+
* dependency, and the ticket's rule is that a new dependency must be shown to
|
|
48
|
+
* beat what is already installed. It does not: `yaml` is already a declared
|
|
49
|
+
* dependency, added by TK-26 for frontmatter, and carries comments too.
|
|
50
|
+
*
|
|
51
|
+
* **JSON is rejected on comments.** This file's main content is a list of
|
|
52
|
+
* exclusion patterns, which is exactly the content a user wants to annotate
|
|
53
|
+
* (`# not ready yet`), and JSON has no way to. Two lesser measurements agree:
|
|
54
|
+
* `JSON.parse` silently keeps the *last* of two duplicate keys — so a user who
|
|
55
|
+
* writes `exclude` twice loses the first list with no signal — and its parse
|
|
56
|
+
* error quotes the file's own bytes back (`Unexpected token ']', ..."ts/**",\n
|
|
57
|
+
* ]\n}" is not valid JSON`), which puts pattern text on a stream TK-25 §2.3
|
|
58
|
+
* bans it from.
|
|
59
|
+
*
|
|
60
|
+
* **YAML wins, and the trade is real rather than nominal.** A user editing
|
|
61
|
+
* notes already writes YAML in their frontmatter, `yaml` is already installed,
|
|
62
|
+
* comments work, and a duplicate key is a *loud* `DUPLICATE_KEY` error rather
|
|
63
|
+
* than a silent overwrite. What it costs is that YAML's plain scalars are a
|
|
64
|
+
* language, and a glob pattern is not written in it. Measured over 34 realistic
|
|
65
|
+
* patterns written unquoted:
|
|
66
|
+
*
|
|
67
|
+
* ```
|
|
68
|
+
* "*.md" ReferenceError: Unresolved alias "!README.md" -> ""
|
|
69
|
+
* "{a,b}/**" UNEXPECTED_TOKEN "&draft/**" -> null
|
|
70
|
+
* "@work/**" BAD_SCALAR_START "- dash/**" -> ["dash/**"]
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Three of those are rewritten with no error at all, and the middle one is the
|
|
74
|
+
* worst available: `!README.md` is the exact spelling `docs/plans/…-plan.md`
|
|
75
|
+
* §2.2 gives for re-including the repository README, and plain YAML turns it
|
|
76
|
+
* into the empty string.
|
|
77
|
+
*
|
|
78
|
+
* **So the checks below are not defensive tidying; they are what makes the
|
|
79
|
+
* format safe.** With every parse *warning* promoted to a failure, every member
|
|
80
|
+
* required to be a non-empty untrimmed string, and every eaten `!` tag refused,
|
|
81
|
+
* the same 34 patterns measure **21 exact, 13 loud, and zero silent** unquoted,
|
|
82
|
+
* and **34 exact, zero loud, zero silent** quoted. Zero silent is the property
|
|
83
|
+
* the format was chosen for.
|
|
84
|
+
*
|
|
85
|
+
* Which of the three mechanisms catches which case was **measured**, after a
|
|
86
|
+
* mutation removed the warning promotion and every gate stayed green — the
|
|
87
|
+
* cases it alone catches are a discarded tag *outside* `exclude`, where the
|
|
88
|
+
* surviving value is a valid string and no other check can see that anything
|
|
89
|
+
* happened. {@link CORRUPTION_GATE} records the split.
|
|
90
|
+
*
|
|
91
|
+
* ## What a message may carry
|
|
92
|
+
*
|
|
93
|
+
* TK-25 §2.1 admits a *config location* — filename, 1-indexed line, array index
|
|
94
|
+
* — and a literal of this tool's own source, and admits no other user-derived
|
|
95
|
+
* string. Two consequences, and the split between them is the whole rule:
|
|
96
|
+
*
|
|
97
|
+
* - **A known key is a literal of this file's own table**, so `exclude` and
|
|
98
|
+
* `origin` are printed in full.
|
|
99
|
+
* - **An unknown key is user-authored text**, and `clients/acme/2026-renewal.md`
|
|
100
|
+
* is a valid YAML key. So a misspelling is reported by *line*, with the
|
|
101
|
+
* suggestion drawn from {@link KNOWN_KEYS}, and its bytes go to the report's
|
|
102
|
+
* private half. The user opens their own file at that line and reads what
|
|
103
|
+
* they wrote there — which is strictly more actionable than the key alone,
|
|
104
|
+
* since the line is where the fix goes.
|
|
105
|
+
*
|
|
106
|
+
* A *value* is never echoed for the same reason; what is printed is the type
|
|
107
|
+
* that was found, which is a closed set of literals.
|
|
108
|
+
*/
|
|
109
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
110
|
+
import { join } from 'node:path';
|
|
111
|
+
import { LineCounter, parseAllDocuments } from 'yaml';
|
|
112
|
+
import { FIELD_LIMITS, isLanguageTag } from "../src/lib/schema.js";
|
|
113
|
+
import { BuildFailure } from "./write-report.js";
|
|
114
|
+
/**
|
|
115
|
+
* The one filename, at the root of the content directory.
|
|
116
|
+
*
|
|
117
|
+
* **One name and no search order**, per plan §4.1: a search order is a place
|
|
118
|
+
* for a file to be silently not found. What the plan does not say, and what
|
|
119
|
+
* {@link NEAR_MISS_NAMES} adds, is that the same argument condemns silence
|
|
120
|
+
* about a *near* name — a user who wrote `publish.config.yml` has configured
|
|
121
|
+
* nothing and been told nothing, which is the failure mode this whole module
|
|
122
|
+
* exists to prevent, one level up from a misspelled key.
|
|
123
|
+
*
|
|
124
|
+
* **At the content directory rather than the invocation directory**, because
|
|
125
|
+
* the patterns inside it are matched against content-directory-relative paths
|
|
126
|
+
* (`scripts/markdown-to-artifact.ts`). Anchoring the file where its own
|
|
127
|
+
* patterns are anchored means `drafts/**` is relative to the directory the file
|
|
128
|
+
* sits in, which is the only reading a user has to be told once.
|
|
129
|
+
*
|
|
130
|
+
* The stem carries no project name — the same decision as TK-25's
|
|
131
|
+
* `publish-report/` — and it matches the `CONFIG_LOCATION` shape
|
|
132
|
+
* `scripts/markdown-to-artifact.ts` requires before it will put a caller's
|
|
133
|
+
* source label on a stream.
|
|
134
|
+
*/
|
|
135
|
+
export const CONFIG_FILENAME = 'publish.config.yaml';
|
|
136
|
+
/**
|
|
137
|
+
* Spellings that are not the filename, and that mean the user meant it.
|
|
138
|
+
*
|
|
139
|
+
* Each is refused by name rather than accepted, because accepting them is a
|
|
140
|
+
* search order. The TypeScript spelling is on the list because it is the one
|
|
141
|
+
* `docs/plans/ssg-generalisation-plan.md` §4.1 names, so it is what a reader of
|
|
142
|
+
* the plan — or of anything derived from it — will write first.
|
|
143
|
+
*
|
|
144
|
+
* Every entry is a literal of this file's own source, so printing one discloses
|
|
145
|
+
* nothing; that is what lets the refusal name the file it found.
|
|
146
|
+
*
|
|
147
|
+
* **Built by joining a stem to an extension rather than written out, and that
|
|
148
|
+
* is a packaging constraint rather than a style.** `scripts/compile-package.ts`
|
|
149
|
+
* rewrites every quoted `….ts` string naming a real file of ours to `.js` on
|
|
150
|
+
* the way into the tarball, and `tests/packaging.test.ts` then fails on any
|
|
151
|
+
* `.ts` string that survives. Both are right to: an unrewritten specifier is a
|
|
152
|
+
* broken packaged build. But these are *the user's* filenames, not modules —
|
|
153
|
+
* there is nothing to rewrite, and a quoted TypeScript spelling here is
|
|
154
|
+
* indistinguishable from a stale import to a tool reading strings. Measured:
|
|
155
|
+
* the gate failed on exactly that, once for the array entry and a second time
|
|
156
|
+
* for a quoted mention of it inside this very comment, since the gate scans
|
|
157
|
+
* bytes rather than syntax. Joining the parts keeps the value identical and the
|
|
158
|
+
* hazard absent, and nothing in this file quotes the spelling any more.
|
|
159
|
+
*/
|
|
160
|
+
export const NEAR_MISS_NAMES = [
|
|
161
|
+
...['yml', 'json', 'ts', 'js', 'mjs', 'toml'].map((extension) => `publish.config.${extension}`),
|
|
162
|
+
...['yaml', 'yml', 'json'].map((extension) => `publish.${extension}`),
|
|
163
|
+
// Measured as silently ignored before they were listed: a hyphen for the dot,
|
|
164
|
+
// an abbreviated stem, and the bare noun are each a spelling a user reaches
|
|
165
|
+
// for first, and each configured nothing while looking configured.
|
|
166
|
+
...['yaml', 'yml'].flatMap((extension) => [`publish-config.${extension}`, `publish.conf.${extension}`]),
|
|
167
|
+
];
|
|
168
|
+
/** Every key the file may carry. The order is the order they are documented in. */
|
|
169
|
+
const KNOWN_KEYS = ['title', 'origin', 'exclude', 'language'];
|
|
170
|
+
/**
|
|
171
|
+
* The site name a repository with no configuration gets.
|
|
172
|
+
*
|
|
173
|
+
* Deliberately not `src/lib/site.ts`'s `SITE_NAME`. That value is this
|
|
174
|
+
* project's own name, and a stranger's first build must not put it in their
|
|
175
|
+
* browser tab — which is the residue plan decision D2 exists to remove. A
|
|
176
|
+
* generic noun is legible, obviously a placeholder, and belongs to nobody.
|
|
177
|
+
*/
|
|
178
|
+
export const DEFAULT_TITLE = 'Notes';
|
|
179
|
+
/**
|
|
180
|
+
* Every default, in one object, so "what does a repository with no config get"
|
|
181
|
+
* is answered by reading one declaration.
|
|
182
|
+
*
|
|
183
|
+
* `origin` is absent by design; see {@link LoadedConfig}.
|
|
184
|
+
*
|
|
185
|
+
* Frozen, and {@link freshDefaults} is what callers actually receive. A spread
|
|
186
|
+
* of this object is a *shallow* copy, so every caller would share one `exclude`
|
|
187
|
+
* array — measured, pushing to the array returned by one `loadConfig` changed
|
|
188
|
+
* what the next one returned. `readonly string[]` is a compile-time claim only,
|
|
189
|
+
* and `exclusionOptions` hands the array straight to `discover`.
|
|
190
|
+
*/
|
|
191
|
+
export const DEFAULTS = Object.freeze({
|
|
192
|
+
title: DEFAULT_TITLE,
|
|
193
|
+
origin: undefined,
|
|
194
|
+
exclude: Object.freeze([]),
|
|
195
|
+
language: undefined,
|
|
196
|
+
});
|
|
197
|
+
/** The defaults, as a value a caller may keep and a later caller cannot see. */
|
|
198
|
+
function freshDefaults() {
|
|
199
|
+
return { ...DEFAULTS, exclude: [] };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* An object with no prototype surprises, which is what a config table must be.
|
|
203
|
+
*
|
|
204
|
+
* The same shape as `src/lib/schema.ts`'s `isPlainObject`, plus the prototype
|
|
205
|
+
* test that `Set` and `Map` need — see the call site for the measurement.
|
|
206
|
+
*/
|
|
207
|
+
function isPlainObject(value) {
|
|
208
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
209
|
+
return false;
|
|
210
|
+
const prototype = Object.getPrototypeOf(value);
|
|
211
|
+
return prototype === Object.prototype || prototype === null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The patterns a plain YAML document rewrites without saying so, with what it
|
|
215
|
+
* makes of them.
|
|
216
|
+
*
|
|
217
|
+
* Exported so the gate that proves the loader refuses them is written against
|
|
218
|
+
* the measurement that motivated it rather than against a rediscovered example.
|
|
219
|
+
*
|
|
220
|
+
* **Which mechanism catches which is the difference between a gate and a
|
|
221
|
+
* coincidence, so it is recorded rather than assumed.** It had to be: a
|
|
222
|
+
* mutation that removed the warning promotion left every gate green, because
|
|
223
|
+
* the members it then admitted were all caught by something else.
|
|
224
|
+
*
|
|
225
|
+
* ```
|
|
226
|
+
* "!README.md" TAG_RESOLVE_FAILED -> "" emptiness check too
|
|
227
|
+
* "!private drafts/**" TAG_RESOLVE_FAILED -> "drafts/**" promotion ONLY
|
|
228
|
+
* "&draft/**" no warning -> null type check only
|
|
229
|
+
* "- dash/**" no warning -> ["dash/**"] type check only
|
|
230
|
+
* "! README.md" no warning -> "README.md" tag rule ONLY
|
|
231
|
+
* ```
|
|
232
|
+
*
|
|
233
|
+
* The second and last rows are each the sole case of their mechanism, and the
|
|
234
|
+
* difference between them is worth reading twice: a *named* tag is reported as
|
|
235
|
+
* `'!private'` and the non-specific one as `'!'`, so the tag rule sees only the
|
|
236
|
+
* second and the promotion is what refuses the first. A rule written against
|
|
237
|
+
* `tag.startsWith('!')` would collapse them and lose the distinction the two
|
|
238
|
+
* gates rest on.
|
|
239
|
+
*
|
|
240
|
+
* The last row is the worst of the five. `! README.md` — a negation written
|
|
241
|
+
* with the space a human naturally puts after punctuation — parses to
|
|
242
|
+
* `README.md` with no error and no warning, and that is not a corrupted pattern
|
|
243
|
+
* but an **inverted** one: measured, `!README.md` re-includes the repository
|
|
244
|
+
* README and `README.md` excludes it. The user asked for a file back and
|
|
245
|
+
* silently told the tool to withhold it. Nothing about the parsed *value* can
|
|
246
|
+
* see it; {@link taggedExcludeMembers} reads the node's tag instead.
|
|
247
|
+
*
|
|
248
|
+
* Note what this list does **not** cover, and why the promotion has a gate of
|
|
249
|
+
* its own beyond the row above: a discarded tag on `title` or `origin` leaves a
|
|
250
|
+
* valid string that no check on the value can question, and outside `exclude`
|
|
251
|
+
* the tag rule does not look.
|
|
252
|
+
*/
|
|
253
|
+
export const CORRUPTION_GATE = [
|
|
254
|
+
'!README.md',
|
|
255
|
+
'!private drafts/**',
|
|
256
|
+
'&draft/**',
|
|
257
|
+
'- dash/**',
|
|
258
|
+
'! README.md',
|
|
259
|
+
];
|
|
260
|
+
/** What was found where a type was expected. A closed set of literals. */
|
|
261
|
+
function typeName(value) {
|
|
262
|
+
if (value === null)
|
|
263
|
+
return 'null';
|
|
264
|
+
if (Array.isArray(value))
|
|
265
|
+
return 'a list';
|
|
266
|
+
if (typeof value === 'string')
|
|
267
|
+
return 'a string';
|
|
268
|
+
if (typeof value === 'number')
|
|
269
|
+
return 'a number';
|
|
270
|
+
if (typeof value === 'boolean')
|
|
271
|
+
return 'a boolean';
|
|
272
|
+
if (typeof value === 'object')
|
|
273
|
+
return 'a table';
|
|
274
|
+
return 'nothing';
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* How far apart two strings are, capped at the only distances worth reporting.
|
|
278
|
+
*
|
|
279
|
+
* The two-row form rather than a full matrix, because the matrix is the version
|
|
280
|
+
* that gets written wrong. Used only to choose a suggestion out of
|
|
281
|
+
* {@link KNOWN_KEYS}, so its output never reaches a message.
|
|
282
|
+
*/
|
|
283
|
+
function editDistance(from, to) {
|
|
284
|
+
let previous = Array.from({ length: to.length + 1 }, (_, index) => index);
|
|
285
|
+
for (let i = 0; i < from.length; i += 1) {
|
|
286
|
+
const current = [i + 1];
|
|
287
|
+
for (let j = 0; j < to.length; j += 1) {
|
|
288
|
+
current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (from[i] === to[j] ? 0 : 1)));
|
|
289
|
+
}
|
|
290
|
+
previous = current;
|
|
291
|
+
}
|
|
292
|
+
return previous[to.length] ?? to.length;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The known key a misspelling most likely meant, or `undefined`.
|
|
296
|
+
*
|
|
297
|
+
* Two rules, because the two common mistakes have different shapes.
|
|
298
|
+
* Containment catches an *elaboration* — `siteTitle` for `title`, `excludes`
|
|
299
|
+
* for `exclude` — which is four edits away and would never clear a distance
|
|
300
|
+
* threshold. Distance catches a *typo*: `exclud`, `orgin`, `titel`.
|
|
301
|
+
*
|
|
302
|
+
* Compared on a folded form so that `Origin` and `EXCLUDE` are recognised;
|
|
303
|
+
* the file's keys are lowercase and a user who capitalised one has still made
|
|
304
|
+
* a spelling mistake rather than named something else.
|
|
305
|
+
*
|
|
306
|
+
* The containment rule needs a length floor, because `known.includes(folded)`
|
|
307
|
+
* is true of any substring: measured, a key of `e` was suggested as `title`,
|
|
308
|
+
* `x` as `exclude`, and `o` as `origin`. A confidently wrong suggestion tells
|
|
309
|
+
* the user to rename a key they did not mean to write, which is worse than
|
|
310
|
+
* listing the three.
|
|
311
|
+
*/
|
|
312
|
+
function suggestionFor(key) {
|
|
313
|
+
const folded = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
314
|
+
if (folded === '')
|
|
315
|
+
return undefined;
|
|
316
|
+
for (const known of KNOWN_KEYS) {
|
|
317
|
+
if (folded.length >= 3 && (folded.includes(known) || known.includes(folded)))
|
|
318
|
+
return known;
|
|
319
|
+
}
|
|
320
|
+
for (const known of KNOWN_KEYS) {
|
|
321
|
+
if (editDistance(folded, known) <= 2)
|
|
322
|
+
return known;
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* How many issues one refusal prints before it stops enumerating.
|
|
328
|
+
*
|
|
329
|
+
* Measured: a list of 5,000 wrong-typed members composes a 303,940-character
|
|
330
|
+
* message. Nothing in it is a disclosure, but a stream nobody can read is a
|
|
331
|
+
* diagnostic nobody uses, and the remainder is reported as a count — which is a
|
|
332
|
+
* class-A integer and so costs nothing.
|
|
333
|
+
*/
|
|
334
|
+
const MAX_REPORTED_ISSUES = 50;
|
|
335
|
+
/**
|
|
336
|
+
* Every issue found in one file, thrown as one failure.
|
|
337
|
+
*
|
|
338
|
+
* The shape mirrors `src/lib/schema.ts`'s `ContentValidationError` — the file,
|
|
339
|
+
* a count, then one indented line per issue — rather than inventing a second
|
|
340
|
+
* way for this project to reject malformed input. Accumulating rather than
|
|
341
|
+
* throwing on the first is the same decision for the same reason: a user
|
|
342
|
+
* correcting three mistakes one build at a time is three builds.
|
|
343
|
+
*
|
|
344
|
+
* A {@link BuildFailure} rather than a new class, so the message is printable:
|
|
345
|
+
* `bin/anc.mjs` prints only errors composed under the
|
|
346
|
+
* disclosure rule, and everything else becomes `build failed`. The private half
|
|
347
|
+
* carries what the public half may not — and the private half is **not**
|
|
348
|
+
* truncated, because the file it goes to is where a user reads the rest.
|
|
349
|
+
*/
|
|
350
|
+
function refuse(issues, details) {
|
|
351
|
+
const shown = issues.slice(0, MAX_REPORTED_ISSUES);
|
|
352
|
+
const remainder = issues.length - shown.length;
|
|
353
|
+
throw new BuildFailure('config-invalid', `${CONFIG_FILENAME}: ${issues.length} configuration violation${issues.length === 1 ? '' : 's'}\n` +
|
|
354
|
+
shown.map((issue) => ` - ${issue}`).join('\n') +
|
|
355
|
+
(remainder === 0 ? '' : `\n … and ${remainder} more`), details.length === 0 ? issues.join('; ') : details.join('; '));
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* An absolute `http(s)` origin and nothing more.
|
|
359
|
+
*
|
|
360
|
+
* The path check is the one that is not obvious and is the one that matters.
|
|
361
|
+
* `src/lib/site.ts` forms every canonical URL, feed id, and sitemap `<loc>` as
|
|
362
|
+
* `new URL(route, site)` where `route` is root-absolute — so a configured
|
|
363
|
+
* `https://example.com/notes/` has its `/notes/` **discarded**, and the site
|
|
364
|
+
* builds cleanly while every public URL in it is wrong. Astro spells that
|
|
365
|
+
* arrangement `base:`, which is a separate option and a separate decision; what
|
|
366
|
+
* this module must not do is accept a value that produces a valid, wrong site.
|
|
367
|
+
*
|
|
368
|
+
* A non-`http` scheme is refused for the same class of reason: a canonical link
|
|
369
|
+
* is dereferenced by a crawler.
|
|
370
|
+
*/
|
|
371
|
+
function checkOrigin(value, line, issues, details) {
|
|
372
|
+
const at = line === undefined ? 'origin' : `origin (line ${line})`;
|
|
373
|
+
if (typeof value !== 'string') {
|
|
374
|
+
issues.push(`${at}: must be a string holding an absolute URL, found ${typeName(value)}`);
|
|
375
|
+
details.push(`origin = ${JSON.stringify(value)}`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// Before `new URL`, because `URL` *tolerates* surrounding whitespace and a
|
|
379
|
+
// tab inside the host — measured, `" https://example.com/ "` and
|
|
380
|
+
// `"https://exa\tmple.com/"` both parse and both pass every check below, and
|
|
381
|
+
// the raw string is what reaches `site:`. Refusing rather than trimming, for
|
|
382
|
+
// the reason `checkExclude` refuses a padded pattern: a silent rewrite is the
|
|
383
|
+
// failure mode this module exists to prevent.
|
|
384
|
+
if (value !== value.trim() || /\s/.test(value)) {
|
|
385
|
+
issues.push(`${at}: must not contain whitespace`);
|
|
386
|
+
details.push(`origin = ${JSON.stringify(value)}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
let url;
|
|
390
|
+
try {
|
|
391
|
+
url = new URL(value);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// The value is not echoed: an origin is not itself sensitive, but a
|
|
395
|
+
// *mistyped* one is whatever the user's editor left in the buffer, and this
|
|
396
|
+
// reaches a workflow log. The line number is where the fix goes.
|
|
397
|
+
issues.push(`${at}: must be an absolute URL with a scheme, such as https://notes.example.com/`);
|
|
398
|
+
details.push(`origin = ${JSON.stringify(value)}`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
402
|
+
issues.push(`${at}: must use the https or http scheme`);
|
|
403
|
+
details.push(`origin = ${JSON.stringify(value)}`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Credentials in the origin are the sharpest case of "builds cleanly and is
|
|
407
|
+
// wrong": measured, `https://alice:s3cret@example.com/` passes every other
|
|
408
|
+
// check, and `new URL('/notes/a/', origin)` then puts the password into every
|
|
409
|
+
// canonical link, feed id, and sitemap entry of the published site.
|
|
410
|
+
if (url.username !== '' || url.password !== '') {
|
|
411
|
+
issues.push(`${at}: must not carry a username or password — they would appear in every published URL`);
|
|
412
|
+
// Deliberately not the value, which is the one string here that really is a
|
|
413
|
+
// secret. The line is enough to find it.
|
|
414
|
+
details.push('origin carried credentials');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
|
|
418
|
+
issues.push(`${at}: must be a bare origin with no path, query, or fragment — every ` +
|
|
419
|
+
'canonical URL, feed id, and sitemap entry is resolved against it as a ' +
|
|
420
|
+
'root-absolute route, so a path here is silently discarded');
|
|
421
|
+
details.push(`origin = ${JSON.stringify(value)}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** Whether a validated origin names only the current host rather than a public server. */
|
|
425
|
+
export function isLoopbackOrigin(origin) {
|
|
426
|
+
let hostname;
|
|
427
|
+
try {
|
|
428
|
+
hostname = new URL(origin).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost'))
|
|
434
|
+
return true;
|
|
435
|
+
if (hostname === '0.0.0.0' || hostname.startsWith('127.'))
|
|
436
|
+
return true;
|
|
437
|
+
if (hostname === '::' || hostname === '::1')
|
|
438
|
+
return true;
|
|
439
|
+
if (!hostname.startsWith('::ffff:'))
|
|
440
|
+
return false;
|
|
441
|
+
const mapped = hostname.slice('::ffff:'.length);
|
|
442
|
+
if (mapped === '0.0.0.0' || mapped.startsWith('127.') || mapped === '0:0')
|
|
443
|
+
return true;
|
|
444
|
+
const first = Number.parseInt(mapped.split(':')[0] ?? '', 16);
|
|
445
|
+
return Number.isFinite(first) && first >= 0x7f00 && first <= 0x7fff;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A non-empty site title carrying nothing XML cannot represent.
|
|
449
|
+
*
|
|
450
|
+
* The control-character rule is not belt-and-braces. `src/lib/site.ts` records
|
|
451
|
+
* that XML 1.0's `Char` production admits no escape for a C0 control, so a
|
|
452
|
+
* title carrying one produces a feed no reader can parse — and that the content
|
|
453
|
+
* contract checks `title` for emptiness and privacy markers but *not* for these
|
|
454
|
+
* characters. This is the site's own title rather than a note's, so it is the
|
|
455
|
+
* one such string this module can close.
|
|
456
|
+
*/
|
|
457
|
+
function checkTitle(value, line, issues, details) {
|
|
458
|
+
const at = line === undefined ? 'title' : `title (line ${line})`;
|
|
459
|
+
if (typeof value !== 'string') {
|
|
460
|
+
issues.push(`${at}: must be a string, found ${typeName(value)}`);
|
|
461
|
+
details.push(`title = ${JSON.stringify(value)}`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (value.trim() === '') {
|
|
465
|
+
issues.push(`${at}: must not be empty`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
// Refused rather than trimmed, which is the same rule `checkExclude` applies
|
|
469
|
+
// to a padded pattern. YAML preserves the padding a user quoted deliberately,
|
|
470
|
+
// and quietly discarding it is a silent rewrite — the thing this module is
|
|
471
|
+
// built to prevent, applied inconsistently if it happened here.
|
|
472
|
+
if (value !== value.trim()) {
|
|
473
|
+
issues.push(`${at}: must not have leading or trailing whitespace`);
|
|
474
|
+
details.push(`title = ${JSON.stringify(value)}`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// oxlint-disable-next-line no-control-regex -- refusing them is the point
|
|
478
|
+
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(value)) {
|
|
479
|
+
issues.push(`${at}: must not contain a control character — XML has no escape for one, so the feed would not parse`);
|
|
480
|
+
details.push(`title = ${JSON.stringify(value)}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* A BCP 47 tag, validated exactly as a note's own `language:` is.
|
|
485
|
+
*
|
|
486
|
+
* The same shape (`isLanguageTag`) and the same 35-character registry ceiling
|
|
487
|
+
* the content contract applies, so a value accepted here is one every consumer
|
|
488
|
+
* of a note's language already accepts. Refused rather than trimmed or
|
|
489
|
+
* case-folded, for the reason {@link checkTitle} gives.
|
|
490
|
+
*/
|
|
491
|
+
function checkLanguage(value, line, issues, details) {
|
|
492
|
+
const at = line === undefined ? 'language' : `language (line ${line})`;
|
|
493
|
+
if (typeof value !== 'string') {
|
|
494
|
+
issues.push(`${at}: must be a string holding a BCP 47 language tag, found ${typeName(value)}`);
|
|
495
|
+
details.push(`language = ${JSON.stringify(value)}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (!isLanguageTag(value) || value.length > FIELD_LIMITS.strings.language) {
|
|
499
|
+
issues.push(`${at}: must be a BCP 47 language tag such as en, zh-CN, or zh-Hans-CN`);
|
|
500
|
+
details.push(`language = ${JSON.stringify(value)}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* A list of gitignore-style patterns, every member checked.
|
|
505
|
+
*
|
|
506
|
+
* The backslash rule is a cross-platform trap `scripts/markdown-to-artifact.ts`
|
|
507
|
+
* measured and this is the only place that can name it: `matchesGlob` is
|
|
508
|
+
* separator-literal, so `drafts\**` written by a Windows user matches nothing.
|
|
509
|
+
* Left to itself that surfaces as TK-26's zero-match failure, which is loud but
|
|
510
|
+
* names a typo the user cannot see — the pattern looks right on their machine.
|
|
511
|
+
* Refusing it here says what is actually wrong.
|
|
512
|
+
*
|
|
513
|
+
* The duplicate rule is here for the same reason and is the one case TK-26's
|
|
514
|
+
* zero-match check structurally cannot reach. Measured: `exclude: ['draft.md',
|
|
515
|
+
* 'draft.md']` builds cleanly, because `excludes()` records a hit for *every*
|
|
516
|
+
* pattern that matched, so a duplicate is never idle. A repeated pattern is
|
|
517
|
+
* always either redundant or — far more likely in a list a user has edited over
|
|
518
|
+
* months — a line they meant to change and copied instead, which reads as a
|
|
519
|
+
* configured rule and is not one.
|
|
520
|
+
*
|
|
521
|
+
* **`tagged` marks the members YAML read a `!` tag on**, which is the one rule
|
|
522
|
+
* that cannot be written against the parsed value: a re-include whose `!` was
|
|
523
|
+
* eaten arrives as an ordinary string. See {@link taggedExcludeMembers}.
|
|
524
|
+
*
|
|
525
|
+
* **`memberLines` is per member, not per list.** A coordinate is only worth
|
|
526
|
+
* printing if it points at the thing that is wrong.
|
|
527
|
+
*/
|
|
528
|
+
function checkExclude(value, line, memberLines, tagged, issues, details) {
|
|
529
|
+
const at = line === undefined ? 'exclude' : `exclude (line ${line})`;
|
|
530
|
+
if (!Array.isArray(value)) {
|
|
531
|
+
issues.push(`${at}: must be a list of strings, found ${typeName(value)}`);
|
|
532
|
+
details.push(`exclude = ${JSON.stringify(value)}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
/** Pattern to the index that claimed it, so a duplicate can name the original. */
|
|
536
|
+
const seen = new Map();
|
|
537
|
+
for (const [index, member] of value.entries()) {
|
|
538
|
+
// The index, never the pattern: TK-25 §2.3 forbids a pattern's text on this
|
|
539
|
+
// surface, because a gitignore-style pattern may be a bare path and echoing
|
|
540
|
+
// it re-admits the exact string the exclusion existed to withhold. The user
|
|
541
|
+
// opens their own file and reads their own pattern at that index.
|
|
542
|
+
const memberLine = memberLines[index];
|
|
543
|
+
const where = `exclude[${index}]${memberLine === undefined ? '' : ` (line ${memberLine})`}`;
|
|
544
|
+
if (typeof member !== 'string') {
|
|
545
|
+
issues.push(`${where}: must be a string, found ${typeName(member)}`);
|
|
546
|
+
details.push(`exclude[${index}] = ${JSON.stringify(member)}`);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (member.trim() === '') {
|
|
550
|
+
issues.push(`${where}: must not be empty`);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (member !== member.trim()) {
|
|
554
|
+
issues.push(`${where}: must not have leading or trailing whitespace`);
|
|
555
|
+
details.push(`exclude[${index}] = ${JSON.stringify(member)}`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (member.includes('\\')) {
|
|
559
|
+
issues.push(`${where}: must use "/" separators on every platform — a backslash is ` +
|
|
560
|
+
'matched literally, so this pattern would match nothing');
|
|
561
|
+
details.push(`exclude[${index}] = ${JSON.stringify(member)}`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const original = seen.get(member);
|
|
565
|
+
if (original !== undefined) {
|
|
566
|
+
// Two coordinates and no text, which is the whole message a user needs:
|
|
567
|
+
// they open their own file and see the same line twice.
|
|
568
|
+
issues.push(`${where}: repeats exclude[${original}] — a duplicate pattern is dead config`);
|
|
569
|
+
details.push(`exclude[${index}] = ${JSON.stringify(member)} duplicates exclude[${original}]`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
seen.set(member, index);
|
|
573
|
+
// The inversion, and the one refusal here that prevents a file being
|
|
574
|
+
// *withheld* rather than published.
|
|
575
|
+
if (tagged[index] === true && !member.startsWith('!')) {
|
|
576
|
+
issues.push(`${where}: a re-include must be written as "!pattern" in quotes, with no ` +
|
|
577
|
+
'space after the "!" — unquoted, YAML reads it as a tag and discards it, ' +
|
|
578
|
+
'which turns a rule that keeps a file into one that removes it');
|
|
579
|
+
details.push(`exclude[${index}] = ${JSON.stringify(member)} carried an eaten "!" tag`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Whether a YAML node carries the non-specific `!` tag, following an alias.
|
|
585
|
+
*
|
|
586
|
+
* The one signal that something the user wrote was discarded. `! README.md`
|
|
587
|
+
* parses to `README.md` with no error and no warning — and inside `exclude`
|
|
588
|
+
* that is not a corrupted pattern but an inverted one, since `!README.md`
|
|
589
|
+
* re-includes the repository README while `README.md` excludes it. So the user
|
|
590
|
+
* wrote "give me this file back", the loader received "withhold it", and every
|
|
591
|
+
* check on the parsed value sees an ordinary string.
|
|
592
|
+
*
|
|
593
|
+
* **The alias hop, and what it is and is not doing.** Measured:
|
|
594
|
+
* `title: &r ! README.md` with `exclude: [*r]` reaches this function as an
|
|
595
|
+
* `Alias` node carrying no tag of its own — the tag lives on the anchored
|
|
596
|
+
* scalar — so a check on `.tag` alone answers `false` for it. `resolve` follows
|
|
597
|
+
* the alias.
|
|
598
|
+
*
|
|
599
|
+
* Recorded honestly, because the mutation that removes the hop stays **green**:
|
|
600
|
+
* every site an anchor can be defined at is already checked by a caller, so I
|
|
601
|
+
* could not construct a document that only the hop refuses. What I tried — an
|
|
602
|
+
* anchor on `title`, on an `exclude` member, on an unknown key, and aliased in
|
|
603
|
+
* both block and flow style — is the search, not a proof there is none. The hop
|
|
604
|
+
* stays because this function must be right about the node it is handed rather
|
|
605
|
+
* than right only while its callers happen to cover the anchor sites, which is
|
|
606
|
+
* the rule `src/lib/routes.ts` states for the same reason.
|
|
607
|
+
*
|
|
608
|
+
* `=== '!'` exactly, never `startsWith('!')`. A *named* tag such as
|
|
609
|
+
* `!private drafts/**` reports `'!private'` and is refused by the warning
|
|
610
|
+
* promotion in {@link parseConfig} instead; widening this test would collapse
|
|
611
|
+
* two mechanisms into one and leave the promotion with no gate of its own.
|
|
612
|
+
*/
|
|
613
|
+
function carriesEatenTag(node, document) {
|
|
614
|
+
if (node === null || typeof node !== 'object')
|
|
615
|
+
return false;
|
|
616
|
+
const direct = node.tag;
|
|
617
|
+
if (direct === '!')
|
|
618
|
+
return true;
|
|
619
|
+
const resolve = node.resolve;
|
|
620
|
+
if (typeof resolve !== 'function')
|
|
621
|
+
return false;
|
|
622
|
+
const target = resolve.call(node, document);
|
|
623
|
+
return target !== null && typeof target === 'object' && target.tag === '!';
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Which `exclude` members carry an eaten `!`.
|
|
627
|
+
*
|
|
628
|
+
* **Read off the node, and the first version read the source line instead —
|
|
629
|
+
* which was wrong in four measured ways.** Recovering the text after the `-` on
|
|
630
|
+
* the member's own line missed a flow sequence (`exclude: [! README.md]`,
|
|
631
|
+
* accepted), missed a member indented onto the next line, missed **every member
|
|
632
|
+
* of a CRLF file** — the ordinary case on Windows, where splitting on a bare
|
|
633
|
+
* newline leaves a `\r` that an unanchored `$` will not match — and *falsely
|
|
634
|
+
* refused* the legitimate `!!str "a/**"`, whose line also opens with a `!`. So
|
|
635
|
+
* the rule was off exactly where it was most needed and on where it was not.
|
|
636
|
+
*
|
|
637
|
+
* The tag is the thing itself rather than a proxy for it: measured, `"!README.md"`
|
|
638
|
+
* carries no tag, `!!str` carries `tag:yaml.org,2002:str`, and CRLF changes
|
|
639
|
+
* neither the tag nor the line numbers.
|
|
640
|
+
*/
|
|
641
|
+
function taggedExcludeMembers(document) {
|
|
642
|
+
const sequence = document.get('exclude', true);
|
|
643
|
+
const items = sequence !== null && typeof sequence === 'object' && 'items' in sequence ? sequence.items : undefined;
|
|
644
|
+
if (!Array.isArray(items))
|
|
645
|
+
return [];
|
|
646
|
+
return items.map((item) => carriesEatenTag(item, document));
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The 1-indexed line each `exclude` member was written on.
|
|
650
|
+
*
|
|
651
|
+
* A member's issue must point at the member, not at the `exclude:` key. The
|
|
652
|
+
* first version passed one line for the whole list, so on a nine-line config
|
|
653
|
+
* the fourth member's error read `exclude[3] (line 4)` while line 4 was
|
|
654
|
+
* `exclude:` — and a *config location* that is wrong is worse than none, since
|
|
655
|
+
* the entire disclosure design rests on the user opening their own file at that
|
|
656
|
+
* line and reading their own text there.
|
|
657
|
+
*/
|
|
658
|
+
function excludeLines(document, lineCounter) {
|
|
659
|
+
const sequence = document.get('exclude', true);
|
|
660
|
+
const items = sequence !== null && typeof sequence === 'object' && 'items' in sequence ? sequence.items : undefined;
|
|
661
|
+
if (!Array.isArray(items))
|
|
662
|
+
return [];
|
|
663
|
+
return items.map((item) => {
|
|
664
|
+
const range = item.range;
|
|
665
|
+
if (!Array.isArray(range) || typeof range[0] !== 'number')
|
|
666
|
+
return undefined;
|
|
667
|
+
return lineCounter.linePos(range[0]).line;
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Parse one configuration file and apply the defaults it did not override.
|
|
672
|
+
*
|
|
673
|
+
* Separated from {@link loadConfig} so the whole validator is reachable from a
|
|
674
|
+
* string, which is what lets a gate state a hostile document inline instead of
|
|
675
|
+
* writing it to a temporary directory first.
|
|
676
|
+
*
|
|
677
|
+
* **Every warning is a failure, and that is the load-bearing line.** `yaml`
|
|
678
|
+
* reports an unresolvable tag as a *warning* and hands back a document whose
|
|
679
|
+
* value has been quietly replaced — measured, `- !README.md` yields `''` with
|
|
680
|
+
* `doc.errors` empty and `doc.warnings` holding one `TAG_RESOLVE_FAILED`. A
|
|
681
|
+
* loader that reads only `errors` publishes the file the user meant to withhold
|
|
682
|
+
* and reports success.
|
|
683
|
+
*
|
|
684
|
+
* **`logLevel: 'silent'` is a disclosure fix, not a tidiness one.** `yaml`
|
|
685
|
+
* writes some diagnostics through `process.emitWarning` rather than collecting
|
|
686
|
+
* them, and measured, one of them prints the user's own config key:
|
|
687
|
+
* `Warning: Keys with collection values will be stringified … "[ clients/acme/…`.
|
|
688
|
+
* That is a user-authored string on stderr from a call nothing here composed,
|
|
689
|
+
* emitted before this function can reject anything, and a `process.on('warning')`
|
|
690
|
+
* listener does not see it. Silencing the logger suppresses it entirely, and —
|
|
691
|
+
* measured — leaves `doc.warnings` populated, so the promotion below is
|
|
692
|
+
* unaffected.
|
|
693
|
+
*/
|
|
694
|
+
export function parseConfig(text) {
|
|
695
|
+
const issues = [];
|
|
696
|
+
const details = [];
|
|
697
|
+
// A leading BOM is what Notepad writes, and it is not YAML. Stripped rather
|
|
698
|
+
// than refused: the user did not type it and cannot see it.
|
|
699
|
+
//
|
|
700
|
+
// CRLF is deliberately *not* normalised here, and the earlier version that
|
|
701
|
+
// did is worth recording. It was added to repair the inversion rule, which
|
|
702
|
+
// then recovered each pattern from the text of its source line and so broke
|
|
703
|
+
// on the carriage return a bare newline split leaves behind. The rule now
|
|
704
|
+
// reads the node's tag instead, and measured, `yaml` handles CRLF itself: the
|
|
705
|
+
// tag, the line numbers, and every scalar — plain, quoted, literal, folded —
|
|
706
|
+
// are identical under both line endings, and no carriage return reaches a
|
|
707
|
+
// value. So the normalisation became a line no mutation could turn red, which
|
|
708
|
+
// is the kind of code that outlives the reason for it.
|
|
709
|
+
const source = text.replace(/^/, "");
|
|
710
|
+
const lineCounter = new LineCounter();
|
|
711
|
+
let documents;
|
|
712
|
+
try {
|
|
713
|
+
// `parseAllDocuments` rather than `parseDocument`, because the latter
|
|
714
|
+
// returns the *first* document and says nothing about the rest. Measured:
|
|
715
|
+
// `title: One\n---\nexclude:\n - "drafts/**"\n` parsed to `{title: 'One'}`
|
|
716
|
+
// with `errors` and `warnings` both empty — the user's entire exclusion list
|
|
717
|
+
// discarded with no error and no line number. That is the same failure class
|
|
718
|
+
// as the `!!set` document below, and worse, because what is dropped is the
|
|
719
|
+
// half that withholds files.
|
|
720
|
+
documents = parseAllDocuments(source, { lineCounter, logLevel: 'silent' });
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
// A throw rather than a collected error is a `yaml` internal, so its message
|
|
724
|
+
// has opaque provenance and goes to the private half only.
|
|
725
|
+
throw new BuildFailure('config-unparsable', `${CONFIG_FILENAME}: is not valid YAML`, error instanceof Error ? error.message : String(error));
|
|
726
|
+
}
|
|
727
|
+
const lineOf = (offset) => offset === undefined ? undefined : lineCounter.linePos(offset).line;
|
|
728
|
+
// Measured, an empty input yields zero documents rather than one empty one.
|
|
729
|
+
if (documents.length === 0)
|
|
730
|
+
return freshDefaults();
|
|
731
|
+
if (documents.length > 1) {
|
|
732
|
+
const second = documents[1]?.range?.[0];
|
|
733
|
+
const line = lineOf(typeof second === 'number' ? second : undefined);
|
|
734
|
+
refuse([
|
|
735
|
+
`${line === undefined ? 'the document' : `line ${line}`}: a second document begins here, ` +
|
|
736
|
+
'and only the first is read — remove the "---" separator and the documents after it',
|
|
737
|
+
], [`${documents.length} documents in one file`]);
|
|
738
|
+
}
|
|
739
|
+
const document = documents[0];
|
|
740
|
+
for (const problem of [...document.errors, ...document.warnings]) {
|
|
741
|
+
// The code is `yaml`'s own closed set of identifiers, and the position is a
|
|
742
|
+
// config location — both admissible. The message is not: it quotes the
|
|
743
|
+
// offending source line back, which for this file is pattern text.
|
|
744
|
+
const line = problem.linePos?.[0]?.line;
|
|
745
|
+
issues.push(`${line === undefined ? 'the document' : `line ${line}`}: ${problem.code}`);
|
|
746
|
+
details.push(problem.message);
|
|
747
|
+
}
|
|
748
|
+
if (issues.length > 0)
|
|
749
|
+
refuse(issues, details);
|
|
750
|
+
// `toJS` is what resolves aliases, and an unresolvable one throws from inside
|
|
751
|
+
// it rather than being collected — so it runs after the promotion above has
|
|
752
|
+
// already refused every document that could reach that state, and is guarded
|
|
753
|
+
// regardless because a throw here would otherwise escape as a library string.
|
|
754
|
+
let value;
|
|
755
|
+
try {
|
|
756
|
+
value = document.toJS({ logLevel: 'silent' });
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
throw new BuildFailure('config-unparsable', `${CONFIG_FILENAME}: is not valid YAML`, error instanceof Error ? error.message : String(error));
|
|
760
|
+
}
|
|
761
|
+
// An empty file is a user who created the file and has not filled it in, which
|
|
762
|
+
// is what the defaults are for. A file whose top level is a list or a string
|
|
763
|
+
// is a structural mistake, and silently defaulting it would discard whatever
|
|
764
|
+
// they did write.
|
|
765
|
+
if (value === null || value === undefined)
|
|
766
|
+
return freshDefaults();
|
|
767
|
+
// **A plain object, not merely an object**, and the difference is a whole
|
|
768
|
+
// configuration silently discarded. Measured: `!!set` and `!!omap` at the
|
|
769
|
+
// document level make `toJS` return a `Set` and a `Map`, both of which pass a
|
|
770
|
+
// bare `typeof === 'object' && !Array.isArray`, and `Object.keys` on either is
|
|
771
|
+
// empty — so the unknown-key loop finds nothing, no key is ever read, and the
|
|
772
|
+
// user's entire file is replaced by the defaults with no error and no line
|
|
773
|
+
// number. That is this module's own headline failure mode one level above the
|
|
774
|
+
// misspelled key it was built to catch.
|
|
775
|
+
if (!isPlainObject(value)) {
|
|
776
|
+
refuse([`the document: must be a table of keys, found ${typeName(value)}`], []);
|
|
777
|
+
}
|
|
778
|
+
const table = value;
|
|
779
|
+
/** The 1-indexed line a key was written on, for the message to point at. */
|
|
780
|
+
const lines = new Map();
|
|
781
|
+
/** Keys whose *value* carried a tag YAML discarded. */
|
|
782
|
+
const eatenTags = new Set();
|
|
783
|
+
const contents = document.contents;
|
|
784
|
+
const items = contents !== null && typeof contents === 'object' && 'items' in contents ? contents.items : undefined;
|
|
785
|
+
if (Array.isArray(items)) {
|
|
786
|
+
for (const item of items) {
|
|
787
|
+
const key = item.key;
|
|
788
|
+
if (key === null || typeof key !== 'object')
|
|
789
|
+
continue;
|
|
790
|
+
const range = key.range;
|
|
791
|
+
const name = key.value;
|
|
792
|
+
if (typeof name !== 'string' || !Array.isArray(range))
|
|
793
|
+
continue;
|
|
794
|
+
const line = lineOf(typeof range[0] === 'number' ? range[0] : undefined);
|
|
795
|
+
if (line !== undefined)
|
|
796
|
+
lines.set(name, line);
|
|
797
|
+
// The same eaten-tag rule the exclusion list gets, applied to every key.
|
|
798
|
+
// Measured: `title: ! My Notes` and `origin: ! https://example.com/` were
|
|
799
|
+
// accepted with the tag discarded and no warning — the user wrote
|
|
800
|
+
// something the parser did not understand and got a build that ignored
|
|
801
|
+
// it. `exclude`'s members are checked separately, per member.
|
|
802
|
+
if (name !== 'exclude' && carriesEatenTag(item.value, document)) {
|
|
803
|
+
eatenTags.add(name);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
for (const key of eatenTags) {
|
|
808
|
+
const line = lines.get(key);
|
|
809
|
+
issues.push(`${line === undefined ? 'a key' : `line ${line}`}: the value carries a "!" tag, which YAML ` +
|
|
810
|
+
'discards — remove it, or quote the value if the "!" is part of it');
|
|
811
|
+
details.push(`${key} carried an eaten "!" tag`);
|
|
812
|
+
}
|
|
813
|
+
for (const key of Object.keys(table)) {
|
|
814
|
+
if (KNOWN_KEYS.includes(key))
|
|
815
|
+
continue;
|
|
816
|
+
// The line, never the key. A known key is a literal of this file's table and
|
|
817
|
+
// is printed in full below; an unknown one is user-authored text, and
|
|
818
|
+
// `clients/acme/2026-renewal.md:` is a valid YAML key. The suggestion comes
|
|
819
|
+
// out of the table, so it discloses nothing and tells the user which key
|
|
820
|
+
// they meant — which is the half the line number cannot supply.
|
|
821
|
+
const line = lines.get(key);
|
|
822
|
+
const suggestion = suggestionFor(key);
|
|
823
|
+
issues.push(`${line === undefined ? 'a key' : `line ${line}`}: unknown key is not allowed` +
|
|
824
|
+
(suggestion === undefined
|
|
825
|
+
? `. The keys are ${KNOWN_KEYS.join(', ')}`
|
|
826
|
+
: `. Did you mean "${suggestion}"?`));
|
|
827
|
+
details.push(`unknown key ${JSON.stringify(key)}`);
|
|
828
|
+
}
|
|
829
|
+
if ('title' in table)
|
|
830
|
+
checkTitle(table['title'], lines.get('title'), issues, details);
|
|
831
|
+
if ('origin' in table)
|
|
832
|
+
checkOrigin(table['origin'], lines.get('origin'), issues, details);
|
|
833
|
+
if ('language' in table)
|
|
834
|
+
checkLanguage(table['language'], lines.get('language'), issues, details);
|
|
835
|
+
if ('exclude' in table) {
|
|
836
|
+
checkExclude(table['exclude'], lines.get('exclude'), excludeLines(document, lineCounter), taggedExcludeMembers(document), issues, details);
|
|
837
|
+
}
|
|
838
|
+
if (issues.length > 0)
|
|
839
|
+
refuse(issues, details);
|
|
840
|
+
return {
|
|
841
|
+
// No `.trim()`: `checkTitle` has already refused a padded value, so trimming
|
|
842
|
+
// here would be a rewrite that can never fire, and a reader would have to
|
|
843
|
+
// check whether it could.
|
|
844
|
+
title: 'title' in table ? table['title'] : DEFAULTS.title,
|
|
845
|
+
origin: 'origin' in table ? table['origin'] : DEFAULTS.origin,
|
|
846
|
+
exclude: 'exclude' in table ? [...table['exclude']] : [],
|
|
847
|
+
language: 'language' in table ? table['language'] : DEFAULTS.language,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* The configuration for a build rooted at `directory`, or the defaults.
|
|
852
|
+
*
|
|
853
|
+
* **Absent must work**, and it is the ordinary case rather than a degradation:
|
|
854
|
+
* this tool publishes by default, so a stranger who has written notes and
|
|
855
|
+
* nothing else gets a complete site. What they do not get is an origin — see
|
|
856
|
+
* {@link LoadedConfig} — because the fallback for that belongs to the one place
|
|
857
|
+
* the origin is written down.
|
|
858
|
+
*
|
|
859
|
+
* `directory` being `undefined` means the caller has no user directory to
|
|
860
|
+
* consult at all, which is this repository building itself. Same answer,
|
|
861
|
+
* reached without a filesystem call.
|
|
862
|
+
*/
|
|
863
|
+
export function loadConfig(directory) {
|
|
864
|
+
if (directory === undefined)
|
|
865
|
+
return freshDefaults();
|
|
866
|
+
const path = join(directory, CONFIG_FILENAME);
|
|
867
|
+
if (!existsSync(path)) {
|
|
868
|
+
for (const name of NEAR_MISS_NAMES) {
|
|
869
|
+
if (!existsSync(join(directory, name)))
|
|
870
|
+
continue;
|
|
871
|
+
// Both names are literals of this file's own source, so the refusal can
|
|
872
|
+
// name them. Refusing rather than reading it: accepting a second spelling
|
|
873
|
+
// is a search order, and a search order is a place for the *next* file to
|
|
874
|
+
// be silently not found.
|
|
875
|
+
throw new BuildFailure('config-misnamed', `found ${name}, but the configuration file is ${CONFIG_FILENAME}. ` +
|
|
876
|
+
'Rename it — a file under any other name is not read, and would ' +
|
|
877
|
+
'leave you believing you had configured something.', `${name} present, ${CONFIG_FILENAME} absent, in the content directory`);
|
|
878
|
+
}
|
|
879
|
+
return freshDefaults();
|
|
880
|
+
}
|
|
881
|
+
let text;
|
|
882
|
+
try {
|
|
883
|
+
text = readFileSync(path, 'utf8');
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
// The path is in the message `readFileSync` composed, and nothing here
|
|
887
|
+
// composed that message. It goes to the private half.
|
|
888
|
+
throw new BuildFailure('config-unreadable', `${CONFIG_FILENAME}: exists but could not be read`, error instanceof Error ? error.message : String(error));
|
|
889
|
+
}
|
|
890
|
+
return parseConfig(text);
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* The exclusion argument `discover()` takes, built from a loaded config.
|
|
894
|
+
*
|
|
895
|
+
* It also carries the configured site `language`, because `discover()` is
|
|
896
|
+
* where an undeclared note's effective language is decided: one options object
|
|
897
|
+
* keeps every caller of `discover()` (build and review) passing the same
|
|
898
|
+
* configuration rather than threading a second argument through each.
|
|
899
|
+
*
|
|
900
|
+
* The return type is imported from the producer rather than restated, so the
|
|
901
|
+
* handoff is checked by `astro check` instead of by a comment: if TK-26's
|
|
902
|
+
* `ExclusionOptions` ever gains a field or changes one, this stops compiling.
|
|
903
|
+
* That is the whole of the "accepted without adaptation" criterion, held at the
|
|
904
|
+
* type level; the gate that calls `discover()` with it holds the other half at
|
|
905
|
+
* run time.
|
|
906
|
+
*
|
|
907
|
+
* `excludeSource` is {@link CONFIG_FILENAME}, which is the shape the producer's
|
|
908
|
+
* `CONFIG_LOCATION` allowlist requires before it will put a source label on a
|
|
909
|
+
* stream — so a zero-match failure names this file rather than falling back to
|
|
910
|
+
* the anonymous literal.
|
|
911
|
+
*
|
|
912
|
+
* **Nothing in the shipped binary calls this yet, and a reader must not assume
|
|
913
|
+
* otherwise.** `bin/anc.mjs` calls `discover(contentDirectory)`
|
|
914
|
+
* with no options, so measured end to end today, a repository whose
|
|
915
|
+
* `publish.config.yaml` excludes `drafts/**` builds and **publishes the drafts**
|
|
916
|
+
* — the only file dropped is the config itself, as `not-markdown`. The same run
|
|
917
|
+
* ignores the configured `title`. That is TK-32's wiring, one line at each call
|
|
918
|
+
* site, and it is stated here rather than left implied because the gate in
|
|
919
|
+
* `tests/config.test.ts` calls `discover` directly and reads as end-to-end
|
|
920
|
+
* evidence when it is not.
|
|
921
|
+
*/
|
|
922
|
+
export function exclusionOptions(config) {
|
|
923
|
+
return {
|
|
924
|
+
exclude: config.exclude,
|
|
925
|
+
excludeSource: CONFIG_FILENAME,
|
|
926
|
+
...(config.language === undefined ? {} : { language: config.language }),
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* The environment variable naming the directory holding the user's config.
|
|
931
|
+
*
|
|
932
|
+
* The seam, and the reason it is one. `bin/anc.mjs` changes the
|
|
933
|
+
* working directory to this package's root before Astro is started, so by the
|
|
934
|
+
* time `astro.config.mjs` is evaluated the user's own directory is no longer
|
|
935
|
+
* reachable from `process.cwd()`. `CONTENT_ARTIFACT` already crosses that
|
|
936
|
+
* boundary the same way and for the same reason.
|
|
937
|
+
*
|
|
938
|
+
* **Nothing sets it yet, and that is deliberate rather than unfinished.**
|
|
939
|
+
* Setting it is one line in the CLI, which TK-32 owns along with the rest of the
|
|
940
|
+
* end-to-end wiring; this ticket owns the loader and the config's arrival in
|
|
941
|
+
* `astro.config.mjs`. Until then a packaged build falls back to the shipped
|
|
942
|
+
* default origin, which is the placeholder `astro.config.mjs` documents as safe.
|
|
943
|
+
*/
|
|
944
|
+
export const CONFIG_DIRECTORY_VARIABLE = 'PUBLISH_CONFIG_DIR';
|
|
945
|
+
/**
|
|
946
|
+
* The configuration an Astro build should use, resolved from the environment.
|
|
947
|
+
*
|
|
948
|
+
* `||` rather than `??`, and `src/lib/artifact-source.ts` documents the same
|
|
949
|
+
* choice for the same measured reason: an *empty* environment variable is how a
|
|
950
|
+
* shell unsets one for a single command, and `??` would treat `''` as a
|
|
951
|
+
* directory — making `join('', 'publish.config.yaml')` a relative path resolved
|
|
952
|
+
* against wherever the process happens to be standing.
|
|
953
|
+
*/
|
|
954
|
+
export function configForBuild() {
|
|
955
|
+
const config = loadConfig(process.env[CONFIG_DIRECTORY_VARIABLE] || process.cwd());
|
|
956
|
+
// The configured site language crosses into the page modules the way the
|
|
957
|
+
// title does — an environment variable, because `src/lib/` cannot import
|
|
958
|
+
// this module (see `SITE_TITLE_VARIABLE` in `src/lib/site.ts`). Set here
|
|
959
|
+
// rather than in `astro.config.mjs` so every caller that resolves the build's
|
|
960
|
+
// configuration also publishes its language, and cleared when the user
|
|
961
|
+
// configured none so a value left by an earlier build in the same process
|
|
962
|
+
// cannot leak into this one. The spelling is `SITE_LANGUAGE_VARIABLE` in
|
|
963
|
+
// `src/lib/translations.ts`; `tests/config.test.ts` holds the two equal.
|
|
964
|
+
if (config.language === undefined)
|
|
965
|
+
delete process.env[SITE_LANGUAGE_VARIABLE];
|
|
966
|
+
else
|
|
967
|
+
process.env[SITE_LANGUAGE_VARIABLE] = config.language;
|
|
968
|
+
return config;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* The environment variable `src/lib/translations.ts` reads the site language
|
|
972
|
+
* from. Spelled out rather than imported: importing `translations.ts` here would
|
|
973
|
+
* evaluate its `NAV_LANGUAGE` before this module has set the variable.
|
|
974
|
+
*/
|
|
975
|
+
export const SITE_LANGUAGE_VARIABLE = 'PUBLISH_SITE_LANGUAGE';
|