@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
@@ -31,7 +31,7 @@ import { embedRole } from "./content-embeds.mjs";
31
31
  import { renderImageFigures } from "./content-images.mjs";
32
32
  import { runContext } from "./content-scripts.mjs";
33
33
  import { eventNoteIndex } from "./event-fields.mjs";
34
- import { isHomepage } from "./homepage.mjs";
34
+ import { homepageTitle, isHomepage } from "./homepage.mjs";
35
35
  import { authoredFrontmatter, isNoteRecord, isStub } from "./index-records.mjs";
36
36
  import { noContentIndexPackages } from "./metadata-index.mjs";
37
37
  import { isGmNote } from "./note-vocabulary.mjs";
@@ -57,32 +57,56 @@ import { siteBase } from "../content-config.mjs";
57
57
  */
58
58
 
59
59
  /**
60
- * Build the object a preview with an index renders from.
60
+ * What a page of the package resolves against, from its content-index records.
61
+ *
62
+ * The site build reads the same answers from its whole tree; this derives
63
+ * them from the records alone, so a single note renders as its page does
64
+ * without the tree. Each note that publishes a page is an entry at the URL
65
+ * the site gives it: the homepage at the package root, a note
66
+ * `site.sections` names at its section, and every other at its address.
61
67
  *
62
68
  * @param {object} options
63
69
  * @param {object} options.config - The resolved package configuration.
64
- * @param {object[]} options.records - The content index's records: every note
65
- * and every asset.
70
+ * @param {object[]} options.records - The content index's records.
66
71
  * @param {object} [options.foreign] - The dependencies' indexes, as
67
72
  * {@link module:engine/metadata-index.loadForeignIndexes} returns them.
68
- * Omitted, the package links only to itself.
69
- * @returns {PreviewIndex} The index.
73
+ * @param {(record: object) => object[]|undefined} [options.anchorsOf] - A
74
+ * note's anchors as its page emits them, where they differ from the
75
+ * record's: those of its body with its query answers and generated sections
76
+ * expanded. Absent, or answering nothing, the record's own.
77
+ * @returns {{site: object, assets: object, events: Map<string, object>, runs: object,
78
+ * pages: Map<string, object>}} The link index, the asset address space,
79
+ * every note's events, the package's marked-text context, and the record
80
+ * behind each page by its URL.
70
81
  */
