@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
@@ -228,6 +228,93 @@ export function siteBase(config) {
228
228
  return config.site.base || `/${config.contentPackage}/`;
229
229
  }
230
230
 
231
+ /**
232
+ * Apply validated runtime site URLs to a resolved configuration, without changing it.
233
+ * The caller checks the authored homepage before applying these build options.
234
+ *
235
+ * @param {ContentBuildConfig} config - The resolved authored configuration.
236
+ * @param {object} options - Runtime options.
237
+ * @param {string} [options.root] - Domain from which www and CDN origins derive.
238
+ * Defaults to internal.heroiclands.org; explicit root replaces configured assets.
239
+ * @param {string} [options.baseUrl] - Absolute HTTP(S) site URL ending in `/`;
240
+ * overrides the root-derived site URL.
241
+ * @param {string} [options.assets] - Absolute HTTP(S) asset root; overrides both
242
+ * root-derived and configured assets.
243
+ * @returns {ContentBuildConfig} The effective configuration, frozen. Its homepage
244
+ * and site.assets are the selected inputs for downstream site integrations.
245
+ */
246
+ export function siteBuildConfig(config, { root, baseUrl, assets } = {}) {
247
+ const domain = root ?? "internal.heroiclands.org";
248
+ if (
249
+ typeof domain !== "string" ||
250
+ domain.length > 253 ||
251
+ !domain.includes(".") ||
252
+ !domain.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))
253
+ ) {
254
+ throw new TypeError("--root must be a domain without a scheme, path, port or credentials");
255
+ }
256
+ const homepage =
257
+ baseUrl === undefined ?
258
+ `https://www.${domain.toLowerCase()}${new URL(config.homepage).pathname}`
259
+ : buildUrl(baseUrl, "--base-url").href;
260
+ const base = normalizeSiteBase(new URL(homepage).pathname);
261
+ const assetRoot =
262
+ assets !== undefined ? normalizeSiteAssets(buildUrl(assets, "--assets").href)
263
+ : root !== undefined ? `https://cdn.${domain.toLowerCase()}`
264
+ : config.site.assets || `https://cdn.${domain.toLowerCase()}`;
265
+ checkSiteSitemaps(config.site.sitemaps, base, config.contentPackage);
266
+ return Object.freeze({
267
+ ...config,
268
+ homepage,
269
+ site: Object.freeze({ ...config.site, base, assets: assetRoot }),
270
+ });
271
+ }
272
+
273
+ /**
274
+ * A runtime web root must survive URL joining and mapping to a local directory.
275
+ *
276
+ * @param {string} value - The command-line value.
277
+ * @param {string} option - The option naming it.
278
+ * @returns {URL} The checked URL.
279
+ */
280
+ function buildUrl(value, option) {
281
+ // URL parsing consumes both literal and escaped dot segments.
282
+ const rawPath = /^https?:\/\/[^/?#]+([^?#]*)/i.exec(value)?.[1] ?? "";
283
+ if (
284
+ rawPath.includes("%") ||
285
+ rawPath.split("/").some((segment) => segment === "." || segment === "..")
286
+ ) {
287
+ throw new TypeError(
288
+ `${option} must use a plain path without percent escapes or dot segments`,
289
+ );
290
+ }
291
+ let url;
292
+ try {
293
+ url = new URL(value);
294
+ } catch {
295
+ throw new TypeError(`${option} must be an absolute HTTP(S) URL`);
296
+ }
297
+ if (
298
+ !/^https?:\/\/[^/]+/i.test(value) ||
299
+ /[\s\\]/.test(value) ||
300
+ !["http:", "https:"].includes(url.protocol) ||
301
+ url.username ||
302
+ url.password ||
303
+ value.includes("?") ||
304
+ value.includes("#") ||
305
+ url.pathname.includes("//") ||
306
+ url.pathname.includes("%")
307
+ ) {
308
+ throw new TypeError(
309
+ `${option} must be an absolute HTTP(S) URL with a plain path and no credentials, query or fragment`,
310
+ );
311
+ }
312
+ if (option === "--base-url" && !url.pathname.endsWith("/")) {
313
+ throw new TypeError(`${option} must end with /`);
314
+ }
315
+ return url;
316
+ }
317
+
231
318
  /**
232
319
  * Whether this package publishes the pages its content tree compiles to.
233
320
  *
@@ -625,6 +712,8 @@ export function publishesContentPages(config) {
625
712
  * `pack:` frontmatter, and one pack of
626
713
  * the type is marked `default: true` to
627
714
  * receive the notes that name none.
715
+ * @property {Record<string, any> & {inputs?: string}} [site] Site settings;
716
+ * inputs names the optional local input provider.
628
717
  * @property {PathsInput} [paths] Layout overrides. See {@link DEFAULT_PATHS}.
629
718
  * @property {string[]} [skipDirectories] Directory names the content walk ignores
630
719
  * wherever they appear (e.g. Obsidian's
@@ -680,6 +769,8 @@ export function publishesContentPages(config) {
680
769
  * which Foundry serves no files for. See
681
770
  * {@link module:engine/helpers.resolveImg},
682
771
  * the one reader of it.
772
+ * @property {Readonly<Record<string, any> & {inputs: string}>} site Frozen site settings;
773
+ * inputs is the local provider path or an empty string.
683
774
  * @property {Readonly<ResolvedPaths>} paths
684
775
  * @property {Readonly<StatsSpec>|null} stats `null` for a `documentation`
685
776
  * package, which stamps no `_stats`.
@@ -785,6 +876,7 @@ const SITE_KEYS = [
785
876
  "tags",
786
877
  "sitemaps",
787
878
  "mergeIndexes",
879
+ "inputs",
788
880
  ];
789
881
  const SITE_NOTFOUND_KEYS = ["tagline", "sitenoun", "heroimage", "links"];
790
882
  const SITE_NOTFOUND_LINK_KEYS = ["title", "url", "text"];
@@ -1874,6 +1966,23 @@ function packageSiteList(value, field, contentPackage) {
1874
1966
  );
1875
1967
  }
1876
1968
 
1969
+ /**
1970
+ * @param {readonly string[]} sitemaps - The configured package sitemaps.
1971
+ * @param {string} base - The site path.
1972
+ * @param {string} contentPackage - The package id.
1973
+ * @returns {void}
1974
+ */
1975
+ function checkSiteSitemaps(sitemaps, base, contentPackage) {
1976
+ if (sitemaps.length && base !== "/") {
1977
+ fail(
1978
+ "site.sitemaps",
1979
+ "names the package sites a sitemap index lists, and a sitemap index and " +
1980
+ "`robots.txt` belong at the root of an origin; this site is served at " +
1981
+ `\`${base || `/${contentPackage}/`}\`, so declare \`site.base: /\` or drop the key`,
1982
+ );
1983
+ }
1984
+ }
1985
+
1877
1986
  /**
1878
1987
  * The `site` section — how this repository frames the website it publishes.
1879
1988
  *
@@ -1922,6 +2031,7 @@ function normalizeSite(value, contentPackage) {
1922
2031
  tags: false,
1923
2032
  sitemaps: Object.freeze([]),
1924
2033
  mergeIndexes: Object.freeze([]),
2034
+ inputs: "",
1925
2035
  });
1926
2036
  if (value === undefined) return empty;
1927
2037
  if (!isPlainObject(value)) fail("site", "must be a mapping");
@@ -1939,14 +2049,7 @@ function normalizeSite(value, contentPackage) {
1939
2049
  const base = normalizeSiteBase(input.base);
1940
2050
  const search = optionalBoolean(input.search, "site.search", true);
1941
2051
  const sitemaps = packageSiteList(input.sitemaps, "site.sitemaps", contentPackage);
1942
- if (sitemaps.length && base !== "/") {
1943
- fail(
1944
- "site.sitemaps",
1945
- "names the package sites a sitemap index lists, and a sitemap index and " +
1946
- "`robots.txt` belong at the root of an origin; this site is served at " +
1947
- `\`${base || `/${contentPackage}/`}\`, so declare \`site.base: /\` or drop the key`,
1948
- );
1949
- }
2052
+ checkSiteSitemaps(sitemaps, base, contentPackage);
1950
2053
  const mergeIndexes = packageSiteList(input.mergeIndexes, "site.mergeIndexes", contentPackage);
1951
2054
  if (mergeIndexes.length && !search) {
1952
2055
  fail(
@@ -1956,6 +2059,12 @@ function normalizeSite(value, contentPackage) {
1956
2059
  );
1957
2060
  }
1958
2061
 
2062
+ const inputs =
2063
+ input.inputs === undefined ? "" : requireNonEmptyString(input.inputs, "site.inputs");
2064
+ if (inputs && !inputs.split("/").every((segment) => /^[a-z0-9][a-z0-9._-]*$/i.test(segment))) {
2065
+ fail("site.inputs", "must be a plain relative module path without dot segments");
2066
+ }
2067
+
1959
2068
  return Object.freeze({
1960
2069
  base,
1961
2070
  title: input.title === undefined ? "" : requireNonEmptyString(input.title, "site.title"),
@@ -1978,6 +2087,7 @@ function normalizeSite(value, contentPackage) {
1978
2087
  tags: optionalBoolean(input.tags, "site.tags", false),
1979
2088
  sitemaps,
1980
2089
  mergeIndexes,
2090
+ inputs,
1981
2091
  });
1982
2092
  }
1983
2093
 
package/docs/api.md CHANGED
@@ -193,16 +193,18 @@ Whether a content tree has anything to compile. The pack build's worst failure i
193
193
 
194
194
  The consuming repository's resolved `package-build.config.yaml`: the content package, the Foundry package and its kind, every path, the `_stats` identity, item-type membership and the pack list — read on call rather than hoisted at import, so importing this package never requires a configuration to exist.
195
195
 
196
- | Export | Signature | Returns | Use it when |
197
- | ------------------- | ------------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------ |
198
- | `CONFIG_BASENAME` | `const CONFIG_BASENAME` | — | naming the stem every consuming repository declares its build under |
199
- | `CONFIG_FILENAMES` | `const CONFIG_FILENAMES` | — | listing the file names a configuration may be written as |
200
- | `findConfigFile` | `findConfigFile(from)` | `string \| undefined` | finding the nearest configuration file at or above a directory |
201
- | `resolveConfigFile` | `resolveConfigFile(...)` | `{path, fromCwd, fromModule}` | asking which configuration file a build launched here would read, without loading it |
202
- | `locateConfigError` | `locateConfigError(err, configPath)` | the decorated error | attaching the position of the key a configuration error names |
203
- | `configFromData` | `configFromData(data, configPath)` | `ContentBuildConfig` | turning a parsed YAML configuration into the frozen one the engine reads |
204
- | `loadPackConfig` | `loadPackConfig()` | `ContentBuildConfig` | reading the consuming repository's resolved, frozen configuration |
205
- | `packConfigPath` | `packConfigPath()` | `string` | naming the file `loadPackConfig` resolved the configuration from, for a diagnostic |
196
+ | Export | Signature | Returns | Use it when |
197
+ | ------------------- | ------------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------ |
198
+ | `CONFIG_BASENAME` | `const CONFIG_BASENAME` | — | naming the stem every consuming repository declares its build under |
199
+ | `CONFIG_FILENAMES` | `const CONFIG_FILENAMES` | — | listing the file names a configuration may be written as |
200
+ | `findConfigFile` | `findConfigFile(from)` | `string \| undefined` | finding the nearest configuration file at or above a directory |
201
+ | `resolveConfigFile` | `resolveConfigFile(...)` | `{path, fromCwd, fromModule}` | asking which configuration file a build launched here would read, without loading it |
202
+ | `locateConfigError` | `locateConfigError(err, configPath)` | the decorated error | attaching the position of the key a configuration error names |
203
+ | `configFromData` | `configFromData(data, configPath)` | `ContentBuildConfig` | turning a parsed YAML configuration into the frozen one the engine reads |
204
+ | `loadPackConfig` | `loadPackConfig()` | `ContentBuildConfig` | reading the consuming repository's resolved, frozen configuration |
205
+ | `packConfigPath` | `packConfigPath()` | `string` | naming the file `loadPackConfig` resolved the configuration from, for a diagnostic |
206
+ | `readPackConfig` | `readPackConfig(configPath)` | `ContentBuildConfig` | reading the configuration one named file declares, unmemoised |
207
+ | `withPackConfig` | `withPackConfig(config, fn, file?)` | what `fn` returns | running work, asynchronous included, with `config` as the configuration `loadPackConfig` answers |
206
208
 
207
209
  ### `engine.packRouter`
208
210
 
@@ -621,12 +623,11 @@ The npm-installed Graphviz runtime draws SVG maps. The `.dot` source stays besid
621
623
 
622
624
  The map from each place, drawn for the site: `package-build site build` draws the map from every place of this package given a **From here** section — with the site's base, so every name links through it — and sets each drawing inline in that section, where its place names stay links. `site.maps: false` gives no place the section, and nothing is drawn.
623
625
 
624
- | Export | Signature | Returns | Use it when |
625
- | --------------------- | ------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
626
- | `SITE_MAP_DIR` | `const SITE_MAP_DIR` | `string` | where the site build draws below a package's root, `build/map/site` |
627
- | `inlineSvg` | `inlineSvg(svg)` | `string` | rewriting a GraphViz SVG for inlining — no prologue, no comment, no `xlink:`, the root sized by `viewBox` alone |
628
- | `drawSiteMaps` | `drawSiteMaps({ records, foreignIndex, config, base })` | `{maps, findings}` | drawing the map from every place given a **From here** section, keyed by the file name the section names |
629
- | `inlineDrawingFigure` | `inlineDrawingFigure(svg, alt)` | `string` | the one HTML block the website sets an inline drawing in, with no blank line to end it early |
626
+ | Export | Signature | Returns | Use it when |
627
+ | -------------- | ----------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
628
+ | `SITE_MAP_DIR` | `const SITE_MAP_DIR` | `string` | where the site build draws below a package's root, `build/map/site` |
629
+ | `inlineSvg` | `inlineSvg(svg)` | `string` | rewriting a GraphViz SVG for inlining — no prologue, no comment, no `xlink:`, the root sized by `viewBox` alone |
630
+ | `drawSiteMaps` | `drawSiteMaps({ records, foreignIndex, config, base, only?, outDir? })` | `{maps, findings}` | drawing the map from every place given a **From here** section, keyed by the file name the section names |
630
631
 
631
632
  ### `engine.systems`
632
633
 
@@ -1277,6 +1278,36 @@ The deployed layout of a package's pictures, and which of them ship. A Foundry p
1277
1278
  | `stagePackageAssets` | `stagePackageAssets({ assetsBase, contentPackage, stageDir, title, transform, shippingLicenses })` | `{staged, excluded, findings, manifest}` | staging a package's shipped pictures flat and writing `asset-sets/default.json`; nothing is staged when `findings` is non-empty |
1278
1279
  | `rowsStagingPictureRoots` | `rowsStagingPictureRoots(rows, { rootDir, assetsBase })` | `{index, from, root}[]` | finding `packageBuild.assets` rows that would copy a picture root around the filter |
1279
1280
 
1281
+ ### CDN publication transports
1282
+
1283
+ Import `@heroiclands/package-build/engine/cdn-publish` directly. `planCdnPublish`
1284
+ collects the same asset records and shipping selection as staging, applies the
1285
+ provided transform, and builds destination-relative keys plus a named set whose
1286
+ sources use its `host` argument. It does not upload anything.
1287
+
1288
+ `publishToCdn(plan, { store, dryRun, log })` accepts a destination-independent
1289
+ store with async `size(key)`, `read(key)`, optional `write(key, body, contentType)`
1290
+ and a safe `describe` string. It validates every planned relative key before
1291
+ reading or writing, skips pictures by size, compares set bytes, and withholds the
1292
+ set after a picture failure. `dryRun` never calls `write`.
1293
+
1294
+ `internalStore({ destination, fetch, transfer })` combines public HTTPS reads at
1295
+ `INTERNAL_CDN_HOST` with rsync writes, defaulting to `INTERNAL_CDN_DESTINATION`
1296
+ (`atlas:/srv/preview/cdn`). An explicit SSH alias may target `alias:cdn` for the
1297
+ forced-rrsync receiver. Writes stage only the supplied bytes in temporary regular
1298
+ files, invoke rsync without deletion, and clean up after success or failure.
1299
+ `fetch` and `transfer(args)` can be supplied for isolated tests; the default
1300
+ transfer executes rsync with batch SSH and strict host-key checking.
1301
+
1302
+ `s3Store(credentials)` retains signed S3/R2 requests. `publicStore(host, fetch)`
1303
+ provides read-only HTTP comparisons. `CANONICAL_CDN_ORIGIN`, exported from
1304
+ `engine/site-config`, names the owned public CDN origin used by pathname
1305
+ projection. Its single definition lives in the leaf `engine/site-origins` module
1306
+ to avoid a cycle through site configuration and content rendering.
1307
+ The CLI owns environment loading and
1308
+ `--dest` selection; site builds never call a publisher. See
1309
+ [CDN publication](commands.md#package-build-cdn-publish) for operator access.
1310
+
1280
1311
  ### `engine.assetReferences`
1281
1312
 
1282
1313
  Every asset reference in a compiled document states the asset's address: an `<img>` carries `data-asset`, and a path field's address sits in `flags.<system>.assets` on the document holding it, nested to mirror the field's path. A reference to a picture its package does not ship compiles to that package's stand-in and keeps the excluded address.
@@ -1357,6 +1388,16 @@ configuration and incremental render state stay in the calling thread. Every
1357
1388
  update is prepared in a separate output tree, then installed between HTTP
1358
1389
  requests so readers receive complete files.
1359
1390
 
1391
+ `buildPackageSite` invokes configured `site.inputs` before content work. The
1392
+ module's default export has the `SiteInputsProvider` contract:
1393
+ `({config}) => {navigation, files}` or a promise of that object. It receives the
1394
+ immutable effective configuration; `navigation` uses `NavigationEntry[]`, and
1395
+ `files` is `Record<string, string>` of relative output paths to UTF-8 text.
1396
+ Provider files are validated against unsafe paths, symlinks and builder-owned
1397
+ output before output replacement, written after the renderer's clearing step,
1398
+ and recorded in `written.files`. Without a provider, cached navigation remains
1399
+ required. See [local site inputs](configuration.md#local-site-inputs).
1400
+
1360
1401
  ### `engine.contentRender`
1361
1402
 
1362
1403
  `createRenderer(target, options?)` builds the one markdown-it configuration every target renders through. `target` is `"foundry"`, `"web"`, `"preview"` or `"book"`. The renderer holds the configured parser (`md`), the names of the plugins installed (`plugins`), and three functions: `prepare` writes the constructs markdown-it has no syntax for — marked text, captioned items, named blocks, verse and alerts — as HTML between blank lines; `render` prepares and renders to HTML; `renderInline` renders one inline run. The `book` target parses only, and its Typst stage reads the tokens.
@@ -1407,9 +1448,17 @@ locate it in the complete source. LF and CRLF retain their authored lengths.
1407
1448
 
1408
1449
  `previewIndex({ config, records, foreign? })` builds what [`renderPreview`](#enginesitepreview) takes as `index`: the link index, the asset address space, the languages' maps, the icon registry and an `expand` function that resolves a buffer's links, embeds, expressions and figure numbers as the website does. `records` are the content index's records and `foreign` the dependencies' indexes. Build it once per index and reuse it for every render.
1409
1450
 
1410
- | Export | Signature | Returns | Use it when |
1411
- | -------------- | -------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------ |
1412
- | `previewIndex` | `previewIndex({ config, records, foreign })` | `{ runs, icons, lookupAsset, expand }` | An editor holds a content index and wants the preview to render with it. |
1451
+ | Export | Signature | Returns | Use it when |
1452
+ | ------------------- | ------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------- |
1453
+ | `previewIndex` | `previewIndex({ config, records, foreign })` | `{ runs, icons, lookupAsset, expand }` | An editor holds a content index and wants the preview to render with it. |
1454
+ | `recordSiteContext` | `recordSiteContext({ config, records, foreign })` | `{ site, assets, events, runs }` | Resolving a page against content-index records instead of a content tree. |
1455
+
1456
+ `recordSiteContext` places every note that publishes a page at the URL the
1457
+ site gives it: the homepage at the package root, a note `site.sections` names
1458
+ at its section, and every other note at its address. `anchorsOf(record)` may
1459
+ answer a note's anchors as its page emits them, generated sections included;
1460
+ answering nothing keeps the record's own. `pages` maps each page's URL to its
1461
+ record.
1413
1462
 
1414
1463
  ### `engine.sitePreview`
1415
1464
 
@@ -1589,16 +1638,16 @@ A list item opened with `[ ]` or `[x]`, reported. The format has no checkbox: a
1589
1638
 
1590
1639
  One authored pathname, and the four addresses it resolves to. A note names a file once — in `img:`, in `data.portrait:`, in the body of a markdown image — and the first segment says which package owns it when an `assets/` follows. Every surface derives its own address from that one statement: the path inside a Foundry install, the file in the owning repository's tree, the address the website serves, and where the book stages its copy.
1591
1640
 
1592
- | Export | Signature | Returns | Use it when |
1593
- | ----------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
1594
- | `ASSETS_SEGMENT` | `const ASSETS_SEGMENT` | — | The directory a package ships its files in. |
1595
- | `PATHNAME_SURFACES` | `const PATHNAME_SURFACES` | — | The surfaces one authored pathname resolves for. |
1596
- | `pathnameProblem` | `function pathnameProblem(raw)` | {string} The problem, as a finding's sentence, or `""`. | What is wrong with an authored pathname, or `""` when nothing is. |
1597
- | `packageAddresses` | `function packageAddresses(config)` | {Map<string, {root: string\|null, id: string\|null, own: boolean}>} The packages, by package name. | Every content package this build can resolve a pathname against. |
1598
- | `resolvePathname` | `function resolvePathname(raw, config)` | {PathnameForms\|null} The four forms, or `null` when the note names no file. | Resolve one authored pathname into the address each surface serves. |
1599
- | `assetPathnameKey` | `function assetPathnameKey(src, config)` | {string\|null} The pathname, or `null` for an address naming no asset record to look up. | The pathname one authored image address keys an asset's role and pixel size under, whichever form it was written in. |
1600
- | `servesFoundry` | `function servesFoundry(config)` | {boolean} Whether the package being built has a Foundry root. | Whether this build installs anything into a Foundry data directory. |
1601
- | `foundryAddressProblem` | `function foundryAddressProblem(raw, config)` | {string} The problem, as a finding's sentence, or `""`. | Why a pathname has no Foundry address, or `""` when it has one. |
1641
+ | Export | Signature | Returns | Use it when |
1642
+ | ----------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
1643
+ | `ASSETS_SEGMENT` | `const ASSETS_SEGMENT` | — | The directory a package ships its files in. |
1644
+ | `PATHNAME_SURFACES` | `const PATHNAME_SURFACES` | — | The surfaces one authored pathname resolves for. |
1645
+ | `pathnameProblem` | `function pathnameProblem(raw)` | {string} The problem, as a finding's sentence, or `""`. | What is wrong with an authored pathname, or `""` when nothing is. |
1646
+ | `packageAddresses` | `function packageAddresses(config)` | {Map<string, {root: string\|null, id: string\|null, own: boolean}>} The packages, by package name. | Every content package this build can resolve a pathname against. |
1647
+ | `resolvePathname` | `function resolvePathname(raw, config)` | {PathnameForms\|null} The four forms, or `null` when the note names no file. | Resolve one authored pathname into the address each surface serves; canonical Heroic Lands CDN assets use effective site.assets in the web form only. |
1648
+ | `assetPathnameKey` | `function assetPathnameKey(src, config)` | {string\|null} The pathname, or `null` for an address naming no asset record to look up. | The pathname one authored image address keys an asset's role and pixel size under, whichever form it was written in. |
1649
+ | `servesFoundry` | `function servesFoundry(config)` | {boolean} Whether the package being built has a Foundry root. | Whether this build installs anything into a Foundry data directory. |
1650
+ | `foundryAddressProblem` | `function foundryAddressProblem(raw, config)` | {string} The problem, as a finding's sentence, or `""`. | Why a pathname has no Foundry address, or `""` when it has one. |
1602
1651
 
1603
1652
  ### `engine.contentLinks`
1604
1653
 
@@ -1654,7 +1703,8 @@ Shared helpers for the pack compilers in `packages/package-build/`. The HeroicLa
1654
1703
  | `convertNoteWikilinks` | `convertNoteWikilinks(body, { type, id, pack, docPack, index, name, file, bodyLine, bodyColumn, lineMap })` | `{{markdown: string, unresolved: Array<object>}}` | Converts the wikilinks in one note's markdown, reporting any that have no target in the content tree. |
1655
1704
  | `expandNoteTables` | `function expandNoteTables(body,` | `{{markdown: string, lineMap: Array<{line: number, generated: boolean}>}}` The body with every table expanded, and where each emitted line came from — which is what lets a diagnostic about the expanded body name an authored position. | Expand prepared SQL tables in one note's markdown, before wikilinks are resolved — so a generated cell may itself be a wikilink. |
1656
1705
  | `folderFilename` | `function folderFilename(name, id)` | — | Builds a compendium-source filename for a folder JSON document: `folder_Name_id.json` with non-alphanumeric runs replaced by underscores. |
1657
- | `walkMarkdownTree` | `walkMarkdownTree(rootDir, { skipDirectories, sources, renderDescription })` | generator yielding `{frontmatter, body, description, raw, bodyLine?, bodyColumn?, file, absPath}` | walking a content tree and reading every markdown file's frontmatter and body; `skipDirectories` is required rather than defaulted, so two callers cannot silently disagree about the corpus's scope |
1706
+ | `walkMarkdownTree` | `walkMarkdownTree(rootDir, { skipDirectories, sources, renderDescription, parse })` | generator yielding `{frontmatter, body, description, raw, bodyLine?, bodyColumn?, file, absPath}` | walking a content tree and reading every markdown file's frontmatter and body; `skipDirectories` is required rather than defaulted, so two callers cannot silently disagree about the corpus's scope |
1707
+ | `walkMarkdownFiles` | `walkMarkdownFiles(rootDir, { skipDirectories })` | generator yielding each `.md` file's absolute path | listing the files `walkMarkdownTree` reads, in its order, without reading them |
1658
1708
 
1659
1709
  `buildContentLinkIndex` accepts `{ config?, records, sqlTables?, foreign? }`.
1660
1710
  `records` is the required, supplied content corpus. The optional prepared
@@ -1665,9 +1715,12 @@ so glossary word links resolve to their dictionary entries.
1665
1715
  `renderDescription` option defaults to true; false leaves `description` empty
1666
1716
  while retaining authored body and positions. `walkMarkdownTree` accepts a
1667
1717
  selected `sources` map in walk order and the same rendering option, also true
1668
- by default. Index collection explicitly omits unused rendered descriptions.
1669
- `indexRecordsFor` accepts that selected map, a `syntax` projection function
1670
- and a captured `foreignIndex`. These adapters do not retain state globally.
1718
+ by default, and a `parse` function called as `parseMarkdownFile` is, for a
1719
+ caller answering from earlier parses. Index collection explicitly omits unused
1720
+ rendered descriptions. `indexRecordsFor` accepts that selected map, a `syntax`
1721
+ projection function, a captured `foreignIndex` and a `cache` of earlier
1722
+ parses and per-note records, the one [`./note-page`](#note-page) keeps. These
1723
+ adapters do not retain state globally.
1671
1724
 
1672
1725
  The physical wrappers `lintContentCharset`, `lintContentHtml`,
1673
1726
  `lintContentIcons`, `lintContentImages` and `lintContentTaskLists` accept an
@@ -2152,6 +2205,138 @@ Where HM3 records the template priority — one statement (`data.templatePriorit
2152
2205
  | `HM3_DEFAULT_ITEM_ART` | `const HM3_DEFAULT_ITEM_ART` | — | reading the full map of default item artwork keyed by **note** type (not document subtype) for HM3 items |
2153
2206
  | `hm3DefaultItemArt` | `hm3DefaultItemArt(type)` | `string` — the default image path | getting the default art path for an HM3 item's note type, throwing when the type is unknown |
2154
2207
 
2208
+ ### `engine.sitePage`
2209
+
2210
+ One content page, from its note to its body HTML. The site build renders every
2211
+ page through these functions, and [`./note-page`](#note-page) renders a single
2212
+ note through the same ones. Nothing here reads a file.
2213
+
2214
+ | Export | Signature | Returns | Use it when |
2215
+ | --------------------------- | ---------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------- |
2216
+ | `parseNote` | `parseNote(source, file, ctx)` | `{fm, body, bodyLine} \| null` | reading a note's frontmatter and body from its text, addresses decoded |
2217
+ | `contentPage` | `contentPage(note, ctx)` | `{page?, fmLinks, addressFinding?} \| null` | deciding whether a note publishes a page, and building it |
2218
+ | `inlineDrawingFigure` | `inlineDrawingFigure(svg, alt)` | `string` | the one HTML block the website sets an inline drawing in, with no blank line to end it early |
2219
+ | `pageFrontmatter` | `pageFrontmatter(page, options)` | `object` | the frontmatter a page publishes with |
2220
+ | `pageTitle` | `pageTitle(page)` | `string` | the title a page publishes under |
2221
+ | `resolveArtFields` | `resolveArtFields(data, webSrc, artSrc)` | `void` | rewriting a page's artwork addresses into the URLs the website serves |
2222
+ | `isPlainObject` | `isPlainObject(value)` | `boolean` | asking whether a value is a mapping a field may be read from |
2223
+ | `renderSitePage` | `renderSitePage(page, options)` | `{markdown, figures, data, resolved, …Errors}` | expanding one page with the passes the site writer uses |
2224
+ | `resolveSitePass` | `resolveSitePass(name, options)` | `{beforeLinks?: Function}` | resolving `site.pass` to its bundle |
2225
+ | `sitePageDecorator` | `sitePageDecorator(config, index)` | `Function` | adding the site's infobox and being metadata to a page's frontmatter |
2226
+ | `siteBodyRenderer` | `siteBodyRenderer(config)` | `(markdown, {runs, figures}) => string` | rendering an expanded page body to the HTML the site writer receives |
2227
+ | `pageDiagnostics` | `pageDiagnostics(result, rawOf)` | `Array<{file, line?, column?, severity, message}>` | turning a page render's findings into located diagnostics |
2228
+ | `frontmatterLinkDiagnostic` | `frontmatterLinkDiagnostic(finding)` | `{file, severity, message}` | reporting a wikilink authored in frontmatter |
2229
+ | `addressDiagnostic` | `addressDiagnostic(finding)` | `{file, severity, message}` | reporting a note whose page has no address |
2230
+
2231
+ `renderSitePage` takes `drawing`, a function from the file name a generated
2232
+ **From here** section names to that drawing's SVG text, and sets the drawing
2233
+ inline when it answers. The site build answers with the maps it drew.
2234
+
2235
+ ## `./note-page`
2236
+
2237
+ One note, rendered to the body HTML its site page carries, for a preview that
2238
+ reads like the published page: links as their labels and unresolved ones
2239
+ marked, `.name` spans in bold, query fences evaluated into tables, inline verse
2240
+ broken onto its lines and the generated sections a page carries.
2241
+
2242
+ ```js
2243
+ import { renderNoteFile } from "@heroiclands/package-build/note-page";
2244
+
2245
+ const { html, styles, findings, cache } = await renderNoteFile("assets/content/Lore/Tides.md", {
2246
+ root: "/path/to/repository",
2247
+ });
2248
+ ```
2249
+
2250
+ | Export | Signature | Returns | Use it when |
2251
+ | ---------------- | --------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------- |
2252
+ | `renderNoteFile` | `renderNoteFile(path, { root?, cache?, prepareTables? })` | `Promise<{ html, styles, findings, cache }>` | rendering a saved note of a repository as its site page shows |
2253
+
2254
+ `path` is the note, relative to `root` or absolute; `root` is the repository
2255
+ or any directory inside it, the working directory by default. The note is read
2256
+ from disk, so what is rendered is what is saved. `renderNoteFile` loads the
2257
+ repository's `package-build.config.yaml` and its dependencies' fetched indexes,
2258
+ derives the content tree's index records as every build does, answers the
2259
+ note's own queries and generated sections over them as `package-build site
2260
+ build` does, and renders the note through the site build's own page render.
2261
+ The repository's configuration answers every read of the configuration made
2262
+ during the call, wherever the caller stands. Nothing has to be built first: no
2263
+ content index and no site.
2264
+
2265
+ The page resolves against the notes it links as their own pages emit them.
2266
+ The note is rendered once to learn which of the package's pages it looks up,
2267
+ and those notes' query answers and generated sections are prepared beside its
2268
+ own, so a link to a generated section such as `#fromhere` resolves, and a
2269
+ cross-note `{{ref}}` finds the target's figures, numbered as its page numbers
2270
+ them. A place's **From here** drawing is drawn for that place alone, in a
2271
+ scratch directory under the system's temporary directory that is removed once
2272
+ the drawing is read, and set inline as the site sets it.
2273
+
2274
+ **The cache.** The records and the note's answers are kept in
2275
+ `build/cache/note-page/` under the repository root, and reused while every
2276
+ input they were derived from is unchanged. Nothing is reused otherwise: each
2277
+ stored answer is keyed by a hash of its inputs, which are
2278
+
2279
+ - package-build's version and the bytes of its own modules, the Node.js
2280
+ version and the DuckDB version;
2281
+ - the configuration file's bytes and the resolved configuration;
2282
+ - the bytes of every fetched dependency index, and the installed
2283
+ `@heroiclands/fonts` version and manifest;
2284
+ - every note's path and the hash of its text, and the package's asset records.
2285
+
2286
+ The dependencies' indexes and the configuration are read between two
2287
+ readings of those inputs, which are read again after the records are derived
2288
+ and around preparing any query answers the store lacks. When any reading
2289
+ differs, something changed while it was read: nothing is stored, and the whole
2290
+ render is done again from the start, the configuration resolved and the note's
2291
+ place in the tree checked afresh, so a retry answers as a fresh call would and
2292
+ a store always holds what was derived from the bytes its key names. After
2293
+ three such attempts the call throws. The cache holds two stores, each
2294
+ replaced whole by renaming a complete file over it. `notes.v8` keeps each note's parse, keyed by its text, and the
2295
+ records derived from that note alone, keyed also by what the tree gives every
2296
+ note: the setting date and calendars, the addresses the notes declare and the
2297
+ asset records. `tree.v8` keeps the whole tree's records, keyed by every input,
2298
+ and beside them the query answers of each note a render has needed. So a second render of an
2299
+ unchanged tree reads the records and the answers; a changed note re-derives
2300
+ its own records and re-runs the passes that read the whole tree; a changed
2301
+ address, calendar, asset, dependency index, configuration or toolchain
2302
+ re-derives every note. A store that cannot be read, that was written for
2303
+ other inputs, or that holds an entry of the wrong shape, down to each table
2304
+ result, inline answer, page-list result and parse inside it, is derived again
2305
+ and overwritten. Deleting the directory loses
2306
+ only time. `cache: false` derives everything and reads and writes nothing.
2307
+ `prepareTables` prepares the query answers, `prepareTreeSqlTables` from
2308
+ `engine/sql-tables.mjs` by default; a host may wrap it to time or observe the
2309
+ preparation.
2310
+
2311
+ `html` is the body before the site writer minifies it and before it unlinks
2312
+ links to pages its homepage cannot reach. Set it in
2313
+ `<div class="single-content"><div class="single-body">…</div></div>` with
2314
+ `styles`, the CSS the site theme applies to a page body, to show it as the page
2315
+ does. `styles` carries the theme's reset, body typography and ordinary link
2316
+ rules scoped to `.single-content`, so the body reads as the page does whatever
2317
+ the host's own styles, and sets the root font size on `:root`, since the
2318
+ theme measures its lengths in `rem`. Each finding is `{ file, line?, column?, severity, message }` and prints
2319
+ through `formatDiagnostic` as `file:line:column: severity: message`. `cache`
2320
+ says what the cache answered: `tree` is `"hit"` when the tree's records were
2321
+ reused whole, `"miss"` when they were derived, and `"off"` without the cache;
2322
+ `notes` counts the notes whose own records were derived again; `tables` is
2323
+ `"hit"` when the answers of the note and of every note it links were all
2324
+ reused, `"miss"` when any was prepared, and `"off"` without the cache.
2325
+
2326
+ It throws when there is no configuration at or above `root`; when the file is
2327
+ not a note of the content tree (outside `paths.content`, in a skipped
2328
+ directory, or not Markdown); when a dependency's index has not been fetched or
2329
+ cannot be read, naming `package-build deps fetch`; when a note of the tree
2330
+ cannot be indexed, as the content-index build reports it; and when the
2331
+ configuration, fonts or dependency indexes keep changing while they are read,
2332
+ three times over. A finding in the
2333
+ rendered note itself is returned in `findings`, never thrown.
2334
+
2335
+ `prepareTreeSqlTables(contentBase, { only })`, with one note's file or a
2336
+ collection of them, answers those notes' directives and generated sections and
2337
+ no other note's; its queries still read the whole corpus, so each note's
2338
+ results equal its results in a preparation of the whole tree.
2339
+
2155
2340
  ## `./preview`
2156
2341
 
2157
2342
  Import the buffer-only renderer directly without loading the build pipeline:
@@ -2193,20 +2378,21 @@ const config = defineConfig({
2193
2378
  });
2194
2379
  ```
2195
2380
 
2196
- | Export | Signature | Returns | Use it when |
2197
- | -------------------------- | ---------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2198
- | `defineConfig` | `defineConfig(config)` | `ContentBuildConfig` — the frozen, defaulted configuration | validating and normalizing a content configuration; every configuration reaches this function whether authored as YAML or as `.mjs` |
2199
- | `PACKAGE_KINDS` | `const PACKAGE_KINDS` | — | reading the three kinds of Foundry package a content module can be built into — `systems`, `modules`, and `documentation`, the kind that compiles no Foundry document at all (also the Foundry install directory name for the first two) |
2200
- | `DOCUMENTATION_KIND` | `const DOCUMENTATION_KIND` | — | naming the `packageKind` value that compiles no Foundry documents, spelled once so the validator, the CLI and the compile passes cannot disagree about what it means |
2201
- | `AUTHOR_ROLES` | `const AUTHOR_ROLES` | `readonly string[]` | reading the closed list an `authors` entry's `role` is drawn from — the configuration check refuses any other value |
2202
- | `RELATIONSHIP_KEYS` | `const RELATIONSHIP_KEYS` | `readonly string[]` | reading every key a declared relationship may carry — the configuration check refuses anything else, and the manifest writer drops the ones Foundry's own schema does not name |
2203
- | `compilesFoundryDocuments` | `compilesFoundryDocuments(config)` | `boolean` | checking whether a resolved configuration compiles Foundry documents at all — false only for a `documentation` package |
2204
- | `PACK_DOCUMENT_TYPES` | `const PACK_DOCUMENT_TYPES` | — | reading the Foundry document types this toolchain is able to compile a compendium pack of |
2205
- | `DEFAULT_PATHS` | `const DEFAULT_PATHS` | — | reading the conventional directory layout a build reads from and writes to, relative to `rootDir` |
2206
- | `DEFAULT_ADDRESS_SCHEME` | `const DEFAULT_ADDRESS_SCHEME` | — | reading an unconfigured repository's address-scheme defaults (`prefix`, where the content tree mounts inside the package) |
2207
- | `DERIVED_SYSTEM_VERSION` | `const DERIVED_SYSTEM_VERSION` | — | the loader-only symbol key `defineConfig` uses internally to receive a resolved system version; not something a configuration author writes |
2208
- | `publishesContentPages` | `publishesContentPages(config)` | `boolean` | checking whether the authored content tree contains notes beyond its homepage, shared by site, PDF, index, and Foundry address emission |
2209
- | `siteBase` | `siteBase(config)` | `string` | reading the path a package's site is served at — `site.base` where the configuration states one, `/<contentPackage>/` otherwise |
2381
+ | Export | Signature | Returns | Use it when |
2382
+ | -------------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2383
+ | `defineConfig` | `defineConfig(config)` | `ContentBuildConfig` — the frozen, defaulted configuration | validating and normalizing a content configuration; every configuration reaches this function whether authored as YAML or as `.mjs` |
2384
+ | `PACKAGE_KINDS` | `const PACKAGE_KINDS` | — | reading the three kinds of Foundry package a content module can be built into — `systems`, `modules`, and `documentation`, the kind that compiles no Foundry document at all (also the Foundry install directory name for the first two) |
2385
+ | `DOCUMENTATION_KIND` | `const DOCUMENTATION_KIND` | — | naming the `packageKind` value that compiles no Foundry documents, spelled once so the validator, the CLI and the compile passes cannot disagree about what it means |
2386
+ | `AUTHOR_ROLES` | `const AUTHOR_ROLES` | `readonly string[]` | reading the closed list an `authors` entry's `role` is drawn from — the configuration check refuses any other value |
2387
+ | `RELATIONSHIP_KEYS` | `const RELATIONSHIP_KEYS` | `readonly string[]` | reading every key a declared relationship may carry — the configuration check refuses anything else, and the manifest writer drops the ones Foundry's own schema does not name |
2388
+ | `compilesFoundryDocuments` | `compilesFoundryDocuments(config)` | `boolean` | checking whether a resolved configuration compiles Foundry documents at all — false only for a `documentation` package |
2389
+ | `PACK_DOCUMENT_TYPES` | `const PACK_DOCUMENT_TYPES` | — | reading the Foundry document types this toolchain is able to compile a compendium pack of |
2390
+ | `DEFAULT_PATHS` | `const DEFAULT_PATHS` | — | reading the conventional directory layout a build reads from and writes to, relative to `rootDir` |
2391
+ | `DEFAULT_ADDRESS_SCHEME` | `const DEFAULT_ADDRESS_SCHEME` | — | reading an unconfigured repository's address-scheme defaults (`prefix`, where the content tree mounts inside the package) |
2392
+ | `DERIVED_SYSTEM_VERSION` | `const DERIVED_SYSTEM_VERSION` | — | the loader-only symbol key `defineConfig` uses internally to receive a resolved system version; not something a configuration author writes |
2393
+ | `publishesContentPages` | `publishesContentPages(config)` | `boolean` | checking whether the authored content tree contains notes beyond its homepage, shared by site, PDF, index, and Foundry address emission |
2394
+ | `siteBase` | `siteBase(config)` | `string` | reading the path a package's site is served at — `site.base` where the configuration states one, `/<contentPackage>/` otherwise |
2395
+ | `siteBuildConfig` | `siteBuildConfig(config, { root, baseUrl, assets })` | `ContentBuildConfig` | selecting internal www by default, explicit root-derived CDN over configured assets, and low-level baseUrl/assets over derived values; exposes effective homepage/site.base/site.assets without changing authored configuration; the caller first validates the authored homepage with `checkHomepage`; the CLI uses it before index/body preflight |
2210
2396
 
2211
2397
  ## `./config`
2212
2398
 
@@ -2637,6 +2823,10 @@ preparation and kept separate.
2637
2823
  `prepareTreeSqlTables` retains the complete record membership when opening a
2638
2824
  public database, so derived citations can distinguish hidden notes from
2639
2825
  unknown addresses. Only public records contribute rendered sources and links.
2826
+ `prepareTreeSqlTables(contentBase, { only })`, with one note's file, answers
2827
+ that note's directives and generated sections and no other note's; its queries
2828
+ still read the whole corpus, so its results equal that note's in a preparation
2829
+ of the whole tree.
2640
2830
 
2641
2831
  | Export | Contract |
2642
2832
  | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- |