@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.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/assets/styles/content-base.css +45 -0
- package/ci/check-banners.mjs +183 -0
- package/ci/ci-steps.mjs +21 -11
- package/docs/api.md +14 -11
- package/docs/authoring/frontmatter.md +21 -0
- package/docs/authoring/links-and-markup.md +69 -2
- package/docs/content-format.md +11 -0
- package/docs/reference/format-details.md +50 -35
- package/docs/reference/note-types.md +27 -26
- package/docs/reference/theme-pages.md +4 -2
- package/engine/calendar-notes.mjs +1 -1
- package/engine/content-blocks.mjs +83 -14
- package/engine/content-figures.mjs +18 -3
- package/engine/content-format.yaml +1 -0
- package/engine/content-poetry.mjs +61 -0
- package/engine/holdings.mjs +38 -66
- package/engine/index.mjs +1 -1
- package/engine/infobox.mjs +10 -0
- package/engine/metadata-index.mjs +3 -0
- package/engine/note-vocabulary.mjs +18 -5
- package/engine/pdf-build.mjs +1 -1
- package/engine/pdf-render.mjs +32 -2
- package/engine/rank-ladder.mjs +32 -0
- package/hugo-theme/data/banners.yaml +1 -1
- package/hugo-theme/static/css/style.css +29 -0
- package/package.json +4 -2
- package/types/engine/calendar-notes.d.mts +1 -1
- package/types/engine/content-figures.d.mts +1 -0
- package/types/engine/content-poetry.d.mts +19 -0
- package/types/engine/holdings.d.mts +20 -17
- package/types/engine/infobox.d.mts +1 -0
- package/types/engine/note-vocabulary.d.mts +4 -0
- package/types/engine/rank-ladder.d.mts +8 -0
|
@@ -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
|
|
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
|
|
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
|
-
| `
|
|
4267
|
-
| `
|
|
4268
|
-
| `
|
|
4269
|
-
| `
|
|
4270
|
-
| `world.
|
|
4271
|
-
| `world.
|
|
4272
|
-
| `
|
|
4273
|
-
| `year.
|
|
4274
|
-
| `year.
|
|
4275
|
-
| `year.
|
|
4276
|
-
| `
|
|
4277
|
-
| `
|
|
4278
|
-
| `body.
|
|
4279
|
-
| `body.
|
|
4280
|
-
| `body.
|
|
4281
|
-
| `
|
|
4282
|
-
| `moon.
|
|
4283
|
-
| `moon.
|
|
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
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
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.
|
|
475
|
-
| `data.
|
|
476
|
-
| `data.
|
|
477
|
-
| `data.
|
|
478
|
-
| `data.world.
|
|
479
|
-
| `data.world.
|
|
480
|
-
| `data.
|
|
481
|
-
| `data.year.
|
|
482
|
-
| `data.year.
|
|
483
|
-
| `data.year.
|
|
484
|
-
| `data.
|
|
485
|
-
| `data.
|
|
486
|
-
| `data.body.
|
|
487
|
-
| `data.body.
|
|
488
|
-
| `data.body.
|
|
489
|
-
| `data.
|
|
490
|
-
| `data.moon.
|
|
491
|
-
| `data.moon.
|
|
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
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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-${
|
|
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") {
|
|
@@ -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
|
+
}
|