@wxxb789/anc 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. package/tsconfig.json +25 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * A synthetic notes repository of any size, deterministic from a seed.
3
+ *
4
+ * This exists because every performance claim in this project was made against
5
+ * a 32-note fixture. A benchmark needs a corpus large enough to be evidence, and
6
+ * a corpus of 10,000 identical notes is evidence of nothing: it collapses link
7
+ * resolution to one bucket, gives Pagefind one document repeated, and hides
8
+ * every cost that scales with *variety* rather than with count.
9
+ *
10
+ * So the shape below is chosen for what it stresses, and each choice is stated
11
+ * where it is made. What matters most:
12
+ *
13
+ * - **The link graph is skewed, not uniform.** Most notes link a few others; a
14
+ * small number are hubs linking dozens. A uniform graph makes every backlink
15
+ * list the same length, and the one thing a backlink derivation can be
16
+ * quadratic in is a hub's in-degree.
17
+ * - **Basenames collide across directories.** Link resolution buckets by
18
+ * lowercase basename, so a corpus of unique names never leaves tier 1 and
19
+ * never measures tiers 2 through 5. See {@link BASENAME_POOL}.
20
+ * - **All five link forms appear**, because they take different paths through
21
+ * `resolveLink` — a wikilink is not percent-decoded, a rooted link can end
22
+ * resolution at tier 4, and a relative link rewrites its path before tiers 3
23
+ * and 5 see it.
24
+ * - **Some links deliberately do not resolve.** A corpus where every link works
25
+ * never exercises the degrade-to-text rewrite, which is the branch that
26
+ * allocates.
27
+ *
28
+ * ## Workload options
29
+ *
30
+ * The defaults are the original corpus — the skewed graph above and no
31
+ * metadata — so an existing caller's bytes are unchanged. Two alternatives
32
+ * exist for the benchmark workloads. `topology: 'sparse'` removes the skew
33
+ * entirely: every note links one or two others and no note is a hub, which is
34
+ * what lets a measurement separate a cost that follows the graph shape from
35
+ * one that follows the note count. `metadata: true` gives every note
36
+ * frontmatter — three tags, at least one CJK, and a per-note alias — and every
37
+ * 25th note a mixed CJK/Latin heading, which is what a tag, alias, or
38
+ * non-ASCII search measurement needs. Neither option makes an unseeded choice,
39
+ * so either corpus is as reproducible as the default.
40
+ *
41
+ * ## Nothing here names this project or this repository
42
+ *
43
+ * The tool is not for one person, so a fixture that encodes this repository's
44
+ * layout measures this repository rather than the product. The vocabulary below
45
+ * is deliberately generic — weather, terrain, and materials — and the directory
46
+ * names are the shapes a notes repository generally has rather than the ones
47
+ * this one does. A corpus is written to a caller-supplied directory, never
48
+ * inside the repository.
49
+ *
50
+ * ## Determinism
51
+ *
52
+ * One seeded generator drives every choice, and the same seed and count produce
53
+ * byte-identical files. That is what makes two benchmark runs comparable: a
54
+ * corpus that varied between runs would put its own variance into every
55
+ * measurement taken over it.
56
+ */
57
+ import { mkdir, writeFile } from 'node:fs/promises';
58
+ import { dirname, join } from 'node:path';
59
+ /**
60
+ * A small, fast, seeded PRNG (mulberry32).
61
+ *
62
+ * `Math.random` is unseedable, so it cannot produce the same corpus twice, and
63
+ * a benchmark over a corpus that changes per run measures the corpus as much as
64
+ * the code. Twelve lines of arithmetic rather than a dependency: the statistical
65
+ * quality needed here is "spreads out and repeats", which this clears easily and
66
+ * which no property of the benchmark depends on beyond reproducibility.
67
+ */
68
+ function seededRandom(seed) {
69
+ let state = seed >>> 0;
70
+ return () => {
71
+ state = (state + 0x6d2b79f5) >>> 0;
72
+ let t = state;
73
+ t = Math.imul(t ^ (t >>> 15), t | 1);
74
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
75
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
76
+ };
77
+ }
78
+ /**
79
+ * Directory names a notes repository plausibly has.
80
+ *
81
+ * Depth matters to resolution rather than to taste: tier 2 joins a relative
82
+ * link onto the source's directory and tier 5 prefers candidates inside the
83
+ * source's subtree, so a flat corpus leaves both inert. The nested entries here
84
+ * are what give those tiers something to prefer.
85
+ */
86
+ const DIRECTORIES = [
87
+ '',
88
+ 'notes',
89
+ 'notes/daily',
90
+ 'notes/reference',
91
+ 'projects',
92
+ 'projects/archive',
93
+ 'reading',
94
+ 'reading/papers',
95
+ 'inbox',
96
+ ];
97
+ /**
98
+ * The word pool every generated name and body is built from.
99
+ *
100
+ * Generic on purpose — see this file's header. Short and repetitive is also what
101
+ * a real search index sees: Pagefind's cost is dominated by the number of
102
+ * *distinct* terms, and a corpus of random unique strings would make its index
103
+ * enormous in a way no real corpus is.
104
+ */
105
+ const WORDS = [
106
+ 'quiet', 'harbor', 'rolling', 'field', 'still', 'water', 'copper', 'lantern',
107
+ 'winter', 'thicket', 'shallow', 'creek', 'northern', 'ridge', 'open', 'meadow',
108
+ 'grey', 'stone', 'brief', 'thaw', 'salt', 'marsh', 'low', 'cloud',
109
+ 'iron', 'gate', 'long', 'shadow', 'first', 'frost', 'pale', 'ember',
110
+ ];
111
+ /**
112
+ * Basenames deliberately reused across directories.
113
+ *
114
+ * `indexCorpus` keys on the lowercase basename, so a corpus of globally unique
115
+ * filenames puts exactly one candidate in every bucket and every link resolves
116
+ * at tier 1 — the cheapest path, and the one that proves least. These names are
117
+ * the ones a real vault repeats in every folder, and each one that appears in
118
+ * two directories forces a link naming it through tiers 2 to 5 and makes it an
119
+ * `ambiguous` finding rather than a `resolved` one.
120
+ */
121
+ const BASENAME_POOL = ['index', 'notes', 'readme', 'log', 'summary'];
122
+ /** Non-Markdown files, which discovery counts and drops rather than reads. */
123
+ const ASSET_EXTENSIONS = ['.png', '.pdf', '.txt', '.canvas', '.json'];
124
+ /**
125
+ * How many of a note's own words become links, by note rank.
126
+ *
127
+ * A power-law-ish skew rather than a constant: in a real vault a handful of
128
+ * index notes link most of the corpus and the long tail links two or three
129
+ * things. The two ends cost differently — a hub's out-edges become every
130
+ * target's backlinks, so the hubs are what make backlink derivation expensive —
131
+ * and a uniform graph measures neither end.
132
+ *
133
+ * Sparse mode skips the skew rather than damping it: one or two links per note,
134
+ * drawn from the same seeded RNG, so a benchmark can tell a cost that follows
135
+ * the graph shape from one that follows the note count.
136
+ */
137
+ function outDegreeFor(rank, total, random, topology) {
138
+ if (topology === 'sparse')
139
+ return 1 + Math.floor(random() * 2);
140
+ // The first 1% of notes are hubs. `Math.max(1, …)` so a tiny corpus still has
141
+ // one, which is what keeps a 100-note run comparable to a 10,000-note one
142
+ // rather than differing in kind.
143
+ const hubs = Math.max(1, Math.floor(total * 0.01));
144
+ if (rank < hubs)
145
+ return 20 + Math.floor(random() * 40);
146
+ if (rank < total * 0.1)
147
+ return 4 + Math.floor(random() * 6);
148
+ return Math.floor(random() * 4);
149
+ }
150
+ /**
151
+ * A note's body length in paragraphs, skewed the way real notes are.
152
+ *
153
+ * Most notes are short; a few are long. The long ones matter twice over: the
154
+ * Markdown renderer's cost is per byte, and `STRING_LIMITS.markdown` caps a note
155
+ * at 200,000 characters — so a corpus of uniformly short notes would never come
156
+ * near a limit the contract actually enforces.
157
+ */
158
+ function paragraphsFor(random) {
159
+ const roll = random();
160
+ if (roll < 0.6)
161
+ return 1 + Math.floor(random() * 3);
162
+ if (roll < 0.95)
163
+ return 4 + Math.floor(random() * 10);
164
+ return 20 + Math.floor(random() * 40);
165
+ }
166
+ /** A sentence of pool words, capitalised. */
167
+ function sentence(random, words) {
168
+ const picked = Array.from({ length: words }, () => WORDS[Math.floor(random() * WORDS.length)]);
169
+ const text = picked.join(' ');
170
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
171
+ }
172
+ /**
173
+ * Every note's path, decided before any body is written.
174
+ *
175
+ * Two passes are forced rather than chosen: a note's links name *other notes*,
176
+ * so the full path set has to exist before the first body can reference it.
177
+ * That mirrors the pipeline itself, where link resolution runs after the whole
178
+ * walk for the same reason.
179
+ */
180
+ function planPaths(count, random) {
181
+ const paths = [];
182
+ const taken = new Set();
183
+ for (let index = 0; index < count; index += 1) {
184
+ let directory = DIRECTORIES[Math.floor(random() * DIRECTORIES.length)];
185
+ // One note in twelve takes a pooled basename, which is what puts more than
186
+ // one candidate in a resolution bucket. The rest get a unique name, so the
187
+ // corpus stays mostly unambiguous the way a real one is.
188
+ const pooled = random() < 1 / 12;
189
+ const stem = pooled
190
+ ? BASENAME_POOL[Math.floor(random() * BASENAME_POOL.length)]
191
+ : `${WORDS[Math.floor(random() * WORDS.length)]}-${WORDS[Math.floor(random() * WORDS.length)]}-${index}`;
192
+ // A pooled name is never placed at the corpus root, and this is a
193
+ // measurement rather than a preference: a root-level `notes.md` slugifies to
194
+ // `notes`, which is in `RESERVED_SLUGS`, and the contract refuses the whole
195
+ // artifact over it. That is a real defect in the product — a two-note
196
+ // repository containing `notes.md` does not build — but it is not a *scale*
197
+ // defect, and a benchmark that trips it measures nothing at any size. It is
198
+ // reported separately rather than worked around silently. Nesting is also
199
+ // the more realistic placement: a repeated basename is a folder index.
200
+ if (pooled && directory === '')
201
+ directory = DIRECTORIES[1];
202
+ let path = directory === '' ? `${stem}.md` : `${directory}/${stem}.md`;
203
+ // A pooled name can collide inside one directory, and two files at one path
204
+ // is one file. Disambiguating by index keeps the count exact while leaving
205
+ // the *cross-directory* collisions this pool exists to create.
206
+ if (taken.has(path))
207
+ path = path.replace(/\.md$/, `-${index}.md`);
208
+ taken.add(path);
209
+ paths.push(path);
210
+ }
211
+ return paths;
212
+ }
213
+ /** The relative path from one note's directory to another's, for tier 2. */
214
+ function relativePath(from, to) {
215
+ const fromParts = from.split('/').slice(0, -1);
216
+ const toParts = to.split('/');
217
+ let shared = 0;
218
+ while (shared < fromParts.length && fromParts[shared] === toParts[shared])
219
+ shared += 1;
220
+ const up = Array.from({ length: fromParts.length - shared }, () => '..');
221
+ const down = toParts.slice(shared);
222
+ const joined = [...up, ...down].join('/');
223
+ // A path with no `../` prefix must still be written `./x` to be a *relative*
224
+ // link — without the prefix `isExternal` never sees one and tier 2 is skipped,
225
+ // so the form this branch exists to generate would not be generated.
226
+ return joined.startsWith('..') ? joined : `./${joined}`;
227
+ }
228
+ /**
229
+ * One link, in one of the five forms the resolver accepts.
230
+ *
231
+ * Rotated by index rather than chosen at random, so every form appears in
232
+ * proportion at any corpus size — a random choice would leave the rarest form
233
+ * absent from a 100-note run and present at 10,000, which is a difference
234
+ * between the two measurements that has nothing to do with scale.
235
+ *
236
+ * The `.md` extension is dropped from the wikilink forms because that is how
237
+ * they are written in practice, and tier 0 appends it.
238
+ */
239
+ function linkTo(form, sourcePath, targetPath) {
240
+ const stem = targetPath.replace(/\.md$/, '');
241
+ const basename = stem.slice(stem.lastIndexOf('/') + 1);
242
+ switch (form % 5) {
243
+ case 0:
244
+ return `[[${basename}]]`;
245
+ case 1:
246
+ return `[[/${stem}]]`;
247
+ case 2:
248
+ return `[[${relativePath(sourcePath, stem)}]]`;
249
+ case 3:
250
+ // Percent-encoded, which is the only difference between a Markdown href
251
+ // and a wikilink anywhere in the resolver.
252
+ return `[a note](${encodeURI(relativePath(sourcePath, targetPath))})`;
253
+ default:
254
+ return `[[${basename}|see the other note]]`;
255
+ }
256
+ }
257
+ /**
258
+ * Topic tags a metadata note draws two of, rotated by its index.
259
+ *
260
+ * A small colliding pool rather than per-note unique tags: a tag query whose
261
+ * every answer is one note measures lookup, not grouping, and the generic
262
+ * vocabulary keeps a generated corpus from naming anyone (see the header).
263
+ */
264
+ const TOPIC_TAGS = ['field-notes', 'gardening', 'journal', 'reference'];
265
+ /** The CJK tag on every metadata note, so tag workloads include a non-ASCII key. */
266
+ const CJK_TAG = '笔记';
267
+ /**
268
+ * Mixed CJK/Latin headings for every 25th metadata note.
269
+ *
270
+ * A pool rather than one string so a search or preview workload has more than
271
+ * one non-ASCII title to tokenize. The first is the title the benchmark
272
+ * snapshot already uses, so both harnesses describe the same corpus shape.
273
+ */
274
+ const CJK_TITLES = [
275
+ '星图与笔记:中英混排',
276
+ '潮汐名录:观测与记录',
277
+ '码头备忘:标点与行句',
278
+ ];
279
+ /**
280
+ * Three deterministic tags for one note, the last CJK.
281
+ *
282
+ * `index % 4` and `(index + 1) % 4` can never agree, so the first two are
283
+ * distinct without a retry loop. See {@link TOPIC_TAGS}.
284
+ */
285
+ function tagsFor(index) {
286
+ return [
287
+ TOPIC_TAGS[index % TOPIC_TAGS.length],
288
+ TOPIC_TAGS[(index + 1) % TOPIC_TAGS.length],
289
+ CJK_TAG,
290
+ ];
291
+ }
292
+ /** One note's Markdown, links included. */
293
+ function noteBody(index, path, paths, random, withheld, topology, metadata) {
294
+ const generatedTitle = sentence(random, 3 + Math.floor(random() * 3)).replace(/\.$/, '');
295
+ // Frontmatter on roughly a third of notes, and on every withheld one. Parsing
296
+ // and stripping it is per-note work the walk pays for, and a corpus with none
297
+ // would leave `frontmatterOf` measuring its own early return. Drawn here even
298
+ // when metadata writes a block regardless, so metadata mode consumes the same
299
+ // RNG sequence and its bodies differ from the default only by that metadata.
300
+ const sampledFrontmatter = withheld || random() < 0.3;
301
+ // Every 25th metadata note gets a mixed-script heading. The sentence above is
302
+ // still generated and discarded, for the same sequence argument.
303
+ const title = metadata && index % 25 === 0
304
+ ? CJK_TITLES[Math.floor(index / 25) % CJK_TITLES.length]
305
+ : generatedTitle;
306
+ const lines = [];
307
+ if (sampledFrontmatter || metadata) {
308
+ lines.push('---');
309
+ if (withheld)
310
+ lines.push('publish: false');
311
+ else if (sampledFrontmatter)
312
+ lines.push(`title: ${title}`);
313
+ if (metadata) {
314
+ lines.push(`tags: [${tagsFor(index).join(', ')}]`);
315
+ lines.push(`aliases: ["alias-${index}"]`);
316
+ }
317
+ lines.push('---', '');
318
+ }
319
+ lines.push(`# ${title}`, '');
320
+ const degree = outDegreeFor(index, paths.length, random, topology);
321
+ for (let paragraph = 0, total = paragraphsFor(random); paragraph < total; paragraph += 1) {
322
+ const parts = [sentence(random, 6 + Math.floor(random() * 14))];
323
+ // Links are spread across the note's paragraphs rather than listed at the
324
+ // end, so the traversal's edits land throughout the body. `applyEdits`
325
+ // replaces spans last-to-first, and a body whose links all sit in one region
326
+ // would not exercise that ordering across a realistic span distribution.
327
+ if (paragraph < degree) {
328
+ let target = paths[Math.floor(random() * paths.length)];
329
+ // Sparse mode promises every note an out-edge, and a drawn self-target
330
+ // would silently delete one. Replacing rather than redrawing keeps the
331
+ // RNG count — and so the default mode's bytes — unchanged.
332
+ if (target === path && topology === 'sparse') {
333
+ for (let step = 1; step < paths.length; step += 1) {
334
+ const candidate = paths[(index + step) % paths.length];
335
+ if (candidate !== path) {
336
+ target = candidate;
337
+ break;
338
+ }
339
+ }
340
+ }
341
+ if (target !== path)
342
+ parts.push(linkTo(index + paragraph, path, target));
343
+ // One link in twenty names nothing at all. This is the degrade-to-text
344
+ // branch, which allocates a replacement where a resolved link does not,
345
+ // and a corpus without it never runs.
346
+ if (random() < 0.05)
347
+ parts.push(`[[${WORDS[Math.floor(random() * WORDS.length)]}-${index}-absent]]`);
348
+ }
349
+ lines.push(parts.join(' '), '');
350
+ }
351
+ return `${lines.join('\n').trimEnd()}\n`;
352
+ }
353
+ /**
354
+ * Write a corpus of `notes` Markdown files, plus assets, into `directory`.
355
+ *
356
+ * The directory is the caller's to create and to clean up. Nothing is written
357
+ * anywhere else, and no path is derived from this repository's own layout.
358
+ */
359
+ export async function generateCorpus(directory, options) {
360
+ const random = seededRandom(options.seed ?? 1);
361
+ const topology = options.topology ?? 'skewed';
362
+ const metadata = options.metadata ?? false;
363
+ const paths = planPaths(options.notes, random);
364
+ const result = {
365
+ files: 0,
366
+ published: 0,
367
+ withheld: 0,
368
+ assets: 0,
369
+ bytes: 0,
370
+ };
371
+ // Directories are created once rather than per file: `mkdir recursive` on
372
+ // 10,000 files is 10,000 syscalls measuring the filesystem instead of the
373
+ // build.
374
+ const created = new Set();
375
+ const ensure = async (path) => {
376
+ const parent = dirname(path);
377
+ if (created.has(parent))
378
+ return;
379
+ await mkdir(parent, { recursive: true });
380
+ created.add(parent);
381
+ };
382
+ for (const [index, path] of paths.entries()) {
383
+ // One note in twenty-five withholds itself. That exercises rank 1 of the
384
+ // exclusion precedence and, more usefully for a benchmark, creates link
385
+ // targets that resolve to a real file this build did not publish — the
386
+ // `unpublished` outcome, which is the branch that rewrites a label rather
387
+ // than just replacing a span.
388
+ const withheld = random() < 0.04;
389
+ const body = noteBody(index, path, paths, random, withheld, topology, metadata);
390
+ const destination = join(directory, path);
391
+ await ensure(destination);
392
+ await writeFile(destination, body, 'utf8');
393
+ result.files += 1;
394
+ result.bytes += Buffer.byteLength(body, 'utf8');
395
+ if (withheld)
396
+ result.withheld += 1;
397
+ else
398
+ result.published += 1;
399
+ }
400
+ // Assets at one per twenty notes, which is the order a notes repository runs
401
+ // at. They are discovered and dropped as `not-markdown`, and every one is
402
+ // offered to every exclusion pattern first — so they are not inert filler,
403
+ // they are what the pattern-matching loop actually spends its time on.
404
+ const assets = Math.max(1, Math.floor(options.notes / 20));
405
+ for (let index = 0; index < assets; index += 1) {
406
+ const directoryName = DIRECTORIES[Math.floor(random() * DIRECTORIES.length)];
407
+ const extension = ASSET_EXTENSIONS[index % ASSET_EXTENSIONS.length];
408
+ const name = `asset-${index}${extension}`;
409
+ const path = join(directory, directoryName === '' ? name : `${directoryName}/${name}`);
410
+ await ensure(path);
411
+ // Bytes rather than an empty file: discovery does not read a non-Markdown
412
+ // file, and an empty one would let a regression that started reading them
413
+ // go unmeasured.
414
+ const contents = `${sentence(random, 40)}\n`;
415
+ await writeFile(path, contents, 'utf8');
416
+ result.files += 1;
417
+ result.assets += 1;
418
+ result.bytes += Buffer.byteLength(contents, 'utf8');
419
+ }
420
+ return result;
421
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `anc init`: prepare a notes repository for its first build.
3
+ *
4
+ * Two writes, and the argument for the ticket is that both are things a user
5
+ * cannot discover by running the tool. A build tells you what it published; it
6
+ * does not tell you that `dist/` is about to be committed, and it does not tell
7
+ * you that the site it just built points at a loopback origin nobody can reach.
8
+ *
9
+ * ## What it does, and what it deliberately does not
10
+ *
11
+ * 1. **Seeds `.gitignore`.** {@link ensureIgnored} has shipped since TK-25 with
12
+ * no caller — this is the caller. Everything about *how* to append to a
13
+ * stranger's `.gitignore` lives there, including the four probe states and
14
+ * the three measured ways to corrupt a working file.
15
+ * 2. **Writes a starter {@link CONFIG_FILENAME}, if there is not one already.**
16
+ *
17
+ * Three things the plan's §4.4 names for `init` are **not** written, each for a
18
+ * reason rather than for want of time:
19
+ *
20
+ * - **`.github/workflows/publish.yml`.** A workflow file names the action it
21
+ * calls, as `uses: <owner>/<repo>@<commit-sha>`. This repository has no git remote, so
22
+ * there is no such coordinate to write — `init` would put a `uses:` line
23
+ * naming a repository that does not exist into a stranger's CI, which fails
24
+ * on their first push with an error about *our* naming rather than about
25
+ * anything they did. It also picks a deploy target for them; the zero-secret
26
+ * GitHub Pages path is the documented default and Cloudflare is a supported
27
+ * alternative, and a file `init` wrote is the worst place for that choice to
28
+ * be made silently. `action.yml` at the root of this repository is the half
29
+ * that can exist today, and it does.
30
+ * - **A starter about and privacy note.** §4.4 ties those to deleting
31
+ * `src/pages/about.astro` and `privacy.astro`, which is not this ticket's
32
+ * scope, and until that lands a seeded note would sit beside a shipped page
33
+ * saying something different about the same subject.
34
+ * - **Prompts.** §4.4 has `init` fill `title` and `origin` from them. A prompt
35
+ * is unanswerable in CI and in a non-interactive shell, and the file this
36
+ * writes is one a user edits in the editor they already have open. What the
37
+ * template must do instead is make the two keys impossible to miss, which is
38
+ * what putting them first and commented does.
39
+ *
40
+ * ## Idempotence, which is the property that makes it safe to suggest
41
+ *
42
+ * A user will run this twice — after a failed first attempt, or because they
43
+ * forgot. `ensureIgnored` is idempotent by asking git rather than by searching
44
+ * for its own marker, so a user who edited or split the block still gets no
45
+ * duplicate. The config is idempotent by the blunter rule: **an existing file is
46
+ * never touched, whatever is in it.** Not "merged", not "updated", not
47
+ * "rewritten with the missing keys" — a configuration file is the one file in a
48
+ * notes repository whose contents are load-bearing for what stays private, and a
49
+ * tool that edits it is a tool that can silently drop an exclusion.
50
+ */
51
+ import { existsSync, statSync, writeFileSync } from 'node:fs';
52
+ import { join, resolve } from 'node:path';
53
+ import { CONFIG_FILENAME } from "./load-config.js";
54
+ import { ensureIgnored } from "./seed-gitignore.js";
55
+ import { BuildFailure } from "./write-report.js";
56
+ /**
57
+ * The starter configuration, with every key commented out.
58
+ *
59
+ * **An entirely commented file and an absent file produce the same build**, and
60
+ * that is measured rather than assumed: `parseConfig` over this text returns
61
+ * `{title: 'Notes', exclude: []}`, byte-identical to what an empty string and a
62
+ * missing file return. So writing it changes nothing about the site, which is
63
+ * what makes writing it unconditionally safe — the file is a menu, not a
64
+ * configuration.
65
+ *
66
+ * The three keys are {@link KNOWN_KEYS} in the order `load-config.ts` documents
67
+ * them, and `origin` carries the longest comment because it is the only key
68
+ * whose default is *wrong for a deployed site* rather than merely generic: with
69
+ * no origin every canonical link, the feed id, the sitemap, and `robots.txt`
70
+ * point at a loopback name. A user learns that from this file or from nowhere.
71
+ *
72
+ * Every glob is shown quoted, and the comment says why in one line. YAML's plain
73
+ * scalars are a language and a glob is not written in it — `!README.md` parses
74
+ * to the empty string and `! README.md` parses to `README.md`, which *inverts* a
75
+ * re-include into an exclusion. The loader refuses all of them loudly, so the
76
+ * cost of not knowing is a failed build rather than a leak; the quoting advice
77
+ * is what turns the failed build into no build at all.
78
+ *
79
+ * The example origin is `notes.example.org`: `example.org` is reserved by
80
+ * RFC 2606 for exactly this, so it can never become a real host somebody owns.
81
+ */
82
+ export const CONFIG_TEMPLATE = `# ${CONFIG_FILENAME} — every key is optional, and every key below is commented
83
+ # out. Delete a "#" to use one. With this file exactly as written, the build
84
+ # behaves as though it were not here at all.
85
+ #
86
+ # Everything in this repository publishes unless you exclude it. There is no
87
+ # opt-in; the work is deciding what to withhold.
88
+
89
+ # The site's name. Reaches the browser tab, page metadata, and the feed.
90
+ # title: My Notes
91
+
92
+ # The public address the site will be served from, with no path.
93
+ #
94
+ # Set this before you deploy. Without it every canonical link, the feed id, the
95
+ # sitemap, and robots.txt point at a loopback name that reaches nobody — the
96
+ # site builds and looks correct and is unreachable by anything that follows one
97
+ # of those links.
98
+ # origin: https://notes.example.org/
99
+
100
+ # Paths not to publish, as gitignore-style globs. "!" re-includes.
101
+ #
102
+ # Quote every pattern. YAML reads an unquoted "!" as a tag and discards it,
103
+ # which turns a rule that keeps a file into one that removes it.
104
+ #
105
+ # A pattern matching no files fails the build, with no override: a mistyped
106
+ # exclusion publishes what it was meant to withhold.
107
+ #
108
+ # Do not add "dist/**". Every file in a build's output is already dropped as
109
+ # not-markdown before exclusion is consulted, so the pattern changes a file's
110
+ # reason for being dropped rather than whether it is walked — measured, the
111
+ # discovered count goes up by one, not down, because the config file itself is
112
+ # then discovered too. Keep build output out of git with .gitignore, which
113
+ # this command seeds; exclude is for notes you wrote and do not want published.
114
+ # exclude:
115
+ # - "drafts/**"
116
+ # - "private/**"
117
+
118
+ # The site's default language, as a BCP 47 tag. Every note that declares no
119
+ # "language:" of its own takes it, and so does the site's navigation. It also
120
+ # picks the search index a page lands in, which matters for Chinese and
121
+ # Japanese: an English-indexed Chinese page is searchable only by whole
122
+ # sentences. Without it, the default is English.
123
+ # language: zh-CN
124
+
125
+ # A single note can also withhold itself, which no glob can re-include:
126
+ #
127
+ # ---
128
+ # publish: false
129
+ # ---
130
+ #
131
+ # Before deployment, run the installed command's "review" subcommand, inspect and
132
+ # commit .publish-set.json, then build with "--release". Ordinary builds remain previews.
133
+ `;
134
+ /**
135
+ * Parse `init`'s options.
136
+ *
137
+ * One flag, and it is `build`'s: the configuration file must land where the
138
+ * build will look for it. `loadConfig` reads {@link CONFIG_FILENAME} from the
139
+ * **content** directory rather than from the invocation directory, so an `init`
140
+ * with no such flag would write the file at the working directory while a user
141
+ * who then runs `build --content notes` gets a build that silently ignores it.
142
+ * That is this project's recurring failure exactly — a configuration that looks
143
+ * applied and is not — installed by the command whose job is to prevent it.
144
+ *
145
+ * The rejected token is never echoed, for the reason
146
+ * `bin/anc.mjs` gives at length: a printed argv token must be
147
+ * byte-equal to a spelling this tool's own table declares, and an unrecognised
148
+ * token is by definition not one.
149
+ */
150
+ export function parseInitArguments(argv) {
151
+ let content = '.';
152
+ for (let index = 0; index < argv.length; index += 1) {
153
+ const argument = argv[index];
154
+ if (argument !== '--content') {
155
+ throw new BuildFailure('unknown-option', 'unrecognised option for init', argument);
156
+ }
157
+ const value = argv[index + 1];
158
+ if (value === undefined || value.startsWith('--')) {
159
+ // `argument` is `--content` by construction — the branch above rejected
160
+ // everything else — so this is a literal of this file's own source.
161
+ throw new BuildFailure('missing-value', `${argument} needs a directory`);
162
+ }
163
+ content = value;
164
+ index += 1;
165
+ }
166
+ return { content };
167
+ }
168
+ /**
169
+ * Write the starter config, unless the user already has one.
170
+ *
171
+ * **Absent is the only case that writes**, and the check is `existsSync` on the
172
+ * one filename rather than anything cleverer. A near-miss spelling is
173
+ * deliberately not consulted: `loadConfig` refuses a build that finds
174
+ * `publish.config.yml`, by name and with the rename instruction, which is a
175
+ * better outcome than `init` quietly producing a second file beside the one the
176
+ * user meant.
177
+ */
178
+ export function writeStarterConfig(contentDirectory) {
179
+ const path = join(contentDirectory, CONFIG_FILENAME);
180
+ // `statSync().isFile()` rather than `existsSync`, which answers true for a
181
+ // *directory* of that name: measured, `init` then reported the config as
182
+ // "already present, left unchanged" when there was no config, and the build
183
+ // failed one command later with `exists but could not be read`. `init` is the
184
+ // command whose job is to leave the repository buildable, so it is the wrong
185
+ // command to be silent there.
186
+ //
187
+ // Refused rather than worked around. Removing a directory the user made is not
188
+ // this command's decision, and writing the file beside it is impossible.
189
+ let existing;
190
+ try {
191
+ existing = statSync(path);
192
+ }
193
+ catch {
194
+ existing = undefined;
195
+ }
196
+ if (existing !== undefined && !existing.isFile()) {
197
+ throw new BuildFailure('config-not-a-file', `${CONFIG_FILENAME} exists but is not a file, so no configuration can be written or read there. ` +
198
+ 'Move or remove it.', `${path} is not a regular file`);
199
+ }
200
+ if (existing !== undefined)
201
+ return 'present';
202
+ writeFileSync(path, CONFIG_TEMPLATE, 'utf8');
203
+ return 'written';
204
+ }
205
+ /**
206
+ * Seed the ignore rules and the configuration, in that order.
207
+ *
208
+ * The order is the fail-closed one and it is not incidental. `ensureIgnored`
209
+ * throws when git cannot be asked or a probe cannot be trusted — and on that run
210
+ * nothing should have been written yet, because a repository holding a config
211
+ * this tool wrote and no ignore rule is a repository one `git add -A` away from
212
+ * committing `dist/`. Writing the config second means the failure leaves the
213
+ * directory as it was found.
214
+ *
215
+ * `.gitignore` goes to the **repository root** and the config to the **content
216
+ * directory**, which are the same place in the ordinary case and deliberately
217
+ * not the same call: an ignore rule governs a worktree and a config governs the
218
+ * notes it sits with. `ensureIgnored` resolves the root itself, by asking git.
219
+ *
220
+ * **The directory is checked first, before either write.** A mistyped
221
+ * `--content` otherwise reaches `ensureIgnored`, whose `git -C <missing>` exits
222
+ * 128 and is reported as "not a repository" — so `init` would write a
223
+ * `.gitignore` into a directory `writeFileSync` then fails to put the config in,
224
+ * with a raw `ENOENT` carrying the user's own path onto a stream the disclosure
225
+ * rule governs. Measured: `mkdir` on the missing directory is the other option
226
+ * and is worse, because it makes a typo succeed.
227
+ */
228
+ export function initialise(contentDirectory) {
229
+ const resolved = resolve(contentDirectory);
230
+ if (!existsSync(resolved)) {
231
+ throw new BuildFailure('content-directory-not-found', 'content directory not found: the directory named by --content does not exist', resolved);
232
+ }
233
+ // **A content directory in no repository is not refused**, and there is
234
+ // deliberately no check here for one "outside the repository". A review
235
+ // proposed it and it cannot be written: `ensureIgnored` resolves the
236
+ // repository root *from the content directory itself*, so the `.gitignore` it
237
+ // seeds always governs the notes it was pointed at — a guard comparing the two
238
+ // would ask whether a directory is inside its own repository, which is true by
239
+ // construction. Measured with the guard in place: it never fired, on any of
240
+ // the three shapes tried.
241
+ //
242
+ // The reviewed symptom was real but is a different arrangement: content and
243
+ // invocation directories both under one repository, which is the ordinary
244
+ // nested case and correctly seeds that repository's root. What that leaves
245
+ // uncovered is the build output under a subdirectory, and `entriesFor` in
246
+ // `seed-gitignore.ts` is what closes it.
247
+ const ignore = ensureIgnored(resolved);
248
+ return { ignore, config: writeStarterConfig(resolved) };
249
+ }