@heroiclands/package-build 22.15.0 → 22.16.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 (223) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +303 -42
  5. package/bin/report.mjs +1 -1
  6. package/ci/ci-docker.mjs +6 -3
  7. package/ci/ci-steps.mjs +6 -4
  8. package/ci/content-format-reference.mjs +106 -0
  9. package/config.mjs +22 -2
  10. package/content-config.mjs +74 -134
  11. package/docs/api.md +294 -234
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +33 -0
  14. package/docs/authoring/first-note.md +48 -0
  15. package/docs/authoring/frontmatter.md +40 -0
  16. package/docs/authoring/links-and-markup.md +137 -0
  17. package/docs/commands.md +631 -162
  18. package/docs/configuration.md +176 -140
  19. package/docs/content-format.md +50 -4229
  20. package/docs/design.md +32 -0
  21. package/docs/diagnostics.md +29 -22
  22. package/docs/getting-started.md +21 -877
  23. package/docs/guides/book.md +153 -0
  24. package/docs/guides/e2e.md +143 -0
  25. package/docs/guides/icons.md +126 -0
  26. package/docs/guides/localization.md +120 -0
  27. package/docs/guides/module-project.md +915 -0
  28. package/docs/guides/packs.md +167 -0
  29. package/docs/guides/reference-pages.md +148 -0
  30. package/docs/guides/release-and-deploy.md +114 -0
  31. package/docs/guides/site.md +152 -0
  32. package/docs/guides/system-project.md +45 -0
  33. package/docs/index.md +16 -0
  34. package/docs/project-setup.md +54 -35
  35. package/docs/reference/format-details.md +4420 -0
  36. package/docs/reference/note-types.md +531 -0
  37. package/engine/address-render.mjs +26 -0
  38. package/engine/address.mjs +3 -20
  39. package/engine/anchors.mjs +15 -6
  40. package/engine/asset-bindings.mjs +6 -2
  41. package/engine/base-compiler.mjs +47 -11
  42. package/engine/being-measurements.mjs +70 -0
  43. package/engine/calendar-artifacts.mjs +1 -1
  44. package/engine/calendar-choice.mjs +32 -0
  45. package/engine/calendar-human.mjs +71 -0
  46. package/engine/calendar-notes.mjs +41 -3
  47. package/engine/calendars.mjs +8 -1
  48. package/engine/changelog-lint.mjs +2 -2
  49. package/engine/code-fences.mjs +23 -97
  50. package/engine/compile-corpus.mjs +6 -7
  51. package/engine/content-admonitions.mjs +114 -0
  52. package/engine/content-captions.mjs +181 -0
  53. package/engine/content-embeds.mjs +2 -2
  54. package/engine/content-footnotes.mjs +34 -0
  55. package/engine/content-format-check.mjs +2 -2
  56. package/engine/content-format.mjs +127 -73
  57. package/engine/content-format.yaml +745 -0
  58. package/engine/content-html.mjs +1 -1
  59. package/engine/content-icons.mjs +45 -92
  60. package/engine/content-images.mjs +22 -74
  61. package/engine/content-index.mjs +12 -3
  62. package/engine/content-links.mjs +21 -26
  63. package/engine/content-lint.mjs +1 -5
  64. package/engine/content-secrets.mjs +3 -1
  65. package/engine/content-tables.mjs +47 -1257
  66. package/engine/culture-choice.mjs +32 -0
  67. package/engine/date-conversion.mjs +47 -0
  68. package/engine/declaration-check.mjs +87 -0
  69. package/engine/docs-checks.mjs +140 -0
  70. package/engine/documentation-links.mjs +31 -0
  71. package/engine/exported-scene.mjs +1 -1
  72. package/engine/extension-attributes.mjs +74 -0
  73. package/engine/field-reference.mjs +1 -1
  74. package/engine/field-spec.mjs +4 -0
  75. package/engine/foreign-catalog.mjs +5 -5
  76. package/engine/format-generated.mjs +15 -0
  77. package/engine/foundry-entries.mjs +3 -1
  78. package/engine/frontmatter-lint.mjs +5 -1
  79. package/engine/frontmatter.mjs +10 -0
  80. package/engine/generate.mjs +2 -2
  81. package/engine/helpers.mjs +71 -117
  82. package/engine/homepage.mjs +2 -3
  83. package/engine/index-records.mjs +1 -0
  84. package/engine/index.mjs +18 -3
  85. package/engine/infobox-registry.mjs +3 -1
  86. package/engine/infobox.mjs +38 -20
  87. package/engine/item-compiler.mjs +10 -8
  88. package/engine/item-docs.mjs +7 -6
  89. package/engine/itinerary-scenes.mjs +4 -2
  90. package/engine/journals.mjs +51 -10
  91. package/engine/macros.mjs +1 -1
  92. package/engine/map-build.mjs +1 -1
  93. package/engine/map-dot.mjs +1 -1
  94. package/engine/map-raster.mjs +4 -2
  95. package/engine/markdown-expressions.mjs +117 -0
  96. package/engine/metadata-index.mjs +7 -3
  97. package/engine/note-addresses.mjs +1 -0
  98. package/engine/note-dates.mjs +283 -61
  99. package/engine/note-package.mjs +5 -25
  100. package/engine/note-state.mjs +6 -2
  101. package/engine/note-vocabulary.mjs +74 -19
  102. package/engine/number-words.mjs +75 -0
  103. package/engine/office-holders.mjs +188 -0
  104. package/engine/pack-config.mjs +2 -15
  105. package/engine/pack-router.mjs +24 -5
  106. package/engine/packages.mjs +3 -2
  107. package/engine/pathnames.mjs +21 -24
  108. package/engine/pdf-build.mjs +217 -55
  109. package/engine/pdf-fonts.mjs +2 -2
  110. package/engine/pdf-images.mjs +82 -0
  111. package/engine/pdf-render.mjs +174 -22
  112. package/engine/populations.mjs +3 -2
  113. package/engine/project-init.mjs +499 -0
  114. package/engine/prose-config.mjs +1 -1
  115. package/engine/prose-lint.mjs +7 -6
  116. package/engine/readability-lint.mjs +86 -0
  117. package/engine/reckoning-markers.mjs +205 -93
  118. package/engine/scenes.mjs +5 -2
  119. package/engine/schema-check.mjs +3 -3
  120. package/engine/site-build.mjs +87 -37
  121. package/engine/site-config.mjs +8 -8
  122. package/engine/site-index.mjs +4 -0
  123. package/engine/site-maps.mjs +1 -1
  124. package/engine/site-preview.mjs +19 -8
  125. package/engine/social-ties.mjs +5 -1
  126. package/engine/sql-tables.mjs +92 -22
  127. package/engine/web-wikilinks.mjs +15 -31
  128. package/engine/wikilink-syntax.mjs +4 -1
  129. package/engine/wikilinks.mjs +12 -14
  130. package/hm3/item-fields.mjs +1 -1
  131. package/hm3/items.mjs +12 -45
  132. package/index.mjs +2 -14
  133. package/manifest.mjs +31 -4
  134. package/markdownlint-config.mjs +1 -1
  135. package/package.json +32 -11
  136. package/prettier-config.mjs +1 -1
  137. package/sohl/being-info.mjs +3 -145
  138. package/sohl/index.mjs +1 -1
  139. package/sohl/infobox.mjs +2 -1
  140. package/sohl/item-builders.mjs +3 -27
  141. package/sohl/item-fields.mjs +61 -22
  142. package/sohl/kb-passes.mjs +2 -2
  143. package/sohl/note-schemas.mjs +0 -8
  144. package/stage.mjs +19 -0
  145. package/types/config.d.mts +12 -3
  146. package/types/content-config.d.mts +15 -81
  147. package/types/engine/address-render.d.mts +13 -0
  148. package/types/engine/address.d.mts +2 -11
  149. package/types/engine/anchors.d.mts +3 -4
  150. package/types/engine/asset-bindings.d.mts +9 -2
  151. package/types/engine/base-compiler.d.mts +1 -2
  152. package/types/engine/being-measurements.d.mts +16 -0
  153. package/types/engine/calendar-choice.d.mts +14 -0
  154. package/types/engine/calendar-human.d.mts +4 -0
  155. package/types/engine/calendar-notes.d.mts +2 -0
  156. package/types/engine/calendars.d.mts +5 -3
  157. package/types/engine/code-fences.d.mts +6 -72
  158. package/types/engine/compile-corpus.d.mts +1 -3
  159. package/types/engine/content-admonitions.d.mts +24 -0
  160. package/types/engine/content-captions.d.mts +45 -0
  161. package/types/engine/content-footnotes.d.mts +10 -0
  162. package/types/engine/content-format-check.d.mts +17 -4
  163. package/types/engine/content-format.d.mts +24 -11
  164. package/types/engine/content-icons.d.mts +14 -23
  165. package/types/engine/content-images.d.mts +2 -2
  166. package/types/engine/content-index.d.mts +9 -2
  167. package/types/engine/content-links.d.mts +3 -5
  168. package/types/engine/content-tables.d.mts +11 -140
  169. package/types/engine/culture-choice.d.mts +14 -0
  170. package/types/engine/date-conversion.d.mts +4 -0
  171. package/types/engine/declaration-check.d.mts +24 -0
  172. package/types/engine/docs-checks.d.mts +28 -0
  173. package/types/engine/documentation-links.d.mts +7 -0
  174. package/types/engine/exported-scene.d.mts +6 -2
  175. package/types/engine/extension-attributes.d.mts +14 -0
  176. package/types/engine/field-spec.d.mts +9 -0
  177. package/types/engine/format-generated.d.mts +7 -0
  178. package/types/engine/frontmatter.d.mts +14 -4
  179. package/types/engine/generate.d.mts +1 -1
  180. package/types/engine/helpers.d.mts +37 -92
  181. package/types/engine/homepage.d.mts +2 -3
  182. package/types/engine/index.d.mts +5 -0
  183. package/types/engine/infobox-registry.d.mts +3 -1
  184. package/types/engine/infobox.d.mts +17 -6
  185. package/types/engine/item-compiler.d.mts +4 -4
  186. package/types/engine/item-docs.d.mts +4 -3
  187. package/types/engine/journals.d.mts +10 -7
  188. package/types/engine/map-graphviz.d.mts +1 -1
  189. package/types/engine/markdown-expressions.d.mts +17 -0
  190. package/types/engine/note-dates.d.mts +47 -1
  191. package/types/engine/note-package.d.mts +3 -19
  192. package/types/engine/note-state.d.mts +2 -1
  193. package/types/engine/note-vocabulary.d.mts +9 -0
  194. package/types/engine/number-words.d.mts +4 -0
  195. package/types/engine/office-holders.d.mts +14 -0
  196. package/types/engine/pack-config.d.mts +1 -1
  197. package/types/engine/packages.d.mts +3 -2
  198. package/types/engine/pdf-images.d.mts +38 -0
  199. package/types/engine/populations.d.mts +2 -2
  200. package/types/engine/project-init.d.mts +17 -0
  201. package/types/engine/prose-config.d.mts +1 -1
  202. package/types/engine/prose-lint.d.mts +3 -2
  203. package/types/engine/readability-lint.d.mts +22 -0
  204. package/types/engine/reckoning-markers.d.mts +4 -2
  205. package/types/engine/schema-check.d.mts +3 -3
  206. package/types/engine/site-build.d.mts +38 -28
  207. package/types/engine/site-config.d.mts +2 -2
  208. package/types/engine/site-index.d.mts +4 -0
  209. package/types/engine/site-preview.d.mts +1 -1
  210. package/types/engine/social-ties.d.mts +8 -2
  211. package/types/engine/sql-tables.d.mts +13 -2
  212. package/types/engine/web-wikilinks.d.mts +2 -1
  213. package/types/engine/wikilinks.d.mts +7 -1
  214. package/types/hm3/items.d.mts +5 -6
  215. package/types/labels.d.mts +40 -0
  216. package/types/manifest.d.mts +3 -3
  217. package/types/sohl/actors.d.mts +2 -2
  218. package/types/sohl/being-info.d.mts +3 -43
  219. package/types/sohl/index.d.mts +1 -1
  220. package/types/sohl/infobox.d.mts +5 -2
  221. package/types/sohl/item-fields.d.mts +4 -13
  222. package/types/stage.d.mts +9 -0
  223. package/CONTENT.md +0 -3239
