@heroiclands/package-build 22.26.0 → 22.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -1
  3. package/assets/styles/content-base.css +46 -3
  4. package/bin/content-commands.mjs +53 -5
  5. package/ci/publish-npm.mjs +49 -0
  6. package/docs/api.md +44 -24
  7. package/docs/authoring/first-note.md +1 -1
  8. package/docs/authoring/frontmatter.md +2 -2
  9. package/docs/authoring/links-and-markup.md +96 -149
  10. package/docs/content-format.md +7 -3
  11. package/docs/guides/government-migration.md +52 -0
  12. package/docs/guides/site.md +5 -5
  13. package/docs/reference/format-details.md +131 -230
  14. package/docs/reference/note-types.md +27 -29
  15. package/docs/reference/theme-pages.md +31 -29
  16. package/engine/anchors.mjs +85 -13
  17. package/engine/art-fields.mjs +1 -1
  18. package/engine/base-compiler.mjs +9 -1
  19. package/engine/bundles.mjs +1 -1
  20. package/engine/code-fences.mjs +6 -2
  21. package/engine/content-alerts.mjs +195 -0
  22. package/engine/content-blocks.mjs +119 -241
  23. package/engine/content-embeds.mjs +1 -1
  24. package/engine/content-figures.mjs +166 -230
  25. package/engine/content-format-check.mjs +3 -2
  26. package/engine/content-format.yaml +0 -5
  27. package/engine/content-images.mjs +3 -3
  28. package/engine/content-poetry.mjs +141 -0
  29. package/engine/content-spans.mjs +177 -0
  30. package/engine/folder-notes.mjs +2 -3
  31. package/engine/foundry-entries.mjs +2 -0
  32. package/engine/frontmatter-lint.mjs +2 -1
  33. package/engine/heading-attributes.mjs +1 -1
  34. package/engine/helpers.mjs +8 -3
  35. package/engine/holdings.mjs +165 -273
  36. package/engine/index.mjs +2 -2
  37. package/engine/infobox.mjs +1 -1
  38. package/engine/journals.mjs +8 -6
  39. package/engine/markdown-expressions.mjs +9 -3
  40. package/engine/metadata-index.mjs +2 -3
  41. package/engine/note-vocabulary.mjs +19 -15
  42. package/engine/pdf-build.mjs +1 -1
  43. package/engine/pdf-render.mjs +90 -31
  44. package/engine/populations.mjs +17 -83
  45. package/engine/site-build.mjs +44 -16
  46. package/engine/site-preview.mjs +22 -6
  47. package/engine/stub-lint.mjs +20 -64
  48. package/engine/wikilinks.mjs +18 -4
  49. package/githooks/pre-push +1 -1
  50. package/hugo-theme/layouts/partials/holdings/entry.html +2 -2
  51. package/hugo-theme/layouts/partials/holdings.html +14 -14
  52. package/hugo-theme/static/css/style.css +40 -2
  53. package/package.json +3 -1
  54. package/sohl/item-fields.mjs +0 -16
  55. package/types/engine/anchors.d.mts +13 -4
  56. package/types/engine/art-fields.d.mts +1 -1
  57. package/types/engine/code-fences.d.mts +3 -1
  58. package/types/engine/content-alerts.d.mts +62 -0
  59. package/types/engine/content-blocks.d.mts +3 -10
  60. package/types/engine/content-embeds.d.mts +1 -1
  61. package/types/engine/content-figures.d.mts +13 -20
  62. package/types/engine/content-images.d.mts +1 -1
  63. package/types/engine/content-poetry.d.mts +32 -0
  64. package/types/engine/content-spans.d.mts +18 -0
  65. package/types/engine/holdings.d.mts +59 -105
  66. package/types/engine/markdown-expressions.d.mts +1 -1
  67. package/types/engine/note-vocabulary.d.mts +12 -4
  68. package/types/engine/populations.d.mts +4 -13
  69. package/types/engine/site-build.d.mts +3 -3
  70. 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 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`. |
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 holdings and related cards, the place
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`, `held_by`, `holdings`, `map`, `package`, `slug`, `url`
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 holdings card
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
- held_by: [{ title: …, url: …, type: affiliation, subType: … }] # affiliations holding this place
180
- holdings: [{ title: …, url: …, type: place, subType: … }] # places this affiliation holds
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 `held_by`; an affiliation page carries
185
- `holdings`. Each key is tested for independently — a page carrying none of the
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 **Holdings** (`holdings`) list places, and each is
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. **Held by** (`held_by`)
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" / "Held by" / "Holdings" |
209
- | `.holdings-table-grid` | the grid of entries (Held by; the innermost grid of Within/Holdings) |
210
- | `.holdings-kind-group` | one kind's block within Within or Holdings |
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 Held by entry |
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`, `held_by` or
216
- `holdings` renders no card at all.
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 `:::figure` fence wraps a picture, a table, a listing, a map or a passage of
295
- prose in a numbered, referable block. The content build decides which from
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 `prose`, labelled **Code**,
307
- **Table**, **Figure**, **Map**, **Poem** and **Prose**. Every kind is drawn alike — the
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 fence carrying an `id`
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` is the one class an author may write on a fence, and it draws the
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.
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The anchors a note declares on headings and figure fences.
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 `{#slug}` and `:::figure {#slug}` anchors a note declares.
42
+ * The identifiers a note declares on headings, captions, divs, poems and spans.
37
43
  *
38
- * A bare `#` heading starts a journal page without declaring a slug, and so
39
- * does a figure fence that declares no id. A fence that declares one declares a
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
- let inCodeBlock = false;
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
- // A fenced block's contents are not headings, and `#` is a comment in
60
- // most of what gets fenced.
61
- if (lines[i].trim().startsWith("```")) {
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
+ }
@@ -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 `:::figure` fence's `map` counter reaches through —
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.
@@ -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 scanBlocks(body).errors) {
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,
@@ -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 `:::figure` of a map names itself `Map 1` here
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
 
@@ -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
- regions.push(through(i, Math.min(close, lines.length - 1)));
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, "&amp;")
135
+ .replace(/"/g, "&quot;")
136
+ .replace(/</g, "&lt;")
137
+ .replace(/>/g, "&gt;");
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
+ }