@heroiclands/package-build 23.9.0 → 23.10.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 (85) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +197 -68
  4. package/docs/api.md +336 -68
  5. package/docs/authoring/links-and-markup.md +114 -47
  6. package/docs/commands.md +92 -3
  7. package/docs/content-format.md +6 -1
  8. package/docs/reference/format-details.md +129 -14
  9. package/docs/reference/note-types.md +4 -4
  10. package/engine/anchors.mjs +8 -6
  11. package/engine/base-compiler.mjs +6 -4
  12. package/engine/changelog-group.mjs +58 -34
  13. package/engine/changelog-lint.mjs +6 -2
  14. package/engine/compendiums.mjs +15 -1
  15. package/engine/compile-corpus.mjs +14 -1
  16. package/engine/content-charset.mjs +13 -1
  17. package/engine/content-html.mjs +18 -4
  18. package/engine/content-icons.mjs +15 -1
  19. package/engine/content-images.mjs +28 -9
  20. package/engine/content-index.mjs +102 -23
  21. package/engine/content-links.mjs +27 -12
  22. package/engine/content-session.mjs +247 -0
  23. package/engine/content-sources.mjs +110 -0
  24. package/engine/content-spans.mjs +87 -8
  25. package/engine/content-tables.mjs +1 -0
  26. package/engine/content-tasklists.mjs +20 -4
  27. package/engine/diagnostics.mjs +17 -6
  28. package/engine/extension-attributes.mjs +28 -5
  29. package/engine/first-use.mjs +371 -0
  30. package/engine/foundry-entries.mjs +12 -9
  31. package/engine/generate.mjs +13 -4
  32. package/engine/helpers.mjs +43 -14
  33. package/engine/index-records.mjs +1 -0
  34. package/engine/index.mjs +3 -0
  35. package/engine/journals.mjs +9 -5
  36. package/engine/language-data.mjs +2 -2
  37. package/engine/language-rows.mjs +4 -4
  38. package/engine/language-terms.mjs +4 -4
  39. package/engine/lexical-sections.mjs +5 -5
  40. package/engine/markup-validation.mjs +7 -2
  41. package/engine/metadata-index.mjs +1 -0
  42. package/engine/name-markup.mjs +114 -0
  43. package/engine/name-rows.mjs +70 -0
  44. package/engine/names.mjs +460 -0
  45. package/engine/note-addresses.mjs +25 -4
  46. package/engine/path-charset.mjs +24 -1
  47. package/engine/pdf-render.mjs +15 -1
  48. package/engine/site-index.mjs +5 -1
  49. package/engine/sql-tables.mjs +76 -15
  50. package/engine/stub-lint.mjs +4 -3
  51. package/engine/yaml-lint.mjs +3 -5
  52. package/hugo-theme/static/css/style.css +4 -0
  53. package/package.json +1 -1
  54. package/types/engine/anchors.d.mts +4 -2
  55. package/types/engine/compendiums.d.mts +5 -1
  56. package/types/engine/compile-corpus.d.mts +5 -2
  57. package/types/engine/content-charset.d.mts +5 -1
  58. package/types/engine/content-html.d.mts +5 -1
  59. package/types/engine/content-icons.d.mts +5 -1
  60. package/types/engine/content-images.d.mts +5 -1
  61. package/types/engine/content-index.d.mts +39 -19
  62. package/types/engine/content-links.d.mts +22 -8
  63. package/types/engine/content-session.d.mts +108 -0
  64. package/types/engine/content-sources.d.mts +32 -0
  65. package/types/engine/content-spans.d.mts +3 -5
  66. package/types/engine/content-tasklists.d.mts +5 -1
  67. package/types/engine/diagnostics.d.mts +10 -0
  68. package/types/engine/extension-attributes.d.mts +16 -2
  69. package/types/engine/first-use.d.mts +31 -0
  70. package/types/engine/foundry-entries.d.mts +11 -4
  71. package/types/engine/generate.d.mts +5 -1
  72. package/types/engine/helpers.d.mts +30 -7
  73. package/types/engine/index.d.mts +1 -0
  74. package/types/engine/journals.d.mts +5 -2
  75. package/types/engine/lexical-sections.d.mts +3 -2
  76. package/types/engine/markup-validation.d.mts +2 -1
  77. package/types/engine/name-markup.d.mts +176 -0
  78. package/types/engine/name-rows.d.mts +35 -0
  79. package/types/engine/names.d.mts +356 -0
  80. package/types/engine/note-addresses.d.mts +3 -3
  81. package/types/engine/path-charset.d.mts +16 -1
  82. package/types/engine/site-build.d.mts +2 -2
  83. package/types/engine/site-index.d.mts +3 -1
  84. package/types/engine/sql-tables.d.mts +7 -2
  85. package/types/engine/stub-lint.d.mts +3 -1
@@ -339,8 +339,11 @@ infoboxes:
339
339
  ### Frontmatter has three regions
340
340
 
341
341
  A HeroicLands note has opening YAML frontmatter with nonempty `shortcode` and
342
- `type` values. Other Markdown files are ordinary Markdown. A note's frontmatter
343
- has three regions:
342
+ `type` values. The opening and closing `---` delimiters accept LF or CRLF line
343
+ endings. Parsing keeps the original source text and diagnostic positions;
344
+ malformed YAML is an error with either line ending. The charset check recommends
345
+ LF and reports carriage returns as warnings. Other Markdown files are ordinary
346
+ Markdown. A note's frontmatter has three regions:
344
347
 
