@heroiclands/package-build 23.8.0 → 23.10.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 +53 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +207 -70
- package/docs/api.md +646 -78
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/links-and-markup.md +253 -102
- package/docs/commands.md +111 -13
- package/docs/content-format.md +7 -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 +398 -137
- package/docs/reference/note-types.md +188 -174
- package/engine/anchors.mjs +15 -26
- package/engine/base-compiler.mjs +6 -4
- package/engine/changelog-group.mjs +58 -34
- package/engine/changelog-lint.mjs +6 -2
- package/engine/compendiums.mjs +15 -1
- package/engine/compile-corpus.mjs +17 -3
- package/engine/content-charset.mjs +13 -1
- package/engine/content-format.yaml +1 -0
- package/engine/content-html.mjs +18 -4
- package/engine/content-icons.mjs +15 -1
- package/engine/content-images.mjs +28 -9
- package/engine/content-index.mjs +496 -16
- package/engine/content-links.mjs +70 -27
- package/engine/content-session.mjs +247 -0
- package/engine/content-sources.mjs +110 -0
- package/engine/content-spans.mjs +87 -8
- package/engine/content-tables.mjs +9 -4
- package/engine/content-tasklists.mjs +20 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/diagnostics.mjs +17 -6
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +29 -8
- package/engine/generate.mjs +13 -4
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +54 -19
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +13 -6
- package/engine/language-data.mjs +55 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +90 -4
- package/engine/language-words.mjs +2 -2
- package/engine/lexical-sections.mjs +247 -0
- package/engine/markup-validation.mjs +7 -2
- package/engine/metadata-index.mjs +1 -0
- package/engine/name-markup.mjs +114 -0
- package/engine/name-rows.mjs +70 -0
- package/engine/names.mjs +460 -0
- package/engine/note-addresses.mjs +25 -4
- package/engine/note-claims.mjs +2 -0
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +7 -6
- package/engine/path-charset.mjs +24 -1
- package/engine/pdf-build.mjs +9 -9
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-build.mjs +16 -2
- package/engine/site-index.mjs +5 -1
- package/engine/sql-tables.mjs +324 -44
- package/engine/stub-lint.mjs +10 -5
- package/engine/web-wikilinks.mjs +8 -6
- package/engine/word-rows.mjs +20 -12
- package/engine/word-terms.mjs +15 -6
- package/engine/yaml-lint.mjs +3 -5
- package/hugo-theme/static/css/style.css +4 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +4 -2
- package/types/engine/compendiums.d.mts +5 -1
- package/types/engine/compile-corpus.d.mts +5 -2
- package/types/engine/content-charset.d.mts +5 -1
- package/types/engine/content-html.d.mts +5 -1
- package/types/engine/content-icons.d.mts +5 -1
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-index.d.mts +102 -18
- package/types/engine/content-links.d.mts +22 -8
- package/types/engine/content-session.d.mts +108 -0
- package/types/engine/content-sources.d.mts +32 -0
- package/types/engine/content-spans.d.mts +3 -5
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/content-tasklists.d.mts +5 -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 +10 -0
- package/types/engine/dictionary-layout.d.mts +35 -4
- package/types/engine/dictionary-rows.d.mts +9 -0
- package/types/engine/extension-attributes.d.mts +16 -2
- package/types/engine/first-use.d.mts +31 -0
- package/types/engine/foundry-entries.d.mts +14 -5
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/heading-attributes.d.mts +2 -2
- package/types/engine/helpers.d.mts +33 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +96 -0
- package/types/engine/markup-validation.d.mts +2 -1
- package/types/engine/name-markup.d.mts +176 -0
- package/types/engine/name-rows.d.mts +35 -0
- package/types/engine/names.d.mts +356 -0
- package/types/engine/note-addresses.d.mts +3 -3
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +6 -3
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +39 -4
- package/types/engine/stub-lint.d.mts +3 -1
- package/types/engine/word-rows.d.mts +9 -7
|
@@ -27,11 +27,20 @@ const NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
|
27
27
|
* @param {RegExp} [options.idPattern=NAME] - What an `#id` may be named. A
|
|
28
28
|
* construct whose id is published as an HTML `id` admits a digit first and
|
|
29
29
|
* passes a looser pattern; the default is the pattern every other name takes.
|
|
30
|
-
* @
|
|
30
|
+
* @param {boolean} [options.positions=false] - Retain the first supplied
|
|
31
|
+
* attribute's key/value ranges as UTF-16 offsets within this input.
|
|
32
|
+
* @returns {{id: string, classes: string[], values: Record<string, string>, problems: string[],
|
|
33
|
+
* positions?: Record<string, {key: {start: number, end: number}, value: {start: number, end: number}}>}}
|
|
31
34
|
*/
|
|
32
|
-
export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
|
|
35
|
+
export function parseExtensionAttributes(source, { idPattern = NAME, positions = false } = {}) {
|
|
33
36
|
const text = String(source ?? "");
|
|
34
|
-
const result = {
|
|
37
|
+
const result = {
|
|
38
|
+
id: "",
|
|
39
|
+
classes: [],
|
|
40
|
+
values: {},
|
|
41
|
+
problems: [],
|
|
42
|
+
...(positions ? { positions: {} } : {}),
|
|
43
|
+
};
|
|
35
44
|
let at = 0;
|
|
36
45
|
while (at < text.length) {
|
|
37
46
|
while (/\s/.test(text[at] ?? "")) at++;
|
|
@@ -59,13 +68,18 @@ export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
|
|
|
59
68
|
result.problems.push(`${text.slice(start, at)} is not a key=value attribute`);
|
|
60
69
|
continue;
|
|
61
70
|
}
|
|
71
|
+
const keyEnd = at + key.length;
|
|
62
72
|
at += key.length + 1;
|
|
63
|
-
let value = ""
|
|
73
|
+
let value = "",
|
|
74
|
+
valueStart = at,
|
|
75
|
+
valueEnd;
|
|
64
76
|
if (text[at] === '"') {
|
|
65
77
|
at++;
|
|
78
|
+
valueStart = at;
|
|
66
79
|
let closed = false;
|
|
67
80
|
while (at < text.length) {
|
|
68
81
|
if (text[at] === '"') {
|
|
82
|
+
valueEnd = at;
|
|
69
83
|
at++;
|
|
70
84
|
closed = true;
|
|
71
85
|
break;
|
|
@@ -73,6 +87,7 @@ export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
|
|
|
73
87
|
if (text[at] === "\\" && ["\\", '"'].includes(text[at + 1])) at++;
|
|
74
88
|
value += text[at++];
|
|
75
89
|
}
|
|
90
|
+
valueEnd ??= at;
|
|
76
91
|
if (!closed) result.problems.push(`${key} has an unclosed quoted value`);
|
|
77
92
|
if (at < text.length && !/\s/.test(text[at])) {
|
|
78
93
|
while (at < text.length && !/\s/.test(text[at])) at++;
|
|
@@ -80,11 +95,19 @@ export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
|
|
|
80
95
|
}
|
|
81
96
|
} else {
|
|
82
97
|
while (at < text.length && !/\s/.test(text[at])) value += text[at++];
|
|
98
|
+
valueEnd = at;
|
|
83
99
|
if (/[{},"\[\]]/.test(value)) result.problems.push(`${key} needs a quoted value`);
|
|
84
100
|
}
|
|
85
101
|
if (!value) result.problems.push(`${key} needs a value`);
|
|
86
102
|
if (Object.hasOwn(result.values, key)) result.problems.push(`${key} is written twice`);
|
|
87
|
-
else
|
|
103
|
+
else {
|
|
104
|
+
result.values[key] = value;
|
|
105
|
+
if (positions)
|
|
106
|
+
result.positions[key] = {
|
|
107
|
+
key: { start, end: keyEnd },
|
|
108
|
+
value: { start: valueStart, end: valueEnd },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
88
111
|
}
|
|
89
112
|
return result;
|
|
90
113
|
}
|
|
@@ -0,0 +1,371 @@
|
|
|
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 { unified } from "unified";
|
|
15
|
+
import remarkParse from "remark-parse";
|
|
16
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
17
|
+
import remarkGfm from "remark-gfm";
|
|
18
|
+
import MarkdownIt from "markdown-it";
|
|
19
|
+
import { scanSpans, spanMarkdownPlugin } from "./content-spans.mjs";
|
|
20
|
+
import { inlineText, rebaseNameSpans } from "./name-markup.mjs";
|
|
21
|
+
import { createNameCatalog, nameForms as forms, normalizeName as normalize } from "./names.mjs";
|
|
22
|
+
import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
|
|
23
|
+
|
|
24
|
+
const parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
|
|
25
|
+
const labelParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
|
|
26
|
+
const linkRule = labelParser.inline.ruler.getRules("").find((rule) => rule.name === "link");
|
|
27
|
+
labelParser.inline.ruler.at("link", (state, silent) => {
|
|
28
|
+
const start = state.pos,
|
|
29
|
+
count = state.tokens.length;
|
|
30
|
+
const matched = linkRule(state, silent);
|
|
31
|
+
if (matched && !silent) {
|
|
32
|
+
const opening = state.tokens.slice(count).find((token) => token.type === "link_open");
|
|
33
|
+
opening.meta = {
|
|
34
|
+
...opening.meta,
|
|
35
|
+
nameLink: { source: state.src, start, end: state.pos },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return matched;
|
|
39
|
+
});
|
|
40
|
+
const blank = (text) => text.replace(/[^\r\n]/g, " ");
|
|
41
|
+
// A quoted bracket belongs to its attribute. This reads boundaries only;
|
|
42
|
+
// the separate raw-HTML check owns whether an author should write the tag.
|
|
43
|
+
const HTML_TOKEN =
|
|
44
|
+
/<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z][A-Za-z0-9-]*(?:\s(?:[^"'<>]|"[^"]*"|'[^']*')*)?\/?>/g;
|
|
45
|
+
|
|
46
|
+
/** Read an authored inline slice once with the renderer's span grammar. */
|
|
47
|
+
function readLabel(text, env = {}) {
|
|
48
|
+
const tokens = labelParser.parseInline(text, env);
|
|
49
|
+
const children = tokens[0]?.children ?? [];
|
|
50
|
+
const offsets = [];
|
|
51
|
+
for (let i = 0; i < text.length; i++) {
|
|
52
|
+
offsets.push(i);
|
|
53
|
+
if (text[i] === "\r" && text[i + 1] === "\n") i++;
|
|
54
|
+
}
|
|
55
|
+
offsets.push(text.length);
|
|
56
|
+
const normalized = text.replace(/\r\n?/g, "\n").replaceAll("\0", "\uFFFD");
|
|
57
|
+
const spans = [],
|
|
58
|
+
links = [];
|
|
59
|
+
for (let i = 0; i < children.length; i++) {
|
|
60
|
+
const token = children[i];
|
|
61
|
+
if (token.meta?.span) spans.push(token.meta);
|
|
62
|
+
else if (token.type === "span_close") spans.pop();
|
|
63
|
+
const link = token.meta?.nameLink;
|
|
64
|
+
if (!link) continue;
|
|
65
|
+
const parent =
|
|
66
|
+
link.source === normalized ?
|
|
67
|
+
undefined
|
|
68
|
+
: spans.findLast((span) => span.text === link.source);
|
|
69
|
+
const base = parent ? parent.start + 1 : 0;
|
|
70
|
+
let depth = 1,
|
|
71
|
+
end = i + 1;
|
|
72
|
+
for (; end < children.length; end++) {
|
|
73
|
+
if (children[end].type === "link_open") depth++;
|
|
74
|
+
if (children[end].type === "link_close" && --depth === 0) break;
|
|
75
|
+
}
|
|
76
|
+
links.push({
|
|
77
|
+
start: offsets[base + link.start],
|
|
78
|
+
end: offsets[base + link.end],
|
|
79
|
+
display: inlineText(children.slice(i + 1, end)),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return { text: inlineText(tokens), links };
|
|
83
|
+
}
|
|
84
|
+
const labelText = (text, env) => readLabel(text, env).text;
|
|
85
|
+
|
|
86
|
+
/** Join overlapping source ranges so lookups stay logarithmic. */
|
|
87
|
+
function ranges(values) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const value of values.sort((a, b) => a.start - b.start || b.end - a.end)) {
|
|
90
|
+
const last = out.at(-1);
|
|
91
|
+
if (last && value.start <= last.end) last.end = Math.max(last.end, value.end);
|
|
92
|
+
else out.push({ start: value.start, end: value.end });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Find the last range opening before an offset. */
|
|
98
|
+
function before(values, offset) {
|
|
99
|
+
let lo = 0,
|
|
100
|
+
hi = values.length;
|
|
101
|
+
while (lo < hi) {
|
|
102
|
+
const mid = (lo + hi) >>> 1;
|
|
103
|
+
if (values[mid].start < offset) lo = mid + 1;
|
|
104
|
+
else hi = mid;
|
|
105
|
+
}
|
|
106
|
+
return values[lo - 1];
|
|
107
|
+
}
|
|
108
|
+
const contains = (values, value) => {
|
|
109
|
+
const range = before(values, value.start + 1);
|
|
110
|
+
return range && range.end >= value.end;
|
|
111
|
+
};
|
|
112
|
+
const overlaps = (values, value) => {
|
|
113
|
+
const range = before(values, value.end);
|
|
114
|
+
return range && range.end > value.start;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Explicit inline occurrences and structural exclusions at original source offsets. */
|
|
118
|
+
function prose(source, capturedNames) {
|
|
119
|
+
const links = [],
|
|
120
|
+
regions = [],
|
|
121
|
+
excluded = [],
|
|
122
|
+
bold = [],
|
|
123
|
+
html = [];
|
|
124
|
+
const masked = source
|
|
125
|
+
.replace(/!\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (text, start) => {
|
|
126
|
+
excluded.push({ start, end: start + text.length });
|
|
127
|
+
return blank(text);
|
|
128
|
+
})
|
|
129
|
+
.replace(WIKILINK, (text, inner, start) => {
|
|
130
|
+
let escapes = 0;
|
|
131
|
+
while (source[start - escapes - 1] === "\\") escapes++;
|
|
132
|
+
if (escapes % 2) return text;
|
|
133
|
+
links.push({ ...parseWikilink(inner), start, end: start + text.length, wiki: true });
|
|
134
|
+
return "x".repeat(text.length);
|
|
135
|
+
});
|
|
136
|
+
const tree = parser.parse(masked);
|
|
137
|
+
const env = { references: Object.create(null) };
|
|
138
|
+
const inline = (node) => {
|
|
139
|
+
const start = node.position?.start.offset,
|
|
140
|
+
end = node.position?.end.offset;
|
|
141
|
+
if (["inlineCode", "image", "imageReference", "footnoteReference"].includes(node.type))
|
|
142
|
+
excluded.push({ start, end });
|
|
143
|
+
else if (node.type === "html") html.push(node);
|
|
144
|
+
else if (["strong", "emphasis", "delete", "link", "linkReference"].includes(node.type)) {
|
|
145
|
+
if (node.type === "strong") bold.push({ start, end });
|
|
146
|
+
if (["link", "linkReference"].includes(node.type) && source[start] === "[")
|
|
147
|
+
links.push({ start, end, labelled: true });
|
|
148
|
+
for (const child of node.children ?? []) inline(child);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const block = (node) => {
|
|
152
|
+
if (node.type === "definition")
|
|
153
|
+
env.references[labelParser.utils.normalizeReference(node.identifier)] = {
|
|
154
|
+
href: node.url,
|
|
155
|
+
title: node.title ?? "",
|
|
156
|
+
};
|
|
157
|
+
else if (node.type === "html") html.push(node);
|
|
158
|
+
else if (node.type === "paragraph") {
|
|
159
|
+
regions.push({ start: node.position.start.offset, end: node.position.end.offset });
|
|
160
|
+
for (const child of node.children ?? []) inline(child);
|
|
161
|
+
} else if (
|
|
162
|
+
["root", "blockquote", "list", "listItem", "footnoteDefinition"].includes(node.type)
|
|
163
|
+
) {
|
|
164
|
+
for (const child of node.children ?? []) block(child);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
block(tree);
|
|
168
|
+
const stack = [];
|
|
169
|
+
for (const node of html) {
|
|
170
|
+
const htmlText = masked.slice(node.position.start.offset, node.position.end.offset);
|
|
171
|
+
let cursor = 0;
|
|
172
|
+
for (const tag of htmlText.matchAll(HTML_TOKEN)) {
|
|
173
|
+
const start = node.position.start.offset + tag.index;
|
|
174
|
+
if (tag.index > cursor)
|
|
175
|
+
regions.push({ start: node.position.start.offset + cursor, end: start });
|
|
176
|
+
cursor = tag.index + tag[0].length;
|
|
177
|
+
excluded.push({ start, end: start + tag[0].length });
|
|
178
|
+
if (tag[0].startsWith("<!--")) continue;
|
|
179
|
+
const name = /^<\/?([A-Za-z][\w-]*)/.exec(tag[0])[1].toLowerCase();
|
|
180
|
+
if (tag[0].startsWith("</")) {
|
|
181
|
+
const opening = stack.findLastIndex((entry) => entry.name === name);
|
|
182
|
+
if (opening < 0) continue;
|
|
183
|
+
for (const entry of stack.splice(opening))
|
|
184
|
+
if (entry.excluded)
|
|
185
|
+
excluded.push({ start: entry.start, end: start + tag[0].length });
|
|
186
|
+
} else if (
|
|
187
|
+
!/\/>$/.test(tag[0]) &&
|
|
188
|
+
!["br", "hr", "img", "input", "meta", "link"].includes(name)
|
|
189
|
+
) {
|
|
190
|
+
const classes = /\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag[0]);
|
|
191
|
+
stack.push({
|
|
192
|
+
name,
|
|
193
|
+
start,
|
|
194
|
+
excluded:
|
|
195
|
+
/^(?:h[1-6]|table|pre|code|script|style)$/.test(name) ||
|
|
196
|
+
(classes?.[1] ?? classes?.[2] ?? classes?.[3] ?? "")
|
|
197
|
+
.split(/\s+/)
|
|
198
|
+
.includes("infobox"),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (cursor < htmlText.length)
|
|
203
|
+
regions.push({
|
|
204
|
+
start: node.position.start.offset + cursor,
|
|
205
|
+
end: node.position.end.offset,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
for (const entry of stack)
|
|
209
|
+
if (entry.excluded) excluded.push({ start: entry.start, end: source.length });
|
|
210
|
+
const allowed = ranges(regions),
|
|
211
|
+
literal = ranges(excluded);
|
|
212
|
+
const eligible = (value) => contains(allowed, value) && !overlaps(literal, value);
|
|
213
|
+
const actualLinks = links.filter(eligible).flatMap((link) => {
|
|
214
|
+
if (link.wiki) return [link];
|
|
215
|
+
const label = readLabel(source.slice(link.start, link.end), env);
|
|
216
|
+
return label.links.map((actual) => ({
|
|
217
|
+
...actual,
|
|
218
|
+
labelled: true,
|
|
219
|
+
start: link.start + actual.start,
|
|
220
|
+
end: link.start + actual.end,
|
|
221
|
+
}));
|
|
222
|
+
});
|
|
223
|
+
const authoredLinks = [...new Map(actualLinks.map((link) => [link.start, link])).values()];
|
|
224
|
+
const linked = ranges(authoredLinks);
|
|
225
|
+
const marked = (capturedNames ?? scanSpans(source).names)
|
|
226
|
+
.filter((span) => eligible(span) && !contains(linked, span))
|
|
227
|
+
.map((span) => ({ ...span, label: span.visible }));
|
|
228
|
+
const explicit = ranges([...marked, ...authoredLinks]);
|
|
229
|
+
return {
|
|
230
|
+
marked,
|
|
231
|
+
links: authoredLinks,
|
|
232
|
+
bold: bold
|
|
233
|
+
.filter((span) => eligible(span) && !overlaps(explicit, span))
|
|
234
|
+
.map((span) => ({
|
|
235
|
+
...span,
|
|
236
|
+
label: labelText(source.slice(span.start, span.end), env),
|
|
237
|
+
})),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Prepare indexed names once, then check only explicit occurrences without reading files.
|
|
243
|
+
* @param {{records: readonly object[], index: object,
|
|
244
|
+
* catalog?: import("./names.mjs").NameCatalog,
|
|
245
|
+
* parsed?: Map<string, {raw: string, body: string, bodyLine?: number, bodyColumn?: number}>,
|
|
246
|
+
* syntax?: import("./content-session.mjs").ContentSyntax}} options - Lint's
|
|
247
|
+
* records/resolver and optional captured authored-body projections.
|
|
248
|
+
* @returns {(note: {file: string, raw: string}) => Array<{file: string, line: number, column: number, severity: "warning", rules: string[], message: string}>} Located warning diagnostics.
|
|
249
|
+
*/
|
|
250
|
+
export function createFirstUseChecker({
|
|
251
|
+
records,
|
|
252
|
+
index,
|
|
253
|
+
parsed,
|
|
254
|
+
syntax,
|
|
255
|
+
catalog = createNameCatalog({ records, index }),
|
|
256
|
+
}) {
|
|
257
|
+
const { recognize: recognized, resolve } = catalog;
|
|
258
|
+
return ({ file, raw }) => {
|
|
259
|
+
const authored = parsed?.get(file);
|
|
260
|
+
const capturedNames =
|
|
261
|
+
authored?.raw === raw && Number.isInteger(authored.bodyLine) ?
|
|
262
|
+
rebaseNameSpans(
|
|
263
|
+
(syntax ? syntax(authored.body).spans : scanSpans(authored.body)).names,
|
|
264
|
+
{ raw, bodyLine: authored.bodyLine, bodyColumn: authored.bodyColumn },
|
|
265
|
+
)
|
|
266
|
+
: undefined;
|
|
267
|
+
const { marked, links, bold } = prose(raw, capturedNames);
|
|
268
|
+
const events = [
|
|
269
|
+
...marked.map((span) => ({ ...span, kind: "marked" })),
|
|
270
|
+
...links.map((link) => {
|
|
271
|
+
const target = link.wiki ? resolve(link.target) : undefined;
|
|
272
|
+
return {
|
|
273
|
+
...link,
|
|
274
|
+
kind: "link",
|
|
275
|
+
preferred: target,
|
|
276
|
+
label:
|
|
277
|
+
link.labelled ?
|
|
278
|
+
link.wiki ?
|
|
279
|
+
labelText(link.display || target?.full || "")
|
|
280
|
+
: link.display
|
|
281
|
+
: undefined,
|
|
282
|
+
};
|
|
283
|
+
}),
|
|
284
|
+
].filter((event) => typeof event.label === "string" && normalize(event.label));
|
|
285
|
+
const unknown = new Map();
|
|
286
|
+
for (const event of [...events].sort(
|
|
287
|
+
(a, b) => a.label.length - b.label.length || a.label.localeCompare(b.label),
|
|
288
|
+
)) {
|
|
289
|
+
if (recognized(event.label, event.preferred) || unknown.has(normalize(event.label)))
|
|
290
|
+
continue;
|
|
291
|
+
const entry = {
|
|
292
|
+
key: "name:" + normalize(event.label),
|
|
293
|
+
full: event.label,
|
|
294
|
+
fullUse: true,
|
|
295
|
+
};
|
|
296
|
+
for (const spelling of forms(event.label))
|
|
297
|
+
if (!unknown.has(spelling)) unknown.set(spelling, entry);
|
|
298
|
+
}
|
|
299
|
+
const findings = new Map();
|
|
300
|
+
const add = (offset, rule, message) => {
|
|
301
|
+
if (!findings.has(offset)) findings.set(offset, { rules: [], messages: [] });
|
|
302
|
+
const finding = findings.get(offset);
|
|
303
|
+
if (!finding.rules.includes(rule)) {
|
|
304
|
+
finding.rules.push(rule);
|
|
305
|
+
finding.messages.push(message);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const used = new Set(),
|
|
309
|
+
fullUsed = new Set();
|
|
310
|
+
for (const event of events.sort((a, b) => a.start - b.start)) {
|
|
311
|
+
const entry =
|
|
312
|
+
recognized(event.label, event.preferred) ?? unknown.get(normalize(event.label));
|
|
313
|
+
if (event.kind === "marked" && entry.indexed) {
|
|
314
|
+
const destinations = (entry.candidates ?? [entry])
|
|
315
|
+
.map((target) => target.key)
|
|
316
|
+
.sort()
|
|
317
|
+
.join(" or ");
|
|
318
|
+
add(
|
|
319
|
+
event.start,
|
|
320
|
+
"unlinked-name",
|
|
321
|
+
`“${event.label}” names “${entry.full}”; use a link to “${destinations}”`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (used.has(entry.key))
|
|
325
|
+
add(
|
|
326
|
+
event.start,
|
|
327
|
+
"repeated-name",
|
|
328
|
+
`“${entry.full}” is linked or marked more than once`,
|
|
329
|
+
);
|
|
330
|
+
if (!entry.fullUse && !fullUsed.has(entry.key))
|
|
331
|
+
add(
|
|
332
|
+
event.start,
|
|
333
|
+
"short-before-full",
|
|
334
|
+
`“${event.label}” appears before the full name “${entry.full}”`,
|
|
335
|
+
);
|
|
336
|
+
used.add(entry.key);
|
|
337
|
+
if (entry.fullUse) fullUsed.add(entry.key);
|
|
338
|
+
}
|
|
339
|
+
for (const span of bold) {
|
|
340
|
+
if (typeof span.label !== "string") continue;
|
|
341
|
+
const entry = recognized(span.label);
|
|
342
|
+
if (entry)
|
|
343
|
+
add(
|
|
344
|
+
span.start,
|
|
345
|
+
"bold-name",
|
|
346
|
+
`“${span.label}” matches an indexed name; mark or link this name`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const lineStarts = [0];
|
|
350
|
+
for (let i = 0; i < raw.length; i++) if (raw[i] === "\n") lineStarts.push(i + 1);
|
|
351
|
+
return [...findings]
|
|
352
|
+
.sort(([a], [b]) => a - b)
|
|
353
|
+
.map(([offset, finding]) => {
|
|
354
|
+
let lo = 0,
|
|
355
|
+
hi = lineStarts.length;
|
|
356
|
+
while (lo + 1 < hi) {
|
|
357
|
+
const mid = (lo + hi) >>> 1;
|
|
358
|
+
if (lineStarts[mid] <= offset) lo = mid;
|
|
359
|
+
else hi = mid;
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
file,
|
|
363
|
+
line: lo + 1,
|
|
364
|
+
column: offset - lineStarts[lo] + 1,
|
|
365
|
+
severity: "warning",
|
|
366
|
+
rules: finding.rules,
|
|
367
|
+
message: `first-use/${finding.rules.join("+")}: ${finding.messages.join("; ")}`,
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
}
|
|
@@ -78,6 +78,8 @@ import { isHomepage } from "./homepage.mjs";
|
|
|
78
78
|
import { declaresNoPack } from "./pack-router.mjs";
|
|
79
79
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
80
80
|
import { collectAnchors } from "./anchors.mjs";
|
|
81
|
+
import { scanSpans } from "./content-spans.mjs";
|
|
82
|
+
import { DICTIONARY_ENTRY_CLASS } from "./dictionary-layout.mjs";
|
|
81
83
|
import { journalPageId, splitPages } from "./journals.mjs";
|
|
82
84
|
import { routerFor } from "./pack-router.mjs";
|
|
83
85
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
@@ -94,7 +96,9 @@ import { publishesContentPages } from "../content-config.mjs";
|
|
|
94
96
|
export const LEAD_ANCHOR = "$lead";
|
|
95
97
|
|
|
96
98
|
/**
|
|
97
|
-
* Every
|
|
99
|
+
* Every anchor of a note's journal, as `anchorName → Foundry target`.
|
|
100
|
+
* A dictionary word's native heading carries its fragment after the
|
|
101
|
+
* containing page's UUID.
|
|
98
102
|
*
|
|
99
103
|
* Whole, not a fragment appended to the entry's UUID: nothing owns a page
|
|
100
104
|
* address, so a complete link restates no fact, and it keeps the page-id hash
|
|
@@ -106,15 +110,27 @@ export const LEAD_ANCHOR = "$lead";
|
|
|
106
110
|
* @param {string} entryId - The entry's id, which page ids hash against.
|
|
107
111
|
* @param {string} body - The note's markdown body.
|
|
108
112
|
* @param {string} name - The note's name, used as the lead page's title.
|
|
113
|
+
* @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session scanner projections.
|
|
109
114
|
* @returns {Record<string, string>} The anchors.
|
|
110
115
|
*/
|
|
111
|
-
export function anchorsOf(entryUuid, entryId, body, name) {
|
|
116
|
+
export function anchorsOf(entryUuid, entryId, body, name, syntax) {
|
|
112
117
|
const anchors = {};
|
|
113
|
-
splitPages(body, name).forEach((page, index) => {
|
|
118
|
+
splitPages(body, name, undefined, syntax).forEach((page, index) => {
|
|
114
119
|
const uuid = pageUuid(entryUuid, journalPageId(entryId, page));
|
|
120
|
+
const words = new Set(
|
|
121
|
+
(syntax ? syntax(page.markdown).spans : scanSpans(page.markdown)).spans
|
|
122
|
+
.filter((span) =>
|
|
123
|
+
span.attrs.some(
|
|
124
|
+
([key, value]) =>
|
|
125
|
+
key === "class" && value.split(/\s+/).includes(DICTIONARY_ENTRY_CLASS),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
.map((span) => span.id),
|
|
129
|
+
);
|
|
115
130
|
if (index === 0) anchors[LEAD_ANCHOR] = uuid;
|
|
116
131
|
if (page.anchorSlug) anchors[page.anchorSlug] = uuid;
|
|
117
|
-
for (const anchor of collectAnchors(page.markdown
|
|
132
|
+
for (const anchor of collectAnchors(page.markdown, 1, undefined, syntax))
|
|
133
|
+
anchors[anchor.slug] = words.has(anchor.slug) ? `${uuid}#${anchor.slug}` : uuid;
|
|
118
134
|
});
|
|
119
135
|
return anchors;
|
|
120
136
|
}
|
|
@@ -140,8 +156,7 @@ export function anchorsOf(entryUuid, entryId, body, name) {
|
|
|
140
156
|
* documentation journal's is {@link itemDocEntryId} of that. Stating it here is
|
|
141
157
|
* what lets the content index publish an identity it did not re-derive.
|
|
142
158
|
*
|
|
143
|
-
* @param {object} ctx - Resolved identities
|
|
144
|
-
* foundryPackageId, packRouter }`.
|
|
159
|
+
* @param {{contentPackage: string, foundryPackageId: string, packRouter: object, web?: boolean, skipDirectories?: readonly string[], syntax?: import("./content-session.mjs").ContentSyntax}} ctx - Resolved identities and optional session scanner projections.
|
|
145
160
|
* @returns {Array<object>} One or two entries, in {@link buildManifest}'s shape.
|
|
146
161
|
*/
|
|
147
162
|
export function entriesForNote(fm, name, address, body, ctx) {
|
|
@@ -273,7 +288,10 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
273
288
|
// UUID's last segment.
|
|
274
289
|
id: docEntryId,
|
|
275
290
|
uuid: docUuid,
|
|
276
|
-
anchors:
|
|
291
|
+
anchors:
|
|
292
|
+
docUuid ?
|
|
293
|
+
anchorsOf(docUuid, docEntryId, body ?? "", name, ctx.syntax)
|
|
294
|
+
: undefined,
|
|
277
295
|
},
|
|
278
296
|
];
|
|
279
297
|
}
|
|
@@ -290,7 +308,10 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
290
308
|
id: fm.id,
|
|
291
309
|
uuid: own,
|
|
292
310
|
systemUuids: systemUuidsFor(fm.type, fm.id),
|
|
293
|
-
anchors:
|
|
311
|
+
anchors:
|
|
312
|
+
own && packForType(fm.type)?.docType === "JournalEntry" ?
|
|
313
|
+
anchorsOf(own, fm.id, body ?? "", name, ctx.syntax)
|
|
314
|
+
: undefined,
|
|
294
315
|
},
|
|
295
316
|
];
|
|
296
317
|
}
|
package/engine/generate.mjs
CHANGED
|
@@ -735,17 +735,25 @@ async function stageGeneratedDrawings(records, config) {
|
|
|
735
735
|
* package's tree — and how the guard-order test below induces id drift, now
|
|
736
736
|
* that the manifest is located by configuration rather than by the working
|
|
737
737
|
* directory.
|
|
738
|
+
* @param {import("./content-session.mjs").ContentSession} [opts.session] - Same configuration, root and scope as this generation.
|
|
739
|
+
* @param {boolean} [opts.reportPreparation=true] - Emit shared preparation findings; false when already reported by the caller.
|
|
738
740
|
* @returns {Promise<number>} Total error count across the generated packs.
|
|
739
741
|
* @throws {Error} If the configured Foundry package id has drifted from the
|
|
740
742
|
* shipped manifest's `id` (see `package-manifest.mjs`).
|
|
741
743
|
*/
|
|
742
|
-
export async function generatePacksJson({
|
|
744
|
+
export async function generatePacksJson({
|
|
745
|
+
only,
|
|
746
|
+
config = loadPackConfig(),
|
|
747
|
+
session,
|
|
748
|
+
reportPreparation = true,
|
|
749
|
+
} = {}) {
|
|
743
750
|
// Before anything is generated: every UUID written below is addressed to
|
|
744
751
|
// the configured `foundryPackage`, so a value that has drifted from the shipped
|
|
745
752
|
// manifest's `id` produces a whole pack of links that resolve nowhere.
|
|
746
753
|
// Throws rather than counting an error — there is nothing worth compiling.
|
|
747
754
|
//
|
|
748
755
|
const contentBase = config.paths.content;
|
|
756
|
+
session?.assertContext({ config, contentBase, skipDirectories: config.skipDirectories });
|
|
749
757
|
if (!fs.existsSync(contentBase)) {
|
|
750
758
|
log.error(`Content tree not found at ${contentBase}.`);
|
|
751
759
|
return 1;
|
|
@@ -770,7 +778,7 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
770
778
|
// a note of that type that goes nowhere. Resolved here because the corpus
|
|
771
779
|
// below is derived against it, and the corpus is what every reader from
|
|
772
780
|
// this point on reads.
|
|
773
|
-
const router = routerFor(config);
|
|
781
|
+
const router = session?.router ?? routerFor(config);
|
|
774
782
|
|
|
775
783
|
// The corpus every pass runs over, and the three whole-tree indexes built
|
|
776
784
|
// over it, derived **once** for the whole compile. Each is a pure
|
|
@@ -782,15 +790,16 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
782
790
|
// Derived here, before the first reader: the unclaimed-type check below is
|
|
783
791
|
// one, and a check that walked the tree itself would be answering about a
|
|
784
792
|
// different corpus from the one the passes then compile.
|
|
785
|
-
const corpusProblems = [];
|
|
793
|
+
const corpusProblems = session?.problems ?? [];
|
|
786
794
|
const corpus = await buildCompileCorpus({
|
|
787
795
|
contentBase,
|
|
788
796
|
skipDirectories: config.skipDirectories,
|
|
789
797
|
router,
|
|
790
798
|
config,
|
|
791
799
|
problems: corpusProblems,
|
|
800
|
+
session,
|
|
792
801
|
});
|
|
793
|
-
for (const problem of corpusProblems) emitDiagnostic(problem);
|
|
802
|
+
if (reportPreparation) for (const problem of corpusProblems) emitDiagnostic(problem);
|
|
794
803
|
for (const finding of await stageGeneratedDrawings(corpus.records, config))
|
|
795
804
|
emitDiagnostic(finding);
|
|
796
805
|
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
* `lore` note of `subType: culture` or on an `affiliation`, a list of
|
|
17
17
|
* `{ name, name_sort?, common?, definition?, pronunciation?, language? }`.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* An entry chooses a common word, gives a dictionary word a contextual
|
|
20
|
+
* meaning, or defines a phrase, compound or term of art. An entry naming a
|
|
21
|
+
* word of its resolved language carries that word's senses and class unless
|
|
22
|
+
* its own `definition` supplies the sense. A rank or office can also be named.
|
|
23
|
+
* Common marks, sort keys and pronunciation overrides belong to the entry's
|
|
24
|
+
* own glossary.
|
|
25
25
|
*
|
|
26
26
|
* The shape is the inner-key check's; this check owns what an entry means:
|
|
27
27
|
*
|
|
@@ -88,7 +88,7 @@ export function checkGlossary(note, { index } = {}) {
|
|
|
88
88
|
if (!Array.isArray(data.glossary)) return [];
|
|
89
89
|
|
|
90
90
|
const findings = [];
|
|
91
|
-
const
|
|
91
|
+
const hasTerms = Boolean(index?.notes);
|
|
92
92
|
const seen = new Set();
|
|
93
93
|
data.glossary.forEach((entry, i) => {
|
|
94
94
|
if (!isMap(entry) || typeof entry.name !== "string" || !entry.name.trim()) return;
|
|
@@ -116,7 +116,7 @@ export function checkGlossary(note, { index } = {}) {
|
|
|
116
116
|
),
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
|
-
if (!
|
|
119
|
+
if (!hasTerms) return;
|
|
120
120
|
const stated = entry.language !== undefined && entry.language !== null;
|
|
121
121
|
const named = stated ? languageProblem(index, entry.language) : undefined;
|
|
122
122
|
if (named) findings.push(at([i, "language"], "error", named));
|
|
@@ -133,15 +133,15 @@ export function checkGlossary(note, { index } = {}) {
|
|
|
133
133
|
"; state `language` on the entry or `data.language` on the note",
|
|
134
134
|
),
|
|
135
135
|
);
|
|
136
|
-
const senses =
|
|
136
|
+
const senses = noteTerms(index, note, entry).get(name);
|
|
137
137
|
const defined = typeof entry.definition === "string" && entry.definition.trim() !== "";
|
|
138
138
|
if (!defined && !senses)
|
|
139
139
|
findings.push(
|
|
140
140
|
at(
|
|
141
141
|
[i, "name"],
|
|
142
142
|
"error",
|
|
143
|
-
`\`${entry.name}\` names nothing: it is no word of
|
|
144
|
-
"
|
|
143
|
+
`\`${entry.name}\` names nothing: it is no word of its resolved language ` +
|
|
144
|
+
"and no rank or office that reaches this note, and the entry gives no " +
|
|
145
145
|
"`definition` to make it a term",
|
|
146
146
|
),
|
|
147
147
|
);
|