@heroiclands/package-build 20.6.0 → 21.0.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 +181 -0
- package/CONTENT.md +134 -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 +381 -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 +208 -9
- package/engine/pdf-render.mjs +461 -21
- package/engine/pdf-toc.mjs +77 -5
- package/engine/scenes.mjs +2 -1
- package/engine/site-build.mjs +115 -8
- 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 +3 -2
- package/sohl/being-info.mjs +9 -3
- 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 +99 -1
- package/types/engine/pdf-toc.d.mts +10 -5
- package/types/engine/site-build.d.mts +19 -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/being-info.d.mts +4 -3
- 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";
|
|
@@ -267,6 +271,14 @@ export function collectContentPages(contentBase, ctx) {
|
|
|
267
271
|
* addressed by type and slug: they are a book with chapters, and a reader
|
|
268
272
|
* follows their paths. A `README` is its directory's landing.
|
|
269
273
|
*
|
|
274
|
+
* **The section is the tree's, never the note's.** `tree.section` is the
|
|
275
|
+
* mount point a `trees` entry configures — fixed, physical, and the same
|
|
276
|
+
* value `site-index.mjs` indexes a tree page's address under. A note's own
|
|
277
|
+
* `subType` is a genre and reaches no address, the same contract
|
|
278
|
+
* `packageAddress()` holds for a content page: reading it here would move a
|
|
279
|
+
* page's URL, its file destination (`pageDestination`) and the address a
|
|
280
|
+
* wikilink cites it by, every time an author classified it.
|
|
281
|
+
*
|
|
270
282
|
* @param {object} tree - `{ from, rel, section, route }`.
|
|
271
283
|
* @param {object} ctx - `{ mount }`.
|
|
272
284
|
* @returns {{pages: object[], fmLinkFindings: object[]}}
|
|
@@ -287,7 +299,7 @@ export function collectTreePages(tree, ctx) {
|
|
|
287
299
|
const rel = path.relative(tree.from, file).replace(/\\/g, "/");
|
|
288
300
|
const base = path.basename(rel);
|
|
289
301
|
const isReadme = base.toLowerCase() === "readme.md";
|
|
290
|
-
const sec =
|
|
302
|
+
const sec = tree.section;
|
|
291
303
|
const h1 = /^#\s+(.+?)\s*$/m.exec(body);
|
|
292
304
|
const h1Title = h1 ? h1[1].replace(/\{@link\s+[^}]*\}/g, "").trim() : null;
|
|
293
305
|
const name = fm.name?.full ?? fm.title ?? h1Title ?? path.basename(base, ".md");
|
|
@@ -615,9 +627,13 @@ export function sectionFrontmatter(meta) {
|
|
|
615
627
|
* for.
|
|
616
628
|
* @param {(data: object, page: object) => void} [options.decorate] - Called
|
|
617
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.
|
|
618
634
|
* @returns {object} The frontmatter to write.
|
|
619
635
|
*/
|
|
620
|
-
export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
636
|
+
export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc }) {
|
|
621
637
|
const { fm, name, slug, sec, isReadme } = page;
|
|
622
638
|
let data;
|
|
623
639
|
if (page.kind === "content") {
|
|
@@ -649,9 +665,40 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
649
665
|
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
650
666
|
}
|
|
651
667
|
delete data.aliases;
|
|
668
|
+
if (webSrc) resolveArtFields(data, webSrc);
|
|
652
669
|
return data;
|
|
653
670
|
}
|
|
654
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
|
+
|
|
655
702
|
/**
|
|
656
703
|
* Where a page is written, relative to the output root.
|
|
657
704
|
*
|
|
@@ -696,7 +743,8 @@ export function pageDestination(page) {
|
|
|
696
743
|
*
|
|
697
744
|
* @param {object[]} pages - Every page.
|
|
698
745
|
* @param {object} options - Everything the render needs.
|
|
699
|
-
* @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
|
|
746
|
+
* @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
|
|
747
|
+
* wikiErrors: object[], imageErrors: object[]}}
|
|
700
748
|
*/
|
|
701
749
|
export function renderPages(pages, options) {
|
|
702
750
|
const {
|
|
@@ -709,12 +757,54 @@ export function renderPages(pages, options) {
|
|
|
709
757
|
decorate,
|
|
710
758
|
linkable = (d) => Boolean(d.fm.shortcode),
|
|
711
759
|
sqlTables,
|
|
760
|
+
config,
|
|
712
761
|
} = options;
|
|
713
762
|
|
|
714
763
|
const tableErrors = [];
|
|
715
764
|
const wikiErrors = [];
|
|
765
|
+
const imageErrors = [];
|
|
716
766
|
const byKind = {};
|
|
717
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
|
+
|
|
718
808
|
for (const page of pages) {
|
|
719
809
|
// The page's path in the tree an author edits: below the content root
|
|
720
810
|
// for a content note, below the tree's own root for a `trees` page. It
|
|
@@ -729,12 +819,17 @@ export function renderPages(pages, options) {
|
|
|
729
819
|
foreignIndex: foreign.index,
|
|
730
820
|
});
|
|
731
821
|
|
|
822
|
+
const webSrc = webAddresses(page.file);
|
|
732
823
|
const resolve = (text) => {
|
|
733
824
|
let t = text;
|
|
734
825
|
if (pass.beforeLinks) t = pass.beforeLinks(t, page);
|
|
735
826
|
t = resolveWebWikilinks(t, ctx);
|
|
736
827
|
if (pass.afterLinks) t = pass.afterLinks(t, page);
|
|
737
|
-
|
|
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);
|
|
738
833
|
};
|
|
739
834
|
|
|
740
835
|
let body = page.body;
|
|
@@ -756,14 +851,14 @@ export function renderPages(pages, options) {
|
|
|
756
851
|
body = markdown;
|
|
757
852
|
}
|
|
758
853
|
|
|
759
|
-
const data = pageFrontmatter(page, { readmeSections, decorate });
|
|
854
|
+
const data = pageFrontmatter(page, { readmeSections, decorate, webSrc });
|
|
760
855
|
const dest = path.join(outRoot, pageDestination(page));
|
|
761
856
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
762
857
|
fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
|
|
763
858
|
byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
|
|
764
859
|
}
|
|
765
860
|
|
|
766
|
-
return { written: pages.length, byKind, tableErrors, wikiErrors };
|
|
861
|
+
return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors };
|
|
767
862
|
}
|
|
768
863
|
|
|
769
864
|
/**
|
|
@@ -963,7 +1058,7 @@ export function resolveOutputRoot(rootDir, out) {
|
|
|
963
1058
|
* `sql` directive with none prepared is a table error: nothing here runs a
|
|
964
1059
|
* query.
|
|
965
1060
|
* @returns {{gates: object, stats: object|null, tableErrors: object[],
|
|
966
|
-
* wikiErrors: object[], manifests: object|null}}
|
|
1061
|
+
* wikiErrors: object[], imageErrors: object[], manifests: object|null}}
|
|
967
1062
|
*/
|
|
968
1063
|
export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
969
1064
|
const resolved = config ?? loadPackConfig();
|
|
@@ -1057,6 +1152,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1057
1152
|
manifests: null,
|
|
1058
1153
|
tableErrors: [],
|
|
1059
1154
|
wikiErrors: [],
|
|
1155
|
+
imageErrors: [],
|
|
1060
1156
|
stats: null,
|
|
1061
1157
|
};
|
|
1062
1158
|
}
|
|
@@ -1074,6 +1170,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1074
1170
|
manifests: null,
|
|
1075
1171
|
tableErrors: [],
|
|
1076
1172
|
wikiErrors: [],
|
|
1173
|
+
imageErrors: [],
|
|
1077
1174
|
stats: {
|
|
1078
1175
|
homepages: writeHomepages(homeRoot, homepages, resolved),
|
|
1079
1176
|
landings: 0,
|
|
@@ -1125,6 +1222,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1125
1222
|
stats: null,
|
|
1126
1223
|
tableErrors: [],
|
|
1127
1224
|
wikiErrors: [],
|
|
1225
|
+
imageErrors: [],
|
|
1128
1226
|
};
|
|
1129
1227
|
}
|
|
1130
1228
|
|
|
@@ -1136,6 +1234,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1136
1234
|
const rendered = renderPages(pages, {
|
|
1137
1235
|
outRoot: out,
|
|
1138
1236
|
sqlTables,
|
|
1237
|
+
config: resolved,
|
|
1139
1238
|
index: gates.index,
|
|
1140
1239
|
foreign: gates.foreign,
|
|
1141
1240
|
universe: tableUniverse(pages),
|
|
@@ -1149,6 +1248,13 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1149
1248
|
if (isBeing(page.fm)) {
|
|
1150
1249
|
data.sohl = deriveBeingInfo(page.fm.sohl, gates.index.refIndex);
|
|
1151
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
|
+
});
|
|
1152
1258
|
},
|
|
1153
1259
|
});
|
|
1154
1260
|
|
|
@@ -1166,6 +1272,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1166
1272
|
gates,
|
|
1167
1273
|
tableErrors: rendered.tableErrors,
|
|
1168
1274
|
wikiErrors: rendered.wikiErrors,
|
|
1275
|
+
imageErrors: rendered.imageErrors,
|
|
1169
1276
|
stats: {
|
|
1170
1277
|
...rendered.byKind,
|
|
1171
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";
|