@heroiclands/package-build 20.7.0 → 21.1.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 +183 -0
- package/CONTENT.md +132 -44
- package/bin/content-build.mjs +37 -5
- package/bin/package-build.mjs +77 -0
- package/content-config.mjs +59 -1
- package/docs/api.md +149 -19
- package/docs/commands.md +75 -0
- package/docs/configuration.md +37 -10
- package/docs/content-format.md +450 -49
- package/engine/content-format.mjs +52 -3
- package/engine/content-images.mjs +699 -0
- package/engine/dependency-bump.mjs +218 -0
- package/engine/frontmatter-lint.mjs +89 -2
- package/engine/helpers.mjs +81 -142
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +81 -0
- package/engine/infobox-render.mjs +382 -0
- package/engine/infobox.mjs +963 -0
- package/engine/item-registry.mjs +5 -5
- package/engine/journals.mjs +22 -1
- package/engine/map-notes.mjs +11 -5
- package/engine/metadata-index.mjs +5 -0
- package/engine/note-vocabulary.mjs +57 -2
- package/engine/pathnames.mjs +374 -0
- package/engine/pdf-build.mjs +206 -9
- package/engine/pdf-render.mjs +453 -20
- package/engine/pdf-toc.mjs +77 -5
- package/engine/scenes.mjs +2 -1
- package/engine/site-build.mjs +106 -7
- package/engine/site-index.mjs +93 -4
- package/engine/wikilinks.mjs +93 -0
- package/hm3/default-item-art.mjs +14 -15
- package/hm3/index.mjs +3 -0
- package/hm3/infobox.mjs +64 -0
- package/package.json +1 -1
- package/sohl/default-item-art.mjs +18 -16
- package/sohl/index.mjs +3 -0
- package/sohl/infobox.mjs +499 -0
- package/types/content-config.d.mts +7 -0
- package/types/engine/content-format.d.mts +36 -0
- package/types/engine/content-images.d.mts +281 -0
- package/types/engine/dependency-bump.d.mts +89 -0
- package/types/engine/frontmatter-lint.d.mts +23 -0
- package/types/engine/helpers.d.mts +30 -72
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -0
- package/types/engine/infobox-render.d.mts +87 -0
- package/types/engine/infobox.d.mts +443 -0
- package/types/engine/item-registry.d.mts +5 -5
- package/types/engine/journals.d.mts +9 -1
- package/types/engine/note-vocabulary.d.mts +51 -0
- package/types/engine/pathnames.d.mts +189 -0
- package/types/engine/pdf-build.d.mts +46 -0
- package/types/engine/pdf-render.d.mts +97 -1
- package/types/engine/pdf-toc.d.mts +10 -5
- package/types/engine/site-build.d.mts +11 -3
- package/types/engine/site-index.d.mts +35 -3
- package/types/engine/wikilinks.d.mts +22 -0
- package/types/hm3/default-item-art.d.mts +5 -6
- package/types/hm3/index.d.mts +1 -0
- package/types/hm3/infobox.d.mts +22 -0
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/infobox.d.mts +145 -0
package/engine/pdf-toc.mjs
CHANGED
|
@@ -54,16 +54,86 @@ import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
|
54
54
|
/**
|
|
55
55
|
* Presentation a node may declare, and that its descendants inherit.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
57
|
+
* Inheritance is what makes them worth declaring at all — `Gear` says once
|
|
58
|
+
* which banner its entries are plated over, and nine sections beneath it
|
|
59
|
+
* agree.
|
|
60
|
+
*
|
|
61
|
+
* **`page` and `footer` are read by the renderer**, and their shapes are
|
|
62
|
+
* checked here so that a key nothing can act on is an error rather than a
|
|
63
|
+
* silence. `header` and `infobox` are reserved and read by nothing: the
|
|
64
|
+
* running head is a foot in this design, and which infobox a note draws is
|
|
65
|
+
* decided by the note's type. They keep their place in the format because the
|
|
66
|
+
* shape of the file is the thing consumers commit to.
|
|
62
67
|
*
|
|
63
68
|
* @type {readonly string[]}
|
|
64
69
|
*/
|
|
65
70
|
export const PRESENTATION_KEYS = Object.freeze(["header", "footer", "infobox", "page"]);
|
|
66
71
|
|
|
72
|
+
/** Keys the `page:` presentation may carry. @type {readonly string[]} */
|
|
73
|
+
const PAGE_KEYS = Object.freeze(["banner", "columns", "kicker"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How many columns a section's pages may be set in.
|
|
77
|
+
*
|
|
78
|
+
* One or two is the real choice; the ceiling is there so a typo that reads as
|
|
79
|
+
* a number cannot produce a page of unreadable slivers.
|
|
80
|
+
*
|
|
81
|
+
* @type {number}
|
|
82
|
+
*/
|
|
83
|
+
const MAX_COLUMNS = 4;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check the presentation a section declares, in the two keys anything reads.
|
|
87
|
+
*
|
|
88
|
+
* @param {Record<string, unknown>} entry - The section.
|
|
89
|
+
* @param {Array<string|number>} at - Where it sits in the document.
|
|
90
|
+
* @param {object} ctx - `{ findings, text }`.
|
|
91
|
+
* @returns {void}
|
|
92
|
+
*/
|
|
93
|
+
function checkPresentation(entry, at, ctx) {
|
|
94
|
+
if (entry.footer !== undefined && typeof entry.footer !== "string") {
|
|
95
|
+
ctx.findings.push(
|
|
96
|
+
finding(ctx, [...at, "footer"], "`footer:` must be the name the running foot carries"),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (entry.page === undefined) return;
|
|
100
|
+
if (!entry.page || typeof entry.page !== "object" || Array.isArray(entry.page)) {
|
|
101
|
+
ctx.findings.push(finding(ctx, [...at, "page"], "`page:` must be a mapping"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const page = /** @type {Record<string, unknown>} */ (entry.page);
|
|
105
|
+
for (const key of Object.keys(page)) {
|
|
106
|
+
if (!PAGE_KEYS.includes(key)) {
|
|
107
|
+
ctx.findings.push(
|
|
108
|
+
finding(ctx, [...at, "page", key], `unknown key \`${key}:\` under \`page:\``, {
|
|
109
|
+
key: true,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const key of ["banner", "kicker"]) {
|
|
115
|
+
if (page[key] !== undefined && (typeof page[key] !== "string" || !page[key].trim())) {
|
|
116
|
+
ctx.findings.push(
|
|
117
|
+
finding(ctx, [...at, "page", key], `\`page.${key}:\` must be a non-empty string`),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
page.columns !== undefined &&
|
|
123
|
+
(!Number.isInteger(page.columns) ||
|
|
124
|
+
/** @type {number} */ (page.columns) < 1 ||
|
|
125
|
+
/** @type {number} */ (page.columns) > MAX_COLUMNS)
|
|
126
|
+
) {
|
|
127
|
+
ctx.findings.push(
|
|
128
|
+
finding(
|
|
129
|
+
ctx,
|
|
130
|
+
[...at, "page", "columns"],
|
|
131
|
+
`\`page.columns:\` must be a whole number from 1 to ${MAX_COLUMNS}`,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
67
137
|
/** Keys a section node may carry. @type {readonly string[]} */
|
|
68
138
|
const SECTION_KEYS = Object.freeze(["sectionName", "contents", ...PRESENTATION_KEYS]);
|
|
69
139
|
|
|
@@ -164,6 +234,8 @@ function walkSections(contents, keyPath, trail, inherited, ctx) {
|
|
|
164
234
|
}
|
|
165
235
|
}
|
|
166
236
|
|
|
237
|
+
checkPresentation(entry, at, ctx);
|
|
238
|
+
|
|
167
239
|
const presentation = { ...inherited };
|
|
168
240
|
for (const key of PRESENTATION_KEYS) {
|
|
169
241
|
if (entry[key] !== undefined) presentation[key] = entry[key];
|
package/engine/scenes.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import log from "loglevel";
|
|
|
53
53
|
import {
|
|
54
54
|
parseMarkdownFile,
|
|
55
55
|
sohlField,
|
|
56
|
+
resolveImg,
|
|
56
57
|
resolveName,
|
|
57
58
|
slugify,
|
|
58
59
|
defaultStats,
|
|
@@ -472,7 +473,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
472
473
|
this.places.set(placeKey, {
|
|
473
474
|
key: placeKey,
|
|
474
475
|
name: sohlField(fm, "placeName", null) || name,
|
|
475
|
-
img: sohlField(fm, "img", null),
|
|
476
|
+
img: resolveImg(sohlField(fm, "img", null)),
|
|
476
477
|
pinned: false,
|
|
477
478
|
scenes: [],
|
|
478
479
|
journal: [],
|
package/engine/site-build.mjs
CHANGED
|
@@ -53,9 +53,13 @@ import { slugify } from "./content-slug.mjs";
|
|
|
53
53
|
import { addressSlug } from "./content-address.mjs";
|
|
54
54
|
import { protectCode } from "./code-fences.mjs";
|
|
55
55
|
import { expandContentTables } from "./content-tables.mjs";
|
|
56
|
-
import {
|
|
56
|
+
import { renderImageFigures } from "./content-images.mjs";
|
|
57
|
+
import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
|
|
58
|
+
import { ART_FIELDS } from "./frontmatter-lint.mjs";
|
|
59
|
+
import { buildSiteIndex, resolveInfoboxRef, wikiContext } from "./site-index.mjs";
|
|
57
60
|
import { frontmatterWikilinks, resolveWebWikilinks } from "./web-wikilinks.mjs";
|
|
58
61
|
import { loadForeignIndexes } from "./metadata-index.mjs";
|
|
62
|
+
import { noteInfoboxes } from "./infobox-registry.mjs";
|
|
59
63
|
import { formatUnaddressableFinding, unaddressableForeignPackages } from "./metadata-index.mjs";
|
|
60
64
|
import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
|
|
61
65
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
@@ -623,9 +627,13 @@ export function sectionFrontmatter(meta) {
|
|
|
623
627
|
* for.
|
|
624
628
|
* @param {(data: object, page: object) => void} [options.decorate] - Called
|
|
625
629
|
* with each page's frontmatter, for whatever a consumer's own pass adds.
|
|
630
|
+
* @param {(src: string) => string} [options.webSrc] - Translates an authored
|
|
631
|
+
* pathname into the address the website serves. Every artwork field goes
|
|
632
|
+
* through it, so a page's `img:` and its body images name the same file the
|
|
633
|
+
* same way.
|
|
626
634
|
* @returns {object} The frontmatter to write.
|
|
627
635
|
*/
|
|
628
|
-
export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
636
|
+
export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc }) {
|
|
629
637
|
const { fm, name, slug, sec, isReadme } = page;
|
|
630
638
|
let data;
|
|
631
639
|
if (page.kind === "content") {
|
|
@@ -657,9 +665,40 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
657
665
|
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
658
666
|
}
|
|
659
667
|
delete data.aliases;
|
|
668
|
+
if (webSrc) resolveArtFields(data, webSrc);
|
|
660
669
|
return data;
|
|
661
670
|
}
|
|
662
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Rewrite a page's artwork fields into the addresses the website serves.
|
|
674
|
+
*
|
|
675
|
+
* The same fields the linter holds to the pathname rule, read from the same
|
|
676
|
+
* list, so a third art field added to the vocabulary reaches the page without
|
|
677
|
+
* anyone remembering this function exists. Only an authored **string** is
|
|
678
|
+
* touched: `null` is a note naming no art and `""` is one naming none on
|
|
679
|
+
* purpose, and neither is a pathname to resolve.
|
|
680
|
+
*
|
|
681
|
+
* @param {object} data - The frontmatter being emitted, rewritten in place.
|
|
682
|
+
* @param {(src: string) => string} webSrc - The website's resolver.
|
|
683
|
+
* @returns {void}
|
|
684
|
+
*/
|
|
685
|
+
function resolveArtFields(data, webSrc) {
|
|
686
|
+
for (const { key, inData } of ART_FIELDS) {
|
|
687
|
+
const holder = inData && isPlainObject(data.data) ? data.data : data;
|
|
688
|
+
const value = holder?.[key];
|
|
689
|
+
if (typeof value !== "string" || value === "") continue;
|
|
690
|
+
holder[key] = webSrc(value);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* @param {unknown} value - Anything.
|
|
696
|
+
* @returns {boolean} Whether it is a mapping a field may be read out of.
|
|
697
|
+
*/
|
|
698
|
+
function isPlainObject(value) {
|
|
699
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
700
|
+
}
|
|
701
|
+
|
|
663
702
|
/**
|
|
664
703
|
* Where a page is written, relative to the output root.
|
|
665
704
|
*
|
|
@@ -704,7 +743,8 @@ export function pageDestination(page) {
|
|
|
704
743
|
*
|
|
705
744
|
* @param {object[]} pages - Every page.
|
|
706
745
|
* @param {object} options - Everything the render needs.
|
|
707
|
-
* @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
|
|
746
|
+
* @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
|
|
747
|
+
* wikiErrors: object[], imageErrors: object[]}}
|
|
708
748
|
*/
|
|
709
749
|
export function renderPages(pages, options) {
|
|
710
750
|
const {
|
|
@@ -717,12 +757,54 @@ export function renderPages(pages, options) {
|
|
|
717
757
|
decorate,
|
|
718
758
|
linkable = (d) => Boolean(d.fm.shortcode),
|
|
719
759
|
sqlTables,
|
|
760
|
+
config,
|
|
720
761
|
} = options;
|
|
721
762
|
|
|
722
763
|
const tableErrors = [];
|
|
723
764
|
const wikiErrors = [];
|
|
765
|
+
const imageErrors = [];
|
|
724
766
|
const byKind = {};
|
|
725
767
|
|
|
768
|
+
/**
|
|
769
|
+
* The address the website serves for one authored pathname.
|
|
770
|
+
*
|
|
771
|
+
* A pathname the rule refuses, and a package-owned one with no asset host
|
|
772
|
+
* to resolve against, are both reported and emitted as authored: a page
|
|
773
|
+
* still publishes, with a picture the reader can see is missing, and the
|
|
774
|
+
* build exits non-zero. The occurrence count is what lets the command find
|
|
775
|
+
* the literal in the note and report a line and a column, the way a
|
|
776
|
+
* wikilink finding is located.
|
|
777
|
+
*
|
|
778
|
+
* @param {string} file - The note, for the finding.
|
|
779
|
+
* @returns {(src: string) => string} The resolver for that note's images.
|
|
780
|
+
*/
|
|
781
|
+
const webAddresses = (file) => {
|
|
782
|
+
/** @type {Map<string, number>} */
|
|
783
|
+
const seen = new Map();
|
|
784
|
+
return (src) => {
|
|
785
|
+
const occurrence = (seen.get(src) ?? 0) + 1;
|
|
786
|
+
seen.set(src, occurrence);
|
|
787
|
+
const problem = pathnameProblem(src);
|
|
788
|
+
if (problem) {
|
|
789
|
+
imageErrors.push({ file, src, occurrence, message: problem });
|
|
790
|
+
return src;
|
|
791
|
+
}
|
|
792
|
+
const forms = resolvePathname(src, config);
|
|
793
|
+
if (!forms || forms.web !== null) return forms?.web ?? src;
|
|
794
|
+
imageErrors.push({
|
|
795
|
+
file,
|
|
796
|
+
src,
|
|
797
|
+
occurrence,
|
|
798
|
+
message:
|
|
799
|
+
`\`${src}\` names a file the \`${forms.package}\` package ships, and ` +
|
|
800
|
+
"no asset host is configured to serve it from — set `site.assets` in " +
|
|
801
|
+
"package-build.config.yaml. Emitted as authored, the address resolves " +
|
|
802
|
+
"against the page's own URL, which is nowhere",
|
|
803
|
+
});
|
|
804
|
+
return src;
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
|
|
726
808
|
for (const page of pages) {
|
|
727
809
|
// The page's path in the tree an author edits: below the content root
|
|
728
810
|
// for a content note, below the tree's own root for a `trees` page. It
|
|
@@ -737,12 +819,17 @@ export function renderPages(pages, options) {
|
|
|
737
819
|
foreignIndex: foreign.index,
|
|
738
820
|
});
|
|
739
821
|
|
|
822
|
+
const webSrc = webAddresses(page.file);
|
|
740
823
|
const resolve = (text) => {
|
|
741
824
|
let t = text;
|
|
742
825
|
if (pass.beforeLinks) t = pass.beforeLinks(t, page);
|
|
743
826
|
t = resolveWebWikilinks(t, ctx);
|
|
744
827
|
if (pass.afterLinks) t = pass.afterLinks(t, page);
|
|
745
|
-
|
|
828
|
+
// Last, so a consumer's own rewrites see the image as the note
|
|
829
|
+
// wrote it rather than as a figure. Hugo is handed markdown, not a
|
|
830
|
+
// rendered page, so a `{…}` directive left in the body would reach
|
|
831
|
+
// the reader as its own literal braces.
|
|
832
|
+
return renderImageFigures(t, webSrc);
|
|
746
833
|
};
|
|
747
834
|
|
|
748
835
|
let body = page.body;
|
|
@@ -764,14 +851,14 @@ export function renderPages(pages, options) {
|
|
|
764
851
|
body = markdown;
|
|
765
852
|
}
|
|
766
853
|
|
|
767
|
-
const data = pageFrontmatter(page, { readmeSections, decorate });
|
|
854
|
+
const data = pageFrontmatter(page, { readmeSections, decorate, webSrc });
|
|
768
855
|
const dest = path.join(outRoot, pageDestination(page));
|
|
769
856
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
770
857
|
fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
|
|
771
858
|
byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
|
|
772
859
|
}
|
|
773
860
|
|
|
774
|
-
return { written: pages.length, byKind, tableErrors, wikiErrors };
|
|
861
|
+
return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors };
|
|
775
862
|
}
|
|
776
863
|
|
|
777
864
|
/**
|
|
@@ -971,7 +1058,7 @@ export function resolveOutputRoot(rootDir, out) {
|
|
|
971
1058
|
* `sql` directive with none prepared is a table error: nothing here runs a
|
|
972
1059
|
* query.
|
|
973
1060
|
* @returns {{gates: object, stats: object|null, tableErrors: object[],
|
|
974
|
-
* wikiErrors: object[], manifests: object|null}}
|
|
1061
|
+
* wikiErrors: object[], imageErrors: object[], manifests: object|null}}
|
|
975
1062
|
*/
|
|
976
1063
|
export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
977
1064
|
const resolved = config ?? loadPackConfig();
|
|
@@ -1065,6 +1152,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1065
1152
|
manifests: null,
|
|
1066
1153
|
tableErrors: [],
|
|
1067
1154
|
wikiErrors: [],
|
|
1155
|
+
imageErrors: [],
|
|
1068
1156
|
stats: null,
|
|
1069
1157
|
};
|
|
1070
1158
|
}
|
|
@@ -1082,6 +1170,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1082
1170
|
manifests: null,
|
|
1083
1171
|
tableErrors: [],
|
|
1084
1172
|
wikiErrors: [],
|
|
1173
|
+
imageErrors: [],
|
|
1085
1174
|
stats: {
|
|
1086
1175
|
homepages: writeHomepages(homeRoot, homepages, resolved),
|
|
1087
1176
|
landings: 0,
|
|
@@ -1133,6 +1222,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1133
1222
|
stats: null,
|
|
1134
1223
|
tableErrors: [],
|
|
1135
1224
|
wikiErrors: [],
|
|
1225
|
+
imageErrors: [],
|
|
1136
1226
|
};
|
|
1137
1227
|
}
|
|
1138
1228
|
|
|
@@ -1144,6 +1234,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1144
1234
|
const rendered = renderPages(pages, {
|
|
1145
1235
|
outRoot: out,
|
|
1146
1236
|
sqlTables,
|
|
1237
|
+
config: resolved,
|
|
1147
1238
|
index: gates.index,
|
|
1148
1239
|
foreign: gates.foreign,
|
|
1149
1240
|
universe: tableUniverse(pages),
|
|
@@ -1157,6 +1248,13 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1157
1248
|
if (isBeing(page.fm)) {
|
|
1158
1249
|
data.sohl = deriveBeingInfo(page.fm.sohl, gates.index.refIndex);
|
|
1159
1250
|
}
|
|
1251
|
+
// The declared infobox, travelling in the page's own front matter.
|
|
1252
|
+
// The site theme draws it; what it holds is settled here, so the
|
|
1253
|
+
// same panel reaches the website, a compendium journal and the
|
|
1254
|
+
// book from one definition.
|
|
1255
|
+
data.infoboxes = noteInfoboxes(page.fm, {
|
|
1256
|
+
resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
|
|
1257
|
+
});
|
|
1160
1258
|
},
|
|
1161
1259
|
});
|
|
1162
1260
|
|
|
@@ -1174,6 +1272,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1174
1272
|
gates,
|
|
1175
1273
|
tableErrors: rendered.tableErrors,
|
|
1176
1274
|
wikiErrors: rendered.wikiErrors,
|
|
1275
|
+
imageErrors: rendered.imageErrors,
|
|
1177
1276
|
stats: {
|
|
1178
1277
|
...rendered.byKind,
|
|
1179
1278
|
homepages: homepagesWritten,
|
package/engine/site-index.mjs
CHANGED
|
@@ -95,9 +95,10 @@ import { isDraftNote } from "./note-vocabulary.mjs";
|
|
|
95
95
|
* @property {Set<string>} contentTypes Every type the resolver should read as
|
|
96
96
|
* an address qualifier, local and foreign.
|
|
97
97
|
* @property {Set<string>} sections Section names, lowercased.
|
|
98
|
-
* @property {Map<string, {name: string, url: string}>} refIndex
|
|
99
|
-
* → page, for callers
|
|
100
|
-
* references (a
|
|
98
|
+
* @property {Map<string, {name: string, url: string, subType?: string}>} refIndex
|
|
99
|
+
* `type:shortcode` → page, for callers
|
|
100
|
+
* resolving embedded references (a
|
|
101
|
+
* being's items, say).
|
|
101
102
|
* @property {{key: string, package: string}[]} conflicts Addresses claimed by
|
|
102
103
|
* more than one package. Non-empty is a
|
|
103
104
|
* build failure; the caller reports it.
|
|
@@ -230,13 +231,23 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
230
231
|
if (e.kind !== "content") continue;
|
|
231
232
|
const type = String(e.fm.type).toLowerCase();
|
|
232
233
|
contentTypes.add(type);
|
|
233
|
-
|
|
234
|
+
// `subType` rides along because a caller resolving a reference often
|
|
235
|
+
// needs to know what it found, not only where it is: an infobox groups
|
|
236
|
+
// a being's skills by the family each skill note declares, and that
|
|
237
|
+
// fact lives on the target rather than on the reference.
|
|
238
|
+
const value = {
|
|
239
|
+
url: e.url,
|
|
240
|
+
name: e.name,
|
|
241
|
+
draft: isDraftNote(e.fm),
|
|
242
|
+
...(e.fm.subType ? { subType: e.fm.subType } : {}),
|
|
243
|
+
};
|
|
234
244
|
|
|
235
245
|
const shortcode = e.fm.shortcode;
|
|
236
246
|
if (typeof shortcode === "string" && shortcode) {
|
|
237
247
|
refIndex.set(`${e.fm.type}:${shortcode}`, {
|
|
238
248
|
name: e.name,
|
|
239
249
|
url: e.url,
|
|
250
|
+
...(e.fm.subType ? { subType: e.fm.subType } : {}),
|
|
240
251
|
});
|
|
241
252
|
index.set(`${type}/${shortcode}`.toLowerCase(), value);
|
|
242
253
|
// The canonical address alongside the short one. The short form
|
|
@@ -338,3 +349,81 @@ export function wikiContext(built, { src, file, type = null, errors, foreignInde
|
|
|
338
349
|
file,
|
|
339
350
|
};
|
|
340
351
|
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Resolve one infobox reference against a site index.
|
|
355
|
+
*
|
|
356
|
+
* A note writes a reference three ways and all three reach here: a bare
|
|
357
|
+
* **shortcode** (`slntlncmpny`), a short **address** (`affiliation-slntlncmpny`)
|
|
358
|
+
* and a **canonical** one (`sohl-sohl-skill-melee`). The first is the ordinary
|
|
359
|
+
* case and the ambiguous one — a shortcode is unique within a type and not
|
|
360
|
+
* across a tree — so a caller that knows what it expects passes `hint.type`
|
|
361
|
+
* and the lookup is narrowed to it.
|
|
362
|
+
*
|
|
363
|
+
* **Without a hint the types are tried in sorted order**, so two trees holding
|
|
364
|
+
* the same note resolve it the same way. A shortcode two types both claim
|
|
365
|
+
* answers with the first alphabetically, which is a stable wrong answer rather
|
|
366
|
+
* than an unstable one; a caller that cares supplies the hint.
|
|
367
|
+
*
|
|
368
|
+
* The `address` on the answer is the slug form — `type-shortcode` — because
|
|
369
|
+
* that is what the book's own link map is keyed by and what a wikilink is
|
|
370
|
+
* written as.
|
|
371
|
+
*
|
|
372
|
+
* @param {SiteIndex} siteIndex - The index.
|
|
373
|
+
* @param {unknown} ref - The reference, as authored.
|
|
374
|
+
* @param {object} [hint] - `{type}`, where the caller knows it.
|
|
375
|
+
* @returns {{name?: string, url?: string, address?: string, subType?: string}|undefined}
|
|
376
|
+
* The page, or `undefined` where nothing answers.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveInfoboxRef(siteIndex, ref, hint) {
|
|
379
|
+
if (typeof ref !== "string" || !ref) return undefined;
|
|
380
|
+
const wanted = ref.toLowerCase();
|
|
381
|
+
const keys = [];
|
|
382
|
+
if (hint?.type) keys.push(`${String(hint.type).toLowerCase()}/${wanted}`);
|
|
383
|
+
// An authored address already names its own type, so it is tried whole
|
|
384
|
+
// before the type sweep — `affiliation-slntlncmpny` must not be read as a
|
|
385
|
+
// shortcode of some other type that happens to spell it.
|
|
386
|
+
if (wanted.includes("-")) {
|
|
387
|
+
keys.push(wanted);
|
|
388
|
+
const segments = wanted.split("-");
|
|
389
|
+
if (segments.length >= 2) {
|
|
390
|
+
keys.push(`${segments[segments.length - 2]}/${segments[segments.length - 1]}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (!hint?.type) {
|
|
394
|
+
for (const type of [...(siteIndex?.contentTypes ?? [])].sort()) {
|
|
395
|
+
keys.push(`${type}/${wanted}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const key of keys) {
|
|
399
|
+
const found = siteIndex?.index?.get(key);
|
|
400
|
+
if (!found) continue;
|
|
401
|
+
const slug = key.includes("/") ? key.replace("/", "-") : addressSlugOfKey(key);
|
|
402
|
+
// No `uuid`, although a foreign entry carries one: this resolver
|
|
403
|
+
// answers for the **published** surfaces, which address a page by URL
|
|
404
|
+
// and an entry in the book by its slug. A compendium reference is what
|
|
405
|
+
// `resolveReference` answers with, from the compile's own index.
|
|
406
|
+
return {
|
|
407
|
+
...(found.name ? { name: found.name } : {}),
|
|
408
|
+
...(found.url ? { url: found.url } : {}),
|
|
409
|
+
...(found.subType ? { subType: found.subType } : {}),
|
|
410
|
+
address: slug,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The `type-shortcode` slug of a canonical key.
|
|
418
|
+
*
|
|
419
|
+
* A canonical key is `package-system-type-shortcode`, and its slug is the last
|
|
420
|
+
* two segments — the same rule `contentAddress` states, applied to a key
|
|
421
|
+
* rather than to frontmatter.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} key - The canonical key.
|
|
424
|
+
* @returns {string} The slug.
|
|
425
|
+
*/
|
|
426
|
+
function addressSlugOfKey(key) {
|
|
427
|
+
const segments = String(key).split("-");
|
|
428
|
+
return segments.length >= 2 ? segments.slice(-2).join("-") : key;
|
|
429
|
+
}
|
package/engine/wikilinks.mjs
CHANGED
|
@@ -781,3 +781,96 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
781
781
|
|
|
782
782
|
return { markdown: out, unresolved };
|
|
783
783
|
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* The short `type/shortcode` view of an index's foreign entries.
|
|
787
|
+
*
|
|
788
|
+
* Derived once per index and memoised: a compile resolves references on every
|
|
789
|
+
* one of thousands of notes, and rebuilding the view per note would rescan a
|
|
790
|
+
* dependency's whole published index each time. Keyed on the index object, so
|
|
791
|
+
* it is discarded with it.
|
|
792
|
+
*
|
|
793
|
+
* An address two foreign packages both claim is left out rather than resolved
|
|
794
|
+
* to whichever loaded first — the same rule the wikilink resolver follows.
|
|
795
|
+
*
|
|
796
|
+
* @type {WeakMap<object, Map<string, object>>}
|
|
797
|
+
*/
|
|
798
|
+
const FOREIGN_BY_SHORTCODE = new WeakMap();
|
|
799
|
+
|
|
800
|
+
/** The memoised short view of `index.foreign`. */
|
|
801
|
+
function foreignByShortcode(index) {
|
|
802
|
+
const cached = FOREIGN_BY_SHORTCODE.get(index);
|
|
803
|
+
if (cached) return cached;
|
|
804
|
+
const short = new Map();
|
|
805
|
+
const ambiguous = new Set();
|
|
806
|
+
for (const [key, value] of index?.foreign ?? []) {
|
|
807
|
+
const parts = readCanonicalKey(key);
|
|
808
|
+
if (!parts) continue;
|
|
809
|
+
const shortKey = `${norm(parts.type)}/${norm(parts.shortcode)}`;
|
|
810
|
+
if (short.has(shortKey) && short.get(shortKey).package !== value.package) {
|
|
811
|
+
ambiguous.add(shortKey);
|
|
812
|
+
} else {
|
|
813
|
+
short.set(shortKey, value);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
for (const key of ambiguous) short.delete(key);
|
|
817
|
+
FOREIGN_BY_SHORTCODE.set(index, short);
|
|
818
|
+
return short;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Resolve one reference against a compile's address index.
|
|
823
|
+
*
|
|
824
|
+
* The compile-time counterpart of
|
|
825
|
+
* {@link module:engine/site-index.resolveInfoboxRef}: the same three authored
|
|
826
|
+
* forms — a bare shortcode, a short address, a canonical one — answered with
|
|
827
|
+
* what a **compendium** can use. A local target answers with the UUID its own
|
|
828
|
+
* document was addressed by; a foreign one with the UUID its package
|
|
829
|
+
* published.
|
|
830
|
+
*
|
|
831
|
+
* @param {object} index - From {@link buildWikilinkIndex}.
|
|
832
|
+
* @param {unknown} ref - The reference, as authored.
|
|
833
|
+
* @param {object} [hint] - `{type}`, where the caller knows what it expects.
|
|
834
|
+
* @returns {{name?: string, uuid?: string, address?: string, subType?: string}|undefined}
|
|
835
|
+
* The target, or `undefined` where nothing answers.
|
|
836
|
+
*/
|
|
837
|
+
export function resolveReference(index, ref, hint) {
|
|
838
|
+
if (typeof ref !== "string" || !ref) return undefined;
|
|
839
|
+
const wanted = norm(ref);
|
|
840
|
+
const keys = [];
|
|
841
|
+
if (hint?.type) keys.push(`${norm(hint.type)}/${wanted}`);
|
|
842
|
+
if (wanted.includes("-")) {
|
|
843
|
+
const segments = wanted.split("-");
|
|
844
|
+
if (segments.length >= 2) {
|
|
845
|
+
keys.push(`${segments[segments.length - 2]}/${segments[segments.length - 1]}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (!hint?.type) {
|
|
849
|
+
for (const type of [...(index?.types ?? [])].sort()) keys.push(`${type}/${wanted}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const foreign = foreignByShortcode(index);
|
|
853
|
+
for (const key of keys) {
|
|
854
|
+
const local = index?.byShortcode?.get(key);
|
|
855
|
+
if (local) {
|
|
856
|
+
return {
|
|
857
|
+
...(local.name ? { name: local.name } : {}),
|
|
858
|
+
...(local.subType ? { subType: local.subType } : {}),
|
|
859
|
+
...(index.uuidByDoc?.get(local)?.uuid ?
|
|
860
|
+
{ uuid: index.uuidByDoc.get(local).uuid }
|
|
861
|
+
: {}),
|
|
862
|
+
address: key.replace("/", "-"),
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
const away = foreign.get(key);
|
|
866
|
+
if (away) {
|
|
867
|
+
return {
|
|
868
|
+
...(away.name ? { name: away.name } : {}),
|
|
869
|
+
...(away.uuid ? { uuid: away.uuid } : {}),
|
|
870
|
+
...(away.subType ? { subType: away.subType } : {}),
|
|
871
|
+
address: key.replace("/", "-"),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
package/hm3/default-item-art.mjs
CHANGED
|
@@ -19,12 +19,11 @@
|
|
|
19
19
|
* no `img:` of its own, rather than shipping a mismatched icon. So every type
|
|
20
20
|
* `item-builders.mjs` declares has a row here.
|
|
21
21
|
*
|
|
22
|
-
* **Paths
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* neighbours.
|
|
22
|
+
* **Paths name the package that owns the file.** These icons are not the
|
|
23
|
+
* consuming package's — they are shipped by the HM3 system — so they are
|
|
24
|
+
* written `hm3/assets/…` and each surface derives its own address from that.
|
|
25
|
+
* That addresses the icons HM3's own compendiums already use, so an item
|
|
26
|
+
* compiled from a note looks like its hand-authored neighbours.
|
|
28
27
|
*
|
|
29
28
|
* **A one-to-many type gets one default**, because art is keyed by note type
|
|
30
29
|
* and a note type is what a registry entry addresses. `weapongear` compiles
|
|
@@ -42,15 +41,15 @@
|
|
|
42
41
|
* @type {Readonly<Record<string, string>>}
|
|
43
42
|
*/
|
|
44
43
|
export const HM3_DEFAULT_ITEM_ART = Object.freeze({
|
|
45
|
-
armorgear: "
|
|
46
|
-
armorlocation: "
|
|
47
|
-
containergear: "
|
|
48
|
-
miscgear: "
|
|
49
|
-
mysticalability: "
|
|
50
|
-
projectilegear: "
|
|
51
|
-
skill: "
|
|
52
|
-
trauma: "
|
|
53
|
-
weapongear: "
|
|
44
|
+
armorgear: "hm3/assets/images/icons/svg/armor.svg",
|
|
45
|
+
armorlocation: "hm3/assets/images/icons/svg/anatomy.svg",
|
|
46
|
+
containergear: "hm3/assets/images/icons/svg/sack.svg",
|
|
47
|
+
miscgear: "hm3/assets/images/icons/svg/miscgear.svg",
|
|
48
|
+
mysticalability: "hm3/assets/images/icons/svg/psionics.svg",
|
|
49
|
+
projectilegear: "hm3/assets/images/icons/svg/arrow.svg",
|
|
50
|
+
skill: "hm3/assets/images/icons/svg/skills.svg",
|
|
51
|
+
trauma: "hm3/assets/images/icons/svg/injury.svg",
|
|
52
|
+
weapongear: "hm3/assets/images/icons/svg/sword.svg",
|
|
54
53
|
});
|
|
55
54
|
|
|
56
55
|
/**
|
package/hm3/index.mjs
CHANGED
|
@@ -51,6 +51,9 @@ export * as actors from "./actors.mjs";
|
|
|
51
51
|
/** Where HM3 records the template priority — one rule, called by both compilers. */
|
|
52
52
|
export * as templatePriority from "./template-priority.mjs";
|
|
53
53
|
|
|
54
|
+
/** Which of HM3's facts a note's summary panel carries. */
|
|
55
|
+
export * as infobox from "./infobox.mjs";
|
|
56
|
+
|
|
54
57
|
// Flat as well as namespaced, matching the SoHL barrel: the default-art map is
|
|
55
58
|
// the one export a Foundry runtime would import by name.
|
|
56
59
|
export { HM3_DEFAULT_ITEM_ART, hm3DefaultItemArt } from "./default-item-art.mjs";
|