@heroiclands/package-build 23.8.0 → 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 +27 -0
- package/bin/content-commands.mjs +12 -4
- package/docs/api.md +328 -28
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/links-and-markup.md +140 -56
- package/docs/commands.md +19 -10
- package/docs/content-format.md +2 -2
- package/docs/diagnostics.md +1 -1
- package/docs/guides/packs.md +2 -1
- package/docs/guides/reference-pages.md +86 -63
- package/docs/reference/format-details.md +269 -123
- package/docs/reference/note-types.md +184 -170
- package/engine/anchors.mjs +7 -20
- package/engine/compile-corpus.mjs +3 -2
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +412 -11
- package/engine/content-links.mjs +43 -15
- package/engine/content-tables.mjs +8 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/foundry-entries.mjs +21 -3
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +12 -6
- package/engine/journals.mjs +4 -1
- 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/note-claims.mjs +2 -0
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +7 -6
- package/engine/pdf-build.mjs +9 -9
- package/engine/site-build.mjs +16 -2
- package/engine/sql-tables.mjs +262 -43
- 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/package.json +1 -1
- package/types/engine/content-index.d.mts +65 -1
- 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/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 +4 -1
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +95 -0
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/site-build.d.mts +4 -1
- package/types/engine/sql-tables.d.mts +33 -3
- package/types/engine/word-rows.d.mts +9 -7
package/engine/language-data.mjs
CHANGED
|
@@ -160,6 +160,59 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
160
160
|
|
|
161
161
|
const findings = [];
|
|
162
162
|
|
|
163
|
+
// Only the shape of a supplied declaration: no word is compared with it.
|
|
164
|
+
const grammar = isMap(data.grammar) ? data.grammar : {};
|
|
165
|
+
listOf(grammar.plural).forEach((row, i) => {
|
|
166
|
+
if (row == null || (Array.isArray(row) && row.length === 0))
|
|
167
|
+
findings.push(
|
|
168
|
+
finding(
|
|
169
|
+
["data", "grammar", "plural", i],
|
|
170
|
+
"error",
|
|
171
|
+
`\`data.grammar.plural[${i}]\` must be a map with ending and becomes strings`,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
const verbs = grammar.verbs;
|
|
176
|
+
if (Array.isArray(verbs) && verbs.length === 0)
|
|
177
|
+
findings.push(
|
|
178
|
+
finding(
|
|
179
|
+
["data", "grammar", "verbs"],
|
|
180
|
+
"error",
|
|
181
|
+
"`data.grammar.verbs` must state `invariant: true` or the six `persons` strings",
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
if (isMap(verbs)) {
|
|
185
|
+
const invariant = Object.hasOwn(verbs, "invariant");
|
|
186
|
+
const persons = Object.hasOwn(verbs, "persons");
|
|
187
|
+
if (invariant === persons)
|
|
188
|
+
findings.push(
|
|
189
|
+
finding(
|
|
190
|
+
["data", "grammar", "verbs"],
|
|
191
|
+
"error",
|
|
192
|
+
"`data.grammar.verbs` must state exactly one of `invariant: true` or `persons`",
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
else if (invariant && (verbs.invariant == null || verbs.invariant === false))
|
|
196
|
+
findings.push(
|
|
197
|
+
finding(
|
|
198
|
+
["data", "grammar", "verbs", "invariant"],
|
|
199
|
+
"error",
|
|
200
|
+
"`data.grammar.verbs.invariant` must be true",
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
else if (
|
|
204
|
+
persons &&
|
|
205
|
+
(verbs.persons == null || (Array.isArray(verbs.persons) && verbs.persons.length === 0))
|
|
206
|
+
)
|
|
207
|
+
findings.push(
|
|
208
|
+
finding(
|
|
209
|
+
["data", "grammar", "verbs", "persons"],
|
|
210
|
+
"error",
|
|
211
|
+
"`data.grammar.verbs.persons` must be a map of all six ending strings",
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
163
216
|
/**
|
|
164
217
|
* The note an Address names, or a finding where it names none.
|
|
165
218
|
*
|
|
@@ -43,6 +43,7 @@ export const LANGUAGE_DATA_KEYS = Object.freeze([
|
|
|
43
43
|
"older",
|
|
44
44
|
"negation",
|
|
45
45
|
"naming",
|
|
46
|
+
"grammar",
|
|
46
47
|
]);
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -1594,6 +1595,81 @@ function listOfMaps(name, fields, describe) {
|
|
|
1594
1595
|
});
|
|
1595
1596
|
}
|
|
1596
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* The six present persons of the one optional regular verb pattern, in
|
|
1600
|
+
* presentation order. Empty endings leave the citation form unchanged.
|
|
1601
|
+
*
|
|
1602
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
1603
|
+
*/
|
|
1604
|
+
export const VERB_PERSON_FIELDS = Object.freeze(
|
|
1605
|
+
[
|
|
1606
|
+
["I", "I"],
|
|
1607
|
+
["you", "you"],
|
|
1608
|
+
["heShe", "he/she"],
|
|
1609
|
+
["we", "we"],
|
|
1610
|
+
["youAll", "you all"],
|
|
1611
|
+
["they", "they"],
|
|
1612
|
+
].map(([name, label]) =>
|
|
1613
|
+
Object.freeze({
|
|
1614
|
+
name,
|
|
1615
|
+
label,
|
|
1616
|
+
kind: "string",
|
|
1617
|
+
required: true,
|
|
1618
|
+
allowEmpty: true,
|
|
1619
|
+
shape: "an ending string, empty for unchanged",
|
|
1620
|
+
describe: `The ending added to the citation form for ${label}.`,
|
|
1621
|
+
}),
|
|
1622
|
+
),
|
|
1623
|
+
);
|
|
1624
|
+
|
|
1625
|
+
/** The two alternatives of a supplied optional verb pattern. */
|
|
1626
|
+
const VERB_PATTERN_FIELDS = Object.freeze([
|
|
1627
|
+
Object.freeze({
|
|
1628
|
+
name: "invariant",
|
|
1629
|
+
kind: "boolean",
|
|
1630
|
+
shape: "true, as the alternative to persons",
|
|
1631
|
+
describe: "True where verbs keep the citation form for every person; excludes `persons`.",
|
|
1632
|
+
}),
|
|
1633
|
+
Object.freeze({
|
|
1634
|
+
name: "persons",
|
|
1635
|
+
kind: "map",
|
|
1636
|
+
shape: "a map — `{ I, you, heShe, we, youAll, they }` of ending strings",
|
|
1637
|
+
fields: VERB_PERSON_FIELDS,
|
|
1638
|
+
describe: "All six present endings, each added to the citation form; excludes `invariant`.",
|
|
1639
|
+
}),
|
|
1640
|
+
]);
|
|
1641
|
+
|
|
1642
|
+
/** The optional, display-only noun and verb patterns of one language. */
|
|
1643
|
+
const GRAMMAR_FIELDS = Object.freeze([
|
|
1644
|
+
innerList(
|
|
1645
|
+
"plural",
|
|
1646
|
+
Object.freeze(
|
|
1647
|
+
["ending", "becomes"].map((name) =>
|
|
1648
|
+
Object.freeze({
|
|
1649
|
+
name,
|
|
1650
|
+
kind: "string",
|
|
1651
|
+
required: true,
|
|
1652
|
+
allowEmpty: true,
|
|
1653
|
+
shape: "a literal ending string, which may be empty",
|
|
1654
|
+
describe:
|
|
1655
|
+
name === "ending" ?
|
|
1656
|
+
"The bare suffix replaced by this rule; empty matches every noun."
|
|
1657
|
+
: "The bare replacement; empty removes the matching suffix.",
|
|
1658
|
+
}),
|
|
1659
|
+
),
|
|
1660
|
+
),
|
|
1661
|
+
"Default noun plurals, replacing the first matching literal ending in authored order. " +
|
|
1662
|
+
"A word's explicit plural wins; no word is checked against this rule.",
|
|
1663
|
+
),
|
|
1664
|
+
Object.freeze({
|
|
1665
|
+
name: "verbs",
|
|
1666
|
+
kind: "map",
|
|
1667
|
+
shape: "a map — `{ invariant: true }` or `{ persons: { I, you, heShe, we, youAll, they } }`",
|
|
1668
|
+
fields: VERB_PATTERN_FIELDS,
|
|
1669
|
+
describe: "One optional regular present pattern, printed once at the dictionary head.",
|
|
1670
|
+
}),
|
|
1671
|
+
]);
|
|
1672
|
+
|
|
1597
1673
|
/**
|
|
1598
1674
|
* The `data` fields this module declares, in the order the vocabulary lists
|
|
1599
1675
|
* them. {@link module:engine/language-data.checkLanguageData} checks what the
|
|
@@ -1602,6 +1678,16 @@ function listOfMaps(name, fields, describe) {
|
|
|
1602
1678
|
* @type {readonly object[]}
|
|
1603
1679
|
*/
|
|
1604
1680
|
export const LANGUAGE_DATA_FIELDS = Object.freeze([
|
|
1681
|
+
Object.freeze({
|
|
1682
|
+
name: "grammar",
|
|
1683
|
+
kind: "map",
|
|
1684
|
+
shape: "a map — `{ plural?, verbs? }`",
|
|
1685
|
+
fields: GRAMMAR_FIELDS,
|
|
1686
|
+
describe:
|
|
1687
|
+
"Optional noun plurals and one verb pattern for dictionary presentation. Only the " +
|
|
1688
|
+
"shape of supplied keys is checked; no word must follow or state a rule. A language " +
|
|
1689
|
+
"skill note only.",
|
|
1690
|
+
}),
|
|
1605
1691
|
listOfMaps(
|
|
1606
1692
|
"tongues",
|
|
1607
1693
|
TONGUE_FIELDS,
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* an ancestor's;
|
|
37
37
|
* - every source language resolves, and names a language skill;
|
|
38
38
|
* - a pronunciation is a respelling;
|
|
39
|
-
* - a `built` piece no word or
|
|
39
|
+
* - a `built` piece no word, affix or element of the language glosses is a warning.
|
|
40
40
|
*
|
|
41
41
|
* {@link checkLanguageFamily} owns `data.family`, the parent language a
|
|
42
42
|
* daughter reads words from: it is stated only on a language, names a
|
|
@@ -285,7 +285,7 @@ export function checkLanguageWords(note, { index } = {}) {
|
|
|
285
285
|
finding(
|
|
286
286
|
[...at("built"), j],
|
|
287
287
|
"warning",
|
|
288
|
-
`the piece \`${piece}\` of \`${word.form}\` matches no word or
|
|
288
|
+
`the piece \`${piece}\` of \`${word.form}\` matches no word, affix or element of this ` +
|
|
289
289
|
"language or its ancestors, so it prints with no gloss",
|
|
290
290
|
),
|
|
291
291
|
);
|
|
@@ -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
|
+
}
|
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**.
|
|
@@ -769,6 +770,7 @@ export function unclaimedNoteFindings(
|
|
|
769
770
|
// claiming this note", because the answer there is yes.
|
|
770
771
|
const produces = documentClassesFor(type, resolved, {
|
|
771
772
|
hasProse: () => {
|
|
773
|
+
if (hasLexicalEntries(fm)) return true;
|
|
772
774
|
const source = parsed?.get(absPath);
|
|
773
775
|
return source ? Boolean(source.body) : noteHasProse(absPath);
|
|
774
776
|
},
|
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
|
/**
|
|
@@ -600,7 +600,7 @@ const WORD_DATA_FIELDS = Object.freeze([
|
|
|
600
600
|
fields: WORD_FIELDS,
|
|
601
601
|
}),
|
|
602
602
|
describe:
|
|
603
|
-
"The language
|
|
603
|
+
"The words this language states, one map each, in authored order; its automatic dictionary sorts them alphabetically. A language skill " +
|
|
604
604
|
"note only. See [a language's words](format-details.md#a-languages-words).",
|
|
605
605
|
}),
|
|
606
606
|
Object.freeze({
|
|
@@ -671,9 +671,10 @@ const GLOSSARY_FIELD = Object.freeze({
|
|
|
671
671
|
fields: GLOSSARY_ENTRY_FIELDS,
|
|
672
672
|
}),
|
|
673
673
|
describe:
|
|
674
|
-
"Its own glossary
|
|
675
|
-
"
|
|
676
|
-
"
|
|
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. " +
|
|
677
678
|
"Empty when unstated. See [glossary terms](format-details.md#glossary-terms).",
|
|
678
679
|
});
|
|
679
680
|
|
|
@@ -1820,8 +1821,8 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1820
1821
|
...LANGUAGE_FIELD,
|
|
1821
1822
|
describe:
|
|
1822
1823
|
"The trade, temple or order tongue the affiliation keeps, as a language " +
|
|
1823
|
-
"skill note.
|
|
1824
|
-
"
|
|
1824
|
+
"skill note. A glossary entry that states no `language` is in it. The " +
|
|
1825
|
+
"affiliation chooses its own common vocabulary and context meanings.",
|
|
1825
1826
|
},
|
|
1826
1827
|
GLOSSARY_FIELD,
|
|
1827
1828
|
{
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -1013,7 +1013,15 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
|
|
|
1013
1013
|
scheme,
|
|
1014
1014
|
};
|
|
1015
1015
|
const collected = collectContentPages(contentBase, ctx);
|
|
1016
|
-
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 });
|
|
1017
1025
|
if (gatesFailed(gates)) {
|
|
1018
1026
|
for (const [name, list] of Object.entries(gates)) {
|
|
1019
1027
|
if (!Array.isArray(list)) continue;
|
|
@@ -1030,14 +1038,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
|
|
|
1030
1038
|
|
|
1031
1039
|
const byFile = new Map(collected.pages.map((page) => [page.file, page]));
|
|
1032
1040
|
const universe = tableUniverse(collected.pages);
|
|
1033
|
-
const sqlTables = await prepareTreeSqlTables(contentBase, {
|
|
1034
|
-
config: resolved,
|
|
1035
|
-
skipDirectories: resolved.skipDirectories,
|
|
1036
|
-
records,
|
|
1037
|
-
parsed,
|
|
1038
|
-
database: db,
|
|
1039
|
-
audience: "public",
|
|
1040
|
-
});
|
|
1041
1041
|
// The address space an `![[…]]` embed resolves against — the same one the
|
|
1042
1042
|
// site builds, so one authored picture reaches both surfaces or neither.
|
|
1043
1043
|
const assets = assetAddressIndex(records, {
|
package/engine/site-build.mjs
CHANGED
|
@@ -430,9 +430,11 @@ export function writeHomepages(outRoot, pages, config, { related, records, forei
|
|
|
430
430
|
* @param {object} options
|
|
431
431
|
* @param {object} options.config - The resolved build configuration.
|
|
432
432
|
* @param {object[]} options.records - Content-index records.
|
|
433
|
+
* @param {Map<string, object[]>} [options.sqlTables] - Prepared directives,
|
|
434
|
+
* whose emitted anchors belong to the pages they expand in.
|
|
433
435
|
* @returns {object} The gate results and, when they pass, the built index.
|
|
434
436
|
*/
|
|
435
|
-
export function siteGates(pages, findings, { config, records }) {
|
|
437
|
+
export function siteGates(pages, findings, { config, records, sqlTables }) {
|
|
436
438
|
const out = {
|
|
437
439
|
// Always empty here: the homepage count is decided in `buildSite`
|
|
438
440
|
// before the content walk, and a failing count returns without ever
|
|
@@ -464,7 +466,18 @@ export function siteGates(pages, findings, { config, records }) {
|
|
|
464
466
|
out.unaddressable = unaddressableForeignPackages(foreign.index);
|
|
465
467
|
if (out.unaddressable.length) return out;
|
|
466
468
|
|
|
467
|
-
|
|
469
|
+
const expanded =
|
|
470
|
+
sqlTables ?
|
|
471
|
+
pages.map((page) => ({
|
|
472
|
+
...page,
|
|
473
|
+
body: expandContentTables(page.body ?? "", {
|
|
474
|
+
source: page.file,
|
|
475
|
+
sqlTables: sqlTables.get(page.file),
|
|
476
|
+
pageLists: sqlTables.pageLists?.get(page.file),
|
|
477
|
+
}).markdown,
|
|
478
|
+
}))
|
|
479
|
+
: pages;
|
|
480
|
+
out.index = buildSiteIndex(expanded, {
|
|
468
481
|
package: config.contentPackage,
|
|
469
482
|
foreignIndex: foreign.index,
|
|
470
483
|
foreignReferences: foreign.references,
|
|
@@ -1367,6 +1380,7 @@ export function buildSite({ config, sqlTables } = {}) {
|
|
|
1367
1380
|
const gates = siteGates([...pages, ...homepageEntries], content, {
|
|
1368
1381
|
config: resolved,
|
|
1369
1382
|
records: ctx.records,
|
|
1383
|
+
sqlTables,
|
|
1370
1384
|
});
|
|
1371
1385
|
if (gatesFailed(gates)) {
|
|
1372
1386
|
return {
|