@heroiclands/package-build 25.6.2 → 25.7.0

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 (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +19 -1
  3. package/bin/content-commands.mjs +64 -79
  4. package/bin/package-build.mjs +25 -10
  5. package/ci/wip-workflows.test.mjs +252 -0
  6. package/content-config.mjs +118 -8
  7. package/docs/api.md +237 -47
  8. package/docs/commands.md +99 -35
  9. package/docs/configuration.md +93 -44
  10. package/docs/guides/release-and-deploy.md +26 -14
  11. package/docs/guides/site.md +53 -0
  12. package/engine/cdn-publish.mjs +164 -6
  13. package/engine/content-index.mjs +71 -19
  14. package/engine/content-spans.mjs +18 -1
  15. package/engine/content-styles.mjs +85 -0
  16. package/engine/derived-sections.mjs +4 -3
  17. package/engine/event-views.mjs +6 -4
  18. package/engine/helpers.mjs +33 -9
  19. package/engine/index.mjs +1 -0
  20. package/engine/note-cache.mjs +1107 -0
  21. package/engine/note-page.mjs +424 -0
  22. package/engine/pack-config.mjs +47 -4
  23. package/engine/pathnames.mjs +22 -7
  24. package/engine/prepared-only.mjs +32 -0
  25. package/engine/preview-index.mjs +55 -13
  26. package/engine/site-build.mjs +34 -448
  27. package/engine/site-config.mjs +33 -4
  28. package/engine/site-maps.mjs +16 -24
  29. package/engine/site-origins.mjs +23 -0
  30. package/engine/site-page.mjs +648 -0
  31. package/engine/site-render.mjs +142 -10
  32. package/engine/site-serve.mjs +1 -1
  33. package/engine/sql-tables.mjs +12 -3
  34. package/package.json +6 -1
  35. package/site-theme/static/css/style.css +14 -0
  36. package/types/content-config.d.mts +34 -0
  37. package/types/engine/cdn-publish.d.mts +22 -0
  38. package/types/engine/content-index.d.mts +36 -2
  39. package/types/engine/content-styles.d.mts +20 -0
  40. package/types/engine/derived-sections.d.mts +3 -3
  41. package/types/engine/event-views.d.mts +4 -3
  42. package/types/engine/helpers.d.mts +18 -1
  43. package/types/engine/index.d.mts +1 -0
  44. package/types/engine/note-cache.d.mts +149 -0
  45. package/types/engine/note-page.d.mts +92 -0
  46. package/types/engine/pack-config.d.mts +30 -0
  47. package/types/engine/prepared-only.d.mts +14 -0
  48. package/types/engine/preview-index.d.mts +35 -0
  49. package/types/engine/site-build.d.mts +5 -100
  50. package/types/engine/site-config.d.mts +3 -2
  51. package/types/engine/site-maps.d.mts +7 -11
  52. package/types/engine/site-origins.d.mts +9 -0
  53. package/types/engine/site-page.d.mts +261 -0
  54. package/types/engine/site-render.d.mts +20 -1
  55. package/types/engine/sql-tables.d.mts +8 -4
@@ -15,14 +15,17 @@
15
15
  * `package-build cdn publish` — every picture a package has, on the CDN, and
16
16
  * the art the package leaves out as a named asset set.
17
17
  *
18
+ * Internal publication uses the same plan with an atlas rsync writer and public
19
+ * HTTPS reads. Cloudflare publication keeps the S3-compatible transport.
20
+ *
18
21
  * **The layout is the deployed one.** Each icon and image is uploaded flat, as
19
- * `icons/<basename>` or `images/<basename>`, under `<site.assets>/<package>/`
20
- * — the address {@link module:engine/pathnames.resolvePathname} gives the
21
- * website for the same file, so the site and the CDN agree by construction.
22
+ * `icons/<basename>` or `images/<basename>`, under the selected CDN's package
23
+ * root. Cloudflare uses `site.assets`; internal uses `INTERNAL_CDN_HOST`.
24
+ * Select the same CDN origin for site builds that consume these pictures.
22
25
  * Shipped and excluded pictures both go up: the website shows all of them.
23
26
  *
24
- * **The bucket is served at `site.assets`.** An object's key is its path below
25
- * that URL, `<package>/images/<basename>`, so the key and the public address
27
+ * **Each destination preserves object keys.** An object's key is its path below
28
+ * the selected CDN origin, `<package>/images/<basename>`, so the key and the public address
26
29
  * differ only by the host.
27
30
  *
28
31
  * **The bytes are the staged bytes.** The repository's `assetTransform` runs
@@ -56,6 +59,9 @@
56
59
 
57
60
  import fs from "node:fs";
58
61
  import path from "node:path";
62
+ import os from "node:os";
63
+ import { execFile } from "node:child_process";
64
+ import { promisify, stripVTControlCharacters } from "node:util";
59
65
 
60
66
  import { AwsClient } from "aws4fetch";
61
67
 
@@ -278,7 +284,7 @@ export function s3Store({ endpoint, bucket, accessKeyId, secretAccessKey, fetch:
278
284
  export function publicStore(host, fetchImpl) {
279
285
  const base = host.replace(/\/+$/, "");
280
286
  const get = (key, method) =>
281
- (fetchImpl ?? fetch)(`${base}/${key}`, {
287
+ (fetchImpl ?? fetch)(`${base}/${key.split("/").map(encodeURIComponent).join("/")}`, {
282
288
  method,
283
289
  headers: method === "HEAD" ? IDENTITY : undefined,
284
290
  });
@@ -300,6 +306,156 @@ export function publicStore(host, fetchImpl) {
300
306
  };
301
307
  }
302
308
 
309
+ /** The internal CDN's public origin. Reads never require SSH credentials. */
310
+ export const INTERNAL_CDN_HOST = "https://cdn.internal.heroiclands.org";
311
+
312
+ /** The manual operator's ordinary SSH account on atlas. */
313
+ export const INTERNAL_CDN_DESTINATION = "atlas:/srv/preview/cdn";
314
+
315
+ /**
316
+ * Refuse traversal, absolute paths and control characters before touching a store.
317
+ *
318
+ * @param {string} key - A planned relative object key.
319
+ * @returns {void}
320
+ */
321
+ function validateCdnKey(key) {
322
+ if (
323
+ typeof key !== "string" ||
324
+ !/^[\x20-\x7e]+$/.test(key) ||
325
+ key.includes("\\") ||
326
+ key.split("/").some((part) => !part || part === "." || part === "..")
327
+ ) {
328
+ throw new Error("unsafe CDN key: expected a printable relative path without traversal");
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Retain the last actionable receiver diagnostic without commands or key material.
334
+ *
335
+ * @param {unknown} stderr - Child-process stderr, never the whole error message.
336
+ * @returns {string} Bounded, terminal-safe diagnostic text, or an empty string.
337
+ */
338
+ function transferDiagnostic(stderr) {
339
+ if (typeof stderr !== "string" && !Buffer.isBuffer(stderr)) return "";
340
+ const text = stripVTControlCharacters(String(stderr).slice(-8192))
341
+ .replace(
342
+ /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g,
343
+ "",
344
+ )
345
+ .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
346
+ let fallback = "";
347
+ for (const raw of text.split("\n").reverse()) {
348
+ const line = raw
349
+ .trim()
350
+ .replace(/^(?:\/[^\s/]+)*\/(rrsync|rsync|ssh)(?=(?: error)?: )/i, "$1");
351
+ if (/command failed|command:|\brsync\s+--|\bssh\s+-/i.test(line)) continue;
352
+ const known = line.match(
353
+ /Host key verification failed\.|Permission denied(?: \([A-Za-z0-9, -]{1,80}\))?\.?|REMOTE HOST IDENTIFICATION HAS CHANGED|Connection refused|Connection timed out|Could not resolve hostname/i,
354
+ );
355
+ if (known) return known[0];
356
+ if (!/^(?:r?rsync|ssh)(?: error)?: /i.test(line)) continue;
357
+ const context = line
358
+ .replace(/(["'])[^"']*\1/g, "[redacted]")
359
+ .replace(/(?:\/|[A-Za-z]:[\\/])[^\s,;)]+/g, "[path]")
360
+ .replace(/[A-Za-z0-9+/=]{48,}/g, "[redacted]")
361
+ .slice(0, 240);
362
+ if (/^rrsync(?: error)?: /i.test(line)) return context;
363
+ fallback ||= context;
364
+ }
365
+ return fallback;
366
+ }
367
+
368
+ /**
369
+ * Report an operation and its structured failure code, excluding error argv/message.
370
+ *
371
+ * @param {unknown} error - A filesystem or child-process error.
372
+ * @param {string} operation - The operation being attempted.
373
+ * @returns {string} The operator-facing failure.
374
+ */
375
+ function internalFailure(error, operation) {
376
+ const details = error && typeof error === "object" ? /** @type {any} */ (error) : {};
377
+ const code =
378
+ Number.isInteger(details.code) && details.code >= 0 && details.code <= 255 ?
379
+ `exit ${details.code}`
380
+ : typeof details.code === "string" && Object.hasOwn(os.constants.errno, details.code) ?
381
+ `code ${details.code}`
382
+ : typeof details.signal === "string" && /^SIG[A-Z0-9]{1,12}$/.test(details.signal) ?
383
+ `signal ${details.signal}`
384
+ : "";
385
+ const context = operation === "rsync transfer" ? transferDiagnostic(details.stderr) : "";
386
+ const action =
387
+ operation === "rsync transfer" ? "rsync transfer failed" : `could not ${operation}`;
388
+ return `internal CDN ${action}${code ? `; ${code}` : ""}${context ? `: ${context}` : ""}`;
389
+ }
390
+
391
+ /**
392
+ * Internal storage: HTTPS reads and rsync writes of the planned bytes only.
393
+ *
394
+ * The default uses the operator's normal atlas account. An explicitly configured
395
+ * SSH alias may select the forced-rrsync account with a relative `cdn` destination.
396
+ * SSH configuration and its agent supply keys; no remote shell is requested.
397
+ *
398
+ * @param {object} [opts]
399
+ * @param {string} [opts.destination] - `host:/srv/preview/cdn` or `alias:cdn`.
400
+ * @param {typeof fetch} [opts.fetch] - The fetch to use for public reads.
401
+ * @param {(args: string[]) => Promise<unknown>} [opts.transfer] - Run rsync.
402
+ * @returns {CdnStore} The store.
403
+ */
404
+ export function internalStore({
405
+ destination = INTERNAL_CDN_DESTINATION,
406
+ fetch: fetchImpl,
407
+ transfer = (args) => promisify(execFile)("rsync", args),
408
+ } = {}) {
409
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*:(?:\/srv\/preview\/)?cdn\/?$/.test(destination)) {
410
+ throw new Error(
411
+ "invalid internal CDN destination: use an SSH host alias followed by :/srv/preview/cdn or :cdn",
412
+ );
413
+ }
414
+ const target = `${destination.replace(/\/+$/, "")}/`;
415
+ const reader = publicStore(INTERNAL_CDN_HOST, fetchImpl);
416
+ return {
417
+ ...reader,
418
+ describe: `${destination} (${INTERNAL_CDN_HOST})`,
419
+ async write(key, body) {
420
+ validateCdnKey(key);
421
+ let scratch;
422
+ let operation = "create temporary directory";
423
+ let failure;
424
+ try {
425
+ scratch = fs.mkdtempSync(path.join(os.tmpdir(), "package-build-cdn-"));
426
+ const file = path.join(scratch, ...key.split("/"));
427
+ operation = "create staged directories";
428
+ fs.mkdirSync(path.dirname(file), { recursive: true });
429
+ operation = "write staged file";
430
+ fs.writeFileSync(file, body);
431
+ operation = "rsync transfer";
432
+ await transfer([
433
+ "--recursive",
434
+ "--relative",
435
+ "--ignore-times",
436
+ "--chmod=D755,F644",
437
+ "--omit-dir-times",
438
+ "--rsh=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes",
439
+ "--",
440
+ `${scratch}/./${key}`,
441
+ target,
442
+ ]);
443
+ } catch (error) {
444
+ failure = internalFailure(error, operation);
445
+ } finally {
446
+ if (scratch) {
447
+ try {
448
+ fs.rmSync(scratch, { recursive: true, force: true });
449
+ } catch (error) {
450
+ failure ??= internalFailure(error, "remove temporary directory");
451
+ }
452
+ }
453
+ }
454
+ if (failure) throw new Error(failure);
455
+ },
456
+ };
457
+ }
458
+
303
459
  /**
304
460
  * One picture to publish.
305
461
  *
@@ -444,6 +600,8 @@ async function pooled(items, limit, work) {
444
600
  * planned size once the run ends — or would be, in a dry run.
445
601
  */
446
602
  export async function publishToCdn(plan, { store, dryRun = false, log = () => {} }) {
603
+ for (const upload of plan.uploads) validateCdnKey(upload.key);
604
+ validateCdnKey(plan.set?.key);
447
605
  if (!dryRun && !store.write) {
448
606
  throw new Error(`the store (${store.describe}) is read-only, so it can only be dry-run`);
449
607
  }
@@ -1108,6 +1108,13 @@ export function resolveCultures(
1108
1108
  * @param {Iterable<string>} [options.nameTypes] - Additional bare name kinds from configured type declarations.
1109
1109
  * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [options.timings] -
1110
1110
  * Invocation stage accounting, which records each phase of the derivation.
1111
+ * @param {NoteDerivationCache} [options.cache] - Earlier work to answer from:
1112
+ * each note's parse, and the records derived from one note alone, which
1113
+ * depend on that note and on the context the tree gives every note (the
1114
+ * setting date and calendars, the addresses the notes declare, the asset
1115
+ * records, the address context and the configured kinds). Not consulted
1116
+ * for the records when `syntax` is given. Every pass that reads the whole
1117
+ * tree runs whatever the cache holds.
1111
1118
  * @returns {Array<Record<string, any>>} The records, in a total order that does
1112
1119
  * not depend on directory-read order.
1113
1120
  */
@@ -1131,6 +1138,7 @@ export function collectContentIndex(
1131
1138
  syntax,
1132
1139
  nameTypes,
1133
1140
  timings,
1141
+ cache,
1134
1142
  },
1135
1143
  ) {
1136
1144
  // Each phase runs until the next begins; the caller's stage closes the last.
@@ -1142,8 +1150,9 @@ export function collectContentIndex(
1142
1150
  phase("index read");
1143
1151
  const records = [];
1144
1152
  // Passed through rather than defaulted away: an absent scope is the
1145
- // caller's omission, and `walkMarkdownTree` says so.
1146
- const walkOpts = { skipDirectories, sources, ...(sources ? { renderDescription: false } : {}) };
1153
+ // caller's omission, and `walkMarkdownTree` says so. No record reads a
1154
+ // note's rendered description, so none is rendered.
1155
+ const walkOpts = { skipDirectories, sources, renderDescription: false, parse: cache?.parse };
1147
1156
 
1148
1157
  // A package may ship assets and no notes at all, in which case there is no
1149
1158
  // tree to walk and the index is its asset records. The caller decides
@@ -1202,6 +1211,29 @@ export function collectContentIndex(
1202
1211
  parsedNotes,
1203
1212
  contentPackage,
1204
1213
  );
1214
+ // Everything the tree gives each note's own records, so a cached note's
1215
+ // records are reused only where none of it moved.
1216
+ const noteContext =
1217
+ cache && !syntax ?
1218
+ cache.context({
1219
+ dates,
1220
+ assetRecords,
1221
+ declared: parsedNotes
1222
+ .map(({ frontmatter: fm }) => `${fm?.type}\0${fm?.shortcode}`)
1223
+ .sort(),
1224
+ addressContext: Object.entries(addressContext ?? { package: contentPackage })
1225
+ .filter(([, value]) => typeof value !== "function")
1226
+ .sort(([a], [b]) =>
1227
+ a < b ? -1
1228
+ : a > b ? 1
1229
+ : 0,
1230
+ ),
1231
+ contentPackage,
1232
+ languages,
1233
+ manifest,
1234
+ nameTypes: nameTypes ? [...nameTypes] : undefined,
1235
+ })
1236
+ : undefined;
1205
1237
 
1206
1238
  // Each recorded note with its body, for the words its prose cites.
1207
1239
  phase("index notes");
@@ -1220,23 +1252,25 @@ export function collectContentIndex(
1220
1252
  applyComputedBeingAge(fm, present, dates);
1221
1253
  const relPath = path.relative(contentBase, absPath);
1222
1254
  try {
1223
- const made = indexRecordsForNote({
1224
- frontmatter: fm,
1225
- relPath,
1226
- absPath,
1227
- raw,
1228
- contentPackage,
1229
- body,
1230
- bodyLine,
1231
- bodyColumn,
1232
- nameTypes,
1233
- manifest,
1234
- addressContext: decodeContext,
1235
- dateContext: dates,
1236
- resolveRole,
1237
- syntax,
1238
- runContext: { languages },
1239
- });
1255
+ const derive = () =>
1256
+ indexRecordsForNote({
1257
+ frontmatter: fm,
1258
+ relPath,
1259
+ absPath,
1260
+ raw,
1261
+ contentPackage,
1262
+ body,
1263
+ bodyLine,
1264
+ bodyColumn,
1265
+ nameTypes,
1266
+ manifest,
1267
+ addressContext: decodeContext,
1268
+ dateContext: dates,
1269
+ resolveRole,
1270
+ syntax,
1271
+ runContext: { languages },
1272
+ });
1273
+ const made = cache ? cache.records(absPath, noteContext, derive) : derive();
1240
1274
  records.push(...made);
1241
1275
  bodies.push({ record: made[0], body });
1242
1276
  } catch (err) {
@@ -1533,6 +1567,8 @@ function dependencyAddresses(config) {
1533
1567
  * @param {Map<string, object>} [opts.foreignIndex] - Captured dependency address view.
1534
1568
  * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [opts.timings] -
1535
1569
  * Invocation stage accounting.
1570
+ * @param {NoteDerivationCache} [opts.cache] - Earlier parses and per-note
1571
+ * records to answer from; see {@link collectContentIndex}.
1536
1572
  * @returns {object[]} One record per note, plus one per documentation entry.
1537
1573
  */
1538
1574
  export function indexRecordsFor({
@@ -1547,6 +1583,7 @@ export function indexRecordsFor({
1547
1583
  nameTypes,
1548
1584
  foreignIndex,
1549
1585
  timings,
1586
+ cache,
1550
1587
  } = {}) {
1551
1588
  const resolved = config ?? loadPackConfig();
1552
1589
  const tree = contentBase ?? resolved.paths.content;
@@ -1582,6 +1619,7 @@ export function indexRecordsFor({
1582
1619
  syntax,
1583
1620
  nameTypes: nameTypes ?? resolved.itemTypes,
1584
1621
  timings,
1622
+ cache,
1585
1623
  });
1586
1624
  }
1587
1625
 
@@ -1802,6 +1840,20 @@ export async function prepareContentIndex({ contentBase, config, session } = {})
1802
1840
  };
1803
1841
  }
1804
1842
 
1843
+ /**
1844
+ * Earlier derivation work {@link collectContentIndex} may answer from, such as
1845
+ * {@link module:engine/note-cache.noteTree}'s store.
1846
+ * @typedef {object} NoteDerivationCache
1847
+ * @property {typeof import("./helpers.mjs").parseMarkdownFile} [parse] - Reads
1848
+ * one note, as {@link module:engine/helpers.parseMarkdownFile} does.
1849
+ * @property {(value: unknown) => string|undefined} context - A key for the
1850
+ * context the tree gives each note's records, or nothing when it has none.
1851
+ * @property {(absPath: string, context: string|undefined, derive: () => object[]) => object[]} records -
1852
+ * The records one note derives: the stored ones when that note's text and
1853
+ * the context are unchanged, else `derive()`'s. Either is the caller's to
1854
+ * change.
1855
+ */
1856
+
1805
1857
  /**
1806
1858
  * A captured source context, consumed without mutation by the synchronous emitter.
1807
1859
  * @typedef {object} PreparedContentIndex
@@ -23,6 +23,23 @@ import { parseExtensionAttributes, refusedAttributes } from "./extension-attribu
23
23
  import { inlineText } from "./name-markup.mjs";
24
24
  import { cartoucheProblems, glyphProblems } from "./content-glyphs.mjs";
25
25
 
26
+ /**
27
+ * Whether the text before `at` ends in `@` and one or more ASCII letters, as
28
+ * a language tag does.
29
+ *
30
+ * Read backwards from `at`, so each bracket costs its own tag's length rather
31
+ * than the length of everything before it.
32
+ *
33
+ * @param {string} text - The source.
34
+ * @param {number} at - The offset of the bracket.
35
+ * @returns {boolean} Whether a tag ends there.
36
+ */
37
+ function followsLanguageTag(text, at) {
38
+ let start = at;
39
+ while (start > 0 && /[A-Za-z]/.test(text[start - 1])) start--;
40
+ return start < at && text[start - 1] === "@";
41
+ }
42
+
26
43
  /** Add bracket spans after recognized Markdown links and footnotes.
27
44
  * @param {import("markdown-it")} md - Markdown parser to extend.
28
45
  * @param {object} [options] - Surface presentation.
@@ -65,7 +82,7 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false, verse
65
82
  // adjacent closing brackets without parsing every label twice.
66
83
  (state.src[start + 1] === "[" &&
67
84
  state.src[state.src.indexOf("]", start + 2) + 1] === "]") ||
68
- /@[A-Za-z]+$/.test(state.src.slice(0, start)) ||
85
+ followsLanguageTag(state.src, start) ||
69
86
  state.src[start + 1] === "^" ||
70
87
  ["!", "]"].includes(state.src[start - 1]) ||
71
88
  (state.src[start - 1] === "[" && state.linkLevel === 0)
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import fs from "node:fs";
28
+ import { fileURLToPath } from "node:url";
28
29
 
29
30
  import { baseStylesheetSource } from "./base-styles.mjs";
30
31
 
@@ -89,3 +90,87 @@ export function highlightRules(scope) {
89
90
  export function siteCodeStylesheet(scope = ".single-body") {
90
91
  return `${scope} :is(pre, code) {\n color-scheme: dark;\n}\n\n${highlightRules(scope)}`;
91
92
  }
93
+
94
+ /** The stretches of the site theme's stylesheet that style a page body. */
95
+ const NOTE_BODY_RULES = /\/\* note-body \*\/([^]*?)\/\* \/note-body \*\//g;
96
+
97
+ /**
98
+ * The page-wide rules the site theme writes for the whole document, and the
99
+ * selector each is given for a body shown on its own: the reset, the root
100
+ * size the theme's `rem` lengths are measured against, the body's text and
101
+ * the ordinary link. `keep` names the declarations that carry over; a rule
102
+ * without one carries over whole.
103
+ */
104
+ const PAGE_RULES = Object.freeze([
105
+ {
106
+ from: "*, *::before, *::after",
107
+ to: ".single-content, .single-content *, .single-content *::before, .single-content *::after",
108
+ },
109
+ { from: "html", to: ":root", keep: ["font-size"] },
110
+ {
111
+ from: "body",
112
+ to: ".single-content",
113
+ keep: [
114
+ "font-family",
115
+ "font-weight",
116
+ "line-height",
117
+ "color",
118
+ "background",
119
+ "-webkit-font-smoothing",
120
+ ],
121
+ },
122
+ { from: "a", to: ".single-content a" },
123
+ { from: "a:hover", to: ".single-content a:hover" },
124
+ ]);
125
+
126
+ /**
127
+ * One page-wide rule of the theme, given the selector a body shown on its own
128
+ * takes.
129
+ *
130
+ * @param {string} css - The theme's stylesheet.
131
+ * @param {{from: string, to: string, keep?: readonly string[]}} rule - Which
132
+ * rule, and how it carries over.
133
+ * @returns {string} The rule.
134
+ * @throws {Error} When the stylesheet has no such rule.
135
+ */
136
+ function scopedPageRule(css, { from, to, keep }) {
137
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
138
+ const found = new RegExp(`^${escaped} \\{([^}]*)\\}`, "m").exec(css);
139
+ if (!found) throw new Error(`the site stylesheet has no \`${from}\` rule for a page body`);
140
+ const declarations = found[1]
141
+ .split(";")
142
+ .map((declaration) => declaration.trim())
143
+ .filter(Boolean)
144
+ .filter((declaration) => !keep || keep.includes(declaration.split(":")[0].trim()));
145
+ return `${to} {\n${declarations.map((declaration) => ` ${declaration};`).join("\n")}\n}`;
146
+ }
147
+
148
+ /**
149
+ * The CSS the site theme applies to a page body: its custom properties, the
150
+ * body's text, named blocks, figures, verse, footnotes, unresolved and draft
151
+ * links, cartouches, glyphs, alerts, spoilers, dictionary lists and faces,
152
+ * then the highlighted-code rules a site links as `css/code.css`.
153
+ *
154
+ * Read from the theme's own stylesheet, the stretches it marks `note-body`,
155
+ * so a rule changed there changes here. The rules are scoped as the theme
156
+ * scopes them: the body sits in a `.single-body` inside a `.single-content`.
157
+ * The theme's page-wide rules for the reset, the body's typography and the
158
+ * ordinary link come first, scoped to `.single-content`, so a body set in that
159
+ * wrapper reads as the page does whatever the host's own styles; the one
160
+ * exception is the root font size, set on `:root`, since every length the
161
+ * theme writes in `rem` is measured against it.
162
+ *
163
+ * @returns {string} The stylesheet.
164
+ * @throws {Error} When the theme's stylesheet marks no body rules, or lacks
165
+ * one of the page-wide rules.
166
+ */
167
+ export function noteBodyStylesheet() {
168
+ const css = fs.readFileSync(
169
+ fileURLToPath(new URL("../site-theme/static/css/style.css", import.meta.url)),
170
+ "utf8",
171
+ );
172
+ const rules = [...css.matchAll(NOTE_BODY_RULES)].map((match) => match[1].trim());
173
+ if (!rules.length) throw new Error("the site stylesheet marks no rules for a page body");
174
+ const page = PAGE_RULES.map((rule) => scopedPageRule(css, rule));
175
+ return `${[...page, ...rules].join("\n\n")}\n\n${siteCodeStylesheet()}`;
176
+ }
@@ -58,6 +58,7 @@ import { mapWorld } from "./map-places.mjs";
58
58
  import { officeBodyKey, officeHolders } from "./office-holders.mjs";
59
59
  import { renderAddress } from "./address.mjs";
60
60
  import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
61
+ import { outsideOnly } from "./prepared-only.mjs";
61
62
 
62
63
  /**
63
64
  * The heading each kind of place is grouped under, in the order the place
@@ -282,8 +283,8 @@ export function drawMapsFrom(records, { outDir, foreignIndex = new Map(), config
282
283
  * @param {object} [opts.config] - The resolved configuration.
283
284
  * @param {(file:string)=>string|undefined} [opts.rawOf] - Captured source locators.
284
285
  * @param {object[]} [opts.all=records] - Complete records for settlement validation.
285
- * @param {string} [opts.only] - One note file to give sections to; absent,
286
- * every note.
286
+ * @param {string|Set<string>} [opts.only] - The note file, or files, to give
287
+ * sections to; absent, every note.
287
288
  * @returns {Map<string, Map<string, string>>} Note file to its sections, each
288
289
  * keyed by its slug, for every note given at least one.
289
290
  */
@@ -319,7 +320,7 @@ export function derivedSections(
319
320
 
320
321
  const out = new Map();
321
322
  for (const record of pages) {
322
- if (only !== undefined && fileOf(record) !== only) continue;
323
+ if (outsideOnly(only, fileOf(record))) continue;
323
324
  const key = record.address.canonical;
324
325
  const lists = holdings.get(key) ?? {};
325
326
  const sections = new Map();
@@ -42,6 +42,7 @@
42
42
  import { ownDocumentSystem, renderAddress } from "./address.mjs";
43
43
  import { authoredSection, sectionHeading } from "./generated-sections.mjs";
44
44
  import { calendarAddress } from "./reckoning-markers.mjs";
45
+ import { outsideOnly } from "./prepared-only.mjs";
45
46
 
46
47
  /** A literal SQL string. */
47
48
  const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
@@ -428,8 +429,9 @@ function section(view, query) {
428
429
  * @param {Iterable<object>} records - The content-index records.
429
430
  * @param {(record: object) => string} fileOf - A record's note file, as the
430
431
  * prepared results are keyed.
431
- * @param {string} [only] - One note file to give views to, every note's being
432
- * read for them; absent, every note is given its views.
432
+ * @param {string|Set<string>} [only] - The note file, or files, to give views
433
+ * to, every note's being read for them; absent, every note is given its
434
+ * views.
433
435
  * @returns {Promise<Map<string, Map<string, string>>>} Note file to its views,
434
436
  * each keyed by its slug, for every note given at least one.
435
437
  */
@@ -448,7 +450,7 @@ export async function eventViewSections(db, records, fileOf, only) {
448
450
  const selves = [];
449
451
  for (const record of all) {
450
452
  if (!record?.address || record.documents || !record.shortcode) continue;
451
- if (only !== undefined && fileOf(record) !== only) continue;
453
+ if (outsideOnly(only, fileOf(record))) continue;
452
454
  if (!view.applies(record) || authoredSection(record, view.slug)) continue;
453
455
  const self = selfForms(record);
454
456
  if (view.query(self, shape)) selves.push(self);
@@ -460,7 +462,7 @@ export async function eventViewSections(db, records, fileOf, only) {
460
462
  }
461
463
  for (const record of all) {
462
464
  if (!record?.address || record.documents || !record.shortcode) continue;
463
- if (only !== undefined && fileOf(record) !== only) continue;
465
+ if (outsideOnly(only, fileOf(record))) continue;
464
466
  const self = selfForms(record);
465
467
  const sections = new Map();
466
468
  for (const view of EVENT_VIEWS) {
@@ -356,6 +356,9 @@ export function assertSuppliedCorpus(records, who) {
356
356
  * @param {Map<string, {raw: string}>} [opts.sources] - Selected sources in walk order.
357
357
  * Supplied, the walk parses these strings instead of reading the filesystem.
358
358
  * @param {boolean} [opts.renderDescription=true] - Render each body's description.
359
+ * @param {typeof parseMarkdownFile} [opts.parse] - What reads one file, called
360
+ * as {@link parseMarkdownFile} is; a caller holding earlier parses answers
361
+ * from them.
359
362
  * @yields {{frontmatter: object|null, body: string, description: string,
360
363
  * file: string, absPath: string, bodyLine?: number, bodyColumn?: number}}
361
364
  * One entry per `.md` file found.
@@ -364,7 +367,13 @@ export function assertSuppliedCorpus(records, who) {
364
367
  */
365
368
  export function* walkMarkdownTree(
366
369
  rootDir,
367
- { skipDirectories, addressContext, sources, renderDescription = true } = {},
370
+ {
371
+ skipDirectories,
372
+ addressContext,
373
+ sources,
374
+ renderDescription = true,
375
+ parse = parseMarkdownFile,
376
+ } = {},
368
377
  ) {
369
378
  // Stated by the caller, never resolved here. A default here
370
379
  // — `loadPackConfig().skipDirectories` — read whichever configuration
@@ -381,7 +390,7 @@ export function* walkMarkdownTree(
381
390
  if (sources) {
382
391
  for (const [absPath, source] of sources) {
383
392
  yield {
384
- ...parseMarkdownFile(absPath, {
393
+ ...parse(absPath, {
385
394
  addressContext,
386
395
  raw: source.raw,
387
396
  renderDescription,
@@ -392,6 +401,27 @@ export function* walkMarkdownTree(
392
401
  }
393
402
  return;
394
403
  }
404
+ for (const absPath of walkMarkdownFiles(rootDir, { skipDirectories })) {
405
+ yield {
406
+ ...parse(absPath, { addressContext, renderDescription }),
407
+ file: path.basename(absPath),
408
+ absPath,
409
+ };
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Recursively yields the path of every `.md` file under `rootDir`, in the
415
+ * order {@link walkMarkdownTree} reads them, without reading any.
416
+ *
417
+ * @param {string} rootDir - Root of the tree to walk.
418
+ * @param {{skipDirectories: readonly string[]}} opts - Directory names to
419
+ * ignore wherever they appear; required, as for {@link walkMarkdownTree}.
420
+ * @yields {string} Each file's absolute path.
421
+ * @throws {Error} When `skipDirectories` is not stated.
422
+ */
423
+ export function* walkMarkdownFiles(rootDir, { skipDirectories } = {}) {
424
+ assertStatedScope(skipDirectories, "walkMarkdownFiles");
395
425
  if (!fs.existsSync(rootDir)) return;
396
426
  const stack = [rootDir];
397
427
  while (stack.length > 0) {
@@ -408,13 +438,7 @@ export function* walkMarkdownTree(
408
438
  if (entry.isDirectory()) {
409
439
  if (skipDirectories.includes(entry.name)) continue;
410
440
  stack.push(absPath);
411
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
412
- yield {
413
- ...parseMarkdownFile(absPath, { addressContext, renderDescription }),
414
- file: entry.name,
415
- absPath,
416
- };
417
- }
441
+ } else if (entry.isFile() && entry.name.endsWith(".md")) yield absPath;
418
442
  }
419
443
  }
420
444
  }
package/engine/index.mjs CHANGED
@@ -212,6 +212,7 @@ export * as packagebuildIndex from "./packagebuild-index.mjs";
212
212
 
213
213
  /** Publishing a content tree as a website: the pass, and its integrity gates. */
214
214
  export * as siteBuild from "./site-build.mjs";
215
+ export * as sitePage from "./site-page.mjs";
215
216
 
216
217
  /** In-memory page rendering for an editor preview. */
217
218
  export * as sitePreview from "./site-preview.mjs";