@heroiclands/package-build 22.24.0 → 22.26.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.
@@ -2648,7 +2648,7 @@ displays **Table 1** as a link.
2648
2648
  optionally followed by a braced attribute block in the grammar a heading and
2649
2649
  a named block take too. A line of `:::` alone closes it. Between the
2650
2650
  two, the first `///` written alone on a line, read at the top level — outside
2651
- any nested code fence — divides what the fence holds from its caption; a `///`
2651
+ any nested code or poetry fence — divides what the fence holds from its caption; a `///`
2652
2652
  written inside a nested fence is content, not a divider. A second top-level
2653
2653
  `///` is an error, naming the line: a figure holds one caption, so it carries
2654
2654
  one `///` line.
@@ -2681,11 +2681,11 @@ note and is `a figure needs a closing :::`, also at the opening line, and
2681
2681
  nothing after it is scanned as a second fence.
2682
2682
 
2683
2683
  **The counter is derived, never authored.** The fence's contents decide which
2684
- of four counters it draws from — `Code`, `Table`, `Figure`, `Prose` — read from
2684
+ of six counters it draws from — `Code`, `Table`, `Figure`, `Map`, `Poem`, `Prose` — read from
2685
2685
  the first top-level block inside the fence: a table is `Table`; a fenced `sql`
2686
2686
  block is `Table`, because its rendered output is one; any other fenced or
2687
2687
  indented code block is `Code`; one or more pictures and nothing else is
2688
- `Figure`; everything else is `Prose`. A table or a picture written outside any
2688
+ `Figure`; a `:::poetry` fence as the first nonblank content is `Poem`; everything else is `Prose`. A poem, table or picture written outside any
2689
2689
  `:::figure` fence draws no label and is counted nowhere. The website and a
2690
2690
  Foundry journal number each counter within the note; the book numbers each
2691
2691
  counter across the book in reading order.
@@ -4255,32 +4255,33 @@ map's prose.
4255
4255
  - feature: A place significant by its terrain — forests, rivers, falls, passes, fords.
4256
4256
  - celestial: A body observed from a world rather than located on one — a sun, a planet, a comet.
4257
4257
 
