@heroiclands/package-build 23.7.0 → 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 +27 -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 +229 -36
- package/docs/commands.md +12 -2
- package/docs/content-format.md +24 -1
- 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 +418 -220
- package/docs/reference/note-types.md +90 -31
- 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-format.yaml +3 -0
- 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/language-data.mjs +236 -1
- package/engine/language-rows.mjs +235 -3
- package/engine/language-terms.mjs +524 -9
- package/engine/language-words.mjs +75 -0
- 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 +37 -12
- package/engine/standings.mjs +14 -12
- package/engine/word-rows.mjs +8 -3
- package/engine/word-terms.mjs +60 -2
- 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/language-rows.d.mts +89 -3
- package/types/engine/language-terms.d.mts +106 -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
- package/types/engine/word-rows.d.mts +2 -1
- package/types/engine/word-terms.d.mts +11 -0
|
@@ -62,6 +62,7 @@ import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-at
|
|
|
62
62
|
* and a stray `<section>` would close the block early.
|
|
63
63
|
*/
|
|
64
64
|
const titleParser = new MarkdownIt({ html: false });
|
|
65
|
+
const structureParser = new MarkdownIt({ html: true });
|
|
65
66
|
|
|
66
67
|
/** The blocks an author may open, with the heading each takes by default. */
|
|
67
68
|
export const BLOCK_NAMES = Object.freeze({
|
|
@@ -99,28 +100,24 @@ function escapeAttribute(value) {
|
|
|
99
100
|
* @returns {{blocks: Array<{name: string, title: string, id: string, classes: string[], attributes: Record<string, string>, start: number, end: number, body: string}>, errors: Array<{line: number, column: number, message: string}>}}
|
|
100
101
|
*/
|
|
101
102
|
export function scanBlocks(source) {
|
|
102
|
-
const lines = String(source ?? "").split(
|
|
103
|
+
const lines = String(source ?? "").split(/\r?\n/),
|
|
103
104
|
blocks = [],
|
|
104
105
|
errors = [];
|
|
105
106
|
const stack = [];
|
|
106
|
-
|
|
107
|
+
// A bare fence can belong to a quote through lazy continuation.
|
|
108
|
+
// Parser ranges decide whether it can delimit a container.
|
|
109
|
+
const literalLines = new Set();
|
|
110
|
+
for (const token of structureParser.parse(String(source ?? ""), {})) {
|
|
111
|
+
if (
|
|
112
|
+
!token.map ||
|
|
113
|
+
!["blockquote_open", "fence", "code_block", "html_block"].includes(token.type)
|
|
114
|
+
)
|
|
115
|
+
continue;
|
|
116
|
+
for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
|
|
117
|
+
}
|
|
107
118
|
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
if (literalLines.has(i)) continue;
|
|
108
120
|
const line = lines[i];
|
|
109
|
-
const code = /^ *(`{3,}|~{3,})(.*)$/.exec(line);
|
|
110
|
-
if (fence) {
|
|
111
|
-
if (
|
|
112
|
-
code &&
|
|
113
|
-
code[1][0] === fence[0] &&
|
|
114
|
-
code[1].length >= fence.length &&
|
|
115
|
-
!code[2].trim()
|
|
116
|
-
)
|
|
117
|
-
fence = null;
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (code) {
|
|
121
|
-
fence = code[1];
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
121
|
const close = /^ *:::\s*$/.test(line);
|
|
125
122
|
const named = /^ *:::([A-Za-z][A-Za-z0-9-]*)(?:\s+(.*))?$/.exec(line);
|
|
126
123
|
const div = /^ *:::\s+(\{.*)$/.exec(line);
|
|
@@ -135,7 +132,7 @@ export function scanBlocks(source) {
|
|
|
135
132
|
if (!block.body)
|
|
136
133
|
errors.push({
|
|
137
134
|
line: block.start + 1,
|
|
138
|
-
column: 1,
|
|
135
|
+
column: lines[block.start].indexOf(":::") + 1,
|
|
139
136
|
message: `${block.name} block is empty`,
|
|
140
137
|
});
|
|
141
138
|
if (!stack.length) blocks.push(block);
|
|
@@ -144,7 +141,7 @@ export function scanBlocks(source) {
|
|
|
144
141
|
if (!close && !named && !div) continue;
|
|
145
142
|
const name = named?.[1] ?? "div",
|
|
146
143
|
raw = named?.[2] ?? div?.[1] ?? "";
|
|
147
|
-
const at = { line: i + 1, column: 1 };
|
|
144
|
+
const at = { line: i + 1, column: line.indexOf(":::") + 1 };
|
|
148
145
|
if (name !== "div" && !Object.hasOwn(BLOCK_NAMES, name)) {
|
|
149
146
|
errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
|
|
150
147
|
stack.push({ start: i, rejected: true });
|
|
@@ -189,27 +186,12 @@ export function scanBlocks(source) {
|
|
|
189
186
|
if (!block.rejected)
|
|
190
187
|
errors.push({
|
|
191
188
|
line: block.start + 1,
|
|
192
|
-
column: 1,
|
|
189
|
+
column: lines[block.start].indexOf(":::") + 1,
|
|
193
190
|
message: `${block.name} block needs a closing ::: line`,
|
|
194
191
|
});
|
|
195
192
|
for (const block of blocks) {
|
|
196
|
-
let literal = null;
|
|
197
193
|
for (let i = block.start + 1; i < block.end; i++) {
|
|
198
|
-
|
|
199
|
-
if (literal) {
|
|
200
|
-
if (
|
|
201
|
-
marker &&
|
|
202
|
-
marker[1][0] === literal[0] &&
|
|
203
|
-
marker[1].length >= literal.length &&
|
|
204
|
-
!marker[2].trim()
|
|
205
|
-
)
|
|
206
|
-
literal = null;
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
if (marker) {
|
|
210
|
-
literal = marker[1];
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
194
|
+
if (literalLines.has(i)) continue;
|
|
213
195
|
if (parseHeadingLine(lines[i])?.startsPage)
|
|
214
196
|
errors.push({
|
|
215
197
|
line: i + 1,
|
package/engine/content-index.mjs
CHANGED
|
@@ -410,23 +410,25 @@ function foundryBlock(entry, system) {
|
|
|
410
410
|
* @param {string} [absPath] - The file, read only on the failing path to locate
|
|
411
411
|
* the offending key.
|
|
412
412
|
* @param {string} [contentPackage] - The package this tree compiles as.
|
|
413
|
+
* @param {string} [raw] - Source text already read by the caller.
|
|
413
414
|
* @returns {void}
|
|
414
415
|
* @throws {Error} When the note authors a derived key. `file` and `position`
|
|
415
416
|
* ride on the error.
|
|
416
417
|
*/
|
|
417
|
-
function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
|
|
418
|
+
function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw) {
|
|
418
419
|
for (const key of DERIVED_KEYS) {
|
|
419
420
|
if (!Object.hasOwn(frontmatter ?? {}, key)) continue;
|
|
420
421
|
if (key === "package") {
|
|
421
422
|
// Throws with its own wording, and its own position.
|
|
422
|
-
assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage });
|
|
423
|
+
assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage, raw });
|
|
423
424
|
}
|
|
424
425
|
const err = new Error(
|
|
425
426
|
`\`${key}:\` is derived by the content index and cannot be ` +
|
|
426
427
|
`authored — rename the frontmatter field`,
|
|
427
428
|
);
|
|
428
429
|
err.file = relPath;
|
|
429
|
-
const position =
|
|
430
|
+
const position =
|
|
431
|
+
absPath ? locateFrontmatterKey(absPath, key, undefined, { raw }) : undefined;
|
|
430
432
|
if (position) err.position = position;
|
|
431
433
|
throw err;
|
|
432
434
|
}
|
|
@@ -445,6 +447,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
|
|
|
445
447
|
* @param {string} options.relPath - Its path below the content root, POSIX-separated.
|
|
446
448
|
* @param {string} [options.absPath] - The file, read only on the failing path to
|
|
447
449
|
* locate the offending key.
|
|
450
|
+
* @param {string} [options.raw] - Source text already read by the caller.
|
|
448
451
|
* @param {string} options.contentPackage - The package the tree compiles as.
|
|
449
452
|
* @param {string} [options.body] - The note's markdown body, for its anchors
|
|
450
453
|
* and for the one question that decides whether the note publishes a page. A
|
|
@@ -473,6 +476,7 @@ export function buildIndexRecord({
|
|
|
473
476
|
frontmatter,
|
|
474
477
|
relPath,
|
|
475
478
|
absPath,
|
|
479
|
+
raw,
|
|
476
480
|
contentPackage,
|
|
477
481
|
body,
|
|
478
482
|
bodyLine,
|
|
@@ -481,7 +485,7 @@ export function buildIndexRecord({
|
|
|
481
485
|
dateContext,
|
|
482
486
|
resolveRole,
|
|
483
487
|
}) {
|
|
484
|
-
assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
|
|
488
|
+
assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
|
|
485
489
|
|
|
486
490
|
const posix = relPath.split(path.sep).join("/");
|
|
487
491
|
const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
|
|
@@ -490,9 +494,7 @@ export function buildIndexRecord({
|
|
|
490
494
|
} catch (error) {
|
|
491
495
|
error.file = absPath ?? relPath;
|
|
492
496
|
if (absPath && error.keyPath) {
|
|
493
|
-
const yamlText =
|
|
494
|
-
.readFileSync(absPath, "utf8")
|
|
495
|
-
.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
497
|
+
const yamlText = (raw ?? rawNote(absPath)).match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
496
498
|
const locate = (/** @type {any} */ found) => {
|
|
497
499
|
const position = positionOfYamlPath(yamlText, found.keyPath, {
|
|
498
500
|
key: found.addressKey,
|
|
@@ -540,7 +542,7 @@ export function buildIndexRecord({
|
|
|
540
542
|
stub ? null : (
|
|
541
543
|
[
|
|
542
544
|
...collectAnchors(body, bodyLine, resolveRole),
|
|
543
|
-
...eventAnchors(frontmatter, rawNote(absPath)),
|
|
545
|
+
...eventAnchors(frontmatter, raw ?? rawNote(absPath)),
|
|
544
546
|
]
|
|
545
547
|
.sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
|
|
546
548
|
.map((a) => ({
|
|
@@ -659,6 +661,7 @@ export function indexRecordsForNote({
|
|
|
659
661
|
bodyLine,
|
|
660
662
|
relPath,
|
|
661
663
|
absPath,
|
|
664
|
+
raw,
|
|
662
665
|
contentPackage,
|
|
663
666
|
manifest,
|
|
664
667
|
addressContext,
|
|
@@ -672,6 +675,7 @@ export function indexRecordsForNote({
|
|
|
672
675
|
bodyLine,
|
|
673
676
|
relPath,
|
|
674
677
|
absPath,
|
|
678
|
+
raw,
|
|
675
679
|
contentPackage,
|
|
676
680
|
manifest,
|
|
677
681
|
addressContext,
|
|
@@ -783,10 +787,14 @@ function cultureTuple(value) {
|
|
|
783
787
|
* immediate parent whose package is not this one.
|
|
784
788
|
* @param {object[]} [options.problems] - Receives each conflict as a finding.
|
|
785
789
|
* Omitted, every conflict is collected and one Error lists them all.
|
|
790
|
+
* @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
|
|
786
791
|
* @returns {Array<Record<string, any>>} The same records.
|
|
787
792
|
* @throws {Error} When `problems` is omitted and any note's sources disagree.
|
|
788
793
|
*/
|
|
789
|
-
export function resolveCultures(
|
|
794
|
+
export function resolveCultures(
|
|
795
|
+
records,
|
|
796
|
+
{ contentPackage, contentBase, foreignRecord, problems, parsed },
|
|
797
|
+
) {
|
|
790
798
|
// Each note under every key a link may name it by: its record's canonical
|
|
791
799
|
// address, and its page address in the `note` system, which is what a
|
|
792
800
|
// `data.parents`, `data.seat` or `data.government` link names.
|
|
@@ -879,7 +887,7 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
|
|
|
879
887
|
*/
|
|
880
888
|
const conflict = (record, keyPath, subject, distinct) => {
|
|
881
889
|
const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
|
|
882
|
-
const raw =
|
|
890
|
+
const raw = parsed?.get(file)?.raw ?? (contentBase ? rawNote(file) : undefined);
|
|
883
891
|
let position = positionOfFrontmatterPath(raw, keyPath, { key: true });
|
|
884
892
|
if (position.line === undefined && keyPath.length > 1)
|
|
885
893
|
position = positionOfFrontmatterPath(raw, ["data"], { key: true });
|
|
@@ -1026,9 +1034,9 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
|
|
|
1026
1034
|
* @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
|
|
1027
1035
|
* Another package's entry for a canonical address, read for a parent that
|
|
1028
1036
|
* package publishes; see {@link resolveCultures}.
|
|
1029
|
-
* @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
|
|
1030
|
-
* Filled with each note's body and
|
|
1031
|
-
*
|
|
1037
|
+
* @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
|
|
1038
|
+
* Filled with each note's source text, body coordinates and frontmatter as
|
|
1039
|
+
* authored, keyed by file, for reuse throughout the command.
|
|
1032
1040
|
* @returns {Array<Record<string, any>>} The records, in a total order that does
|
|
1033
1041
|
* not depend on directory-read order.
|
|
1034
1042
|
*/
|
|
@@ -1042,7 +1050,7 @@ export function collectContentIndex(
|
|
|
1042
1050
|
problems,
|
|
1043
1051
|
addressContext,
|
|
1044
1052
|
foreignRecord,
|
|
1045
|
-
parsed,
|
|
1053
|
+
parsed = new Map(),
|
|
1046
1054
|
},
|
|
1047
1055
|
) {
|
|
1048
1056
|
const records = [];
|
|
@@ -1115,10 +1123,16 @@ export function collectContentIndex(
|
|
|
1115
1123
|
|
|
1116
1124
|
// Each recorded note with its body, for the words its prose cites.
|
|
1117
1125
|
const bodies = [];
|
|
1118
|
-
for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
|
|
1126
|
+
for (const { frontmatter, body, bodyLine, bodyColumn, raw, absPath } of parsedNotes) {
|
|
1119
1127
|
// Before the index derives anything onto the frontmatter, so a later
|
|
1120
1128
|
// pass reads the note as authored.
|
|
1121
|
-
parsed
|
|
1129
|
+
parsed.set(absPath, {
|
|
1130
|
+
body,
|
|
1131
|
+
bodyLine,
|
|
1132
|
+
bodyColumn,
|
|
1133
|
+
raw,
|
|
1134
|
+
frontmatter: structuredClone(frontmatter),
|
|
1135
|
+
});
|
|
1122
1136
|
const fm = frontmatter ?? {};
|
|
1123
1137
|
applyComputedBeingAge(fm, present, dates);
|
|
1124
1138
|
const relPath = path.relative(contentBase, absPath);
|
|
@@ -1127,6 +1141,7 @@ export function collectContentIndex(
|
|
|
1127
1141
|
frontmatter: fm,
|
|
1128
1142
|
relPath,
|
|
1129
1143
|
absPath,
|
|
1144
|
+
raw,
|
|
1130
1145
|
contentPackage,
|
|
1131
1146
|
body,
|
|
1132
1147
|
bodyLine,
|
|
@@ -1142,9 +1157,7 @@ export function collectContentIndex(
|
|
|
1142
1157
|
err.identity = { type: fm.type, shortcode: fm.shortcode };
|
|
1143
1158
|
err.keyPath = ["shortcode"];
|
|
1144
1159
|
err.message = `shortcode "${fm.shortcode}" is not strictly alphanumeric — lowercase letters and digits only`;
|
|
1145
|
-
const yamlText =
|
|
1146
|
-
.readFileSync(absPath, "utf8")
|
|
1147
|
-
.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
1160
|
+
const yamlText = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
1148
1161
|
err.position = positionOfYamlPath(yamlText, err.keyPath);
|
|
1149
1162
|
if (err.position.line) err.position.line++;
|
|
1150
1163
|
}
|
|
@@ -1186,13 +1199,13 @@ export function collectContentIndex(
|
|
|
1186
1199
|
// Every note record exists and every Address in it is a tuple, so a parent
|
|
1187
1200
|
// is found by its canonical address. Before the sort, which reads nothing
|
|
1188
1201
|
// the pass writes.
|
|
1189
|
-
resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
|
|
1202
|
+
resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
|
|
1190
1203
|
const cultureNotes = records
|
|
1191
1204
|
.filter((record) => isNoteRecord(record) && record.type === "lore")
|
|
1192
1205
|
.map((record) => ({
|
|
1193
1206
|
fm: record,
|
|
1194
1207
|
file: noteFile(contentBase, record),
|
|
1195
|
-
raw:
|
|
1208
|
+
raw: parsed.get(noteFile(contentBase, record)).raw,
|
|
1196
1209
|
}));
|
|
1197
1210
|
const cultureByKey = new Map(
|
|
1198
1211
|
cultureNotes.map((note) => [
|
|
@@ -1352,7 +1365,7 @@ function dependencyAddresses(config) {
|
|
|
1352
1365
|
* aborting the derivation. Omitted, the note throws — which is the contract
|
|
1353
1366
|
* the emitter needs, since an index missing a note asserts that it does not
|
|
1354
1367
|
* exist.
|
|
1355
|
-
* @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
|
|
1368
|
+
* @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [opts.parsed] -
|
|
1356
1369
|
* Filled with each note's body and authored frontmatter, keyed by the note's
|
|
1357
1370
|
* file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
|
|
1358
1371
|
* spares that pass reading and parsing every note again.
|
|
@@ -71,9 +71,12 @@ export function poetryMarkdown(body) {
|
|
|
71
71
|
.join("\n\n");
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
import MarkdownIt from "markdown-it";
|
|
74
75
|
import { slugify } from "./content-slug.mjs";
|
|
75
76
|
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
76
77
|
|
|
78
|
+
const structureParser = new MarkdownIt({ html: true });
|
|
79
|
+
|
|
77
80
|
/** Read poetry code fences; start/end are inclusive zero-based source lines.
|
|
78
81
|
* @param {string} source - Authored Markdown.
|
|
79
82
|
*/
|
|
@@ -81,8 +84,14 @@ export function scanPoetry(source) {
|
|
|
81
84
|
const lines = String(source ?? "").split("\n"),
|
|
82
85
|
blocks = [],
|
|
83
86
|
errors = [];
|
|
87
|
+
const literalLines = new Set();
|
|
88
|
+
for (const token of structureParser.parse(String(source ?? ""), {})) {
|
|
89
|
+
if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
|
|
90
|
+
for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
|
|
91
|
+
}
|
|
84
92
|
let opening = null;
|
|
85
93
|
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
if (literalLines.has(i)) continue;
|
|
86
95
|
const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
|
|
87
96
|
if (opening) {
|
|
88
97
|
if (
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -249,15 +249,20 @@ export function positionInFrontmatter(raw, key, value = undefined, { topLevel =
|
|
|
249
249
|
* {@link positionInFrontmatter}.
|
|
250
250
|
* @param {boolean} [options.topLevel=false] - Require the key at column 1, so
|
|
251
251
|
* an identically named nested key cannot answer for it.
|
|
252
|
+
* @param {string} [options.raw] - Source text already read by the caller.
|
|
252
253
|
* @returns {{line?: number, column?: number}|undefined} Spreadable position
|
|
253
254
|
* fields, dropped rather than guessed when the file cannot be read or the key
|
|
254
255
|
* cannot be found — as `formatDiagnostic` requires.
|
|
255
256
|
*/
|
|
256
|
-
export function locateFrontmatterKey(
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
export function locateFrontmatterKey(
|
|
258
|
+
absPath,
|
|
259
|
+
key,
|
|
260
|
+
value = undefined,
|
|
261
|
+
{ topLevel = false, raw } = {},
|
|
262
|
+
) {
|
|
263
|
+
if (!absPath && raw === undefined) return undefined;
|
|
259
264
|
try {
|
|
260
|
-
raw
|
|
265
|
+
raw ??= fs.readFileSync(absPath, "utf8");
|
|
261
266
|
} catch {
|
|
262
267
|
return undefined;
|
|
263
268
|
}
|
|
@@ -306,6 +311,9 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
|
|
|
306
311
|
};
|
|
307
312
|
}
|
|
308
313
|
|
|
314
|
+
/** The most recently located text; retaining one document bounds memory. */
|
|
315
|
+
let locatedYaml;
|
|
316
|
+
|
|
309
317
|
/**
|
|
310
318
|
* Where a **key or value in a YAML document** sits, addressed by its path.
|
|
311
319
|
*
|
|
@@ -315,11 +323,10 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
|
|
|
315
323
|
* first occurrence is routinely not the one the finding is about — which is the
|
|
316
324
|
* one thing the located form exists to prevent.
|
|
317
325
|
*
|
|
318
|
-
* A path resolves the exact node
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* representation of the same file to keep in step.
|
|
326
|
+
* A path resolves the exact node. The most recently located text retains its
|
|
327
|
+
* document and line counter, so several findings in one note share a parse.
|
|
328
|
+
* A different text replaces that entry, including an edited copy of the same
|
|
329
|
+
* file. The loader returns plain data without source ranges.
|
|
323
330
|
*
|
|
324
331
|
* Every failure — unparseable text, an `.mjs` configuration, a path that
|
|
325
332
|
* resolves to nothing — yields `{}`, so a caller spreads the result and the
|
|
@@ -348,9 +355,15 @@ export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
|
|
|
348
355
|
if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
|
|
349
356
|
|
|
350
357
|
let node;
|
|
351
|
-
|
|
358
|
+
let counter;
|
|
352
359
|
try {
|
|
353
|
-
|
|
360
|
+
if (locatedYaml?.text !== text) {
|
|
361
|
+
const lineCounter = new LineCounter();
|
|
362
|
+
const doc = YAML.parseDocument(text, { lineCounter });
|
|
363
|
+
locatedYaml = { text, doc, counter: lineCounter };
|
|
364
|
+
}
|
|
365
|
+
const { doc } = locatedYaml;
|
|
366
|
+
counter = locatedYaml.counter;
|
|
354
367
|
// `keepScalar` returns the Scalar node rather than its value, which is
|
|
355
368
|
// the only form carrying a range.
|
|
356
369
|
node = doc.getIn(keyPath, true);
|
|
@@ -1541,7 +1541,7 @@ export function lintNote(
|
|
|
1541
1541
|
for (const field of fields) {
|
|
1542
1542
|
if (note.inheritedCultureFields?.has(field.name)) continue;
|
|
1543
1543
|
if (typeof field.check !== "function") continue;
|
|
1544
|
-
findings.push(...field.check(note, { index }));
|
|
1544
|
+
findings.push(...field.check(note, { index, addressContext: referenceContext }));
|
|
1545
1545
|
}
|
|
1546
1546
|
// A check the type declares of the whole note, with the same index.
|
|
1547
1547
|
if (typeof entry.check === "function") findings.push(...entry.check(note, { index }));
|
package/engine/generate.mjs
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* The HeroicLands vault is not a build input for SoHL content.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import { noteAddressContext } from "./note-addresses.mjs";
|
|
41
|
+
import { noteAddressContext, decodeNoteAddresses } from "./note-addresses.mjs";
|
|
42
42
|
import { GENERATED_DRAWING_DIR } from "./generated-sections.mjs";
|
|
43
43
|
import fs from "fs";
|
|
44
44
|
import path from "path";
|
|
@@ -54,7 +54,7 @@ import { Hm3Actors } from "../hm3/actors.mjs";
|
|
|
54
54
|
import { Macros } from "./macros.mjs";
|
|
55
55
|
import { Scenes } from "./scenes.mjs";
|
|
56
56
|
import { Bundles } from "./bundles.mjs";
|
|
57
|
-
import { statsForPack,
|
|
57
|
+
import { statsForPack, folderFilename } from "./helpers.mjs";
|
|
58
58
|
import {
|
|
59
59
|
buildFolderNoteIndex,
|
|
60
60
|
collectFolderNotes,
|
|
@@ -800,7 +800,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
800
800
|
// every type a system deliberately does not map, which is exactly the
|
|
801
801
|
// silence the rule requires. Independent of `only`, since it is a fact about the
|
|
802
802
|
// configured pack list rather than about which passes this run executes.
|
|
803
|
-
const unclaimed = unclaimedNoteFindings(config, undefined, {
|
|
803
|
+
const unclaimed = unclaimedNoteFindings(config, undefined, {
|
|
804
|
+
records: corpus.records,
|
|
805
|
+
parsed: corpus.parsed,
|
|
806
|
+
});
|
|
804
807
|
for (const finding of unclaimed) emitDiagnostic(finding);
|
|
805
808
|
|
|
806
809
|
fs.mkdirSync(config.paths.packJson, { recursive: true });
|
|
@@ -831,22 +834,8 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
831
834
|
try {
|
|
832
835
|
folderNotes = buildFolderNoteIndex(
|
|
833
836
|
collectFolderNotes(
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
// places where that distinction is load-bearing rather than
|
|
837
|
-
// incidental.
|
|
838
|
-
//
|
|
839
|
-
// `collectFolderNotes` treats `fm.id` as an **authored pin**,
|
|
840
|
-
// which wins over the id it derives under the folder namespace.
|
|
841
|
-
// A record's `id` is not that: the index fills it in for every
|
|
842
|
-
// addressable note, so handing records straight over
|
|
843
|
-
// would make every folder look pinned. The *value*
|
|
844
|
-
// would be right either way — the index derives a folder's id
|
|
845
|
-
// under the folder namespace, so the two agree — but `derivedId`
|
|
846
|
-
// would not, and it is what tells an author whether a duplicate
|
|
847
|
-
// id was two pins or two addresses. The index cannot tell a pin
|
|
848
|
-
// from a derivation, so the note is read — and only folder notes
|
|
849
|
-
// are, 79 of `sohl`'s 1,685 rather than all of them.
|
|
837
|
+
// Folder pins are authored ids; index-derived ids must not
|
|
838
|
+
// turn an unpinned folder into a pinned one.
|
|
850
839
|
corpus.records
|
|
851
840
|
.filter(
|
|
852
841
|
(record) =>
|
|
@@ -856,9 +845,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
856
845
|
.map((record) => {
|
|
857
846
|
const absPath = noteFile(contentBase, record);
|
|
858
847
|
return {
|
|
859
|
-
frontmatter:
|
|
860
|
-
|
|
861
|
-
|
|
848
|
+
frontmatter: decodeNoteAddresses(
|
|
849
|
+
structuredClone(corpus.parsed.get(absPath).frontmatter),
|
|
850
|
+
noteAddressContext(config),
|
|
851
|
+
),
|
|
862
852
|
absPath,
|
|
863
853
|
};
|
|
864
854
|
}),
|
package/engine/helpers.mjs
CHANGED
|
@@ -233,17 +233,18 @@ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, res
|
|
|
233
233
|
/**
|
|
234
234
|
* Parses a markdown file with YAML frontmatter.
|
|
235
235
|
*
|
|
236
|
-
* Returns `{ frontmatter, body, description, bodyLine, bodyColumn }` where
|
|
236
|
+
* Returns `{ frontmatter, body, description, bodyLine, bodyColumn, raw }` where
|
|
237
237
|
* `body` is the trimmed raw markdown after the frontmatter block, and
|
|
238
|
-
* `description` is `body` rendered to HTML.
|
|
238
|
+
* `description` is `body` rendered to HTML. The full source is retained as
|
|
239
|
+
* `raw` for diagnostic locations. `bodyLine` / `bodyColumn` are the
|
|
239
240
|
* 1-based **file** position of the body's first character, which is what turns
|
|
240
241
|
* an offset within `body` into a position a diagnostic can name — see
|
|
241
242
|
* {@link positionInBody}. If the file has no frontmatter block, returns
|
|
242
|
-
* `{ frontmatter: null, body: "", description: "" }`
|
|
243
|
+
* `{ frontmatter: null, body: "", description: "", raw }` and no
|
|
243
244
|
* position: there is no body to have one.
|
|
244
245
|
* @param {string} filePath - Markdown file to read.
|
|
245
246
|
* @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
|
|
246
|
-
* @returns {{frontmatter: object|null, body: string, description: string,
|
|
247
|
+
* @returns {{frontmatter: object|null, body: string, description: string, raw: string,
|
|
247
248
|
* bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
|
|
248
249
|
* line?: number, column?: number}}} Parsed note and body position.
|
|
249
250
|
*/
|
|
@@ -251,7 +252,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
|
|
|
251
252
|
const content = fs.readFileSync(filePath, "utf8");
|
|
252
253
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
253
254
|
if (!fmMatch) {
|
|
254
|
-
return { frontmatter: null, body: "", description: "" };
|
|
255
|
+
return { frontmatter: null, body: "", description: "", raw: content };
|
|
255
256
|
}
|
|
256
257
|
let frontmatter;
|
|
257
258
|
try {
|
|
@@ -264,6 +265,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
|
|
|
264
265
|
frontmatter: null,
|
|
265
266
|
body: "",
|
|
266
267
|
description: "",
|
|
268
|
+
raw: content,
|
|
267
269
|
parseError: {
|
|
268
270
|
message: `YAML parse error: ${message}`,
|
|
269
271
|
...(Number.isInteger(location?.line) ? { line: location.line + 1 } : {}),
|
|
@@ -295,7 +297,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
|
|
|
295
297
|
const before = content.slice(0, bodyStart);
|
|
296
298
|
const bodyLine = before.split("\n").length;
|
|
297
299
|
const bodyColumn = bodyStart - before.lastIndexOf("\n");
|
|
298
|
-
return { frontmatter, body, description, bodyLine, bodyColumn };
|
|
300
|
+
return { frontmatter, body, description, bodyLine, bodyColumn, raw: content };
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
/**
|
package/engine/infobox.mjs
CHANGED
|
@@ -277,6 +277,9 @@ export const GEAR_UNITS = Object.freeze({
|
|
|
277
277
|
* unit?: string, group?: string, phrase?: (value: any) => string}>>}
|
|
278
278
|
*/
|
|
279
279
|
export const NOTE_FIELD_PRESENTATION = Object.freeze({
|
|
280
|
+
seeAlso: Object.freeze({
|
|
281
|
+
withheld: "navigation machinery, rendered in its own reading section",
|
|
282
|
+
}),
|
|
280
283
|
...DURATION_LABELS,
|
|
281
284
|
...GEAR_UNITS,
|
|
282
285
|
|
package/engine/journals.mjs
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
52
|
import log from "loglevel";
|
|
53
|
+
import { seeAlsoEntries, seeAlsoMarkdown } from "./see-also.mjs";
|
|
53
54
|
|
|
54
55
|
import path from "node:path";
|
|
55
56
|
|
|
@@ -825,7 +826,16 @@ export class Journals extends BasePackCompiler {
|
|
|
825
826
|
return buildJournalEntry({
|
|
826
827
|
id,
|
|
827
828
|
name,
|
|
828
|
-
markdown
|
|
829
|
+
markdown: [
|
|
830
|
+
markdown,
|
|
831
|
+
seeAlsoMarkdown(
|
|
832
|
+
seeAlsoEntries(fm, (ref) => resolveReference(this.linkIndex, ref)),
|
|
833
|
+
linkToUuid,
|
|
834
|
+
1,
|
|
835
|
+
),
|
|
836
|
+
]
|
|
837
|
+
.filter(Boolean)
|
|
838
|
+
.join("\n\n"),
|
|
829
839
|
resolveRole: (pathname) => roles.get(pathname),
|
|
830
840
|
infoboxes: boxes.map((box) => ({
|
|
831
841
|
id: box.id,
|