71
- export function previewIndex({ config, records, foreign }) {
82
+ export function recordSiteContext({ config, records, foreign, anchorsOf = () => undefined }) {
72
83
  const base = siteBase(config);
84
+ const sections = new Map(
85
+ Object.entries(config.site?.sections ?? {}).map(([name, address]) => [address, name]),
86
+ );
73
87
  const entries = [];
88
+ const pages = new Map();
74
89
  for (const record of records) {
75
90
  if (!isNoteRecord(record) || isStub(record)) continue;
76
91
  const fm = authoredFrontmatter(record);
77
92
  if (isGmNote(fm) || typeof fm.shortcode !== "string" || !fm.shortcode) continue;
93
+ const slug = addressSlug(fm);
94
+ const home = isHomepage(fm);
95
+ const url =
96
+ home ? base
97
+ : sections.has(slug) ? `${base}${sections.get(slug)}/`
98
+ : `${base}${slug}/`;
99
+ pages.set(url, record);
78
100
  entries.push({
79
101
  kind: "content",
80
102
  fm,
81
103
  body: "",
82
- anchors: record.anchors ?? [],
104
+ anchors: anchorsOf(record) ?? record.anchors ?? [],
83
105
  pkg: record.package ?? config.contentPackage,
84
- name: fm.name?.full ?? fm.shortcode,
85
- url: isHomepage(fm) ? base : `${base}${addressSlug(fm)}/`,
106
+ name:
107
+ fm.name?.full ??
108
+ (home ? homepageTitle(fm, config) : (record.file?.name ?? fm.shortcode)),
109
+ url,
86
110
  });
87
111
  }
88
112
  const site = buildSiteIndex(entries, {
@@ -99,6 +123,26 @@ export function previewIndex({ config, records, foreign }) {
99
123
  foreignIndex: foreign?.index,
100
124
  dates: site.dateContext,
101
125
  });
126
+ const runs = runContext(config, records, {
127
+ referenceName: (target) => resolveInfoboxRef(site, target)?.name,
128
+ });
129
+ return { site, assets, events, runs, pages };
130
+ }
131
+
132
+ /**
133
+ * Build the object a preview with an index renders from.
134
+ *
135
+ * @param {object} options
136
+ * @param {object} options.config - The resolved package configuration.
137
+ * @param {object[]} options.records - The content index's records: every note
138
+ * and every asset.
139
+ * @param {object} [options.foreign] - The dependencies' indexes, as
140
+ * {@link module:engine/metadata-index.loadForeignIndexes} returns them.
141
+ * Omitted, the package links only to itself.
142
+ * @returns {PreviewIndex} The index.
143
+ */
144
+ export function previewIndex({ config, records, foreign }) {
145
+ const { site, assets, events, runs } = recordSiteContext({ config, records, foreign });
102
146
  const resolveRole = (address) => embedRole(assets, address);
103
147
  const webSrc = (src) =>
104
148
  pathnameProblem(src) ? src : (resolvePathname(src, config)?.web ?? src);
@@ -107,9 +151,7 @@ export function previewIndex({ config, records, foreign }) {
107
151
  return key ? assets?.byPath?.get(key) : undefined;
108
152
  };
109
153
  return {
110
- runs: runContext(config, records, {
111
- referenceName: (target) => resolveInfoboxRef(site, target)?.name,
112
- }),
154
+ runs,
113
155
  icons: config.icons,
114
156
  lookupAsset,
115
157
  resolveSrc: webSrc,
@@ -51,61 +51,49 @@
51
51
  * @module
52
52
  */
53
53
 
54
- import { scanPageBreaks } from "./page-breaks.mjs";
55
- import { scanAlerts } from "./content-alerts.mjs";
56
- import { positionOfYamlPath } from "./diagnostics.mjs";
57
- import { decodeNoteAddresses, noteAddressContext } from "./note-addresses.mjs";
58
54
  import fs from "node:fs";
59
55
  import path from "node:path";
60
- import { createRequire } from "node:module";
61
- import { readMatter } from "./note-matter.mjs";
62
56
 
63
57
  import { addressSlug } from "./content-address.mjs";
64
- import { expandContent } from "./content-expand.mjs";
65
58
  import { eventNoteIndex } from "./event-fields.mjs";
66
59
  import { expandContentTables } from "./content-tables.mjs";
67
- import { scanSpans } from "./content-spans.mjs";
68
- import { runContext, scanRuns } from "./content-scripts.mjs";
69
- import { scanBlocks } from "./content-blocks.mjs";
70
- import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
60
+ import { runContext } from "./content-scripts.mjs";
71
61
  import { scanFigures } from "./content-figures.mjs";
72
- import { footnoteFindings } from "./content-footnotes.mjs";
73
- import { markupAnchorFindings } from "./anchors.mjs";
74
- import { checkImages, renderImageFigures } from "./content-images.mjs";
75
- import { isAddressTuple } from "./address.mjs";
76
- import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
62
+ import { resolvePathname } from "./pathnames.mjs";
77
63
  import { buildSiteIndex, figureIndexKeys, resolveInfoboxRef } from "./site-index.mjs";
78
- import { frontmatterWikilinks } from "./wikilinks.mjs";
79
64
  import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
80
- import { noteInfoboxes } from "./infobox-registry.mjs";
81
65
  import { formatUnaddressableFinding, unaddressableForeignPackages } from "./metadata-index.mjs";
82
66
  import { loadPackConfig } from "./pack-config.mjs";
83
- import { routerFor } from "./pack-router.mjs";
84
67
  import { searchableFrontmatter } from "./note-package.mjs";
85
68
  // The corpus, from the one pass that derives it.
86
69
  import { indexRecordsFor } from "./content-index.mjs";
87
- // A being's computed `age` — see `engine/being-age.mjs`. `pdf-build.mjs`
88
- // reuses `collectContentPages`'s pages, so wiring it here reaches the book too.
89
- import { applyComputedBeingAge } from "./being-age.mjs";
90
70
  import { normalizeCurrentDate } from "./current-date.mjs";
91
71
  import { reckoningContext } from "./reckoning-markers.mjs";
92
- import { resolvedDateFields } from "./note-dates.mjs";
93
72
  import { isNoteRecord, noteFile } from "./index-records.mjs";
94
- // The one statement of what an empty body means, shared with the index.
95
- import { isStubNote } from "./note-state.mjs";
96
73
  import { isGmNote } from "./note-vocabulary.mjs";
97
- import { ART_SLOTS, artPathname, assetAddressIndex } from "./art-fields.mjs";
74
+ import { artPathname, assetAddressIndex } from "./art-fields.mjs";
98
75
  import { embedRole } from "./content-embeds.mjs";
99
76
  import { checkHomepageCount, homepageFrontmatter, homepageTitle, isHomepage } from "./homepage.mjs";
100
77
  import { publishesContentPages, siteBase } from "../content-config.mjs";
101
78
  import { seeAlsoPages } from "./see-also.mjs";
102
79
  import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
103
- import { GENERATED_MARK } from "./site-reach.mjs";
104
- import { drawSiteMaps, inlineDrawingFigure } from "./site-maps.mjs";
105
- import { generatedDrawing } from "./generated-sections.mjs";
106
- import { IMAGE_PATTERN } from "./content-images.mjs";
80
+ import { drawSiteMaps } from "./site-maps.mjs";
81
+ // One page's render, shared with the single-note render of `./note-page`.
82
+ // A being's computed `age` is applied there — see `engine/being-age.mjs`;
83
+ // `pdf-build.mjs` reuses `collectContentPages`'s pages, so it reaches the book too.
84
+ import {
85
+ contentPage,
86
+ isPlainObject,
87
+ pageFrontmatter,
88
+ pageTitle,
89
+ parseNote,
90
+ renderSitePage,
91
+ resolveArtFields,
92
+ resolveSitePass,
93
+ sitePageDecorator,
94
+ } from "./site-page.mjs";
107
95
 
108
- const require = createRequire(import.meta.url);
96
+ export { pageFrontmatter, renderSitePage, resolveSitePass, sitePageDecorator };
109
97
 
110
98
  /**
111
99
  * The content-tree files this build publishes from, in the order it emits them.
@@ -150,33 +138,13 @@ function siteCorpusFiles(contentBase, ctx) {
150
138
  * @returns {{fm: object, body: string}|null}
151
139
  */
152
140
  function readNote(file, ctx) {
153
- let parsed;
154
141
  let source;
155
142
  try {
156
143
  source = fs.readFileSync(file, "utf8");
157
- parsed = readMatter(source);
158
144
  } catch {
159
145
  return null;
160
146
  }
161
- const context = {
162
- ...noteAddressContext(ctx.config ?? {}),
163
- package: ctx.contentPackage ?? ctx.config?.contentPackage,
164
- };
165
- try {
166
- return {
167
- fm: decodeNoteAddresses(parsed.data, context),
168
- body: parsed.content,
169
- bodyLine: source.slice(0, source.length - parsed.content.length).split("\n").length,
170
- };
171
- } catch (error) {
172
- const position = positionOfYamlPath(parsed.matter, error.keyPath ?? [], {
173
- key: error.addressKey,
174
- });
175
- if (position.line) position.line++;
176
- error.file = file;
177
- error.position = position;
178
- throw error;
179
- }
147
+ return parseNote(source, file, ctx);
180
148
  }
181
149
 
182
150
  /**
@@ -219,76 +187,19 @@ export function collectContentPages(contentBase, ctx) {
219
187
  if (ctx.only && file !== ctx.only) continue;
220
188
  const note = readNote(file, ctx);
221
189
  if (!note) continue;
222
- applyComputedBeingAge(note.fm, present, dates);
223
- const { fm, body, bodyLine } = note;
224
- // The configuration's, never a note's: a note declares no `package:`, so every
225
- // note in the tree belongs to the package this repository compiles.
226
- const pkg = ctx.contentPackage;
227
- if (!ctx.packages.has(pkg) || !fm.type) continue;
228
- // A homepage is addressed like any other note, but it is
229
- // gathered by {@link collectHomepages} rather than here: it is the
230
- // whole of a homepage-only build, which never walks the tree for
231
- // content pages at all.
232
- if (isHomepage(fm)) continue;
233
- // A stub has no body, so it has no page. Asked of the note's own text
234
- // rather than of the absent address the index derived, so the site and
235
- // the index apply one rule from one module and a note that cannot be
236
- // addressed for some *other* reason still reaches the finding below
237
- // that says so.
238
- if (isStubNote(fm, body) || isGmNote(fm)) continue;
239
-
240
- for (const hit of frontmatterWikilinks(fm)) {
241
- fmLinkFindings.push({ file, ...hit });
242
- }
243
-
244
- const name = fm.name?.full ?? path.basename(file, ".md");
245
- // The page's address, and therefore its URL. `name` is the
246
- // display string and nothing else: it titles the page and labels an
247
- // inbound link, and moving it moves no address.
248
- let slug;
249
- try {
250
- slug = addressSlug(fm);
251
- } catch (err) {
252
- addressFindings.push({ file, reason: err.message });
253
- continue;
254
- }
255
-
256
- const base = path.basename(file);
257
- const rel = path.relative(contentBase, file);
258
- pages.push({
259
- kind: "content",
260
- fm,
261
- // The note's own path on disk. Carried so a link finding can be
262
- // reported as `file:line:column:` against the source an author
263
- // edits, rather than against the page this build emits.
190
+ const made = contentPage(note, {
191
+ ...ctx,
264
192
  file,
265
- // The page's package, recorded once here so every consumer — the
266
- // index's canonical keys, the table universe, the local-package set
267
- // — reads one configured value and never frontmatter.
268
- pkg,
269
- body,
270
- bodyLine,
271
- name,
272
- slug,
273
- base,
274
- // Top-level content directory ("Rules", "Skills", …). Wikilinks do
275
- // not use it: a note is addressed as `type/shortcode` wherever it
276
- // is filed.
277
- tld: rel.split(path.sep)[0],
278
- // Location below the content root, POSIX-separated.
279
- relPath: rel.split(path.sep).join("/"),
280
- // The immediate source subfolder — the page's `kbfolder`.
193
+ fileBase: path.basename(file),
194
+ relPath: path.relative(contentBase, file).split(path.sep).join("/"),
281
195
  folder: path.basename(path.dirname(file)),
282
- // Every page is addressed by `(type, shortcode)` at the package
283
- // root, which takes no content mount.
284
- //
285
- // A note `site.sections` names is the page of its section, and is
286
- // published at the section rather than at its own address.
287
- url:
288
- ctx.sectionPages?.has(slug) ?
289
- `${ctx.base}${ctx.sectionPages.get(slug)}/`
290
- : `${ctx.base}${slug}/`,
196
+ present,
197
+ dates,
291
198
  });
199
+ if (!made) continue;
200
+ fmLinkFindings.push(...made.fmLinks);
201
+ if (made.addressFinding) addressFindings.push(made.addressFinding);
202
+ else pages.push(made.page);
292
203
  }
293
204
  return { pages, addressFindings, fmLinkFindings };
294
205
  }
@@ -574,281 +485,6 @@ export function tableUniverse(pages) {
574
485
  return byPackage;
575
486
  }
576
487
 
577
- /**
578
- * The frontmatter a page publishes with.
579
- *
580
- * A content page carries the package the build **derived**. No note
581
- * declares one, so the note's frontmatter alone would publish a page that does
582
- * not say which package it belongs to. Writing the derived value keeps a page
583
- * self-describing and makes sweeping the field out of a content tree
584
- * output-preserving for a site as it already is for the packs.
585
- *
586
- * **`related` is derived, never authored.** What links to a page is a fact
587
- * about every other page, known only once the whole tree has resolved — see
588
- * {@link module:engine/related-pages} — so an authored value is dropped, and
589
- * {@link renderPages} sets the derived block once it holds the graph.
590
- *
591
- * @param {object} page - The page.
592
- * @param {object} options
593
- * @param {(data: object, page: object) => void} [options.decorate] - Called
594
- * with each page's frontmatter, for whatever a consumer's own pass adds.
595
- * @param {(value: unknown, type: string, accepts?: Iterable<string>) => string|null} [options.artSrc] -
596
- * Translates an art address into the pathname the website resolver takes, or
597
- * `null` where nothing answers it.
598
- * @param {(src: string) => string} [options.webSrc] - Translates an authored
599
- * pathname into the address the website serves. Every artwork field goes
600
- * through it, so a page's `img:` and its body images name the same file the
601
- * same way.
602
- * @returns {object} The frontmatter.
603
- */
604
- export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
605
- const { fm, slug } = page;
606
- const data = {
607
- ...fm,
608
- ...(isPlainObject(fm.data) ? { data: { ...fm.data } } : {}),
609
- // Spread after the note's own frontmatter. Guarded because
610
- // `package: undefined` is not a value YAML can carry.
611
- ...(page.pkg ? { package: page.pkg } : {}),
612
- // The address, stated site-root relative; `slug` is its last segment.
613
- slug,
614
- url: `/${slug}/`,
615
- title: pageTitle(page),
616
- kbfolder: page.folder,
617
- };
618
- if (decorate) decorate(data, page);
619
- delete data.related;
620
- if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
621
- return data;
622
- }
623
-
624
- /**
625
- * The title a content page publishes under — the note's name.
626
- *
627
- * One rule, read by the page's own front matter and by every `related` entry
628
- * that names the page, so a card lists a page by exactly the title its heading
629
- * shows.
630
- *
631
- * @param {object} page - The page, from {@link collectContentPages}.
632
- * @returns {string} The title.
633
- */
634
- function pageTitle(page) {
635
- return page.name;
636
- }
637
-
638
- /**
639
- * Rewrite a page's artwork addresses into the URLs the website serves.
640
- *
641
- * The slots are read from {@link module:engine/art-fields.ART_SLOTS}, so a slot
642
- * added to the vocabulary reaches the page without anyone remembering this
643
- * function exists. Only an authored **string** is touched: `null` is a note
644
- * naming no art and `""` is one naming none on purpose, and neither is an
645
- * address to resolve. A slot's declining word (`banner: none`) is emitted as
646
- * a blank, the value a theme reads as "draw no image".
647
- *
648
- * **An address nothing answers is dropped**, not emitted as written. The theme
649
- * renders nothing where a value is absent — its silent-disappear convention —
650
- * and a raw address left in place would reach the reader as a broken image
651
- * source with nothing saying why.
652
- *
653
- * @param {object} data - The frontmatter being emitted, rewritten in place.
654
- * @param {(src: string) => string} webSrc - The website's pathname resolver.
655
- * @param {(value: unknown, type: string, accepts?: Iterable<string>) => string|null} artSrc -
656
- * The address resolver, yielding the pathname `webSrc` takes.
657
- * @returns {void}
658
- */
659
- function resolveArtFields(data, webSrc, artSrc) {
660
- for (const { key, type, accepts, declines } of ART_SLOTS) {
661
- const holder = isPlainObject(data.data) ? data.data : null;
662
- if (!holder) continue;
663
- const value = holder[key];
664
- if (declines !== undefined && value === declines) {
665
- holder[key] = "";
666
- continue;
667
- }
668
- if ((typeof value !== "string" && !isAddressTuple(value)) || value === "") continue;
669
- const pathname = artSrc(value, type, accepts);
670
- if (pathname === null) delete holder[key];
671
- else holder[key] = webSrc(pathname);
672
- }
673
- }
674
-
675
- /**
676
- * @param {unknown} value - Anything.
677
- * @returns {boolean} Whether it is a mapping a field may be read out of.
678
- */
679
- function isPlainObject(value) {
680
- return typeof value === "object" && value !== null && !Array.isArray(value);
681
- }
682
-
683
- /** Transform one content page. */
684
- export function renderSitePage(
685
- page,
686
- {
687
- index,
688
- foreign,
689
- pass = {},
690
- decorate,
691
- sqlTables,
692
- config,
693
- artIndex,
694
- figuresByAddress = new Map(),
695
- events,
696
- drawings = new Map(),
697
- runs = runContext(config, undefined, {
698
- referenceName: (target) => resolveInfoboxRef(index, target)?.name,
699
- }),
700
- },
701
- ) {
702
- const secretErrors = [];
703
- const captionErrors = [];
704
- const headingErrors = [];
705
- const footnoteErrors = [];
706
- const imageErrors = [];
707
- const embedErrors = [];
708
- const src = page.relPath ?? page.base;
709
-
710
- const seenImages = new Map();
711
- const webSrc = (src) => {
712
- const occurrence = (seenImages.get(src) ?? 0) + 1;
713
- seenImages.set(src, occurrence);
714
- const problem = pathnameProblem(src);
715
- if (problem) {
716
- imageErrors.push({ file: page.file, src, occurrence, message: problem });
717
- return src;
718
- }
719
- const forms = resolvePathname(src, config);
720
- if (!forms || forms.web !== null) return forms?.web ?? src;
721
- imageErrors.push({
722
- file: page.file,
723
- src,
724
- occurrence,
725
- message:
726
- `\`${src}\` names a file the \`${forms.package}\` package ships, and ` +
727
- "no asset host is configured to serve it from — set `site.assets` in " +
728
- "package-build.config.yaml. Emitted as authored, the address resolves " +
729
- "against the page's own URL, which is nowhere",
730
- });
731
- return src;
732
- };
733
- const artSrc = (value, type, accepts) => artPathname(artIndex, value, type, accepts).pathname;
734
- // The role lookup a captioned item's `map` counter reaches through — see
735
- // `engine/content-figures.mjs`'s `resolveRole`. It numbers the page's
736
- // figures once, in expansion, and the render below reads that numbering.
737
- const resolveRole = (address) => embedRole(artIndex, address);
738
- // The picture's role and pixel size, by the address it resolved to — read
739
- // before `webSrc` translates it to the page's own host. A body image is
740
- // free to write the bare, own-package form, so the address is normalized
741
- // to the pathname the asset index keys by first.
742
- const lookupAsset = (src) => {
743
- const key = assetPathnameKey(src, config);
744
- return key ? artIndex?.byPath?.get(key) : undefined;
745
- };
746
- // A generated **From here** section names the drawing the build made, which
747
- // the asset host does not serve: it is set inline, so its place names stay
748
- // links. A drawing this build did not make is left for the image pass,
749
- // which reports it.
750
- const inlineDrawings = (text) =>
751
- text.replace(new RegExp(IMAGE_PATTERN.source, "g"), (whole, alt, src) => {
752
- const name = generatedDrawing(src);
753
- const drawing = name ? drawings.get(name) : undefined;
754
- if (!drawing) return whole;
755
- return inlineDrawingFigure(fs.readFileSync(drawing.file, "utf8"), alt);
756
- });
757
-
758
- // The frontmatter's art resolves before the body's pictures, so a picture
759
- // named in both is counted in that order.
760
- const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
761
- const expanded = expandContent(page.body, {
762
- target: "web",
763
- index,
764
- note: { fm: page.fm, file: page.file, source: src, bodyLine: page.bodyLine },
765
- prepared: {
766
- sqlTables: sqlTables?.get(page.file),
767
- pageLists: sqlTables?.pageLists?.get(page.file),
768
- inline: sqlTables?.inline?.get(page.file),
769
- },
770
- events,
771
- resolveRole,
772
- figuresByAddress,
773
- links: {
774
- foreign: foreign.index,
775
- assets: artIndex,
776
- beforeLinks: pass.beforeLinks ? (text) => pass.beforeLinks(text, page) : undefined,
777
- afterLinks: (text) => renderImageFigures(inlineDrawings(text), webSrc, lookupAsset),
778
- },
779
- });
780
- const { lineMap } = expanded;
781
- const tableErrors = expanded.findings.tables;
782
- const expressionErrors = expanded.findings.expressions;
783
- const wikiErrors = expanded.findings.links;
784
- // The author's text and the sections the build generated after it are
785
- // told apart by a comment, so the site writer can leave the generated
786
- // links out of what makes a page reachable.
787
- const resolvedBody =
788
- expanded.generated ?
789
- `${expanded.authored}\n\n${GENERATED_MARK}\n\n${expanded.generated}`
790
- : expanded.markdown;
791
- for (const error of expanded.findings.captions)
792
- captionErrors.push({
793
- file: page.file,
794
- line: (page.bodyLine ?? 1) + (lineMap[error.line - 1]?.line ?? error.line - 1),
795
- column: error.column,
796
- message: error.message,
797
- });
798
- for (const error of [
799
- ...scanBlocks(page.body).errors,
800
- ...scanAlerts(page.body).errors,
801
- ...scanPageBreaks(page.body).errors,
802
- ...scanSpans(page.body).errors,
803
- ...scanRuns(page.body, runs).errors,
804
- ...markupAnchorFindings(page.body),
805
- ])
806
- secretErrors.push({
807
- file: error.file ?? page.file,
808
- line: error.file ? error.line : (page.bodyLine ?? 1) + error.line - 1,
809
- column: error.column,
810
- message: error.message,
811
- });
812
- for (const error of scanHeadingAttributes(page.body, page.bodyLine ?? 1).errors)
813
- headingErrors.push({ file: page.file, ...error });
814
- for (const error of withheldSections(page.body, page.bodyLine ?? 1).errors)
815
- headingErrors.push({ file: page.file, ...error });
816
- for (const error of footnoteFindings(page.body))
817
- footnoteErrors.push({
818
- file: error.file ?? page.file,
819
- line: error.file ? error.line : (page.bodyLine ?? 1) + error.line - 1,
820
- column: error.column,
821
- message: error.message,
822
- });
823
- // An image sharing its paragraph with other text, or an address or title
824
- // the lint already refuses — asked here too, so a site build run on its
825
- // own fails on the same input `renderImageFigures` otherwise renders as
826
- // though the directive were absent.
827
- for (const error of checkImages(page.body, page.file, { bodyLine: page.bodyLine ?? 1 }))
828
- embedErrors.push(error);
829
- return {
830
- page,
831
- // The expanded body and its numbered figures, for the renderer that
832
- // writes the whole page.
833
- markdown: resolvedBody,
834
- figures: expanded.figures.figures,
835
- data,
836
- // The links a generated section makes join no page's Related card:
837
- // that card is what the authors link, and the sections already list
838
- // what they derive.
839
- resolved: expanded.resolved.slice(0, expanded.authoredLinks),
840
- tableErrors,
841
- expressionErrors,
842
- secretErrors,
843
- captionErrors,
844
- headingErrors,
845
- footnoteErrors,
846
- wikiErrors,
847
- imageErrors,
848
- embedErrors,
849
- };
850
- }
851
-
852
488
  /**
853
489
  * Renders every page.
854
490
  *
@@ -1004,7 +640,10 @@ export function renderPages(pages, options) {
1004
640
  artIndex,
1005
641
  figuresByAddress,
1006
642
  events,
1007
- drawings: maps,
643
+ drawing: (name) => {
644
+ const drawn = maps.get(name);
645
+ return drawn ? fs.readFileSync(drawn.file, "utf8") : undefined;
646
+ },
1008
647
  runs,
1009
648
  });
1010
649
  const externalRunFindings = new Set();
@@ -1081,59 +720,6 @@ export function renderPages(pages, options) {
1081
720
  };
1082
721
  }
1083
722
 
1084
- /**
1085
- * The named pass bundles a configuration may select.
1086
- *
1087
- * `site.pass` is the one part of the site contract that is *code* — a
1088
- * repository's own body rewrites — so a configuration names a bundle instead of
1089
- * supplying one, exactly as `itemBuilders` names an item registry. Required
1090
- * lazily, so importing this module does not drag a package-specific half of the
1091
- * toolchain in behind it.
1092
- *
1093
- * @type {Readonly<Record<string, () => Function>>}
1094
- */
1095
- const SITE_PASSES = Object.freeze({
1096
- sohlKb: () => require("../sohl/kb-passes.mjs").sohlKbPass,
1097
- });
1098
-
1099
- /**
1100
- * Resolves `site.pass` to its bundle.
1101
- *
1102
- * @param {string|undefined} name - The configured name.
1103
- * @param {object} options - The configured options, plus `repoRoot`.
1104
- * @returns {{beforeLinks?: Function}} The bundle.
1105
- */
1106
- export function resolveSitePass(name, options) {
1107
- if (!name) return {};
1108
- const factory = SITE_PASSES[name];
1109
- if (!factory) {
1110
- throw new Error(
1111
- `unknown site pass ${JSON.stringify(name)} — expected one of ` +
1112
- `${Object.keys(SITE_PASSES).join(", ")}`,
1113
- );
1114
- }
1115
- return factory()(options);
1116
- }
1117
-
1118
- /** The site frontmatter decoration shared by the site writer and page preview. */
1119
- export function sitePageDecorator(config, index) {
1120
- const router = routerFor(config);
1121
- return (data, page) => {
1122
- // A being's dates reach the page; a note's events reach no page.
1123
- const { events: _events, ...resolvedDates } = resolvedDateFields(
1124
- page.fm,
1125
- index.dateContext,
1126
- );
1127
- if (Object.keys(resolvedDates).length) data.resolvedDates = resolvedDates;
1128
- data.infoboxes = noteInfoboxes(page.fm, {
1129
- resolve: (ref, hint) => resolveInfoboxRef(index, ref, hint),
1130
- router,
1131
- dates: index.dateContext,
1132
- contentPackage: index.contentPackage,
1133
- });
1134
- };
1135
- }
1136
-
1137
723
  /**
1138
724
  * Collects, checks and renders a content tree's pages, and reports what it found.
1139
725
  *