@heroiclands/package-build 22.4.6 → 22.5.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 (45) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CONTENT.md +192 -1
  3. package/bin/content-build.mjs +26 -4
  4. package/content-config.mjs +8 -3
  5. package/docs/api.md +99 -26
  6. package/docs/configuration.md +20 -1
  7. package/docs/content-format.md +238 -0
  8. package/docs/diagnostics.md +44 -0
  9. package/engine/content-index.mjs +63 -21
  10. package/engine/content-links.mjs +60 -6
  11. package/engine/content-tables.mjs +29 -2
  12. package/engine/frontmatter-lint.mjs +2 -0
  13. package/engine/holdings.mjs +416 -0
  14. package/engine/index-records.mjs +25 -0
  15. package/engine/index.mjs +16 -0
  16. package/engine/map-graphviz.mjs +12 -3
  17. package/engine/map-places.mjs +33 -3
  18. package/engine/market-class.mjs +178 -0
  19. package/engine/metadata-index.mjs +7 -4
  20. package/engine/note-state.mjs +213 -0
  21. package/engine/note-vocabulary.mjs +59 -2
  22. package/engine/pdf-build.mjs +4 -2
  23. package/engine/populations.mjs +557 -0
  24. package/engine/site-build.mjs +96 -10
  25. package/engine/site-maps.mjs +165 -0
  26. package/engine/sql-tables.mjs +157 -4
  27. package/engine/stub-lint.mjs +237 -0
  28. package/engine/wikilink-syntax.mjs +27 -1
  29. package/package.json +1 -1
  30. package/types/engine/content-index.d.mts +19 -5
  31. package/types/engine/content-links.d.mts +3 -3
  32. package/types/engine/content-tables.d.mts +2 -1
  33. package/types/engine/holdings.d.mts +157 -0
  34. package/types/engine/index-records.d.mts +22 -0
  35. package/types/engine/index.d.mts +6 -0
  36. package/types/engine/map-graphviz.d.mts +8 -1
  37. package/types/engine/map-places.d.mts +26 -0
  38. package/types/engine/market-class.d.mts +62 -0
  39. package/types/engine/note-state.d.mts +110 -0
  40. package/types/engine/note-vocabulary.d.mts +25 -0
  41. package/types/engine/populations.d.mts +121 -0
  42. package/types/engine/site-build.d.mts +37 -6
  43. package/types/engine/site-maps.d.mts +81 -0
  44. package/types/engine/stub-lint.d.mts +51 -0
  45. package/types/engine/wikilink-syntax.d.mts +19 -1
@@ -36,7 +36,9 @@
36
36
  * `/<package>/<type>-<shortcode>/`. Nothing is generated between them — no
37
37
  * section directory, no listing, no `_index.md` but the root's. An index of
38
38
  * what the package publishes is a `doc` note carrying a content table, and it
39
- * is authored where every other page is.
39
+ * is authored where every other page is. The one file a page carries beside
40
+ * itself is the map from a place — see `engine/site-maps.mjs` — and a page
41
+ * that carries one is a leaf bundle at the same address.
40
42
  *
41
43
  * **Every gate reports; none exits.** The integrity checks a site build needs —
42
44
  * a wikilink authored in frontmatter, a name that yields no slug, an unusable
@@ -71,6 +73,8 @@ import { searchableFrontmatter } from "./note-package.mjs";
71
73
  // The corpus, from the one pass that derives it.
72
74
  import { indexRecordsFor } from "./content-index.mjs";
73
75
  import { isNoteRecord, noteFile } from "./index-records.mjs";
76
+ // The one statement of what an empty body means, shared with the index.
77
+ import { isStubNote } from "./note-state.mjs";
74
78
  import { ART_SLOTS, artPathname, assetAddressIndex } from "./art-fields.mjs";
