@heroiclands/package-build 23.9.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 +26 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +197 -68
- package/docs/api.md +336 -68
- package/docs/authoring/links-and-markup.md +114 -47
- package/docs/commands.md +92 -3
- package/docs/content-format.md +6 -1
- package/docs/reference/format-details.md +129 -14
- package/docs/reference/note-types.md +4 -4
- package/engine/anchors.mjs +8 -6
- 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 +14 -1
- package/engine/content-charset.mjs +13 -1
- 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 +102 -23
- package/engine/content-links.mjs +27 -12
- 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 +1 -0
- package/engine/content-tasklists.mjs +20 -4
- package/engine/diagnostics.mjs +17 -6
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +12 -9
- package/engine/generate.mjs +13 -4
- package/engine/helpers.mjs +43 -14
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +9 -5
- package/engine/language-data.mjs +2 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +4 -4
- package/engine/lexical-sections.mjs +5 -5
- 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/path-charset.mjs +24 -1
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-index.mjs +5 -1
- package/engine/sql-tables.mjs +76 -15
- package/engine/stub-lint.mjs +4 -3
- 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 +39 -19
- 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-tasklists.d.mts +5 -1
- package/types/engine/diagnostics.d.mts +10 -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 +11 -4
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/helpers.d.mts +30 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/lexical-sections.d.mts +3 -2
- 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/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +2 -2
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +7 -2
- package/types/engine/stub-lint.d.mts +3 -1
|
@@ -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
|
+
}
|
|
@@ -110,15 +110,16 @@ export const LEAD_ANCHOR = "$lead";
|
|
|
110
110
|
* @param {string} entryId - The entry's id, which page ids hash against.
|
|
111
111
|
* @param {string} body - The note's markdown body.
|
|
112
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.
|
|
113
114
|
* @returns {Record<string, string>} The anchors.
|
|
114
115
|
*/
|
|
115
|
-
export function anchorsOf(entryUuid, entryId, body, name) {
|
|
116
|
+
export function anchorsOf(entryUuid, entryId, body, name, syntax) {
|
|
116
117
|
const anchors = {};
|
|
117
|
-
splitPages(body, name).forEach((page, index) => {
|
|
118
|
+
splitPages(body, name, undefined, syntax).forEach((page, index) => {
|
|
118
119
|
const uuid = pageUuid(entryUuid, journalPageId(entryId, page));
|
|
119
120
|
const words = new Set(
|
|
120
|
-
scanSpans(page.markdown)
|
|
121
|
-
.
|
|
121
|
+
(syntax ? syntax(page.markdown).spans : scanSpans(page.markdown)).spans
|
|
122
|
+
.filter((span) =>
|
|
122
123
|
span.attrs.some(
|
|
123
124
|
([key, value]) =>
|
|
124
125
|
key === "class" && value.split(/\s+/).includes(DICTIONARY_ENTRY_CLASS),
|
|
@@ -128,7 +129,7 @@ export function anchorsOf(entryUuid, entryId, body, name) {
|
|
|
128
129
|
);
|
|
129
130
|
if (index === 0) anchors[LEAD_ANCHOR] = uuid;
|
|
130
131
|
if (page.anchorSlug) anchors[page.anchorSlug] = uuid;
|
|
131
|
-
for (const anchor of collectAnchors(page.markdown))
|
|
132
|
+
for (const anchor of collectAnchors(page.markdown, 1, undefined, syntax))
|
|
132
133
|
anchors[anchor.slug] = words.has(anchor.slug) ? `${uuid}#${anchor.slug}` : uuid;
|
|
133
134
|
});
|
|
134
135
|
return anchors;
|
|
@@ -155,8 +156,7 @@ export function anchorsOf(entryUuid, entryId, body, name) {
|
|
|
155
156
|
* documentation journal's is {@link itemDocEntryId} of that. Stating it here is
|
|
156
157
|
* what lets the content index publish an identity it did not re-derive.
|
|
157
158
|
*
|
|
158
|
-
* @param {object} ctx - Resolved identities
|
|
159
|
-
* 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.
|
|
160
160
|
* @returns {Array<object>} One or two entries, in {@link buildManifest}'s shape.
|
|
161
161
|
*/
|
|
162
162
|
export function entriesForNote(fm, name, address, body, ctx) {
|
|
@@ -288,7 +288,10 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
288
288
|
// UUID's last segment.
|
|
289
289
|
id: docEntryId,
|
|
290
290
|
uuid: docUuid,
|
|
291
|
-
anchors:
|
|
291
|
+
anchors:
|
|
292
|
+
docUuid ?
|
|
293
|
+
anchorsOf(docUuid, docEntryId, body ?? "", name, ctx.syntax)
|
|
294
|
+
: undefined,
|
|
292
295
|
},
|
|
293
296
|
];
|
|
294
297
|
}
|
|
@@ -307,7 +310,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
307
310
|
systemUuids: systemUuidsFor(fm.type, fm.id),
|
|
308
311
|
anchors:
|
|
309
312
|
own && packForType(fm.type)?.docType === "JournalEntry" ?
|
|
310
|
-
anchorsOf(own, fm.id, body ?? "", name)
|
|
313
|
+
anchorsOf(own, fm.id, body ?? "", name, ctx.syntax)
|
|
311
314
|
: undefined,
|
|
312
315
|
},
|
|
313
316
|
];
|
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
|
|