@@ -15,7 +15,7 @@
15
15
  * Raw HTML in a note's prose, reported.
16
16
  *
17
17
  * **A note is markdown.** What markdown cannot say, a note does not say — it
18
- * gets a construct every surface can render, the way `:icon-…:` replaces a
18
+ * gets a construct every surface can render, the way `:icon …:` replaces a
19
19
  * pasted glyph.
20
20
  *
21
21
  * ## There is no route from `<p>` to a book
@@ -12,61 +12,22 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Naming an interface icon in a note, without drawing it there.
16
- *
17
- * The user guide describes Foundry's interface, and it did so by pasting
18
- * Unicode lookalikes of the icons the sheets actually draw: `☆` for the improve
19
- * flag, `✎` for the formula editor, `◆` in the success-value table, `★★★` for
20
- * mastery. The system renders every one of those with **Font Awesome** — a
21
- * `fa-regular fa-star`, a `fa-solid fa-pen-to-square` — so the note and the
22
- * screen it describes were drawing different pictures, and drifting apart with
23
- * every sheet change.
24
- *
25
- * They are also the worst characters in the corpus to typeset. Of the eight
26
- * book faces probed, **none** carries `✕ ✗ ✎ ☆ ⚗ ➕`; in a Libertinus
27
- * setting `✕` resolves to macOS LastResort, which draws a tofu box.
28
- *
29
- * **Neither obvious fix works.** Keeping the dingbats pins the book to some
30
- * icon-capable font forever, which is the coupling this exists to remove.
31
- * Pasting Font Awesome's own codepoints is worse: they live in the Private Use
32
- * Area, which is unassigned by definition, so they break search, copy-paste and
33
- * screen readers, and no charset check can validate them.
34
- *
35
- * So a note **names** an icon and never contains one. `:icon-star-outline:` is
36
- * ASCII, it is greppable, it survives a charset check, and it degrades to
37
- * visible literal text on any surface that has not been taught to render it —
38
- * which is the failure mode you want, because you can see it.
39
- *
40
- * **Why a registry rather than the Font Awesome classes.** Three surfaces need
41
- * three different artefacts from one name: the journals and the website want
42
- * `<i class="fa-solid fa-star">`, and the PDF wants a font file and a glyph.
43
- * Only a mapping serves both. It also means a Font Awesome major version that
44
- * renames an icon — `fa-trash-o` became `fa-trash-can` — costs one line here
45
- * rather than a sweep of the corpus, and it lets an unknown name be *reported*
46
- * instead of passing silently through as literal text.
47
- *
48
- * **The codepoint is deliberately not here.** A renderer that embeds Font
49
- * Awesome has to read the font to subset it, and the font's own `cmap` is the
50
- * only trustworthy source for which glyph a name resolves to. Writing the
51
- * codepoints out by hand would be a second copy of that table, wrong the first
52
- * time Font Awesome renumbers anything, and wrong silently. This module states
53
- * the style and the name; the renderer resolves them against the file it ships.
54
- *
55
- * **Licence.** Font Awesome Free's icons are CC BY 4.0 and its fonts SIL OFL
56
- * 1.1, so a distributed PDF may embed the subset it uses. Attribution belongs
57
- * in the book's colophon, not in every note.
58
- *
59
- * **Every finding here is a `warning`**, so nothing this module says can fail a
60
- * build — `reportFindings` fails on an error and not on a warning. An
61
- * undeclared name renders as its own literal text, which is visible on the page
62
- * and wrong in a way a reader will notice; that deserves to be reported and
63
- * does not deserve to stop a build that is otherwise correct.
15
+ * Resolve named inline font glyphs from a package's icon registry.
16
+ *
17
+ * A note writes `:icon warning:` or `:icon warning:{size=2x}`. Registry keys
18
+ * use lowercase letters, digits and hyphens. The package supplies the font,
19
+ * stylesheet, glyph name and accessible label. Unknown names remain literal
20
+ * and produce a warning; malformed syntax and attributes produce errors.
21
+ *
22
+ * The registry stores glyph names rather than font codepoints. Print reads the
23
+ * font file's character map, while HTML uses the declared style classes.
64
24
  *
