@heroiclands/package-build 23.12.4 → 23.12.5

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 (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/docs/api.md +1 -0
  3. package/docs/authoring/frontmatter.md +1 -1
  4. package/docs/authoring/links-and-markup.md +4 -4
  5. package/docs/configuration.md +3 -1
  6. package/docs/guides/book.md +12 -12
  7. package/docs/reference/format-details.md +28 -16
  8. package/docs/reference/note-types.md +1 -1
  9. package/engine/content-index.mjs +2 -39
  10. package/engine/event-fields.mjs +24 -1
  11. package/engine/event-rows.mjs +37 -2
  12. package/engine/event-views.mjs +58 -14
  13. package/engine/frontmatter-lint.mjs +11 -0
  14. package/engine/generated-sections.mjs +8 -1
  15. package/engine/holdings.mjs +8 -2
  16. package/engine/infobox-render.mjs +16 -5
  17. package/engine/infobox.mjs +23 -2
  18. package/engine/literature-works.mjs +8 -2
  19. package/engine/metadata-index.mjs +3 -0
  20. package/engine/name-sort.mjs +91 -0
  21. package/engine/note-frontmatter.mjs +1 -1
  22. package/engine/note-vocabulary.mjs +1 -1
  23. package/engine/page-lists.mjs +3 -2
  24. package/engine/pdf-index.mjs +11 -4
  25. package/engine/pdf-pages.mjs +2 -1
  26. package/engine/pdf-render.mjs +43 -13
  27. package/engine/pdf-toc.mjs +4 -3
  28. package/engine/related-pages.mjs +12 -2
  29. package/engine/site-build.mjs +3 -0
  30. package/engine/sql-tables.mjs +20 -5
  31. package/package.json +1 -1
  32. package/types/engine/content-index.d.mts +2 -31
  33. package/types/engine/event-fields.d.mts +11 -0
  34. package/types/engine/event-rows.d.mts +4 -1
  35. package/types/engine/event-views.d.mts +7 -3
  36. package/types/engine/generated-sections.d.mts +6 -0
  37. package/types/engine/holdings.d.mts +7 -1
  38. package/types/engine/infobox.d.mts +11 -0
  39. package/types/engine/literature-works.d.mts +5 -0
  40. package/types/engine/name-sort.d.mts +49 -0
  41. package/types/engine/pdf-render.d.mts +8 -0
  42. package/types/engine/pdf-toc.d.mts +1 -1
  43. package/types/engine/related-pages.d.mts +5 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.12.5
4
+
5
+ ### Patch Changes
6
+
7
+ In PDF books, the page number and section name at the foot of each page are set at the size of the body text.
8
+
9
+ In PDF books, list items sit apart from one another and carry a bullet large enough to see at book size.
10
+
11
+ In PDF books, a reference to another book that already sits in brackets joins them, reading (The Crossing, Thal. p524) rather than nesting a second pair.
12
+
13
+ Chronology sections, and the other event sections, list each event's date first, written in the setting's calendar. A chronology shows its Felt here column only when some event is felt in the place, and prints across the full page in PDF books. A captioned table can ask to span the page in a PDF book with `.full-width`. A wide table floats to where it fits, and the text runs on past its heading rather than leaving an empty page.
14
+
15
+ In PDF books, a profile box too long for the rest of a page continues on the next one between its rows, instead of running off the page over the footer.
16
+
17
+ A note's name may give a `sort` form, the way an index would write it: `sort: Black Flame, The` for _The Black Flame_, or `sort: Hilvan, Doran`. It files the note in the book index, page lists and other name-ordered lists, and it is the note's entry text in the book index; every other list still shows the full name. Authored `sql` fences can order by the new `nameSort` column of `entries`, which is the `sort` form or, without one, the full name.
18
+
19
+ Profile figures read as numbers do: 12,500 under a million, and 11 million or 1.2 million above it, on the website, in Foundry and in PDF books.
20
+
3
21
  ## 23.12.4
4
22
 
5
23
  ### Patch Changes
package/docs/api.md CHANGED
@@ -384,6 +384,7 @@ The declared infobox: what a note's summary panel holds, decided once and render
384
384
  | `assertInfoboxSet` | `assertInfoboxSet(boxes, fm, options)` | `readonly object[]`, throws | refusing a page that carries anything but the boxes its type maps to |
385
385
  | `buildInfoboxes` | `buildInfoboxes(fm, options)` | `object[]` | building every box a note carries, against a given set of maps and declarations |
386
386
  | `defineInfobox` | `defineInfobox(declaration)` | `object` | declaring one system's half of the infobox |
387
+ | `formatQuantity` | `formatQuantity(value)` | `string` | writing a number as a profile shows it: `12,500` under a million, `11 million` above |
387
388
  | `hasRenderableValue` | `hasRenderableValue(kind, value)` | `boolean` | deciding whether a built value is worth a row |
388
389
  | `hasValue` | `hasValue(value)` | `boolean` | deciding whether an authored value is worth a row |
389
390
  | `humanizeFieldName` | `humanizeFieldName(name)` | `string` | turning a declared key into the label a reader sees |
@@ -21,7 +21,7 @@ data: {}
21
21
  ---
22
22
  ```
23
23
 
24
- `name` is a map with a required, nonempty `full` display name. Every note may also use `aliases` for an ordered list of other names. The note infobox lists aliases directly beneath the full name. For a `being` with `subType: character`, `given` and `clan` are optional name components. The first alias of a character or NPC is its nickname. Omit optional keys when they do not apply; `given`, `clan`, and alias entries must be nonempty strings when present. No other keys are accepted under `name`.
24
+ `name` is a map with a required, nonempty `full` display name. Every note may also use `aliases` for an ordered list of other names, and `sort` for the form the name takes in an index, such as `Black Flame, The` or `Hilvan, Doran`. The note infobox lists aliases directly beneath the full name. `sort` files the note wherever notes are ordered by name — the book index, page lists, generated lists of related notes, and the `nameSort` column of the `entries` relation — and is the note's entry text in the book index. Every other list still displays the `full` name. A note with no `sort` files and indexes by its `full` name. For `name: {full: The Black Flame, sort: "Black Flame, The"}`, the book index prints _Black Flame, The_ under **B**, and a page list prints _The Black Flame_ at the same place.
25
25
 
26
26
  `description` is the short page summary. `tags` hold draft state and descriptive labels. The note type's classification belongs in `subType` where it is declared. `data` holds facts about the subject shared by systems. A YAML comment is the place for an author-only note: commented values remain valid YAML but do not enter the content index or generated documents.
27
27
 
@@ -197,7 +197,7 @@ The caption attaches to exactly the next supported block:
197
197
 
198
198
  The optional `type` attribute overrides inference and accepts `figure`, `table`, `poetry`, `code`, `prose`, and `example`. `type=example` uses **Example 1**, **Example 2**, and so on. It does not allow an unsupported target. A heading, list, thematic break, another caption or end of document after a caption is an error. Caption markers without separating spaces or caption text are errors too.
199
199
 
200
- Identifiers are optional and must be unique within the note. Classes are unrestricted; `.border` uses the shared border style, while other classes can be styled by the medium. Named attributes use `key=value`, with quoted values for text containing spaces. Use `#id` and `.class` rather than `id=` and `class=`; event-handler attributes are refused.
200
+ Identifiers are optional and must be unique within the note. Classes are unrestricted; `.border` uses the shared border style, while other classes can be styled by the medium. On a captioned table or SQL fence, `.full-width` sets the table across the page in a PDF book instead of in one column (`: Regional trade routes {.full-width}`); the other surfaces ignore it. Named attributes use `key=value`, with quoted values for text containing spaces. Use `#id` and `.class` rather than `id=` and `class=`; event-handler attributes are refused.
201
201
 
202
202
  Each numbered type has its own counter. Websites and Foundry number within a note; books number across their reading order. Unnumbered captions neither display nor consume a number. A map image can retain its asset-derived **Map** classification when no type is specified.
203
203
 
@@ -543,7 +543,7 @@ A column's heading is its SQL alias, so `AS "Name"` is what a reader sees. A cel
543
543
  | `market` | The market scale as three columns — `value`, `name`, `trade` — so a table can print `village` beside the number a note wrote. |
544
544
  | `<package>.notes` | The same relations — `notes`, `entries`, `events`, `names`, `words`, `affixes`, `fields`, `glossary`, `language_lineage`, `dictionary`, `terms`, `register`, `tongues`, `retired`, `scope`, `letters`, `edges`, `clusters`, `stress`, `finals`, `syllables`, `joining`, `joins`, `marks`, `skeleton`, `frames`, `read_past`, `mutations`, `cluster_groups`, `sound_changes`, `elements`, `roots`, `cognates`, `calling_names`, `older`, `negation`, `name_shapes`, `name_edges`, `name_draws`, `held_from` and `near_names` — for each package this one declares a dependency on, in a schema named for it. |
545
545
 
546
- Nested frontmatter is addressed exactly as a note writes it: `name.full`, `sohl.weight`, `data.born`. Every Address is a string — the full canonical Address, as in `list_contains(data.parents, 'thalorna-note-place-xerathia')` — whichever package published it. Every note carries a computed `state` column of `full`, `draft` or `stub`. Notes tagged `gm` are absent from the relations on public surfaces.
546
+ Nested frontmatter is addressed exactly as a note writes it: `name.full`, `sohl.weight`, `data.born`. Every Address is a string — the full canonical Address, as in `list_contains(data.parents, 'thalorna-note-place-xerathia')` — whichever package published it. Every note carries a computed `state` column of `full`, `draft` or `stub`, and a `nameSort` column holding its `name.sort` or, without one, its `name.full`, to `ORDER BY`. Notes tagged `gm` are absent from the relations on public surfaces.
547
547
 
548
548
  ### Sections the build adds
549
549
 
@@ -567,7 +567,7 @@ The vale holds three villages, of which only [[place-ford|Ford]] is walled.
567
567
 
568
568
  ### Sections the build adds about events
569
569
 
570
- A note's events are listed for you on the notes they concern: a place gets a **Chronology** of what happened there and below it, with every `depth: world` event as context, a being, affiliation or people the **Events** it took part in, an affiliation, place or people the **Accounts** it gives, and a note holding events **What followed** them. Each is a section appended after everything you wrote, with a fixed anchor — `chronology`, `events`, `accounts`, `followed` — and it appears only where it has something to list.
570
+ A note's events are listed for you on the notes they concern: a place gets a **Chronology** of what happened there and below it, with every `depth: world` event as context, a being, affiliation or people the **Events** it took part in, an affiliation, place or people the **Accounts** it gives, and a note holding events **What followed** them. Each is a section appended after everything you wrote, with a fixed anchor — `chronology`, `events`, `accounts`, `followed` — and it appears only where it has something to list. Each lists the date first, written in the setting's calendar, followed by what happened; a Chronology adds a **Felt here** column only where some event is felt in the place, and a PDF book prints it across the full page width.
571
571
 
572
572
  To replace one, write the section yourself with the same anchor. The build then adds nothing for it and leaves yours exactly as written:
573
573
 
@@ -577,7 +577,7 @@ To replace one, write the section yourself with the same anchor. The build then
577
577
  The fells keep no reckoning before the raising.
578
578
 
579
579
  ```sql
580
- SELECT summary AS "What happened", "when" AS "When"
580
+ SELECT summary AS "What happened", whenText AS "When"
581
581
  FROM events
582
582
  WHERE list_contains("where".locus, 'thalorna-note-place-ironfells')
583
583
  ORDER BY whenSort
@@ -1259,7 +1259,9 @@ Each entry of `pdf.books` is one PDF:
1259
1259
  abbreviation and the page, and the other book's index, when
1260
1260
  `pdf.indexSeeAlso` is on, lists the note as `Name: Aelw. p307`. A book
1261
1261
  with no abbreviation is named by its full title in italics, `Name (_Book
1262
- title_, p. 307)`. Each abbreviation is used once, since a reader could not
1262
+ title_, p. 307)`. A link the text closes a bracket on takes the reference
1263
+ inside that bracket after a comma, so `(see The Crossing)` prints
1264
+ `(see The Crossing, Thal. p524)` rather than nesting brackets. Each abbreviation is used once, since a reader could not
1263
1265
  tell which book to open from one shared by two.
1264
1266
  - `contents` is the tree written inline — the same `sectionName` /
1265
1267
  `contents` / `filter` / `file` shape a tree file holds under its own
@@ -174,11 +174,11 @@ the Lyrebird Inn (The Tenebrae Volume, p. 214)
174
174
 
175
175
  Which form a wikilink takes depends on where its target is printed:
176
176
 
177
- | The target is | The link prints |
178
- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
179
- | An entry of the same book, including a note the book reprints | An internal destination |
180
- | An entry of another book, and not of this one | `Name (_Book title_, p. N)`, or `Name (Aelw. pN)` with an abbreviation, with no link |
181
- | A note in no book | The web address of the note's page |
177
+ | The target is | The link prints |
178
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
179
+ | An entry of the same book, including a note the book reprints | An internal destination |
180
+ | An entry of another book, and not of this one | `Name (_Book title_, p. N)`, or `Name (Aelw. pN)` with an abbreviation, with no link; inside a bracket the text closes, `(Name, Aelw. pN)` |
181
+ | A note in no book | The web address of the note's page |
182
182
 
183
183
  The page is the page the PDF itself numbers, which is the number printed in the
184
184
  footer and used by the contents and the index, so the printed `p. N` is the
@@ -406,12 +406,12 @@ A book that prints at least one note ends with an **Index** chapter, grouped
406
406
  under letter headings. Nothing configures it: the build derives it from the
407
407
  notes the book prints.
408
408
 
409
- | Term | Source | Shown as |
410
- | ------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
411
- | A note's name | `name.full` | the page of the note's entry in **bold**, then the pages of every authored mention |
412
- | A name without its head | `name.full` | the same pages as the note's entry, filed as `Targud, Kingdom of` |
413
- | An alias | `name.aliases` | _see_ the note's name |
414
- | An event's in-world name | `data.events[].names` | _see_ the name of the note that holds the event |
409
+ | Term | Source | Shown as |
410
+ | ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------- |
411
+ | A note's name | `name.sort`, else `name.full` | the page of the note's entry in **bold**, then the pages of every authored mention |
412
+ | A name without its head | `name.full` | the same pages as the note's entry, filed as `Targud, Kingdom of` |
413
+ | An alias | `name.aliases` | _see_ the note's name |
414
+ | An event's in-world name | `data.events[].names` | _see_ the name of the note that holds the event |
415
415
 
416
416
  A name that opens with a generic word a reader would not look under, such as
417
417
  `Kingdom of Targud` or `Ritual: Blessing`, is also filed under the rest of the
@@ -460,7 +460,7 @@ Two kinds of link add nothing:
460
460
 
461
461
  Terms sort on the ASCII fold the content index derives for names (`nameAscii`),
462
462
  ignoring case, so an accented name files under its base letter and the order
463
- matches the table of contents. A term that does not begin with a letter files
463
+ matches the table of contents. A note's own entry reads as its `name.sort` when it declares one, in index form such as `Black Flame, The` or `Hilvan, Doran`, and as its `name.full` otherwise; it files, and takes its letter heading, from that form. A note that declares a `sort` is not also filed under `pdf.indexHeads`. A term that does not begin with a letter files
464
464
  first, under `#`. An alias or event name that folds to the same text as the
465
465
  note's own name is not listed again.
466
466
 
@@ -379,7 +379,13 @@ content, comment out its YAML lines with `#`. Comments remain valid YAML text
379
379
  and do not become data in the JSONL index, generated pages, or Foundry documents.
380
380
 
381
381
  `name` is a map with a required nonempty `full` string when present. It may
382
- also contain `aliases`, an ordered list of nonempty strings. A being with
382
+ also contain `aliases`, an ordered list of nonempty strings, and `sort`, a nonempty
383
+ string giving the form the name takes in an index, such as `Black Flame, The` or
384
+ `Hilvan, Doran`. `sort` files the note wherever notes are ordered by name (page
385
+ lists, generated lists of related notes, the book index, the `nameSort` column)
386
+ and is the note's entry text in the book index; every other list still displays
387
+ `full`. Without `sort`, the note files and indexes by `full`. Ordering compares
388
+ the ASCII fold of the key (`Árdavon` files beside `Ardavon`). A being with
383
389
  `subType: character` may additionally contain nonempty `given` and `clan`
384
390
  strings. Other keys under `name` are errors. For a character or NPC, the first
385
391
  alias is its nickname. A being's homes belong in `data.homes`.
@@ -2916,7 +2922,9 @@ reads `NULL` rather than failing. `data.culture` is the one field that reads as
2916
2922
  a derived value: the note's own culture, or the one it inherits. See
2917
2923
  [Culture in the index](#culture-in-the-index).
2918
2924
 
2919
- **`notes` leaves out stubs; `entries` does not.** Both carry a derived `state`
2925
+ **`notes` leaves out stubs; `entries` does not.** Both carry a derived `nameSort`
2926
+ column — the note's `name.sort` when it declares one, otherwise its `name.full`,
2927
+ unfolded — for `ORDER BY`, and a derived `state`
2920
2928
  column — `stub`, `draft` or `full` — and `notes` is exactly
2921
2929
  `SELECT * FROM entries WHERE state <> 'stub'`. So every table already written
2922
2930
  selects what it always selected, and gains `state` for free; a table that should
@@ -2957,20 +2965,21 @@ query rather than a list kept by hand:
2957
2965
 
2958
2966
  ````markdown
2959
2967
  ```sql
2960
- SELECT note AS _ref, "when" AS "When", summary AS "What happened"
2968
+ SELECT note AS _ref, whenText AS "When", summary AS "What happened"
2961
2969
  FROM events
2962
2970
  WHERE kind IN ('war', 'siege', 'battle')
2963
2971
  ORDER BY whenSort
2964
2972
  ```
2965
2973
  ````
2966
2974
 
2967
- | Column | What it holds |
2968
- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2969
- | `note` | The canonical Address of the note the event is written on. |
2970
- | `address` | The event's own Address: `note#id` where the event has an `id`, the note's Address alone where it has none. |
2971
- | `id`, `kind`, `depth`, … | Every key of an event, each its own column under its own name — `id`, `kind`, `depth`, `when`, `until`, `recurs`, `summary`, `standing`, `names`, `where`, `who`, `follows`, `accounts`, `unresolved`, `sources`, `stated`. |
2972
- | `whenSort`, `untilSort` | The date's position on the canonical axis: the canonical year, with a day as its fraction of the year where the calendar states the year's length. `ORDER BY whenSort` is date order. |
2973
- | `whenYear`, `untilYear` | The canonical year alone, for a filter such as `whenYear BETWEEN 200 AND 299`. |
2975
+ | Column | What it holds |
2976
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2977
+ | `note` | The canonical Address of the note the event is written on. |
2978
+ | `address` | The event's own Address: `note#id` where the event has an `id`, the note's Address alone where it has none. |
2979
+ | `id`, `kind`, `depth`, … | Every key of an event, each its own column under its own name — `id`, `kind`, `depth`, `when`, `until`, `recurs`, `summary`, `standing`, `names`, `where`, `who`, `follows`, `accounts`, `unresolved`, `sources`, `stated`. |
2980
+ | `whenSort`, `untilSort` | The date's position on the canonical axis: the canonical year, with a day as its fraction of the year where the calendar states the year's length. `ORDER BY whenSort` is date order. |
2981
+ | `whenText`, `untilText` | The date as a reader sees it, printed as `dateformat` prints it in the calendar the owning note names in `data.calendar`, else the one the world note names, else in the era the date itself names; `~` marks an approximate date. A date that does not resolve keeps the text its note wrote. |
2982
+ | `whenYear`, `untilYear` | The canonical year alone, for a filter such as `whenYear BETWEEN 200 AND 299`. |
2974
2983
 
2975
2984
  `when` and `until` read as the text the note wrote, `~` included. The sort
2976
2985
  keys and years are positions on the canonical axis, which counts a year `0`
@@ -3498,7 +3507,7 @@ The caption attaches to exactly the next supported block:
3498
3507
 
3499
3508
  The optional `type` attribute overrides inference and accepts `figure`, `table`, `poetry`, `code`, `prose`, and `example`. `type=example` uses **Example 1**, **Example 2**, and so on. It does not allow an unsupported target. A heading, list, thematic break, another caption or end of document after a caption is an error. Caption markers without separating spaces or caption text are errors too.
3500
3509
 
3501
- Identifiers are optional and must be unique within the note. Classes are unrestricted; `.border` uses the shared border style, while other classes can be styled by the medium. Named attributes use `key=value`, with quoted values for text containing spaces. Use `#id` and `.class` rather than `id=` and `class=`; event-handler attributes are refused.
3510
+ Identifiers are optional and must be unique within the note. Classes are unrestricted; `.border` uses the shared border style, while other classes can be styled by the medium. On a captioned table or SQL fence, `.full-width` sets the table across the page in a PDF book instead of in one column (`: Regional trade routes {.full-width}`); the other surfaces ignore it. Named attributes use `key=value`, with quoted values for text containing spaces. Use `#id` and `.class` rather than `id=` and `class=`; event-handler attributes are refused.
3502
3511
 
3503
3512
  Each numbered type has its own counter. Websites and Foundry number within a note; books number across their reading order. Unnumbered captions neither display nor consume a number. A map image can retain its asset-derived **Map** classification when no type is specified.
3504
3513
 
@@ -3763,11 +3772,14 @@ The build appends sections to the end of a note's body that list the events conc
3763
3772
 
3764
3773
  | View | Anchor | On | Rows | Columns |
3765
3774
  | ------------- | ------------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
3766
- | Chronology | `chronology` | a `place` | events whose `where.locus` names the place or a place below it in `parents`; events whose `where.reach` names one, with that entry's `how`; every `depth: world` event as context | What happened, When, Felt here |
3767
- | Events | `events` | a `being`, an `affiliation`, a `lore` note of `subType: culture` | events whose `who[].ref` names the note, with the role it played | What happened, When, Role |
3768
- | Accounts | `accounts` | an `affiliation`, a `place`, a `lore` note of `subType: culture` | each `accounts[]` entry the note gives, with the event it concerns | Event, When, What they say, Agrees |
3769
- | What followed | `followed` | a note holding events | events whose `follows[].event` names one of this note's events, with how | What followed, When, How |
3770
-
3775
+ | Chronology | `chronology` | a `place` | events whose `where.locus` names the place or a place below it in `parents`; events whose `where.reach` names one, with that entry's `how`; every `depth: world` event as context | When, What happened, Felt here |
3776
+ | Events | `events` | a `being`, an `affiliation`, a `lore` note of `subType: culture` | events whose `who[].ref` names the note, with the role it played | When, What happened, Role |
3777
+ | Accounts | `accounts` | an `affiliation`, a `place`, a `lore` note of `subType: culture` | each `accounts[]` entry the note gives, with the event it concerns | When, Event, What they say, Agrees |
3778
+ | What followed | `followed` | a note holding events | events whose `follows[].event` names one of this note's events, with how | When, What followed, How |
3779
+
3780
+ - **Every view's first column is When**, the event's date written in the setting's calendar — the `events` relation's `whenText` column. An event whose date does not resolve shows the text its note wrote.
3781
+ - **A chronology's Felt here column appears only where some row is felt in the place.** A place whose rows are all events that happened there, or world events, has the When and What happened columns alone.
3782
+ - **A chronology is always set across the full page width in a PDF book**, whatever its columns.
3771
3783
  - **The views follow everything the author wrote**, in the order of the [generated sections](#generated-sections).
3772
3784
  - **A view with no rows is not generated.** A chronology is generated only where the place has rows of its own; the world events beside them are context and make no chronology by themselves.
3773
3785
  - **An author's own section replaces a view.** A note whose body declares a prose anchor with the view's slug — `# Chronology {#chronology}`, or a block or span with that `id` — gets no generated section for it, and its own section is left exactly as written. This is the one way to turn a view off, and the way to choose a different table: write the section and its own `sql` fence.
@@ -18,7 +18,7 @@ These are the only keys a note writes at its top level, in the order the formatt
18
18
  | Key | Meaning |
19
19
  | ------------- | ----------------------------------------------------------------------------------------- |
20
20
  | `shortcode` | The note's own address segment, unique within its type. |
21
- | `name` | The display names — a required `full`, and any `aliases`. |
21
+ | `name` | The display names — a required `full`, and any `aliases` and `sort`. |
22
22
  | `type` | What the note is about, which decides its vocabulary and its document. |
23
23
  | `subType` | The type's own genre, where it declares one. |
24
24
  | `description` | The short page summary. |
@@ -108,8 +108,9 @@ import { analyzeNameSpans } from "./names.mjs";
108
108
  import fs from "node:fs";
109
109
  import path from "node:path";
110
110
 
111
- import unidecode from "unidecode";
111
+ import { asciiName } from "./name-sort.mjs";
112
112
 
113
+ export { asciiName };
113
114
  import { loadForeignIndexes, metadataFileName } from "./metadata-index.mjs";
114
115
  import { collectAssetRecords } from "./asset-index.mjs";
115
116
  import { checkForeignAssetBindings } from "./asset-bindings.mjs";
@@ -245,44 +246,6 @@ export function noteAddress(frontmatter, contentPackage) {
245
246
  };
246
247
  }
247
248
 
248
- /**
249
- * A note's display name reduced to printable 7-bit ASCII.
250
- *
251
- * Content names carry the setting's orthography — `Kûrbúl Helm`, `Hârn`,
252
- * `Kèthîra` — and nobody types them. A reader searching the index, or an editor
253
- * completing a wikilink, needs a form that matches what a keyboard produces, so
254
- * the record states one rather than leaving every consumer to invent it (and to
255
- * invent a *different* one, which is how two searches over the same data come
256
- * to disagree).
257
- *
258
- * **Transliterated, not stripped.** `unidecode` — the same table
259
- * {@link slugify} already runs, so an ASCII name and a slug can never disagree
260
- * about a character — carries a letter across rather than deleting it:
261
- * diacritics fold (`â`→`a`, `è`→`e`), ligatures expand (`æ`→`ae`, `Œ`→`OE`,
262
- * `ß`→`ss`), the runic letters spell out (`þ`→`th`, `Þ`→`Th`, `ð`→`d`), and
263
- * even a vulgar fraction becomes readable (`¾`→`3/4`). Deleting them instead
264
- * would collapse `Kûrbúl` to `Krbl`, which is worse than the original.
265
- *
266
- * Anything still outside printable ASCII after that becomes a space, and runs
267
- * of whitespace collapse — a space rather than nothing, so a character that
268
- * transliterates away cannot silently weld two words together.
269
- *
270
- * The value is emitted even when it equals the name, so a consumer matching on
271
- * it never has to branch on whether the name happened to be ASCII already.
272
- *
273
- * @param {unknown} name - The note's `name.full`.
274
- * @returns {string|null} The ASCII form, or `null` when there is no name, or
275
- * nothing printable survives.
276
- */
277
- export function asciiName(name) {
278
- if (typeof name !== "string") return null;
279
- const folded = unidecode(name)
280
- .replace(/[^\x20-\x7E]/g, " ")
281
- .replace(/\s+/g, " ")
282
- .trim();
283
- return folded === "" ? null : folded;
284
- }
285
-
286
249
  /**
287
250
  * A note's `name.aliases` reduced to printable 7-bit ASCII, in order.
288
251
  *
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { ownDocumentSystem, parseAddress, readCanonicalKey, renderAddress } from "./address.mjs";
26
- import { formatNoteDate, parseNoteDate } from "./note-dates.mjs";
26
+ import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
27
27
  import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
28
28
 
29
29
  /** The fields of an event an inline reference may print, in the order a message names them. */
@@ -161,6 +161,29 @@ export function printEventDate(written, date, dates) {
161
161
  return printable?.prose ?? printable?.text ?? date.prose ?? date.text ?? String(written);
162
162
  }
163
163
 
164
+ /**
165
+ * A date as `dateformat` prints it in a calendar, where one is named and
166
+ * prints it; otherwise as {@link printEventDate} prints it.
167
+ *
168
+ * @param {unknown} written - The date as the event writes it.
169
+ * @param {object|null} date - The resolved date.
170
+ * @param {object} [dates] - The reckoning context.
171
+ * @param {string} [calendar] - The calendar's Address or shortcode.
172
+ * @returns {string} The printed date; the authored text when nothing resolves.
173
+ */
174
+ export function printEventDateIn(written, date, dates, calendar) {
175
+ if (date?.known && calendar) {
176
+ try {
177
+ const printable = formatDateInCalendar(date, calendar, dates);
178
+ const text = printable?.prose ?? printable?.text;
179
+ if (text) return text;
180
+ } catch {
181
+ // A calendar that does not resolve leaves the date in its own era.
182
+ }
183
+ }
184
+ return printEventDate(written, date, dates);
185
+ }
186
+
164
187
  /**
165
188
  * One field of one event, as the text a reference prints, or why it cannot.
166
189
  *
@@ -25,10 +25,16 @@
25
25
  * `resolvedDates.events`, position for position with `data.events`, and the
26
26
  * sort keys are read from it rather than parsed again here.
27
27
  *
28
+ * **`whenText` and `untilText` are the dates as a reader sees them**: printed
29
+ * as the `dateformat` helper prints a date, in the calendar the owning note
30
+ * names in `data.calendar`, else the one the world note names, else in the era
31
+ * the date itself names. A date that does not resolve keeps its authored text.
32
+ *
28
33
  * @module
29
34
  */
30
35
 
31
36
  import { ownDocumentSystem, renderAddress } from "./address.mjs";
37
+ import { printEventDateIn } from "./event-fields.mjs";
32
38
  import { EVENT_ENTRY_KEYS } from "./note-event-terms.mjs";
33
39
 
34
40
  /** The columns a row carries besides the event's own keys, before them. */
@@ -37,11 +43,15 @@ const IDENTITY_COLUMNS = Object.freeze(["note", "address"]);
37
43
  /** The sort keys a row carries, after the event's own keys. */
38
44
  export const EVENT_SORT_COLUMNS = Object.freeze(["whenSort", "whenYear", "untilSort", "untilYear"]);
39
45
 
46
+ /** The printed dates a row carries, after the sort keys. */
47
+ export const EVENT_TEXT_COLUMNS = Object.freeze(["whenText", "untilText"]);
48
+
40
49
  /** Every column of the `events` relation, in order. */
41
50
  export const EVENT_COLUMNS = Object.freeze([
42
51
  ...IDENTITY_COLUMNS,
43
52
  ...EVENT_ENTRY_KEYS,
44
53
  ...EVENT_SORT_COLUMNS,
54
+ ...EVENT_TEXT_COLUMNS,
45
55
  ]);
46
56
 
47
57
  /** The keys whose authored value is a date, printed as the text it was written as. */
@@ -84,22 +94,41 @@ function axisKeys(date) {
84
94
  };
85
95
  }
86
96
 
97
+ /**
98
+ * The calendar the world note names, which displays every date whose own note
99
+ * names none.
100
+ *
101
+ * @param {Record<string, any>[]} records - Index records.
102
+ * @returns {string|undefined} The calendar's Address or shortcode.
103
+ */
104
+ function worldCalendar(records) {
105
+ for (const record of records) {
106
+ if (record?.type === "place" && record.data?.year?.days && record.data.calendar)
107
+ return String(record.data.calendar);
108
+ }
109
+ return undefined;
110
+ }
111
+
87
112
  /**
88
113
  * The `events` rows of a set of index records.
89
114
  *
90
115
  * @param {Iterable<Record<string, any>>} records - Index records, every
91
116
  * Address a string, already narrowed to the audience being published to.
117
+ * @param {object} [dates] - The reckoning context the dates are printed in.
92
118
  * @returns {object[]} One row per event entry, in record then entry order.
93
119
  */
94
- export function eventRows(records) {
120
+ export function eventRows(records, dates) {
95
121
  const rows = [];
96
- for (const record of records) {
122
+ const all = [...records];
123
+ const world = worldCalendar(all);
124
+ for (const record of all) {
97
125
  const events = record?.data?.events;
98
126
  if (!Array.isArray(events) || !events.length) continue;
99
127
  const note = noteAddressOf(record);
100
128
  if (!note) continue;
101
129
  const resolved =
102
130
  Array.isArray(record.resolvedDates?.events) ? record.resolvedDates.events : [];
131
+ const calendar = record.data?.calendar ? String(record.data.calendar) : world;
103
132
  events.forEach((entry, position) => {
104
133
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
105
134
  const row = {
@@ -119,6 +148,12 @@ export function eventRows(records) {
119
148
  row.whenYear = when.year;
120
149
  row.untilSort = until.sort;
121
150
  row.untilYear = until.year;
151
+ for (const key of DATE_KEYS) {
152
+ row[`${key}Text`] =
153
+ row[key] === null ?
154
+ null
155
+ : printEventDateIn(row[key], resolved[position]?.[key], dates, calendar);
156
+ }
122
157
  rows.push(row);
123
158
  });
124
159
  }
@@ -65,8 +65,9 @@ const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
65
65
  *
66
66
  * @type {ReadonlyArray<Readonly<{slug: string, heading: string,
67
67
  * applies: (record: object) => boolean,
68
- * query: (self: string[], shape: CorpusShape) => {fence: string, own?: string}|null,
69
- * owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
68
+ * query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean}) => {fence: string, own?: string}|null,
69
+ * owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>,
70
+ * felt?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
70
71
  */
71
72
  export const EVENT_VIEWS = Object.freeze([
72
73
  Object.freeze({
@@ -75,6 +76,7 @@ export const EVENT_VIEWS = Object.freeze([
75
76
  applies: (record) => record.type === "place",
76
77
  query: chronologyQuery,
77
78
  owners: chronologyOwners,
79
+ felt: chronologyFeltOwners,
78
80
  }),
79
81
  Object.freeze({
80
82
  slug: "events",
@@ -91,8 +93,8 @@ export const EVENT_VIEWS = Object.freeze([
91
93
  return {
92
94
  fence: [
93
95
  "SELECT e.note AS _ref,",
96
+ ' e.whenText AS "When",',
94
97
  ' e.summary AS "What happened",',
95
- ' e."when" AS "When",',
96
98
  ` array_to_string(list_transform(${named}, w -> w.role), ', ') AS "Role"`,
97
99
  "FROM events e",
98
100
  `WHERE len(${named}) > 0`,
@@ -120,8 +122,8 @@ export const EVENT_VIEWS = Object.freeze([
120
122
  return {
121
123
  fence: [
122
124
  "SELECT e.note AS _ref,",
125
+ ' e.whenText AS "When",',
123
126
  ' e.summary AS "Event",',
124
- ' e."when" AS "When",',
125
127
  ' e.account.says AS "What they say",',
126
128
  ' e.account.agrees AS "Agrees"',
127
129
  "FROM (SELECT *, unnest(accounts) AS account FROM events) e",
@@ -151,8 +153,8 @@ export const EVENT_VIEWS = Object.freeze([
151
153
  return {
152
154
  fence: [
153
155
  "SELECT e.note AS _ref,",
156
+ ' e.whenText AS "When",',
154
157
  ' e.summary AS "What followed",',
155
- ' e."when" AS "When",',
156
158
  ` array_to_string(list_transform(${edges}, f -> f.how), ', ') AS "How"`,
157
159
  "FROM events e",
158
160
  `WHERE len(${edges}) > 0`,
@@ -237,6 +239,36 @@ function chronologyOwnRows(shape) {
237
239
  * @returns {Promise<Set<string>>} The roots with at least one own row.
238
240
  */
239
241
  async function chronologyOwners(db, selves, shape) {
242
+ return chronologyRoots(db, selves, shape, chronologyOwnRows(shape));
243
+ }
244
+
245
+ /**
246
+ * Which places have a row felt there, asked of every place in one query. A
247
+ * chronology prints its `Felt here` column only for these.
248
+ *
249
+ * @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
250
+ * @param {string[][]} selves - Each place's Address, in each form.
251
+ * @param {CorpusShape} shape - What the corpus states.
252
+ * @returns {Promise<Set<string>>} The roots with at least one felt row.
253
+ */
254
+ async function chronologyFeltOwners(db, selves, shape) {
255
+ if (!shape.reach) return new Set();
256
+ return chronologyRoots(db, selves, shape, [
257
+ `len(list_filter(${chronologyFelt()}, r -> coalesce(r.how, '') <> '')) > 0`,
258
+ ]);
259
+ }
260
+
261
+ /**
262
+ * The roots among `selves` for which an event satisfies one of `conditions`,
263
+ * over the places at or below each root.
264
+ *
265
+ * @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
266
+ * @param {string[][]} selves - Each place's Address, in each form.
267
+ * @param {CorpusShape} shape - What the corpus states.
268
+ * @param {string[]} conditions - Alternatives over `e` and `here.places`.
269
+ * @returns {Promise<Set<string>>} The roots with at least one such event.
270
+ */
271
+ async function chronologyRoots(db, selves, shape, conditions) {
240
272
  const values = [...new Set(selves.map((self) => self[0]))]
241
273
  .map((root) => `(${literal(root)})`)
242
274
  .join(", ");
@@ -258,7 +290,7 @@ async function chronologyOwners(db, selves, shape) {
258
290
  "here AS (SELECT root, list(place) AS places FROM below GROUP BY root)",
259
291
  "SELECT DISTINCT here.root AS root",
260
292
  "FROM events e, here",
261
- `WHERE ${chronologyOwnRows(shape).join(" OR ")}`,
293
+ `WHERE ${conditions.join(" OR ")}`,
262
294
  ].join("\n"),
263
295
  );
264
296
  return new Set(rows.map((row) => String(row.root)));
@@ -275,10 +307,12 @@ async function chronologyOwners(db, selves, shape) {
275
307
  *
276
308
  * @param {string[]} self - The place's Address, in each form.
277
309
  * @param {CorpusShape} shape - What the corpus states.
310
+ * @param {{felt?: boolean}} [columns] - `felt: false` when no row of this
311
+ * place is felt there, which leaves the `Felt here` column out.
278
312
  * @returns {{fence: string, own: string}|null} The query, and the one that
279
313
  * counts the place's own rows.
280
314
  */
281
- function chronologyQuery(self, shape) {
315
+ function chronologyQuery(self, shape, columns = {}) {
282
316
  if (!shape.locus && !shape.reach) return null;
283
317
  const below =
284
318
  shape.parents ?
@@ -298,11 +332,13 @@ function chronologyQuery(self, shape) {
298
332
  ...below,
299
333
  "here AS (SELECT list(place) AS places FROM below)",
300
334
  "SELECT e.note AS _ref,",
301
- ' e.summary AS "What happened",',
302
- ' e."when" AS "When",',
303
- shape.reach ?
304
- ` array_to_string(list_transform(${felt}, r -> r.how), '; ') AS "Felt here"`
305
- : ' NULL AS "Felt here"',
335
+ ' e.whenText AS "When",',
336
+ shape.reach && columns.felt !== false ?
337
+ ' e.summary AS "What happened",'
338
+ : ' e.summary AS "What happened"',
339
+ ...(shape.reach && columns.felt !== false ?
340
+ [` array_to_string(list_transform(${felt}, r -> r.how), '; ') AS "Felt here"`]
341
+ : []),
306
342
  "FROM events e, here",
307
343
  ];
308
344
  const context = shape.depth ? ["e.depth = 'world'"] : [];
@@ -394,6 +430,7 @@ export async function eventViewSections(db, records, fileOf, only) {
394
430
  // A view that can say which notes have rows in one query is asked once, over
395
431
  // every note it will be given, and the per-note count is skipped.
396
432
  const owned = new Map();
433
+ const felt = new Map();
397
434
  for (const view of EVENT_VIEWS) {
398
435
  if (!view.owners) continue;
399
436
  const selves = [];
@@ -404,7 +441,10 @@ export async function eventViewSections(db, records, fileOf, only) {
404
441
  const self = selfForms(record);
405
442
  if (view.query(self, shape)) selves.push(self);
406
443
  }
407
- if (selves.length) owned.set(view.slug, await view.owners(db, selves, shape));
444
+ if (selves.length) {
445
+ owned.set(view.slug, await view.owners(db, selves, shape));
446
+ if (view.felt) felt.set(view.slug, await view.felt(db, selves, shape));
447
+ }
408
448
  }
409
449
  for (const record of all) {
410
450
  if (!record?.address || record.documents || !record.shortcode) continue;
@@ -413,7 +453,11 @@ export async function eventViewSections(db, records, fileOf, only) {
413
453
  const sections = new Map();
414
454
  for (const view of EVENT_VIEWS) {
415
455
  if (!view.applies(record) || authoredSection(record, view.slug)) continue;
416
- const query = view.query(self, shape);
456
+ const query = view.query(
457
+ self,
458
+ shape,
459
+ felt.has(view.slug) ? { felt: felt.get(view.slug).has(self[0]) } : undefined,
460
+ );
417
461
  if (!query) continue;
418
462
  const has =
419
463
  owned.has(view.slug) ?
@@ -1285,6 +1285,17 @@ export function lintNote(
1285
1285
  });
1286
1286
  }
1287
1287
  }
1288
+ if (
1289
+ Object.hasOwn(name, "sort") &&
1290
+ (typeof name.sort !== "string" || !name.sort.trim())
1291
+ ) {
1292
+ findings.push({
1293
+ file: note.file,
1294
+ ...nameAt(["sort"]),
1295
+ severity: "error",
1296
+ message: "`name.sort` must be a nonempty string",
1297
+ });
1298
+ }
1288
1299
  for (const key of CHARACTER_NAME_KEYS) {
1289
1300
  if (!Object.hasOwn(name, key) || !allowed.has(key)) continue;
1290
1301
  if (typeof name[key] === "string" && name[key].trim()) continue;