75
79
  import {
76
80
  HOMEPAGE_DESTINATION,
@@ -82,6 +86,8 @@ import {
82
86
  import { publishesContentPages } from "../content-config.mjs";
83
87
  import { HUGO_CONTENT } from "./site-config.mjs";
84
88
  import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
89
+ import { HOLDINGS_KEYS, foreignHoldingsNodes, holdingsNode, holdingsPages } from "./holdings.mjs";
90
+ import { drawSiteMaps } from "./site-maps.mjs";
85
91
 
86
92
  const require = createRequire(import.meta.url);
87
93
 
@@ -173,6 +179,12 @@ export function collectContentPages(contentBase, ctx) {
173
179
  // whole of a homepage-only build, which never walks the tree for
174
180
  // content pages at all.
175
181
  if (isHomepage(fm)) continue;
182
+ // A stub has no body, so it has no page. Asked of the note's own text
183
+ // rather than of the absent address the index derived, so the site and
184
+ // the index apply one rule from one module and a note that cannot be
185
+ // addressed for some *other* reason still reaches the finding below
186
+ // that says so.
187
+ if (isStubNote(fm, body)) continue;
176
188
 
177
189
  for (const hit of frontmatterWikilinks(fm)) {
178
190
  fmLinkFindings.push({ file, ...hit });
@@ -489,7 +501,10 @@ export function tableUniverse(pages) {
489
501
  * about every other page, known only once the whole tree has resolved — see
490
502
  * {@link module:engine/related-pages} — so an authored value is dropped the
491
503
  * way `aliases` is, and {@link renderPages} writes the derived block once it
492
- * holds the graph.
504
+ * holds the graph. `contains`, `held_by` and `holdings` are dropped for the
505
+ * same reason — see {@link module:engine/holdings}. **So is `map`**: the
506
+ * file a place page names is the one the build drew beside it — see
507
+ * {@link module:engine/site-maps} — and a page with no drawing names none.
493
508
  *
494
509
  * @param {object} page - The page.
495
510
  * @param {object} options
@@ -525,6 +540,8 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
525
540
  if (decorate) decorate(data, page);
526
541
  delete data.aliases;
527
542
  delete data.related;
543
+ for (const key of HOLDINGS_KEYS) delete data[key];
544
+ delete data.map;
528
545
  if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
529
546
  return data;
530
547
  }
@@ -598,11 +615,20 @@ function isPlainObject(value) {
598
615
  * `doc` note's `subType` may be spelled the same as another note's `type`, and
599
616
  * `doc-gear.md` and `weapongear-gear.md` are distinct.
600
617
  *
618
+ * **A page that carries a file beside itself is a leaf bundle**, `<slug>/index.md`,
619
+ * because that is the one shape under which Hugo hands a page its own
620
+ * resources — the map from a place, inlined by the theme through
621
+ * `.Resources.Get`. The directory is the page, not a section: it holds no
622
+ * `_index.md`, and the page's stated `url` keeps its address exactly where the
623
+ * flat file's was.
624
+ *
601
625
  * @param {object} page - The page.
626
+ * @param {object} [opts]
627
+ * @param {boolean} [opts.bundle=false] - Whether the page carries a resource.
602
628
  * @returns {string} The file, relative to the mount.
603
629
  */
604
- export function pageDestination(page) {
605
- return `${page.slug}.md`;
630
+ export function pageDestination(page, { bundle = false } = {}) {
631
+ return bundle ? `${page.slug}/index.md` : `${page.slug}.md`;
606
632
  }
607
633
 
608
634
  /**
@@ -627,16 +653,25 @@ export function pageDestination(page) {
627
653
  * point the resolver answers, and inverted once between them. Nothing is
628
654
  * resolved twice.
629
655
  *
656
+ * **A map is written with its page.** The drawing from a place is made before
657
+ * this render — see {@link module:engine/site-maps} — and handed in keyed by
658
+ * page URL; the page that owns one is written as a leaf bundle, the file is
659
+ * copied beside it, and the front matter names it as `map`. Every other page
660
+ * is written flat, and carries no such key.
661
+ *
630
662
  * @param {object[]} pages - Every page.
631
663
  * @param {object} options - Everything the render needs. `homepages` is the
632
664
  * homepage as the index knows it — `{ fm, body, url, title }` — which takes
633
665
  * no part in the render and every part in the graph: a content page links
634
- * it by wikilink, and its own markdown links name content pages.
666
+ * it by wikilink, and its own markdown links name content pages. `maps` is
667
+ * each drawing by the URL of the page that carries it.
635
668
  * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
636
669
  * wikiErrors: object[], imageErrors: object[],
637
- * related: Map<string, import("./related-pages.mjs").Related>}} `related`
670
+ * related: Map<string, import("./related-pages.mjs").Related>,
671
+ * maps: number}} `related`
638
672
  * is keyed by page URL, and holds the homepage's block beside every content
639
673
  * page's, so the caller can write it on the page this render does not.
674
+ * `maps` counts the pages written with one.
640
675
  */
641
676
  export function renderPages(pages, options) {
642
677
  const {
@@ -651,6 +686,7 @@ export function renderPages(pages, options) {
651
686
  config,
652
687
  records = [],
653
688
  homepages = [],
689
+ maps = new Map(),
654
690
  } = options;
655
691
 
656
692
  // The address space the art slots and the body's embeds resolve against:
@@ -783,17 +819,40 @@ export function renderPages(pages, options) {
783
819
  }
784
820
 
785
821
  const related = relatedPages(edges, entries);
822
+ // What lies within a place, who holds it, and what an affiliation holds
823
+ // — read off `parents` and `domains` across this package and every
824
+ // fetched index, local notes first so they shadow a dependency's.
825
+ const holdings = holdingsPages([
826
+ ...pages.map((page) => holdingsNode(page.fm, { title: pageTitle(page), url: page.url })),
827
+ ...foreignHoldingsNodes(foreign?.index),
828
+ ]);
786
829
 
830
+ let withMap = 0;
787
831
  for (const { page, body, data } of rendered) {
788
832
  const block = related.get(page.url);
789
833
  if (block) data.related = block;
790
- const dest = path.join(outRoot, pageDestination(page));
834
+ Object.assign(data, holdings.get(page.url));
835
+ const map = maps.get(page.url);
836
+ const dest = path.join(outRoot, pageDestination(page, { bundle: Boolean(map) }));
791
837
  fs.mkdirSync(path.dirname(dest), { recursive: true });
838
+ if (map) {
839
+ fs.copyFileSync(map.file, path.join(path.dirname(dest), map.name));
840
+ data.map = map.name;
841
+ withMap += 1;
842
+ }
792
843
  fs.writeFileSync(dest, matter.stringify(body, data));
793
844
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
794
845
  }
795
846
 
796
- return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors, related };
847
+ return {
848
+ written: pages.length,
849
+ byKind,
850
+ tableErrors,
851
+ wikiErrors,
852
+ imageErrors,
853
+ related,
854
+ maps: withMap,
855
+ };
797
856
  }
798
857
 
799
858
  /**
@@ -846,10 +905,15 @@ export function resolveSitePass(name, options) {
846
905
  * {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
847
906
  * `sql` directive with none prepared is a table error: nothing here runs a
848
907
  * query.
908
+ * @param {(engine: string) => string|undefined} [options.locate] - How a
909
+ * GraphViz engine's binary is found, for the maps; the real lookup by
910
+ * default.
849
911
  * @returns {{gates: object, stats: object|null, tableErrors: object[],
850
- * wikiErrors: object[], imageErrors: object[], manifests: object|null}}
912
+ * wikiErrors: object[], imageErrors: object[], mapFindings: object[],
913
+ * manifests: object|null}} `mapFindings` is what drawing the maps found —
914
+ * warnings, never a reason to fail the build.
851
915
  */
852
- export function buildSite({ config, sqlTables } = {}) {
916
+ export function buildSite({ config, sqlTables, locate } = {}) {
853
917
  const resolved = config ?? loadPackConfig();
854
918
  const site = resolved.site;
855
919
  const scheme = resolved.publish.address;
@@ -945,6 +1009,7 @@ export function buildSite({ config, sqlTables } = {}) {
945
1009
  tableErrors: [],
946
1010
  wikiErrors: [],
947
1011
  imageErrors: [],
1012
+ mapFindings: [],
948
1013
  stats: null,
949
1014
  };
950
1015
  }
@@ -963,6 +1028,7 @@ export function buildSite({ config, sqlTables } = {}) {
963
1028
  tableErrors: [],
964
1029
  wikiErrors: [],
965
1030
  imageErrors: [],
1031
+ mapFindings: [],
966
1032
  stats: {
967
1033
  homepages: writeHomepages(homeRoot, homepages, resolved),
968
1034
  out: homeRoot,
@@ -1004,6 +1070,7 @@ export function buildSite({ config, sqlTables } = {}) {
1004
1070
  tableErrors: [],
1005
1071
  wikiErrors: [],
1006
1072
  imageErrors: [],
1073
+ mapFindings: [],
1007
1074
  };
1008
1075
  }
1009
1076
 
@@ -1012,6 +1079,22 @@ export function buildSite({ config, sqlTables } = {}) {
1012
1079
  repoRoot: resolved.rootDir,
1013
1080
  });
1014
1081
 
1082
+ // The map from each related place, drawn now that every page is known to
1083
+ // be addressable and before any is written, so the page writer lays each
1084
+ // drawing beside its page. Drawn with `base`, which is what every name
1085
+ // in a drawing links through — the same `<base><slug>/` every other href
1086
+ // composes. Off, nothing is drawn and nothing is asked of GraphViz.
1087
+ const drawn =
1088
+ site.maps ?
1089
+ drawSiteMaps({
1090
+ records: ctx.records,
1091
+ foreignIndex: gates.foreign.index,
1092
+ config: resolved,
1093
+ base,
1094
+ ...(locate ? { locate } : {}),
1095
+ })
1096
+ : { maps: new Map(), findings: [] };
1097
+
1015
1098
  const rendered = renderPages(pages, {
1016
1099
  outRoot: out,
1017
1100
  sqlTables,
@@ -1021,6 +1104,7 @@ export function buildSite({ config, sqlTables } = {}) {
1021
1104
  foreign: gates.foreign,
1022
1105
  universe: tableUniverse(pages),
1023
1106
  homepages: homepageEntries,
1107
+ maps: drawn.maps,
1024
1108
  pass,
1025
1109
  // What counts as a being is the toolchain's to say, not a consumer's.
1026
1110
  // Asking in a consumer's script is how one came to still be checking
@@ -1053,9 +1137,11 @@ export function buildSite({ config, sqlTables } = {}) {
1053
1137
  tableErrors: rendered.tableErrors,
1054
1138
  wikiErrors: rendered.wikiErrors,
1055
1139
  imageErrors: rendered.imageErrors,
1140
+ mapFindings: drawn.findings,
1056
1141
  stats: {
1057
1142
  ...rendered.byKind,
1058
1143
  homepages: homepagesWritten,
1144
+ maps: rendered.maps,
1059
1145
  out,
1060
1146
  },
1061
1147
  };
@@ -0,0 +1,165 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The maps a site build writes into its place pages.
16
+ *
17
+ * A place that states, or is named in, a border or a route has a map from it
18
+ * — the drawing `content-build map --from` makes — and the site build draws
19
+ * that map for every such place of this package and hands each one to the
20
+ * page writer, which lays it beside the page as `from-<shortcode>.svg` and
21
+ * names it in the front matter as `map`. The theme inlines it, so its place
22
+ * names are links to their pages: every drawing is made with the site's base,
23
+ * and every `href` in it composes `<base><slug>/` the way every other href
24
+ * the build renders does.
25
+ *
26
+ * GraphViz draws, and a site never fails for want of a map: when the engine is
27
+ * absent the build says so once, as a warning, and writes every page as it
28
+ * would without maps. `site.maps: false` says the site carries none and asks
29
+ * nothing of GraphViz.
30
+ *
31
+ * The drawings land under `build/map/site/` — beside the author's own
32
+ * drawings under `build/map/`, and apart from them, because the site's
33
+ * carry links and the author's may not — and are copied from there into the
34
+ * content mount. A build never mutates its inputs.
35
+ *
36
+ * @module
37
+ */
38
+
39
+ import fs from "node:fs";
40
+ import path from "node:path";
41
+
42
+ import { buildMaps, relatedPlaces } from "./map-build.mjs";
43
+ import { GRAPHVIZ_INSTALL, findGraphviz } from "./map-graphviz.mjs";
44
+ import { mapWorld } from "./map-places.mjs";
45
+
46
+ /**
47
+ * Where the site build draws, below the package root.
48
+ *
49
+ * @type {string}
50
+ */
51
+ export const SITE_MAP_DIR = "build/map/site";
52
+
53
+ /**
54
+ * The engine the map from a place is drawn with.
55
+ *
56
+ * @type {string}
57
+ */
58
+ const ENGINE = "neato";
59
+
60
+ /**
61
+ * A drawing as the page writer receives it.
62
+ *
63
+ * @typedef {object} SiteMap
64
+ * @property {string} name - The file's name in the page's bundle, and the
65
+ * value of the page's `map` key: `from-<shortcode>.svg`.
66
+ * @property {string} file - Where the drawing is, absolute.
67
+ */
68
+
69
+ /**
70
+ * A GraphViz SVG rewritten for inlining in an HTML page.
71
+ *
72
+ * GraphViz writes a standalone document: an XML declaration, a DOCTYPE naming
73
+ * the SVG 1.1 DTD by URL, generator comments, a root sized in points beside
74
+ * its `viewBox`, and links through the `xlink` namespace. Inline in HTML the
75
+ * prologue is invalid, the DTD reference is the one external reference the
76
+ * drawing would carry, the fixed size fights the column it sits in, and
77
+ * `xlink:` is the pre-SVG-2 spelling. So the prologue and the comments go,
78
+ * the root keeps its `viewBox` and loses `width` and `height`, and
79
+ * `xlink:href` and `xlink:title` become `href` and `title`, with the
80
+ * namespace declaration they needed.
81
+ *
82
+ * @param {string} svg - The SVG as GraphViz wrote it.
83
+ * @returns {string} The SVG to inline, starting at `<svg`.
84
+ */
85
+ export function inlineSvg(svg) {
86
+ const start = svg.indexOf("<svg");
87
+ let out = start < 0 ? svg : svg.slice(start);
88
+ out = out.replace(/<!--[\s\S]*?-->\n?/g, "");
89
+ out = out.replace(/^<svg[^>]*>/, (root) =>
90
+ root
91
+ .replace(/\s+(width|height)="[^"]*"/g, "")
92
+ .replace(/\s+xmlns:xlink="[^"]*"/, "")
93
+ .replace(/^<svg\s+/, "<svg "),
94
+ );
95
+ return out.replace(/\bxlink:(href|title)=/g, "$1=");
96
+ }
97
+
98
+ /**
99
+ * Draw the map from every related place of this package.
100
+ *
101
+ * @param {object} opts
102
+ * @param {Array<Record<string, any>>} opts.records - The package's index
103
+ * records, as the site build read them.
104
+ * @param {Map<string, object>} opts.foreignIndex - The fetched indexes the
105
+ * site build resolved links against.
106
+ * @param {object} opts.config - The resolved configuration.
107
+ * @param {string} opts.base - The site base every page is served under,
108
+ * ending in a slash: what every name in a drawing links through.
109
+ * @param {(engine: string) => string|undefined} [opts.locate] - How the
110
+ * engine's binary is found; the real lookup by default.
111
+ * @returns {{maps: Map<string, SiteMap>, findings: Array<{file: string,
112
+ * line?: number, column?: number, severity: "error"|"warning",
113
+ * message: string}>}} The drawings, keyed by the URL of the page each
114
+ * belongs to, and what the drawing found — one warning naming what to
115
+ * install when GraphViz is absent, and a warning for each relation that
116
+ * names no place.
117
+ */
118
+ export function drawSiteMaps({ records, foreignIndex, config, base, locate = findGraphviz }) {
119
+ /** @type {Map<string, SiteMap>} */
120
+ const maps = new Map();
121
+ const outDir = path.join(config.rootDir, ...SITE_MAP_DIR.split("/"));
122
+ // Every run draws afresh: a place that lost its relations must not keep
123
+ // the map an earlier run drew.
124
+ fs.rmSync(outDir, { recursive: true, force: true });
125
+
126
+ if (!locate(ENGINE)) {
127
+ return {
128
+ maps,
129
+ findings: [
130
+ {
131
+ file: config.rootDir,
132
+ severity: "warning",
133
+ message:
134
+ `GraphViz's \`${ENGINE}\` is not installed, so no place page carries ` +
135
+ `the map from that place; ${GRAPHVIZ_INSTALL} to draw them, or set ` +
136
+ "`site.maps: false` to say the site carries none",
137
+ },
138
+ ],
139
+ };
140
+ }
141
+
142
+ const world = mapWorld({
143
+ records,
144
+ foreignIndex,
145
+ contentBase: config.paths.content,
146
+ base,
147
+ config,
148
+ });
149
+ // This package's related places, which are the ones with a page here to
150
+ // carry a map. A dependency's place is drawn on those maps and gets none
151
+ // of its own from this build.
152
+ const centres = relatedPlaces(world.places).filter((s) => world.places.get(s)?.local);
153
+ if (centres.length === 0) return { maps, findings: [] };
154
+
155
+ const { findings } = buildMaps({ world, outDir, from: centres, locate });
156
+ for (const centre of centres) {
157
+ const place = world.places.get(centre);
158
+ if (!place?.url) continue;
159
+ const name = `from-${centre}.svg`;
160
+ const file = path.join(outDir, name);
161
+ fs.writeFileSync(file, inlineSvg(fs.readFileSync(file, "utf8")));
162
+ maps.set(place.url, { name, file });
163
+ }
164
+ return { maps, findings };
165
+ }
@@ -38,6 +38,7 @@ import os from "node:os";
38
38
  import path from "node:path";
39
39
 
40
40
  import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
41
+ import { MARKET_CLASSES } from "./market-class.mjs";
41
42
  import { parseMarkdownFile } from "./helpers.mjs";
42
43
  // The record accessors only — see `engine/index-records.mjs`.
43
44
  import { isNoteRecord, noteFile } from "./index-records.mjs";
@@ -166,19 +167,84 @@ export async function openNotesDatabase(records, { dir, dependencies = [] } = {}
166
167
  const instance = await DuckDBInstance.create(":memory:");
167
168
  const connection = await instance.connect();
168
169
  await connection.run("SET threads=1");
169
- await connection.run(`CREATE VIEW notes AS ${readJsonAuto(jsonl)}`);
170
+ await createRelations(connection, jsonl);
170
171
 
171
172
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
172
173
  // that package published. Quoted, because a package id may carry a hyphen
173
- // (`sohl-thalorna`) and an unquoted identifier may not.
174
+ // (`sohl-thalorna`) and an unquoted identifier may not. Both views are
175
+ // created, so `FROM sohl.entries` reads a dependency's stubs the same way.
174
176
  for (const dep of dependencies) {
175
177
  if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
176
178
  const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
177
179
  await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
178
- await connection.run(`CREATE VIEW ${schema}.notes AS ${readJsonAuto(dep.file)}`);
180
+ await createRelations(connection, dep.file, `${schema}.`);
181
+ }
182
+
183
+ // The market scale as a relation, so a table prints `village` beside the
184
+ // number a note wrote without a second copy of the scale living in authored
185
+ // content. Three columns, because the scale states three things and a join
186
+ // that could only give back the name would send the next author to copy the
187
+ // rest by hand.
188
+ await connection.run(
189
+ "CREATE VIEW market AS SELECT * FROM (VALUES " +
190
+ MARKET_CLASSES.map(
191
+ (c) =>
192
+ `(${c.value}, '${c.name.replace(/'/g, "''")}', ` +
193
+ `'${c.trade.replace(/'/g, "''")}')`,
194
+ ).join(", ") +
195
+ ") AS t(value, name, trade)",
196
+ );
197
+
198
+ // The stub-inclusive reading of `notes`, in a schema of its own. Putting it
199
+ // on the search path re-runs an authored query with unqualified `notes`
200
+ // resolving to `entries`, which is how a fence that would gain rows from
201
+ // the wider relation is found without parsing anybody's SQL — see
202
+ // {@link prepareSqlTables}.
203
+ await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
204
+ await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
205
+
206
+ // Whether this index holds a stub at all, asked once. A corpus with none
207
+ // pays nothing for the comparison above, which is every package that has
208
+ // not started writing them.
209
+ const hasStubs =
210
+ (
211
+ await connection.runAndReadAll("SELECT count(*) AS n FROM entries WHERE state = 'stub'")
212
+ ).getRowObjects()[0].n > 0;
213
+
214
+ /**
215
+ * How many rows a query loses by selecting `FROM notes` rather than
216
+ * `FROM entries`.
217
+ *
218
+ * Answered by **running the author's query again** with unqualified `notes`
219
+ * resolving to the stub-inclusive relation, rather than by reading the
220
+ * `WHERE` clause. Reading it would mean parsing somebody else's SQL to work
221
+ * out which types a fence covers, and being wrong about that is exactly the
222
+ * silent failure the two views exist to prevent. Re-running is exact, and
223
+ * it costs nothing at all for a corpus with no stub, which is the common
224
+ * case and the only one that is asked twice for nothing.
225
+ *
226
+ * @param {string} sql - The query, as authored.
227
+ * @param {number} rows - How many rows it selected as written.
228
+ * @returns {Promise<number>} How many rows it would gain.
229
+ */
230
+ async function stubsExcluded(sql, rows) {
231
+ if (!hasStubs) return 0;
232
+ try {
233
+ await connection.run(`SET search_path='${WITH_STUBS_SCHEMA}'`);
234
+ const wider = await connection.runAndReadAll(sql);
235
+ return Math.max(0, wider.getRowObjects().length - rows);
236
+ } catch {
237
+ // A query that cannot run under the wider relation has nothing to
238
+ // say about stubs, and the failure it does have is reported by the
239
+ // run that matters.
240
+ return 0;
241
+ } finally {
242
+ await connection.run("RESET search_path");
243
+ }
179
244
  }
180
245
 
181
246
  return {
247
+ stubsExcluded,
182
248
  async query(sql) {
183
249
  const reader = await connection.runAndReadAll(sql);
184
250
  return {
@@ -201,6 +267,78 @@ export async function openNotesDatabase(records, { dir, dependencies = [] } = {}
201
267
  };
202
268
  }
203
269
 
270
+ /**
271
+ * The schema whose `notes` is the stub-inclusive relation.
272
+ *
273
+ * A name nothing authored would collide with, because it is put on the search
274
+ * path underneath a query somebody else wrote.
275
+ *
276
+ * @type {string}
277
+ */
278
+ const WITH_STUBS_SCHEMA = "__with_stubs";
279
+
280
+ /**
281
+ * Create the two relations an index is read through, and the ladder one of them
282
+ * derives.
283
+ *
284
+ * **`entries` is every row**, stubs included, plus a derived `state` column:
285
+ *
286
+ * ```text
287
+ * address IS NULL → stub
288
+ * list_contains(tags, 'draft') → draft
289
+ * otherwise → full
290
+ * ```
291
+ *
292
+ * The derivation lives here and nowhere else. Nothing is stored in the index,
293
+ * no note can author it, and no query repeats the `CASE` — the same rule that
294
+ * keeps `_ref` and `_section` ordinary SQL rather than fence options. The stub
295
+ * test is `address IS NULL` because the index carries no bodies, and it is
296
+ * exact: an address is emitted if and only if the body is non-empty on a type
297
+ * an empty body suppresses.
298
+ *
299
+ * **`notes` is `entries` without the stubs**, which is what every authored
300
+ * fence already means by it. A `notes` that silently gained stubs would grow
301
+ * every table selecting over it with nobody deciding, and a build that exits 0
302
+ * and produces the wrong output is the costly kind of failure. Adopting stubs
303
+ * in a given table is a one-word edit an author makes on purpose; the fences
304
+ * that would gain rows are warned about rather than changed.
305
+ *
306
+ * **The columns are asked for rather than assumed.** `union_by_name` infers the
307
+ * union of what the rows carry, so a corpus in which no note carries a tag has
308
+ * no `tags` column at all and naming it would fail to bind — which is a real
309
+ * index, not a hypothetical one. So the shape is described first and each half
310
+ * of the ladder falls back to a constant where its column is absent.
311
+ *
312
+ * @param {object} connection - An open DuckDB connection.
313
+ * @param {string} file - The JSONL to read.
314
+ * @param {string} [prefix] - A schema to qualify the view names with.
315
+ * @returns {Promise<void>}
316
+ */
317
+ async function createRelations(connection, file, prefix = "") {
318
+ const read = readJsonAuto(file);
319
+ const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
320
+ const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
321
+ // A row with no address is a stub. With no address column anywhere, every
322
+ // row is one — which is what an index of nothing but unaddressable notes
323
+ // says, and saying it is more honest than calling them all full.
324
+ const stub = columns.has("address") ? "address IS NULL" : "true";
325
+ // `tags:` is authored by hand: a single tag may be a scalar rather than a
326
+ // list, so both readings are asked.
327
+ const draft =
328
+ columns.has("tags") ?
329
+ "COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
330
+ "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
331
+ : "false";
332
+ await connection.run(
333
+ `CREATE VIEW ${prefix}entries AS SELECT *, ` +
334
+ `CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
335
+ `ELSE 'full' END AS state FROM (${read})`,
336
+ );
337
+ await connection.run(
338
+ `CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
339
+ );
340
+ }
341
+
204
342
  /**
205
343
  * The `read_json_auto` clause both the own-notes view and a dependency's use.
206
344
  *
@@ -407,6 +545,11 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
407
545
  sectionLevel: block.sectionLevel,
408
546
  }),
409
547
  rows: result.rows.length,
548
+ // How many rows this table would gain from the wider
549
+ // relation, so a fence that has not adopted stubs says so
550
+ // rather than quietly listing fewer settlements than the
551
+ // region has.
552
+ stubsExcluded: await db.stubsExcluded?.(block.query, result.rows.length),
410
553
  allowEmpty: block.allowEmpty,
411
554
  });
412
555
  } catch (err) {
@@ -470,7 +613,17 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
470
613
  if (!sources.length) return undefined;
471
614
  // A cell links only where the address it would emit resolves, so a table
472
615
  // never ships a link the wikilink pass will then report dead.
473
- const addresses = new Set(indexRecords.map((record) => record.address?.slug).filter(Boolean));
616
+ //
617
+ // A documentation journal is left out, and that is the whole of the stub
618
+ // rule here: its slug is its note's, so a written note supplies it anyway,
619
+ // while a stub's journal would put back the very slug the stub withheld and
620
+ // the cell would link to a page that does not exist.
621
+ const addresses = new Set(
622
+ indexRecords
623
+ .filter((record) => !record.documents)
624
+ .map((record) => record.address?.slug)
625
+ .filter(Boolean),
626
+ );
474
627
  // Each declared dependency's published index, attached as its own schema so
475
628
  // a table can read `FROM <package>.notes`. Imported here for the
476
629
  // same cycle reason the index is, and tolerated when absent: a tree with no