345
348
  | Region | Describes | Unknown keys |
346
349
  | -------------------- | ------------------------------------------------------- | ------------ |
@@ -1805,6 +1808,86 @@ name at every citation with no link edited. `[[x|]]` is labelled; `[[x]]` is not
1805
1808
  It is the label that is required, not a target: the anchor-only form above
1806
1809
  carries its pipe like every other link.
1807
1810
 
1811
+ #### Marking names in prose
1812
+
1813
+ `package-build lint` reports name-marking warnings in authored body prose.
1814
+ The warnings leave a valid package passing. Headings, frontmatter, tables,
1815
+ infoboxes, code, comments and embeds are outside this check.
1816
+
1817
+ A name is explicit: a `[Name]{.name}` bracket span or an authored link label.
1818
+ Use `.name` for names without indexed notes and links for names with notes.
1819
+ Plain prose is not searched for names. Wikilinks, ordinary Markdown links and
1820
+ reference links all supply names; a resolved empty wikilink label uses the
1821
+ note's full name. Images, code, footnote markers and bare autolinks without
1822
+ authored labels supply none.
1823
+
1824
+ 1. A `.name` span whose whole text matches an indexed `name.full` or
1825
+ `name.aliases` should be a link. Use `[[place-arden|Arden Vale]]` or
1826
+ `[[place-arden|]]` to introduce that note.
1827
+ 2. Mark or link each name at most once per note. An indexed full name and its
1828
+ declared aliases share one identity, including across different heading
1829
+ anchors. Other labels use their whole text as their identity: different
1830
+ labels linking to one page are separate names, and a label is not
1831
+ automatically an alias of its destination.
1832
+ 3. Introduce the full name before a short form or declared alias.
1833
+ Possessives and regular plurals count as uses; other indexed forms can be
1834
+ declared aliases.
1835
+
1836
+ Bold is emphasis. A whole bold span exactly matching an indexed full name or
1837
+ alias receives one advisory to mark or link the name. It supplies no name
1838
+ occurrence or introduction, and bold containing a marked or linked name
1839
+ receives no such advisory. Unknown bold phrases supply no names.
1840
+
1841
+ Inline Markdown remains active inside name spans, and name spans remain
1842
+ valid inside emphasis:
1843
+
1844
+ ```markdown
1845
+ **This is a phrase with [Joe Blow]{.name} in it**
1846
+ ```
1847
+
1848
+ The name is `Joe Blow`; the surrounding phrase is emphasis. Sites and books display
1849
+ `.name` text in bold.
1850
+
1851
+ Each affected occurrence produces one warning at its authored
1852
+ `file:line:column`, naming the full name or explicit label. A warning
1853
+ can describe several marking problems at that occurrence.
1854
+
1855
+ An optional `data-type` on a `.name` span states what the named thing is:
1856
+
1857
+ ```markdown
1858
+ [Ravenmoor]{.name data-type="place/settlement"}
1859
+ [Aldric Venn]{.name data-type="being"}
1860
+ ```
1861
+
1862
+ Use a declared [note type](note-types.md), optionally followed by `/` and one
1863
+ of that type's subtypes. An enumerated declaration requires a listed subtype;
1864
+ an open declaration accepts a well-formed subtype, and an absent subtype
1865
+ declaration admits none. An unknown or malformed supplied value is an error
1866
+ at the `data-type` attribute's file, line and UTF-16 column. The same attribute
1867
+ on a span without the exact `name` class is an ordinary attribute. An untyped
1868
+ `.name` is valid; its type is not inferred from a matching note.
1869
+
1870
+ The index captures actual `.name` spans throughout the authored body, including
1871
+ headings, tables, verse and spans within links. The prose-only marking checks
1872
+ above keep their narrower scope. Frontmatter, literal code, escaped syntax,
1873
+ unmarked link labels, bold, plain prose and generated content add no names.
1874
+ The rendered visible text preserves its spelling and inline content.
1875
+
1876
+ Each own-note metadata record carries `names` in authored order. Every entry
1877
+ has exactly `name`, `type`, `subType`, `line` and `column`; absent
1878
+ classifications are explicit `null`, and no occurrences gives an empty array.
1879
+ This is derived metadata, not frontmatter. Documentation mirrors and assets
1880
+ do not duplicate a note's occurrences. The [`names` relation](#content-tables)
1881
+ flattens these entries with their containing NOTE Address.
1882
+
1883
+ Lint groups names without an indexed full-name or alias match by supplied
1884
+ classification and lists all their source uses. It also groups conflicting
1885
+ annotations of the same visible name. Untyped uses do not conflict. A bare
1886
+ type and its subtype agree; different supplied types or different supplied
1887
+ subtypes conflict. One package advisory contains both groups and is located
1888
+ at the earliest reported use. Advisory findings preserve a successful lint
1889
+ exit status. Supplied-value errors prevent publication and compilation.
1890
+
1808
1891
  ### Assets are types
1809
1892
 
1810
1893
  `icon`, `image` and `audio` are types in the type vocabulary, and an address
@@ -2911,7 +2994,7 @@ for the keys.
2911
2994
  | `frames` | `data.sounds.frames` | `frame`, `shape`, `class` (a list), `makes`, `examples` (a list of `{ root, form }`) |