65
25
  * @module
66
26
  */
67
27
 
68
28
  import fs from "node:fs";
69
29
  import path from "node:path";
30
+ import { parseExtensionAttributes } from "./extension-attributes.mjs";
70
31
 
71
32
  /**
72
33
  * A **family** is an icon font, and a consumer declares the ones it ships.
@@ -117,7 +78,7 @@ import path from "node:path";
117
78
  *
118
79
  * Empty rather than a starter set, because a starter set is a promise about
119
80
  * fonts this package does not ship. A tree with no `icons:` configured names no
120
- * icons, and `:icon-star:` in one of its notes renders as its own literal text
81
+ * icons, and `:icon star:` in one of its notes renders as its own literal text
121
82
  * and is reported — which is the visible failure, not a silent one.
122
83
  *
123
84
  * @type {IconRegistry}
@@ -203,22 +164,11 @@ export function parseIconAttributes(raw) {
203
164
  const problems = [];
204
165
  if (!raw || !raw.trim()) return { attrs, problems };
205
166
 
206
- // Split on commas, not whitespace: `size: 2x` is one pair with a space in
207
- // it, and the space after the colon is the whole point of the spelling.
208
- for (const part of raw
209
- .split(",")
210
- .map((s) => s.trim())
211
- .filter(Boolean)) {
212
- const colon = part.indexOf(":");
213
- if (colon === -1) {
214
- problems.push(
215
- `\`${part}\` is not a \`key: value\` attribute — an icon takes ` +
216
- `${Object.keys(ICON_ATTRIBUTES).join(", ")}`,
217
- );
218
- continue;
219
- }
220
- const key = part.slice(0, colon).trim();
221
- const value = part.slice(colon + 1).trim();
167
+ const parsed = parseExtensionAttributes(raw);
168
+ problems.push(...parsed.problems);
169
+ if (parsed.id) problems.push("an icon does not accept an id");
170
+ if (parsed.classes.length) problems.push("an icon does not accept classes");
171
+ for (const [key, value] of Object.entries(parsed.values)) {
222
172
  const spec =
223
173
  Object.prototype.hasOwnProperty.call(ICON_ATTRIBUTES, key) ?
224
174
  ICON_ATTRIBUTES[key]
@@ -232,7 +182,7 @@ export function parseIconAttributes(raw) {
232
182
  }
233
183
  if (!spec.values.includes(value)) {
234
184
  problems.push(
235
- `\`${key}: ${value}\` is not one of ${spec.values.join(", ")} — ` +
185
+ `\`${key}=${value}\` is not one of ${spec.values.join(", ")} — ` +
236
186
  `${key} says ${spec.describe}`,
237
187
  );
238
188
  continue;
@@ -245,25 +195,16 @@ export function parseIconAttributes(raw) {
245
195
  /**
246
196
  * The shape a note writes, and the one this module claims.
247
197
  *
248
- * The `icon-` prefix is what keeps it out of the way of an emoji shortcode: a
249
- * surface that also renders `:smile:` can tell the two apart without a lookup,
250
- * and a reader can tell what `:icon-star:` is without knowing this module
251
- * exists. Names are lowercase, digits and hyphens — the charset an address
252
- * segment already uses, so nothing new has to be explained.
253
- *
254
- * Not `:name[content]`. That is remark-directive syntax, and this toolchain
255
- * parses with markdown-it; a directive would render as its own literal text.
198
+ * The `icon` keyword distinguishes inline glyphs from asset Addresses and
199
+ * other colon-based shorthand. Names use lowercase letters, digits and
200
+ * hyphens, and each name resolves in the package's declared icon registry.
256
201
  *
257
- * An optional trailing brace carries **attributes**:
202
+ * An optional brace carries attributes, for example:
258
203
  *
259
- * :icon-affiliation:{size: 2x}
204
+ * :icon affiliation:{size=2x}
260
205
  *
261
- * `key: value` pairs, comma-separated, in the shape `markdown-it-attrs` and
262
- * remark-directive already use — so it is a convention a reader may recognise
263
- * rather than one this module invented. Attributes rather than a bare value
264
- * because `size` is merely the first one anybody needed: a fixed-width flag, a
265
- * rotation, a title override are the same shape of thing, and a syntax that
266
- * could only ever express size would have to be replaced to gain any of them.
206
+ * Attributes are whitespace-separated `key=value` pairs. The closed `size`
207
+ * values are declared in {@link ICON_SIZES}.
267
208
  *
268
209
  * One inline rule consumes the token **and** its brace, so there is no state in
269
210
  * which the icon resolves and the brace is left stranded on the page. An
@@ -271,12 +212,13 @@ export function parseIconAttributes(raw) {
271
212
  *
272
213
  * @type {RegExp}
273
214
  */
