@heroiclands/package-build 25.3.0 → 25.4.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 (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +32 -0
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +136 -53
  6. package/docs/commands.md +2 -2
  7. package/docs/content-format.md +14 -6
  8. package/docs/diagnostics.md +12 -11
  9. package/docs/guides/writing-in-invented-scripts.md +62 -35
  10. package/docs/reference/format-details.md +6 -6
  11. package/docs/reference/note-types.md +15 -15
  12. package/docs/reference/theme-pages.md +26 -18
  13. package/engine/anchors.mjs +14 -0
  14. package/engine/code-fences.mjs +15 -6
  15. package/engine/content-blocks.mjs +4 -2
  16. package/engine/content-figures.mjs +19 -5
  17. package/engine/content-footnotes.mjs +60 -1
  18. package/engine/content-index.mjs +1 -1
  19. package/engine/content-render.mjs +59 -43
  20. package/engine/content-scripts.mjs +204 -42
  21. package/engine/content-spans.mjs +73 -5
  22. package/engine/content-verse.mjs +320 -192
  23. package/engine/first-use.mjs +43 -12
  24. package/engine/heading-attributes.mjs +2 -1
  25. package/engine/journals.mjs +8 -1
  26. package/engine/markdown-blocks.mjs +3 -2
  27. package/engine/markup-validation.mjs +17 -0
  28. package/engine/name-markup.mjs +1 -0
  29. package/engine/note-vocabulary.mjs +2 -2
  30. package/engine/pdf-render.mjs +34 -23
  31. package/engine/prose-lint.mjs +14 -2
  32. package/engine/run-rows.mjs +1 -1
  33. package/engine/sql-tables.mjs +1 -1
  34. package/engine/verse-format.mjs +77 -0
  35. package/engine/verse-metre.mjs +18 -9
  36. package/engine/verse-regions.mjs +181 -0
  37. package/engine/wikilink-pattern.mjs +31 -0
  38. package/engine/wikilink-syntax.mjs +2 -16
  39. package/package.json +1 -1
  40. package/site-theme/static/css/style.css +24 -0
  41. package/types/engine/code-fences.d.mts +1 -3
  42. package/types/engine/content-footnotes.d.mts +23 -0
  43. package/types/engine/content-scripts.d.mts +4 -3
  44. package/types/engine/content-spans.d.mts +3 -1
  45. package/types/engine/content-verse.d.mts +69 -74
  46. package/types/engine/name-markup.d.mts +5 -0
  47. package/types/engine/verse-format.d.mts +9 -0
  48. package/types/engine/verse-metre.d.mts +4 -4
  49. package/types/engine/verse-regions.d.mts +78 -0
  50. package/types/engine/wikilink-pattern.d.mts +17 -0
  51. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -72,9 +72,15 @@ keys makes a separate presentation choice. `enc=default` inside an encoded
72
72
  parent keeps the inherited language but does not inherit its inferred script
73
73
  face. An explicitly authored font remains a font choice.
74
74
 
75
+ Footnotes are not letters of the tongue. A defined `[^key]` in any encoded span
76
+ is removed before encoding and restored after the span, outside the script font.
77
+ In an encoded verse div, a reference appears only in the Roman column, beside
78
+ its authored word, with no duplicate number, definition or backlink. Footnotes
79
+ never change verse shape. See [footnotes in works and encoded spans](../authoring/links-and-markup.md#footnotes-in-works-and-encoded-spans).
80
+
75
81
  A name's braces use the shared name controls. Resolved references encode only
76
82
  their visible labels, retaining URLs and presentation cues. These keys apply
77
- to spans, divs, verse fences and scroll fences. A scroll's selected font must
83
+ to spans, divs and scroll fences. A scroll's selected font must
78
84
  belong to a script whose note declares `direction: vertical-rl`.
79
85
 
80
86
  ## Roman source and literal characters
@@ -106,8 +112,8 @@ escapes are removed before encoding, and diagnostics retain the source position.
106
112
 
107
113
  Every `Zs` space separator and U+0009 through U+000D count as whitespace. A run
108
114
  of them is one word separator, which the encoding writes by its own rule.
109
- Newlines join as whitespace. In a verse fence, `/` ends a logical line,
110
- blank lines or `//` end stanzas, and standalone `***` ends a canto. A literal
115
+ Newlines join as whitespace. In a verse div, `/` ends a logical line,
116
+ blank lines or `//` end stanzas, and `--- {.canto}` after a blank line ends a canto. A literal
111
117
  slash is written `\/`. Indentation at a logical line boundary is layout,
112
118
  not a punctuation character added to the encoded words.
113
119
 
@@ -485,52 +491,73 @@ The keys are checked where they sit:
485
491
 
486
492
  ## Verse
487
493
 
488
- ### A poem in a verse fence
494
+ ### A poem in a verse div
489
495
 
490
- A poem is written in a `verse` fence, whose lines and stanzas the build
491
- preserves. The keys of the previous sections go on the fence. Prettier leaves
492
- the contents of a fence byte for byte, which is why a poem is a fence and not
493
- paragraphs: indentation, line breaks and spans survive formatting untouched.
496
+ A `.verse` div holds inline Markdown and explicit verse structure. It is opaque
497
+ to lists, headings, quotes and code-block syntax. Its source newlines join unless
498
+ `/` ends a logical line. The authoring formatter preserves the complete div and
499
+ its attributes byte for byte, and also preserves inline `.verse` quotations.
494
500
 
495
- ````markdown
496
- ```verse {lang=vyl font=semaron-kuriale enc=vylsemaronenc}
501
+ ```markdown
502
+ ::: {.verse lang=vyl font=semaron-kuriale enc=vylsemaronenc}
497
503
  Lokazor lonak./
498
504
  .lokamen zaron.
505
+ :::
499
506
  ```
500
- ````
507
+
508
+ Quoted verse is a span within prose, such as
509
+ `[Lokazor lonak zaron./..Lokamen lonak zaron.//]{.verse lang=vyl}`.
510
+ A concrete `enc=` sets a quotation in script only, continuously in the prose.
511
+ Its `/` and `//` become the encoding's verse marks; only a div has paired script
512
+ and Roman columns. An unencoded quotation keeps Roman line and stanza layout.
513
+ Quotations take no `work=`, `form=`, `scan=`, labels or canto breaks.
514
+ A div's canto separator can carry a heading and an anchor:
515
+
516
+ ```markdown
517
+ ::: {.verse lang=en}
518
+ The watch arrives.
519
+
520
+ --- {.canto #choosing title="The Choosing"}
521
+ The captain answers.
522
+ :::
523
+ ```
524
+
525
+ The title is shown once as heading text; the encoder receives its Roman `***`
526
+ canto control. Authored `***` inside verse is a finding. The canto stays within
527
+ the work and does not open a separate journal page.
501
528
 
502
529
  ### Versions of a work
503
530
 
504
531
  `form=epic` records the author's literary form without inferring layout or metre.
505
532
 
506
- A `verse` fence with a concrete `enc=` sets one original in its script on the
533
+ A `.verse` div with a concrete `enc=` sets one original in its script on the
507
534
  left and Roman on the right. The author writes the text once; the two
508
535
  presentations align logical lines at full weight. `/` ends a logical line,
509
536
  source newlines otherwise join, a blank line or `//` ends a stanza, and a line
510
- of only `***` ends a canto. Leading periods at a logical line boundary indent
537
+ of `--- {.canto}`, preceded by a blank line, ends a canto. Leading periods at a logical line boundary indent
511
538
  that line. Use `\/` for a literal slash.
512
539
 
513
- ````markdown
514
- ```verse {work=walking lang=en}
540
+ ```markdown
541
+ ::: {.verse work=walking lang=en}
515
542
  He walks the oath road.
516
543
 
517
544
  I walk the oath road.
518
- ```
545
+ :::
519
546
 
520
- ```verse {work=walking lang=vyl enc=vylsemaronenc}
547
+ ::: {.verse work=walking lang=vyl enc=vylsemaronenc}
521
548
  Lokazor lonak zaron.
522
549
 
523
550
  Lokamen lonak zaron.
551
+ :::
524
552
  ```
525
- ````
526
553
 
527
554
  `work=` groups the versions within the page. Their stanza labels match, and the
528
555
  build checks that their canto counts and stanza counts per canto agree. The
529
- English fence is one column, and `enc=default` also stays one Roman column.
556
+ English div is one column, and `enc=default` also stays one Roman column.
530
557
  The original's encoded and Roman presentations share one label per stanza.
531
558
  Names, `⟦…⟧` codage, language gaps and other inline spans retain their meaning;
532
559
  codage draws its glyphs on the left and appears as codage on the right. The
533
- content index records the original once with its fence language and encoding.
560
+ content index records the original once with its div language and encoding.
534
561
  See [versions and stanza labels](../authoring/links-and-markup.md#versions-and-stanza-labels)
535
562
  for poetry and prose examples, and [directions and speakers](../authoring/links-and-markup.md#directions-and-speakers)
536
563
  for plain semantic span hooks.
@@ -549,15 +576,15 @@ data:
549
576
  `name` is lowercase letters, digits and `-`, unique in the package; `beats` is
550
577
  the beats of one line, a whole number above zero; `kind` is `accentual`, the
551
578
  only kind: a beat falls on a stressed syllable and the length of a syllable is
552
- not counted. A fence that states `scan=` with the declared name and a `lang=`
579
+ not counted. A div that states `scan=` with the declared name and a `lang=`
553
580
  whose language note states `data.sounds.stress` is counted line by line:
554
581
 
555
- ````markdown
556
- ```verse {lang=vyl scan=trimeter}
582
+ ```markdown
583
+ ::: {.verse lang=vyl scan=trimeter}
557
584
  Lokazor lonak zaron./
558
585
  Lokamen lonak zaron.
586
+ :::
559
587
  ```
560
- ````
561
588
 
562
589
  **The stress rule** is `data.sounds.stress`, `{ two, longer, mark }`. `two`
563
590
  (`first` or `last`) places the stress in a word of two syllables; `longer`
@@ -587,14 +614,14 @@ the second beats and, in brackets, with all of them:
587
614
 
588
615
  > ``this line has 4 beats (5 with secondary beats); the metre `hexameter` has 6``
589
616
 
590
- **The check is author-side.** Readers see nothing of it: the rendered fence
591
- carries `data-metre` with the metre's name and nothing a reader sees. A fence
617
+ **The check is author-side.** Readers see nothing of it: the rendered div
618
+ carries `data-metre` with the metre's name and nothing a reader sees. A div
592
619
  without `scan=` is not counted. `scan=` is refused where it cannot mean
593
620
  anything, each an error:
594
621
 
595
622
  > `` `hexamter` is not a declared metre: `scan=` names the `name` of a `data.metres` entry of a `doc` note; did you mean `hexameter`? ``
596
623
 
597
- > `` `scan=` counts beats by a language's stress rule: the fence needs a `lang=` ``
624
+ > `` `scan=` counts beats by a language's stress rule: the div needs a `lang=` ``
598
625
 
599
626
  > ``the language `vyl` states no `data.sounds.stress`, which `scan=` counts beats by``
600
627
 
@@ -1144,11 +1171,11 @@ capital `L` of the rows, `zaron` is the exception's long vowel, and each period
1144
1171
  writes the script's pause point.
1145
1172
 
1146
1173
  A work's English and original versions sit on one note. Its original is written
1147
- once and appears beside its Roman presentation; a separate fence checks metre.
1174
+ once and appears beside its Roman presentation; a separate div checks metre.
1148
1175
 
1149
1176
  **`assets/content/Lore/Walking.md`**
1150
1177
 
1151
- ````markdown
1178
+ ```markdown
1152
1179
  ---
1153
1180
  shortcode: walking
1154
1181
  name: { full: The Walking }
@@ -1159,23 +1186,23 @@ The walking of the oath road is sung in two settings. The first sets the poem
1159
1186
  beside its translation, and the second keeps the metre that the court keeps, so
1160
1187
  that a line with the wrong count is reported to the author who wrote it.
1161
1188
 
1162
- ```verse {work=walking lang=en}
1189
+ ::: {.verse work=walking lang=en}
1163
1190
  He walks the oath road.
1164
1191
 
1165
1192
  I walk the oath road.
1166
- ```
1193
+ :::
1167
1194
 
1168
- ```verse {work=walking lang=vyl enc=vylsemaronenc}
1195
+ ::: {.verse work=walking lang=vyl enc=vylsemaronenc}
1169
1196
  Lokazor lonak zaron.
1170
1197
 
1171
1198
  Lokamen lonak zaron.
1172
- ```
1199
+ :::
1173
1200
 
1174
- ```verse {lang=vyl scan=trimeter}
1201
+ ::: {.verse lang=vyl scan=trimeter}
1175
1202
  Lokazor lonak zaron./
1176
1203
  Lokamen lonak zaron.
1204
+ :::
1177
1205
  ```
1178
- ````
1179
1206
 
1180
1207
  A script note gives Semaron its emphasis convention, and a run with double stars then
1181
1208
  sets them in red:
@@ -2107,7 +2107,7 @@ face. An explicitly authored font remains a font choice.
2107
2107
 
2108
2108
  A name's braces use the shared name controls. Resolved references encode only
2109
2109
  their visible labels, retaining URLs and presentation cues. These keys apply
2110
- to spans, divs, verse fences and scroll fences. A scroll's selected font must
2110
+ to spans, divs and scroll fences. A scroll's selected font must
2111
2111
  belong to a script whose note declares `direction: vertical-rl`.
2112
2112
 
2113
2113
  #### Encodings
@@ -2273,7 +2273,7 @@ keeps a lexicon to its exceptions.
2273
2273
 
2274
2274
  #### Metres
2275
2275
 
2276
- A verse fence that states `scan=` is counted against a metre declared in a
2276
+ A verse div that states `scan=` is counted against a metre declared in a
2277
2277
  `doc` note's `data.metres`, each entry `{ name, beats, kind }`. `name` is
2278
2278
  lowercase letters, digits and `-`, unique in the package, and a name declared
2279
2279
  twice is an error at the second. `beats` is a whole number above zero. `kind` is
@@ -3592,7 +3592,7 @@ metadata supplies `names` in that package's SQL schema. Public queries exclude
3592
3592
  GM-only owners; all/native contexts retain them. Only authored occurrences
3593
3593
  contribute rows, so generated queries and sections add no further names.
3594
3594
 
3595
- **`runs`** is one row per span, generic div and verse fence an authored note
3595
+ **`runs`** is one row per span, generic div and verse div an authored note
3596
3596
  body marks with `lang=`, `font=` or `enc=`. Each row has these columns,
3597
3597
  including on an empty relation:
3598
3598
 
@@ -4033,7 +4033,7 @@ The caption attaches to exactly the next supported block:
4033
4033
  | Markdown table | `table` | Table |
4034
4034
  | Image, Markdown or wikilink | `figure` | Figure |
4035
4035
  | SQL fence, including backtick SQL | `table` | Table |
4036
- | Verse fence | `verse` | Poem |
4036
+ | Verse div | `verse` | Poem |
4037
4037
  | Any other code fence | `code` | Code |
4038
4038
  | Paragraph, quote block or fenced div | `prose` | Prose |
4039
4039
 
@@ -6855,7 +6855,7 @@ closed list where its values are words:
6855
6855
  end, `antepenultimate`, `penultimate` or `final`; `mark` is the diacritic, `acute`,
6856
6856
  `grave` or `circumflex`, that marks a stress the rule would not choose and
6857
6857
  moves it to the syllable it sits on. Syllables are vowel groups, a pair listed
6858
- in `syllables.vowelPairs` counting as one. A verse fence's `scan=` counts
6858
+ in `syllables.vowelPairs` counting as one. A verse div's `scan=` counts
6859
6859
  beats by this rule; see [metres](#metres).
6860
6860
  - `finals`: the syllable finals, each with what it sounds like and, as `ipa`,
6861
6861
  its sound in the International Phonetic Alphabet, for a language whose
@@ -8274,7 +8274,7 @@ Content prepared to be played — a situation with its cast, places, and possibl
8274
8274
  | `concepts[].id` | string | Required: lowercase letters, digits and `-`, unique in the package |
8275
8275
  | `concepts[].gloss` | string | Required: what the concept means, in brief |
8276
8276
  | `concepts[].group` | string | Required: the group the report lists the concept under |
8277
- | `metres` | `{ name, beats, kind }[]` | The metres a verse fence's `scan=` names; any subType. See [metres](#metres) |
8277
+ | `metres` | `{ name, beats, kind }[]` | The metres a verse div's `scan=` names; any subType. See [metres](#metres) |
8278
8278
  | `metres[].name` | string | Required: what `scan=` names it by, lowercase letters, digits and `-`, unique in the package |
8279
8279
  | `metres[].beats` | `integer`, required | The beats of one line, above zero |
8280
8280
  | `metres[].kind` | `accentual` | Required: how the beats are counted |
@@ -997,20 +997,20 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
997
997
 
998
998
  **Subtypes:** `rules`, `userguide`, `reference`, `howto`, `concept`, `settingguide`, `post`.
999
999
 
1000
- | Field | Shape | Meaning |
1001
- | --------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1002
- | `data.language` | a configured language alias | The configured language alias an encoding document writes. |
1003
- | `data.font` | an installed font slug | The encoding's installed font, which supplies all glyph codes. |
1004
- | `data.accepts` | a list of characters or ranges | Accepted Roman letters and additions to standard punctuation. |
1005
- | `data.vowels` | a string | The letters a contextual vowel match reads as vowels. |
1006
- | `data.mappings` | Roman strings to glyph codes | Roman source strings mapped to codes published by the selected font. |
1007
- | `data.rules` | the closed encoding rules vocabulary | Executable normalization, matching, case, stress, vowel groups, accents and contextual mappings. |
1008
- | `data.concepts` | list of `{ id, gloss, group }` | The concepts of a core vocabulary, in the order the report lists them. See [the core vocabulary](format-details.md#the-core-vocabulary). |
1009
- | `data.date` | string | The day a post was published, as `YYYY-MM-DD`. Required on a `post`; no other subType states it. Posts are listed and fed newest first by it. |
1010
- | `data.author` | string | Who wrote a post, as the byline prints them. A post that names none carries the site's byline, the first entry of `authors`; no other subType states it. |
1011
- | `data.culture` | an Address | The culture a setting guide introduces, as a culture lore note. Required on `settingguide`; no other subType states it. |
1012
- | `data.sounds` | a map — `{ letters?, marks? }` | A spelling guide's letters and the marks written on them, for every tongue of the setting: each letter row's `tongue` names the language notes that write it. |
1013
- | `data.metres` | list of `{ name, beats, kind }` | The metres a verse fence's `scan=` names. A fence that states one is warned at each line that cannot have its beats. See [metres](../authoring/links-and-markup.md#metre). |
1000
+ | Field | Shape | Meaning |
1001
+ | --------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1002
+ | `data.language` | a configured language alias | The configured language alias an encoding document writes. |
1003
+ | `data.font` | an installed font slug | The encoding's installed font, which supplies all glyph codes. |
1004
+ | `data.accepts` | a list of characters or ranges | Accepted Roman letters and additions to standard punctuation. |
1005
+ | `data.vowels` | a string | The letters a contextual vowel match reads as vowels. |
1006
+ | `data.mappings` | Roman strings to glyph codes | Roman source strings mapped to codes published by the selected font. |
1007
+ | `data.rules` | the closed encoding rules vocabulary | Executable normalization, matching, case, stress, vowel groups, accents and contextual mappings. |
1008
+ | `data.concepts` | list of `{ id, gloss, group }` | The concepts of a core vocabulary, in the order the report lists them. See [the core vocabulary](format-details.md#the-core-vocabulary). |
1009
+ | `data.date` | string | The day a post was published, as `YYYY-MM-DD`. Required on a `post`; no other subType states it. Posts are listed and fed newest first by it. |
1010
+ | `data.author` | string | Who wrote a post, as the byline prints them. A post that names none carries the site's byline, the first entry of `authors`; no other subType states it. |
1011
+ | `data.culture` | an Address | The culture a setting guide introduces, as a culture lore note. Required on `settingguide`; no other subType states it. |
1012
+ | `data.sounds` | a map — `{ letters?, marks? }` | A spelling guide's letters and the marks written on them, for every tongue of the setting: each letter row's `tongue` names the language notes that write it. |
1013
+ | `data.metres` | list of `{ name, beats, kind }` | The metres a verse div's `scan=` names. A div that states one is warned at each line that cannot have its beats. See [metres](../authoring/links-and-markup.md#metre). |
1014
1014
 
1015
1015
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
1016
1016
 
@@ -1065,7 +1065,7 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
1065
1065
  | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1066
1066
  | `data.sounds.marks[].kind` | one of stress, length, apart | yes | What the mark says of its letter. |
1067
1067
  | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1068
- | `data.metres[].name` | string | yes | What a verse fence's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1068
+ | `data.metres[].name` | string | yes | What a verse div's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1069
1069
  | `data.metres[].beats` | a whole number above zero | yes | The beats of one line. |
1070
1070
  | `data.metres[].kind` | one of accentual | yes | How the beats are counted: `accentual`, one beat for each stressed syllable the language's stress rule and the beat convention give. |
1071
1071
 
@@ -333,32 +333,40 @@ Caption classes are unrestricted. The built-in `border` class draws the block as
333
333
 
334
334
  ## Verse
335
335
 
336
- A `verse` fence reaches the page as `.verse`, with one `.verse-stanza` per
336
+ A `.verse` div reaches the page as `.verse`, with one `.verse-stanza` per
337
337
  stanza and `.verse-line` per logical line. Indentation uses `i1` through `i8`;
338
338
  deeper levels carry their complete `--verse-indent`. `.verse-canto-break`
339
- separates cantos. A work-labelled fence adds `.verse-labelled`, and each stanza
339
+ separates cantos. A work-labelled div adds `.verse-labelled`, and each stanza
340
340
  opens with a small `.verse-label` shared across its presentations.
341
341
 
342
- A concrete fence encoding adds `.verse-paired`. Each `.verse-row` aligns one
342
+ A concrete div encoding adds `.verse-paired`. Each `.verse-row` aligns one
343
343
  `.verse-native` cell with one `.verse-roman` cell at full weight. The script is
344
344
  on the left; its direction applies within its cell. A narrow container stacks
345
- the native presentation before the Roman presentation. An unencoded fence or
345
+ the native presentation before the Roman presentation. An unencoded div or
346
346
  `enc=default` is one Roman column. See [versions of a work](../authoring/links-and-markup.md#versions-and-stanza-labels).
347
347
 
348
- | Class | Meaning |
349
- | -------------------- | ---------------------------------------------- |
350
- | `.verse` | A version of a work |
351
- | `.verse-stanza` | One stanza or prose paragraph |
352
- | `.verse-line` | One logical line |
353
- | `.verse-labelled` | A version carrying automatic stanza labels |
354
- | `.verse-label` | The stanza number or canto:stanza label |
355
- | `.verse-paired` | Script and Roman presentations of one original |
356
- | `.verse-row` | Matching logical lines in both presentations |
357
- | `.verse-native` | Encoded text on the left |
358
- | `.verse-roman` | Roman text on the right |
359
- | `.verse-canto-break` | A canto boundary |
360
- | `.direction` | An instruction, inside or outside verse |
361
- | `.speaker` | A speaker label, inside or outside verse |
348
+ An unencoded quotation uses `.verse-inline` with inline-safe layout wrappers.
349
+ An encoded quotation is a script-only span flowing within prose, with no verse
350
+ grid or Roman companion. Its verse signs come from the encoding. Footnote
351
+ references follow encoded spans outside their script font. In a paired div,
352
+ references occur only in `.verse-roman`, beside their authored words.
353
+
354
+ | Class | Meaning |
355
+ | ---------------------- | ------------------------------------------------- |
356
+ | `.verse` | A block or unencoded quoted verse |
357
+ | `.verse-inline` | A quotation with inline-safe span wrappers |
358
+ | `.verse-canto-heading` | A canto title and optional anchor within the work |
359
+ | `.verse-stanza` | One stanza or prose paragraph |
360
+ | `.verse-line` | One logical line |
361
+ | `.verse-labelled` | A version carrying automatic stanza labels |
362
+ | `.verse-label` | The stanza number or canto:stanza label |
363
+ | `.verse-paired` | Script and Roman presentations of one original |
364
+ | `.verse-row` | Matching logical lines in both presentations |
365
+ | `.verse-native` | Encoded text on the left |
366
+ | `.verse-roman` | Roman text on the right |
367
+ | `.verse-canto-break` | A canto boundary |
368
+ | `.direction` | An instruction, inside or outside verse |
369
+ | `.speaker` | A speaker label, inside or outside verse |
362
370
 
363
371
  Direction and speaker classes have plain defaults. The book preserves the same
364
372
  classes through `book-span`, with plain `book-direction` and `book-speaker`
@@ -124,6 +124,20 @@ export function collectAnchors(body, bodyLine = 1, resolveRole, syntax) {
124
124
  });
125
125
  }
126
126
  for (const poem of scanVerse(body).blocks) {
127
+ for (const group of poem.model.groups)
128
+ if (group.cantoBreak && group.id)
129
+ anchors.push({
130
+ slug: slugify(group.id),
131
+ name: group.title || group.id,
132
+ level: 0,
133
+ line:
134
+ bodyLine +
135
+ String(body)
136
+ .slice(0, poem.bodyStart + group.from)
137
+ .split("\n").length -
138
+ 1,
139
+ kind: "prose",
140
+ });
127
141
  if (poem.id)
128
142
  anchors.push({
129
143
  slug: slugify(poem.id),
@@ -46,6 +46,7 @@
46
46
  * prevent.
47
47
  */
48
48
 
49
+ import { verseRegions } from "./verse-regions.mjs";
49
50
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
50
51
 
51
52
  /** A fence line, capturing its indent, its marker, and its info string. */
@@ -104,11 +105,10 @@ function column(space) {
104
105
  * @param {object} [options]
105
106
  * @param {boolean} [options.spans=true] - Include inline code spans. Set false
106
107
  * to consider only block-level code.
107
- * @param {boolean} [options.verseAsProse=true] - Treat verse bodies as inline prose.
108
108
  * @returns {Array<{start: number, end: number}>} Non-overlapping regions, in
109
109
  * source order. `end` is exclusive.
110
110
  */
111
- export function codeRegions(markdown, { spans = true, verseAsProse = true } = {}) {
111
+ export function codeRegions(markdown, { spans = true } = {}) {
112
112
  const src = String(markdown ?? "");
113
113
  if (!src) return [];
114
114
  const lines = src.split("\n");
@@ -127,6 +127,16 @@ export function codeRegions(markdown, { spans = true, verseAsProse = true } = {}
127
127
  });
128
128
 
129
129
  const regions = [];
130
+ const verses = verseRegions(src);
131
+ const verseLines = new Set(
132
+ verses.flatMap((region) =>
133
+ Array.from({ length: region.end - region.start + 1 }, (_, i) => region.start + i),
134
+ ),
135
+ );
136
+ for (const verse of verses) {
137
+ regions.push(through(verse.start, verse.start));
138
+ if (verse.closed) regions.push(through(verse.end, verse.end));
139
+ }
130
140
  // The content column of the innermost open list item: an indented block
131
141
  // has to clear *that*, not column zero, or every list continuation would
132
142
  // read as code.
@@ -137,6 +147,7 @@ export function codeRegions(markdown, { spans = true, verseAsProse = true } = {}
137
147
 
138
148
  for (let i = 0; i < lines.length; i++) {
139
149
  const line = lines[i];
150
+ if (verseLines.has(i)) continue;
140
151
 
141
152
  const fence = FENCE_LINE.exec(line);
142
153
  if (fence) {
@@ -145,10 +156,7 @@ export function codeRegions(markdown, { spans = true, verseAsProse = true } = {}
145
156
  let close = i + 1;
146
157
  while (close < lines.length && !closer.test(lines[close])) close++;
147
158
  // An unclosed fence runs to the end of the document.
148
- if (verseAsProse && /^verse(?:\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)));
159
+ regions.push(through(i, Math.min(close, lines.length - 1)));
152
160
  i = close;
153
161
  afterBlank = false;
154
162
  continue;
@@ -186,6 +194,7 @@ export function codeRegions(markdown, { spans = true, verseAsProse = true } = {}
186
194
  afterBlank = false;
187
195
  }
188
196
 
197
+ regions.sort((a, b) => a.start - b.start);
189
198
  if (spans) regions.push(...codeSpans(src, regions));
190
199
  regions.sort((a, b) => a.start - b.start);
191
200
  return regions;
@@ -111,7 +111,9 @@ export function scanBlocks(source) {
111
111
  for (const token of blockTokens(source)) {
112
112
  if (
113
113
  !token.map ||
114
- !["blockquote_open", "fence", "code_block", "html_block"].includes(token.type)
114
+ !["blockquote_open", "fence", "code_block", "html_block", "verse_div"].includes(
115
+ token.type,
116
+ )
115
117
  )
116
118
  continue;
117
119
  for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
@@ -277,7 +279,7 @@ export function renderBlocks(source, target, { renderInline, verses } = {}) {
277
279
  // at every depth, never a rendered fragment.
278
280
  const held =
279
281
  BLOCK_CONTAINERS.includes(block.name) ?
280
- renderBlocks(block.body, target, { renderInline }).markdown
282
+ renderBlocks(block.body, target, { renderInline, verses }).markdown
281
283
  : block.body;
282
284
  if (block.name === "div") {
283
285
  output.push("", `<div ${attributes}>`, "", held, "", "</div>", "");
@@ -18,6 +18,7 @@ import MarkdownIt from "markdown-it";
18
18
  import footnotePlugin from "markdown-it-footnote";
19
19
  import deflistPlugin from "markdown-it-deflist";
20
20
 
21
+ import { verseRegions } from "./verse-regions.mjs";
21
22
  import { slugify } from "./content-slug.mjs";
22
23
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
23
24
  import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
@@ -184,9 +185,20 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
184
185
  errors = [],
185
186
  ids = new Set();
186
187
  const counts = {};
188
+ const verse = verseRegions(String(source ?? ""));
189
+ const verseStarts = new Map(verse.map((region) => [region.start, region]));
190
+ const verseLines = new Set(
191
+ verse.flatMap((region) =>
192
+ Array.from(
193
+ { length: region.end - region.start + 1 },
194
+ (_, index) => region.start + index,
195
+ ),
196
+ ),
197
+ );
187
198
  let codeFence = null;
188
199
  const fault = (line, message) => errors.push({ line: line + 1, column: 1, message });
189
200
  for (let i = 0; i < lines.length; i++) {
201
+ if (verseLines.has(i)) continue;
190
202
  const fence = FENCE.exec(lines[i]);
191
203
  if (codeFence) {
192
204
  if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
@@ -233,7 +245,11 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
233
245
  let bodyEnd;
234
246
  const firstLine = lines[bodyStart];
235
247
  let kind;
236
- if (/^ {0,3}:::(?:[ \t]+\{.*\})?[ \t]*$/.test(firstLine)) {
248
+ if (verseStarts.has(bodyStart)) {
249
+ const region = verseStarts.get(bodyStart);
250
+ bodyEnd = region.end + 1;
251
+ kind = "poem";
252
+ } else if (/^ {0,3}:::(?:[ \t]+\{.*\})?[ \t]*$/.test(firstLine)) {
237
253
  let depth = 1,
238
254
  literal = null;
239
255
  bodyEnd = bodyStart + 1;
@@ -305,10 +321,7 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
305
321
  fault(bodyStart, "this block cannot be captioned");
306
322
  continue;
307
323
  }
308
- kind =
309
- first.type === "fence" && /^verse(?:\s|$)/.test(first.info) ?
310
- "poem"
311
- : figureKind(first, contents.trim(), resolveRole);
324
+ kind = figureKind(first, contents.trim(), resolveRole);
312
325
  }
313
326
  if (explicit) kind = explicit === "verse" ? "poem" : explicit;
314
327
  const id = attributes.id;
@@ -338,6 +351,7 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
338
351
  }
339
352
  let literal = null;
340
353
  for (let at = 0; at < lines.length; at++) {
354
+ if (verseLines.has(at)) continue;
341
355
  const fence = FENCE.exec(lines[at]);
342
356
  if (literal) {
343
357
  if (fence && fence[1][0] === literal[0] && fence[1].length >= literal.length)
@@ -29,7 +29,8 @@
29
29
  * @module
30
30
  */
31
31
 
32
- import { matchAllOutsideCode } from "./code-fences.mjs";
32
+ import { inlineSyntaxRanges, verseRegions } from "./verse-regions.mjs";
33
+ import { codeRegions, matchAllOutsideCode } from "./code-fences.mjs";
33
34
 
34
35
  /**
35
36
  * Where a character offset in `source` falls, as a 1-based line and column.
@@ -52,9 +53,15 @@ function lineColumn(source, offset) {
52
53
  export function separateFootnotes(source) {
53
54
  const lines = String(source ?? "").split("\n");
54
55
  const body = [...lines];
56
+ const verseLines = new Set(
57
+ verseRegions(String(source ?? "")).flatMap((region) =>
58
+ Array.from({ length: region.end - region.start + 1 }, (_, i) => region.start + i),
59
+ ),
60
+ );
55
61
  const definitions = [];
56
62
  let fence = null;
57
63
  for (let i = 0; i < lines.length; i++) {
64
+ if (verseLines.has(i)) continue;
58
65
  const marker = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
59
66
  if (fence) {
60
67
  if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length)
@@ -240,3 +247,55 @@ export function footnoteFindings(source) {
240
247
  (a, b) => a.line - b.line || a.column - b.column,
241
248
  );
242
249
  }
250
+
251
+ /**
252
+ * Labels defined at the top level of this note.
253
+ * @param {string} source - Complete authored note Markdown.
254
+ * @returns {Set<string>} Same-note footnote labels.
255
+ */
256
+ export function footnoteLabels(source) {
257
+ if (!source.includes("[^")) return new Set();
258
+ return new Set(
259
+ [...separateFootnotes(source).definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)].map(
260
+ (match) => match[1],
261
+ ),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Actual defined references, excluding literal code, escapes and metadata.
267
+ * @param {string} source - Full note or raw inline fragment.
268
+ * @param {object} [options] - Definition context and source grammar.
269
+ * @param {ReadonlySet<string>} [options.definitions] - Same-note labels; derived from source when omitted.
270
+ * @param {boolean} [options.inline] - Treat all source as inline verse/prose.
271
+ * @returns {Array<{from:number,to:number,key:string,text:string}>} Original UTF-16 source ranges.
272
+ */
273
+ export function footnoteReferences(
274
+ source,
275
+ { definitions = footnoteLabels(source), inline = false } = {},
276
+ ) {
277
+ if (!definitions.size || !source.includes("[^")) return [];
278
+ const protectedRanges = inlineSyntaxRanges(source).filter(
279
+ ([from]) => source[from] !== "{" || source[from - 1] === "]",
280
+ );
281
+ const code = inline ? [] : codeRegions(source);
282
+ return [...source.matchAll(/\[\^([^\]\s]+)\]/g)].flatMap((match) => {
283
+ const from = match.index,
284
+ to = from + match[0].length;
285
+ const escapes = /\\+$/.exec(source.slice(0, from))?.[0].length ?? 0;
286
+ if (
287
+ !definitions.has(match[1]) ||
288
+ escapes % 2 ||
289
+ protectedRanges.some(([first, past]) => from >= first && from < past) ||
290
+ code.some((range) => from >= range.start && from < range.end)
291
+ )
292
+ return [];
293
+ if (
294
+ !inline &&
295
+ source[to] === ":" &&
296
+ /^[ \t]*$/.test(source.slice(source.lastIndexOf("\n", from - 1) + 1, from))
297
+ )
298
+ return [];
299
+ return [{ from, to, key: match[1], text: match[0] }];
300
+ });
301
+ }
@@ -1568,7 +1568,7 @@ function dynamicPublicationBody(body) {
1568
1568
  const source = String(body ?? "");
1569
1569
  return (
1570
1570
  [...matchAllOutsideCode(source, EXPRESSION)].length > 0 ||
1571
- codeRegions(source, { spans: false, verseAsProse: false }).some(({ start, end }) => {
1571
+ codeRegions(source, { spans: false }).some(({ start, end }) => {
1572
1572
  const opener = FENCE_LINE.exec(source.slice(start, end).split("\n", 1)[0]);
1573
1573
  const language = opener && parseHeaderArgs(opener[3]).language;
1574
1574
  return language === "sql" || language === PAGE_LIST_LANGUAGE;