4258
- | `data` property | Values | Description |
4259
- | --------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------- |
4260
- | `calendar` | `Address` | Calendar note used to print this place's dates |
4261
- | `demonym` | `string` | What a person from this place is called — a Vylarian |
4262
- | `purpose` | `placeCharacter` tag | The reason a settlement, site, or structure exists |
4263
- | `lore` | `Address[]` | Lore concerning this place — its peoples, its law, its calendar, its history |
4264
- | `parents` | `Address[]` | Enclosing places within which this place is located |
4265
- | `population` | `number` | Approximate population (precision 2 significant digits) |
4266
- | `market` | `number` | Market class, 1 to 6 — what trade a settlement supports |
4267
- | `borders` | `{ to, bearing }[]` | Places sharing a frontier with this one, and where each lies from here |
4268
- | `routes` | `{ to, bearing, mode, days, terrain?, leagues? }[]` | Journeys from this place's centre to another place |
4269
- | `world.equatorialCircumferenceKm` | `number` | The distance round the body at its equator, in kilometres |
4270
- | `world.surfaceGravityG` | `number` | Surface gravity, as a multiple of Earth's |
4271
- | `world.axialTiltDegrees` | `number` | The tilt of the axis, in degrees |
4272
- | `year.days` | `number` | How many days the body's year holds |
4273
- | `year.hoursPerDay` | `number` | How many hours the day divides into |
4274
- | `year.minutesPerHour` | `number` | How many minutes the hour divides into |
4275
- | `year.secondsPerMinute` | `number` | How many seconds the minute divides into |
4276
- | `present` | `date` | The day the setting stops and play begins |
4277
- | `body.diameterKm` | `number` | The body's diameter, in kilometres |
4278
- | `body.orbitalRadiusKm` | `number` | How far it orbits from the body it circles, in kilometres |
4279
- | `body.orbit` | `string` | The orbit's shape — `circular` means the cycle never varies |
4280
- | `body.inclined` | `boolean` | Whether the orbit is inclined to the plane the world orbits in |
4281
- | `moon.cycle` | `number` | How many days it takes to return to the same phase |
4282
- | `moon.newOn` | day-precision `date` | A day it was new, written in the reference calendar |
4283
- | `moon.eclipses` | `string` | `never`, `rare`, `occasional` or `frequent` |
4258
+ | `data` property | Values | Description |
4259
+ | --------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
4260
+ | `calendar` | `Address` | Calendar note used to print this place's dates |
4261
+ | `demonym` | `string` | What a person from this place is called — a Vylarian |
4262
+ | `purpose` | `placeCharacter` tag | The reason a settlement, site, or structure exists |
4263
+ | `lore` | `Address[]` | Lore concerning this place — its peoples, its law, its calendar, its history |
4264
+ | `parents` | `Address[]` | Enclosing places within which this place is located |
4265
+ | `population` | `number` | Approximate population (precision 2 significant digits) |
4266
+ | `government` | affiliation Address or `null` | Governing affiliation; explicit null means complete anarchy. Omission warns only for positive population. |
4267
+ | `market` | `number` | Market class, 1 to 6 — what trade a settlement supports |
4268
+ | `borders` | `{ to, bearing }[]` | Places sharing a frontier with this one, and where each lies from here |
4269
+ | `routes` | `{ to, bearing, mode, days, terrain?, leagues? }[]` | Journeys from this place's centre to another place |
4270
+ | `world.equatorialCircumferenceKm` | `number` | The distance round the body at its equator, in kilometres |
4271
+ | `world.surfaceGravityG` | `number` | Surface gravity, as a multiple of Earth's |
4272
+ | `world.axialTiltDegrees` | `number` | The tilt of the axis, in degrees |
4273
+ | `year.days` | `number` | How many days the body's year holds |
4274
+ | `year.hoursPerDay` | `number` | How many hours the day divides into |
4275
+ | `year.minutesPerHour` | `number` | How many minutes the hour divides into |
4276
+ | `year.secondsPerMinute` | `number` | How many seconds the minute divides into |
4277
+ | `present` | `date` | The day the setting stops and play begins |
4278
+ | `body.diameterKm` | `number` | The body's diameter, in kilometres |
4279
+ | `body.orbitalRadiusKm` | `number` | How far it orbits from the body it circles, in kilometres |
4280
+ | `body.orbit` | `string` | The orbit's shape — `circular` means the cycle never varies |
4281
+ | `body.inclined` | `boolean` | Whether the orbit is inclined to the plane the world orbits in |
4282
+ | `moon.cycle` | `number` | How many days it takes to return to the same phase |
4283
+ | `moon.newOn` | day-precision `date` | A day it was new, written in the reference calendar |
4284
+ | `moon.eclipses` | `string` | `never`, `rare`, `occasional` or `frequent` |
4284
4285
 
4285
4286
  #### Why a place exists
4286
4287
 
@@ -4501,12 +4502,26 @@ never expanded**: a region held by a polity lists the polity in `held_by`, and
4501
4502
  a settlement within that region lists only the house or order whose `domains`
4502
4503
  names it — its lord's lord is reached through `affiliation.parents`.
4503
4504
 
