@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
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
* - no form stands twice in one language;
|
|
29
29
|
* - a word's `gloss` states at least one sense;
|
|
30
30
|
* - an affix that `replaces` the stem's close does not also `lengthen` it;
|
|
31
|
+
* - an affix states `given` only where its `kind` is `name`;
|
|
32
|
+
* - an affix states a `form` or `zero: true`, never both;
|
|
33
|
+
* - an element's `word` and the particle of `data.negation` are forms of the
|
|
34
|
+
* words of the language or an ancestor;
|
|
31
35
|
* - a word's `frame` names a frame of `data.sounds.frames`, its language's or
|
|
32
36
|
* an ancestor's;
|
|
33
37
|
* - every source language resolves, and names a language skill;
|
|
@@ -51,6 +55,7 @@ import { addressKeyOf, ownKey } from "./culture-reach.mjs";
|
|
|
51
55
|
import {
|
|
52
56
|
BORROWED_SOURCE_KIND,
|
|
53
57
|
LANGUAGE_SUBTYPE,
|
|
58
|
+
NAME_AFFIX_KIND,
|
|
54
59
|
WORD_DATA_KEYS,
|
|
55
60
|
glossSenses,
|
|
56
61
|
pronunciationProblem,
|
|
@@ -287,6 +292,76 @@ export function checkLanguageWords(note, { index } = {}) {
|
|
|
287
292
|
});
|
|
288
293
|
});
|
|
289
294
|
|
|
295
|
+
listOf(data.affixes).forEach((affix, i) => {
|
|
296
|
+
if (!isMap(affix)) return;
|
|
297
|
+
const form = typeof affix.form === "string" && affix.form.trim() !== "";
|
|
298
|
+
const zero = affix.zero === true;
|
|
299
|
+
if (form && zero)
|
|
300
|
+
findings.push(
|
|
301
|
+
finding(
|
|
302
|
+
["data", "affixes", i, "zero"],
|
|
303
|
+
"error",
|
|
304
|
+
`\`data.affixes[${i}]\` states a \`form\` and \`zero: true\`; a zero affix states no form`,
|
|
305
|
+
true,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
else if (!form && !zero && affix.form == null)
|
|
309
|
+
findings.push(
|
|
310
|
+
finding(
|
|
311
|
+
["data", "affixes", i],
|
|
312
|
+
"error",
|
|
313
|
+
`\`data.affixes[${i}]\` states neither a \`form\` nor \`zero: true\``,
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
listOf(data.affixes).forEach((affix, i) => {
|
|
319
|
+
if (!isMap(affix) || affix.given == null || affix.kind === NAME_AFFIX_KIND) return;
|
|
320
|
+
findings.push(
|
|
321
|
+
finding(
|
|
322
|
+
["data", "affixes", i, "given"],
|
|
323
|
+
"error",
|
|
324
|
+
"`given` says who a given name belongs to, and " +
|
|
325
|
+
(typeof affix.kind === "string" ?
|
|
326
|
+
`\`data.affixes[${i}]\` is of kind \`${affix.kind}\``
|
|
327
|
+
: `\`data.affixes[${i}]\` is of no kind`) +
|
|
328
|
+
`; it is stated on an affix of kind \`${NAME_AFFIX_KIND}\` only`,
|
|
329
|
+
true,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// The forms an element's `word` and the negative particle may name.
|
|
335
|
+
const wordForms = new Set();
|
|
336
|
+
for (const one of [data, ...ancestors])
|
|
337
|
+
for (const word of listOf(one?.words))
|
|
338
|
+
if (isMap(word) && typeof word.form === "string") wordForms.add(word.form);
|
|
339
|
+
listOf(data.elements).forEach((element, i) => {
|
|
340
|
+
if (!isMap(element) || typeof element.word !== "string" || wordForms.has(element.word))
|
|
341
|
+
return;
|
|
342
|
+
findings.push(
|
|
343
|
+
finding(
|
|
344
|
+
["data", "elements", i, "word"],
|
|
345
|
+
"error",
|
|
346
|
+
`the word \`${element.word}\` of the element \`${element.form}\` is not a form of ` +
|
|
347
|
+
"this language's words or its ancestors'",
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
if (
|
|
352
|
+
isMap(data.negation) &&
|
|
353
|
+
typeof data.negation.particle === "string" &&
|
|
354
|
+
!wordForms.has(data.negation.particle)
|
|
355
|
+
)
|
|
356
|
+
findings.push(
|
|
357
|
+
finding(
|
|
358
|
+
["data", "negation", "particle"],
|
|
359
|
+
"error",
|
|
360
|
+
`the negative particle \`${data.negation.particle}\` is not a form of this ` +
|
|
361
|
+
"language's words or its ancestors'",
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
|
|
290
365
|
listOf(data.affixes).forEach((affix, i) => {
|
|
291
366
|
if (!isMap(affix) || affix.lengthens !== true || affix.replaces == null) return;
|
|
292
367
|
findings.push(
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
import fs from "node:fs";
|
|
42
42
|
import path from "node:path";
|
|
43
43
|
import { pathToFileURL } from "node:url";
|
|
44
|
+
import { markupFindings } from "./markup-validation.mjs";
|
|
44
45
|
|
|
45
46
|
import ignore from "ignore";
|
|
46
47
|
import {
|
|
@@ -447,8 +448,9 @@ function gitignoreHooks(root) {
|
|
|
447
448
|
* `config`, `ignores`, `gitignore` and `globs`.
|
|
448
449
|
* @param {boolean} [params.fix=false] - Repair what markdownlint can, then
|
|
449
450
|
* report what remains.
|
|
450
|
-
* @returns {Promise<{results: object[]}>} markdownlint results, each with its
|
|
451
|
+
* @returns {Promise<{results: object[], markup: object[]}>} markdownlint results, each with its
|
|
451
452
|
* `fileName` relative to `directory`, ordered by file, line and rule.
|
|
453
|
+
* `markup` carries block syntax diagnostics with absolute filenames.
|
|
452
454
|
* @throws {MarkdownConfigError} A configuration file the contract refuses.
|
|
453
455
|
*/
|
|
454
456
|
export async function runMarkdownlint(params) {
|
|
@@ -530,12 +532,20 @@ export async function runMarkdownlint(params) {
|
|
|
530
532
|
};
|
|
531
533
|
|
|
532
534
|
const resultsByFile = new Map();
|
|
535
|
+
const markup = [];
|
|
533
536
|
for (const [dir, files] of groups) {
|
|
534
537
|
const options = await lintOptions(dir, files);
|
|
538
|
+
const malformed = new Set();
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
const findings = markupFindings(fs.readFileSync(file, "utf8"), file);
|
|
541
|
+
if (findings.length) malformed.add(file);
|
|
542
|
+
markup.push(...findings);
|
|
543
|
+
}
|
|
535
544
|
let results = await lint(options);
|
|
536
545
|
if (fix) {
|
|
537
546
|
const toFix = [];
|
|
538
547
|
for (const file of files) {
|
|
548
|
+
if (malformed.has(file)) continue;
|
|
539
549
|
const fixable = (results[file] ?? []).filter((error) => error.fixInfo);
|
|
540
550
|
if (!fixable.length) continue;
|
|
541
551
|
const original = fs.readFileSync(file, "utf8");
|
|
@@ -563,5 +573,5 @@ export async function runMarkdownlint(params) {
|
|
|
563
573
|
a.ruleNames[0].localeCompare(b.ruleNames[0]) ||
|
|
564
574
|
order.get(a) - order.get(b),
|
|
565
575
|
);
|
|
566
|
-
return { results: flat };
|
|
576
|
+
return { results: flat, markup };
|
|
567
577
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { scanBlocks } from "./content-blocks.mjs";
|
|
15
|
+
import { scanAlerts } from "./content-alerts.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate authored block syntax with positions in the original file.
|
|
19
|
+
* Frontmatter is masked so literal YAML values cannot become body markup.
|
|
20
|
+
* @param {string} source - Complete Markdown source, optionally with frontmatter.
|
|
21
|
+
* @param {string} file - Diagnostic filename.
|
|
22
|
+
* @returns {Array<{file: string, line: number, column: number, severity: string, message: string}>} Syntax errors.
|
|
23
|
+
*/
|
|
24
|
+
export function markupFindings(source, file) {
|
|
25
|
+
const body = String(source ?? "").replace(
|
|
26
|
+
/^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,
|
|
27
|
+
(frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
|
|
28
|
+
);
|
|
29
|
+
return [...scanBlocks(body).errors, ...scanAlerts(body).errors].map((error) => ({
|
|
30
|
+
file,
|
|
31
|
+
...error,
|
|
32
|
+
severity: "error",
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
@@ -41,6 +41,7 @@ export {
|
|
|
41
41
|
|
|
42
42
|
/** Structured positions whose Address is nested inside a declared container. */
|
|
43
43
|
export const STRUCTURED_ADDRESSES = Object.freeze([
|
|
44
|
+
{ path: ["data", "seeAlso", "*", "address"], shape: "value", lenient: true },
|
|
44
45
|
{
|
|
45
46
|
path: ["packFolder"],
|
|
46
47
|
shape: "scalar-or-map",
|
package/engine/note-claims.mjs
CHANGED
|
@@ -666,11 +666,12 @@ function packsServing(config, docType, system) {
|
|
|
666
666
|
* @param {string} absPath - The note's path, for the finding's position.
|
|
667
667
|
* @param {string} type - The note's type.
|
|
668
668
|
* @param {object} config - The resolved build configuration.
|
|
669
|
+
* @param {string} [raw] - Source text already read by the caller.
|
|
669
670
|
* @returns {Array<{file: string, line?: number, column?: number,
|
|
670
671
|
* severity: "error", message: string, type: string}>} One finding per
|
|
671
672
|
* discarded block.
|
|
672
673
|
*/
|
|
673
|
-
function discardedSystemBlockFindings(fm, absPath, type, config) {
|
|
674
|
+
function discardedSystemBlockFindings(fm, absPath, type, config, raw) {
|
|
674
675
|
const findings = [];
|
|
675
676
|
for (const system of SHIPPED_SYSTEMS) {
|
|
676
677
|
if (!carriesSystemBlock(fm, system)) continue;
|
|
@@ -683,7 +684,7 @@ function discardedSystemBlockFindings(fm, absPath, type, config) {
|
|
|
683
684
|
if (!packs || serving) continue;
|
|
684
685
|
findings.push({
|
|
685
686
|
file: absPath,
|
|
686
|
-
...locateFrontmatterKey(absPath, system, undefined, { topLevel: true }),
|
|
687
|
+
...locateFrontmatterKey(absPath, system, undefined, { topLevel: true, raw }),
|
|
687
688
|
severity: /** @type {"error"} */ ("error"),
|
|
688
689
|
type,
|
|
689
690
|
message:
|
|
@@ -715,10 +716,16 @@ function discardedSystemBlockFindings(fm, absPath, type, config) {
|
|
|
715
716
|
* @param {object} [opts] - Options.
|
|
716
717
|
* @param {readonly object[]} [opts.records] - The corpus, derived once by the
|
|
717
718
|
* compile and handed in — required, for the reason above.
|
|
719
|
+
* @param {Map<string, {body: string, raw: string}>} [opts.parsed] - Already parsed
|
|
720
|
+
* notes, keyed by file, for body checks and finding positions.
|
|
718
721
|
* @returns {Array<{file: string, line?: number, column?: number,
|
|
719
722
|
* severity: "error", message: string, type: string}>} One finding per note.
|
|
720
723
|
*/
|
|
721
|
-
export function unclaimedNoteFindings(
|
|
724
|
+
export function unclaimedNoteFindings(
|
|
725
|
+
config = loadPackConfig(),
|
|
726
|
+
sources,
|
|
727
|
+
{ records, parsed } = {},
|
|
728
|
+
) {
|
|
722
729
|
const resolved = resolveSources(sources);
|
|
723
730
|
const vocabulary = noteTypeVocabulary(resolved);
|
|
724
731
|
const findings = [];
|
|
@@ -752,17 +759,19 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
|
|
|
752
759
|
// Asked first, and asked whatever the type's claim turns out to be: a
|
|
753
760
|
// note whose documents all have packs may still carry a block none of
|
|
754
761
|
// them reads, and that is invisible to every question below.
|
|
755
|
-
findings.push(
|
|
762
|
+
findings.push(
|
|
763
|
+
...discardedSystemBlockFindings(fm, absPath, type, config, parsed?.get(absPath)?.raw),
|
|
764
|
+
);
|
|
756
765
|
|
|
757
766
|
// Every document this note produces, against the classes this
|
|
758
767
|
// configuration has a pack for. Three outcomes, and the middle one is
|
|
759
768
|
// the half-claimed one — invisible while the question is only "is anything
|
|
760
769
|
// claiming this note", because the answer there is yes.
|
|
761
770
|
const produces = documentClassesFor(type, resolved, {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
771
|
+
hasProse: () => {
|
|
772
|
+
const source = parsed?.get(absPath);
|
|
773
|
+
return source ? Boolean(source.body) : noteHasProse(absPath);
|
|
774
|
+
},
|
|
766
775
|
});
|
|
767
776
|
const missing = produces.filter((docType) => !configured.has(docType));
|
|
768
777
|
const compiled = produces.filter((docType) => configured.has(docType));
|
|
@@ -791,7 +800,7 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
|
|
|
791
800
|
if (compiled.length) {
|
|
792
801
|
findings.push({
|
|
793
802
|
file: absPath,
|
|
794
|
-
...locateFrontmatterKey(absPath, "type", type),
|
|
803
|
+
...locateFrontmatterKey(absPath, "type", type, { raw: parsed?.get(absPath)?.raw }),
|
|
795
804
|
severity: /** @type {"error"} */ ("error"),
|
|
796
805
|
type,
|
|
797
806
|
message: partialMessage(type, missing, compiled),
|
|
@@ -801,7 +810,7 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
|
|
|
801
810
|
|
|
802
811
|
findings.push({
|
|
803
812
|
file: absPath,
|
|
804
|
-
...locateFrontmatterKey(absPath, "type", type),
|
|
813
|
+
...locateFrontmatterKey(absPath, "type", type, { raw: parsed?.get(absPath)?.raw }),
|
|
805
814
|
severity: /** @type {"error"} */ ("error"),
|
|
806
815
|
type,
|
|
807
816
|
message:
|
package/engine/note-package.mjs
CHANGED
|
@@ -62,12 +62,13 @@ export function searchableFrontmatter(fm, configured) {
|
|
|
62
62
|
* @param {string} [options.absPath] - The note's file on disk, read only on the
|
|
63
63
|
* failing path to locate the offending line and column. The position rides on
|
|
64
64
|
* the thrown error as `position`, for a caller that emits a diagnostic.
|
|
65
|
+
* @param {string} [options.raw] - Source text already read by the caller.
|
|
65
66
|
* @param {string} [options.configured] - The package this build compiles.
|
|
66
67
|
* Defaults to the configured `contentPackage`.
|
|
67
68
|
* @returns {void}
|
|
68
69
|
* @throws {Error} When the note declares the field.
|
|
69
70
|
*/
|
|
70
|
-
export function assertNoDeclaredPackage(fm, { file, absPath, configured } = {}) {
|
|
71
|
+
export function assertNoDeclaredPackage(fm, { file, absPath, configured, raw } = {}) {
|
|
71
72
|
if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "package")) return;
|
|
72
73
|
|
|
73
74
|
const target = configured ?? contentPackage();
|
|
@@ -84,10 +85,7 @@ export function assertNoDeclaredPackage(fm, { file, absPath, configured } = {})
|
|
|
84
85
|
`\`contentPackage\` ("${target}", in package-build.config.yaml), ` +
|
|
85
86
|
`and every note in the tree belongs to it.`,
|
|
86
87
|
);
|
|
87
|
-
|
|
88
|
-
// to be deleted. Read here rather than carried through every walk: this is
|
|
89
|
-
// the failing path, and the build stops on it.
|
|
90
|
-
const position = locateFrontmatterKey(absPath, "package");
|
|
88
|
+
const position = locateFrontmatterKey(absPath, "package", undefined, { raw });
|
|
91
89
|
if (position) err.position = position;
|
|
92
90
|
throw err;
|
|
93
91
|
}
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
* @module
|
|
73
73
|
*/
|
|
74
74
|
|
|
75
|
+
import { checkSeeAlso } from "./see-also.mjs";
|
|
75
76
|
// The one charset, read rather than restated. A second spelling of the pattern
|
|
76
77
|
// is how a disagreement between the three arises.
|
|
77
78
|
import { ART_SLOTS } from "./art-slots.mjs";
|
|
@@ -203,7 +204,7 @@ import { checkSettlementTables } from "./settlement-tables.mjs";
|
|
|
203
204
|
* @property {"pack"} [keys] - For a `scalar-or-map` field, what its keys name.
|
|
204
205
|
* `"pack"` means each is a pack this package declares, so a key naming none
|
|
205
206
|
* is a finding of its own: it addresses a hierarchy nothing will ever read.
|
|
206
|
-
* @property {(note: object, opts: {index?: object}) => object[]} [check] - A
|
|
207
|
+
* @property {(note: object, opts: {index?: object, addressContext?: object}) => object[]} [check] - A
|
|
207
208
|
* check of the value beyond its shape, run by the frontmatter lint with the
|
|
208
209
|
* link index once the container has been checked. The lint stays
|
|
209
210
|
* vocabulary-agnostic this way: the rule lives beside the field that needs
|
|
@@ -1001,6 +1002,33 @@ export const NOTE_TOP_LEVEL_KEY_SET = Object.freeze(new Set(NOTE_TOP_LEVEL_KEYS)
|
|
|
1001
1002
|
* @type {readonly DataFieldSpec[]}
|
|
1002
1003
|
*/
|
|
1003
1004
|
export const SHARED_DATA_FIELDS = Object.freeze([
|
|
1005
|
+
Object.freeze({
|
|
1006
|
+
name: "seeAlso",
|
|
1007
|
+
kind: "list",
|
|
1008
|
+
shape: "list of `{ address, gloss? }`",
|
|
1009
|
+
entries: Object.freeze({
|
|
1010
|
+
kind: "map",
|
|
1011
|
+
shape: "`{ address, gloss? }`",
|
|
1012
|
+
fields: Object.freeze([
|
|
1013
|
+
Object.freeze({
|
|
1014
|
+
name: "address",
|
|
1015
|
+
kind: "address",
|
|
1016
|
+
shape: "a note Address",
|
|
1017
|
+
required: true,
|
|
1018
|
+
describe: "The note to read next.",
|
|
1019
|
+
}),
|
|
1020
|
+
Object.freeze({
|
|
1021
|
+
name: "gloss",
|
|
1022
|
+
kind: "string",
|
|
1023
|
+
shape: "a string",
|
|
1024
|
+
describe: "A short phrase saying why to follow the reference.",
|
|
1025
|
+
}),
|
|
1026
|
+
]),
|
|
1027
|
+
}),
|
|
1028
|
+
check: checkSeeAlso,
|
|
1029
|
+
describe:
|
|
1030
|
+
"Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books.",
|
|
1031
|
+
}),
|
|
1004
1032
|
Object.freeze({
|
|
1005
1033
|
name: "id",
|
|
1006
1034
|
...TEXT,
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -120,6 +120,7 @@ import {
|
|
|
120
120
|
import { artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs";
|
|
121
121
|
import { embedRole } from "./content-embeds.mjs";
|
|
122
122
|
import { expandContentTables } from "./content-tables.mjs";
|
|
123
|
+
import { seeAlsoEntries, seeAlsoMarkdown, seeAlsoWebLink } from "./see-also.mjs";
|
|
123
124
|
import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
|
|
124
125
|
import { eventNoteIndex } from "./event-fields.mjs";
|
|
125
126
|
import { numberFigures, scanFigures } from "./content-figures.mjs";
|
|
@@ -1645,7 +1646,13 @@ async function buildBook(book, shared) {
|
|
|
1645
1646
|
// Whether the entry is settled comes before anything it says: the
|
|
1646
1647
|
// notice leads the leaf, before the authored body.
|
|
1647
1648
|
const notice = isDraftNote(page.fm) ? draftNoticeTypst() : "";
|
|
1648
|
-
const
|
|
1649
|
+
const references = seeAlsoMarkdown(
|
|
1650
|
+
seeAlsoEntries(page.fm, (ref) => resolveInfoboxRef(gates.index, ref)),
|
|
1651
|
+
seeAlsoWebLink,
|
|
1652
|
+
);
|
|
1653
|
+
const parts = [notice, prose, panel, references ? typst(references, {}) : ""].filter(
|
|
1654
|
+
(part) => part,
|
|
1655
|
+
);
|
|
1649
1656
|
return parts.join("\n\n");
|
|
1650
1657
|
};
|
|
1651
1658
|
|
package/engine/prose-lint.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
sharedPrettierDivergence,
|
|
44
44
|
sharedPrettierOptionsFor,
|
|
45
45
|
} from "./prose-config.mjs";
|
|
46
|
+
import { markupFindings } from "./markup-validation.mjs";
|
|
46
47
|
import { MarkdownConfigError, runMarkdownlint } from "./markdownlint-run.mjs";
|
|
47
48
|
import { walkFiles } from "./walk-files.mjs";
|
|
48
49
|
|
|
@@ -152,6 +153,13 @@ export async function checkFormatting(root, opts = {}) {
|
|
|
152
153
|
filepath: file,
|
|
153
154
|
};
|
|
154
155
|
const source = fs.readFileSync(file, "utf8");
|
|
156
|
+
if (["markdown", "mdx"].includes(info.inferredParser)) {
|
|
157
|
+
const syntaxErrors = markupFindings(source, file);
|
|
158
|
+
if (syntaxErrors.length) {
|
|
159
|
+
findings.push(...syntaxErrors);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
155
163
|
|
|
156
164
|
// A file Prettier cannot parse is a finding, not a crash. Its own CLI
|
|
157
165
|
// reports the syntax error and carries on to the next file, and a
|
|
@@ -399,9 +407,9 @@ export async function lintMarkdown(root, opts = {}) {
|
|
|
399
407
|
const { paths, fix = false } = opts;
|
|
400
408
|
const directory = path.resolve(root);
|
|
401
409
|
|
|
402
|
-
let results;
|
|
410
|
+
let results, markup;
|
|
403
411
|
try {
|
|
404
|
-
({ results } = await runMarkdownlint({
|
|
412
|
+
({ results, markup } = await runMarkdownlint({
|
|
405
413
|
directory,
|
|
406
414
|
globs: paths,
|
|
407
415
|
fix,
|
|
@@ -430,8 +438,6 @@ export async function lintMarkdown(root, opts = {}) {
|
|
|
430
438
|
};
|
|
431
439
|
}
|
|
432
440
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
exitCode: results.length ? 1 : 0,
|
|
436
|
-
};
|
|
441
|
+
const findings = [...results.map((result) => toDiagnostic(directory, result)), ...markup];
|
|
442
|
+
return { findings, exitCode: findings.length ? 1 : 0 };
|
|
437
443
|
}
|
package/engine/related-pages.mjs
CHANGED
|
@@ -27,11 +27,12 @@
|
|
|
27
27
|
* `<type>-<shortcode>` form: the site index keys one page under both, and both
|
|
28
28
|
* answer with one URL.
|
|
29
29
|
*
|
|
30
|
-
* **Only a page of this site is an endpoint.** A link into another package
|
|
30
|
+
* **Only a page of this site is an inferred endpoint.** A link into another package
|
|
31
31
|
* resolves to a URL the build renders but no page it writes, so it appears
|
|
32
32
|
* on neither side: a backlink can only be counted on a page this build emits,
|
|
33
33
|
* and listing a foreign mention beside local ones would make the two lists
|
|
34
|
-
* disagree about what a connection is.
|
|
34
|
+
* disagree about what a connection is. Deliberate see-also references also
|
|
35
|
+
* name pages in declared dependencies.
|
|
35
36
|
*
|
|
36
37
|
* @module
|
|
37
38
|
*/
|
|
@@ -46,6 +47,7 @@ import { homepageAddresses } from "./homepage.mjs";
|
|
|
46
47
|
* @property {string} url `<base><slug>/` — the page's address as every href
|
|
47
48
|
* this build renders composes it, never the
|
|
48
49
|
* site-root-relative form the page states for itself.
|
|
50
|
+
* @property {string} [gloss] Why the author recommends this page.
|
|
49
51
|
* @property {string} type The note's `type`, which the theme groups by.
|
|
50
52
|
*/
|
|
51
53
|
|
|
@@ -53,6 +55,7 @@ import { homepageAddresses } from "./homepage.mjs";
|
|
|
53
55
|
* The `related` block one page publishes.
|
|
54
56
|
*
|
|
55
57
|
* @typedef {object} Related
|
|
58
|
+
* @property {RelatedEntry[]} [seeAlso] Deliberate references in authored order.
|
|
56
59
|
* @property {RelatedEntry[]} backlinks Pages that link to this one.
|
|
57
60
|
* @property {RelatedEntry[]} mentions Pages this one links to.
|
|
58
61
|
*/
|
|
@@ -89,10 +92,11 @@ function byTypeThenTitle(a, b) {
|
|
|
89
92
|
* the source page's URL and the target page's.
|
|
90
93
|
* @param {ReadonlyMap<string, RelatedEntry>} entries - Every page of this
|
|
91
94
|
* site, keyed by URL.
|
|
95
|
+
* @param {ReadonlyMap<string, RelatedEntry[]>} [deliberate] Authored references, including foreign destinations.
|
|
92
96
|
* @returns {Map<string, Related>} URL → the page's `related` block, both lists
|
|
93
97
|
* sorted, for every page with at least one entry in either.
|
|
94
98
|
*/
|
|
95
|
-
export function relatedPages(edges, entries) {
|
|
99
|
+
export function relatedPages(edges, entries, deliberate = new Map()) {
|
|
96
100
|
/** @type {Map<string, {backlinks: Set<string>, mentions: Set<string>}>} */
|
|
97
101
|
const graph = new Map();
|
|
98
102
|
const node = (url) => {
|
|
@@ -120,6 +124,21 @@ export function relatedPages(edges, entries) {
|
|
|
120
124
|
for (const [url, { backlinks, mentions }] of graph) {
|
|
121
125
|
out.set(url, { backlinks: list(backlinks), mentions: list(mentions) });
|
|
122
126
|
}
|
|
127
|
+
for (const [url, refs] of deliberate) {
|
|
128
|
+
const seen = new Set([url]);
|
|
129
|
+
const seeAlso = refs.filter((ref) => {
|
|
130
|
+
if (seen.has(ref.url)) return false;
|
|
131
|
+
seen.add(ref.url);
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
if (!seeAlso.length) continue;
|
|
135
|
+
const block = out.get(url) ?? { backlinks: [], mentions: [] };
|
|
136
|
+
out.set(url, {
|
|
137
|
+
seeAlso,
|
|
138
|
+
backlinks: block.backlinks.filter((ref) => !seen.has(ref.url)),
|
|
139
|
+
mentions: block.mentions.filter((ref) => !seen.has(ref.url)),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
123
142
|
return out;
|
|
124
143
|
}
|
|
125
144
|
|
|
@@ -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;
|