@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.
- package/CHANGELOG.md +23 -0
- package/README.md +19 -1
- package/bin/content-commands.mjs +64 -79
- package/bin/package-build.mjs +25 -10
- package/ci/wip-workflows.test.mjs +252 -0
- package/content-config.mjs +118 -8
- package/docs/api.md +237 -47
- package/docs/commands.md +99 -35
- package/docs/configuration.md +93 -44
- package/docs/guides/release-and-deploy.md +26 -14
- package/docs/guides/site.md +53 -0
- package/engine/cdn-publish.mjs +164 -6
- package/engine/content-index.mjs +71 -19
- package/engine/content-spans.mjs +18 -1
- package/engine/content-styles.mjs +85 -0
- package/engine/derived-sections.mjs +4 -3
- package/engine/event-views.mjs +6 -4
- package/engine/helpers.mjs +33 -9
- package/engine/index.mjs +1 -0
- package/engine/note-cache.mjs +1107 -0
- package/engine/note-page.mjs +424 -0
- package/engine/pack-config.mjs +47 -4
- package/engine/pathnames.mjs +22 -7
- package/engine/prepared-only.mjs +32 -0
- package/engine/preview-index.mjs +55 -13
- package/engine/site-build.mjs +34 -448
- package/engine/site-config.mjs +33 -4
- package/engine/site-maps.mjs +16 -24
- package/engine/site-origins.mjs +23 -0
- package/engine/site-page.mjs +648 -0
- package/engine/site-render.mjs +142 -10
- package/engine/site-serve.mjs +1 -1
- package/engine/sql-tables.mjs +12 -3
- package/package.json +6 -1
- package/site-theme/static/css/style.css +14 -0
- package/types/content-config.d.mts +34 -0
- package/types/engine/cdn-publish.d.mts +22 -0
- package/types/engine/content-index.d.mts +36 -2
- package/types/engine/content-styles.d.mts +20 -0
- package/types/engine/derived-sections.d.mts +3 -3
- package/types/engine/event-views.d.mts +4 -3
- package/types/engine/helpers.d.mts +18 -1
- package/types/engine/index.d.mts +1 -0
- package/types/engine/note-cache.d.mts +149 -0
- package/types/engine/note-page.d.mts +92 -0
- package/types/engine/pack-config.d.mts +30 -0
- package/types/engine/prepared-only.d.mts +14 -0
- package/types/engine/preview-index.d.mts +35 -0
- package/types/engine/site-build.d.mts +5 -100
- package/types/engine/site-config.d.mts +3 -2
- package/types/engine/site-maps.d.mts +7 -11
- package/types/engine/site-origins.d.mts +9 -0
- package/types/engine/site-page.d.mts +261 -0
- package/types/engine/site-render.d.mts +20 -1
- package/types/engine/sql-tables.d.mts +8 -4
package/engine/cdn-publish.mjs
CHANGED
|
@@ -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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
* **
|
|
25
|
-
*
|
|
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
|
}
|
package/engine/content-index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
package/engine/content-spans.mjs
CHANGED
|
@@ -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
|
-
|
|
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] -
|
|
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
|
|
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();
|
package/engine/event-views.mjs
CHANGED
|
@@ -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] -
|
|
432
|
-
* read for them; absent, every note is given its
|
|
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
|
|
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
|
|
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) {
|
package/engine/helpers.mjs
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
...
|
|
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";
|