@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
@@ -427,6 +427,7 @@ export function isUnsetSentinel(value) {
427
427
  /**
428
428
  * Whether a value is worth a row.
429
429
  *
430
+ * A field declaring `nullText` handles explicit null before this general test.
430
431
  * Rule 4: an absent field is absent. `null`, `""` and `[]` are how the corpus
431
432
  * writes "nobody filled this in" — a note that declares every key of its type
432
433
  * and leaves most of them empty is the ordinary shape, not the exception — and
@@ -722,7 +723,7 @@ function structuredRows(field, raw, resolve, label, contentPackage) {
722
723
  // Address: an authored plain list of addresses normalises to a list of
723
724
  // Address tuples, which `isMapping` also reports true for, and treating
724
725
  // one as a relationship keyed off a `to` it never carries is what emptied
725
- // `lore`, `parents`, `domains` and `economy` silently.
726
+ // `lore`, `parents` and `economy` silently.
726
727
  const relations =
727
728
  Array.isArray(raw) ? raw.filter((entry) => isMapping(entry) && !isAddressTuple(entry)) : [];
728
729
  if (relations.length) {
@@ -943,6 +944,15 @@ function noteBox(
943
944
  if (structured.length) shown.add(field.name);
944
945
  continue;
945
946
  }
947
+ if (raw === null && field.nullText) {
948
+ rows.push({
949
+ label: overlay.label ?? humanizeFieldName(field.name),
950
+ kind: "text",
951
+ value: field.nullText,
952
+ });
953
+ shown.add(field.name);
954
+ continue;
955
+ }
946
956
  if (!hasValue(raw)) continue;
947
957
 
948
958
  if (overlay.group) {
@@ -196,7 +196,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
196
196
  if (figure) {
197
197
  closeCurrent();
198
198
  current = {
199
- name: figure.label,
199
+ name: figure.label || figure.caption,
200
200
  anchorSlug: figure.id || null,
201
201
  level: 1,
202
202
  classes: [],
@@ -404,7 +404,7 @@ function figureIsWholePage(figure, page) {
404
404
  }
405
405
 
406
406
  /**
407
- * The fields particular to the Foundry page a `:::figure` fence begins.
407
+ * The fields particular to the Foundry page a captioned item begins.
408
408
  *
409
409
  * A fence holding exactly one picture, captioned with no inline markup, and
410
410
  * standing alone on its page becomes a page of type `image`: `src` the
@@ -442,14 +442,16 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
442
442
  // image's own render takes when no configuration resolves it.
443
443
  }
444
444
  return {
445
- name: figure.label,
445
+ name: figure.label || figure.caption,
446
446
  type: "image",
447
447
  src,
448
448
  image: figure.hasCaption ? { caption: figure.caption } : {},
449
449
  };
450
450
  }
451
451
  const label =
452
- figure.hasCaption ? `${figure.label}: ${md.renderInline(figure.caption)}` : figure.label;
452
+ figure.hasCaption ?
453
+ `${figure.label ? `${figure.label}: ` : ""}${md.renderInline(figure.caption)}`
454
+ : figure.label;
453
455
  const content = renderFoundryMarkdown(
454
456
  page.markdown,
455
457
  undefined,
@@ -458,7 +460,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
458
460
  resolveRole,
459
461
  );
460
462
  return {
461
- name: figure.label,
463
+ name: figure.label || figure.caption,
462
464
  type: "text",
463
465
  text: {
464
466
  format: 1,
@@ -763,7 +765,7 @@ export class Journals extends BasePackCompiler {
763
765
 
764
766
  // The markdown this pass reads has already had its embeds rewritten
765
767
  // into ordinary images, each `src` the pathname `pathnameRoles` keys
766
- // its roles by — so a `:::figure` of a map names itself `Map 1` here
768
+ // its roles by — so a captioned map names itself `Map 1` here
767
769
  // exactly as the website and the book name the same picture.
768
770
  const roles = pathnameRoles(this.linkIndex);
769
771
 
@@ -154,7 +154,7 @@ const HELPERS = Object.freeze([
154
154
  name: "ref",
155
155
  params: 'address [form="number"|"full"|"title"]',
156
156
  summary:
157
- 'A link to a `:::figure` fence, by its anchor — `"#thorn"` on this note, ' +
157
+ 'A link to a captioned item, by its anchor — `"#thorn"` on this note, ' +
158
158
  '`"note-address#thorn"` on another. `form` is `number` (the default, and ' +
159
159
  'admitted explicitly), rendering "Figure 13"; `full`, the number and the ' +
160
160
  "caption; or `title`, the caption alone. Always renders as a link to the " +
@@ -173,6 +173,10 @@ const HELPERS = Object.freeze([
173
173
  const { parsed, figure, crossNote, noteFound, url } = refFigure(address, figures);
174
174
  if (!figure)
175
175
  throw new RangeError(refNotFoundFault(address, parsed, crossNote, noteFound));
176
+ if (form === "number" && !figure.label)
177
+ throw new RangeError(
178
+ 'a reference to an unnumbered caption requires form="title" or form="full"',
179
+ );
176
180
  if (form !== "number" && !figure.hasCaption)
177
181
  throw new RangeError(refNoCaptionFault(form, parsed));
178
182
  return renderRef(figure, parsed, form, { url, link: figures?.link });
@@ -271,7 +275,7 @@ function flattenCaptionLinks(caption) {
271
275
  function refLabel(figure, form) {
272
276
  if (form === "number") return figure.label;
273
277
  const title = flattenCaptionLinks(figure.caption);
274
- return form === "title" ? title : `${figure.label}: ${title}`;
278
+ return form === "title" || !figure.label ? title : `${figure.label}: ${title}`;
275
279
  }
276
280
 
277
281
  /**
@@ -394,6 +398,8 @@ function refCallFault(node, figures) {
394
398
  if (typeof address !== "string" || !address.trim()) return null;
395
399
  const { parsed, figure, crossNote, noteFound } = refFigure(address, figures);
396
400
  if (!figure) return refNotFoundFault(address, parsed, crossNote, noteFound);
401
+ if (form === "number" && !figure.label)
402
+ return 'a reference to an unnumbered caption requires form="title" or form="full"';
397
403
  if (form !== "number" && !figure.hasCaption) return refNoCaptionFault(form, parsed);
398
404
  return null;
399
405
  }
@@ -448,7 +454,7 @@ function positionAt(body, offset, bodyLine) {
448
454
  * {url: string|null, figures: Map<string, object>}|undefined,
449
455
  * link?: Function}, file?: string, bodyLine?: number}} [options] -
450
456
  * `figures` is the `ref` helper's own view of the corpus: `get` reads this
451
- * note's `:::figure` fences by id, and `note` resolves another note's the
457
+ * note's captioned items by id, and `note` resolves another note's the
452
458
  * way a wikilink would — omitted, a cross-note address is refused rather
453
459
  * than looked up. `link` overrides how a resolved reference renders; the
454
460
  * default is a Markdown link to the target's own anchor.
@@ -424,17 +424,19 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
424
424
  // the record naming a path at all.
425
425
  asset: record.asset ?? undefined,
426
426
  // What a place is next to, reachable from and within, and
427
- // what an affiliation holds, carried so a consumer's lint
427
+ // which affiliation governs it, carried so a consumer's lint
428
428
  // checks a border across the package boundary from both ends,
429
429
  // a consumer's map draws the dependency's places beside its
430
430
  // own, and a consumer's pages list what a dependency's
431
- // affiliation holds and what lies within its regions. A
431
+ // affiliation governs and what lies within its regions. A
432
432
  // population lets the same checks include dependency figures.
433
433
  borders: record.data?.borders ?? undefined,
434
434
  routes: record.data?.routes ?? undefined,
435
435
  parents: record.data?.parents ?? undefined,
436
- domains: record.data?.domains ?? undefined,
437
436
  population: record.data?.population ?? undefined,
437
+ ...(Object.hasOwn(record.data ?? {}, "government") ?
438
+ { government: record.data.government }
439
+ : {}),
438
440
  package: pkg,
439
441
  });
440
442
  const entry = index.get(key);
@@ -112,7 +112,7 @@ import { checkCultureChoice } from "./culture-choice.mjs";
112
112
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
113
113
  import { parseNoteDate } from "./note-dates.mjs";
114
114
  import { reckoningContext } from "./reckoning-markers.mjs";
115
- import { checkHeld } from "./holdings.mjs";
115
+ import { checkGovernment } from "./holdings.mjs";
116
116
  import {
117
117
  checkBeingMeasurement,
118
118
  parseBeingHeight,
@@ -153,6 +153,7 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
153
153
  * so each takes a row labelled and anchored by the post's own name.
154
154
  * @property {"subType"} [keySelector] - An alternative `subType:<skill-subtype>` key.
155
155
  * @property {readonly string[]} [accepts] - Allowed Address types, separate from `ref`.
156
+ * @property {string} [nullText] - Infobox text for an explicitly authored null with a declared meaning.
156
157
  * @property {string} [shape] - Human-readable shape, for a finding and for
157
158
  * documentation.
158
159
  * @property {string} [entryShape] - For a `scalar-or-map` field, what one
@@ -208,8 +209,7 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
208
209
  * @property {(note: object, opts: {index?: object}) => object[]} [check] - A
209
210
  * check of the whole note, run by the frontmatter lint beside the field
210
211
  * checks with the same index. For a rule that is about the note rather than
211
- * one of its fields — a place's tenure is a fact about every affiliation's
212
- * `domains`, and is located at the note's `type:` line.
212
+ * one of its fields, located at the note's `type:` line.
213
213
  */
214
214
 
215
215
  /* --------------------------------------------------------------------- */
@@ -512,8 +512,8 @@ const CHARGES = Object.freeze([
512
512
  /**
513
513
  * Everything `place` asks of a whole note.
514
514
  *
515
- * Two questions about one subject, and they are independent: who holds this
516
- * land, and — where the note is a body rather than somewhere within one — what
515
+ * Two questions about one subject, and they are independent: who governs this
516
+ * inhabited place, and — where the note is a body rather than somewhere within one — what
517
517
  * the body states about itself. A type declares one whole-note check, so the
518
518
  * two are composed here rather than either being folded into the other.
519
519
  *
@@ -522,7 +522,7 @@ const CHARGES = Object.freeze([
522
522
  * @returns {object[]} Findings from both.
523
523
  */
524
524
  function checkPlace(note, opts) {
525
- return [...checkHeld(note, opts), ...checkWorldFacts(note, opts)];
525
+ return [...checkGovernment(note), ...checkWorldFacts(note, opts)];
526
526
  }
527
527
 
528
528
  /** Validate a being's authored date using the shared note-date grammar. */
@@ -1089,13 +1089,6 @@ export const NOTE_VOCABULARY = Object.freeze({
1089
1089
  accepts: ["place"],
1090
1090
  describe: "Where the affiliation's authority sits.",
1091
1091
  },
1092
- {
1093
- name: "domains",
1094
- ...LINKS,
1095
- ref: "place",
1096
- accepts: ["place"],
1097
- describe: "Places over which it holds sway.",
1098
- },
1099
1092
  { name: "population", ...NUM, describe: "How many people it counts." },
1100
1093
  {
1101
1094
  name: "economy",
@@ -1563,6 +1556,18 @@ export const NOTE_VOCABULARY = Object.freeze({
1563
1556
  check: checkPopulation,
1564
1557
  describe: "Approximate population, to two significant digits.",
1565
1558
  },
1559
+ {
1560
+ name: "government",
1561
+ ...LINK,
1562
+ shape: "an Address or null",
1563
+ ref: "affiliation",
1564
+ accepts: ["affiliation"],
1565
+ nullText: "Complete anarchy",
1566
+ describe:
1567
+ "The governing affiliation Address; the default target type is affiliation. " +
1568
+ "Explicit null means complete anarchy. " +
1569
+ "A positive population with no government key produces an advisory.",
1570
+ },
1566
1571
  {
1567
1572
  name: "market",
1568
1573
  ...NUM,
@@ -1856,3 +1861,15 @@ export function subTypes(type, vocabulary = NOTE_VOCABULARY) {
1856
1861
  if (!entry || !Object.hasOwn(entry, "subTypes")) return undefined;
1857
1862
  return entry.subTypes;
1858
1863
  }
1864
+
1865
+ /**
1866
+ * Accepted migration input with no canonical field or emitted value.
1867
+ *
1868
+ * Legacy affiliation domains are ignored. They never supply a government.
1869
+ * @param {string} type - The note type.
1870
+ * @param {string} key - An authored data key.
1871
+ * @returns {boolean} Whether the obsolete key is accepted during migration.
1872
+ */
1873
+ export function isLegacyDataField(type, key) {
1874
+ return currentType(type) === "affiliation" && key === "domains";
1875
+ }
@@ -745,7 +745,7 @@ export async function buildPdf({
745
745
  * @returns {string} Typst markup.
746
746
  */
747
747
  const figureCounts = { code: 0, table: 0, figure: 0, map: 0, poem: 0, prose: 0 };
748
- // The role lookup a `:::figure` fence's `map` counter reaches through —
748
+ // The role lookup a captioned item's `map` counter reaches through —
749
749
  // see `engine/content-figures.mjs`'s `resolveRole`. Addressed form, for a
750
750
  // page's own scan below, which runs before its embeds are rewritten into
751
751
  // ordinary images; `pathRoles` is the same lookup keyed by the pathname
@@ -85,10 +85,13 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
85
85
  xlarge: "8cm",
86
86
  "full-width": '"full-width"',
87
87
  });
88
+ import { markupAnchorFindings } from "./anchors.mjs";
88
89
  import { slugify } from "./content-slug.mjs";
90
+ import { alertMarkdownPlugin, scanAlerts, ALERT_TYPES } from "./content-alerts.mjs";
91
+ import { spanMarkdownPlugin, scanSpans } from "./content-spans.mjs";
89
92
  import { scanFigures } from "./content-figures.mjs";
90
93
  import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
91
- import { inlineVerse, poetryStanzas } from "./content-poetry.mjs";
94
+ import { inlineVerse, poetryStanzas, scanPoetry } from "./content-poetry.mjs";
92
95
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
93
96
  import { HTML_TAG, htmlMessage } from "./content-html.mjs";
94
97
  import { EXPRESSION } from "./markdown-expressions.mjs";
@@ -108,9 +111,7 @@ import {
108
111
  * a class and let CSS decide.
109
112
  */
110
113
  const BLOCK_PRINT = Object.freeze({
111
- info: Object.freeze({ color: "#2f6f9f", background: "#eef6fb", symbol: "i" }),
112
114
  secret: Object.freeze({ color: "#5c4b8a", background: "#f2eefb", symbol: "!" }),
113
- warn: Object.freeze({ color: "#9a6700", background: "#fff5db", symbol: "!" }),
114
115
  });
115
116
 
116
117
  import {
@@ -197,6 +198,24 @@ export function labelFor(anchor) {
197
198
  export function createParser(registry) {
198
199
  const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
199
200
  md.use(footnotePlugin);
201
+ md.use(alertMarkdownPlugin);
202
+ md.use(spanMarkdownPlugin);
203
+ // Verse is held inside a literal fence. Parse its inline content before
204
+ // footnote_tail collects definitions, using the surrounding note's env.
205
+ md.core.ruler.after("inline", "poetry_inline", (state) => {
206
+ for (const token of state.tokens) {
207
+ if (token.type !== "fence" || !/^poetry(?:\s|$)/.test(token.info?.trim() || ""))
208
+ continue;
209
+ token.meta ??= {};
210
+ token.meta.poetryStanzas = poetryStanzas(token.content).map((stanza) =>
211
+ stanza.map(({ text, level }) => {
212
+ const children = [];
213
+ state.md.inline.parse(inlineVerse(text), state.md, state.env, children);
214
+ return { children, level };
215
+ }),
216
+ );
217
+ }
218
+ });
200
219
  md.use(deflistPlugin);
201
220
  md.use(iconPlugin(registry));
202
221
  // The same plugin the HTML surfaces use, so one directive is read once and
@@ -298,7 +317,7 @@ function offsetPosition(source, offset, opts = {}) {
298
317
  * those passes did not run — `opts.prepared` — because each of them leaves the
299
318
  * markup as written when it fails and has already said so in its own words.
300
319
  *
301
- * A `:::` block and a `:::figure` are the pair this pass does handle, and their
320
+ * A fenced div and a leading caption are the structures this pass handles, and their
302
321
  * own scanners decide what is well formed; their findings are reported here so
303
322
  * that a front-matter file, a prose file and a note body all get them.
304
323
  *
@@ -325,7 +344,7 @@ function reportUnrenderable(source, definitions, opts, original) {
325
344
  };
326
345
 
327
346
  const blocks = scanBlocks(source);
328
- for (const error of blocks.errors)
347
+ for (const error of [...blocks.errors, ...scanAlerts(source).errors])
329
348
  report(linePosition(error.line, error.column, opts), "error", error.message);
330
349
  // A container's body, read for faults of its own — an empty box, an
331
350
  // attribute that does not parse — which the scan above passes over, because
@@ -349,6 +368,8 @@ function reportUnrenderable(source, definitions, opts, original) {
349
368
  );
350
369
  }
351
370
  }
371
+ for (const error of [...scanSpans(source).errors, ...markupAnchorFindings(source)])
372
+ report(linePosition(error.line, error.column, opts), "error", error.message);
352
373
  for (const error of scanFigures(source).errors)
353
374
  report(linePosition(error.line, error.column, opts), "error", error.message);
354
375
  // An image sharing its paragraph with other text, or an address or title
@@ -527,14 +548,11 @@ export function markdownToTypst(markdown, opts = {}) {
527
548
  }
528
549
  }
529
550
  if (!opts.insideAdmonition) {
530
- const figuresHoldingPoetry = scanFigures(source).figures;
551
+ const captionTargets = scanFigures(source).figures;
531
552
  const blocks = scanBlocks(source).blocks.filter(
532
553
  (block) =>
533
- !figuresHoldingPoetry.some(
534
- (figure) =>
535
- block.name === "poetry" &&
536
- block.start >= figure.bodyStart &&
537
- block.end < figure.bodyEnd,
554
+ !captionTargets.some(
555
+ (figure) => block.start >= figure.bodyStart && block.end < figure.bodyEnd,
538
556
  ),
539
557
  );
540
558
  if (blocks.length) {
@@ -548,21 +566,13 @@ export function markdownToTypst(markdown, opts = {}) {
548
566
  insideAdmonition: true,
549
567
  }),
550
568
  );
551
- if (block.name === "poetry") {
552
- const content = poetryStanzas(block.body)
553
- .map((stanza) =>
554
- stanza
555
- .map(({ text, level }) => {
556
- const verse = markdownToTypst(inlineVerse(text), {
557
- ...sharedOptions,
558
- insideAdmonition: true,
559
- });
560
- return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
561
- })
562
- .join(" \\\n"),
563
- )
564
- .join("\n\n");
565
- output.push(`\n#block(width: 100%)[${content}]\n`);
569
+ if (block.name === "div") {
570
+ const content = markdownToTypst(block.body, {
571
+ ...sharedOptions,
572
+ insideAdmonition: false,
573
+ });
574
+ const anchor = block.id ? ` <${sectionLabel(anchorPrefix, block.id)}>` : "";
575
+ output.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
566
576
  cursor = block.end + 1;
567
577
  continue;
568
578
  }
@@ -629,7 +639,7 @@ export function markdownToTypst(markdown, opts = {}) {
629
639
  { ...caption, imagesRemaining: { n: countImages(block) } }
630
640
  : caption;
631
641
  const segments = [
632
- caption.kind === "poem" ?
642
+ caption.kind !== "table" && caption.kind !== "figure" && caption.kind !== "map" ?
633
643
  markdownToTypst(block, { ...sharedOptions, captions: undefined })
634
644
  : renderMarkdownSegment(block, md, { ...ctx, caption: captioned }, definitions),
635
645
  ];
@@ -732,7 +742,23 @@ function renderBlock(tokens, i, out, ctx) {
732
742
  }
733
743
  case "fence":
734
744
  case "code_block": {
735
- out.push(rawBlock(token.content, token.info?.trim() || ""));
745
+ if (token.type === "fence" && /^poetry(?:\s|$)/.test(token.info?.trim() || "")) {
746
+ const content = token.meta.poetryStanzas
747
+ .map((stanza) =>
748
+ stanza
749
+ .map(({ children, level }) => {
750
+ const verse = renderInline({ children }, ctx);
751
+ return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
752
+ })
753
+ .join(" \\\n"),
754
+ )
755
+ .join("\n\n");
756
+ const poem = scanPoetry(
757
+ `${token.markup}${token.info}\n${token.content}${token.markup}`,
758
+ ).blocks[0];
759
+ const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
760
+ out.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
761
+ } else out.push(rawBlock(token.content, token.info?.trim() || ""));
736
762
  return 1;
737
763
  }
738
764
  case "hr":
@@ -741,7 +767,20 @@ function renderBlock(tokens, i, out, ctx) {
741
767
  case "blockquote_open": {
742
768
  const end = matching(tokens, i, "blockquote_open", "blockquote_close");
743
769
  const inner = renderTokens(tokens.slice(i + 1, end), { ...ctx });
744
- out.push(`\n#quote(block: true)[${inner}]\n\n`);
770
+ if (token.meta?.alert) {
771
+ const alert = token.meta.alert;
772
+ const colors = {
773
+ note: "#0969da",
774
+ tip: "#1a7f37",
775
+ important: "#8250df",
776
+ warning: "#9a6700",
777
+ };
778
+ const spec = ALERT_TYPES[alert.type];
779
+ const icon = `#image(bytes("${escapeTypstString(spec.svg.replace("<svg ", '<svg xmlns="http://www.w3.org/2000/svg" ').replaceAll("currentColor", colors[alert.type]))}"), format: "svg", width: 0.9em, height: 0.9em)`;
780
+ const anchor = alert.id ? ` <${sectionLabel(ctx.anchorPrefix, alert.id)}>` : "";
781
+ const box = `\n#block(width: 100%, stroke: (left: 2pt + rgb("${colors[alert.type]}")), inset: 8pt, above: 0.7em, below: 0.7em)[#text(fill: rgb("${colors[alert.type]}"), weight: "bold")[${icon} ${inlineMarkup(alert.title ?? alert.attributes?.title ?? spec.title, ctx)}]\n\n${inner}]${anchor}\n\n`;
782
+ out.push(alert.classes?.includes("border") ? figureBorder(box) : box);
783
+ } else out.push(`\n#quote(block: true)[${inner}]\n\n`);
745
784
  return end - i + 1;
746
785
  }
747
786
  case "bullet_list_open":
@@ -964,13 +1003,15 @@ function inlineMarkup(text, ctx) {
964
1003
  */
965
1004
  function captionMarkup(caption, ctx) {
966
1005
  const label = escapeTypst(caption.label);
967
- return caption.hasCaption ? `${label}: ${inlineMarkup(caption.caption, ctx)}` : label;
1006
+ return caption.hasCaption ?
1007
+ `${label ? `${label}: ` : ""}${inlineMarkup(caption.caption, ctx)}`
1008
+ : label;
968
1009
  }
969
1010
 
970
1011
  /**
971
1012
  * How many markdown images a figure's contents carry.
972
1013
  *
973
- * A grouped figure's `///` caption describes the whole plate, not any one
1014
+ * A grouped figure's leading caption describes the whole plate, not any one
974
1015
  * picture in it, so {@link renderImage} needs to know which image is the
975
1016
  * last — the one that carries the group's single label and anchor — and this
976
1017
  * is the count it counts down from.
@@ -1157,6 +1198,24 @@ function renderInline(token, ctx) {
1157
1198
  case "code_inline":
1158
1199
  out.push(inlineRaw(child.content));
1159
1200
  break;
1201
+ case "span_open": {
1202
+ out.push("#box[");
1203
+ break;
1204
+ }
1205
+ case "span_close": {
1206
+ let depth = 1;
1207
+ let opening;
1208
+ for (let j = i - 1; j >= 0; j -= 1) {
1209
+ if (children[j].type === "span_close") depth += 1;
1210
+ if (children[j].type === "span_open" && --depth === 0) {
1211
+ opening = children[j];
1212
+ break;
1213
+ }
1214
+ }
1215
+ const id = opening?.attrGet?.("id");
1216
+ out.push(`]${id ? ` <${sectionLabel(ctx.anchorPrefix, id)}>` : ""}`);
1217
+ break;
1218
+ }
1160
1219
  case "strong_open":
1161
1220
  out.push("#strong[");
1162
1221
  break;