@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
@@ -67,11 +67,10 @@
67
67
 
68
68
  import fs from "node:fs";
69
69
  import path from "node:path";
70
- import { fileURLToPath } from "node:url";
70
+ import { fileURLToPath, pathToFileURL } from "node:url";
71
71
 
72
72
  import { LEXICAL_LINK_CLASS } from "./dictionary-layout.mjs";
73
- import { createRenderer } from "./content-render.mjs";
74
- import { resolvePathname } from "./pathnames.mjs";
73
+ import { siteBodyRenderer } from "./site-page.mjs";
75
74
  import { minifyHtml } from "./html-minify.mjs";
76
75
  import { mapWorld } from "./map-places.mjs";
77
76
  import {
@@ -81,7 +80,7 @@ import {
81
80
  siteFontsDestination,
82
81
  } from "./script-font-delivery.mjs";
83
82
  import { buildSite, gatesFailed } from "./site-build.mjs";
84
- import { readCachedNavigation, siteSettings } from "./site-config.mjs";
83
+ import { checkNavigation, readCachedNavigation, siteSettings } from "./site-config.mjs";
85
84
  import { atomFeed, robotsTxt, sitemapIndexXml, sitemapXml } from "./site-files.mjs";
86
85
  import {
87
86
  pageAddress,
@@ -548,8 +547,7 @@ export function writeSite({
548
547
  * becomes its page.
549
548
  */
550
549
  export function packageSite({ config, built, settings, navigation = [], year }) {
551
- const renderer = createRenderer("web", { icons: config.icons });
552
- const resolveSrc = (src) => resolvePathname(src, config)?.web ?? null;
550
+ const renderBody = siteBodyRenderer(config);
553
551
  const { site: from } = built;
554
552
  const relative = (file) =>
555
553
  file ? path.relative(process.cwd(), file).split(path.sep).join("/") : undefined;
@@ -582,7 +580,7 @@ export function packageSite({ config, built, settings, navigation = [], year })
582
580
  params: data,
583
581
  date: isPost(data) ? data.data?.date : undefined,
584
582
  weight: data.weight,
585
- content: renderer.render(markdown, { runs: from.runs, figures, resolveSrc }),
583
+ content: renderBody(markdown, { runs: from.runs, figures }),
586
584
  source: relative(page.file),
587
585
  search: search(data),
588
586
  };
@@ -595,7 +593,7 @@ export function packageSite({ config, built, settings, navigation = [], year })
595
593
  title: text(home.data.title),
596
594
  url: from.base,
597
595
  params: home.data,
598
- content: renderer.render(home.body, { runs: from.runs, resolveSrc }),
596
+ content: renderBody(home.body, { runs: from.runs }),
599
597
  source: relative(home.file),
600
598
  search: search(home.data),
601
599
  });
@@ -657,6 +655,128 @@ export function deliverSiteFonts(config, out) {
657
655
  return { findings, count: copyScriptFonts(config, dest, ["woff2"]), dest };
658
656
  }
659
657
 
658
+ /**
659
+ * @typedef {object} SiteInputs
660
+ * @property {import("./site-config.mjs").NavigationEntry[]} navigation The selected menu.
661
+ * @property {Record<string, string>} files Text artifacts relative to the site's output.
662
+ */
663
+
664
+ /**
665
+ * @callback SiteInputsProvider
666
+ * @param {{config: import("../content-config.mjs").ContentBuildConfig}} options Frozen effective configuration.
667
+ * @returns {SiteInputs|Promise<SiteInputs>} Inputs prepared before content rendering.
668
+ */
669
+
670
+ /**
671
+ * Refuse a symlink anywhere along a provider module or output path.
672
+ * @param {string} file The absolute path.
673
+ */
674
+ function checkInputSymlinks(file) {
675
+ const absolute = path.resolve(file);
676
+ let current = path.parse(absolute).root;
677
+ for (const segment of absolute.slice(current.length).split(path.sep)) {
678
+ current = path.join(current, segment);
679
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false });
680
+ if (!stat) break;
681
+ if (stat.isSymbolicLink()) {
682
+ throw new Error(`site.inputs refuses a symlink at ${current}`);
683
+ }
684
+ if (!stat.isDirectory()) break;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Read the one optional local input provider. It runs before content work.
690
+ * @param {object} config The effective configuration.
691
+ * @returns {Promise<SiteInputs>} Validated navigation and unvalidated text artifacts.
692
+ */
693
+ async function readSiteInputs(config) {
694
+ if (!config.site.inputs) return { navigation: readCachedNavigation(config), files: {} };
695
+ const modulePath = path.resolve(config.rootDir, config.site.inputs);
696
+ checkInputSymlinks(modulePath);
697
+ const provider = (await import(pathToFileURL(modulePath).href)).default;
698
+ if (typeof provider !== "function")
699
+ throw new TypeError("site.inputs must export a default function");
700
+ const inputs = await provider({ config });
701
+ if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
702
+ throw new TypeError("site.inputs must return {navigation, files}");
703
+ }
704
+ return {
705
+ navigation: checkNavigation(inputs.navigation, "site.inputs.navigation"),
706
+ files: inputs.files,
707
+ };
708
+ }
709
+
710
+ /**
711
+ * Validate every provider artifact before the output tree can be replaced.
712
+ * @param {Record<string, string>} files Text artifacts.
713
+ * @param {object} options Output and builder-owned paths.
714
+ * @param {string} options.out Site output directory.
715
+ * @param {object} options.site Resolved site settings.
716
+ * @param {object[]} options.pages Rendered pages.
717
+ * @returns {[string, string][]} Absolute destination/content pairs.
718
+ */
719
+ function siteInputFiles(files, { out, site, pages }) {
720
+ if (!files || typeof files !== "object" || Array.isArray(files)) {
721
+ throw new TypeError(
722
+ "site.inputs.files must be a mapping of relative paths to UTF-8 strings",
723
+ );
724
+ }
725
+ if (!Object.keys(files).length) return [];
726
+ const reservedTrees = new Set(
727
+ [
728
+ "assets",
729
+ "fonts",
730
+ "pagefind",
731
+ "tags",
732
+ ...fs.readdirSync(SITE_STATIC_DIR),
733
+ ...siteThemeAssets().map((asset) => asset.path.split("/")[0]),
734
+ ].map((name) => name.toLowerCase()),
735
+ );
736
+ const owned = [
737
+ "_headers",
738
+ "_redirects",
739
+ "sitemap.xml",
740
+ "sitemap-index.xml",
741
+ "robots.txt",
742
+ "404.html",
743
+ ];
744
+ for (const page of siteTree(site, pages))
745
+ owned.push(
746
+ path
747
+ .relative(out, pageFile(site, out, page.url))
748
+ .split(path.sep)
749
+ .join("/"),
750
+ );
751
+ if (site.feed) owned.push(`${site.feed.section}/atom.xml`);
752
+ const ownedPaths = owned.map((name) => name.toLowerCase());
753
+ /** @type {string[]} */
754
+ const seen = [];
755
+ const conflicts = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
756
+ return Object.entries(files).map(([name, content]) => {
757
+ const lower = name.toLowerCase();
758
+ if (
759
+ !name.split("/").every((segment) => /^[a-z0-9][a-z0-9._-]*$/i.test(segment)) ||
760
+ typeof content !== "string"
761
+ )
762
+ throw new TypeError(
763
+ `site.inputs.files: ${name} must be a plain relative path with UTF-8 text`,
764
+ );
765
+ if (
766
+ lower.endsWith(".html") ||
767
+ reservedTrees.has(lower.split("/")[0]) ||
768
+ [...ownedPaths, ...seen].some((other) => conflicts(lower, other))
769
+ )
770
+ throw new TypeError(
771
+ `site.inputs.files: ${name} collides with a builder-owned or provider file`,
772
+ );
773
+ seen.push(lower);
774
+ const file = path.join(out, ...name.split("/"));
775
+ checkInputSymlinks(file);
776
+ return [file, content];
777
+ });
778
+ }
779
+
660
780
  /**
661
781
  * Build a package's site from its content tree and write it.
662
782
  *
@@ -677,11 +797,17 @@ export function deliverSiteFonts(config, out) {
677
797
  * What the build found, and, when it found nothing that stops it, what was
678
798
  * written, the pages it was written from and how a note rendered again
679
799
  * becomes its page. `written` is `null` when a gate fired.
680
- * @throws {Error} When the site's settings or its cached menu cannot be read.
800
+ * With `site.inputs`, the local default-exported provider receives `{config}`
801
+ * and supplies the navigation plus text artifacts. All artifact paths are checked
802
+ * before replacing output; artifacts are then written after the renderer clears
803
+ * and writes the site, and included in `written.files`.
804
+ *
805
+ * @throws {Error} When the site's settings, cached menu or input provider fail,
806
+ * or a provider artifact is unsafe or collides with builder-owned output.
681
807
  */
682
808
  export async function buildPackageSite({ config, out, year, progress = () => {} }) {
683
809
  const settings = siteSettings({ config });
684
- const navigation = readCachedNavigation(config);
810
+ const { navigation, files } = await readSiteInputs(config);
685
811
  progress("indexing content");
686
812
  const sqlTables = await prepareTreeSqlTables(config.paths.content, {
687
813
  skipDirectories: config.skipDirectories,
@@ -691,7 +817,13 @@ export async function buildPackageSite({ config, out, year, progress = () => {}
691
817
  const built = buildSite({ config, sqlTables, progress });
692
818
  if (gatesFailed(built.gates)) return { built, written: null, fonts: null };
693
819
  const { site, pages, pageOf } = packageSite({ config, built, settings, navigation, year });
820
+ const artifacts = siteInputFiles(files, { out, site, pages });
694
821
  const written = writeSite({ site, pages, out, progress });
822
+ for (const [file, content] of artifacts) {
823
+ fs.mkdirSync(path.dirname(file), { recursive: true });
824
+ fs.writeFileSync(file, content, "utf8");
825
+ written.files.push(file);
826
+ }
695
827
  const fonts = deliverSiteFonts(config, out);
696
828
  return { built, written, fonts, site, pages, pageOf };
697
829
  }
@@ -94,7 +94,7 @@ export async function serveSite({
94
94
  }) {
95
95
  // Refuse a site that cannot be described before starting its server.
96
96
  siteSettings({ config });
97
- readCachedNavigation(config);
97
+ if (!config.site.inputs) readCachedNavigation(config);
98
98
  root = path.resolve(root);
99
99
  out = path.resolve(out);
100
100
  let timer = null;
@@ -72,6 +72,7 @@ import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
72
72
  // The record accessors only — see `engine/index-records.mjs`.
73
73
  import { authoredFrontmatter, isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
74
74
  import { nameSortOf } from "./name-sort.mjs";
75
+ import { outsideOnly } from "./prepared-only.mjs";
75
76
 
76
77
  /** Rendered in a cell whose value is absent. */
77
78
  const EMPTY_CELL = "—";
@@ -1613,8 +1614,8 @@ export async function prepareInlineSqlExpressions(db, sources) {
1613
1614
  * surface publishes to.
1614
1615
  * @param {string} contentBase - Root of the content tree.
1615
1616
  * @param {object} [opts]
1616
- * @param {string} [opts.only] - One note file to give sections to; absent,
1617
- * every note.
1617
+ * @param {string|Set<string>} [opts.only] - The note file, or files, to give
1618
+ * sections to; absent, every note.
1618
1619
  * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes, whose
1619
1620
  * places, affiliations and works the lists take in.
1620
1621
  * @param {object} [opts.config] - The resolved configuration.
@@ -1685,7 +1686,7 @@ export async function generatedSectionsFor(
1685
1686
  type: note.type,
1686
1687
  shortcode: note.shortcode,
1687
1688
  });
1688
- if (owners.has(key) && (only === undefined || fileOf(note) === only))
1689
+ if (owners.has(key) && !outsideOnly(only, fileOf(note)))
1689
1690
  lexical.set(fileOf(note), owners.get(key));
1690
1691
  }
1691
1692
  return { sections, views: new Set(views.keys()), derived, lexical, declared };
@@ -1897,6 +1898,9 @@ export async function cachedDependencies(config) {
1897
1898
  * generated sections take in, where the caller holds them already; absent,
1898
1899
  * they are read from the cache.
1899
1900
  * @param {import("./content-session.mjs").ContentSyntax} [opts.syntax] - Exact-text scanner projections.
1901
+ * @param {string|Iterable<string>} [opts.only] - One note's file, or several:
1902
+ * answer their directives and give them their generated sections, and no
1903
+ * other note's. The queries still read the whole corpus.
1900
1904
  * @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
1901
1905
  * carrying `inline` and `pageLists` beside them, or nothing when the tree
1902
1906
  * holds no such directive and no note is given a generated section.
@@ -1912,6 +1916,7 @@ export async function prepareTreeSqlTables(
1912
1916
  audience = "all",
1913
1917
  foreignIndex,
1914
1918
  syntax,
1919
+ only,
1915
1920
  } = {},
1916
1921
  ) {
1917
1922
  // Imported here rather than at module scope: the index reaches the pack
@@ -1919,6 +1924,7 @@ export async function prepareTreeSqlTables(
1919
1924
  // module they load would close a cycle and leave `BasePackCompiler`
1920
1925
  // uninitialised for whichever module the runtime happened to load first.
1921
1926
  const { indexRecordsFor } = await import("./content-index.mjs");
1927
+ if (only !== undefined && typeof only !== "string") only = new Set(only);
1922
1928
  const notes = parsed ?? new Map();
1923
1929
  const allRecords =
1924
1930
  records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
@@ -1937,6 +1943,7 @@ export async function prepareTreeSqlTables(
1937
1943
  for (const record of indexRecords) {
1938
1944
  if (!isNoteRecord(record)) continue;
1939
1945
  const absPath = noteFile(contentBase, record);
1946
+ if (outsideOnly(only, absPath)) continue;
1940
1947
  const { body, frontmatter } = notes.get(absPath) ?? parseMarkdownFile(absPath);
1941
1948
  if (
1942
1949
  body &&
@@ -1971,6 +1978,7 @@ export async function prepareTreeSqlTables(
1971
1978
  foreignIndex: foreign,
1972
1979
  config,
1973
1980
  all: allRecords,
1981
+ only,
1974
1982
  });
1975
1983
  if (
1976
1984
  !sources.length &&
@@ -2036,6 +2044,7 @@ export async function prepareTreeSqlTables(
2036
2044
  parsed,
2037
2045
  derived: derivedOnly.derived,
2038
2046
  all: allRecords,
2047
+ only,
2039
2048
  });
2040
2049
  joinGeneratedSections(generated, sources, notes);
2041
2050
  const prepared = await prepareSqlTables(db, sources, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.6.2",
3
+ "version": "25.7.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -40,6 +40,11 @@
40
40
  "import": "./engine/site-preview.mjs",
41
41
  "default": "./engine/site-preview.mjs"
42
42
  },
43
+ "./note-page": {
44
+ "types": "./types/engine/note-page.d.mts",
45
+ "import": "./engine/note-page.mjs",
46
+ "default": "./engine/note-page.mjs"
47
+ },
43
48
  "./content-config": {
44
49
  "types": "./types/content-config.d.mts",
45
50
  "import": "./content-config.mjs"
@@ -3,6 +3,10 @@
3
3
  Dark fantasy / manuscript aesthetic
4
4
  ============================================ */
5
5
 
6
+ /* Each stretch between a note-body comment and its closing comment styles
7
+ what the renderer writes into a page body. A host showing one note's body,
8
+ such as a preview, takes exactly these stretches as the note's styles. */
9
+ /* note-body */
6
10
  :root {
7
11
  --bg: #0f1923;
8
12
  --bg-deeper: #0a1219;
@@ -67,6 +71,7 @@
67
71
  --content-width: 100%;
68
72
  --content-narrow: 100%;
69
73
  }
74
+ /* /note-body */
70
75
 
71
76
  /* ---- Reset ---- */
72
77
 
@@ -103,6 +108,7 @@ a:hover {
103
108
  color: var(--accent-hover);
104
109
  }
105
110
 
111
+ /* note-body */
106
112
  /* Inline body links get a subtle underline on hover so they feel
107
113
  tactile without competing with headings or navigation. Links
108
114
  inside utility contexts (cards, nav, TOC) opt out via more
@@ -114,6 +120,7 @@ a:hover {
114
120
  .single-body a:hover {
115
121
  border-bottom-color: var(--accent-dim);
116
122
  }
123
+ /* /note-body */
117
124
 
118
125
  img {
119
126
  max-width: 100%;
@@ -1000,6 +1007,7 @@ a.tag:hover {
1000
1007
  line-height: 1.6;
1001
1008
  }
1002
1009
 
1010
+ /* note-body */
1003
1011
  /* ---- Article Content ---- */
1004
1012
 
1005
1013
  .single-content {
@@ -1735,6 +1743,8 @@ a.tag:hover {
1735
1743
  cursor: help;
1736
1744
  }
1737
1745
 
1746
+ /* /note-body */
1747
+
1738
1748
  /* ---- The draft notice ----
1739
1749
 
1740
1750
  A page's statement that its own content is not settled, at the head of the
@@ -2727,6 +2737,7 @@ summary.info-sidebar-title::after {
2727
2737
  }
2728
2738
  }
2729
2739
 
2740
+ /* note-body */
2730
2741
  /* ---- Cartouches ----
2731
2742
 
2732
2743
  Named-entity SVGs rendered as ornamental accents on culture-flavored
@@ -2927,6 +2938,7 @@ summary.info-sidebar-title::after {
2927
2938
  height: 3.25rem;
2928
2939
  }
2929
2940
  }
2941
+ /* /note-body */
2930
2942
 
2931
2943
  /* ---- Related card (bottom of article) ----
2932
2944
 
@@ -2993,6 +3005,7 @@ summary.info-sidebar-title::after {
2993
3005
  border-bottom-color: var(--accent-dim);
2994
3006
  }
2995
3007
 
3008
+ /* note-body */
2996
3009
  /* A drawing the build sets inline — the map in a place's From here section —
2997
3010
  scales to its figure, and scrolls rather than shrinking past legibility on
2998
3011
  a phone. */
@@ -3110,3 +3123,4 @@ summary.info-sidebar-title::after {
3110
3123
  .script :is(em, strong) { font-style:normal; font-weight:inherit; font-synthesis:none; }
3111
3124
  .emph-rubric { color:var(--rubric); }
3112
3125
  .emph-overline { text-decoration-line:overline; text-decoration-skip-ink:none; }
3126
+ /* /note-body */
@@ -25,6 +25,26 @@ export function siteBase(config: {
25
25
  base: string;
26
26
  };
27
27
  }): string;
28
+ /**
29
+ * Apply validated runtime site URLs to a resolved configuration, without changing it.
30
+ * The caller checks the authored homepage before applying these build options.
31
+ *
32
+ * @param {ContentBuildConfig} config - The resolved authored configuration.
33
+ * @param {object} options - Runtime options.
34
+ * @param {string} [options.root] - Domain from which www and CDN origins derive.
35
+ * Defaults to internal.heroiclands.org; explicit root replaces configured assets.
36
+ * @param {string} [options.baseUrl] - Absolute HTTP(S) site URL ending in `/`;
37
+ * overrides the root-derived site URL.
38
+ * @param {string} [options.assets] - Absolute HTTP(S) asset root; overrides both
39
+ * root-derived and configured assets.
40
+ * @returns {ContentBuildConfig} The effective configuration, frozen. Its homepage
41
+ * and site.assets are the selected inputs for downstream site integrations.
42
+ */
43
+ export function siteBuildConfig(config: ContentBuildConfig, { root, baseUrl, assets }?: {
44
+ root?: string | undefined;
45
+ baseUrl?: string | undefined;
46
+ assets?: string | undefined;
47
+ }): ContentBuildConfig;
28
48
  /**
29
49
  * Whether this package publishes the pages its content tree compiles to.
30
50
  *
@@ -700,6 +720,13 @@ export type ContentBuildConfigInput = {
700
720
  * receive the notes that name none.
701
721
  */
702
722
  packs: PackSpec[];
723
+ /**
724
+ * Site settings;
725
+ * inputs names the optional local input provider.
726
+ */
727
+ site?: (Record<string, any> & {
728
+ inputs?: string;
729
+ }) | undefined;
703
730
  /**
704
731
  * Layout overrides. See {@link DEFAULT_PATHS}.
705
732
  */
@@ -802,6 +829,13 @@ export type ContentBuildConfig = {
802
829
  * the one reader of it.
803
830
  */
804
831
  assetRoot: string | null;
832
+ /**
833
+ * Frozen site settings;
834
+ * inputs is the local provider path or an empty string.
835
+ */
836
+ site: Readonly<Record<string, any> & {
837
+ inputs: string;
838
+ }>;
805
839
  paths: Readonly<ResolvedPaths>;
806
840
  /**
807
841
  * `null` for a `documentation`
@@ -58,6 +58,24 @@ export function s3Store({ endpoint, bucket, accessKeyId, secretAccessKey, fetch:
58
58
  * @returns {CdnStore} The read-only store.
59
59
  */
60
60
  export function publicStore(host: string, fetchImpl?: typeof fetch): CdnStore;
61
+ /**
62
+ * Internal storage: HTTPS reads and rsync writes of the planned bytes only.
63
+ *
64
+ * The default uses the operator's normal atlas account. An explicitly configured
65
+ * SSH alias may select the forced-rrsync account with a relative `cdn` destination.
66
+ * SSH configuration and its agent supply keys; no remote shell is requested.
67
+ *
68
+ * @param {object} [opts]
69
+ * @param {string} [opts.destination] - `host:/srv/preview/cdn` or `alias:cdn`.
70
+ * @param {typeof fetch} [opts.fetch] - The fetch to use for public reads.
71
+ * @param {(args: string[]) => Promise<unknown>} [opts.transfer] - Run rsync.
72
+ * @returns {CdnStore} The store.
73
+ */
74
+ export function internalStore({ destination, fetch: fetchImpl, transfer, }?: {
75
+ destination?: string | undefined;
76
+ fetch?: typeof fetch | undefined;
77
+ transfer?: ((args: string[]) => Promise<unknown>) | undefined;
78
+ }): CdnStore;
61
79
  /**
62
80
  * One picture to publish.
63
81
  *
@@ -220,6 +238,10 @@ export const CDN_ENV: Readonly<{
220
238
  accessKeyId: string;
221
239
  secretAccessKey: string;
222
240
  }>;
241
+ /** The internal CDN's public origin. Reads never require SSH credentials. */
242
+ export const INTERNAL_CDN_HOST: "https://cdn.internal.heroiclands.org";
243
+ /** The manual operator's ordinary SSH account on atlas. */
244
+ export const INTERNAL_CDN_DESTINATION: "atlas:/srv/preview/cdn";
223
245
  /**
224
246
  * One picture to publish.
225
247
  */
@@ -227,10 +227,17 @@ export function resolveCultures(records: Array<Record<string, any>>, { contentPa
227
227
  * @param {Iterable<string>} [options.nameTypes] - Additional bare name kinds from configured type declarations.
228
228
  * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [options.timings] -
229
229
  * Invocation stage accounting, which records each phase of the derivation.
230
+ * @param {NoteDerivationCache} [options.cache] - Earlier work to answer from:
231
+ * each note's parse, and the records derived from one note alone, which
232
+ * depend on that note and on the context the tree gives every note (the
233
+ * setting date and calendars, the addresses the notes declare, the asset
234
+ * records, the address context and the configured kinds). Not consulted
235
+ * for the records when `syntax` is given. Every pass that reads the whole
236
+ * tree runs whatever the cache holds.
230
237
  * @returns {Array<Record<string, any>>} The records, in a total order that does
231
238
  * not depend on directory-read order.
232
239
  */
233
- export function collectContentIndex(contentBase: string, { contentPackage, currentDate, defaultCalendar, configFile, rootDir, languages, skipDirectories, assetsBase, manifest, problems, addressContext, foreignRecord, parsed, sources, syntax, nameTypes, timings, }: {
240
+ export function collectContentIndex(contentBase: string, { contentPackage, currentDate, defaultCalendar, configFile, rootDir, languages, skipDirectories, assetsBase, manifest, problems, addressContext, foreignRecord, parsed, sources, syntax, nameTypes, timings, cache, }: {
234
241
  contentPackage: string;
235
242
  skipDirectories: readonly string[];
236
243
  assetsBase?: string | undefined;
@@ -270,6 +277,7 @@ export function collectContentIndex(contentBase: string, { contentPackage, curre
270
277
  hits: any;
271
278
  };
272
279
  } | undefined;
280
+ cache?: NoteDerivationCache | undefined;
273
281
  }): Array<Record<string, any>>;
274
282
  /**
275
283
  * Serialize records as JSON Lines.
@@ -316,9 +324,11 @@ export function serializeContentIndex(records: Array<Record<string, any>>): stri
316
324
  * @param {Map<string, object>} [opts.foreignIndex] - Captured dependency address view.
317
325
  * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [opts.timings] -
318
326
  * Invocation stage accounting.
327
+ * @param {NoteDerivationCache} [opts.cache] - Earlier parses and per-note
328
+ * records to answer from; see {@link collectContentIndex}.
319
329
  * @returns {object[]} One record per note, plus one per documentation entry.
320
330
  */
321
- export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectories, problems, parsed, sources, syntax, nameTypes, foreignIndex, timings, }?: {
331
+ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectories, problems, parsed, sources, syntax, nameTypes, foreignIndex, timings, cache, }?: {
322
332
  contentBase?: string | undefined;
323
333
  assetsBase?: string | undefined;
324
334
  config?: object | undefined;
@@ -348,6 +358,7 @@ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectori
348
358
  hits: any;
349
359
  };
350
360
  } | undefined;
361
+ cache?: NoteDerivationCache | undefined;
351
362
  }): object[];
352
363
  /**
353
364
  * Prepare the complete bodies a content-index publication describes.
@@ -409,6 +420,29 @@ export function emitContentIndex({ contentBase, outDir, config, publication }?:
409
420
  rows: number;
410
421
  };
411
422
  };
423
+ /**
424
+ * Earlier derivation work {@link collectContentIndex} may answer from, such as
425
+ * {@link module :engine/note-cache.noteTree}'s store.
426
+ */
427
+ export type NoteDerivationCache = {
428
+ /**
429
+ * - Reads
430
+ * one note, as {@link module :engine/helpers.parseMarkdownFile} does.
431
+ */
432
+ parse?: typeof import("./helpers.mjs").parseMarkdownFile | undefined;
433
+ /**
434
+ * - A key for the
435
+ * context the tree gives each note's records, or nothing when it has none.
436
+ */
437
+ context: (value: unknown) => string | undefined;
438
+ /**
439
+ * -
440
+ * The records one note derives: the stored ones when that note's text and
441
+ * the context are unchanged, else `derive()`'s. Either is the caller's to
442
+ * change.
443
+ */
444
+ records: (absPath: string, context: string | undefined, derive: () => object[]) => object[];
445
+ };
412
446
  /**
413
447
  * A captured source context, consumed without mutation by the synchronous emitter.
414
448
  */
@@ -29,5 +29,25 @@ export function highlightRules(scope: string): string;
29
29
  * @returns {string} The stylesheet.
30
30
  */
31
31
  export function siteCodeStylesheet(scope?: string): string;
32
+ /**
33
+ * The CSS the site theme applies to a page body: its custom properties, the
34
+ * body's text, named blocks, figures, verse, footnotes, unresolved and draft
35
+ * links, cartouches, glyphs, alerts, spoilers, dictionary lists and faces,
36
+ * then the highlighted-code rules a site links as `css/code.css`.
37
+ *
38
+ * Read from the theme's own stylesheet, the stretches it marks `note-body`,
39
+ * so a rule changed there changes here. The rules are scoped as the theme
40
+ * scopes them: the body sits in a `.single-body` inside a `.single-content`.
41
+ * The theme's page-wide rules for the reset, the body's typography and the
42
+ * ordinary link come first, scoped to `.single-content`, so a body set in that
43
+ * wrapper reads as the page does whatever the host's own styles; the one
44
+ * exception is the root font size, set on `:root`, since every length the
45
+ * theme writes in `rem` is measured against it.
46
+ *
47
+ * @returns {string} The stylesheet.
48
+ * @throws {Error} When the theme's stylesheet marks no body rules, or lacks
49
+ * one of the page-wide rules.
50
+ */
51
+ export function noteBodyStylesheet(): string;
32
52
  /** The roots the shipped content stylesheet scopes its rules to. */
33
53
  export const CONTENT_ROOTS: ":is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)";
@@ -53,8 +53,8 @@ export function drawMapsFrom(records: object[], { outDir, foreignIndex, config,
53
53
  * @param {object} [opts.config] - The resolved configuration.
54
54
  * @param {(file:string)=>string|undefined} [opts.rawOf] - Captured source locators.
55
55
  * @param {object[]} [opts.all=records] - Complete records for settlement validation.
56
- * @param {string} [opts.only] - One note file to give sections to; absent,
57
- * every note.
56
+ * @param {string|Set<string>} [opts.only] - The note file, or files, to give
57
+ * sections to; absent, every note.
58
58
  * @returns {Map<string, Map<string, string>>} Note file to its sections, each
59
59
  * keyed by its slug, for every note given at least one.
60
60
  */
@@ -64,7 +64,7 @@ export function derivedSections(records: object[], { fileOf, foreignIndex, confi
64
64
  config?: object | undefined;
65
65
  rawOf?: ((file: string) => string | undefined) | undefined;
66
66
  all?: object[] | undefined;
67
- only?: string | undefined;
67
+ only?: string | Set<string> | undefined;
68
68
  }): Map<string, Map<string, string>>;
69
69
  /**
70
70
  * The heading each kind of place is grouped under, in the order the place
@@ -14,12 +14,13 @@ export function selfForms(record: object): string[];
14
14
  * @param {Iterable<object>} records - The content-index records.
15
15
  * @param {(record: object) => string} fileOf - A record's note file, as the
16
16
  * prepared results are keyed.
17
- * @param {string} [only] - One note file to give views to, every note's being
18
- * read for them; absent, every note is given its views.
17
+ * @param {string|Set<string>} [only] - The note file, or files, to give views
18
+ * to, every note's being read for them; absent, every note is given its
19
+ * views.
19
20
  * @returns {Promise<Map<string, Map<string, string>>>} Note file to its views,
20
21
  * each keyed by its slug, for every note given at least one.
21
22
  */
22
- export function eventViewSections(db: object, records: Iterable<object>, fileOf: (record: object) => string, only?: string): Promise<Map<string, Map<string, string>>>;
23
+ export function eventViewSections(db: object, records: Iterable<object>, fileOf: (record: object) => string, only?: string | Set<string>): Promise<Map<string, Map<string, string>>>;
23
24
  /**
24
25
  * The views, in the order
25
26
  * {@link module:engine/generated-sections.GENERATED_SECTIONS} sets them in.