274
- export const ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/g;
215
+ export const ICON_PATTERN = /:icon ([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/g;
216
+ const OBSOLETE_ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/g;
275
217
 
276
218
  /**
277
219
  * Look one name up.
278
220
  *
279
- * @param {string} name - The name written between the colons, without `icon-`.
221
+ * @param {string} name - The name written between the colons, without `icon `.
280
222
  * @param {IconRegistry} [registry] - The package's registry.
281
223
  * @returns {object|null} The entry, or `null` when the registry does not
282
224
  * declare it.
@@ -355,17 +297,28 @@ export function iconsIn(text) {
355
297
  * Report every icon a tree names that its registry does not declare.
356
298
  *
357
299
  * The whole point of a registry is that a typo is answerable, so this is the
358
- * half that makes `:icon-stra:` a finding rather than three words of literal
300
+ * half that makes `:icon stra:` a finding rather than three words of literal
359
301
  * text nobody notices in a rendered page.
360
302
  *
361
303
  * @param {string} text - The file's contents.
362
304
  * @param {string} file - Path to report.
363
305
  * @param {IconRegistry} [registry] - The package's registry.
364
306
  * @returns {Array<{file: string, line: number, column: number,
365
- * severity: "warning", message: string}>} The unknown names.
307
+ * severity: "warning"|"error", message: string}>} The findings.
366
308
  */
367
309
  export function lintIcons(text, file, registry = EMPTY_ICON_REGISTRY) {
368
310
  const findings = [];
311
+ for (const match of text.matchAll(OBSOLETE_ICON_PATTERN)) {
312
+ const index = match.index ?? 0;
313
+ const before = text.slice(0, index);
314
+ findings.push({
315
+ file,
316
+ line: before.split("\n").length,
317
+ column: index - (before.lastIndexOf("\n") + 1) + 1,
318
+ severity: "error",
319
+ message: `\`${match[0]}\` needs the form \`:icon ${match[1]}:\``,
320
+ });
321
+ }
369
322
  for (const { name, index, raw, problems } of iconsIn(text)) {
370
323
  const before = text.slice(0, index);
371
324
  const line = before.split("\n").length;
@@ -380,7 +333,7 @@ export function lintIcons(text, file, registry = EMPTY_ICON_REGISTRY) {
380
333
  ...at,
381
334
  message:
382
335
  `\`${raw}\` names an icon the registry does not declare` +
383
- (suggestion ? `; did you mean \`:icon-${suggestion}:\`?` : "") +
336
+ (suggestion ? `; did you mean \`:icon ${suggestion}:\`?` : "") +
384
337
  ` — an undeclared name renders as its own literal text`,
385
338
  });
386
339
  // The name is the bigger fault; reporting its attributes as well
@@ -392,7 +345,7 @@ export function lintIcons(text, file, registry = EMPTY_ICON_REGISTRY) {
392
345
  // and renders differently from what was asked for, which is the case
393
346
  // nobody notices without being told.
394
347
  for (const problem of problems) {
395
- findings.push({ ...at, message: `\`${raw}\`: ${problem}` });
348
+ findings.push({ ...at, severity: "error", message: `\`${raw}\`: ${problem}` });
396
349
  }
397
350
  }
398
351
  return findings;
@@ -612,11 +565,11 @@ export function lintContentIcons(contentBase, { skipDirectories = [], registry }
612
565
  }
613
566
 
614
567
  /**
615
- * A markdown-it plugin rendering `:icon-name:` inline.
568
+ * A markdown-it plugin rendering `:icon name:` inline.
616
569
  *
617
570
  * An unknown name is left **exactly as written** rather than dropped. The name
618
571
  * is reported by {@link lintIcons}, and a rendered page that still shows
619
- * `:icon-stra:` is how the author finds it without reading a log.
572
+ * `:icon stra:` is how the author finds it without reading a log.
620
573
  *
621
574
  * **A function is accepted as well as a table**, and resolved per render. The
622
575
  * shared markdown-it instance is a module-level constant, so it is built before
@@ -645,7 +598,7 @@ export function iconPlugin(registry = EMPTY_ICON_REGISTRY) {
645
598
  if (state.src.charCodeAt(start) !== 0x3a /* : */) return false;
646
599
  // Anchored at the cursor, so the scan is O(token) rather than a
647
600
  // search of the remaining source at every colon in the paragraph.
648
- const re = /^:icon-([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/;
601
+ const re = /^:icon ([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/;
649
602
  const m = re.exec(state.src.slice(start));
650
603
  if (!m) return false;
651
604
 
@@ -16,15 +16,15 @@
16
16
  * no way to say. A directive in the curly-attribute convention Pandoc and
17
17
  * Kramdown use closes that:
18
18
  *
19
- * ![Brànwâal Dôrgaar](images/beings/branwldrgr-portrait.webp){float: top-left}
19
+ * ![Brànwâal Dôrgaar](images/beings/branwldrgr-portrait.webp){float=top-left}
20
20
  * ![Map of Thalorna](images/map.webp){.full-width}
21
21
  *
22
22
  * ## Three closed vocabularies, and closed is the point
23
23
  *
24
24
  * **Width is a class**, and the ordinary width carries no marker at all — the
25
25
  * simple case needs no spelling. {@link IMAGE_CLASSES} holds the one class
26
- * there is. **Position is `float:`**, and {@link IMAGE_FLOATS} holds the five
27
- * values it takes. **Named size is `size:`**, and {@link IMAGE_SIZES} holds its
26
+ * there is. **Position is `float`**, and {@link IMAGE_FLOATS} holds the five
27
+ * values it takes. **Named size is `size`**, and {@link IMAGE_SIZES} holds its
28
28
  * values. The HTML surfaces receive a size class; the book receives a print
29
29
  * measure.
30
30
  *
@@ -77,6 +77,7 @@ import fs from "node:fs";
77
77
  import path from "node:path";
78
78
 
79
79
  import { matchAllOutsideCode } from "./code-fences.mjs";
80
+ import { parseExtensionAttributes } from "./extension-attributes.mjs";
80
81
  import { positionInBody } from "./diagnostics.mjs";
81
82
  import { foundryAddressProblem, pathnameProblem, servesFoundry } from "./pathnames.mjs";
82
83
 
@@ -117,7 +118,7 @@ export const IMAGE_SIZES = Object.freeze([
117
118
  ]);
118
119
 
119
120
  /**
120
- * The `float:` positions an image may take, and where each puts it.
121
+ * The `float` positions an image may take, and where each puts it.
121
122
  *
122
123
  * `align` is the Typst alignment the float is placed at. **Print cannot wrap
123
124
  * text around an arbitrary shape**: a Typst float occupies the column measure,
@@ -235,7 +236,6 @@ export function parseImageDirective(raw) {
235
236
  /** @type {string[]} */
236
237
  const classes = [];
237
238
  let size = "auto";
238
- let sizeSeen = false;
239
239
  let float = "";
240
240
  /** @type {string[]} */
241
241
  const problems = [];
@@ -245,75 +245,23 @@ export function parseImageDirective(raw) {
245
245
  .replace(/\}$/, "");
246
246
  if (!inner.trim()) return { classes, size, float, problems };
247
247
 
248
- // Split on commas, not whitespace: `float: top-left` is one pair with a
249
- // space in it, and the space after the colon is the spelling people write.
250
- for (const part of inner
251
- .split(",")
252
- .map((s) => s.trim())
253
- .filter(Boolean)) {
254
- if (part.startsWith(".")) {
255
- const name = part.slice(1);
256
- if (!Object.prototype.hasOwnProperty.call(IMAGE_CLASSES, name)) {
257
- problems.push(
258
- `\`.${name}\` is not a width an image has — the width there is ` +
259
- `is \`.${Object.keys(IMAGE_CLASSES).join("`, `.")}\`, and an image ` +
260
- "with no class at all is the ordinary width",
261
- );
262
- continue;
263
- }
264
- if (classes.includes(name)) {
265
- problems.push(`\`.${name}\` is written twice, and an image has one width`);
266
- continue;
267
- }
268
- classes.push(name);
269
- continue;
270
- }
271
-
272
- const colon = part.indexOf(":");
273
- if (colon === -1) {
274
- problems.push(
275
- `\`${part}\` is neither a width class nor \`size: <name>\` or ` +
276
- "`float: <position>` — an image states its width as a class and its position as `float:`, " +
277
- "and it states no dimensions at all",
278
- );
279
- continue;
280
- }
281
- const key = part.slice(0, colon).trim();
282
- const value = part.slice(colon + 1).trim();
248
+ const parsed = parseExtensionAttributes(inner);
249
+ problems.push(...parsed.problems);
250
+ if (parsed.id) problems.push("an image does not accept an id");
251
+ for (const name of parsed.classes) {
252
+ if (!Object.hasOwn(IMAGE_CLASSES, name)) problems.push(`.${name} is not an image width`);
253
+ else classes.push(name);
254
+ }
255
+ for (const [key, value] of Object.entries(parsed.values)) {
283
256
  if (key === "size") {
284
- if (!IMAGE_SIZES.includes(value)) {
285
- problems.push(
286
- `\`size: ${value}\` is not a size — the ones there are: ${IMAGE_SIZES.join(", ")}`,
287
- );
288
- continue;
289
- }
290
- if (sizeSeen) {
291
- problems.push("`size:` is written twice, and an image has one named size");
292
- continue;
293
- }
294
- size = value;
295
- sizeSeen = true;
296
- continue;
297
- }
298
- if (key !== "float") {
299
- problems.push(
300
- `\`${key}\` is not an image attribute — use \`size\` or \`float\`, ` +
301
- "and width is a class rather than an attribute",
302
- );
303
- continue;
304
- }
305
- if (!Object.prototype.hasOwnProperty.call(IMAGE_FLOATS, value)) {
306
- problems.push(
307
- `\`float: ${value}\` is not a position — the ones there are: ` +
308
- `${Object.keys(IMAGE_FLOATS).join(", ")}`,
309
- );
310
- continue;
311
- }
312
- if (float) {
313
- problems.push("`float:` is written twice, and an image sits in one place");
314
- continue;
315
- }
316
- float = value;
257
+ if (!IMAGE_SIZES.includes(value))
258
+ problems.push(`size=${value} is not a named image size`);
259
+ else size = value;
260
+ } else if (key === "float") {
261
+ if (!Object.hasOwn(IMAGE_FLOATS, value))
262
+ problems.push(`float=${value} is not an image position`);
263
+ else float = value;
264
+ } else problems.push(`${key} is not an image attribute`);
317
265
  }
318
266
 
319
267
  if (classes.length > 1) {
@@ -666,7 +614,7 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
666
614
  * A markdown-it plugin that reads an image's directive and renders its figure.
667
615
  *
668
616
  * **A core rule, not an inline one.** markdown-it's own `image` rule consumes
669
- * `![alt](src)` and leaves `{float: top-left}` behind as text, and a rule
617
+ * `![alt](src)` and leaves `{float=top-left}` behind as text, and a rule
670
618
  * running before it would have to re-implement link parsing to find the brace.
671
619
  * Reading the token stream afterwards costs one pass and re-implements nothing.
672
620
  *
@@ -33,8 +33,7 @@
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
35
  * renames — a reader addresses `sohl.body.weight.base` because that is what the
36
- * note says, which is also, not by accident, exactly what a `dataview` query
37
- * writes.
36
+ * note says, which a SQL query can address directly.
38
37
  *
39
38
  * **JSON Lines rather than a database.** The artifact has to survive its build
40
39
  * and be usable by anything — a person with `jq`, an editor, a CI check,
@@ -46,7 +45,7 @@
46
45
  * nested access — whereas a stored schema would forfeit the open shape.
47
46
  *
48
47
  * **Byte-stable, because it is meant to be rebuilt.** {@link emitContentIndex}
49
- * is reachable on its own (`content-build content-index`) and costs a
48
+ * is reachable on its own (`package-build content-index`) and costs a
50
49
  * frontmatter parse, not a build, so the honest expectation is that anyone
51
50
  * regenerates it whenever they want rather than treating it as precious. That
52
51
  * only holds if two runs over an unchanged tree produce an identical file, so
@@ -152,6 +151,7 @@ import { isDraftNote } from "./note-vocabulary.mjs";
152
151
  // a record is built, so the index carries it without the note being written to.
153
152
  import { applyComputedBeingAge, presentAmongFrontmatters } from "./being-age.mjs";
154
153
  import { reckoningContext } from "./reckoning-markers.mjs";
154
+ import { resolvedDateFields } from "./note-dates.mjs";
155
155
 
156
156
  export {
157
157
  authoredFrontmatter,
@@ -441,6 +441,8 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
441
441
  * @param {number} [options.bodyLine] - The 1-based file line the body starts on.
442
442
  * @param {object} [options.manifest] - The package manifest, which the Foundry
443
443
  * entries are derived against.
444
+ * @param {object} [options.addressContext] - Address resolution context.
445
+ * @param {object} [options.dateContext] - Calendar conversion context.
444
446
  * @returns {Record<string, any>} The record, keys sorted at every depth. A
445
447
  * **stub** — a note with an empty body, on a type an empty body suppresses —
446
448
  * carries `address` and `anchors` as `null`: it publishes no page, so it
@@ -461,6 +463,7 @@ export function buildIndexRecord({
461
463
  bodyLine,
462
464
  manifest,
463
465
  addressContext,
466
+ dateContext,
464
467
  }) {
465
468
  assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
466
469
 
@@ -494,11 +497,13 @@ export function buildIndexRecord({
494
497
  // its file is what the author edits and what every diagnostic names, and
495
498
  // its `id` is the identity every diagnostic and every table reads.
496
499
  const stub = isStubNote(frontmatter, body);
500
+ const resolvedDates = dateContext ? resolvedDateFields(frontmatter, dateContext) : {};
497
501
 
498
502
  return /** @type {Record<string, any>} */ (
499
503
  decodeIndexAddresses(
500
504
  {
501
505
  ...frontmatter,
506
+ ...(Object.keys(resolvedDates).length ? { resolvedDates } : {}),
502
507
  package: contentPackage,
503
508
  address: stub ? null : address,
504
509
  nameAscii: asciiName(frontmatter?.name?.full),
@@ -609,6 +614,7 @@ export function indexRecordsForNote({
609
614
  contentPackage,
610
615
  manifest,
611
616
  addressContext,
617
+ dateContext,
612
618
  }) {
613
619
  resolveNoteId(frontmatter, { pkg: contentPackage });
614
620
  const record = buildIndexRecord({
@@ -620,6 +626,7 @@ export function indexRecordsForNote({
620
626
  contentPackage,
621
627
  manifest,
622
628
  addressContext,
629
+ dateContext,
623
630
  });
624
631
  const records = [decodeIndexAddresses(record, addressContext ?? { package: contentPackage })];
625
632
  const address = noteAddress(frontmatter, contentPackage);
@@ -663,6 +670,7 @@ export function indexRecordsForNote({
663
670
  * anyway.
664
671
  * @param {object} [options.manifest] - The package manifest, which the Foundry
665
672
  * entries are derived against.
673
+ * @param {object} [options.addressContext] - Address resolution context.
666
674
  * @param {object[]} [options.problems] - Supplied by a **reader**: a note that
667
675
  * cannot be recorded is pushed here as a diagnostic and skipped. Omitted, the
668
676
  * note throws — the contract the emitter needs, since an index missing a note
@@ -710,6 +718,7 @@ export function collectContentIndex(
710
718
  bodyLine,
711
719
  manifest,
712
720
  addressContext,
721
+ dateContext: dates,
713
722
  }),
714
723
  );
715
724
  } catch (err) {
@@ -78,8 +78,8 @@ import { foundryAddressProblem, servesFoundry } from "./pathnames.mjs";
78
78
  import { hasDocEntry } from "./item-docs.mjs";
79
79
  import { ownDocumentSystem } from "./address.mjs";
80
80
  import { NOTE_SYSTEM } from "./systems.mjs";
81
+ import { isGmNote } from "./note-vocabulary.mjs";
81
82
  import { loadPackConfig } from "./pack-config.mjs";
82
- import { searchableFrontmatter } from "./note-package.mjs";
83
83
  import {
84
84
  blockSystem,
85
85
  canonicalKey,
@@ -301,18 +301,8 @@ export function buildLinkIndex(
301
301
  foreign: foreign.index,
302
302
  };
303
303
 
304
- /** The searchable universe a `dataview` table draws its rows from. */
305
- const tableDocs = notes.map((n) => ({
306
- // Package present for a `WHERE … package = "…"` clause, synthesised
307
- // rather than authored — see {@link searchableFrontmatter}.
308
- fm: searchableFrontmatter(n.fm, pkg),
309
- path: n.rel,
310
- tld: n.rel.split("/")[0],
311
- folder: path.dirname(n.rel).split("/").pop(),
312
- }));
313
-
314
304
  /**
315
- * One note's body with its `dataview` and `sql` tables expanded.
305
+ * One note's body with its SQL tables expanded.
316
306
  *
317
307
  * The body every body-level check reads, so a link and an embed in one note
318
308
  * are found in the same text — a generated table is as free to carry either
@@ -323,12 +313,8 @@ export function buildLinkIndex(
323
313
  */
324
314
  function expandedBody(note) {
325
315
  const body = note.body;
326
- if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:dataview|sql)\b/im.test(body)) return body;
316
+ if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*sql\b/im.test(body)) return body;
327
317
  return expandContentTables(body, {
328
- // Unfiltered: every note in the tree is this package's, so
329
- // there is no other package's note to exclude.
330
- docs: tableDocs,
331
- linkable: (d) => Boolean(d.fm.shortcode),
332
318
  source: note.file,
333
319
  // A `sql` table's links are checked like an authored one's, so
334
320
  // its rows are prepared ahead of this walk — see
@@ -392,7 +378,7 @@ export function buildLinkIndex(
392
378
  }
393
379
 
394
380
  /**
395
- * Every wikilink in a note body, with its `dataview` tables expanded.
381
+ * Every wikilink in a note body, with its SQL tables expanded.
396
382
  *
397
383
  * An `![[…]]` embed is not one: it names a file rather than a note, and
398
384
  * {@link module:engine/wikilink-syntax.WIKILINK} excludes it so that no
@@ -779,9 +765,8 @@ function readAddress(url, packages) {
779
765
  *
780
766
  * **Why the homepage needs its own audit at all.** Every other note addresses
781
767
  * the corpus with wikilinks, which {@link auditLinks} resolves. A homepage does
782
- * not and cannot: it is published *verbatim* by every publishing mode, including
783
- * the homepage-only mode two fan-licensed packages ship under, where the content
784
- * tree is never walked and there is no index for a wikilink to resolve against.
768
+ * not and cannot: it is published *verbatim* even when the tree contains other
769
+ * notes. Its body never runs through the wikilink resolver.
785
770
  * So a homepage addresses the web the way the web does — markdown links in its
786
771
  * body — and this is what looks at those. A dead link on the page a reader
787
772
  * arrives at is the one nothing else would report.
@@ -796,8 +781,7 @@ function readAddress(url, packages) {
796
781
  * is why every one is reported — including a bare `/<package>/`, which names
797
782
  * another package's front page. A front page's address *is* its package
798
783
  * prefix, so `/<package>/` is the absolute URL with the host struck off —
799
- * host-free, emitted verbatim, and needing no index, which is what lets it
800
- * hold in homepage-only mode where the tree is never walked.
784
+ * host-free, emitted verbatim, and needing no index.
801
785
  * - A **wikilink**, which nothing on this page will ever resolve.
802
786
  *
803
787
  * **What is not checkable, and is not attempted.** Whether an external URL
@@ -842,7 +826,7 @@ export function auditHomepageLinks(index) {
842
826
  all,
843
827
  at(all),
844
828
  `wikilink ${all} on the package homepage — a homepage is ` +
845
- `published verbatim in every publishing mode, so nothing ` +
829
+ `published verbatim, so nothing ` +
846
830
  `resolves it; write a markdown link, package-relative`,
847
831
  );
848
832
  }
@@ -1004,7 +988,13 @@ export function auditLinks(index) {
1004
988
  deadAddresses.push({ ...at, reason: "not-an-address" });
1005
989
  continue;
1006
990
  }
1007
- if (index.resolveAddress(target)) continue;
991
+ const resolved = index.resolveAddress(target);
992
+ if (resolved) {
993
+ if ((isGmNote(resolved.fm) || resolved.gm) && !isGmNote(note.fm)) {
994
+ deadAddresses.push({ ...at, reason: "gm" });
995
+ }
996
+ continue;
997
+ }
1008
998
  // A stub is in the index, so the refusal can name it. Asked before
1009
999
  // the foreign manifests, because a local note is what the author
1010
1000
  // meant and reporting it as an unresolved foreign address would
@@ -1019,7 +1009,12 @@ export function auditLinks(index) {
1019
1009
  // a fully qualified target names one package, so there is nothing
1020
1010
  // left to disambiguate, and no ambiguity finding this resolver can
1021
1011
  // report.
1022
- if (manifestHit(target)) {
1012
+ const manifest = manifestHit(target);
1013
+ if (manifest) {
1014
+ if (manifest.gm && !isGmNote(note.fm)) {
1015
+ deadAddresses.push({ ...at, reason: "gm" });
1016
+ continue;
1017
+ }
1023
1018
  usedManifest.add(encodeAddresses(target).toLowerCase());
1024
1019
  continue;
1025
1020
  }
@@ -394,11 +394,7 @@ export function lintContentTree(
394
394
  // thing this guard needs them to do. A tree holding notes is therefore a
395
395
  // tree; only a tree holding none is the absent one.
396
396
  //
397
- // The homepage is not the headline example, though it was once addressed
398
- // by the package rather than by a slug — so a `publish.site: homepage`
399
- // package had a tree with exactly one note and no key at all. It carries an
400
- // address like every other note now; the guard is unchanged, because
401
- // what it reads was never the key count.
397
+ // A tree containing only a homepage still contains one note.
402
398
  if (notes.length === 0) {
403
399
  findings.push({
404
400
  file: path.relative(process.cwd(), contentBase) || contentBase,
@@ -4,8 +4,10 @@
4
4
 
5
5
  import crypto from "node:crypto";
6
6
  import markdownit from "markdown-it";
7
+ import footnotePlugin from "markdown-it-footnote";
8
+ import deflistPlugin from "markdown-it-deflist";
7
9
 
8
- const webMarkdown = markdownit({ html: true });
10
+ const webMarkdown = markdownit({ html: true }).use(footnotePlugin).use(deflistPlugin);
9
11
 
10
12
  /**
11
13
  * Render whole-line `:::secret` blocks for one publishing surface.