@heroiclands/package-build 22.25.0 → 22.27.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 (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +4 -0
  3. package/assets/styles/content-base.css +46 -3
  4. package/docs/api.md +35 -12
  5. package/docs/authoring/frontmatter.md +21 -0
  6. package/docs/authoring/links-and-markup.md +96 -149
  7. package/docs/content-format.md +6 -2
  8. package/docs/guides/government-migration.md +52 -0
  9. package/docs/guides/site.md +5 -5
  10. package/docs/reference/format-details.md +152 -246
  11. package/docs/reference/note-types.md +27 -28
  12. package/docs/reference/theme-pages.md +33 -29
  13. package/engine/anchors.mjs +85 -13
  14. package/engine/art-fields.mjs +1 -1
  15. package/engine/base-compiler.mjs +9 -1
  16. package/engine/bundles.mjs +1 -1
  17. package/engine/calendar-notes.mjs +1 -1
  18. package/engine/code-fences.mjs +6 -2
  19. package/engine/content-alerts.mjs +195 -0
  20. package/engine/content-blocks.mjs +119 -241
  21. package/engine/content-embeds.mjs +1 -1
  22. package/engine/content-figures.mjs +166 -230
  23. package/engine/content-format-check.mjs +3 -2
  24. package/engine/content-format.yaml +1 -5
  25. package/engine/content-images.mjs +3 -3
  26. package/engine/content-poetry.mjs +141 -0
  27. package/engine/content-spans.mjs +177 -0
  28. package/engine/foundry-entries.mjs +2 -0
  29. package/engine/frontmatter-lint.mjs +2 -1
  30. package/engine/heading-attributes.mjs +1 -1
  31. package/engine/helpers.mjs +8 -3
  32. package/engine/holdings.mjs +197 -333
  33. package/engine/index.mjs +1 -1
  34. package/engine/infobox.mjs +11 -1
  35. package/engine/journals.mjs +8 -6
  36. package/engine/markdown-expressions.mjs +9 -3
  37. package/engine/metadata-index.mjs +5 -3
  38. package/engine/note-vocabulary.mjs +30 -13
  39. package/engine/pdf-build.mjs +1 -1
  40. package/engine/pdf-render.mjs +90 -31
  41. package/engine/populations.mjs +17 -83
  42. package/engine/site-build.mjs +44 -16
  43. package/engine/site-preview.mjs +22 -6
  44. package/engine/wikilinks.mjs +18 -4
  45. package/hugo-theme/layouts/partials/holdings/entry.html +2 -2
  46. package/hugo-theme/layouts/partials/holdings.html +14 -14
  47. package/hugo-theme/static/css/style.css +40 -2
  48. package/package.json +1 -1
  49. package/sohl/item-fields.mjs +0 -16
  50. package/types/engine/anchors.d.mts +13 -4
  51. package/types/engine/art-fields.d.mts +1 -1
  52. package/types/engine/calendar-notes.d.mts +1 -1
  53. package/types/engine/code-fences.d.mts +3 -1
  54. package/types/engine/content-alerts.d.mts +62 -0
  55. package/types/engine/content-blocks.d.mts +3 -10
  56. package/types/engine/content-embeds.d.mts +1 -1
  57. package/types/engine/content-figures.d.mts +13 -20
  58. package/types/engine/content-images.d.mts +1 -1
  59. package/types/engine/content-poetry.d.mts +32 -0
  60. package/types/engine/content-spans.d.mts +18 -0
  61. package/types/engine/holdings.d.mts +75 -118
  62. package/types/engine/infobox.d.mts +1 -0
  63. package/types/engine/markdown-expressions.d.mts +1 -1
  64. package/types/engine/note-vocabulary.d.mts +14 -2
  65. package/types/engine/populations.d.mts +4 -13
  66. package/types/engine/site-build.d.mts +3 -3
  67. package/types/engine/wikilinks.d.mts +5 -1
@@ -177,7 +177,7 @@ export function resolveEmbed(index, embed) {
177
177
  * The role an address's asset declares, read through the same resolution an
178
178
  * embed's address takes.
179
179
  *
180
- * This is the lookup a `:::figure` fence's `map` counter reaches through —
180
+ * This is the lookup a captioned item's `map` counter reaches through —
181
181
  * see {@link module:engine/content-figures.scanFigures}'s `resolveRole` — for
182
182
  * an address still in its authored form, before {@link resolveEmbeds} has
183
183
  * rewritten it into the ordinary image every surface renders.
@@ -2,83 +2,22 @@
2
2
  * SPDX-License-Identifier: GPL-3.0-or-later
3
3
  */
4
4
 
5
- /**
6
- * `:::figure` — the fence that declares what is numbered and captioned.
7
- *
8
- * ```
9
- * :::figure {#thorn .border}
10
- * ![[being-foobar|The Great Beast]]
11
- * ///
12
- * The great beast, as drawn by [[person-havard]].
13
- * :::
14
- * ```
15
- *
16
- * The fence states the unit, so nothing is inferred about scope and a group —
17
- * two portraits as one plate, a table with its key beneath it — is one figure
18
- * with one number.
19
- *
20
- * **The caption is optional and the fence is what numbers a thing.** A fence
21
- * with no `///` draws its label alone. So the rule is "no fence, no number":
22
- * a bare embed takes no number and nothing is drawn beneath it.
23
- *
24
- * **`{#id}` is optional too.** A fence that declares none is numbered and drawn
25
- * and carries no anchor, so nothing can reference it.
26
- *
27
- * ## The split is lexical
28
- *
29
- * The first `///` at the top level of the body divides the contents from the
30
- * caption, and it is found **on the raw lines, before any markdown parsing**.
31
- * The alternative delimiters are claimed by the grammar: `---` or `===` on the
32
- * line after a paragraph is a setext heading, so an image tight against one
33
- * becomes an `<h2>` with nothing said. `///` has no CommonMark meaning, which
34
- * leaves a genuine thematic break available inside a grouped figure.
35
- *
36
- * A delimiter inside a nested code fence is content: a captioned listing
37
- * carries `///` as a doc comment, so the scan for both the delimiter and the
38
- * closing `:::` tracks nested fences and reads neither inside one.
39
- *
40
- * ## The kind is derived, never authored
41
- *
42
- * A table is self-evidently a table and a lone picture a figure, so the counter
43
- * comes from the fence's contents: a table is `table`, an `sql` fence is
44
- * `table`, any other fence or code block is `code`, pictures alone are
45
- * `figure`, and everything else is `prose`. A fence around prose, with
46
- * `.border`, is a numbered, referable boxed aside, which is what `Prose` names.
47
- *
48
- * **A picture counts as `map` instead of `figure` when every picture the fence
49
- * holds is one.** The fence's own markup cannot say so — a picture of a map
50
- * looks like any other picture — so the fact travels through a `resolveRole`
51
- * function the caller supplies, from the address each picture draws (an
52
- * embed's address, an image's path, or an `<img>` tag's `src`) to the role its
53
- * asset declares. This pass resolves no address of its own; `resolveRole` is
54
- * the one seam a caller reaches through with whatever lookup it already holds.
55
- * A caller that supplies none, or whose lookup answers nothing for a picture,
56
- * gets `figure` — the same reading as a picture with a role other than `map`.
57
- *
58
- * ## The class vocabulary is closed
59
- *
60
- * {@link FIGURE_CLASSES} holds what an author may write, and a class outside it
61
- * is a finding naming the class — so `.border` does not become the first of
62
- * twenty presentational classes. `key=value` is refused: the construct takes an
63
- * id and classes.
64
- *
65
- * @module
66
- */
5
+ /** Leading captions apply to the next supported Markdown block. @module */
67
6
 
7
+ import { spanMarkdownPlugin } from "./content-spans.mjs";
68
8
  import MarkdownIt from "markdown-it";
69
9
  import footnotePlugin from "markdown-it-footnote";
70
10
  import deflistPlugin from "markdown-it-deflist";
71
11
 
72
12
  import { slugify } from "./content-slug.mjs";
73
- import { parseExtensionAttributes } from "./extension-attributes.mjs";
74
- import { HEADING_LINE, parseHeadingLine, splitHeadingAttributes } from "./heading-attributes.mjs";
13
+ import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
14
+ import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
75
15
  import { IMAGE_PATTERN } from "./content-images.mjs";
76
16
 
77
- const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
78
- const OPEN = /^:::figure(?:[ \t]+(\{[^}\n]*\}))?[ \t]*$/;
79
- const FIGURE_LINE = /^:::figure\b/;
80
- const CLOSE = /^:::\s*$/;
81
- const SPLIT = /^\/\/\/\s*$/;
17
+ const parser = new MarkdownIt({ html: true })
18
+ .use(footnotePlugin)
19
+ .use(deflistPlugin)
20
+ .use(spanMarkdownPlugin);
82
21
  const FENCE = /^ {0,3}(`{3,}|~{3,})/;
83
22
 
84
23
  /**
@@ -104,6 +43,7 @@ const HTML_IMG_SRC = /<img\b[^>]*\bsrc="([^"]*)"/g;
104
43
  /** A figure's automatically determined kind, and the word that labels it. */
105
44
  export const FIGURE_NAMES = Object.freeze({
106
45
  code: "Code",
46
+ example: "Example",
107
47
  table: "Table",
108
48
  figure: "Figure",
109
49
  map: "Map",
@@ -112,12 +52,8 @@ export const FIGURE_NAMES = Object.freeze({
112
52
  });
113
53
 
114
54
  /**
115
- * The classes a figure takes.
116
- *
117
- * `.border` states that the captioned thing is drawn inside a border, and each
118
- * medium owns what that means. The vocabulary is closed: a class absent from it
119
- * is a finding naming the class.
120
- *
55
+ * Historical built-in presentation classes, retained for theme consumers.
56
+ * Captions now accept arbitrary classes through the shared attribute grammar.
121
57
  * @type {readonly string[]}
122
58
  */
123
59
  export const FIGURE_CLASSES = Object.freeze(["border"]);
@@ -125,6 +61,7 @@ export const FIGURE_CLASSES = Object.freeze(["border"]);
125
61
  /** Number a sequence of figures with counters shared across documents. */
126
62
  export function numberFigures(figures, counts) {
127
63
  return figures.map((figure) => {
64
+ if (figure.numbered === false) return { ...figure, number: 0, label: "" };
128
65
  const number = (counts[figure.kind] ?? 0) + 1;
129
66
  counts[figure.kind] = number;
130
67
  return { ...figure, number, label: `${FIGURE_NAMES[figure.kind]} ${number}` };
@@ -208,7 +145,7 @@ function picturesAreMap(contents, resolveRole) {
208
145
  * @returns {"code"|"table"|"figure"|"map"|"poem"|"prose"} The kind.
209
146
  */
210
147
  function figureKind(first, contents, resolveRole) {
211
- if (/^(?: *:::poetry(?:\s|$)|<div\b[^>]*\bclass="poetry(?:\s|"))/.test(contents)) return "poem";
148
+ if (/^(?:<div\b[^>]*\bclass="poetry(?:\s|"))/.test(contents)) return "poem";
212
149
  if (first?.type === "table_open") return "table";
213
150
  if (first?.type === "fence" && /^\s*sql\b/i.test(first.info ?? "")) return "table";
214
151
  if (first?.type === "fence" || first?.type === "code_block") return "code";
@@ -221,35 +158,27 @@ function figureKind(first, contents, resolveRole) {
221
158
  }
222
159
 
223
160
  /**
224
- * Read every `:::figure` fence in a body.
225
- *
226
- * SQL fences are expanded before this pass, so their output is a table.
227
- *
228
- * @param {string} source - A note body with expanded tables.
161
+ * Read leading captions and their following blocks outside literal fences.
162
+ * Historical figure API names remain for caption references and consumers.
163
+ * @param {string} source - The authored or expanded note body.
229
164
  * @param {object} [options] - Options.
230
- * @param {(address: string) => string|undefined} [options.resolveRole] - From
231
- * a picture's address to the role its asset declares, so a picture whose
232
- * asset declares `role: map` counts as `map` rather than `figure`. Omitted,
233
- * every picture counts as `figure` — the same reading a role other than
234
- * `map` gets.
165
+ * @param {(address: string) => string|undefined} [options.resolveRole] - Asset role lookup.
235
166
  * @returns {{figures: Array<{id: string, slug: string, caption: string,
236
- * hasCaption: boolean, classes: string[], kind: string, number: number,
237
- * label: string, line: number, bodyStart: number, bodyEnd: number,
238
- * captionStart: number, captionEnd: number, close: number}>,
167
+ * hasCaption: boolean, classes: string[], attributes: Record<string,string>,
168
+ * kind: string, numbered: boolean, number: number, label: string, line: number,
169
+ * bodyStart: number, bodyEnd: number, captionStart: number, captionEnd: number, close: number}>,
239
170
  * errors: Array<{line: number, column: number, message: string}>}}
240
171
  */
241
172
  export function scanFigures(source, { resolveRole = () => undefined } = {}) {
242
173
  const lines = String(source ?? "").split("\n");
243
- const figures = [];
244
- const errors = [];
245
- const counts = { code: 0, table: 0, figure: 0, map: 0, poem: 0, prose: 0 };
246
- const ids = new Set();
247
- const headingIds = [];
174
+ const figures = [],
175
+ errors = [],
176
+ ids = new Set();
177
+ const counts = {};
248
178
  let codeFence = null;
249
-
179
+ const fault = (line, message) => errors.push({ line: line + 1, column: 1, message });
250
180
  for (let i = 0; i < lines.length; i++) {
251
- const line = lines[i];
252
- const fence = FENCE.exec(line);
181
+ const fence = FENCE.exec(lines[i]);
253
182
  if (codeFence) {
254
183
  if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
255
184
  codeFence = null;
@@ -259,166 +188,167 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
259
188
  codeFence = fence[1];
260
189
  continue;
261
190
  }
262
- const heading = HEADING_LINE.exec(line);
263
- const headingId = heading ? splitHeadingAttributes(heading[2]).id : "";
264
- if (headingId) headingIds.push({ id: slugify(headingId), line: i + 1 });
265
- if (!FIGURE_LINE.test(line)) continue;
266
- const open = OPEN.exec(line);
267
- if (!open) {
268
- errors.push({
269
- line: i + 1,
270
- column: 1,
271
- message: "a figure's attributes are written as {#id .border}",
272
- });
191
+ const match = /^ {0,3}:(@)?[ \t]+(.+?)\s*$/.exec(lines[i]);
192
+ if (!match) {
193
+ if (/^ {0,3}:@?[ \t]*$/.test(lines[i])) fault(i, "a caption needs text");
273
194
  continue;
274
195
  }
275
- const start = i;
276
- const attributes = parseExtensionAttributes((open[1] ?? "{}").slice(1, -1));
277
- const faults = [...attributes.problems];
278
- for (const name of attributes.classes) {
279
- if (FIGURE_CLASSES.includes(name)) continue;
280
- faults.push(
281
- `a figure takes no .${name} class — the classes a figure takes are ` +
282
- FIGURE_CLASSES.map((known) => `.${known}`).join(", "),
283
- );
196
+ // A definition-list description has a nonblank term immediately before it.
197
+ if (!match[1] && i > 0 && lines[i - 1].trim()) continue;
198
+ if ((i > 0 && lines[i - 1].trim()) || lines[i + 1]?.trim()) {
199
+ fault(i, "a caption needs a blank line before and after it");
200
+ continue;
284
201
  }
285
- for (const key of Object.keys(attributes.values)) {
286
- faults.push(
287
- `${key}= is not an attribute a figure takes — a figure takes an id and classes`,
288
- );
202
+ let caption = match[2],
203
+ attributes = parseExtensionAttributes("");
204
+ const attr = /\s+(\{.*\})$/.exec(caption);
205
+ if (attr) {
206
+ attributes = parseExtensionAttributes(attr[1].slice(1, -1));
207
+ caption = caption.slice(0, attr.index).trim();
208
+ } else if (/\s+\{/.test(caption)) fault(i, "a caption has malformed attributes");
209
+ for (const message of [...attributes.problems, ...refusedAttributes(attributes.values)])
210
+ fault(i, message);
211
+ const explicit = attributes.values.type;
212
+ if (
213
+ explicit &&
214
+ !["figure", "table", "poetry", "code", "prose", "example"].includes(explicit)
215
+ )
216
+ fault(i, `unsupported caption type "${explicit}"`);
217
+ if (!caption) fault(i, "a caption needs text");
218
+ let bodyStart = i + 1;
219
+ while (bodyStart < lines.length && !lines[bodyStart].trim()) bodyStart++;
220
+ if (bodyStart === lines.length || /^ {0,3}:@?[ \t]+/.test(lines[bodyStart])) {
221
+ fault(i, "a caption needs a following item");
222
+ continue;
289
223
  }
290
- for (const message of faults) errors.push({ line: start + 1, column: 1, message });
291
-
292
- // The body, read line by line: the first top-level `///` splits it, the
293
- // closing `:::` ends it, and a nested fence holds neither.
294
- let close = start + 1;
295
- let split = -1;
296
- let nested = null;
297
- let namedDepth = 0;
298
- const fenced = new Set();
299
- while (close < lines.length) {
300
- const body = lines[close];
301
- const bodyFence = FENCE.exec(body);
302
- if (namedDepth) {
303
- fenced.add(close);
304
- if (/^ *:::[ \t]*$/.test(body)) namedDepth--;
305
- close++;
306
- continue;
307
- }
308
- if (nested) {
309
- fenced.add(close);
224
+ let bodyEnd;
225
+ const firstLine = lines[bodyStart];
226
+ let kind;
227
+ if (/^ {0,3}:::(?:[ \t]+\{.*\})?[ \t]*$/.test(firstLine)) {
228
+ let depth = 1,
229
+ literal = null;
230
+ bodyEnd = bodyStart + 1;
231
+ for (; bodyEnd < lines.length; bodyEnd++) {
232
+ const code = FENCE.exec(lines[bodyEnd]);
233
+ if (literal) {
234
+ if (code && code[1][0] === literal[0] && code[1].length >= literal.length)
235
+ literal = null;
236
+ continue;
237
+ }
238
+ if (code) {
239
+ literal = code[1];
240
+ continue;
241
+ }
310
242
  if (
311
- bodyFence &&
312
- bodyFence[1][0] === nested[0] &&
313
- bodyFence[1].length >= nested.length
314
- )
315
- nested = null;
316
- close++;
317
- continue;
243
+ /^ *:::(?:[ \t]+\{.*\}|(?:secret|info|warn)\b.*)?[ \t]*$/.test(lines[bodyEnd])
244
+ ) {
245
+ if (/^ *:::[ \t]*$/.test(lines[bodyEnd])) depth--;
246
+ else depth++;
247
+ if (!depth) {
248
+ bodyEnd++;
249
+ break;
250
+ }
251
+ }
318
252
  }
319
- if (bodyFence) {
320
- nested = bodyFence[1];
321
- fenced.add(close);
322
- close++;
253
+ if (depth) fault(bodyStart, "a captioned div needs a closing :::");
254
+ kind = "prose";
255
+ } else {
256
+ if (/^ *:::/.test(firstLine)) {
257
+ fault(bodyStart, "this fence cannot be captioned");
323
258
  continue;
324
259
  }
325
- if (/^ *:::poetry(?:\s|$)/.test(body)) {
326
- namedDepth++;
327
- fenced.add(close);
328
- close++;
260
+ const tokens = parser.parse(lines.slice(bodyStart).join("\n"), {});
261
+ const first = tokens.find((token) => token.level === 0 && token.map);
262
+ if (
263
+ !first ||
264
+ ![
265
+ "table_open",
266
+ "fence",
267
+ "code_block",
268
+ "paragraph_open",
269
+ "blockquote_open",
270
+ "html_block",
271
+ ].includes(first.type)
272
+ ) {
273
+ fault(
274
+ bodyStart,
275
+ "a caption must be followed by an image, table, poetry or code fence, paragraph, quote, or fenced div",
276
+ );
329
277
  continue;
330
278
  }
331
- if (CLOSE.test(body)) break;
332
- if (SPLIT.test(body)) {
333
- if (split < 0) split = close;
334
- else
335
- errors.push({
336
- line: close + 1,
337
- column: 1,
338
- message: "a figure holds one caption, so it carries one /// line",
339
- });
279
+ bodyEnd = bodyStart + first.map[1];
280
+ if (first.type === "html_block" && /^<div\b/.test(firstLine.trim())) {
281
+ let depth = 0;
282
+ for (let at = bodyStart; at < lines.length; at++) {
283
+ depth += (lines[at].match(/<div\b/g) ?? []).length;
284
+ depth -= (lines[at].match(/<\/div>/g) ?? []).length;
285
+ if (!depth) {
286
+ bodyEnd = at + 1;
287
+ break;
288
+ }
289
+ }
340
290
  }
341
- close++;
342
- }
343
- if (close === lines.length) {
344
- errors.push({ line: start + 1, column: 1, message: "a figure needs a closing :::" });
345
- continue;
346
- }
347
-
348
- const bodyStart = start + 1;
349
- const bodyEnd = split < 0 ? close : split;
350
- const captionStart = split < 0 ? -1 : split + 1;
351
- const captionEnd = split < 0 ? -1 : close;
352
- const contents = lines.slice(bodyStart, bodyEnd).join("\n");
353
- const caption = split < 0 ? "" : lines.slice(captionStart, captionEnd).join("\n").trim();
354
- if (!contents.trim())
355
- errors.push({ line: start + 1, column: 1, message: "a figure has no contents" });
356
- if (split >= 0 && !caption)
357
- errors.push({
358
- line: split + 1,
359
- column: 1,
360
- message:
361
- "a figure's /// section carries no caption — write no /// to leave the " +
362
- "figure uncaptioned",
363
- });
364
- // An H1, or an anchored heading at any level, starts a Foundry
365
- // journal page — see `engine/content-blocks.mjs`'s identical refusal
366
- // for a named block.
367
- for (let at = bodyStart; at < close; at++) {
368
- if (fenced.has(at)) continue;
369
- if (!parseHeadingLine(lines[at])?.startsPage) continue;
370
- errors.push({
371
- line: at + 1,
372
- column: 1,
373
- message:
374
- "a heading that starts a page cannot be written inside a figure — " +
375
- "keep an H1 or an anchored heading at the top level, or drop the " +
376
- "anchor and the level to stay inside it",
377
- });
291
+ const contents = lines.slice(bodyStart, bodyEnd).join("\n");
292
+ if (
293
+ first.type === "html_block" &&
294
+ !/^(?:<figure\b|<img\b|<div\b)/.test(contents.trim())
295
+ ) {
296
+ fault(bodyStart, "this block cannot be captioned");
297
+ continue;
298
+ }
299
+ kind =
300
+ first.type === "fence" && /^poetry(?:\s|$)/.test(first.info) ?
301
+ "poem"
302
+ : figureKind(first, contents.trim(), resolveRole);
378
303
  }
304
+ if (explicit) kind = explicit === "poetry" ? "poem" : explicit;
379
305
  const id = attributes.id;
380
- if (id) {
381
- if (ids.has(slugify(id)))
382
- errors.push({ line: start + 1, column: 1, message: `duplicate figure id "${id}"` });
383
- ids.add(slugify(id));
384
- }
385
-
386
- const trimmed = contents.trim();
387
- const tokens = parser.parse(contents, {});
388
- const first = tokens.find((token) => token.level === 0 && token.map);
389
- const kind = figureKind(first, trimmed, resolveRole);
390
- const number = ++counts[kind];
306
+ if (id && ids.has(slugify(id))) fault(i, `duplicate figure id "${id}"`);
307
+ if (id) ids.add(slugify(id));
308
+ const numbered = Boolean(match[1]);
309
+ const number = numbered ? (counts[kind] = (counts[kind] ?? 0) + 1) : 0;
391
310
  figures.push({
392
311
  id,
393
312
  slug: id ? slugify(id) : "",
394
313
  caption,
395
- hasCaption: Boolean(caption),
314
+ hasCaption: true,
396
315
  classes: attributes.classes,
316
+ attributes: attributes.values,
397
317
  kind,
318
+ numbered,
398
319
  number,
399
- label: `${FIGURE_NAMES[kind]} ${number}`,
400
- line: start + 1,
320
+ label: numbered ? `${FIGURE_NAMES[kind]} ${number}` : "",
321
+ line: i + 1,
401
322
  bodyStart,
402
323
  bodyEnd,
403
- captionStart,
404
- captionEnd,
405
- close,
324
+ captionStart: i,
325
+ captionEnd: i + 1,
326
+ close: bodyEnd - 1,
406
327
  });
407
- i = close;
328
+ i = bodyEnd - 1;
408
329
  }
409
- for (const heading of headingIds) {
410
- if (ids.has(heading.id))
411
- errors.push({
412
- line: heading.line,
413
- column: 1,
414
- message: `heading and figure declare the same anchor "${heading.id}"`,
415
- });
330
+ let literal = null;
331
+ for (let at = 0; at < lines.length; at++) {
332
+ const fence = FENCE.exec(lines[at]);
333
+ if (literal) {
334
+ if (fence && fence[1][0] === literal[0] && fence[1].length >= literal.length)
335
+ literal = null;
336
+ continue;
337
+ }
338
+ if (fence) {
339
+ literal = fence[1];
340
+ continue;
341
+ }
342
+ const heading = HEADING_LINE.exec(lines[at]);
343
+ const id = heading ? splitHeadingAttributes(heading[2]).id : "";
344
+ if (id && ids.has(slugify(id)))
345
+ fault(at, `heading and figure declare the same anchor "${slugify(id)}"`);
416
346
  }
417
347
  return { figures, errors };
418
348
  }
419
349
 
420
350
  /**
421
- * Render figure fences as HTML, leaving other Markdown untouched.
351
+ * Render leading captions and their next blocks as HTML, leaving other Markdown untouched.
422
352
  *
423
353
  * A code, table or prose figure's contents are left as Markdown inside the
424
354
  * wrapper, blank-line separated from its tags exactly as
@@ -463,7 +393,11 @@ export function renderFigureBlocks(
463
393
  const contents = lines.slice(figure.bodyStart, figure.bodyEnd).join("\n");
464
394
  const classes = ["content-figure", `content-figure-${numbered.kind}`, ...figure.classes];
465
395
  const id = figure.slug ? ` id="${escapeHtml(figure.slug)}"` : "";
466
- output.push(`<div${id} class="${escapeHtml(classes.join(" "))}">`);
396
+ const attrs = Object.entries(figure.attributes ?? {})
397
+ .filter(([key]) => key !== "type")
398
+ .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`)
399
+ .join("");
400
+ output.push(`<div${id} class="${escapeHtml(classes.join(" "))}"${attrs}>`);
467
401
  if (figure.kind === "figure" || figure.kind === "map") {
468
402
  output.push(renderMarkdown(contents).trim());
469
403
  } else {
@@ -472,7 +406,9 @@ export function renderFigureBlocks(
472
406
  const label = escapeHtml(numbered.label);
473
407
  output.push(
474
408
  `<p class="content-figure-label">${
475
- figure.hasCaption ? `${label}: ${parser.renderInline(figure.caption)}` : label
409
+ figure.hasCaption ?
410
+ `${label ? `${label}: ` : ""}${parser.renderInline(figure.caption)}`
411
+ : label
476
412
  }</p>`,
477
413
  "</div>",
478
414
  );
@@ -106,7 +106,7 @@ import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
106
106
  import { authoredKey } from "./system-block.mjs";
107
107
  // The top-level vocabulary, read rather than restated — a second list of the
108
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";
109
+ import { NOTE_TOP_LEVEL_KEY_SET, isLegacyDataField } from "./note-vocabulary.mjs";
110
110
 
111
111
  /**
112
112
  * Every field path any subtype of a published schema declares.
@@ -371,7 +371,8 @@ export function measureNote(note, format, { severity = "warning" } = {}) {
371
371
  [...spec.dataKeys].map((key) => RETIRED_FIELD_ALIASES[key]).filter(Boolean),
372
372
  );
373
373
  for (const key of Object.keys(data ?? {})) {
374
- if (spec.dataKeys.has(key) || renamedDataKeys.has(key)) continue;
374
+ if (spec.dataKeys.has(key) || renamedDataKeys.has(key) || isLegacyDataField(type, key))
375
+ continue;
375
376
  const guess = nearest(key, spec.dataKeys);
376
377
  add(
377
378
  "unknown-data-key",
@@ -52,7 +52,6 @@ types:
52
52
  - governance.ranks
53
53
  - governance.offices
54
54
  - seat
55
- - domains
56
55
  - population
57
56
  - economy
58
57
  - lore
@@ -292,6 +291,7 @@ types:
292
291
  - lore
293
292
  - parents
294
293
  - population
294
+ - government
295
295
  - market
296
296
  - borders
297
297
  - routes
@@ -395,10 +395,6 @@ claims:
395
395
  system: sohl
396
396
  source: data.seat
397
397
  target: system.seat
398
- - noteType: affiliation
399
- system: sohl
400
- source: data.domains
401
- target: system.domain
402
398
  - noteType: affiliation
403
399
  system: sohl
404
400
  source: data.parents
@@ -59,7 +59,7 @@
59
59
  * floated run of text inside a paragraph would mean.
60
60
  *
61
61
  * **Alt text renders as a caption on no surface.** A caption is drawn only for
62
- * an image inside a `:::figure` fence that carries an authored caption, and the
62
+ * an image inside a captioned item that carries an authored caption, and the
63
63
  * text drawn is always that caption. An image with no fence, or a fence with no
64
64
  * caption, draws no line under the picture anywhere. The HTML surfaces still
65
65
  * carry the alt text as the `img` element's `alt` attribute, for the reader a
@@ -333,7 +333,7 @@ export function escapeHtml(text) {
333
333
  * function, so the two cannot drift into styling the same directive through
334
334
  * different class names.
335
335
  *
336
- * **No `<figcaption>` is emitted here.** A `:::figure` fence draws its own
336
+ * **No `<figcaption>` is emitted here.** A captioned item draws its own
337
337
  * label and authored caption around whatever this returns; the alt text stays
338
338
  * on the `img` element alone.
339
339
  *
@@ -538,7 +538,7 @@ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, lineMap,
538
538
  report(
539
539
  image.index,
540
540
  `\`"${image.title}"\` is a title on an image, and no surface here draws ` +
541
- "one — a caption is written in a `:::figure` fence's /// section",
541
+ "one — write a leading caption before the image",
542
542
  );
543
543
  }
544
544
  if (!image.block) {