4504
- **A settlement, a site or a structure is held by someone.** One of those that
4505
- no affiliation's `domains` names — in this package or in a fetched index — is
4506
- a warning from `package-build lint`, `unheld land`, located at the note's
4507
- `type:` line, so a gap in tenure shows while a note is written. A `world`, a
4508
- `region` and a `feature` are exempt: a region is held through its polity's
4509
- `domains`, and a river by nobody.
4505
+ #### Who governs a place
4506
+
4507
+ A place may state its governing affiliation in `data.government`. Use an affiliation Address, either a local shortcode or a fully qualified Address from this package or a fetched dependency:
4508
+
4509
+ ```yaml
4510
+ data:
4511
+ population: 1200
4512
+ government: crown
4513
+ ```
4514
+
4515
+ Write `government: null` for **complete anarchy**. Omitting the key leaves government unspecified. Null is retained in published metadata and displayed as “Complete anarchy” in website, Foundry and book infoboxes; an omitted key has no Government row. A reference displays a link to the governing affiliation.
4516
+
4517
+ | Population | Government | Governance advisory |
4518
+ | --------------------- | ------------------- | -------------------------------- |
4519
+ | Positive | Omitted | Warning at `data.population` |
4520
+ | Missing, zero or null | Omitted | None |
4521
+ | Any | Explicit `null` | None; complete anarchy |
4522
+ | Any | Affiliation Address | None; the reference must resolve |
4523
+
4524
+ These rules apply to every place subtype, including settlements, sites, structures, features, regions and worlds. Empty strings, numbers, lists, maps, references to another note type and unresolved references are errors. They do not declare anarchy. `domains`, `parents`, containment and capitals do not supply a government. Holdings remain tenure facts; the former unconditional “unheld land” warning is retired.
4510
4525
 
4511
4526
  #### Population figures
4512
4527
 
@@ -463,32 +463,33 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
463
463
 
464
464
  **Subtypes:** `world`, `region`, `settlement`, `site`, `structure`, `feature`, `celestial`.
465
465
 
466
- | Field | Shape | Meaning |
467
- | -------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
468
- | `data.calendar` | an Address | Calendar used to display this place's dates. |
469
- | `data.demonym` | string | What a person from this place is called — a Vylarian. |
470
- | `data.purpose` | string | The reason this settlement, site, or structure exists, selected from its placeCharacter tags. |
471
- | `data.lore` | list of Addresses | Lore concerning this place — its peoples, its law, its calendar, its history. |
472
- | `data.parents` | list of Addresses | Enclosing places this one sits within. |
473
- | `data.population` | number | Approximate population, to two significant digits. |
474
- | `data.market` | number | What trade the settlement supports, on a scale of six: 1 hamlet, 2 village, 3 town, 4 market town, 5 city, 6 great city. |
475
- | `data.borders` | list of `{ to, bearing }` entries | Places sharing a frontier with this one — each the other's shortcode and where it lies from here. |
476
- | `data.routes` | list of `{ to, bearing, mode, days, terrain?, leagues? }` entries | Journeys from this place's centre — where the destination lies, how it is travelled, and about how many days it takes. |
477
- | `data.world.equatorialCircumferenceKm` | number | The distance round the body at its equator, in kilometres. |
478
- | `data.world.surfaceGravityG` | number | Surface gravity, as a multiple of Earth's. |
479
- | `data.world.axialTiltDegrees` | number | The tilt of the axis, in degrees — what makes the body have seasons. |
480
- | `data.year.days` | number | How many days the body's year holds. Every calendar's months sum to it. |
481
- | `data.year.hoursPerDay` | number | How many hours the day divides into. |
482
- | `data.year.minutesPerHour` | number | How many minutes the hour divides into. |
483
- | `data.year.secondsPerMinute` | number | How many seconds the minute divides into. |
484
- | `data.present` | a date | The day the setting stops and play begins — what an age is computed against. |
485
- | `data.body.diameterKm` | number | The body's diameter, in kilometres. |
486
- | `data.body.orbitalRadiusKm` | number | How far the body orbits from the one it circles, in kilometres. |
487
- | `data.body.orbit` | string | The orbit's shape — `circular` means the cycle never varies. |
488
- | `data.body.inclined` | boolean | Whether the orbit is inclined to the plane the world orbits in. |
489
- | `data.moon.cycle` | number | How many days the body takes to return to the same phase. |
490
- | `data.moon.newOn` | a day-precision date | A day the body was new, written in the reference calendar. |
491
- | `data.moon.eclipses` | string | How often the body eclipses or is eclipsed — `never`, `rare`, `occasional` or `frequent`. |
466
+ | Field | Shape | Meaning |
467
+ | -------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
468
+ | `data.calendar` | an Address | Calendar used to display this place's dates. |
469
+ | `data.demonym` | string | What a person from this place is called — a Vylarian. |
470
+ | `data.purpose` | string | The reason this settlement, site, or structure exists, selected from its placeCharacter tags. |
471
+ | `data.lore` | list of Addresses | Lore concerning this place — its peoples, its law, its calendar, its history. |
472
+ | `data.parents` | list of Addresses | Enclosing places this one sits within. |
473
+ | `data.population` | number | Approximate population, to two significant digits. |
474
+ | `data.government` | an affiliation Address or null | The governing affiliation; explicit null means complete anarchy. A positive population with no government key produces an advisory. |
475
+ | `data.market` | number | What trade the settlement supports, on a scale of six: 1 hamlet, 2 village, 3 town, 4 market town, 5 city, 6 great city. |
476
+ | `data.borders` | list of `{ to, bearing }` entries | Places sharing a frontier with this one — each the other's shortcode and where it lies from here. |
477
+ | `data.routes` | list of `{ to, bearing, mode, days, terrain?, leagues? }` entries | Journeys from this place's centre — where the destination lies, how it is travelled, and about how many days it takes. |
478
+ | `data.world.equatorialCircumferenceKm` | number | The distance round the body at its equator, in kilometres. |
479
+ | `data.world.surfaceGravityG` | number | Surface gravity, as a multiple of Earth's. |
480
+ | `data.world.axialTiltDegrees` | number | The tilt of the axis, in degrees — what makes the body have seasons. |
481
+ | `data.year.days` | number | How many days the body's year holds. Every calendar's months sum to it. |
482
+ | `data.year.hoursPerDay` | number | How many hours the day divides into. |
483
+ | `data.year.minutesPerHour` | number | How many minutes the hour divides into. |
484
+ | `data.year.secondsPerMinute` | number | How many seconds the minute divides into. |
485
+ | `data.present` | a date | The day the setting stops and play begins — what an age is computed against. |
486
+ | `data.body.diameterKm` | number | The body's diameter, in kilometres. |
487
+ | `data.body.orbitalRadiusKm` | number | How far the body orbits from the one it circles, in kilometres. |
488
+ | `data.body.orbit` | string | The orbit's shape — `circular` means the cycle never varies. |
489
+ | `data.body.inclined` | boolean | Whether the orbit is inclined to the plane the world orbits in. |
490
+ | `data.moon.cycle` | number | How many days the body takes to return to the same phase. |
491
+ | `data.moon.newOn` | a day-precision date | A day the body was new, written in the reference calendar. |
492
+ | `data.moon.eclipses` | string | How often the body eclipses or is eclipsed — `never`, `rare`, `occasional` or `frequent`. |
492
493
 
