@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.
- package/CHANGELOG.md +33 -0
- package/CONTENT.md +192 -1
- package/bin/content-build.mjs +26 -4
- package/content-config.mjs +8 -3
- package/docs/api.md +99 -26
- package/docs/configuration.md +20 -1
- package/docs/content-format.md +238 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +63 -21
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/frontmatter-lint.mjs +2 -0
- package/engine/holdings.mjs +416 -0
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +16 -0
- package/engine/map-graphviz.mjs +12 -3
- package/engine/map-places.mjs +33 -3
- package/engine/market-class.mjs +178 -0
- package/engine/metadata-index.mjs +7 -4
- package/engine/note-state.mjs +213 -0
- package/engine/note-vocabulary.mjs +59 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +96 -10
- package/engine/site-maps.mjs +165 -0
- package/engine/sql-tables.mjs +157 -4
- package/engine/stub-lint.mjs +237 -0
- package/engine/wikilink-syntax.mjs +27 -1
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +19 -5
- package/types/engine/content-links.d.mts +3 -3
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/holdings.d.mts +157 -0
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-graphviz.d.mts +8 -1
- package/types/engine/map-places.d.mts +26 -0
- package/types/engine/market-class.d.mts +62 -0
- package/types/engine/note-state.d.mts +110 -0
- package/types/engine/note-vocabulary.d.mts +25 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/site-build.d.mts +37 -6
- package/types/engine/site-maps.d.mts +81 -0
- package/types/engine/stub-lint.d.mts +51 -0
- package/types/engine/wikilink-syntax.d.mts +19 -1
package/engine/site-build.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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[],
|
|
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
|
+
}
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|