@heroiclands/package-build 23.7.1 → 23.9.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 +41 -0
- package/README.md +32 -0
- package/bin/content-commands.mjs +32 -10
- package/ci/check-banners.mjs +37 -19
- package/container.mjs +48 -10
- package/docs/api.md +402 -58
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/frontmatter.md +30 -1
- package/docs/authoring/links-and-markup.md +154 -56
- package/docs/commands.md +31 -12
- package/docs/content-format.md +25 -2
- package/docs/design.md +31 -0
- package/docs/diagnostics.md +1 -1
- package/docs/guides/e2e.md +37 -1
- package/docs/guides/packs.md +2 -1
- package/docs/guides/reference-pages.md +86 -63
- package/docs/project-setup.md +30 -15
- package/docs/reference/format-details.md +313 -132
- package/docs/reference/note-types.md +191 -174
- package/e2e.mjs +89 -2
- package/engine/anchors.mjs +7 -20
- package/engine/base-compiler.mjs +11 -18
- package/engine/compile-corpus.mjs +5 -3
- package/engine/content-alerts.mjs +4 -2
- package/engine/content-blocks.mjs +18 -36
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +447 -33
- package/engine/content-links.mjs +43 -15
- package/engine/content-poetry.mjs +9 -0
- package/engine/content-tables.mjs +8 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/diagnostics.mjs +24 -11
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/foundry-entries.mjs +21 -3
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/generate.mjs +12 -22
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +20 -12
- package/engine/infobox.mjs +3 -0
- package/engine/journals.mjs +15 -2
- package/engine/language-data.mjs +53 -0
- package/engine/language-terms.mjs +86 -0
- package/engine/language-words.mjs +2 -2
- package/engine/lexical-sections.mjs +247 -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 +21 -10
- package/engine/note-package.mjs +3 -5
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +36 -7
- package/engine/pdf-build.mjs +17 -10
- 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 +21 -3
- package/engine/site-preview.mjs +4 -2
- package/engine/sql-tables.mjs +287 -53
- package/engine/standings.mjs +14 -12
- package/engine/stub-lint.mjs +6 -2
- package/engine/web-wikilinks.mjs +8 -6
- package/engine/word-rows.mjs +20 -12
- package/engine/word-terms.mjs +15 -6
- 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 +85 -8
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/culture-reach.d.mts +22 -2
- package/types/engine/data-keys.d.mts +7 -0
- package/types/engine/diagnostics.d.mts +7 -6
- package/types/engine/dictionary-layout.d.mts +35 -4
- package/types/engine/dictionary-rows.d.mts +9 -0
- package/types/engine/foundry-entries.d.mts +3 -1
- package/types/engine/heading-attributes.d.mts +2 -2
- package/types/engine/helpers.d.mts +11 -5
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +95 -0
- 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-state.d.mts +2 -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/site-build.d.mts +4 -1
- package/types/engine/sql-tables.d.mts +40 -6
- package/types/engine/word-rows.d.mts +9 -7
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Automatic dictionaries and glossaries, derived synchronously from the same
|
|
16
|
+
* rows their SQL layouts read. An existing anchored section receives its list
|
|
17
|
+
* after its authored text; otherwise the note receives a new final section.
|
|
18
|
+
* The insertion and the publisher read the same Markdown, on every surface.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { renderAddress } from "./address.mjs";
|
|
24
|
+
import { encodeAddresses } from "./address-values.mjs";
|
|
25
|
+
import { scanBlocks } from "./content-blocks.mjs";
|
|
26
|
+
import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
|
|
27
|
+
import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
|
|
28
|
+
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
29
|
+
import { scanHeadingAttributes } from "./heading-attributes.mjs";
|
|
30
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
31
|
+
|
|
32
|
+
/** The automatic lexical sections and the authored anchors each may reuse. */
|
|
33
|
+
export const LEXICAL_SECTIONS = Object.freeze(
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
slug: "dictionary",
|
|
37
|
+
heading: "Dictionary",
|
|
38
|
+
aliases: ["dictionary", "lexicon"],
|
|
39
|
+
dataKey: "words",
|
|
40
|
+
types: [{ type: "skill", subType: "language" }],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
slug: "glossary",
|
|
44
|
+
heading: "Glossary",
|
|
45
|
+
aliases: ["glossary"],
|
|
46
|
+
dataKey: "glossary",
|
|
47
|
+
types: [{ type: "lore", subType: "culture" }, { type: "affiliation" }],
|
|
48
|
+
},
|
|
49
|
+
].map((section) =>
|
|
50
|
+
Object.freeze({
|
|
51
|
+
...section,
|
|
52
|
+
aliases: Object.freeze(section.aliases),
|
|
53
|
+
types: Object.freeze(section.types.map((kind) => Object.freeze(kind))),
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** The lexical section this note's own nonempty frontmatter requests. */
|
|
59
|
+
function sectionFor(record) {
|
|
60
|
+
return LEXICAL_SECTIONS.find(
|
|
61
|
+
(section) =>
|
|
62
|
+
section.types.some(
|
|
63
|
+
(kind) =>
|
|
64
|
+
record?.type === kind.type &&
|
|
65
|
+
(!kind.subType || record.subType === kind.subType),
|
|
66
|
+
) &&
|
|
67
|
+
Array.isArray(record.data?.[section.dataKey]) &&
|
|
68
|
+
record.data[section.dataKey].length > 0,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether frontmatter supplies an automatic lexical section, including on a
|
|
74
|
+
* note with no authored prose. Such a note has content and is not a stub.
|
|
75
|
+
*
|
|
76
|
+
* @param {object|null|undefined} frontmatter - A note or its index record.
|
|
77
|
+
* @returns {boolean} Whether its own nonempty words or glossary qualify.
|
|
78
|
+
*/
|
|
79
|
+
export function hasLexicalEntries(frontmatter) {
|
|
80
|
+
return Boolean(sectionFor(frontmatter));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A note's canonical identity, including a data-only note that was a stub. */
|
|
84
|
+
const keyOf = (record) =>
|
|
85
|
+
record?.package && record.type && record.shortcode ?
|
|
86
|
+
renderAddress({
|
|
87
|
+
package: record.package,
|
|
88
|
+
system: "note",
|
|
89
|
+
type: record.type,
|
|
90
|
+
shortcode: record.shortcode,
|
|
91
|
+
})
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One automatic section, whose rows are rendered at its containing heading's
|
|
96
|
+
* level when inserted.
|
|
97
|
+
*
|
|
98
|
+
* @typedef {object} LexicalSection
|
|
99
|
+
* @property {string} slug - The canonical section anchor and layout name.
|
|
100
|
+
* @property {string} heading - The heading used when none is authored.
|
|
101
|
+
* @property {readonly string[]} aliases - Authored heading anchors to reuse.
|
|
102
|
+
* @property {{columns: string[], rows: object[]}} result - The selected rows.
|
|
103
|
+
* @property {import("./dictionary-layout.mjs").LayoutOptions} options - Labels,
|
|
104
|
+
* linkability and the page's language, shared by every surface.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Automatic lexical sections by owning note's canonical Address. Only
|
|
109
|
+
* nonempty own `words` or `glossary` arrays qualify. A glossary selects only
|
|
110
|
+
* explicitly declared owner entries, including a rank only when named there.
|
|
111
|
+
*
|
|
112
|
+
* @param {object[]} records - The audience's visible index records.
|
|
113
|
+
* @param {object[]} [all=records] - Complete records for lineage traversal.
|
|
114
|
+
* @returns {Map<string, LexicalSection[]>} Sections by owning Address.
|
|
115
|
+
*/
|
|
116
|
+
export function lexicalSectionsFor(records, all = records) {
|
|
117
|
+
const visible = encodeAddresses(records).filter(isNoteRecord);
|
|
118
|
+
const complete = encodeAddresses(all).filter(isNoteRecord);
|
|
119
|
+
const byKey = new Map(visible.map((record) => [keyOf(record), record]));
|
|
120
|
+
const addressContext = {
|
|
121
|
+
types: new Set(complete.map((record) => record.type)),
|
|
122
|
+
packages: new Set(complete.map((record) => record.package)),
|
|
123
|
+
system: "note",
|
|
124
|
+
};
|
|
125
|
+
const { dictionary, terms } = dictionaryAndTermRows(visible, complete);
|
|
126
|
+
const sections = new Map();
|
|
127
|
+
for (const [key, record] of byKey) {
|
|
128
|
+
if (!key) continue;
|
|
129
|
+
const spec = sectionFor(record);
|
|
130
|
+
if (!spec) continue;
|
|
131
|
+
const language = spec.slug === "dictionary";
|
|
132
|
+
const rows =
|
|
133
|
+
language ?
|
|
134
|
+
dictionary.filter((row) => row.language === key)
|
|
135
|
+
: terms.filter((row) => row.owner === key && row.origins.includes("glossary"));
|
|
136
|
+
const pageLanguage = glossaryLanguage(undefined, record, {
|
|
137
|
+
self: key,
|
|
138
|
+
keyOf: (value) => (typeof value === "string" ? value : undefined),
|
|
139
|
+
lookup: (address) => byKey.get(address),
|
|
140
|
+
});
|
|
141
|
+
sections.set(key, [
|
|
142
|
+
{
|
|
143
|
+
slug: spec.slug,
|
|
144
|
+
heading: spec.heading,
|
|
145
|
+
aliases: spec.aliases,
|
|
146
|
+
result: { columns: [...(language ? DICTIONARY_COLUMNS : TERM_COLUMNS)], rows },
|
|
147
|
+
options: {
|
|
148
|
+
addressContext: { ...addressContext, package: record.package },
|
|
149
|
+
pageLanguage,
|
|
150
|
+
pageOwner: key,
|
|
151
|
+
...(language ? { verbs: record.data?.grammar?.verbs } : {}),
|
|
152
|
+
labelOf: (address) => byKey.get(address)?.name?.full,
|
|
153
|
+
linkable: (ref) => {
|
|
154
|
+
const target = byKey.get(renderAddress({ ...ref, system: "note" }));
|
|
155
|
+
return Boolean(target && (target.address || hasLexicalEntries(target)));
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
return sections;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Authored section headings outside literal content and named containers. */
|
|
165
|
+
function headings(markdown) {
|
|
166
|
+
const containers = scanBlocks(markdown).blocks;
|
|
167
|
+
return scanHeadingAttributes(markdown)
|
|
168
|
+
.headings.filter(
|
|
169
|
+
(heading) =>
|
|
170
|
+
!containers.some(
|
|
171
|
+
(block) => heading.line - 1 >= block.start && heading.line - 1 <= block.end,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
.map((heading) => ({ line: heading.line - 1, level: heading.level, slug: heading.id }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Insert lists at the end of their authored anchored sections, or append a
|
|
179
|
+
* new H1 section when no heading names one. Source lines after an insertion
|
|
180
|
+
* retain their original positions; inserted lines point to their heading.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} markdown - The body with authored directives expanded.
|
|
183
|
+
* @param {LexicalSection[]} [sections=[]] - From {@link lexicalSectionsFor}.
|
|
184
|
+
* @param {{authored?: string, lineMap?: object[], generatedFrom?: number}} [context] -
|
|
185
|
+
* The original authored body, source mappings and the start of appended
|
|
186
|
+
* nonlexical generated sections. Without `authored`, `markdown` is the source.
|
|
187
|
+
* @returns {{markdown: string, lineMap: object[], generatedFrom?: number}}
|
|
188
|
+
*/
|
|
189
|
+
export function insertLexicalSections(
|
|
190
|
+
markdown,
|
|
191
|
+
sections = [],
|
|
192
|
+
{ authored = markdown, lineMap, generatedFrom } = {},
|
|
193
|
+
) {
|
|
194
|
+
const lines = String(markdown ?? "").split("\n");
|
|
195
|
+
const locations = lineMap ? [...lineMap] : lines.map((_, line) => ({ line, generated: false }));
|
|
196
|
+
if (!sections.length)
|
|
197
|
+
return {
|
|
198
|
+
markdown: lines.join("\n"),
|
|
199
|
+
lineMap: locations,
|
|
200
|
+
...(generatedFrom === undefined ? {} : { generatedFrom }),
|
|
201
|
+
};
|
|
202
|
+
const edits = [];
|
|
203
|
+
const realHeadings = headings(String(authored ?? ""))
|
|
204
|
+
.map((heading) => ({
|
|
205
|
+
...heading,
|
|
206
|
+
sourceLine: heading.line,
|
|
207
|
+
line: locations.findIndex(
|
|
208
|
+
(location, at) =>
|
|
209
|
+
location.line === heading.line &&
|
|
210
|
+
!location.generated &&
|
|
211
|
+
(generatedFrom === undefined || at < generatedFrom),
|
|
212
|
+
),
|
|
213
|
+
}))
|
|
214
|
+
.filter((heading) => heading.line >= 0);
|
|
215
|
+
for (const section of sections) {
|
|
216
|
+
const own = realHeadings.find((heading) => section.aliases.includes(heading.slug));
|
|
217
|
+
const next =
|
|
218
|
+
own &&
|
|
219
|
+
realHeadings.find((heading) => heading.line > own.line && heading.level <= own.level);
|
|
220
|
+
const at = next?.line ?? (own ? (generatedFrom ?? lines.length) : lines.length);
|
|
221
|
+
const level = own?.level ?? 1;
|
|
222
|
+
const render = section.slug === "dictionary" ? renderDictionary : renderGlossary;
|
|
223
|
+
const list = render(section.result, {
|
|
224
|
+
...section.options,
|
|
225
|
+
sectionLevel: Math.min(6, level + 1),
|
|
226
|
+
});
|
|
227
|
+
if (!list && own) continue;
|
|
228
|
+
const text = `${own ? "" : `# ${section.heading} {#${section.slug}}\n\n`}${list}`;
|
|
229
|
+
edits.push({
|
|
230
|
+
at,
|
|
231
|
+
source: own?.sourceLine ?? Math.max(0, String(authored ?? "").split("\n").length - 1),
|
|
232
|
+
lines: ["", ...text.split("\n"), ""],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
for (const edit of edits.sort((a, b) => b.at - a.at)) {
|
|
236
|
+
const location = { line: edit.source, generated: true };
|
|
237
|
+
lines.splice(edit.at, 0, ...edit.lines);
|
|
238
|
+
locations.splice(edit.at, 0, ...edit.lines.map(() => ({ ...location })));
|
|
239
|
+
if (generatedFrom !== undefined && edit.at <= generatedFrom)
|
|
240
|
+
generatedFrom += edit.lines.length;
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
markdown: lines.join("\n"),
|
|
244
|
+
lineMap: locations,
|
|
245
|
+
...(generatedFrom === undefined ? {} : { generatedFrom }),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -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
|
@@ -105,6 +105,7 @@ import { HOMEPAGE_TYPE } from "./homepage.mjs";
|
|
|
105
105
|
import { FOLDER_TYPE } from "./folder-notes.mjs";
|
|
106
106
|
import { BUNDLE_TYPE } from "./bundle-notes.mjs";
|
|
107
107
|
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
108
|
+
import { hasLexicalEntries } from "./lexical-sections.mjs";
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
111
|
* Note types that compile into **no compendium document, by design**.
|
|
@@ -666,11 +667,12 @@ function packsServing(config, docType, system) {
|
|
|
666
667
|
* @param {string} absPath - The note's path, for the finding's position.
|
|
667
668
|
* @param {string} type - The note's type.
|
|
668
669
|
* @param {object} config - The resolved build configuration.
|
|
670
|
+
* @param {string} [raw] - Source text already read by the caller.
|
|
669
671
|
* @returns {Array<{file: string, line?: number, column?: number,
|
|
670
672
|
* severity: "error", message: string, type: string}>} One finding per
|
|
671
673
|
* discarded block.
|
|
672
674
|
*/
|
|
673
|
-
function discardedSystemBlockFindings(fm, absPath, type, config) {
|
|
675
|
+
function discardedSystemBlockFindings(fm, absPath, type, config, raw) {
|
|
674
676
|
const findings = [];
|
|
675
677
|
for (const system of SHIPPED_SYSTEMS) {
|
|
676
678
|
if (!carriesSystemBlock(fm, system)) continue;
|
|
@@ -683,7 +685,7 @@ function discardedSystemBlockFindings(fm, absPath, type, config) {
|
|
|
683
685
|
if (!packs || serving) continue;
|
|
684
686
|
findings.push({
|
|
685
687
|
file: absPath,
|
|
686
|
-
...locateFrontmatterKey(absPath, system, undefined, { topLevel: true }),
|
|
688
|
+
...locateFrontmatterKey(absPath, system, undefined, { topLevel: true, raw }),
|
|
687
689
|
severity: /** @type {"error"} */ ("error"),
|
|
688
690
|
type,
|
|
689
691
|
message:
|
|
@@ -715,10 +717,16 @@ function discardedSystemBlockFindings(fm, absPath, type, config) {
|
|
|
715
717
|
* @param {object} [opts] - Options.
|
|
716
718
|
* @param {readonly object[]} [opts.records] - The corpus, derived once by the
|
|
717
719
|
* compile and handed in — required, for the reason above.
|
|
720
|
+
* @param {Map<string, {body: string, raw: string}>} [opts.parsed] - Already parsed
|
|
721
|
+
* notes, keyed by file, for body checks and finding positions.
|
|
718
722
|
* @returns {Array<{file: string, line?: number, column?: number,
|
|
719
723
|
* severity: "error", message: string, type: string}>} One finding per note.
|
|
720
724
|
*/
|
|
721
|
-
export function unclaimedNoteFindings(
|
|
725
|
+
export function unclaimedNoteFindings(
|
|
726
|
+
config = loadPackConfig(),
|
|
727
|
+
sources,
|
|
728
|
+
{ records, parsed } = {},
|
|
729
|
+
) {
|
|
722
730
|
const resolved = resolveSources(sources);
|
|
723
731
|
const vocabulary = noteTypeVocabulary(resolved);
|
|
724
732
|
const findings = [];
|
|
@@ -752,17 +760,20 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
|
|
|
752
760
|
// Asked first, and asked whatever the type's claim turns out to be: a
|
|
753
761
|
// note whose documents all have packs may still carry a block none of
|
|
754
762
|
// them reads, and that is invisible to every question below.
|
|
755
|
-
findings.push(
|
|
763
|
+
findings.push(
|
|
764
|
+
...discardedSystemBlockFindings(fm, absPath, type, config, parsed?.get(absPath)?.raw),
|
|
765
|
+
);
|
|
756
766
|
|
|
757
767
|
// Every document this note produces, against the classes this
|
|
758
768
|
// configuration has a pack for. Three outcomes, and the middle one is
|
|
759
769
|
// the half-claimed one — invisible while the question is only "is anything
|
|
760
770
|
// claiming this note", because the answer there is yes.
|
|
761
771
|
const produces = documentClassesFor(type, resolved, {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
772
|
+
hasProse: () => {
|
|
773
|
+
if (hasLexicalEntries(fm)) return true;
|
|
774
|
+
const source = parsed?.get(absPath);
|
|
775
|
+
return source ? Boolean(source.body) : noteHasProse(absPath);
|
|
776
|
+
},
|
|
766
777
|
});
|
|
767
778
|
const missing = produces.filter((docType) => !configured.has(docType));
|
|
768
779
|
const compiled = produces.filter((docType) => configured.has(docType));
|
|
@@ -791,7 +802,7 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
|
|
|
791
802
|
if (compiled.length) {
|
|
792
803
|
findings.push({
|
|
793
804
|
file: absPath,
|
|
794
|
-
...locateFrontmatterKey(absPath, "type", type),
|
|
805
|
+
...locateFrontmatterKey(absPath, "type", type, { raw: parsed?.get(absPath)?.raw }),
|
|
795
806
|
severity: /** @type {"error"} */ ("error"),
|
|
796
807
|
type,
|
|
797
808
|
message: partialMessage(type, missing, compiled),
|
|
@@ -801,7 +812,7 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
|
|
|
801
812
|
|
|
802
813
|
findings.push({
|
|
803
814
|
file: absPath,
|
|
804
|
-
...locateFrontmatterKey(absPath, "type", type),
|
|
815
|
+
...locateFrontmatterKey(absPath, "type", type, { raw: parsed?.get(absPath)?.raw }),
|
|
805
816
|
severity: /** @type {"error"} */ ("error"),
|
|
806
817
|
type,
|
|
807
818
|
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
|
}
|
package/engine/note-state.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* The three states of a note, and the one observable fact they rest on.
|
|
16
16
|
*
|
|
17
|
-
* A note whose body is empty is a **stub**: frontmatter only — identity and
|
|
17
|
+
* A note whose body is empty and has no automatic lexical content is a **stub**: frontmatter only — identity and
|
|
18
18
|
* statistics — reaching the content index and queryable there, generating no
|
|
19
19
|
* page, no address and no link target. A note with a body and the declared
|
|
20
20
|
* `draft` tag is a **draft**: it publishes, compiles and resolves like any
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
42
|
+
import { hasLexicalEntries } from "./lexical-sections.mjs";
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* The three states, in the order they read as a ladder.
|
|
@@ -97,7 +98,8 @@ export function isStubbableType(type, vocabulary = NOTE_VOCABULARY) {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
|
-
* Whether a note is a stub: an empty body on a type an empty body suppresses
|
|
101
|
+
* Whether a note is a stub: an empty body on a type an empty body suppresses,
|
|
102
|
+
* without an automatic dictionary or glossary supplied by its frontmatter.
|
|
101
103
|
*
|
|
102
104
|
* The one place the two halves are put together, so the index, the lint and the
|
|
103
105
|
* guard cannot answer it differently.
|
|
@@ -109,7 +111,11 @@ export function isStubbableType(type, vocabulary = NOTE_VOCABULARY) {
|
|
|
109
111
|
* @returns {boolean} Whether the note is a stub.
|
|
110
112
|
*/
|
|
111
113
|
export function isStubNote(frontmatter, body, vocabulary = NOTE_VOCABULARY) {
|
|
112
|
-
return
|
|
114
|
+
return (
|
|
115
|
+
isEmptyBody(body) &&
|
|
116
|
+
!hasLexicalEntries(frontmatter) &&
|
|
117
|
+
isStubbableType(frontmatter?.type, vocabulary)
|
|
118
|
+
);
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
/**
|
|
@@ -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
|
|
@@ -599,7 +600,7 @@ const WORD_DATA_FIELDS = Object.freeze([
|
|
|
599
600
|
fields: WORD_FIELDS,
|
|
600
601
|
}),
|
|
601
602
|
describe:
|
|
602
|
-
"The language
|
|
603
|
+
"The words this language states, one map each, in authored order; its automatic dictionary sorts them alphabetically. A language skill " +
|
|
603
604
|
"note only. See [a language's words](format-details.md#a-languages-words).",
|
|
604
605
|
}),
|
|
605
606
|
Object.freeze({
|
|
@@ -670,9 +671,10 @@ const GLOSSARY_FIELD = Object.freeze({
|
|
|
670
671
|
fields: GLOSSARY_ENTRY_FIELDS,
|
|
671
672
|
}),
|
|
672
673
|
describe:
|
|
673
|
-
"Its own glossary
|
|
674
|
-
"
|
|
675
|
-
"
|
|
674
|
+
"Its own glossary entries: common vocabulary, dictionary words with a contextual " +
|
|
675
|
+
"meaning, and phrases, compounds or terms of art. A named dictionary word supplies " +
|
|
676
|
+
"its senses and class unless `definition` supplies the sense. Common marks belong " +
|
|
677
|
+
"to this owner. Every entry resolves to a language. " +
|
|
676
678
|
"Empty when unstated. See [glossary terms](format-details.md#glossary-terms).",
|
|
677
679
|
});
|
|
678
680
|
|
|
@@ -1001,6 +1003,33 @@ export const NOTE_TOP_LEVEL_KEY_SET = Object.freeze(new Set(NOTE_TOP_LEVEL_KEYS)
|
|
|
1001
1003
|
* @type {readonly DataFieldSpec[]}
|
|
1002
1004
|
*/
|
|
1003
1005
|
export const SHARED_DATA_FIELDS = Object.freeze([
|
|
1006
|
+
Object.freeze({
|
|
1007
|
+
name: "seeAlso",
|
|
1008
|
+
kind: "list",
|
|
1009
|
+
shape: "list of `{ address, gloss? }`",
|
|
1010
|
+
entries: Object.freeze({
|
|
1011
|
+
kind: "map",
|
|
1012
|
+
shape: "`{ address, gloss? }`",
|
|
1013
|
+
fields: Object.freeze([
|
|
1014
|
+
Object.freeze({
|
|
1015
|
+
name: "address",
|
|
1016
|
+
kind: "address",
|
|
1017
|
+
shape: "a note Address",
|
|
1018
|
+
required: true,
|
|
1019
|
+
describe: "The note to read next.",
|
|
1020
|
+
}),
|
|
1021
|
+
Object.freeze({
|
|
1022
|
+
name: "gloss",
|
|
1023
|
+
kind: "string",
|
|
1024
|
+
shape: "a string",
|
|
1025
|
+
describe: "A short phrase saying why to follow the reference.",
|
|
1026
|
+
}),
|
|
1027
|
+
]),
|
|
1028
|
+
}),
|
|
1029
|
+
check: checkSeeAlso,
|
|
1030
|
+
describe:
|
|
1031
|
+
"Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books.",
|
|
1032
|
+
}),
|
|
1004
1033
|
Object.freeze({
|
|
1005
1034
|
name: "id",
|
|
1006
1035
|
...TEXT,
|
|
@@ -1792,8 +1821,8 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1792
1821
|
...LANGUAGE_FIELD,
|
|
1793
1822
|
describe:
|
|
1794
1823
|
"The trade, temple or order tongue the affiliation keeps, as a language " +
|
|
1795
|
-
"skill note.
|
|
1796
|
-
"
|
|
1824
|
+
"skill note. A glossary entry that states no `language` is in it. The " +
|
|
1825
|
+
"affiliation chooses its own common vocabulary and context meanings.",
|
|
1797
1826
|
},
|
|
1798
1827
|
GLOSSARY_FIELD,
|
|
1799
1828
|
{
|
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";
|
|
@@ -1012,7 +1013,15 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
|
|
|
1012
1013
|
scheme,
|
|
1013
1014
|
};
|
|
1014
1015
|
const collected = collectContentPages(contentBase, ctx);
|
|
1015
|
-
const
|
|
1016
|
+
const sqlTables = await prepareTreeSqlTables(contentBase, {
|
|
1017
|
+
config: resolved,
|
|
1018
|
+
skipDirectories: resolved.skipDirectories,
|
|
1019
|
+
records,
|
|
1020
|
+
parsed,
|
|
1021
|
+
database: db,
|
|
1022
|
+
audience: "public",
|
|
1023
|
+
});
|
|
1024
|
+
const gates = siteGates(collected.pages, collected, { config: resolved, records, sqlTables });
|
|
1016
1025
|
if (gatesFailed(gates)) {
|
|
1017
1026
|
for (const [name, list] of Object.entries(gates)) {
|
|
1018
1027
|
if (!Array.isArray(list)) continue;
|
|
@@ -1029,14 +1038,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
|
|
|
1029
1038
|
|
|
1030
1039
|
const byFile = new Map(collected.pages.map((page) => [page.file, page]));
|
|
1031
1040
|
const universe = tableUniverse(collected.pages);
|
|
1032
|
-
const sqlTables = await prepareTreeSqlTables(contentBase, {
|
|
1033
|
-
config: resolved,
|
|
1034
|
-
skipDirectories: resolved.skipDirectories,
|
|
1035
|
-
records,
|
|
1036
|
-
parsed,
|
|
1037
|
-
database: db,
|
|
1038
|
-
audience: "public",
|
|
1039
|
-
});
|
|
1040
1041
|
// The address space an `![[…]]` embed resolves against — the same one the
|
|
1041
1042
|
// site builds, so one authored picture reaches both surfaces or neither.
|
|
1042
1043
|
const assets = assetAddressIndex(records, {
|
|
@@ -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
|
|