@heroiclands/package-build 23.7.1 → 23.8.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 +14 -0
- package/README.md +32 -0
- package/bin/content-commands.mjs +20 -6
- package/ci/check-banners.mjs +37 -19
- package/container.mjs +48 -10
- package/docs/api.md +87 -43
- package/docs/authoring/frontmatter.md +30 -1
- package/docs/authoring/links-and-markup.md +14 -0
- package/docs/commands.md +12 -2
- package/docs/content-format.md +23 -0
- package/docs/design.md +31 -0
- package/docs/guides/e2e.md +37 -1
- package/docs/project-setup.md +30 -15
- package/docs/reference/format-details.md +44 -9
- package/docs/reference/note-types.md +7 -4
- package/e2e.mjs +89 -2
- package/engine/base-compiler.mjs +11 -18
- package/engine/compile-corpus.mjs +2 -1
- package/engine/content-alerts.mjs +4 -2
- package/engine/content-blocks.mjs +18 -36
- package/engine/content-index.mjs +35 -22
- package/engine/content-poetry.mjs +9 -0
- package/engine/diagnostics.mjs +24 -11
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/generate.mjs +12 -22
- package/engine/helpers.mjs +8 -6
- package/engine/infobox.mjs +3 -0
- package/engine/journals.mjs +11 -1
- package/engine/markdownlint-run.mjs +12 -2
- package/engine/markup-validation.mjs +34 -0
- package/engine/note-addresses.mjs +1 -0
- package/engine/note-claims.mjs +19 -10
- package/engine/note-package.mjs +3 -5
- package/engine/note-vocabulary.mjs +29 -1
- package/engine/pdf-build.mjs +8 -1
- package/engine/prose-lint.mjs +12 -6
- package/engine/related-pages.mjs +22 -3
- package/engine/see-also.mjs +150 -0
- package/engine/site-build.mjs +5 -1
- package/engine/site-preview.mjs +4 -2
- package/engine/sql-tables.mjs +25 -10
- package/engine/standings.mjs +14 -12
- package/hugo-theme/layouts/partials/related.html +25 -8
- package/package.json +2 -1
- package/types/container.d.mts +4 -3
- package/types/e2e.d.mts +28 -0
- package/types/engine/base-compiler.d.mts +2 -2
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +20 -7
- package/types/engine/diagnostics.d.mts +7 -6
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/markdownlint-run.d.mts +3 -1
- package/types/engine/markup-validation.d.mts +14 -0
- package/types/engine/note-addresses.d.mts +10 -0
- package/types/engine/note-claims.d.mts +7 -1
- package/types/engine/note-package.d.mts +3 -1
- package/types/engine/note-vocabulary.d.mts +1 -0
- package/types/engine/related-pages.d.mts +10 -1
- package/types/engine/see-also.d.mts +55 -0
- package/types/engine/sql-tables.d.mts +7 -3
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
import { parseAddress, renderAddress, hasAnchor } from "./address.mjs";
|
|
15
|
+
import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
|
|
16
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate note references, refusing unresolved, repeated and self destinations.
|
|
20
|
+
* @param {object} note - The authored note, including its raw source.
|
|
21
|
+
* @param {object} [options] - The local and declared dependency context.
|
|
22
|
+
* @param {object} [options.index] - The local and declared dependency index.
|
|
23
|
+
* @param {string} [options.index.contentPackage] - The package owning the note.
|
|
24
|
+
* @param {Set<string>} [options.index.types] - The indexed note types.
|
|
25
|
+
* @param {Set<string>} [options.index.packages] - The indexed packages.
|
|
26
|
+
* @param {(key: string) => {url?: string, uuid?: string, file?: string}|undefined}
|
|
27
|
+
* [options.index.addressHit] - Resolve a canonical Address to its destination.
|
|
28
|
+
* @param {import("./address.mjs").AddressDefaults} [options.addressContext]
|
|
29
|
+
* - The Address defaults and vocabularies at the authored reference.
|
|
30
|
+
* @returns {object[]} Findings located at the offending address.
|
|
31
|
+
*/
|
|
32
|
+
export function checkSeeAlso(note, { index, addressContext } = {}) {
|
|
33
|
+
const entries = note.fm?.data?.seeAlso;
|
|
34
|
+
if (!Array.isArray(entries)) return [];
|
|
35
|
+
const defaults = {
|
|
36
|
+
package: index?.contentPackage,
|
|
37
|
+
system: "note",
|
|
38
|
+
types: index?.types,
|
|
39
|
+
packages: index?.packages,
|
|
40
|
+
...addressContext,
|
|
41
|
+
type: undefined,
|
|
42
|
+
};
|
|
43
|
+
const own = parseAddress(note.fm.shortcode, { ...defaults, type: note.fm.type });
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const findings = [];
|
|
46
|
+
entries.forEach((entry, i) => {
|
|
47
|
+
if (entry === null) {
|
|
48
|
+
findings.push({
|
|
49
|
+
file: note.file,
|
|
50
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "seeAlso", i]),
|
|
51
|
+
severity: "error",
|
|
52
|
+
message: `\`data.seeAlso.${i}\` should be a map with an address`,
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!entry || typeof entry !== "object" || entry.address == null) return;
|
|
57
|
+
const tuple = parseAddress(entry.address, defaults, { declared: true });
|
|
58
|
+
const key = tuple.reason ? undefined : renderAddress(tuple);
|
|
59
|
+
const target = key ? index?.addressHit?.(key) : undefined;
|
|
60
|
+
const destination = target?.url ?? target?.uuid ?? target?.file ?? key;
|
|
61
|
+
const self =
|
|
62
|
+
(!own.reason &&
|
|
63
|
+
tuple.package === own.package &&
|
|
64
|
+
tuple.type === own.type &&
|
|
65
|
+
tuple.shortcode === own.shortcode) ||
|
|
66
|
+
(target?.file && target.file === note.file);
|
|
67
|
+
const reason =
|
|
68
|
+
hasAnchor(entry.address) ? "takes no anchor — it names a whole note"
|
|
69
|
+
: tuple.reason || ASSET_TYPE_NAMES.has(tuple.type) ? "should be a note Address"
|
|
70
|
+
: index?.addressHit && !target ?
|
|
71
|
+
"does not resolve in this package or its declared dependencies"
|
|
72
|
+
: self ? "names the note itself"
|
|
73
|
+
: seen.has(destination) ? "is a duplicate destination"
|
|
74
|
+
: undefined;
|
|
75
|
+
seen.add(destination);
|
|
76
|
+
if (reason)
|
|
77
|
+
findings.push({
|
|
78
|
+
file: note.file,
|
|
79
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "seeAlso", i, "address"]),
|
|
80
|
+
severity: "error",
|
|
81
|
+
message: `\`data.seeAlso.${i}.address\` ${reason}`,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve deliberate references in authored order using the surface's index.
|
|
89
|
+
* @param {object} fm - The note frontmatter.
|
|
90
|
+
* @param {(address: unknown) => object|undefined} resolve - The surface's reference resolver.
|
|
91
|
+
* @returns {object[]} Display names, destinations and optional glosses.
|
|
92
|
+
*/
|
|
93
|
+
export function seeAlsoEntries(fm, resolve) {
|
|
94
|
+
const entries = fm?.data?.seeAlso;
|
|
95
|
+
if (!Array.isArray(entries)) return [];
|
|
96
|
+
return entries.flatMap((entry) => {
|
|
97
|
+
const target = entry?.address == null ? undefined : resolve(entry.address);
|
|
98
|
+
if (!target) return [];
|
|
99
|
+
return [{ ...target, text: target.name, ...(entry.gloss ? { gloss: entry.gloss } : {}) }];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Plain text in a generated Markdown paragraph. */
|
|
104
|
+
const plain = (text) =>
|
|
105
|
+
String(text ?? "")
|
|
106
|
+
.replace(/([\\`*_[\]<>{}|#!~])/g, "\\$1")
|
|
107
|
+
.replace(/\r?\n/g, " ");
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A generated section; an empty list emits nothing.
|
|
111
|
+
* @param {object[]} entries - Resolved references in authored order.
|
|
112
|
+
* @param {(entry: object) => string} link - The medium's link markup.
|
|
113
|
+
* @param {number} [level=2] - Heading level; a journal uses one for a separate page.
|
|
114
|
+
* @returns {string} Markdown for the section.
|
|
115
|
+
*/
|
|
116
|
+
export function seeAlsoMarkdown(entries, link, level = 2) {
|
|
117
|
+
if (!entries.length) return "";
|
|
118
|
+
return `${"#".repeat(level)} See also\n\n${entries.map((entry) => `- ${link(entry)}${entry.gloss ? ` — ${plain(entry.gloss)}` : ""}`).join("\n")}\n`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A reference rendered as an ordinary Markdown link for the book pipeline.
|
|
123
|
+
* @param {object} entry - A resolved name and URL.
|
|
124
|
+
* @returns {string} Markdown.
|
|
125
|
+
*/
|
|
126
|
+
export function seeAlsoWebLink(entry) {
|
|
127
|
+
return entry.url ? `[${plain(entry.text)}](<${entry.url}>)` : plain(entry.text);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Deliberate site connections for a set of pages, including dependency pages.
|
|
132
|
+
* @param {object[]} pages - Pages carrying frontmatter and their published URL.
|
|
133
|
+
* @param {(address: unknown) => object|undefined} resolve - The site's reference resolver.
|
|
134
|
+
* @returns {Map<string, import("./related-pages.mjs").RelatedEntry[]>} Entries keyed by source URL.
|
|
135
|
+
*/
|
|
136
|
+
export function seeAlsoPages(pages, resolve) {
|
|
137
|
+
const deliberate = new Map();
|
|
138
|
+
for (const page of pages) {
|
|
139
|
+
const refs = seeAlsoEntries(page.fm, resolve)
|
|
140
|
+
.filter((ref) => ref.url)
|
|
141
|
+
.map((ref) => ({
|
|
142
|
+
title: ref.name,
|
|
143
|
+
url: ref.url,
|
|
144
|
+
type: ref.address.type,
|
|
145
|
+
...(ref.gloss ? { gloss: ref.gloss } : {}),
|
|
146
|
+
}));
|
|
147
|
+
if (refs.length) deliberate.set(page.url, refs);
|
|
148
|
+
}
|
|
149
|
+
return deliberate;
|
|
150
|
+
}
|
package/engine/site-build.mjs
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
} from "./homepage.mjs";
|
|
110
110
|
import { publishesContentPages } from "../content-config.mjs";
|
|
111
111
|
import { HUGO_CONTENT } from "./site-config.mjs";
|
|
112
|
+
import { seeAlsoPages } from "./see-also.mjs";
|
|
112
113
|
import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
|
|
113
114
|
import { drawSiteMaps, inlineDrawingFigure } from "./site-maps.mjs";
|
|
114
115
|
import { generatedDrawing, markGenerated, splitGenerated } from "./generated-sections.mjs";
|
|
@@ -1095,7 +1096,10 @@ export function renderPages(pages, options) {
|
|
|
1095
1096
|
for (const hit of result.resolved) if (hit.url) edges.push([page.url, hit.url]);
|
|
1096
1097
|
}
|
|
1097
1098
|
|
|
1098
|
-
const
|
|
1099
|
+
const deliberate = seeAlsoPages([...pages, ...homepages], (ref) =>
|
|
1100
|
+
resolveInfoboxRef(index, ref),
|
|
1101
|
+
);
|
|
1102
|
+
const related = relatedPages(edges, entries, deliberate);
|
|
1099
1103
|
|
|
1100
1104
|
let withMap = 0;
|
|
1101
1105
|
const outputs = capture === true ? new Map() : null;
|
package/engine/site-preview.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import { foundryIdentities } from "./foundry-entries.mjs";
|
|
|
22
22
|
import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
|
|
23
23
|
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
24
24
|
import { cachedMetadataIndexes, noContentIndexPackages } from "./metadata-index.mjs";
|
|
25
|
-
import {
|
|
25
|
+
import { seeAlsoPages } from "./see-also.mjs";
|
|
26
|
+
import { buildSiteIndex, resolveInfoboxRef } from "./site-index.mjs";
|
|
26
27
|
import {
|
|
27
28
|
attachGeneratedSections,
|
|
28
29
|
findSqlBlocks,
|
|
@@ -320,7 +321,8 @@ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
|
|
|
320
321
|
url: page.url,
|
|
321
322
|
type: String(fm.type),
|
|
322
323
|
});
|
|
323
|
-
const
|
|
324
|
+
const deliberate = seeAlsoPages([page], (ref) => resolveInfoboxRef(index, ref));
|
|
325
|
+
const related = relatedPages(edges, entries, deliberate).get(page.url);
|
|
324
326
|
if (related) result.data.related = related;
|
|
325
327
|
return {
|
|
326
328
|
ok: true,
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -879,8 +879,9 @@ export function renderSqlTable(
|
|
|
879
879
|
* its own table and not the whole build's report.
|
|
880
880
|
*
|
|
881
881
|
* @param {object} db - From {@link openNotesDatabase}.
|
|
882
|
-
* @param {Array<{source: string, markdown: string}>} sources -
|
|
883
|
-
* scan.
|
|
882
|
+
* @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
|
|
883
|
+
* The bodies to scan. `joinGeneratedSections` records the number of authored
|
|
884
|
+
* fences; following generated event fences need no stub comparison.
|
|
884
885
|
* @param {object} [opts]
|
|
885
886
|
* @param {(ref: string) => boolean} [opts.linkable] - Passed to
|
|
886
887
|
* {@link renderSqlTable}.
|
|
@@ -904,26 +905,33 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
904
905
|
const prepared = new Map();
|
|
905
906
|
if (records) prepared.pageLists = preparePageLists(records, sources);
|
|
906
907
|
// The corpus does not change while a preparation runs, so a query written
|
|
907
|
-
// twice has one answer
|
|
908
|
-
//
|
|
908
|
+
// twice has one answer in each stub context. The normal result and the
|
|
909
|
+
// inclusive comparison occupy separate fields of the same cache entry.
|
|
910
|
+
// Each result use gets a copy because a caller rewrites the rows.
|
|
909
911
|
const answers = new Map();
|
|
910
912
|
const reader = {
|
|
911
913
|
...db,
|
|
912
914
|
async query(sql) {
|
|
913
|
-
if (!answers.has(sql)) answers.set(sql, await db.query(sql));
|
|
914
|
-
return structuredClone(answers.get(sql));
|
|
915
|
+
if (!answers.has(sql)) answers.set(sql, { result: await db.query(sql) });
|
|
916
|
+
return structuredClone(answers.get(sql).result);
|
|
917
|
+
},
|
|
918
|
+
async stubsExcluded(sql, rows) {
|
|
919
|
+
const answer = answers.get(sql);
|
|
920
|
+
if (!Object.hasOwn(answer, "excluded"))
|
|
921
|
+
answer.excluded = await db.stubsExcluded?.(sql, rows);
|
|
922
|
+
return answer.excluded;
|
|
915
923
|
},
|
|
916
924
|
};
|
|
917
925
|
let labels;
|
|
918
926
|
const labelOf = (address) => labels.get(address);
|
|
919
927
|
const languageOf =
|
|
920
928
|
pageLanguage ?? ((_source, frontmatter) => frontmatterLanguage(frontmatter, db));
|
|
921
|
-
for (const { source, markdown, frontmatter } of sources) {
|
|
929
|
+
for (const { source, markdown, frontmatter, authoredSqlBlocks = Infinity } of sources) {
|
|
922
930
|
const blocks = findSqlBlocks(markdown);
|
|
923
931
|
if (!blocks.length) continue;
|
|
924
932
|
const forNote = [];
|
|
925
933
|
prepared.set(source, forNote);
|
|
926
|
-
for (const block of blocks) {
|
|
934
|
+
for (const [ordinal, block] of blocks.entries()) {
|
|
927
935
|
try {
|
|
928
936
|
if (block.problems.length) throw new Error(block.problems.join("; "));
|
|
929
937
|
const result = await runSqlQuery(reader, block.query);
|
|
@@ -952,7 +960,12 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
952
960
|
// relation, so a fence that has not adopted stubs says so
|
|
953
961
|
// rather than quietly listing fewer settlements than the
|
|
954
962
|
// region has.
|
|
955
|
-
|
|
963
|
+
// Generated event views read entries and events, whose
|
|
964
|
+
// meaning is identical in both stub contexts.
|
|
965
|
+
stubsExcluded:
|
|
966
|
+
ordinal >= authoredSqlBlocks ? 0 : (
|
|
967
|
+
await reader.stubsExcluded(block.query, result.rows.length)
|
|
968
|
+
),
|
|
956
969
|
allowEmpty: block.allowEmpty,
|
|
957
970
|
});
|
|
958
971
|
} catch (err) {
|
|
@@ -1138,7 +1151,7 @@ export async function generatedSectionsFor(
|
|
|
1138
1151
|
*
|
|
1139
1152
|
* @param {{sections: Map<string, string>, views: Set<string>}} generated -
|
|
1140
1153
|
* From {@link generatedSectionsFor}.
|
|
1141
|
-
* @param {Array<{source: string, markdown: string, frontmatter?: object}>} sources -
|
|
1154
|
+
* @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
|
|
1142
1155
|
* The bodies to answer, extended in place.
|
|
1143
1156
|
* @param {Map<string, {body: string, frontmatter: object|null}>} [parsed] -
|
|
1144
1157
|
* Notes already parsed, keyed by file; a note absent from it is read.
|
|
@@ -1148,6 +1161,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1148
1161
|
for (const [absPath, text] of generated.sections) {
|
|
1149
1162
|
const source = sources.find((one) => one.source === absPath);
|
|
1150
1163
|
if (source) {
|
|
1164
|
+
source.authoredSqlBlocks ??= findSqlBlocks(source.markdown).length;
|
|
1151
1165
|
source.markdown = appendGeneratedSections(source.markdown, text);
|
|
1152
1166
|
continue;
|
|
1153
1167
|
}
|
|
@@ -1157,6 +1171,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1157
1171
|
source: absPath,
|
|
1158
1172
|
markdown: appendGeneratedSections(body, text),
|
|
1159
1173
|
frontmatter,
|
|
1174
|
+
authoredSqlBlocks: findSqlBlocks(body).length,
|
|
1160
1175
|
});
|
|
1161
1176
|
}
|
|
1162
1177
|
}
|
package/engine/standings.mjs
CHANGED
|
@@ -378,17 +378,19 @@ function resolveBody(note, index, body) {
|
|
|
378
378
|
|
|
379
379
|
/** Findings about the body an entry names. */
|
|
380
380
|
function bodyFindings(note, body, path, isKey, found) {
|
|
381
|
-
const at = position(note, path, isKey);
|
|
381
|
+
const at = () => position(note, path, isKey);
|
|
382
382
|
const written = isAddressTuple(body) ? renderAddress(body) : String(body);
|
|
383
383
|
const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
|
|
384
384
|
if (found.reason)
|
|
385
|
-
return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
|
|
385
|
+
return [{ ...at(), message: `${label} is not a complete Address (${found.reason})` }];
|
|
386
386
|
if (found.type)
|
|
387
|
-
return [
|
|
387
|
+
return [
|
|
388
|
+
{ ...at(), message: `\`data.affiliations\` accepts affiliation, not ${found.type}` },
|
|
389
|
+
];
|
|
388
390
|
if (found.missing)
|
|
389
391
|
return [
|
|
390
392
|
{
|
|
391
|
-
...at,
|
|
393
|
+
...at(),
|
|
392
394
|
message:
|
|
393
395
|
`\`data.affiliations\` names ${found.address}, which does not resolve ` +
|
|
394
396
|
"in this package or its declared dependencies",
|
|
@@ -438,12 +440,12 @@ function absentRankFindings(note, body, path) {
|
|
|
438
440
|
function rankFindings(note, standing, body, path, resolved) {
|
|
439
441
|
// Absent, or not a whole number: the inner-key check's finding.
|
|
440
442
|
if (!fitsKind(standing.rank ?? null, "integer")) return [];
|
|
441
|
-
const at = position(note, [...path, "rank"]);
|
|
443
|
+
const at = () => position(note, [...path, "rank"]);
|
|
442
444
|
const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
|
|
443
445
|
if (typeof standing.rank !== "number" || !Number.isInteger(level))
|
|
444
446
|
return [
|
|
445
447
|
{
|
|
446
|
-
...at,
|
|
448
|
+
...at(),
|
|
447
449
|
message:
|
|
448
450
|
`\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
|
|
449
451
|
`ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
|
|
@@ -455,7 +457,7 @@ function rankFindings(note, standing, body, path, resolved) {
|
|
|
455
457
|
if (!ladder.size)
|
|
456
458
|
return [
|
|
457
459
|
{
|
|
458
|
-
...at,
|
|
460
|
+
...at(),
|
|
459
461
|
message:
|
|
460
462
|
`\`data.affiliations\` entry for ${body} states a rung, and that body ` +
|
|
461
463
|
"declares no `data.governance.ranks` ladder to hold one",
|
|
@@ -464,7 +466,7 @@ function rankFindings(note, standing, body, path, resolved) {
|
|
|
464
466
|
const levels = [...ladder.keys()].sort((a, b) => a - b);
|
|
465
467
|
return [
|
|
466
468
|
{
|
|
467
|
-
...at,
|
|
469
|
+
...at(),
|
|
468
470
|
message:
|
|
469
471
|
`\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
|
|
470
472
|
`${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
|
|
@@ -477,11 +479,11 @@ function officeFindings(note, standing, body, path, resolved) {
|
|
|
477
479
|
// Absent is a membership with no post; a value that is not text is the
|
|
478
480
|
// inner-key check's finding.
|
|
479
481
|
if (typeof standing.office !== "string") return [];
|
|
480
|
-
const at = position(note, [...path, "office"]);
|
|
482
|
+
const at = () => position(note, [...path, "office"]);
|
|
481
483
|
if (!standing.office.trim())
|
|
482
484
|
return [
|
|
483
485
|
{
|
|
484
|
-
...at,
|
|
486
|
+
...at(),
|
|
485
487
|
message:
|
|
486
488
|
`\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
|
|
487
489
|
`\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
|
|
@@ -493,7 +495,7 @@ function officeFindings(note, standing, body, path, resolved) {
|
|
|
493
495
|
if (!roster.size)
|
|
494
496
|
return [
|
|
495
497
|
{
|
|
496
|
-
...at,
|
|
498
|
+
...at(),
|
|
497
499
|
message:
|
|
498
500
|
`\`data.affiliations\` entry for ${body} names an office, and that body ` +
|
|
499
501
|
`${resolved.address} declares no \`data.governance.offices\` to hold one`,
|
|
@@ -501,7 +503,7 @@ function officeFindings(note, standing, body, path, resolved) {
|
|
|
501
503
|
];
|
|
502
504
|
return [
|
|
503
505
|
{
|
|
504
|
-
...at,
|
|
506
|
+
...at(),
|
|
505
507
|
message:
|
|
506
508
|
`\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
|
|
507
509
|
`an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
Structure in front matter:
|
|
8
8
|
related:
|
|
9
|
+
seeAlso: [{ title, url, type, gloss? }, ...] # deliberate references
|
|
9
10
|
backlinks: [{ title, url, type }, ...] # pages that link here
|
|
10
11
|
mentions: [{ title, url, type }, ...] # pages this page links to
|
|
11
12
|
|
|
12
13
|
Rendering strategy:
|
|
13
|
-
-
|
|
14
|
+
- Render deliberate references in authored order, with their glosses.
|
|
15
|
+
- Merge both inferred directions into a single deduped pool (direction
|
|
14
16
|
doesn't matter to a reader — a connection is a connection).
|
|
15
17
|
- Group by the referenced page's `type`.
|
|
16
18
|
- Emit one labelled group per type, its entries laid out as a
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
- The whole block is wrapped in a bordered, tinted card so it
|
|
21
23
|
visually sets off the end of the article.
|
|
22
24
|
|
|
23
|
-
Silent disappear: a page with no `related`, or whose
|
|
25
|
+
Silent disappear: a page with no `related`, or whose lists are all
|
|
24
26
|
empty, emits nothing at all rather than an empty heading. The rows are
|
|
25
27
|
computed first and the <section> is rendered only when there is something
|
|
26
28
|
to put in it.
|
|
@@ -34,15 +36,23 @@
|
|
|
34
36
|
{{- /* `default dict` keeps the lookups below safe on a page with no
|
|
35
37
|
`related` at all, so the whole computation stays flat. */ -}}
|
|
36
38
|
{{- $rel := .Params.related | default dict -}}
|
|
39
|
+
{{- $seeAlso := or $rel.seealso slice -}}
|
|
37
40
|
{{- $backlinks := or $rel.backlinks slice -}}
|
|
38
41
|
{{- $mentions := or $rel.mentions slice -}}
|
|
39
42
|
|
|
40
43
|
{{- /* Merge both directions, deduped by URL. `union` can't hash
|
|
41
44
|
slices of maps, so we walk the two lists in order and dedupe
|
|
42
|
-
by URL manually.
|
|
43
|
-
|
|
45
|
+
by URL manually. Deliberate references take priority; mentions
|
|
46
|
+
precede backlinks among inferred connections. */ -}}
|
|
44
47
|
{{- $seen := slice -}}
|
|
45
48
|
{{- $merged := slice -}}
|
|
49
|
+
{{- $deliberate := slice -}}
|
|
50
|
+
{{- range $seeAlso -}}
|
|
51
|
+
{{- if not (in $seen .url) -}}
|
|
52
|
+
{{- $seen = $seen | append .url -}}
|
|
53
|
+
{{- $deliberate = $deliberate | append . -}}
|
|
54
|
+
{{- end -}}
|
|
55
|
+
{{- end -}}
|
|
46
56
|
{{- range $mentions -}}
|
|
47
57
|
{{- if not (in $seen .url) -}}
|
|
48
58
|
{{- $seen = $seen | append .url -}}
|
|
@@ -101,12 +111,19 @@
|
|
|
101
111
|
{{- $rows = sort $rows "label" "asc" -}}
|
|
102
112
|
{{- end -}}
|
|
103
113
|
|
|
104
|
-
{{-
|
|
105
|
-
column 0 leaves the emitted HTML byte-identical to what pages with
|
|
106
|
-
`related` rendered before the guard was added. */ -}}
|
|
107
|
-
{{- if $rows -}}
|
|
114
|
+
{{- if or $rows $deliberate -}}
|
|
108
115
|
<section class="related" aria-labelledby="related-heading" data-pagefind-ignore>
|
|
109
116
|
<h2 id="related-heading" class="related-heading">Related</h2>
|
|
117
|
+
{{- with $deliberate }}
|
|
118
|
+
<div class="related-group">
|
|
119
|
+
<h3 class="related-group-label">See also</h3>
|
|
120
|
+
<ul class="related-group-grid">
|
|
121
|
+
{{- range . }}
|
|
122
|
+
<li><a href="{{ .url }}">{{ .title }}</a>{{ with .gloss }} — {{ . }}{{ end }}</li>
|
|
123
|
+
{{- end }}
|
|
124
|
+
</ul>
|
|
125
|
+
</div>
|
|
126
|
+
{{- end }}
|
|
110
127
|
{{- range $rows }}
|
|
111
128
|
<div class="related-group">
|
|
112
129
|
<h3 class="related-group-label">{{ .label }}</h3>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.8.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"dependencies": {
|
|
133
133
|
"@duckdb/node-api": "^1.5.5-r.4",
|
|
134
134
|
"@foundryvtt/foundryvtt-cli": "^3.0.4",
|
|
135
|
+
"@puppeteer/browsers": "^3.2.5",
|
|
135
136
|
"@viz-js/viz": "^3.30.0",
|
|
136
137
|
"acorn": "^8.18.0",
|
|
137
138
|
"archiver": "^8.0.0",
|
package/types/container.d.mts
CHANGED
|
@@ -247,8 +247,8 @@ export function s3RunArgs(plan: S3Plan): string[];
|
|
|
247
247
|
*/
|
|
248
248
|
export function s3ShellArgs(plan: S3Plan, command: string): string[];
|
|
249
249
|
/**
|
|
250
|
-
* Start the S3 server on its network
|
|
251
|
-
*
|
|
250
|
+
* Start or reuse the S3 server on its network, wait for readiness, ensure the
|
|
251
|
+
* bucket exists, and copy the `awsConfig` into the data root.
|
|
252
252
|
*
|
|
253
253
|
* @param {object} opts
|
|
254
254
|
* @param {S3Plan} opts.plan - The S3 plan.
|
|
@@ -261,7 +261,8 @@ export function s3ShellArgs(plan: S3Plan, command: string): string[];
|
|
|
261
261
|
* @param {(ms: number) => void} [opts.sleep] - Waits.
|
|
262
262
|
* @param {number} [opts.attempts] - How often to ask the server whether it is
|
|
263
263
|
* ready.
|
|
264
|
-
* @throws {Error} When an input
|
|
264
|
+
* @throws {Error} When an input is not a readable regular file, a reused
|
|
265
|
+
* identities mount is incompatible, or the S3 HTTP service never answers.
|
|
265
266
|
*/
|
|
266
267
|
export function ensureS3({ plan, dataRoot, log, run, capture, sleep, attempts, }: {
|
|
267
268
|
plan: S3Plan;
|
package/types/e2e.d.mts
CHANGED
|
@@ -236,6 +236,32 @@ export function waitForWorld({ url, container, stage, dataRoot, readLog, timeout
|
|
|
236
236
|
* @returns {string[]} The executables to resolve, in the order to report them.
|
|
237
237
|
*/
|
|
238
238
|
export function suiteExecutables(command: readonly string[]): string[];
|
|
239
|
+
/**
|
|
240
|
+
* The browser cache survives reseeding and container teardown.
|
|
241
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment overrides.
|
|
242
|
+
* @returns {string} The browser cache directory.
|
|
243
|
+
*/
|
|
244
|
+
export function browserCacheDir(env?: NodeJS.ProcessEnv): string;
|
|
245
|
+
/**
|
|
246
|
+
* Supply pinned Chrome only to a recognizable Cypress command without a browser.
|
|
247
|
+
* Explicit browser arguments remain verbatim, including Electron and paths.
|
|
248
|
+
* @param {object} opts
|
|
249
|
+
* @param {readonly string[]} opts.command - The configured suite command.
|
|
250
|
+
* @param {string[]} [opts.args] - Extra suite arguments.
|
|
251
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Cache overrides.
|
|
252
|
+
* @param {string} [opts.platform] - Host operating system.
|
|
253
|
+
* @param {string} [opts.arch] - Host architecture.
|
|
254
|
+
* @param {(message: string) => void} [opts.log] - Progress reporting.
|
|
255
|
+
* @returns {Promise<string[]>} Arguments to append to the suite command.
|
|
256
|
+
*/
|
|
257
|
+
export function prepareSuiteBrowser({ command, args, env, platform, arch, log, }: {
|
|
258
|
+
command: readonly string[];
|
|
259
|
+
args?: string[] | undefined;
|
|
260
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
261
|
+
platform?: string | undefined;
|
|
262
|
+
arch?: string | undefined;
|
|
263
|
+
log?: ((message: string) => void) | undefined;
|
|
264
|
+
}): Promise<string[]>;
|
|
239
265
|
/**
|
|
240
266
|
* Find an executable the way the child process will: a path is a path, and a
|
|
241
267
|
* bare name is looked for in the repository's `node_modules/.bin` first, then
|
|
@@ -467,6 +493,8 @@ export const E2E_GM_ID: "heroiclandsE2EGM";
|
|
|
467
493
|
export const E2E_SCENE_ID: "heroiclandsScene";
|
|
468
494
|
/** How the suite may be run. */
|
|
469
495
|
export const E2E_MODES: readonly string[];
|
|
496
|
+
/** The Chrome for Testing build shared by local and CI Cypress runs. */
|
|
497
|
+
export const CHROME_BUILD_ID: "155.0.8059.84";
|
|
470
498
|
/**
|
|
471
499
|
* The disposable world's identity and credentials.
|
|
472
500
|
*/
|
|
@@ -631,8 +631,8 @@ export class BasePackCompiler {
|
|
|
631
631
|
compile(): Promise<void>;
|
|
632
632
|
currentNote: {
|
|
633
633
|
absPath: string;
|
|
634
|
-
bodyLine:
|
|
635
|
-
bodyColumn:
|
|
634
|
+
bodyLine: any;
|
|
635
|
+
bodyColumn: any;
|
|
636
636
|
} | undefined;
|
|
637
637
|
#private;
|
|
638
638
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* created when none is passed, and returned on the corpus either way — a note
|
|
16
16
|
* the index refuses is a note the compile must still *report*, exactly as the
|
|
17
17
|
* compile loop reported it when the loop was the first to see it.
|
|
18
|
-
* @returns {Promise<{records: object[], linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
|
|
18
|
+
* @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
|
|
19
19
|
* its indexes, and the notes it could not record.
|
|
20
20
|
*/
|
|
21
21
|
export function buildCompileCorpus({ contentBase, assetsBase, skipDirectories, router, config, problems, }: {
|
|
@@ -27,6 +27,7 @@ export function buildCompileCorpus({ contentBase, assetsBase, skipDirectories, r
|
|
|
27
27
|
problems?: object[] | undefined;
|
|
28
28
|
}): Promise<{
|
|
29
29
|
records: object[];
|
|
30
|
+
parsed: Map<string, object>;
|
|
30
31
|
linkIndex: object;
|
|
31
32
|
sqlTables: Map<string, object[]> | undefined;
|
|
32
33
|
problems: object[];
|
|
@@ -94,6 +94,7 @@ export function asciiAliases(aliases: unknown): Array<string>;
|
|
|
94
94
|
* @param {string} options.relPath - Its path below the content root, POSIX-separated.
|
|
95
95
|
* @param {string} [options.absPath] - The file, read only on the failing path to
|
|
96
96
|
* locate the offending key.
|
|
97
|
+
* @param {string} [options.raw] - Source text already read by the caller.
|
|
97
98
|
* @param {string} options.contentPackage - The package the tree compiles as.
|
|
98
99
|
* @param {string} [options.body] - The note's markdown body, for its anchors
|
|
99
100
|
* and for the one question that decides whether the note publishes a page. A
|
|
@@ -118,10 +119,11 @@ export function asciiAliases(aliases: unknown): Array<string>;
|
|
|
118
119
|
* otherwise be overwritten without a word. `file` and, where the file was
|
|
119
120
|
* read, `position` ride on the error.
|
|
120
121
|
*/
|
|
121
|
-
export function buildIndexRecord({ frontmatter, relPath, absPath, contentPackage, body, bodyLine, manifest, addressContext, dateContext, resolveRole, }: {
|
|
122
|
+
export function buildIndexRecord({ frontmatter, relPath, absPath, raw, contentPackage, body, bodyLine, manifest, addressContext, dateContext, resolveRole, }: {
|
|
122
123
|
frontmatter: Record<string, any>;
|
|
123
124
|
relPath: string;
|
|
124
125
|
absPath?: string | undefined;
|
|
126
|
+
raw?: string | undefined;
|
|
125
127
|
contentPackage: string;
|
|
126
128
|
body?: string | undefined;
|
|
127
129
|
bodyLine?: number | undefined;
|
|
@@ -131,12 +133,13 @@ export function buildIndexRecord({ frontmatter, relPath, absPath, contentPackage
|
|
|
131
133
|
resolveRole?: ((address: string) => string | undefined) | undefined;
|
|
132
134
|
}): Record<string, any>;
|
|
133
135
|
/** Derive every index record belonging to one parsed note. */
|
|
134
|
-
export function indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, contentPackage, manifest, addressContext, dateContext, resolveRole, }: {
|
|
136
|
+
export function indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, raw, contentPackage, manifest, addressContext, dateContext, resolveRole, }: {
|
|
135
137
|
frontmatter: any;
|
|
136
138
|
body: any;
|
|
137
139
|
bodyLine: any;
|
|
138
140
|
relPath: any;
|
|
139
141
|
absPath: any;
|
|
142
|
+
raw: any;
|
|
140
143
|
contentPackage: any;
|
|
141
144
|
manifest: any;
|
|
142
145
|
addressContext: any;
|
|
@@ -188,16 +191,20 @@ export function indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absP
|
|
|
188
191
|
* immediate parent whose package is not this one.
|
|
189
192
|
* @param {object[]} [options.problems] - Receives each conflict as a finding.
|
|
190
193
|
* Omitted, every conflict is collected and one Error lists them all.
|
|
194
|
+
* @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
|
|
191
195
|
* @returns {Array<Record<string, any>>} The same records.
|
|
192
196
|
* @throws {Error} When `problems` is omitted and any note's sources disagree.
|
|
193
197
|
*/
|
|
194
|
-
export function resolveCultures(records: Array<Record<string, any>>, { contentPackage, contentBase, foreignRecord, problems }: {
|
|
198
|
+
export function resolveCultures(records: Array<Record<string, any>>, { contentPackage, contentBase, foreignRecord, problems, parsed }: {
|
|
195
199
|
contentPackage: string;
|
|
196
200
|
contentBase?: string | undefined;
|
|
197
201
|
foreignRecord?: ((key: string) => ({
|
|
198
202
|
culture?: unknown;
|
|
199
203
|
} | undefined)) | undefined;
|
|
200
204
|
problems?: object[] | undefined;
|
|
205
|
+
parsed?: Map<string, {
|
|
206
|
+
raw: string;
|
|
207
|
+
}> | undefined;
|
|
201
208
|
}): Array<Record<string, any>>;
|
|
202
209
|
/**
|
|
203
210
|
* Read a content tree into index records, in the order they will be written.
|
|
@@ -233,9 +240,9 @@ export function resolveCultures(records: Array<Record<string, any>>, { contentPa
|
|
|
233
240
|
* @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
|
|
234
241
|
* Another package's entry for a canonical address, read for a parent that
|
|
235
242
|
* package publishes; see {@link resolveCultures}.
|
|
236
|
-
* @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
|
|
237
|
-
* Filled with each note's body and
|
|
238
|
-
*
|
|
243
|
+
* @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
|
|
244
|
+
* Filled with each note's source text, body coordinates and frontmatter as
|
|
245
|
+
* authored, keyed by file, for reuse throughout the command.
|
|
239
246
|
* @returns {Array<Record<string, any>>} The records, in a total order that does
|
|
240
247
|
* not depend on directory-read order.
|
|
241
248
|
*/
|
|
@@ -252,6 +259,9 @@ export function collectContentIndex(contentBase: string, { contentPackage, skipD
|
|
|
252
259
|
parsed?: Map<string, {
|
|
253
260
|
body: string;
|
|
254
261
|
frontmatter: object | null;
|
|
262
|
+
raw: string;
|
|
263
|
+
bodyLine?: number;
|
|
264
|
+
bodyColumn?: number;
|
|
255
265
|
}> | undefined;
|
|
256
266
|
}): Array<Record<string, any>>;
|
|
257
267
|
/**
|
|
@@ -289,7 +299,7 @@ export function serializeContentIndex(records: Array<Record<string, any>>): stri
|
|
|
289
299
|
* aborting the derivation. Omitted, the note throws — which is the contract
|
|
290
300
|
* the emitter needs, since an index missing a note asserts that it does not
|
|
291
301
|
* exist.
|
|
292
|
-
* @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
|
|
302
|
+
* @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [opts.parsed] -
|
|
293
303
|
* Filled with each note's body and authored frontmatter, keyed by the note's
|
|
294
304
|
* file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
|
|
295
305
|
* spares that pass reading and parsing every note again.
|
|
@@ -304,6 +314,9 @@ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectori
|
|
|
304
314
|
parsed?: Map<string, {
|
|
305
315
|
body: string;
|
|
306
316
|
frontmatter: object | null;
|
|
317
|
+
raw: string;
|
|
318
|
+
bodyLine?: number;
|
|
319
|
+
bodyColumn?: number;
|
|
307
320
|
}> | undefined;
|
|
308
321
|
}): object[];
|
|
309
322
|
/**
|