493
494
  ### scenario
494
495
 
@@ -25,6 +25,8 @@ describes the keys the build writes from it.
25
25
 
26
26
  ## The infobox
27
27
 
28
+ A place’s Government row links to its authored governing affiliation. Explicit `data.government: null` produces a text row saying “Complete anarchy”; an omitted government produces no row. The same distinction is retained in the page’s `data` metadata.
29
+
28
30
  The toolchain settles what each box holds and in what order — see
29
31
  [the infobox](format-details.md#the-infobox) for the field semantics, the
30
32
  three states a system box can say, and the four section layouts a box is
@@ -301,8 +303,8 @@ its label beneath whatever it holds:
301
303
  </div>
302
304
  ```
303
305
 
304
- The kind is `code`, `table`, `figure`, `map` or `prose`, labelled **Code**,
305
- **Table**, **Figure**, **Map** and **Prose**. Every kind is drawn alike — the
306
+ The kind is `code`, `table`, `figure`, `map`, `poem` or `prose`, labelled **Code**,
307
+ **Table**, **Figure**, **Map**, **Poem** and **Prose**. Every kind is drawn alike — the
306
308
  block carries the spacing, its contents add none of their own at the edges,
307
309
  and the label reads as a caption rather than as another paragraph of the body
308
310
  — so a kind added to the vocabulary arrives styled. A fence carrying an `id`
@@ -511,7 +511,7 @@ function checkCalendarDayNames(note, daysPerYear) {
511
511
  /**
512
512
  * Check a `place` note's world facts.
513
513
  *
514
- * Declared on `place` beside its tenure check, so the lint runs both with the
514
+ * Declared on `place` beside its government advisory, so the lint runs both with the
515
515
  * same index. Four rules, and each is a fact about the setting rather than
516
516
  * about the file:
517
517
  *
@@ -50,6 +50,7 @@ import MarkdownIt from "markdown-it";
50
50
  import footnotePlugin from "markdown-it-footnote";
51
51
  import deflistPlugin from "markdown-it-deflist";
52
52
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
53
+ import { poetryMarkdown, verseLines } from "./content-poetry.mjs";
53
54
  import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-attributes.mjs";
54
55
 
55
56
  const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
@@ -63,6 +64,8 @@ const titleParser = new MarkdownIt({ html: false });
63
64
 
64
65
  const OPEN = /^:::([A-Za-z][A-Za-z0-9-]*)(?:[ \t]+(.*?))?[ \t]*$/;
65
66
  const CLOSE = /^:::[ \t]*$/;
67
+ const INDENTED_POETRY_OPEN = /^ +:::poetry(?:[ \t]+(.*?))?[ \t]*$/;
68
+ const INDENTED_CLOSE = /^ *:::[ \t]*$/;
66
69
 
67
70
  /** The blocks an author may open, with the heading each takes by default. */
68
71
  export const BLOCK_NAMES = Object.freeze({
@@ -98,7 +101,7 @@ const FOREIGN = Object.freeze(["figure"]);
98
101
  /** `title` is the heading. Everything else an author writes becomes an attribute. */
99
102
  const TITLE = "title";
100
103
 
101
- const names = () => Object.keys(BLOCK_NAMES).join(", ");
104
+ const names = () => [...Object.keys(BLOCK_NAMES), "poetry"].join(", ");
102
105
 
103
106
  /** Text safe inside a double-quoted attribute. */
104
107
  function escapeAttribute(value) {
@@ -132,7 +135,7 @@ export function scanBlocks(source) {
132
135
 
133
136
  for (let i = 0; i < lines.length; i++) {
134
137
  const line = lines[i];
135
- const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
138
+ const fence = opening?.name === "poetry" ? null : /^ {0,3}(`{3,}|~{3,})/.exec(line);
136
139
  if (codeFence) {
137
140
  if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
138
141
  codeFence = null;
@@ -144,7 +147,7 @@ export function scanBlocks(source) {
144
147
  continue;
145
148
  }
146
149
 
147
- if (CLOSE.test(line)) {
150
+ if (CLOSE.test(line) || (opening?.name === "poetry" && INDENTED_CLOSE.test(line))) {
148
151
  // A closer belongs to the innermost block still open, whichever
149
152
  // pass owns it.
150
153
  if (opening && innerOpen > 0) {
@@ -163,11 +166,21 @@ export function scanBlocks(source) {
163
166
  opening = null;
164
167
  continue;
165
168
  }
166
- const body = lines
167
- .slice(opening.start + 1, i)
168
- .join("\n")
169
- .trim();
170
- if (!body) {
169
+ const bodyLines = lines.slice(opening.start + 1, i);
170
+ const rawBody = bodyLines.join("\n");
171
+ const body = opening.name === "poetry" ? rawBody : rawBody.trim();
172
+ if (opening.name === "poetry") {
173
+ for (let offset = 0; offset < bodyLines.length; offset++) {
174
+ const column = bodyLines[offset].indexOf("\t");
175
+ if (column >= 0)
176
+ errors.push({
177
+ line: opening.start + offset + 2,
178
+ column: column + 1,
179
+ message: "tabs are not allowed in poetry",
180
+ });
181
+ }
182
+ }
183
+ if (!body.trim()) {
171
184
  errors.push({
172
185
  line: opening.start + 1,
173
186
  column: 1,
@@ -176,12 +189,23 @@ export function scanBlocks(source) {
176
189
  opening = null;
177
190
  continue;
178
191
  }
192
+ if (opening.name === "poetry" && opening.attributes.syllables) {
193
+ const expected = opening.attributes.syllables.split(",").length;
194
+ const actual = verseLines(body).length;
195
+ if (expected !== actual)
196
+ errors.push({
197
+ line: opening.start + 1,
198
+ column: 1,
199
+ message: `poetry syllables= gives ${expected} counts for ${actual} verse lines`,
200
+ });
201
+ }
179
202
  // An H1, or an anchored heading at any level, starts a Foundry
180
203
  // journal page — splitting the block's own page in two and
181
204
  // publishing the rest with no wrapper around it, a GM-only
182
205
  // section included. Refused rather than split: a lower heading
183
206
  // with no anchor is still the ordinary way to structure a box.
184
- for (let at = opening.start + 1; at < i; at++) {
207
+ // Poetry treats those same characters as verse text.
208
+ for (let at = opening.start + 1; opening.name !== "poetry" && at < i; at++) {
185
209
  if (!parseHeadingLine(lines[at])?.startsPage) continue;
186
210
  const article = /^[aeiou]/i.test(opening.name) ? "an" : "a";
187
211
  errors.push({
@@ -198,7 +222,8 @@ export function scanBlocks(source) {
198
222
  continue;
199
223
  }
200
224
 
201
- const open = OPEN.exec(line);
225
+ const indentedPoetry = INDENTED_POETRY_OPEN.exec(line);
226
+ const open = OPEN.exec(line) ?? (indentedPoetry && [line, "poetry", indentedPoetry[1]]);
202
227
  if (!open) continue;
203
228
  const [, name, raw = ""] = open;
204
229
  const at = { line: i + 1, column: 1 };
@@ -207,7 +232,7 @@ export function scanBlocks(source) {
207
232
  else foreignOutside += 1;
208
233
  continue;
209
234
  }
210
- if (!Object.hasOwn(BLOCK_NAMES, name)) {
235
+ if (!Object.hasOwn(BLOCK_NAMES, name) && name !== "poetry") {
211
236
  errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
212
237
  opening = { start: i, rejected: true };
213
238
  continue;
@@ -254,11 +279,30 @@ export function scanBlocks(source) {
254
279
  continue;
255
280
  }
256
281
  for (const [key, value] of Object.entries(parsed.values)) {
282
+ if (name === "poetry") {
283
+ if (!["form", "meter", "rhyme", "syllables", "lang"].includes(key))
284
+ errors.push({ ...at, message: `${key}= is not a poetry attribute` });
285
+ else if (key === "syllables" && !/^[1-9]\d*(?:,[1-9]\d*)*$/.test(value))
286
+ errors.push({
287
+ ...at,
288
+ message: "poetry syllables= needs positive comma-separated counts",
289
+ });
290
+ else if (key === "lang" && !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(value))
291
+ errors.push({ ...at, message: "poetry lang= needs a language tag" });
292
+ }
257
293
  if (key === TITLE) title = value;
258
294
  else attributes[key] = value;
259
295
  }
260
296
  }
261
- opening = { start: i, name, title, id, classes, attributes };
297
+ opening = {
298
+ start: i,
299
+ name,
300
+ title,
301
+ id,
302
+ classes,
303
+ attributes,
304
+ indent: name === "poetry" ? /^ */.exec(line)[0] : "",
305
+ };
262
306
  }
263
307
 
264
308
  if (opening && !opening.rejected) {
@@ -295,11 +339,26 @@ function attributeText(block, target, classes) {
295
339
  const parts = [`class="${escapeAttribute(classes.join(" "))}"`];
296
340
  if (id) parts.push(`id="${escapeAttribute(id)}"`);
297
341
  for (const [key, value] of Object.entries(block.attributes)) {
298
- parts.push(`${key}="${escapeAttribute(value)}"`);
342
+ const attribute = block.name === "poetry" && key !== "lang" ? `data-${key}` : key;
343
+ parts.push(`${attribute}="${escapeAttribute(value)}"`);
299
344
  }
300
345
  return parts.join(" ");
301
346
  }
302
347
 
348
+ /** Keep a poem in its list, without turning standalone readable indentation into code. */
349
+ function poetryWrapperIndent(lines, block) {
350
+ const indent = block.indent;
351
+ if (indent.length < 4) return indent;
352
+ for (let at = block.start - 1; at >= 0; at--) {
353
+ const line = lines[at];
354
+ if (!line.trim()) continue;
355
+ const leading = /^ */.exec(line)[0].length;
356
+ if (/^ *(?:[-+*]|\d+[.)]) +/.test(line) && leading < indent.length) return indent;
357
+ if (leading < indent.length) break;
358
+ }
359
+ return "";
360
+ }
361
+
303
362
  /**
304
363
  * Render named blocks for one publishing surface.
305
364
  *
@@ -327,7 +386,6 @@ export function renderBlocks(source, target) {
327
386
  output.push(...lines.slice(cursor, block.start));
328
387
  const classes = [block.name, ...block.classes];
329
388
  const attributes = attributeText(block, target, classes);
330
- const title = titleParser.renderInline(block.title);
331
389
  // A container's body may hold a block of its own, rendered first so
332
390
  // its own wrapper is already written when the outer one wraps it in
333
391
  // turn — nesting composes because each level is markdown-with-raw-HTML
@@ -336,6 +394,17 @@ export function renderBlocks(source, target) {
336
394
  BLOCK_CONTAINERS.includes(block.name) ?
337
395
  renderBlocks(block.body, target).markdown
338
396
  : block.body;
397
+ if (block.name === "poetry") {
398
+ const indent = poetryWrapperIndent(lines, block);
399
+ const verses = poetryMarkdown(held)
400
+ .split("\n")
401
+ .map((line) => (line ? `${indent}${line}` : line))
402
+ .join("\n");
403
+ output.push("", `${indent}<div ${attributes}>`, "", verses, "", `${indent}</div>`, "");
404
+ cursor = block.end + 1;
405
+ continue;
406
+ }
407
+ const title = titleParser.renderInline(block.title);
339
408
  if (target === "foundry") {
340
409
  output.push(
341
410
  "",
@@ -107,6 +107,7 @@ export const FIGURE_NAMES = Object.freeze({
107
107
  table: "Table",
108
108
  figure: "Figure",
109
109
  map: "Map",
110
+ poem: "Poem",
110
111
  prose: "Prose",
111
112
  });
112
113
 
@@ -204,9 +205,10 @@ function picturesAreMap(contents, resolveRole) {
204
205
  * @param {string} contents - The contents, trimmed.
205
206
  * @param {(address: string) => string|undefined} resolveRole - The caller's
206
207
  * lookup from a picture's address to the role its asset declares.
207
- * @returns {"code"|"table"|"figure"|"map"|"prose"} The kind.
208
+ * @returns {"code"|"table"|"figure"|"map"|"poem"|"prose"} The kind.
208
209
  */
209
210
  function figureKind(first, contents, resolveRole) {
211
+ if (/^(?: *:::poetry(?:\s|$)|<div\b[^>]*\bclass="poetry(?:\s|"))/.test(contents)) return "poem";
210
212
  if (first?.type === "table_open") return "table";
211
213
  if (first?.type === "fence" && /^\s*sql\b/i.test(first.info ?? "")) return "table";
212
214
  if (first?.type === "fence" || first?.type === "code_block") return "code";
@@ -240,7 +242,7 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
240
242
  const lines = String(source ?? "").split("\n");
241
243
  const figures = [];
242
244
  const errors = [];
243
- const counts = { code: 0, table: 0, figure: 0, map: 0, prose: 0 };
245
+ const counts = { code: 0, table: 0, figure: 0, map: 0, poem: 0, prose: 0 };
244
246
  const ids = new Set();
245
247
  const headingIds = [];
246
248
  let codeFence = null;
@@ -292,10 +294,17 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
292
294
  let close = start + 1;
293
295
  let split = -1;
294
296
  let nested = null;
297
+ let namedDepth = 0;
295
298
  const fenced = new Set();
296
299
  while (close < lines.length) {
297
300
  const body = lines[close];
298
301
  const bodyFence = FENCE.exec(body);
302
+ if (namedDepth) {
303
+ fenced.add(close);
304
+ if (/^ *:::[ \t]*$/.test(body)) namedDepth--;
305
+ close++;
306
+ continue;
307
+ }
299
308
  if (nested) {
300
309
  fenced.add(close);
301
310
  if (
@@ -313,6 +322,12 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
313
322
  close++;
314
323
  continue;
315
324
  }
325
+ if (/^ *:::poetry(?:\s|$)/.test(body)) {
326
+ namedDepth++;
327
+ fenced.add(close);
328
+ close++;
329
+ continue;
330
+ }
316
331
  if (CLOSE.test(body)) break;
317
332
  if (SPLIT.test(body)) {
318
333
  if (split < 0) split = close;
@@ -446,7 +461,7 @@ export function renderFigureBlocks(
446
461
  output.push(...lines.slice(cursor, figure.line - 1));
447
462
  const numbered = (figure.id ? byId.get(figure.id) : null) ?? figure;
448
463
  const contents = lines.slice(figure.bodyStart, figure.bodyEnd).join("\n");
449
- const classes = ["content-figure", `content-figure-${figure.kind}`, ...figure.classes];
464
+ const classes = ["content-figure", `content-figure-${numbered.kind}`, ...figure.classes];
450
465
  const id = figure.slug ? ` id="${escapeHtml(figure.slug)}"` : "";
451
466
  output.push(`<div${id} class="${escapeHtml(classes.join(" "))}">`);
452
467
  if (figure.kind === "figure" || figure.kind === "map") {
@@ -292,6 +292,7 @@ types:
292
292
  - lore
293
293
  - parents
294
294
  - population
295
+ - government
295
296
  - market
296
297
  - borders
297
298
  - routes
@@ -0,0 +1,61 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /** The authored verse lines, excluding stanza separators. */
4
+ export function verseLines(body) {
5
+ return String(body ?? "")
6
+ .split("\n")
7
+ .filter((line) => line.trim());
8
+ }
9
+
10
+ /**
11
+ * The least-indented verse is level zero. Every two additional leading spaces
12
+ * add one level; a leftover space is discarded and the level stops at eight.
13
+ * Blank lines divide stanzas rather than contributing to the baseline.
14
+ */
15
+ export function poetryStanzas(body) {
16
+ const lines = String(body ?? "").split("\n");
17
+ const verses = verseLines(body);
18
+ if (!verses.length) return [];
19
+ const baseline = Math.min(...verses.map((line) => /^ */.exec(line)[0].length));
20
+ const stanzas = [];
21
+ let stanza = [];
22
+ for (const line of lines) {
23
+ if (!line.trim()) {
24
+ if (stanza.length) stanzas.push(stanza);
25
+ stanza = [];
26
+ continue;
27
+ }
28
+ const spaces = /^ */.exec(line)[0].length;
29
+ stanza.push({
30
+ text: line.slice(spaces).trimEnd(),
31
+ level: Math.min(8, Math.floor((spaces - baseline) / 2)),
32
+ });
33
+ }
34
+ if (stanza.length) stanzas.push(stanza);
35
+ return stanzas;
36
+ }
37
+
38
+ /** A verse is inline Markdown even when it starts with block syntax. */
39
+ export function inlineVerse(text) {
40
+ return String(text)
41
+ .replace(/^(#{1,6}\s|>\s|[-+*]\s|:{1,2}\s|`{3,}|~{3,}|-{3,}\s*$)/, "\\$1")
42
+ .replace(/^(\d+)([.)])\s/, "$1\\$2 ");
43
+ }
44
+
45
+ /**
46
+ * Keep stanzas as Markdown paragraphs and make every verse line a hard break.
47
+ * The surrounding renderer still sees the entire note, so links and footnote
48
+ * definitions remain in scope. The span carries the indent for each line.
49
+ */
50
+ export function poetryMarkdown(body) {
51
+ return poetryStanzas(body)
52
+ .map((stanza) =>
53
+ stanza
54
+ .map(
55
+ ({ text, level }) =>
56
+ `<span class="poetry-line${level ? ` i${level}` : ""}">${inlineVerse(text)}</span>`,
57
+ )
58
+ .join(" \n"),
59
+ )
60
+ .join("\n\n");
61
+ }