@heroiclands/package-build 22.20.0 → 22.22.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-content-build.md +0 -8
- package/CHANGELOG.md +204 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +46 -28
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +265 -42
- package/docs/reference/note-types.md +45 -28
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/frontmatter-lint.mjs +62 -66
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +19 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +21 -4
- package/engine/infobox.mjs +167 -7
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-addresses.mjs +17 -8
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +216 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/reference-targets.mjs +13 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +39 -4
- package/engine/site-preview.mjs +18 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standing-terms.mjs +19 -0
- package/engine/standings.mjs +538 -0
- package/engine/svg-theme.mjs +15 -4
- package/engine/system-block.mjs +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/engine/wikilinks.mjs +3 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +82 -103
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +98 -8
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pdf-render.d.mts +72 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standing-terms.d.mts +16 -0
- package/types/engine/standings.d.mts +147 -0
- package/types/engine/system-block.d.mts +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/engine/wikilinks.d.mts +2 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +25 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-secrets.d.mts +0 -17
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Where a footnote's two halves belong, and the two findings that hold the
|
|
5
|
+
* line.
|
|
6
|
+
*
|
|
7
|
+
* A reference (`[^id]`) may be written anywhere prose can be written — a
|
|
8
|
+
* paragraph, a list item, a block quote, a table cell, a heading, a
|
|
9
|
+
* definition-list term. A definition (`[^id]: text`) belongs at the top
|
|
10
|
+
* level of the note, the one place {@link separateFootnotes} looks for it:
|
|
11
|
+
* the web and Foundry can resolve one nested in a list or a block quote, but
|
|
12
|
+
* the book cannot, so the three surfaces would otherwise disagree about what
|
|
13
|
+
* one note means. {@link misplacedFootnoteDefinitions} and
|
|
14
|
+
* {@link unresolvedFootnoteReferences} are what every surface reports
|
|
15
|
+
* instead of rendering the three different wrong things a misplaced or
|
|
16
|
+
* missing definition otherwise produces.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where a character offset in `source` falls, as a 1-based line and column.
|
|
25
|
+
* @param {string} source - The text the offset indexes into.
|
|
26
|
+
* @param {number} offset - A 0-based character offset.
|
|
27
|
+
* @returns {{line: number, column: number}}
|
|
28
|
+
*/
|
|
29
|
+
function lineColumn(source, offset) {
|
|
30
|
+
const before = source.slice(0, offset);
|
|
31
|
+
const nl = before.lastIndexOf("\n");
|
|
32
|
+
return { line: before.split("\n").length, column: offset - nl };
|
|
33
|
+
}
|
|
34
|
+
|
|
3
35
|
/**
|
|
4
36
|
* Separate top-level footnote definitions from prose while retaining line numbers.
|
|
5
37
|
* Definitions belong to the note and can be used by any rendered page.
|
|
@@ -32,3 +64,160 @@ export function separateFootnotes(source) {
|
|
|
32
64
|
}
|
|
33
65
|
return { markdown: body.join("\n"), definitions: definitions.join("\n\n") };
|
|
34
66
|
}
|
|
67
|
+
|
|
68
|
+
/** A top-level footnote definition's opening line. */
|
|
69
|
+
const TOP_LEVEL_DEF = /^\[\^([^\]\s]+)\]:/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A list marker or a block quote marker, stripped as many times as it
|
|
73
|
+
* repeats — a block quote can hold a list, and either can nest.
|
|
74
|
+
*/
|
|
75
|
+
const CONTAINER_PREFIX = /^(?:[ \t]{0,3}>[ \t]?|[ \t]{0,3}(?:[-*+]|\d{1,9}[.)])[ \t]+)+/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A footnote definition written inside a list item or a block quote, where
|
|
79
|
+
* the web and Foundry can resolve it but the book drops it silently.
|
|
80
|
+
*
|
|
81
|
+
* A definition inside a table cell is not reported here: a table cell is
|
|
82
|
+
* inline content, so `[^id]:` there is never recognised as a definition on
|
|
83
|
+
* any surface and simply prints as written — the reference it was meant to
|
|
84
|
+
* answer is what {@link unresolvedFootnoteReferences} reports.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} source - A note's markdown body.
|
|
87
|
+
* @returns {Array<{line: number, column: number, label: string, message: string}>}
|
|
88
|
+
*/
|
|
89
|
+
export function misplacedFootnoteDefinitions(source) {
|
|
90
|
+
const lines = String(source ?? "").split("\n");
|
|
91
|
+
const errors = [];
|
|
92
|
+
let fence = null;
|
|
93
|
+
// Inside the continuation of a genuine top-level definition: a blank
|
|
94
|
+
// line or a four-space indent belongs to it, not to a new mistake.
|
|
95
|
+
let continuation = false;
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
const line = lines[i];
|
|
98
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
99
|
+
if (fence) {
|
|
100
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length)
|
|
101
|
+
fence = null;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (marker) {
|
|
105
|
+
fence = marker[1];
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (TOP_LEVEL_DEF.test(line)) {
|
|
109
|
+
continuation = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (continuation) {
|
|
113
|
+
if (!line.trim() || /^(?: {4}|\t)/.test(line)) continue;
|
|
114
|
+
continuation = false;
|
|
115
|
+
}
|
|
116
|
+
const prefix = CONTAINER_PREFIX.exec(line);
|
|
117
|
+
if (!prefix) continue;
|
|
118
|
+
const rest = line.slice(prefix[0].length);
|
|
119
|
+
const match = TOP_LEVEL_DEF.exec(rest);
|
|
120
|
+
if (!match) continue;
|
|
121
|
+
errors.push({
|
|
122
|
+
line: i + 1,
|
|
123
|
+
column: prefix[0].length + 1,
|
|
124
|
+
label: match[1],
|
|
125
|
+
message:
|
|
126
|
+
"a footnote definition belongs at the top level of the note " +
|
|
127
|
+
"— a reference may be written anywhere, but move this " +
|
|
128
|
+
`\`[^${match[1]}]:\` definition out of the list or block quote`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A footnote reference with no top-level definition to resolve against.
|
|
136
|
+
*
|
|
137
|
+
* Covers both commoner slips alike: a label never defined at all, and one
|
|
138
|
+
* "defined" only inside a table cell, which is inline content and never
|
|
139
|
+
* registers as a definition on any surface. A label misplaced in a list or a
|
|
140
|
+
* block quote is excluded — {@link misplacedFootnoteDefinitions} already
|
|
141
|
+
* reports that mistake at the definition's own line, and reporting it again
|
|
142
|
+
* at the reference would say the same thing twice.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} source - A note's markdown body.
|
|
145
|
+
* @param {string} [definitions] - Its top-level definitions, when a caller
|
|
146
|
+
* already extracted them with {@link separateFootnotes}. Derived from
|
|
147
|
+
* `source` when omitted; a caller already holding both passes its own
|
|
148
|
+
* extraction instead, so the two cannot disagree about what is defined.
|
|
149
|
+
* @returns {Array<{line: number, column: number, message: string}>}
|
|
150
|
+
*/
|
|
151
|
+
export function unresolvedFootnoteReferences(source, definitions) {
|
|
152
|
+
const src = String(source ?? "");
|
|
153
|
+
const resolvedAgainst = definitions ?? separateFootnotes(src).definitions;
|
|
154
|
+
const resolved = new Set([...resolvedAgainst.matchAll(/\[\^([^\]\s]+)\]:/g)].map((m) => m[1]));
|
|
155
|
+
const misplaced = new Set(misplacedFootnoteDefinitions(src).map((e) => e.label));
|
|
156
|
+
const errors = [];
|
|
157
|
+
for (const match of matchAllOutsideCode(src, /\[\^([^\]\s]+)\](?!:)/g)) {
|
|
158
|
+
const label = match[1];
|
|
159
|
+
if (resolved.has(label) || misplaced.has(label)) continue;
|
|
160
|
+
errors.push({
|
|
161
|
+
...lineColumn(src, match.index),
|
|
162
|
+
message:
|
|
163
|
+
`footnote [^${label}] has no definition, so it is set as text — write ` +
|
|
164
|
+
`\`[^${label}]: …\` at the top level of the note`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return errors;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A top-level footnote definition no reference anywhere in the note uses.
|
|
172
|
+
*
|
|
173
|
+
* `separateFootnotes` lifts it out correctly, nothing resolves against it,
|
|
174
|
+
* and the paragraph it held vanishes from every surface with the build
|
|
175
|
+
* exiting 0 — a dropped paragraph is not something a reader can notice is
|
|
176
|
+
* missing, so it is an error here rather than a warning, the same severity
|
|
177
|
+
* every other silent loss this pass catches already carries. A note mid-edit,
|
|
178
|
+
* with the reference already deleted and the definition not yet followed, is
|
|
179
|
+
* exactly the case this refuses — which is the point: the alternative is
|
|
180
|
+
* shipping it.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} source - A note's markdown body.
|
|
183
|
+
* @returns {Array<{line: number, column: number, message: string}>}
|
|
184
|
+
*/
|
|
185
|
+
export function unusedFootnoteDefinitions(source) {
|
|
186
|
+
const src = String(source ?? "");
|
|
187
|
+
const { definitions } = separateFootnotes(src);
|
|
188
|
+
const referenced = new Set(
|
|
189
|
+
[...matchAllOutsideCode(src, /\[\^([^\]\s]+)\](?!:)/g)].map((m) => m[1]),
|
|
190
|
+
);
|
|
191
|
+
const errors = [];
|
|
192
|
+
for (const match of definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)) {
|
|
193
|
+
const label = match[1];
|
|
194
|
+
if (referenced.has(label)) continue;
|
|
195
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
196
|
+
const at = new RegExp(`^\\[\\^${escaped}\\]:`, "m").exec(src);
|
|
197
|
+
if (!at) continue;
|
|
198
|
+
errors.push({
|
|
199
|
+
...lineColumn(src, at.index),
|
|
200
|
+
message:
|
|
201
|
+
`footnote [^${label}] is defined but no reference uses it — delete the ` +
|
|
202
|
+
`definition, or add a [^${label}] where it belongs`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return errors;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Every footnote placement finding for one note body — the union
|
|
210
|
+
* {@link misplacedFootnoteDefinitions}, {@link unresolvedFootnoteReferences}
|
|
211
|
+
* and {@link unusedFootnoteDefinitions} report, in document order.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} source - A note's markdown body.
|
|
214
|
+
* @returns {Array<{line: number, column: number, message: string}>}
|
|
215
|
+
*/
|
|
216
|
+
export function footnoteFindings(source) {
|
|
217
|
+
const misplaced = misplacedFootnoteDefinitions(source).map(({ label, ...rest }) => rest);
|
|
218
|
+
const unresolved = unresolvedFootnoteReferences(source);
|
|
219
|
+
const unused = unusedFootnoteDefinitions(source);
|
|
220
|
+
return [...misplaced, ...unresolved, ...unused].sort(
|
|
221
|
+
(a, b) => a.line - b.line || a.column - b.column,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -104,6 +104,9 @@ import { authoredFields } from "./field-spec.mjs";
|
|
|
104
104
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
105
105
|
import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
|
|
106
106
|
import { authoredKey } from "./system-block.mjs";
|
|
107
|
+
// The top-level vocabulary, read rather than restated — a second list of the
|
|
108
|
+
// keys a note's own identity uses is one more thing to drift.
|
|
109
|
+
import { NOTE_TOP_LEVEL_KEY_SET } from "./note-vocabulary.mjs";
|
|
107
110
|
|
|
108
111
|
/**
|
|
109
112
|
* Every field path any subtype of a published schema declares.
|
|
@@ -273,18 +276,18 @@ function nearest(key, candidates) {
|
|
|
273
276
|
/**
|
|
274
277
|
* Top-level keys the format names as the note's own, whatever its type.
|
|
275
278
|
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
279
|
+
* The top-level vocabulary itself, read rather than restated: this set exists
|
|
280
|
+
* to keep a note's own identity fields from being mistaken for a misplaced
|
|
281
|
+
* `data` property where the two share a name, and the keys that are a note's
|
|
282
|
+
* own are exactly the ones the region declares. `type` and `subType` are the
|
|
283
|
+
* pair that matters — several mapping tables name `subType` as a shared source,
|
|
284
|
+
* and it is authored at top level by design. A key the region does *not*
|
|
285
|
+
* declare, such as `id` beside `data.id`, is reported as the misplaced `data`
|
|
286
|
+
* property it is.
|
|
282
287
|
*
|
|
283
288
|
* @type {ReadonlySet<string>}
|
|
284
289
|
*/
|
|
285
|
-
export const NOTE_LEVEL_KEYS =
|
|
286
|
-
new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"]),
|
|
287
|
-
);
|
|
290
|
+
export const NOTE_LEVEL_KEYS = NOTE_TOP_LEVEL_KEY_SET;
|
|
288
291
|
|
|
289
292
|
/** Whether a value is a plain object a block could be written as. */
|
|
290
293
|
function isBlock(value) {
|
|
@@ -379,9 +382,9 @@ export function measureNote(note, format, { severity = "warning" } = {}) {
|
|
|
379
382
|
}
|
|
380
383
|
|
|
381
384
|
// The two regions a shared source is written in today, and neither is where
|
|
382
|
-
// the format puts it.
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
+
// the format puts it. A key the type's own table declares is not merely an
|
|
386
|
+
// unrecognised one — it is one whose home the format states, so the finding
|
|
387
|
+
// names that home rather than the vocabulary.
|
|
385
388
|
for (const key of spec.dataKeys) {
|
|
386
389
|
if (NOTE_LEVEL_KEYS.has(key)) continue;
|
|
387
390
|
if (Object.hasOwn(fm, key)) {
|
package/engine/content-index.mjs
CHANGED
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
* over 15 types, from 9 on a `macro` to 72 on a `being`, and adding a field to
|
|
33
33
|
* one type is ordinary authoring. Any format that fixes a column set would turn
|
|
34
34
|
* that authoring into a schema migration, so nothing here selects, flattens, or
|
|
35
|
-
* renames — a reader addresses `sohl.body.weight.base` because that is
|
|
36
|
-
* note says, which a SQL query can address directly.
|
|
35
|
+
* renames — a reader addresses `sohl.system.body.weight.base` because that is
|
|
36
|
+
* what the note says, which a SQL query can address directly.
|
|
37
37
|
*
|
|
38
38
|
* **JSON Lines rather than a database.** The artifact has to survive its build
|
|
39
39
|
* and be usable by anything — a person with `jq`, an editor, a CI check,
|
package/engine/content-links.mjs
CHANGED
|
@@ -302,24 +302,25 @@ export function buildLinkIndex(
|
|
|
302
302
|
};
|
|
303
303
|
|
|
304
304
|
/**
|
|
305
|
-
* One note's body with its SQL tables expanded.
|
|
305
|
+
* One note's body with its SQL tables and page lists expanded.
|
|
306
306
|
*
|
|
307
307
|
* The body every body-level check reads, so a link and an embed in one note
|
|
308
|
-
* are found in the same text — a generated table is as free to
|
|
309
|
-
* as prose is.
|
|
308
|
+
* are found in the same text — a generated table or list is as free to
|
|
309
|
+
* carry either as prose is.
|
|
310
310
|
*
|
|
311
311
|
* @param {object} note - A note from this index.
|
|
312
312
|
* @returns {string} The markdown.
|
|
313
313
|
*/
|
|
314
314
|
function expandedBody(note) {
|
|
315
315
|
const body = note.body;
|
|
316
|
-
if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*sql\b/im.test(body)) return body;
|
|
316
|
+
if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)) return body;
|
|
317
317
|
return expandContentTables(body, {
|
|
318
318
|
source: note.file,
|
|
319
|
-
// A
|
|
320
|
-
//
|
|
319
|
+
// A generated link is checked like an authored one, so both
|
|
320
|
+
// directives are prepared ahead of this walk — see
|
|
321
321
|
// {@link module:engine/sql-tables.prepareTreeSqlTables}.
|
|
322
322
|
sqlTables: sqlTables?.get(note.file),
|
|
323
|
+
pageLists: sqlTables?.pageLists?.get(note.file),
|
|
323
324
|
}).markdown;
|
|
324
325
|
}
|
|
325
326
|
|
|
@@ -954,6 +955,13 @@ export function auditLinks(index) {
|
|
|
954
955
|
deadAnchors.push({
|
|
955
956
|
note,
|
|
956
957
|
link: `${rawTarget}#${anchor}`,
|
|
958
|
+
// The two the shared `unknown-anchor` wording reads, in the
|
|
959
|
+
// same spelling the build's own pass gives them: the
|
|
960
|
+
// address alone, and the slug the heading would have had to
|
|
961
|
+
// declare. Carrying them is what lets one sentence serve
|
|
962
|
+
// the checker and the build.
|
|
963
|
+
target: rawTarget,
|
|
964
|
+
anchor: slugify(anchor),
|
|
957
965
|
dest,
|
|
958
966
|
text,
|
|
959
967
|
occurrence,
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
2
|
|
|
3
3
|
import { FENCE_LINE } from "./code-fences.mjs";
|
|
4
|
+
import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Replace prepared SQL fences with Markdown
|
|
7
|
-
*
|
|
7
|
+
* Replace prepared SQL fences and page lists with Markdown while retaining
|
|
8
|
+
* source lines.
|
|
9
|
+
*
|
|
10
|
+
* Both directives read the content index, so both are answered before this
|
|
11
|
+
* pass runs — query execution in the asynchronous SQL preparation pass, page
|
|
12
|
+
* lists in {@link module:engine/page-lists.preparePageLists} — and each is
|
|
13
|
+
* looked up by the ordinal of its own kind of fence within the body.
|
|
14
|
+
*
|
|
8
15
|
* @param {string} markdown - The authored body.
|
|
9
|
-
* @param {{source?: string, sqlTables?: object[]}} [context] -
|
|
16
|
+
* @param {{source?: string, sqlTables?: object[], pageLists?: object[]}} [context] -
|
|
17
|
+
* Prepared results.
|
|
10
18
|
* @returns {{markdown: string, errors: object[], warnings: object[], lineMap: object[]}}
|
|
11
19
|
*/
|
|
12
|
-
export function expandContentTables(markdown, { source = "", sqlTables } = {}) {
|
|
20
|
+
export function expandContentTables(markdown, { source = "", sqlTables, pageLists } = {}) {
|
|
13
21
|
const lines = String(markdown ?? "").split("\n");
|
|
14
22
|
const out = [];
|
|
15
23
|
const lineMap = [];
|
|
16
24
|
const errors = [];
|
|
17
25
|
const warnings = [];
|
|
18
26
|
let sqlOrdinal = 0;
|
|
27
|
+
let pageListOrdinal = 0;
|
|
19
28
|
const emit = (value, line, generated = false) => {
|
|
20
29
|
out.push(value);
|
|
21
30
|
lineMap.push({ line, generated });
|
|
@@ -41,6 +50,38 @@ export function expandContentTables(markdown, { source = "", sqlTables } = {}) {
|
|
|
41
50
|
column: indent.length + 1,
|
|
42
51
|
});
|
|
43
52
|
}
|
|
53
|
+
if (language === PAGE_LIST_LANGUAGE) {
|
|
54
|
+
// The ordinal is consumed whether or not the fence was closed, so
|
|
55
|
+
// the two readings agree: `findPageListBlocks` records an unclosed
|
|
56
|
+
// directive as a directive with a problem rather than skipping it.
|
|
57
|
+
const prepared = pageLists?.[pageListOrdinal++];
|
|
58
|
+
const failure =
|
|
59
|
+
!prepared ? "page list was not prepared"
|
|
60
|
+
: prepared.reason ? prepared.reason
|
|
61
|
+
: prepared.pages === 0 && !prepared.allowEmpty ? prepared.empty
|
|
62
|
+
: undefined;
|
|
63
|
+
if (failure) {
|
|
64
|
+
errors.push({
|
|
65
|
+
source,
|
|
66
|
+
directive: block.join("\n"),
|
|
67
|
+
reason: failure,
|
|
68
|
+
line: i,
|
|
69
|
+
column: indent.length + 1,
|
|
70
|
+
});
|
|
71
|
+
block.forEach((value, offset) => emit(value, i + offset));
|
|
72
|
+
i = close;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// A list the directive permitted to be empty leaves nothing
|
|
76
|
+
// behind, rather than a blank line where a list would have been.
|
|
77
|
+
if (prepared.markdown) {
|
|
78
|
+
if (out.length && out.at(-1).trim()) emit("", i, true);
|
|
79
|
+
for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
|
|
80
|
+
if (close + 1 < lines.length && lines[close + 1].trim()) emit("", i, true);
|
|
81
|
+
}
|
|
82
|
+
i = close;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
44
85
|
if (language !== "sql" || close >= lines.length) {
|
|
45
86
|
block.forEach((value, offset) => emit(value, i + offset));
|
|
46
87
|
i = close;
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
* A list item opened with `[ ]` or `[x]`, reported.
|
|
16
|
+
*
|
|
17
|
+
* **The format has no checkbox.** A list is written in plain markdown, and
|
|
18
|
+
* `[ ]` or `[x]` right after the marker is read as the start of the item's own
|
|
19
|
+
* text on every surface — stating what is done belongs in the words, not in a
|
|
20
|
+
* bracket.
|
|
21
|
+
*
|
|
22
|
+
* ## What it does not look at
|
|
23
|
+
*
|
|
24
|
+
* - **Fenced blocks and code spans.** A checklist shown as a markup example is
|
|
25
|
+
* prose *about* the two characters, not an authored list — this module's own
|
|
26
|
+
* documentation is written out of such examples. Which runs count as code is
|
|
27
|
+
* {@link module:engine/code-fences.codeRegions}' rule rather than a second
|
|
28
|
+
* copy of it.
|
|
29
|
+
* - **A bracket anywhere else in a line.** Only the marker that opens a list
|
|
30
|
+
* item is read this way; the same two characters in the middle of a sentence
|
|
31
|
+
* are prose.
|
|
32
|
+
*
|
|
33
|
+
* ## A warning, like the checks around it
|
|
34
|
+
*
|
|
35
|
+
* Nothing here is miscompiled — every surface sets the marker as the item's
|
|
36
|
+
* own text, and all three agree. The finding is for the author: the two
|
|
37
|
+
* characters read as an interactive control only on one surface, and nowhere
|
|
38
|
+
* else, so a list reaching for that meaning should say what is done in words
|
|
39
|
+
* instead.
|
|
40
|
+
*
|
|
41
|
+
* @module
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import fs from "node:fs";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
|
|
47
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
48
|
+
import { positionInBody } from "./diagnostics.mjs";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A list item's marker, immediately followed by `[ ]` or `[x]` and a space —
|
|
52
|
+
* bulleted (`-`, `*`, `+`) or ordered (`1.` or `1)`), at the start of a line.
|
|
53
|
+
*
|
|
54
|
+
* The leading indentation is a lookbehind rather than part of the match, so a
|
|
55
|
+
* nested item's finding points at the marker itself, not at the line's first
|
|
56
|
+
* column.
|
|
57
|
+
*
|
|
58
|
+
* @type {RegExp}
|
|
59
|
+
*/
|
|
60
|
+
export const TASK_LIST_MARKER = /(?<=^[ \t]*)(?:[-*+]|\d+[.)])[ \t]+\[[ xX]\][ \t]/gm;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a note opening a list item with `[ ]` or `[x]` is told.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} marker - The matched marker, as written.
|
|
66
|
+
* @returns {string} The message, unpunctuated at the end as a finding is.
|
|
67
|
+
*/
|
|
68
|
+
export function taskListMessage(marker) {
|
|
69
|
+
return (
|
|
70
|
+
`\`${marker.trim()}\` opens a list item with no checkbox to become — ` +
|
|
71
|
+
"write a checklist as an ordinary list, stating what is done in the " +
|
|
72
|
+
"item's own words. Inside a fence or a code span it is an example, and " +
|
|
73
|
+
"not reported"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every list item opened with `[ ]` or `[x]` in one note's body.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} body - The note's markdown, without its frontmatter.
|
|
81
|
+
* @param {string} file - The note's path, for the finding.
|
|
82
|
+
* @param {object} [opts]
|
|
83
|
+
* @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
|
|
84
|
+
* @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
|
|
85
|
+
* @returns {Array<{file: string, line: number, column: number,
|
|
86
|
+
* severity: "warning", message: string}>} One finding per item, in source
|
|
87
|
+
* order.
|
|
88
|
+
*/
|
|
89
|
+
export function checkTaskLists(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
|
|
90
|
+
const text = String(body ?? "");
|
|
91
|
+
if (!text) return [];
|
|
92
|
+
|
|
93
|
+
return matchAllOutsideCode(text, TASK_LIST_MARKER).map((match) => ({
|
|
94
|
+
file,
|
|
95
|
+
...positionInBody(text, /** @type {number} */ (match.index), { bodyLine, bodyColumn }),
|
|
96
|
+
severity: /** @type {"warning"} */ ("warning"),
|
|
97
|
+
message: taskListMessage(match[0]),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Walk a content tree and report every list item opened with `[ ]` or `[x]`.
|
|
103
|
+
*
|
|
104
|
+
* The frontmatter fence is taken off first, so what is scanned is the body and
|
|
105
|
+
* the positions are still the file's. A file with no frontmatter is scanned
|
|
106
|
+
* whole: it is not a note, but a stray `.md` in the tree carrying the marker is
|
|
107
|
+
* the same problem for the same reason.
|
|
108
|
+
*
|
|
109
|
+
* A finding names its file **relative to the working directory**, which is
|
|
110
|
+
* where a reader is standing and what `formatDiagnostic` emits. The content
|
|
111
|
+
* root is where the walk starts, not what a path is measured from.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} contentBase - Root of the content tree.
|
|
114
|
+
* @param {object} [opts]
|
|
115
|
+
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
|
|
116
|
+
* in addition to the dot-directories always skipped.
|
|
117
|
+
* @returns {{findings: Array<{file: string, line: number, column: number,
|
|
118
|
+
* severity: "warning", message: string}>, files: number}} The findings, and
|
|
119
|
+
* how many files were read.
|
|
120
|
+
*/
|
|
121
|
+
export function lintContentTaskLists(contentBase, { skipDirectories = [] } = {}) {
|
|
122
|
+
const skip = new Set(skipDirectories);
|
|
123
|
+
/** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
|
|
124
|
+
const findings = [];
|
|
125
|
+
let files = 0;
|
|
126
|
+
|
|
127
|
+
/** @param {string} dir - Directory to descend into. */
|
|
128
|
+
const walk = (dir) => {
|
|
129
|
+
/** @type {import("node:fs").Dirent[]} */
|
|
130
|
+
let entries;
|
|
131
|
+
try {
|
|
132
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
|
|
138
|
+
const full = path.join(dir, entry.name);
|
|
139
|
+
if (entry.isDirectory()) {
|
|
140
|
+
walk(full);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!/\.(md|markdown)$/i.test(entry.name)) continue;
|
|
144
|
+
let content;
|
|
145
|
+
try {
|
|
146
|
+
content = fs.readFileSync(full, "utf8");
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
files += 1;
|
|
151
|
+
findings.push(
|
|
152
|
+
...checkTaskLists(...bodyOf(content, path.relative(process.cwd(), full))),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
walk(contentBase);
|
|
158
|
+
return { findings, files };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A file's body, its path, and where the body starts in the file.
|
|
163
|
+
*
|
|
164
|
+
* The same split {@link module:engine/helpers.parseMarkdownFile} makes, without
|
|
165
|
+
* parsing the YAML: this check has no use for the frontmatter's *values*, and
|
|
166
|
+
* reading them would make an unparseable note silently unscanned.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} content - The whole file.
|
|
169
|
+
* @param {string} file - Its path, for the finding.
|
|
170
|
+
* @returns {[string, string, {bodyLine: number, bodyColumn: number}]} The
|
|
171
|
+
* arguments {@link checkTaskLists} takes.
|
|
172
|
+
*/
|
|
173
|
+
function bodyOf(content, file) {
|
|
174
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
175
|
+
if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
|
|
176
|
+
|
|
177
|
+
const raw = match[2];
|
|
178
|
+
const body = raw.trim();
|
|
179
|
+
const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
|
|
180
|
+
const before = content.slice(0, bodyStart);
|
|
181
|
+
return [
|
|
182
|
+
body,
|
|
183
|
+
file,
|
|
184
|
+
{
|
|
185
|
+
bodyLine: before.split("\n").length,
|
|
186
|
+
bodyColumn: bodyStart - before.lastIndexOf("\n"),
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
}
|
package/engine/docs-checks.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
8
|
+
import { splitHeadingAttributes } from "./heading-attributes.mjs";
|
|
9
|
+
|
|
8
10
|
/** @param {string} root */
|
|
9
11
|
function* markdownFiles(root) {
|
|
10
12
|
for (const entry of fs
|
|
@@ -40,11 +42,9 @@ export function docAnchors(source) {
|
|
|
40
42
|
const body = maskMarkdownCode(source, false);
|
|
41
43
|
const anchors = new Set();
|
|
42
44
|
for (const [, heading] of body.matchAll(/^#{1,6}\s+(.+?)\s*#*$/gm)) {
|
|
43
|
-
const explicit = heading
|
|
45
|
+
const explicit = splitHeadingAttributes(heading);
|
|
44
46
|
anchors.add(
|
|
45
|
-
explicit
|
|
46
|
-
explicit[1].trim()
|
|
47
|
-
: docHeadingSlug(heading.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")),
|
|
47
|
+
explicit.id || docHeadingSlug(explicit.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")),
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
for (const [, anchor] of body.matchAll(/<a\s[^>]*(?:id|name)="([^"]+)"/g)) {
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* A name an id, a class or an attribute key may carry.
|
|
5
|
+
*
|
|
6
|
+
* One letter first, then letters, digits, `_` and `-`: a CSS class and an
|
|
7
|
+
* attribute key are written this way wherever they appear.
|
|
8
|
+
*/
|
|
9
|
+
const NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
10
|
+
|
|
3
11
|
/**
|
|
4
12
|
* Read the shared braced attribute grammar used by body extensions.
|
|
5
13
|
* Callers validate which names and classes their extension understands.
|
|
6
14
|
* @param {string} source - Text inside the braces.
|
|
15
|
+
* @param {object} [options={}]
|
|
16
|
+
* @param {RegExp} [options.idPattern=NAME] - What an `#id` may be named. A
|
|
17
|
+
* construct whose id is published as an HTML `id` admits a digit first and
|
|
18
|
+
* passes a looser pattern; the default is the pattern every other name takes.
|
|
7
19
|
* @returns {{id: string, classes: string[], values: Record<string, string>, problems: string[]}}
|
|
8
20
|
*/
|
|
9
|
-
export function parseExtensionAttributes(source) {
|
|
21
|
+
export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
|
|
10
22
|
const text = String(source ?? "");
|
|
11
23
|
const result = { id: "", classes: [], values: {}, problems: [] };
|
|
12
24
|
let at = 0;
|
|
@@ -18,7 +30,7 @@ export function parseExtensionAttributes(source) {
|
|
|
18
30
|
const kind = text[at++];
|
|
19
31
|
while (at < text.length && !/\s/.test(text[at])) at++;
|
|
20
32
|
const name = text.slice(start + 1, at);
|
|
21
|
-
if (
|
|
33
|
+
if (!(kind === "#" ? idPattern : NAME).test(name)) {
|
|
22
34
|
result.problems.push(
|
|
23
35
|
`invalid ${kind === "#" ? "id" : "class"} attribute ${text.slice(start, at)}`,
|
|
24
36
|
);
|
|
@@ -72,3 +84,37 @@ export function booleanAttribute(value, key) {
|
|
|
72
84
|
if (value === "false") return false;
|
|
73
85
|
throw new TypeError(`${key} needs the literal value true or false`);
|
|
74
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Attributes a construct's own markup owns, so an author sets them through
|
|
90
|
+
* `#id` and `.class` rather than through a key.
|
|
91
|
+
*/
|
|
92
|
+
const OWNED = Object.freeze(["id", "class"]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The attribute keys no construct writes onto an element.
|
|
96
|
+
*
|
|
97
|
+
* `id=` and `class=` are refused in favour of `#id` and `.class`, which the
|
|
98
|
+
* grammar already states; a key beginning `on` is an event handler, and nothing
|
|
99
|
+
* authored in a note becomes code on a published page.
|
|
100
|
+
*
|
|
101
|
+
* Shared by every construct that writes an author's attributes onto an element,
|
|
102
|
+
* because a refusal that held on one and not the next would be the hole the
|
|
103
|
+
* other construct is checked for.
|
|
104
|
+
*
|
|
105
|
+
* @param {Record<string, string>} values - The `key=value` attributes parsed.
|
|
106
|
+
* @returns {string[]} One message per refused key, in the order written.
|
|
107
|
+
*/
|
|
108
|
+
export function refusedAttributes(values) {
|
|
109
|
+
const problems = [];
|
|
110
|
+
for (const key of Object.keys(values ?? {})) {
|
|
111
|
+
if (OWNED.includes(key.toLowerCase())) {
|
|
112
|
+
problems.push(
|
|
113
|
+
`set ${key} with ${key.toLowerCase() === "id" ? "#id" : ".class"} rather than ${key}=`,
|
|
114
|
+
);
|
|
115
|
+
} else if (/^on/i.test(key)) {
|
|
116
|
+
problems.push(`${key} is an event handler and is not written`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return problems;
|
|
120
|
+
}
|