@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,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
|
+
}
|