2912
2995
  | `read_past` | `data.sounds.readPast` | `written`, `readAs`, `what` |
2913
2996
  | `sound_changes` | `data.soundChanges` | One row per step: `position` (the set's), `name`, `from_language`, `to_language`, `takes`, `step_position` (the step's in its set), `step`, `change`, `where` |
2914
- | `elements` | `data.elements` | `form`, `variants` (a list), `element_position` (the element's `position`), `kind`, `sense`, `examples` (a list), `word`, `genitive` |
2997
+ | `elements` | `data.elements` | `form`, `variants` (a list), `element_position` (the element's `position`), `kind`, `sense`, `examples` (a list), `word`, `joining` |
2915
2998
  | `roots` | `data.roots` | `root`, `sense`, `field` |
2916
2999
  | `cognates` | `data.cognates` | One row per form of a set: `position` (the set's), `gloss`, `proto`, `form_language` (the form's language note), `form` |
2917
3000
  | `calling_names` | `data.callingNames` | One row per closing: `form`, `doubled` (`true` or `false`), `usual`, and the language's `min_syllables` |
@@ -2922,7 +3005,7 @@ for the keys.
2922
3005
  | `name_shapes` | `data.naming.shapes` | `of`, `syllables_min`, `syllables_max`, `per_vowel_min`, `per_vowel_max` |
2923
3006
  | `name_edges` | `data.naming.edges` | `edge`, `sounds` (a list), `vowel` (`false` where unstated), `of` (`name` where unstated) |
2924
3007
  | `name_draws` | `data.naming.draws` | `kind`, `elements` (a list) |
2925
- | `held_from` | `data.naming.heldFrom` | `source`, `takes`, `clip`, `genitive` and `elements` (lists) |
3008
+ | `held_from` | `data.naming.heldFrom` | `source`, `takes`, `clip`, `joining` and `elements` (lists) |
2926
3009
  | `near_names` | `data.naming.nearName.examples` | `near`, `given`, and the language's `min_syllables` |
2927
3010
 
2928
3011
  A register name is carried by a note in the language's `data.scope` — any
@@ -2937,6 +3020,30 @@ office title, and by an anchor that has it as its name, which the row cites as
2937
3020
  `max(form) FILTER (WHERE form_language = 'thalorna-note-skill-helonclng') AS "Helonic"`,
2938
3021
  grouped by `position` and `gloss`.
2939
3022
 
3023
+ **`names`** is one row per actual `.name` span in an authored note body. Each
3024
+ row has these columns, including on an empty relation:
3025
+
3026
+ | Column | SQL type | Meaning |
3027
+ | --------- | --------- | -------------------------------------------------------------------------------------------- |
3028
+ | `owner` | `VARCHAR` | Containing authored page's canonical NOTE Address, or `NULL` when the record has no Address. |
3029
+ | `name` | `VARCHAR` | Rendered visible text, preserving its spelling. |
3030
+ | `type` | `VARCHAR` | Supplied note type, or `NULL`. |
3031
+ | `subType` | `VARCHAR` | Supplied subtype, or `NULL`. |
3032
+ | `line` | `BIGINT` | Original 1-based source line at the span's opening `[`. |
3033
+ | `column` | `BIGINT` | Original 1-based UTF-16 source column at that `[`. |
3034
+
3035
+ ```sql
3036
+ SELECT owner, name, type, subType, line, "column"
3037
+ FROM names
3038
+ WHERE type = 'place'
3039
+ ORDER BY owner, line, "column"
3040
+ ```
3041
+
3042
+ A native Item's source uses its NOTE page as `owner`. Declared dependency
3043
+ metadata supplies `names` in that package's SQL schema. Public queries exclude
3044
+ GM-only owners; all/native contexts retain them. Only authored occurrences
3045
+ contribute rows, so generated queries and sections add no further names.
3046
+
2940
3047
  **`language_lineage`** is one row per language note and each language it
2941
3048
  inherits words from through [`data.family`](#a-languages-words): `language`,
2942
3049
  `ancestor` and `depth`, with the language itself at depth `0`, its parent at
@@ -3374,6 +3481,9 @@ An attribute-bearing opener allows a nested div to be distinguished from the bar
3374
3481
  The watch wears [a _blue_ sash]{#watch-sash .uniform lang=en}.
3375
3482
  ```
3376
3483
 
3484
+ The exact `.name` class marks a name, with optional `data-type="type/subType"`;
3485
+ see [marking names in prose](#marking-names-in-prose) for the vocabulary and index.
3486
+
3377
3487
  Inline Markdown remains active inside the span. Bracket spans have lower priority than URL links, image links, reference links, footnotes and wikilinks. A bracketed form immediately preceded by `!`, followed by `(` or another `[`, or beginning with `[[` is not a span, even when the link is unresolved. Ordinary links retain their existing syntax. Escape a bracket when it should be literal, or put examples inside a code span or fence. A span identifier addresses that point in the note; it does not start a journal page.
3378
3488
 
3379
3489
  ##### Alerts
@@ -5492,7 +5602,7 @@ SoHL sets `system.quantity` to `1` for each compendium item.
5492
5602
  | `soundChanges[].steps[].step` | `string`, required | The step's label, as `A1` or `"1"`; no label stands twice in a set |
5493
5603
  | `soundChanges[].steps[].change` | `string`, required | The change in arrow notation, as `` `ph` > `f`; `kh` > `h` ``, or the operation it names in words |
5494
5604
  | `soundChanges[].steps[].where` | `string` | Where it holds, as `word end`; everywhere where absent |
5495
- | `elements` | `{ form, variants?, position?, kind?, sense, examples?, word?, genitive? }[]` | `language` only: the elements its names are built from |
5605
+ | `elements` | `{ form, variants?, position?, kind?, sense, examples?, word?, joining? }[]` | `language` only: the elements its names are built from |
5496
5606
  | `elements[].form` | `string`, required | The element, as `arn-` |
5497
5607
  | `elements[].variants` | `string[]` | The element's other forms, as `aldr-` beside `ald-` |
5498
5608
  | `elements[].position` | `opening`, `closing`, `either` or `link` | Where in a name it stands |
@@ -5500,7 +5610,7 @@ SoHL sets `system.quantity` to `1` for each compendium item.
5500
5610
  | `elements[].sense` | `string`, required | What it means |
5501
5611
  | `elements[].examples` | `string[]` | The names it is read from |
5502
5612
  | `elements[].word` | `string` | The word it is the stem of, a form of `words` of the language or an ancestor |
5503
- | `elements[].genitive` | `string` | The element in the genitive, as `-borgar`, where a name governing another word takes it |
5613
+ | `elements[].joining` | `string` | The element's joining form, as `-borgar`, when it qualifies another word |
5504
5614
  | `roots` | `{ root, sense, field? }[]` | `language` only: the consonantal roots its words are set from |
5505
5615
  | `roots[].root` | `string`, required | The root, as `d-l-k` |
5506
5616
  | `roots[].sense` | `string`, required | What it means |
@@ -5540,11 +5650,11 @@ SoHL sets `system.quantity` to `1` for each compendium item.
5540
5650
  | `naming.draws` | `{ kind, elements }[]` | The element kinds each kind of name is built from |
5541
5651
  | `naming.draws[].kind` | `byname`, `given` or `place`, required | The kind of name; once per kind |
5542
5652
  | `naming.draws[].elements` | list of `byname`, `given` or `place`, required | The element kinds, as `elements[].kind` states them |
5543
- | `naming.heldFrom` | `{ source, takes, clip?, genitive?, elements? }[]` | What a place name takes first, by what the place is held from |
5653
+ | `naming.heldFrom` | `{ source, takes, clip?, joining?, elements? }[]` | What a place name takes first, by what the place is held from |
5544
5654
  | `naming.heldFrom[].source` | `string`, required | What the place is held from, as `a founder` |
5545
5655
  | `naming.heldFrom[].takes` | `stem`, `godName`, `givenName` or `element`, required | What stands first: a name-stem in any grade, a god's name, a founder's given name, or one of `elements` |
5546
5656
  | `naming.heldFrom[].clip` | `first` | How the name is clipped: to its first element; whole where absent. A name row only |
5547
- | `naming.heldFrom[].genitive` | `string[]` | The genitive endings, one of which stands at the seam after the name. A name row only |
5657
+ | `naming.heldFrom[].joining` | `string[]` | The joining endings, one of which stands at the seam after the name. A name row only |
5548
5658
  | `naming.heldFrom[].elements` | `string[]` | The elements that may stand first; required on, and only on, a row that takes an `element` |
5549
5659
  | `naming.nearName` | `{ minSyllables, examples? }` | The near name a long given name carries |
5550
5660
  | `naming.nearName.minSyllables` | `integer`, required | The fewest syllables a given name has that carries a near name |
@@ -5856,7 +5966,7 @@ data:
5856
5966
  - { kind: byname, elements: [byname, place] }
5857
5967
  heldFrom:
5858
5968
  - { source: a god, takes: godName, clip: first }
5859
- - { source: a founder, takes: givenName, genitive: ["-s", "-a"] }
5969
+ - { source: a founder, takes: givenName, joining: ["-s", "-a"] }
5860
5970
  - { source: the ting and its law, takes: element, elements: ["ting-", "lög-"] }
5861
5971
  nearName:
5862
5972
  minSyllables: 4
@@ -5972,9 +6082,8 @@ form and its other forms, where in a name it stands (`opening`, `closing`,
5972
6082
  `either` or `link`), its sense and the names it is read from. In a language
5973
6083
  that keeps one set of elements for each kind of name, `kind` says which
5974
6084
  (`byname`, `given` or `place`). `word` names the word of `words` the element is
5975
- the stem of, the language's or an ancestor's, and `genitive` gives the element
5976
- in the genitive where a name governing another word takes it. An element that
5977
- is also a word stays a word.
6085
+ the stem of, the language's or an ancestor's, and `joining` gives its form
6086
+ when it qualifies another word. An element that is also a word stays a word.
5978
6087
 
5979
6088
  **`roots` lists consonantal roots**, each with its sense and the field it
5980
6089
  belongs to. **`cognates`** lists, on the eldest language of a set, the form one
@@ -6007,7 +6116,7 @@ there too and `of` where the edge holds for one class of name only; an edge
6007
6116
  stands once for each class. `draws` gives the element kinds each kind of name
6008
6117
  is built from. `heldFrom` gives what a place name takes first by what the
6009
6118
  place is held from: a name-stem, a god's name, a founder's given name — that
6010
- name `clip`ped to its `first` element, or followed by one of its `genitive`
6119
+ name `clip`ped to its `first` element, or followed by one of its `joining`
6011
6120
  endings — or one of the `elements` listed. `nearName` states the fewest
6012
6121
  syllables a given name has that carries a near name, with worked examples.
6013
6122
 
@@ -6056,7 +6165,7 @@ Each finding is located at the key at fault:
6056
6165
  | A naming edge stated twice for one class of name, at the second | error |
6057
6166
  | A kind of name whose draws are stated twice, at the second | error |
6058
6167
  | A held-from row that takes an `element` and names no `elements`, at the row | error |
6059
- | `elements` on a held-from row that takes no element, or `clip` or `genitive` on one that takes no name, at the key | error |
6168
+ | `elements` on a held-from row that takes no element, or `clip` or `joining` on one that takes no name, at the key | error |
6060
6169
  | A `scope` Address that does not resolve, or a type that is no note type | error |
6061
6170
  | A tongue `language` or a cognate form's language that is no language note | error |
6062
6171
  | A register row with neither a `tongue` nor a `kind`, at the row | error |
@@ -6391,6 +6500,12 @@ word.
6391
6500
  them, then its culture's `data.language`;
6392
6501
  3. on a culture, its own `data.language`.
6393
6502
 
6503
+ On lore and affiliation notes, `data.language` and
6504
+ `data.glossary[].language` name language notes. Use `skill-provnzlng` for a
6505
+ language in this package, or `<pkg>-note-skill-provnzlng` for its complete
6506
+ NOTE Address. Any other system segment, including `sohl`, is an error at the
6507
+ authored value. A SOHL skill Address names a compiled Item.
6508
+
6394
6509
  An affiliation's culture is the one it states or inherits, as
6395
6510
  [Culture in the index](#culture-in-the-index) describes, and a culture's
6396
6511
  `data.language` includes one it inherits from its
@@ -474,7 +474,7 @@ These fields are accepted by every note type. A field's value can still be irrel
474
474
  | `data.scope` | a map — `{ folders?, cultures?, lore?, parents?, addresses?, paths?, types?, skip? }` | The notes the register covers. A language skill note only. |
475
475
  | `data.sounds` | a map — `{ letters?, edges?, clusters?, stress?, finals?, syllables?, joining?, joins?, marks?, skeleton?, frames?, readPast?, mutations? }` | The letters the language writes and the rules its words keep. A language skill note only. |
476
476
  | `data.soundChanges` | list of `{ name, from?, to?, takes?, steps }` | Named sets of ordered changes, each making one tongue's words from another's. A language skill note only. |
477
- | `data.elements` | list of `{ form, variants?, position?, kind?, sense, examples?, word?, genitive? }` | The elements the language's names are built from. A language skill note only. |
477
+ | `data.elements` | list of `{ form, variants?, position?, kind?, sense, examples?, word?, joining? }` | The elements the language's names are built from. A language skill note only. |
478
478
  | `data.roots` | list of `{ root, sense, field? }` | The consonantal roots the language's words are set from. A language skill note only. |
479
479
  | `data.cognates` | list of `{ forms, proto?, gloss }` | Sets of forms one older word takes in several languages, stated on the eldest. A language skill note only. |
480
480
  | `data.callingNames` | a map — `{ closings, minSyllables }` | The short name a long given name is called by. A language skill note only. |
@@ -635,7 +635,7 @@ These fields are accepted by every note type. A field's value can still be irrel
635
635
  | `data.elements[].sense` | a string | yes | What the element means. |
636
636
  | `data.elements[].examples` | a list of names | no | The names the element is read from. |
637
637
  | `data.elements[].word` | a form of the language's words | no | The word the element is the stem of, a form of `data.words` of the language or an ancestor. |
638
- | `data.elements[].genitive` | the element as written, such as `-borgar` | no | The element in the genitive, where a name governing another word takes it. |
638
+ | `data.elements[].joining` | the element as written, such as `-borgar` | no | The element's joining form, as written when it qualifies another word. |
639
639
  | `data.roots[].root` | a consonantal skeleton, such as `d-l-k` | yes | The root. |
640
640
  | `data.roots[].sense` | a string | yes | What the root means. |
641
641
  | `data.roots[].field` | a string, such as `stone` | no | The field the root belongs to. |
@@ -668,11 +668,11 @@ These fields are accepted by every note type. A field's value can still be irrel
668
668
  | `data.naming.draws` | list of `{ kind, elements }` | no | The element kinds each kind of name is built from. |
669
669
  | `data.naming.draws[].kind` | one of byname, given, place | yes | The kind of name; once per kind. |
670
670
  | `data.naming.draws[].elements` | a list of element kinds | yes | The kinds of element, as `data.elements` states them, the name is built from. |
671
- | `data.naming.heldFrom` | list of `{ source, takes, clip?, genitive?, elements? }` | no | What a place name takes first, by what the place is held from. |
671
+ | `data.naming.heldFrom` | list of `{ source, takes, clip?, joining?, elements? }` | no | What a place name takes first, by what the place is held from. |
672
672
  | `data.naming.heldFrom[].source` | a string, such as `a founder` | yes | What the place is held from. |
673
673
  | `data.naming.heldFrom[].takes` | one of stem, godName, givenName, element | yes | What stands first: a name-stem in any grade, a god's name, a founder's given name, or one of `elements`. |
674
674
  | `data.naming.heldFrom[].clip` | one of first | no | How the name is clipped: `first`, to its first element; whole where absent. |
675
- | `data.naming.heldFrom[].genitive` | a list of endings | no | The genitive endings, one of which stands at the seam after the name. |
675
+ | `data.naming.heldFrom[].joining` | a list of endings | no | The joining endings, one of which stands at the seam after the name. |
676
676
  | `data.naming.heldFrom[].elements` | a list of elements | no | The elements that may stand first, for a row that takes an `element`. |
677
677
  | `data.naming.nearName` | a map — `{ minSyllables, examples? }` | no | The near name a long given name carries. |
678
678
  | `data.naming.nearName.minSyllables` | a whole number | yes | The fewest syllables a given name has that carries a near name. |
@@ -65,10 +65,11 @@ export const ANCHOR_KINDS = Object.freeze(["prose", "event"]);
65
65
  * picture's address to the role its asset declares, so a figure anchor's
66
66
  * `name` reads `Map 1` rather than `Figure 1` where it is due — see
67
67
  * {@link module:engine/content-figures.scanFigures}.
68
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
68
69
  * @returns {Array<{slug: string, name: string, level: number, line: number,
69
70
  * kind: string}>} In document order.
70
71
  */
71
- export function collectAnchors(body, bodyLine = 1, resolveRole) {
72
+ export function collectAnchors(body, bodyLine = 1, resolveRole, syntax) {
72
73
  const anchors = [];
73
74
  for (const heading of scanHeadingAttributes(body, bodyLine).headings) {
74
75
  if (!heading.id) continue;
@@ -92,7 +93,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
92
93
  }
93
94
  const blockAnchors = (source, firstLine) => {
94
95
  const sourceLines = source.split("\n");
95
- for (const block of scanBlocks(source).blocks) {
96
+ for (const block of (syntax ? syntax(source).blocks : scanBlocks(source)).blocks) {
96
97
  if (block.id)
97
98
  anchors.push({
98
99
  slug: slugify(block.id),
@@ -108,7 +109,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
108
109
  }
109
110
  };
110
111
  blockAnchors(String(body ?? ""), bodyLine);
111
- for (const alert of scanAlerts(body).blocks) {
112
+ for (const alert of (syntax ? syntax(body).alerts : scanAlerts(body)).blocks) {
112
113
  if (alert.id)
113
114
  anchors.push({
114
115
  slug: slugify(alert.id),
@@ -128,7 +129,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
128
129
  kind: "prose",
129
130
  });
130
131
  }
131
- for (const span of scanSpans(body).spans) {
132
+ for (const span of (syntax ? syntax(body).spans : scanSpans(body)).spans) {
132
133
  if (span.id)
133
134
  anchors.push({
134
135
  slug: slugify(span.id),
@@ -144,11 +145,12 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
144
145
  /** Collisions involving a div, verse or inline-span anchor.
145
146
  * Heading and caption collisions are diagnosed by their existing scanners.
146
147
  * @param {string} body - Authored Markdown.
148
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
147
149
  * @returns {Array<{line: number, column: number, message: string}>} Located collisions.
148
150
  */
149
- export function markupAnchorFindings(body) {
151
+ export function markupAnchorFindings(body, syntax) {
150
152
  const groups = new Map();
151
- for (const anchor of collectAnchors(body)) {
153
+ for (const anchor of collectAnchors(body, 1, undefined, syntax)) {
152
154
  const slug = slugify(anchor.slug);
153
155
  if (!groups.has(slug)) groups.set(slug, []);
154
156
  groups.get(slug).push(anchor);
@@ -686,11 +686,13 @@ export class BasePackCompiler {
686
686
  // it made — `errors` or `findings`, never just the first — so nothing
687
687
  // here re-scans; it only stops taking `[0]`.
688
688
  const findings = [];
689
+ const syntax = this.corpus?.syntax;
690
+ const scanned = syntax?.(body);
689
691
  for (const secretError of [
690
- ...scanBlocks(body).errors,
691
- ...scanAlerts(body).errors,
692
- ...scanSpans(body).errors,
693
- ...markupAnchorFindings(body),
692
+ ...(scanned?.blocks ?? scanBlocks(body)).errors,
693
+ ...(scanned?.alerts ?? scanAlerts(body)).errors,
694
+ ...(scanned?.spans ?? scanSpans(body)).errors,
695
+ ...markupAnchorFindings(body, syntax),
694
696
  ]) {
695
697
  findings.push({
696
698
  message: secretError.message,
@@ -46,33 +46,37 @@ import {
46
46
  const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes$/gm;
47
47
 
48
48
  /**
49
- * A labelled block's own `**Label**` line, split from everything after it.
49
+ * A label, its complete opening paragraph and its remaining items.
50
50
  *
51
51
  * @param {string} blockText - One block, as {@link topLevelBlocks} collects
52
52
  * it — its first line is the label line for a labelled block.
53
- * @returns {{labelLine: string, rest: string, hadBlankLine: boolean}} `rest`
54
- * has its leading blank line dropped; a block with nothing but the label
55
- * line yields `""`. `hadBlankLine` says whether the label line was
56
- * followed by a blank line in `blockText` — false for a bold sentence a
57
- * hard-wrapped paragraph continues directly beneath, with no blank line
58
- * of its own.
53
+ * @returns {{labelLine: string, introduction: string, rest: string}} The
54
+ * introduction retains hard wrapping and stops before a bullet or code.
59
55
  */
60
- function splitLabelLine(blockText) {
61
- const nl = blockText.indexOf("\n");
62
- if (nl === -1) return { labelLine: blockText, rest: "", hadBlankLine: false };
63
- const after = blockText.slice(nl + 1);
56
+ function splitLabelBlock(blockText) {
57
+ const lines = blockText.replace(/\s+$/, "").split("\n");
58
+ const labelLine = /^\*\*[^*]+\*\*/.exec(lines[0])[0];
59
+ const inline = lines[0].slice(labelLine.length).trimStart();
60
+ const introduction = inline ? [inline] : [];
61
+ const codeLines = codeLineSet(blockText);
62
+ let cursor = 1;
63
+ while (
64
+ cursor < lines.length &&
65
+ lines[cursor].trim() !== "" &&
66
+ !/^-\s/.test(lines[cursor]) &&
67
+ !codeLines.has(cursor + 1)
68
+ )
69
+ introduction.push(lines[cursor++]);
64
70
  return {
65
- labelLine: blockText.slice(0, nl),
66
- rest: after.replace(/^\n+/, ""),
67
- hadBlankLine: after.startsWith("\n"),
71
+ labelLine,
72
+ introduction: introduction.join("\n"),
73
+ rest: lines.slice(cursor).join("\n").replace(/^\n+/, ""),
68
74
  };
69
75
  }
70
76
 
71
77
  /**
72
- * A block's top-level items: its bullets, or — when it carries none — the
73
- * whole thing as one paragraph item. This is what merging concatenates and
74
- * deduplicates, so a bulleted `**Compendiums**` block and a prose one merge
75
- * on the same footing.
78
+ * A block's top-level bullets and the prose or code between them, in source
79
+ * order. A bullet retains its indented continuation and nested items.
76
80
  *
77
81
  * @param {string} text - A block's content, label line already stripped (or
78
82
  * a lead block's whole text).
@@ -83,7 +87,26 @@ function itemsOf(text) {
83
87
  if (!trimmed) return [];
84
88
  const bullets = topLevelBullets(trimmed, codeLineSet(trimmed));
85
89
  if (bullets.length === 0) return [trimmed];
86
- return bullets.map((bullet) => bullet.text.replace(/\s+$/, ""));
90
+ const starts = [];
91
+ let offset = 0;
92
+ for (const line of trimmed.split("\n")) {
93
+ starts.push(offset);
94
+ offset += line.length + 1;
95
+ }
96
+ const items = [];
97
+ const append = (part) => {
98
+ const item = part.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
99
+ if (item) items.push(item);
100
+ };
101
+ let cursor = 0;
102
+ for (const bullet of bullets) {
103
+ const start = starts[bullet.startLine - 1];
104
+ append(trimmed.slice(cursor, start));
105
+ append(bullet.text);
106
+ cursor = start + bullet.text.length;
107
+ }
108
+ append(trimmed.slice(cursor));
109
+ return items;
87
110
  }
88
111
 
89
112
  /**
@@ -124,13 +147,9 @@ function joinItems(items) {
124
147
  * One label's blocks (or every unlabelled one), folded into the single
125
148
  * block `group` writes for it.
126
149
  *
127
- * A single, unmerged block — the common case, one changeset writing one
128
- * label once — keeps its own label-to-body separator rather than forcing a
129
- * blank line: a bold sentence a hard-wrapped paragraph continues directly
130
- * beneath must round-trip unchanged, not gain a blank line it never had.
131
- * Merging two or more blocks, or a label whose lone block carries several
132
- * items (bullets), still separates the label from its body with a blank
133
- * line — the convention every existing multi-item block already uses.
150
+ * A single block with no duplicate items retains its own spacing. Merged
151
+ * opening paragraphs form one hard-wrapped introduction before the items,
152
+ * so paragraph boundaries cannot turn labelled text into unlabelled lead.
134
153
  *
135
154
  * @param {Array<{label: string|null, text: string}>} blocks - Every block
136
155
  * sharing one label, in file order. All carry the same `label`.
@@ -140,13 +159,15 @@ function mergeGroup(blocks) {
140
159
  if (blocks[0].label === null) {
141
160
  return joinItems(dedupeItems(blocks.flatMap((block) => itemsOf(block.text))));
142
161
  }
143
- const { labelLine, hadBlankLine } = splitLabelLine(blocks[0].text);
144
- const items = dedupeItems(blocks.flatMap((block) => itemsOf(splitLabelLine(block.text).rest)));
145
- if (items.length === 0) return labelLine;
146
- if (blocks.length === 1 && items.length === 1) {
147
- return `${labelLine}${hadBlankLine ? "\n\n" : "\n"}${items[0]}`;
148
- }
149
- return `${labelLine}\n\n${joinItems(items)}`;
162
+ const parts = blocks.map((block) => splitLabelBlock(block.text));
163
+ const introductions = dedupeItems(parts.map((part) => part.introduction).filter(Boolean));
164
+ const originalItems = parts.flatMap((part) => itemsOf(part.rest));
165
+ const items = dedupeItems(originalItems);
166
+ if (blocks.length === 1 && items.length === originalItems.length)
167
+ return blocks[0].text.replace(/\s+$/, "");
168
+ const heading =
169
+ parts[0].labelLine + (introductions.length ? ` ${introductions.join("\n")}` : "");
170
+ return heading + (items.length ? `\n\n${joinItems(items)}` : "");
150
171
  }
151
172
 
152
173
  /**
@@ -245,7 +266,10 @@ export function groupChangelogText(text, { labels = null } = {}) {
245
266
  if (!range) return { text, findings: [] };
246
267
 
247
268
  const section = text.slice(range.start, range.end);
248
- const headings = [...section.matchAll(CHANGES_HEADING_RE)];
269
+ const codeLines = codeLineSet(section);
270
+ const headings = [...section.matchAll(CHANGES_HEADING_RE)].filter(
271
+ (heading) => !codeLines.has(lineColOf(section, heading.index).line),
272
+ );
249
273
  if (headings.length === 0) return { text, findings: [] };
250
274
 
251
275
  let rebuilt = section.slice(0, headings[0].index);
@@ -277,7 +277,8 @@ export function topLevelBlocks(text, codeLines, scaffoldLines = new Set()) {
277
277
  const raw = lines[i];
278
278
 
279
279
  if (codeLines.has(lineNo)) {
280
- if (current) current.parts.push(raw);
280
+ current ??= { startLine: lineNo, label: null, parts: [] };
281
+ current.parts.push(raw);
281
282
  afterBlank = raw.trim() === "";
282
283
  continue;
283
284
  }
@@ -800,7 +801,10 @@ export function lintReleaseText(text, { labels = null } = {}) {
800
801
  * of the file.
801
802
  */
802
803
  export function releaseSectionRange(text) {
803
- const matches = [...text.matchAll(/^## /gm)];
804
+ const codeLines = codeLineSet(text);
805
+ const matches = [...text.matchAll(/^## /gm)].filter(
806
+ (heading) => !codeLines.has(lineColOf(text, heading.index).line),
807
+ );
804
808
  if (matches.length === 0) return null;
805
809
  const start = matches[0].index;
806
810
  const end = matches.length > 1 ? matches[1].index : text.length;
@@ -70,6 +70,8 @@ import { loadPackConfig } from "./pack-config.mjs";
70
70
  * @param {string} [opts.stageDest] Directory the LevelDB packs are built
71
71
  * into, one subdirectory per pack. Defaults to the configured stage.
72
72
  * @param {string} [opts.packName] Restrict the run to a single pack.
73
+ * @param {import("./content-session.mjs").ContentSession} [opts.session] - Captured context shared with other operations.
74
+ * @param {boolean} [opts.reportPreparation=true] - Emit shared preparation findings unless the caller already reported them.
73
75
  * @throws {Error} If pack JSON generation reported any error. Packs compiled
74
76
  * from incomplete or empty JSON ship blank or short compendiums, and the
75
77
  * omission is invisible until a player looks for content that is not there
@@ -86,7 +88,14 @@ export async function compilePacks({
86
88
  sourcePacks = config.packDirectories,
87
89
  stageDest = config.paths.stage,
88
90
  packName,
91
+ session,
92
+ reportPreparation = true,
89
93
  } = {}) {
94
+ session?.assertContext({
95
+ config,
96
+ contentBase: config.paths.content,
97
+ skipDirectories: config.skipDirectories,
98
+ });
90
99
  const packNames = sourcePacks.filter((name) => !packName || name === packName);
91
100
 
92
101
  // A prebuilt pack's JSON is checked in rather than generated, so it is
@@ -105,7 +114,12 @@ export async function compilePacks({
105
114
  // content tree, and a package that ships only prebuilt packs has none at
106
115
  // all — so running it would fail the build over a tree nothing reads.
107
116
  if (needsGeneration) {
108
- const errors = await generatePacksJson({ only: packName, config });
117
+ const errors = await generatePacksJson({
118
+ only: packName,
119
+ config,
120
+ session,
121
+ reportPreparation,
122
+ });
109
123
  if (errors > 0) {
110
124
  throw new Error(
111
125
  `Pack JSON generation reported ${errors} error(s); refusing to ` +
@@ -60,7 +60,8 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
60
60
  * created when none is passed, and returned on the corpus either way — a note
61
61
  * the index refuses is a note the compile must still *report*, exactly as the
62
62
  * compile loop reported it when the loop was the first to see it.
63
- * @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
63
+ * @param {import("./content-session.mjs").ContentSession} [opts.session] - Captured context shared with other operations.
64
+ * @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[], syntax?: import("./content-session.mjs").ContentSyntax}>} The corpus,
64
65
  * its indexes, and the notes it could not record.
65
66
  */
66
67
  export async function buildCompileCorpus({
@@ -70,8 +71,20 @@ export async function buildCompileCorpus({
70
71
  router,
71
72
  config,
72
73
  problems,
74
+ session,
73
75
  }) {
74
76
  const resolved = config ?? loadPackConfig();
77
+ if (session) {
78
+ session.assertContext({
79
+ config: resolved,
80
+ contentBase,
81
+ assetsBase,
82
+ skipDirectories,
83
+ router,
84
+ });
85
+ if (problems && problems !== session.problems) problems.push(...session.problems);
86
+ return session.compileCorpus();
87
+ }
75
88
  const collected = problems ?? [];
76
89
  const parsed = new Map();
77
90
  const records = indexRecordsFor({