@heroiclands/package-build 22.21.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +26 -3
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +73 -4
  8. package/docs/api.md +36 -18
  9. package/docs/authoring/dates-and-calendars.md +4 -2
  10. package/docs/authoring/frontmatter.md +2 -2
  11. package/docs/authoring/links-and-markup.md +237 -28
  12. package/docs/configuration.md +27 -1
  13. package/docs/content-format.md +3 -1
  14. package/docs/guides/reference-pages.md +15 -0
  15. package/docs/reference/format-details.md +224 -46
  16. package/docs/reference/note-types.md +21 -4
  17. package/engine/actor-compiler.mjs +23 -0
  18. package/engine/anchored-sections.mjs +35 -4
  19. package/engine/anchors.mjs +10 -20
  20. package/engine/base-compiler.mjs +86 -21
  21. package/engine/base-styles.mjs +149 -0
  22. package/engine/bundles.mjs +0 -1
  23. package/engine/compendiums.mjs +8 -1
  24. package/engine/content-blocks.mjs +410 -0
  25. package/engine/content-captions.mjs +57 -8
  26. package/engine/content-footnotes.mjs +189 -0
  27. package/engine/content-format-check.mjs +15 -12
  28. package/engine/content-index.mjs +2 -2
  29. package/engine/content-links.mjs +14 -6
  30. package/engine/content-tables.mjs +45 -4
  31. package/engine/content-tasklists.mjs +189 -0
  32. package/engine/docs-checks.mjs +4 -4
  33. package/engine/extension-attributes.mjs +48 -2
  34. package/engine/frontmatter-lint.mjs +47 -64
  35. package/engine/frontmatter.mjs +3 -3
  36. package/engine/heading-attributes.mjs +308 -0
  37. package/engine/helpers.mjs +14 -13
  38. package/engine/homepage.mjs +8 -8
  39. package/engine/index.mjs +3 -0
  40. package/engine/infobox-render.mjs +15 -3
  41. package/engine/infobox.mjs +83 -8
  42. package/engine/journals.mjs +36 -17
  43. package/engine/macros.mjs +1 -1
  44. package/engine/markdown-expressions.mjs +178 -53
  45. package/engine/note-dates.mjs +49 -0
  46. package/engine/note-frontmatter.mjs +7 -16
  47. package/engine/note-vocabulary.mjs +172 -37
  48. package/engine/page-lists.mjs +358 -0
  49. package/engine/pdf-build.mjs +76 -42
  50. package/engine/pdf-render.mjs +422 -49
  51. package/engine/rank-ladder.mjs +198 -0
  52. package/engine/reckoning-markers.mjs +1 -1
  53. package/engine/retired-fields.mjs +8 -13
  54. package/engine/scenes.mjs +10 -3
  55. package/engine/site-build.mjs +41 -12
  56. package/engine/site-config.mjs +10 -1
  57. package/engine/site-index.mjs +18 -2
  58. package/engine/site-preview.mjs +18 -7
  59. package/engine/social-ties.mjs +52 -14
  60. package/engine/sql-tables.mjs +66 -9
  61. package/engine/standings.mjs +89 -6
  62. package/engine/system-block.mjs +12 -14
  63. package/engine/web-wikilinks.mjs +48 -1
  64. package/hm3/actors.mjs +6 -3
  65. package/manifest.mjs +13 -0
  66. package/package.json +1 -1
  67. package/sohl/actors.mjs +22 -99
  68. package/sohl/item-fields.mjs +4 -5
  69. package/sohl/items.mjs +1 -1
  70. package/sohl/note-schemas.mjs +11 -21
  71. package/types/config.d.mts +9 -0
  72. package/types/engine/actor-compiler.d.mts +11 -0
  73. package/types/engine/anchored-sections.d.mts +24 -0
  74. package/types/engine/base-compiler.d.mts +19 -0
  75. package/types/engine/base-styles.d.mts +93 -0
  76. package/types/engine/content-blocks.d.mts +87 -0
  77. package/types/engine/content-captions.d.mts +14 -1
  78. package/types/engine/content-footnotes.d.mts +73 -0
  79. package/types/engine/content-format-check.d.mts +8 -6
  80. package/types/engine/content-tables.d.mts +12 -4
  81. package/types/engine/content-tasklists.d.mts +72 -0
  82. package/types/engine/extension-attributes.d.mts +22 -1
  83. package/types/engine/frontmatter.d.mts +1 -1
  84. package/types/engine/heading-attributes.d.mts +174 -0
  85. package/types/engine/helpers.d.mts +7 -4
  86. package/types/engine/homepage.d.mts +7 -7
  87. package/types/engine/index.d.mts +1 -0
  88. package/types/engine/infobox-render.d.mts +6 -1
  89. package/types/engine/journals.d.mts +14 -7
  90. package/types/engine/markdown-expressions.d.mts +23 -0
  91. package/types/engine/note-frontmatter.d.mts +1 -4
  92. package/types/engine/note-vocabulary.d.mts +82 -6
  93. package/types/engine/page-lists.d.mts +126 -0
  94. package/types/engine/pdf-render.d.mts +72 -2
  95. package/types/engine/rank-ladder.d.mts +32 -0
  96. package/types/engine/retired-fields.d.mts +4 -6
  97. package/types/engine/site-build.d.mts +13 -1
  98. package/types/engine/site-config.d.mts +9 -0
  99. package/types/engine/site-index.d.mts +13 -1
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/sql-tables.d.mts +31 -7
  102. package/types/engine/standings.d.mts +21 -3
  103. package/types/engine/system-block.d.mts +11 -12
  104. package/types/engine/web-wikilinks.d.mts +3 -1
  105. package/types/hm3/actors.d.mts +3 -3
  106. package/types/sohl/actors.d.mts +6 -7
  107. package/engine/content-admonitions.mjs +0 -114
  108. package/engine/content-secrets.mjs +0 -112
  109. package/types/engine/content-admonitions.d.mts +0 -24
  110. 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
- * Top level is otherwise **open** — an unrecognised key there is a Hugo or
277
- * theme parameter this build has no standing to refuse — so this set exists
278
- * only to keep a note's own identity fields from being mistaken for a
279
- * misplaced `data` property where the two share a name. `type` and `subType`
280
- * are the pair that matters: several mapping tables name `subType` as a shared
281
- * source, and it is authored at top level by design.
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 = Object.freeze(
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. Top level is open in general — but a key the type's
383
- // own table declares is not an unrecognised one, it is one whose home the
384
- // format states.
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)) {
@@ -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 what the
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,
@@ -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 carry either
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 `sql` table's links are checked like an authored one's, so
320
- // its rows are prepared ahead of this walk — see
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 tables while retaining source lines.
7
- * Query execution happens in the asynchronous SQL preparation pass.
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] - Prepared results.
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
+ }
@@ -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.match(/\{#([^}]+)\}\s*$/);
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 (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
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
+ }