@heroiclands/package-build 22.26.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.
- package/CHANGELOG.md +11 -0
- package/README.md +3 -1
- package/assets/styles/content-base.css +46 -3
- package/docs/api.md +33 -13
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +96 -149
- package/docs/content-format.md +5 -3
- package/docs/guides/government-migration.md +52 -0
- package/docs/guides/site.md +5 -5
- package/docs/reference/format-details.md +110 -219
- package/docs/reference/note-types.md +27 -29
- package/docs/reference/theme-pages.md +31 -29
- package/engine/anchors.mjs +85 -13
- package/engine/art-fields.mjs +1 -1
- package/engine/base-compiler.mjs +9 -1
- package/engine/bundles.mjs +1 -1
- package/engine/code-fences.mjs +6 -2
- package/engine/content-alerts.mjs +195 -0
- package/engine/content-blocks.mjs +119 -241
- package/engine/content-embeds.mjs +1 -1
- package/engine/content-figures.mjs +166 -230
- package/engine/content-format-check.mjs +3 -2
- package/engine/content-format.yaml +0 -5
- package/engine/content-images.mjs +3 -3
- package/engine/content-poetry.mjs +141 -0
- package/engine/content-spans.mjs +177 -0
- package/engine/foundry-entries.mjs +2 -0
- package/engine/frontmatter-lint.mjs +2 -1
- package/engine/heading-attributes.mjs +1 -1
- package/engine/helpers.mjs +8 -3
- package/engine/holdings.mjs +165 -273
- package/engine/index.mjs +1 -1
- package/engine/infobox.mjs +1 -1
- package/engine/journals.mjs +8 -6
- package/engine/markdown-expressions.mjs +9 -3
- package/engine/metadata-index.mjs +2 -3
- package/engine/note-vocabulary.mjs +16 -11
- package/engine/pdf-build.mjs +1 -1
- package/engine/pdf-render.mjs +90 -31
- package/engine/populations.mjs +17 -83
- package/engine/site-build.mjs +44 -16
- package/engine/site-preview.mjs +22 -6
- package/engine/wikilinks.mjs +18 -4
- package/hugo-theme/layouts/partials/holdings/entry.html +2 -2
- package/hugo-theme/layouts/partials/holdings.html +14 -14
- package/hugo-theme/static/css/style.css +40 -2
- package/package.json +1 -1
- package/sohl/item-fields.mjs +0 -16
- package/types/engine/anchors.d.mts +13 -4
- package/types/engine/art-fields.d.mts +1 -1
- package/types/engine/code-fences.d.mts +3 -1
- package/types/engine/content-alerts.d.mts +62 -0
- package/types/engine/content-blocks.d.mts +3 -10
- package/types/engine/content-embeds.d.mts +1 -1
- package/types/engine/content-figures.d.mts +13 -20
- package/types/engine/content-images.d.mts +1 -1
- package/types/engine/content-poetry.d.mts +32 -0
- package/types/engine/content-spans.d.mts +18 -0
- package/types/engine/holdings.d.mts +59 -105
- package/types/engine/markdown-expressions.d.mts +1 -1
- package/types/engine/note-vocabulary.d.mts +10 -2
- package/types/engine/populations.d.mts +4 -13
- package/types/engine/site-build.d.mts +3 -3
- package/types/engine/wikilinks.d.mts +5 -1
|
@@ -115,7 +115,6 @@ These fields are accepted by every note type. A field's value can still be irrel
|
|
|
115
115
|
| `data.governance.ranks` | list | The ladder of ranks the body confers — level, title, description. |
|
|
116
116
|
| `data.governance.offices` | as authored | Named offices, each with a description and optional dated holders. |
|
|
117
117
|
| `data.seat` | an Address | Where the affiliation's authority sits. |
|
|
118
|
-
| `data.domains` | list of Addresses | Places over which it holds sway. |
|
|
119
118
|
| `data.population` | number | How many people it counts. |
|
|
120
119
|
| `data.economy` | list of Addresses | What its economic life runs on — currencies, banking bodies, goods. |
|
|
121
120
|
| `data.lore` | list of Addresses | Lore concerning it — the peoples it draws on, the god a faith venerates, its law, its calendar. |
|
|
@@ -128,7 +127,6 @@ These fields are accepted by every note type. A field's value can still be irrel
|
|
|
128
127
|
| ---------------- | ------ | ------------------ |
|
|
129
128
|
| `subType` | sohl | `system.subType` |
|
|
130
129
|
| `data.seat` | sohl | `system.seat` |
|
|
131
|
-
| `data.domains` | sohl | `system.domain` |
|
|
132
130
|
| `data.parents` | sohl | `system.parents` |
|
|
133
131
|
| `data.relations` | sohl | `system.relations` |
|
|
134
132
|
|
|
@@ -463,33 +461,33 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
|
|
|
463
461
|
|
|
464
462
|
**Subtypes:** `world`, `region`, `settlement`, `site`, `structure`, `feature`, `celestial`.
|
|
465
463
|
|
|
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
|
|
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`.
|
|
464
|
+
| Field | Shape | Meaning |
|
|
465
|
+
| -------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
466
|
+
| `data.calendar` | an Address | Calendar used to display this place's dates. |
|
|
467
|
+
| `data.demonym` | string | What a person from this place is called — a Vylarian. |
|
|
468
|
+
| `data.purpose` | string | The reason this settlement, site, or structure exists, selected from its placeCharacter tags. |
|
|
469
|
+
| `data.lore` | list of Addresses | Lore concerning this place — its peoples, its law, its calendar, its history. |
|
|
470
|
+
| `data.parents` | list of Addresses | Enclosing places this one sits within. |
|
|
471
|
+
| `data.population` | number | Approximate population, to two significant digits. |
|
|
472
|
+
| `data.government` | an Address or null | The governing affiliation Address; the default target type is affiliation. Explicit null means complete anarchy. A positive population with no government key produces an advisory. |
|
|
473
|
+
| `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. |
|
|
474
|
+
| `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. |
|
|
475
|
+
| `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. |
|
|
476
|
+
| `data.world.equatorialCircumferenceKm` | number | The distance round the body at its equator, in kilometres. |
|
|
477
|
+
| `data.world.surfaceGravityG` | number | Surface gravity, as a multiple of Earth's. |
|
|
478
|
+
| `data.world.axialTiltDegrees` | number | The tilt of the axis, in degrees — what makes the body have seasons. |
|
|
479
|
+
| `data.year.days` | number | How many days the body's year holds. Every calendar's months sum to it. |
|
|
480
|
+
| `data.year.hoursPerDay` | number | How many hours the day divides into. |
|
|
481
|
+
| `data.year.minutesPerHour` | number | How many minutes the hour divides into. |
|
|
482
|
+
| `data.year.secondsPerMinute` | number | How many seconds the minute divides into. |
|
|
483
|
+
| `data.present` | a date | The day the setting stops and play begins — what an age is computed against. |
|
|
484
|
+
| `data.body.diameterKm` | number | The body's diameter, in kilometres. |
|
|
485
|
+
| `data.body.orbitalRadiusKm` | number | How far the body orbits from the one it circles, in kilometres. |
|
|
486
|
+
| `data.body.orbit` | string | The orbit's shape — `circular` means the cycle never varies. |
|
|
487
|
+
| `data.body.inclined` | boolean | Whether the orbit is inclined to the plane the world orbits in. |
|
|
488
|
+
| `data.moon.cycle` | number | How many days the body takes to return to the same phase. |
|
|
489
|
+
| `data.moon.newOn` | a day-precision date | A day the body was new, written in the reference calendar. |
|
|
490
|
+
| `data.moon.eclipses` | string | How often the body eclipses or is eclipsed — `never`, `rare`, `occasional` or `frequent`. |
|
|
493
491
|
|
|
494
492
|
### scenario
|
|
495
493
|
|
|
@@ -9,12 +9,12 @@ subType: reference
|
|
|
9
9
|
|
|
10
10
|
`package-build site` writes a page's front matter; the shared Hugo theme reads
|
|
11
11
|
it and draws the page around it. This reference describes that second half —
|
|
12
|
-
the infobox rail, the hero band, the
|
|
12
|
+
the infobox rail, the hero band, the government and related cards, the place
|
|
13
13
|
map, search, and the markup an image or a figure renders as — and names the
|
|
14
14
|
classes a consumer styles against.
|
|
15
15
|
|
|
16
16
|
**None of the sections below is an authoring instruction.** `infoboxes`,
|
|
17
|
-
`related`, `contains`, `
|
|
17
|
+
`related`, `contains`, `governed_by`, `governed_places`, `map`, `package`, `slug`, `url`
|
|
18
18
|
and `title` cannot be written by a note at all: a note's top-level region is
|
|
19
19
|
closed and does not admit them, so each arrives already resolved by the time
|
|
20
20
|
the theme sees it. Read this page when a panel is missing or a class needs
|
|
@@ -166,7 +166,7 @@ artwork where the considered answer was "none". See
|
|
|
166
166
|
declines one; the spelling differs from the hand-authored page's, and the two
|
|
167
167
|
are not interchangeable.
|
|
168
168
|
|
|
169
|
-
## The
|
|
169
|
+
## The containment and government card
|
|
170
170
|
|
|
171
171
|
`partials/holdings.html` renders a bordered card below the body and above the
|
|
172
172
|
Related card, holding up to three small tables. The build derives this front
|
|
@@ -176,13 +176,13 @@ note and dropped if one carries them:
|
|
|
176
176
|
```yaml
|
|
177
177
|
---
|
|
178
178
|
contains: [{ title: …, url: …, type: place, subType: … }] # places within this one
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
governed_by: [{ title: …, url: …, type: affiliation, subType: … }] # the governing affiliation of this place
|
|
180
|
+
governed_places: [{ title: …, url: …, type: place, subType: … }] # places explicitly governed by this affiliation
|
|
181
181
|
---
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
A place page carries `contains` and/or `
|
|
185
|
-
`
|
|
184
|
+
A place page carries `contains` and/or `governed_by`; an affiliation page carries
|
|
185
|
+
`governed_places`. Each key is tested for independently — a page carrying none of the
|
|
186
186
|
three renders no card, and a page carrying one renders one table.
|
|
187
187
|
|
|
188
188
|
**`url` is absent for an entry with no page of its own** — a stub note with an
|
|
@@ -191,29 +191,29 @@ an entry's title renders as plain text; every other entry renders as a link.
|
|
|
191
191
|
`partials/holdings/entry.html` carries the guard, the same one
|
|
192
192
|
`partials/infobox/entry.html` already applies to its own `{text, url}` shape.
|
|
193
193
|
|
|
194
|
-
**Within** (`contains`) and **
|
|
194
|
+
**Within** (`contains`) and **Governed places** (`governed_places`) list places, and each is
|
|
195
195
|
broken into groups by `subType`, in the closed order region, settlement, site,
|
|
196
196
|
structure, feature — a `subType` outside that order, or missing entirely,
|
|
197
|
-
groups last, alphabetically by its rendered label. **
|
|
197
|
+
groups last, alphabetically by its rendered label. **Governed by** (`governed_by`)
|
|
198
198
|
lists affiliations, whose kinds have no fixed hierarchy to group by, so it
|
|
199
199
|
stays one flat grid with the kind printed beside each name.
|
|
200
200
|
|
|
201
201
|
Every grid is `repeat(auto-fill, minmax(14em, 1fr))`, the one the Related card
|
|
202
202
|
uses, so a region with forty settlements stays scannable.
|
|
203
203
|
|
|
204
|
-
| class | drawn as
|
|
205
|
-
| ----------------------- |
|
|
206
|
-
| `.holdings` | the card
|
|
207
|
-
| `.holdings-table` | one of the three tables, label and body together
|
|
208
|
-
| `.holdings-table-label` | "Within" / "
|
|
209
|
-
| `.holdings-table-grid` | the grid of entries (
|
|
210
|
-
| `.holdings-kind-group` | one kind's block within Within or
|
|
211
|
-
| `.holdings-kind-label` | the kind label heading a group
|
|
212
|
-
| `.holdings-entry-name` | an entry's title, linked or plain
|
|
213
|
-
| `.holdings-entry-kind` | the kind printed beside a
|
|
204
|
+
| class | drawn as |
|
|
205
|
+
| ----------------------- | ------------------------------------------------------------------------------- |
|
|
206
|
+
| `.holdings` | the card |
|
|
207
|
+
| `.holdings-table` | one of the three tables, label and body together |
|
|
208
|
+
| `.holdings-table-label` | "Within" / "Governed by" / "Governed places" |
|
|
209
|
+
| `.holdings-table-grid` | the grid of entries (Governed by; the innermost grid of Within/Governed places) |
|
|
210
|
+
| `.holdings-kind-group` | one kind's block within Within or Governed places |
|
|
211
|
+
| `.holdings-kind-label` | the kind label heading a group |
|
|
212
|
+
| `.holdings-entry-name` | an entry's title, linked or plain |
|
|
213
|
+
| `.holdings-entry-kind` | the kind printed beside a Governed by entry |
|
|
214
214
|
|
|
215
|
-
A page whose front matter declares none of `contains`, `
|
|
216
|
-
`
|
|
215
|
+
A page whose front matter declares none of `contains`, `governed_by` or
|
|
216
|
+
`governed_places` renders no card at all.
|
|
217
217
|
|
|
218
218
|
## The place map
|
|
219
219
|
|
|
@@ -291,9 +291,8 @@ for how an author writes `size`, `float` and a role.
|
|
|
291
291
|
|
|
292
292
|
## A numbered figure
|
|
293
293
|
|
|
294
|
-
A
|
|
295
|
-
|
|
296
|
-
the fence's contents, counts each kind separately, and emits the block with
|
|
294
|
+
A leading caption wraps the next supported item in a referable block; `:@` also numbers it. The content build decides which from
|
|
295
|
+
the next item, counts each kind separately, and emits the block with
|
|
297
296
|
its label beneath whatever it holds:
|
|
298
297
|
|
|
299
298
|
```html
|
|
@@ -303,15 +302,14 @@ its label beneath whatever it holds:
|
|
|
303
302
|
</div>
|
|
304
303
|
```
|
|
305
304
|
|
|
306
|
-
The kind is `code`, `table`, `figure`, `map`, `poem` or `
|
|
307
|
-
**Table**, **Figure**, **Map**, **Poem** and **
|
|
305
|
+
The kind is `code`, `table`, `figure`, `map`, `poem`, `prose` or `example`, labelled **Code**,
|
|
306
|
+
**Table**, **Figure**, **Map**, **Poem**, **Prose** and **Example**. Every kind is drawn alike — the
|
|
308
307
|
block carries the spacing, its contents add none of their own at the edges,
|
|
309
308
|
and the label reads as a caption rather than as another paragraph of the body
|
|
310
|
-
— so a kind added to the vocabulary arrives styled. A
|
|
309
|
+
— so a kind added to the vocabulary arrives styled. A caption carrying an `id`
|
|
311
310
|
is a cross-reference target and takes the same scroll offset a heading does.
|
|
312
311
|
|
|
313
|
-
`border`
|
|
314
|
-
block as a boxed aside.
|
|
312
|
+
Caption classes are unrestricted. The built-in `border` class draws the block as a boxed aside. Unnumbered captions use the same wrapper and show caption text without a numbered label.
|
|
315
313
|
|
|
316
314
|
| class | drawn as |
|
|
317
315
|
| ------------------------ | ------------------------------------------ |
|
|
@@ -496,3 +494,7 @@ Each section below has its own landing page.
|
|
|
496
494
|
```
|
|
497
495
|
|
|
498
496
|
A row is a linked title and, when the page carries one, its description.
|
|
497
|
+
|
|
498
|
+
## Alerts
|
|
499
|
+
|
|
500
|
+
The content renderer emits an `aside` with `alert` and `alert-note`, `alert-tip`, `alert-important`, `alert-warning` classes. The `alert-title` header contains a Font Awesome icon and the type's label. Shared styles supply a colored left rule, colored header and ordinary readable body; alerts do not use `details` and never start collapsed. Authored identifiers, additional classes and named attributes belong to the entire aside. Secret blocks retain their separate expandable presentation on the website.
|
package/engine/anchors.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The anchors a note declares on headings and
|
|
15
|
+
* The anchors a note declares on headings, captions, divs, poems and spans.
|
|
16
16
|
*
|
|
17
17
|
* **A leaf, deliberately.** This is asked by the link checker, by the content
|
|
18
18
|
* index, and by the builds that emit a link, and they cannot all import one
|
|
@@ -29,15 +29,20 @@
|
|
|
29
29
|
* @module
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import { scanAlerts } from "./content-alerts.mjs";
|
|
33
|
+
import { scanBlocks } from "./content-blocks.mjs";
|
|
34
|
+
import { scanPoetry } from "./content-poetry.mjs";
|
|
35
|
+
import { scanSpans } from "./content-spans.mjs";
|
|
36
|
+
import { codeRegions } from "./code-fences.mjs";
|
|
37
|
+
import { slugify } from "./content-slug.mjs";
|
|
32
38
|
import { scanFigures } from "./content-figures.mjs";
|
|
33
39
|
import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
* The
|
|
42
|
+
* The identifiers a note declares on headings, captions, divs, poems and spans.
|
|
37
43
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* slug and starts an addressable journal page.
|
|
44
|
+
* Headings and captions may start journal pages. Div, poem and inline-span
|
|
45
|
+
* anchors address locations within their containing pages.
|
|
41
46
|
*
|
|
42
47
|
* @param {string} body - The note's markdown body, frontmatter already removed.
|
|
43
48
|
* @param {number} [bodyLine] - The 1-based file line the body starts on, from
|
|
@@ -52,17 +57,15 @@ import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
|
|
|
52
57
|
*/
|
|
53
58
|
export function collectAnchors(body, bodyLine = 1, resolveRole) {
|
|
54
59
|
const anchors = [];
|
|
55
|
-
|
|
60
|
+
const literal = codeRegions(body, { spans: false, poetryAsProse: false });
|
|
61
|
+
let offset = 0;
|
|
56
62
|
const lines = String(body ?? "").split("\n");
|
|
57
63
|
|
|
58
64
|
for (let i = 0; i < lines.length; i++) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
inCodeBlock = !inCodeBlock;
|
|
65
|
+
const lineOffset = offset;
|
|
66
|
+
offset += lines[i].length + 1;
|
|
67
|
+
if (literal.some((region) => lineOffset >= region.start && lineOffset < region.end))
|
|
63
68
|
continue;
|
|
64
|
-
}
|
|
65
|
-
if (inCodeBlock) continue;
|
|
66
69
|
|
|
67
70
|
const heading = HEADING_LINE.exec(lines[i]);
|
|
68
71
|
if (!heading) continue;
|
|
@@ -79,10 +82,79 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
|
|
|
79
82
|
if (!figure.id) continue;
|
|
80
83
|
anchors.push({
|
|
81
84
|
slug: figure.id,
|
|
82
|
-
name: figure.label,
|
|
85
|
+
name: figure.label || figure.caption,
|
|
83
86
|
level: 1,
|
|
84
87
|
line: bodyLine + figure.line - 1,
|
|
85
88
|
});
|
|
86
89
|
}
|
|
90
|
+
const blockAnchors = (source, firstLine) => {
|
|
91
|
+
const sourceLines = source.split("\n");
|
|
92
|
+
for (const block of scanBlocks(source).blocks) {
|
|
93
|
+
if (block.id)
|
|
94
|
+
anchors.push({
|
|
95
|
+
slug: slugify(block.id),
|
|
96
|
+
name: block.title || block.id,
|
|
97
|
+
level: 0,
|
|
98
|
+
line: firstLine + block.start,
|
|
99
|
+
});
|
|
100
|
+
blockAnchors(
|
|
101
|
+
sourceLines.slice(block.start + 1, block.end).join("\n"),
|
|
102
|
+
firstLine + block.start + 1,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
blockAnchors(String(body ?? ""), bodyLine);
|
|
107
|
+
for (const alert of scanAlerts(body).blocks) {
|
|
108
|
+
if (alert.id)
|
|
109
|
+
anchors.push({
|
|
110
|
+
slug: slugify(alert.id),
|
|
111
|
+
name: alert.title,
|
|
112
|
+
level: 0,
|
|
113
|
+
line: bodyLine + alert.start,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
for (const poem of scanPoetry(body).blocks) {
|
|
117
|
+
if (poem.id)
|
|
118
|
+
anchors.push({
|
|
119
|
+
slug: slugify(poem.id),
|
|
120
|
+
name: poem.id,
|
|
121
|
+
level: 0,
|
|
122
|
+
line: bodyLine + poem.start,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
for (const span of scanSpans(body).spans) {
|
|
126
|
+
if (span.id)
|
|
127
|
+
anchors.push({
|
|
128
|
+
slug: slugify(span.id),
|
|
129
|
+
name: span.text,
|
|
130
|
+
level: 0,
|
|
131
|
+
line: bodyLine + span.line - 1,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
87
134
|
return anchors.sort((a, b) => a.line - b.line);
|
|
88
135
|
}
|
|
136
|
+
|
|
137
|
+
/** Collisions involving a div, verse or inline-span anchor.
|
|
138
|
+
* Heading and caption collisions are diagnosed by their existing scanners.
|
|
139
|
+
* @param {string} body - Authored Markdown.
|
|
140
|
+
* @returns {Array<{line: number, column: number, message: string}>} Located collisions.
|
|
141
|
+
*/
|
|
142
|
+
export function markupAnchorFindings(body) {
|
|
143
|
+
const groups = new Map();
|
|
144
|
+
for (const anchor of collectAnchors(body)) {
|
|
145
|
+
const slug = slugify(anchor.slug);
|
|
146
|
+
if (!groups.has(slug)) groups.set(slug, []);
|
|
147
|
+
groups.get(slug).push(anchor);
|
|
148
|
+
}
|
|
149
|
+
const errors = [];
|
|
150
|
+
for (const [slug, anchors] of groups) {
|
|
151
|
+
if (anchors.length < 2 || !anchors.some((anchor) => anchor.level === 0)) continue;
|
|
152
|
+
for (const anchor of anchors.slice(1))
|
|
153
|
+
errors.push({
|
|
154
|
+
line: anchor.line,
|
|
155
|
+
column: 1,
|
|
156
|
+
message: `duplicate markup anchor "${slug}"`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
package/engine/art-fields.mjs
CHANGED
|
@@ -247,7 +247,7 @@ export function resolveArtRecord(index, value, defaultType, accepts) {
|
|
|
247
247
|
* carries once an embed's address has been rewritten into the ordinary image
|
|
248
248
|
* every surface renders ({@link module:engine/content-embeds.resolveEmbeds}).
|
|
249
249
|
*
|
|
250
|
-
* This is the lookup a
|
|
250
|
+
* This is the lookup a captioned item's `map` counter reaches through —
|
|
251
251
|
* see {@link module:engine/content-figures.scanFigures}'s `resolveRole` —
|
|
252
252
|
* for a caller that reads a body after that rewrite: a Foundry journal, an
|
|
253
253
|
* item or actor's documentation, and the book.
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -61,7 +61,10 @@
|
|
|
61
61
|
* @module
|
|
62
62
|
*/
|
|
63
63
|
|
|
64
|
+
import { scanAlerts } from "./content-alerts.mjs";
|
|
64
65
|
import { authoredFrontmatter } from "./index-records.mjs";
|
|
66
|
+
import { markupAnchorFindings } from "./anchors.mjs";
|
|
67
|
+
import { scanSpans } from "./content-spans.mjs";
|
|
65
68
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
66
69
|
import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
|
|
67
70
|
import { scanFigures } from "./content-figures.mjs";
|
|
@@ -682,7 +685,12 @@ export class BasePackCompiler {
|
|
|
682
685
|
// it made — `errors` or `findings`, never just the first — so nothing
|
|
683
686
|
// here re-scans; it only stops taking `[0]`.
|
|
684
687
|
const findings = [];
|
|
685
|
-
for (const secretError of
|
|
688
|
+
for (const secretError of [
|
|
689
|
+
...scanBlocks(body).errors,
|
|
690
|
+
...scanAlerts(body).errors,
|
|
691
|
+
...scanSpans(body).errors,
|
|
692
|
+
...markupAnchorFindings(body),
|
|
693
|
+
]) {
|
|
686
694
|
findings.push({
|
|
687
695
|
message: secretError.message,
|
|
688
696
|
line: (this.currentNote?.bodyLine ?? 1) + secretError.line - 1,
|
package/engine/bundles.mjs
CHANGED
|
@@ -314,7 +314,7 @@ export class Bundles extends BasePackCompiler {
|
|
|
314
314
|
|
|
315
315
|
// The markdown this pass reads has already had its embeds rewritten
|
|
316
316
|
// into ordinary images, each `src` the pathname `pathnameRoles` keys
|
|
317
|
-
// its roles by — so a
|
|
317
|
+
// its roles by — so a captioned map names itself `Map 1` here
|
|
318
318
|
// exactly as the website and the book name the same picture.
|
|
319
319
|
const roles = pathnameRoles(this.linkIndex);
|
|
320
320
|
|
package/engine/code-fences.mjs
CHANGED
|
@@ -104,10 +104,11 @@ function column(space) {
|
|
|
104
104
|
* @param {object} [options]
|
|
105
105
|
* @param {boolean} [options.spans=true] - Include inline code spans. Set false
|
|
106
106
|
* to consider only block-level code.
|
|
107
|
+
* @param {boolean} [options.poetryAsProse=true] - Treat verse bodies as inline prose.
|
|
107
108
|
* @returns {Array<{start: number, end: number}>} Non-overlapping regions, in
|
|
108
109
|
* source order. `end` is exclusive.
|
|
109
110
|
*/
|
|
110
|
-
export function codeRegions(markdown, { spans = true } = {}) {
|
|
111
|
+
export function codeRegions(markdown, { spans = true, poetryAsProse = true } = {}) {
|
|
111
112
|
const src = String(markdown ?? "");
|
|
112
113
|
if (!src) return [];
|
|
113
114
|
const lines = src.split("\n");
|
|
@@ -144,7 +145,10 @@ export function codeRegions(markdown, { spans = true } = {}) {
|
|
|
144
145
|
let close = i + 1;
|
|
145
146
|
while (close < lines.length && !closer.test(lines[close])) close++;
|
|
146
147
|
// An unclosed fence runs to the end of the document.
|
|
147
|
-
|
|
148
|
+
if (poetryAsProse && /^poetry(?:\s|$)/.test(fence[3])) {
|
|
149
|
+
regions.push(through(i, i));
|
|
150
|
+
if (close < lines.length) regions.push(through(close, close));
|
|
151
|
+
} else regions.push(through(i, Math.min(close, lines.length - 1)));
|
|
148
152
|
i = close;
|
|
149
153
|
afterBlank = false;
|
|
150
154
|
continue;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
|
+
import MarkdownIt from "markdown-it";
|
|
3
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
4
|
+
import { slugify } from "./content-slug.mjs";
|
|
5
|
+
|
|
6
|
+
const icon = (body) =>
|
|
7
|
+
`<svg class="alert-icon" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor">${body}</svg>`;
|
|
8
|
+
/** Alert titles and static icons shared by all publishing surfaces. */
|
|
9
|
+
export const ALERT_TYPES = Object.freeze({
|
|
10
|
+
note: {
|
|
11
|
+
title: "Note",
|
|
12
|
+
icon: '<i class="alert-icon fa-regular fa-message" aria-hidden="true"></i>',
|
|
13
|
+
svg: icon(
|
|
14
|
+
'<path d="M2 1h12a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H6l-4 3V2a1 1 0 0 1 0-1Zm1.5 1.5V12l2-1.5h8V2.5Z"/>',
|
|
15
|
+
),
|
|
16
|
+
},
|
|
17
|
+
tip: {
|
|
18
|
+
title: "Tip",
|
|
19
|
+
icon: '<i class="alert-icon fa-regular fa-lightbulb" aria-hidden="true"></i>',
|
|
20
|
+
svg: icon(
|
|
21
|
+
'<path d="M8 0a5 5 0 0 0-3 9v2h6V9a5 5 0 0 0-3-9Zm0 1.5A3.5 3.5 0 0 1 10 8l-.5.4v1.1h-3V8.4L6 8a3.5 3.5 0 0 1 2-6.5ZM5 12h6v1.5H5zm1 2.5h4V16H6z"/>',
|
|
22
|
+
),
|
|
23
|
+
},
|
|
24
|
+
important: {
|
|
25
|
+
title: "Important",
|
|
26
|
+
icon: '<i class="alert-icon fa-solid fa-circle-info" aria-hidden="true"></i>',
|
|
27
|
+
svg: icon(
|
|
28
|
+
'<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 1.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Z"/><circle cx="8" cy="5" r="1"/><path d="M7.25 7h1.5v5h-1.5z"/>',
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
warning: {
|
|
32
|
+
title: "Warning",
|
|
33
|
+
icon: '<i class="alert-icon fa-solid fa-triangle-exclamation" aria-hidden="true"></i>',
|
|
34
|
+
svg: icon(
|
|
35
|
+
'<path d="M8 0 0 15h16L8 0Zm0 3 5.5 10.5h-11L8 3Z"/><path d="M7.25 6h1.5v4h-1.5z"/><circle cx="8" cy="12" r="1"/>',
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const parser = new MarkdownIt();
|
|
40
|
+
const titleParser = new MarkdownIt({ html: false });
|
|
41
|
+
const marker = /^ {0,3}>[ \t]?\[!([^\]\n]+)\](.*)$/;
|
|
42
|
+
/** Read alerts outside code examples, retaining inclusive source line ranges.
|
|
43
|
+
* @param {string} source - Authored Markdown.
|
|
44
|
+
*/
|
|
45
|
+
export function scanAlerts(source) {
|
|
46
|
+
const text = String(source ?? ""),
|
|
47
|
+
lines = text.split("\n"),
|
|
48
|
+
blocks = [],
|
|
49
|
+
errors = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
for (const token of parser.parse(text, {})) {
|
|
52
|
+
if (token.type === "blockquote_close") {
|
|
53
|
+
depth--;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (token.type !== "blockquote_open" || !token.map) continue;
|
|
57
|
+
depth++;
|
|
58
|
+
const [start, after] = token.map;
|
|
59
|
+
let first = lines[start] ?? "";
|
|
60
|
+
for (let level = 1; level < depth; level++) first = first.replace(/^ {0,3}>[ \t]?/, "");
|
|
61
|
+
const match = marker.exec(first);
|
|
62
|
+
if (!match) {
|
|
63
|
+
if (/^ {0,3}>[ \t]?\[!/.test(first))
|
|
64
|
+
errors.push({
|
|
65
|
+
line: start + 1,
|
|
66
|
+
column: lines[start].indexOf("[") + 1,
|
|
67
|
+
message: "alert marker needs [!TYPE] brackets",
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const at = { line: start + 1, column: lines[start].indexOf("[") + 1 };
|
|
72
|
+
const type = match[1].toLowerCase();
|
|
73
|
+
if (match[1] !== match[1].toUpperCase() || !Object.hasOwn(ALERT_TYPES, type)) {
|
|
74
|
+
errors.push({
|
|
75
|
+
...at,
|
|
76
|
+
message: `unknown alert type ${match[1]}; use NOTE, TIP, IMPORTANT or WARNING`,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const raw = match[2].trim();
|
|
81
|
+
let parsed = { id: "", classes: [], values: {}, problems: [] };
|
|
82
|
+
if (raw) {
|
|
83
|
+
if (!/^\{.*\}$/.test(raw))
|
|
84
|
+
parsed.problems.push(
|
|
85
|
+
"alert attributes need {…} braces immediately after the marker",
|
|
86
|
+
);
|
|
87
|
+
else parsed = parseExtensionAttributes(raw.slice(1, -1));
|
|
88
|
+
}
|
|
89
|
+
for (const message of [...parsed.problems, ...refusedAttributes(parsed.values)])
|
|
90
|
+
errors.push({ ...at, message });
|
|
91
|
+
const body = lines
|
|
92
|
+
.slice(start + 1, after)
|
|
93
|
+
.map((line) => {
|
|
94
|
+
for (let level = 0; level < depth; level++)
|
|
95
|
+
line = line.replace(/^ {0,3}>[ \t]?/, "");
|
|
96
|
+
return line;
|
|
97
|
+
})
|
|
98
|
+
.join("\n")
|
|
99
|
+
.trimEnd();
|
|
100
|
+
if (!body.trim())
|
|
101
|
+
errors.push({ ...at, message: `${ALERT_TYPES[type].title} alert needs a body` });
|
|
102
|
+
const attributes = Object.fromEntries(
|
|
103
|
+
Object.entries(parsed.values).filter(
|
|
104
|
+
([key, value]) => !refusedAttributes({ [key]: value }).length,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
const title = attributes.title ?? ALERT_TYPES[type].title;
|
|
108
|
+
delete attributes.title;
|
|
109
|
+
blocks.push({
|
|
110
|
+
start,
|
|
111
|
+
end: after - 1,
|
|
112
|
+
depth,
|
|
113
|
+
type,
|
|
114
|
+
title,
|
|
115
|
+
id: parsed.id,
|
|
116
|
+
classes: parsed.classes,
|
|
117
|
+
attributes,
|
|
118
|
+
body,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { blocks, errors };
|
|
122
|
+
}
|
|
123
|
+
/** Render alert wrappers while preserving ordinary Markdown and note footnotes.
|
|
124
|
+
* @param {string} source - Authored Markdown.
|
|
125
|
+
* @param {"web"|"foundry"} [target="web"] - Publishing surface.
|
|
126
|
+
*/
|
|
127
|
+
export function renderAlerts(source, target = "web") {
|
|
128
|
+
const { blocks, errors } = scanAlerts(source),
|
|
129
|
+
lines = String(source ?? "").split("\n"),
|
|
130
|
+
output = [];
|
|
131
|
+
let cursor = 0;
|
|
132
|
+
const escape = (value) =>
|
|
133
|
+
String(value)
|
|
134
|
+
.replace(/&/g, "&")
|
|
135
|
+
.replace(/"/g, """)
|
|
136
|
+
.replace(/</g, "<")
|
|
137
|
+
.replace(/>/g, ">");
|
|
138
|
+
for (const block of blocks.filter(
|
|
139
|
+
(block) => !blocks.some((parent) => parent.start < block.start && parent.end >= block.end),
|
|
140
|
+
)) {
|
|
141
|
+
output.push(...lines.slice(cursor, block.start));
|
|
142
|
+
const attrs = [
|
|
143
|
+
`class="${escape(["alert", "alert-" + block.type, ...block.classes].join(" "))}"`,
|
|
144
|
+
];
|
|
145
|
+
if (block.id) attrs.push(`id="${escape(slugify(block.id))}"`);
|
|
146
|
+
for (const [key, value] of Object.entries(block.attributes))
|
|
147
|
+
attrs.push(`${key}="${escape(value)}"`);
|
|
148
|
+
const definition = ALERT_TYPES[block.type];
|
|
149
|
+
output.push(
|
|
150
|
+
"",
|
|
151
|
+
`<aside ${attrs.join(" ")}>`,
|
|
152
|
+
`<header class="alert-title">${definition.icon} ${titleParser.renderInline(block.title)}</header>`,
|
|
153
|
+
"",
|
|
154
|
+
renderAlerts(block.body, target).markdown,
|
|
155
|
+
"",
|
|
156
|
+
"</aside>",
|
|
157
|
+
"",
|
|
158
|
+
);
|
|
159
|
+
cursor = block.end + 1;
|
|
160
|
+
}
|
|
161
|
+
output.push(...lines.slice(cursor));
|
|
162
|
+
return { markdown: output.join("\n"), errors };
|
|
163
|
+
}
|
|
164
|
+
/** Mark alert blockquotes and remove their marker before inline parsing.
|
|
165
|
+
* @param {import("markdown-it")} md - Markdown parser to extend.
|
|
166
|
+
*/
|
|
167
|
+
export function alertMarkdownPlugin(md) {
|
|
168
|
+
md.core.ruler.before("inline", "alert_markers", (state) => {
|
|
169
|
+
const alerts = scanAlerts(state.src).blocks;
|
|
170
|
+
const depths = new Map();
|
|
171
|
+
let depth = 0;
|
|
172
|
+
for (let i = 0; i < state.tokens.length; i++) {
|
|
173
|
+
if (state.tokens[i].type === "blockquote_open") depths.set(i, ++depth);
|
|
174
|
+
if (state.tokens[i].type === "blockquote_close") depth--;
|
|
175
|
+
}
|
|
176
|
+
for (let i = state.tokens.length - 1; i >= 0; i--) {
|
|
177
|
+
const token = state.tokens[i];
|
|
178
|
+
if (token.type !== "blockquote_open") continue;
|
|
179
|
+
const block = alerts.find(
|
|
180
|
+
(block) => token.map?.[0] === block.start && depths.get(i) === block.depth,
|
|
181
|
+
);
|
|
182
|
+
if (!block) continue;
|
|
183
|
+
token.meta = { ...token.meta, alert: block };
|
|
184
|
+
const inline = state.tokens[i + 2];
|
|
185
|
+
if (state.tokens[i + 1]?.type !== "paragraph_open" || inline?.type !== "inline")
|
|
186
|
+
continue;
|
|
187
|
+
const newline = inline.content.indexOf("\n");
|
|
188
|
+
if (newline < 0) state.tokens.splice(i + 1, 3);
|
|
189
|
+
else {
|
|
190
|
+
inline.content = inline.content.slice(newline + 1);
|
|
191
|
+
if (inline.map) inline.map[0]++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|