@heroiclands/package-build 23.7.0 → 23.8.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 (71) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +20 -6
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +87 -43
  7. package/docs/authoring/frontmatter.md +30 -1
  8. package/docs/authoring/links-and-markup.md +229 -36
  9. package/docs/commands.md +12 -2
  10. package/docs/content-format.md +24 -1
  11. package/docs/design.md +31 -0
  12. package/docs/guides/e2e.md +37 -1
  13. package/docs/project-setup.md +30 -15
  14. package/docs/reference/format-details.md +418 -220
  15. package/docs/reference/note-types.md +90 -31
  16. package/e2e.mjs +89 -2
  17. package/engine/base-compiler.mjs +11 -18
  18. package/engine/compile-corpus.mjs +2 -1
  19. package/engine/content-alerts.mjs +4 -2
  20. package/engine/content-blocks.mjs +18 -36
  21. package/engine/content-format.yaml +3 -0
  22. package/engine/content-index.mjs +35 -22
  23. package/engine/content-poetry.mjs +9 -0
  24. package/engine/diagnostics.mjs +24 -11
  25. package/engine/frontmatter-lint.mjs +1 -1
  26. package/engine/generate.mjs +12 -22
  27. package/engine/helpers.mjs +8 -6
  28. package/engine/infobox.mjs +3 -0
  29. package/engine/journals.mjs +11 -1
  30. package/engine/language-data.mjs +236 -1
  31. package/engine/language-rows.mjs +235 -3
  32. package/engine/language-terms.mjs +524 -9
  33. package/engine/language-words.mjs +75 -0
  34. package/engine/markdownlint-run.mjs +12 -2
  35. package/engine/markup-validation.mjs +34 -0
  36. package/engine/note-addresses.mjs +1 -0
  37. package/engine/note-claims.mjs +19 -10
  38. package/engine/note-package.mjs +3 -5
  39. package/engine/note-vocabulary.mjs +29 -1
  40. package/engine/pdf-build.mjs +8 -1
  41. package/engine/prose-lint.mjs +12 -6
  42. package/engine/related-pages.mjs +22 -3
  43. package/engine/see-also.mjs +150 -0
  44. package/engine/site-build.mjs +5 -1
  45. package/engine/site-preview.mjs +4 -2
  46. package/engine/sql-tables.mjs +37 -12
  47. package/engine/standings.mjs +14 -12
  48. package/engine/word-rows.mjs +8 -3
  49. package/engine/word-terms.mjs +60 -2
  50. package/hugo-theme/layouts/partials/related.html +25 -8
  51. package/package.json +2 -1
  52. package/types/container.d.mts +4 -3
  53. package/types/e2e.d.mts +28 -0
  54. package/types/engine/base-compiler.d.mts +2 -2
  55. package/types/engine/compile-corpus.d.mts +2 -1
  56. package/types/engine/content-index.d.mts +20 -7
  57. package/types/engine/diagnostics.d.mts +7 -6
  58. package/types/engine/helpers.d.mts +7 -4
  59. package/types/engine/language-rows.d.mts +89 -3
  60. package/types/engine/language-terms.d.mts +106 -4
  61. package/types/engine/markdownlint-run.d.mts +3 -1
  62. package/types/engine/markup-validation.d.mts +14 -0
  63. package/types/engine/note-addresses.d.mts +10 -0
  64. package/types/engine/note-claims.d.mts +7 -1
  65. package/types/engine/note-package.d.mts +3 -1
  66. package/types/engine/note-vocabulary.d.mts +1 -0
  67. package/types/engine/related-pages.d.mts +10 -1
  68. package/types/engine/see-also.d.mts +55 -0
  69. package/types/engine/sql-tables.d.mts +7 -3
  70. package/types/engine/word-rows.d.mts +2 -1
  71. package/types/engine/word-terms.d.mts +11 -0
@@ -178,6 +178,20 @@ The harbor master is working for the smugglers.
178
178
  :::
179
179
  ```
180
180
 
181
+ Leave a blank line between a blockquote or alert and the closing `:::`. Without it, Markdown treats the fence as part of the quote and leaves the enclosing block unclosed:
182
+
183
+ ```markdown
184
+ :::secret {title="For the GM"}
185
+ Hidden information.
186
+
187
+ > [!TIP]
188
+ > Advice for the GM.
189
+
190
+ :::
191
+ ```
192
+
193
+ `lint`, `markdown` and `format` refuse malformed block and alert syntax, including unclosed divs and unknown alert types. `format` leaves a malformed file unchanged so you can correct the reported opening line before formatting it.
194
+
181
195
  A secret block can contain alerts and general divs. It cannot contain another secret block. A heading that starts its own journal page cannot split a secret block or general div; use a lower heading without an anchor within the section.
182
196
 
183
197
  Foundry renders a secret section; the website renders an expandable spoiler, and the book renders a labeled box. An untitled secret takes its normal default title, and a Foundry secret without an id receives an id derived from its body. The presentation does not make published HTML private: deliberately inspecting generated source can reveal its content. Use YAML comments for information that must never be published.
@@ -309,41 +323,50 @@ A column's heading is its SQL alias, so `AS "Name"` is what a reader sees. A cel
309
323
 
310
324
  ### What a query can read
311
325
 
312
- | Relation | What it holds |
313
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
314
- | `notes` | Every note except a stub. This is the one to use unless a stub is wanted. |
315
- | `entries` | Every note, stubs included. |
316
- | `events` | One row per event a `lore`, `place` or `affiliation` note writes in `data.events`, with `whenSort` to order them by date. |
317
- | `words` | One row per word a language note writes in `data.words`, with that note as its `language`, its `position` and what it is built from. |
318
- | `affixes` | One row per affix a language note writes in `data.affixes`, with that note as its `language`, its `position`, and each key the affix states: `form`, `gloss`, `attaches`, `replaces`, `lengthens`, `front`, `examples` and `tones`. |
319
- | `fields` | One row per field a language note writes in `data.fields`, with that note as its `language`, its `position`, and the field's `name` and what it `holds`. |
320
- | `glossary` | One row per entry a culture or an affiliation writes in `data.glossary`, with the `note` it belongs to and the `language` it resolves to. |
321
- | `language_lineage` | One row per language note and each language it inherits words from through `data.family`, itself included at `depth` 0. Join it to `words` for a language's words with inherited ones. |
322
- | `dictionary` | One row per word per language it is listed under, inherited words included, with its `depth`. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
323
- | `terms` | One row per glossary entry, rank or office per note that owns it, with the `cultures` it reaches. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
324
- | `register` | One row per name a language note writes in `data.register`, with its `tongue`, `kind` and `why`, its pieces as `built_text`, and the `notes` carrying the name, derived from the index. |
325
- | `tongues` | One row per tag a language note writes in `data.tongues`: `tag`, `meaning` and `tongue_language`. |
326
- | `retired` | One row per form a language note writes in `data.retired`: `form`, `instead` as a list, and `why`. |
327
- | `scope` | One row per value of a language note's `data.scope`: the `kind` it is under and the `value`. |
328
- | `letters` | One row per entry of a language note's `data.sounds.letters`: `letters` as a list, `kind`, `like` and `tongue`. |
329
- | `edges` | One row per entry of a language note's `data.sounds.edges`: the `edge` and its `sounds` as a list. |
330
- | `clusters` | One row per language note stating `data.sounds.clusters`, with its `max`. |
331
- | `stress` | One row per language note stating `data.sounds.stress`, with its `stress`. |
332
- | `finals` | One row per entry of a language note's `data.sounds.finals`: `final` and `like`. |
333
- | `syllables` | One row per language note stating `data.sounds.syllables`: each rule a column, the harmony as `harmony_back`, `harmony_front`, `harmony_neutral` and `harmony_neutralAs`. |
334
- | `joining` | One row per entry of a language note's `data.sounds.joining`: `first`, `second`, `written` and `example`. |
335
- | `joins` | One row per entry of a language note's `data.sounds.joins`: `meeting`, `rule`, `letters` and `"where"`. |
336
- | `marks` | One row per entry of a language note's `data.sounds.marks`: `mark`, `letters`, `kind` and `"limit"`. |
337
- | `skeleton` | One row per entry of a language note's `data.sounds.skeleton`: `second` and `between`. |
338
- | `frames` | One row per frame of a language note's `data.sounds.frames`: `frame`, `shape`, `class`, `makes` and `examples` as `{ root, form }`. |
339
- | `read_past` | One row per entry of a language note's `data.sounds.readPast`: `written`, `readAs` and `what`. |
340
- | `sound_changes` | One row per step of each set in a language note's `data.soundChanges`: the set's `position`, `name`, `from_language` and `to_language`, then `step_position`, `step`, `change` and `"where"`. |
341
- | `elements` | One row per element of a language note's `data.elements`: `form`, `variants`, `element_position`, `sense` and `examples`. |
342
- | `roots` | One row per root of a language note's `data.roots`: `root`, `sense` and `field`. |
343
- | `cognates` | One row per form of each set in a language note's `data.cognates`: the set's `position`, `gloss` and `proto`, the `form_language` and the `form`. |
344
- | `calling_names` | One row per closing of a language note's `data.callingNames`: `form`, `doubled`, `usual` and the language's `min_syllables`. |
345
- | `market` | The market scale as three columns — `value`, `name`, `trade` — so a table can print `village` beside the number a note wrote. |
346
- | `<package>.notes` | The same relations — `notes`, `entries`, `events`, `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`, `sound_changes`, `elements`, `roots`, `cognates` and `calling_names` — for each package this one declares a dependency on, in a schema named for it. |
326
+ | Relation | What it holds |
327
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
328
+ | `notes` | Every note except a stub. This is the one to use unless a stub is wanted. |
329
+ | `entries` | Every note, stubs included. |
330
+ | `events` | One row per event a `lore`, `place` or `affiliation` note writes in `data.events`, with `whenSort` to order them by date. |
331
+ | `words` | One row per word a language note writes in `data.words`, with that note as its `language`, its `position` and what it is built from. |
332
+ | `affixes` | One row per affix a language note writes in `data.affixes`, with that note as its `language`, its `position`, and each key the affix states: `form` (`NULL` on a zero affix), `zero`, `kind`, `given`, `"where"`, `gloss`, `attaches`, `replaces`, `lengthens`, `front`, `examples`, `barredAfter` and `tones`, then `written`, the form as a table prints it, `∅` for a zero affix. |
333
+ | `fields` | One row per field a language note writes in `data.fields`, with that note as its `language`, its `position`, and the field's `name` and what it `holds`. |
334
+ | `glossary` | One row per entry a culture or an affiliation writes in `data.glossary`, with the `note` it belongs to and the `language` it resolves to. |
335
+ | `language_lineage` | One row per language note and each language it inherits words from through `data.family`, itself included at `depth` 0. Join it to `words` for a language's words with inherited ones. |
336
+ | `dictionary` | One row per word per language it is listed under, inherited words included, with its `depth`. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
337
+ | `terms` | One row per glossary entry, rank or office per note that owns it, with the `cultures` it reaches. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
338
+ | `register` | One row per name a language note writes in `data.register`, with its `tongue`, `kind`, `gloss` and `why`, its pieces as `built_text`, and the `notes` carrying the name, derived from the index. |
339
+ | `tongues` | One row per tag a language note writes in `data.tongues`: `tag`, `meaning` and `tongue_language`. |
340
+ | `retired` | One row per form a language note writes in `data.retired`: `form`, `instead` as a list, and `why`. |
341
+ | `scope` | One row per value of a language note's `data.scope`: the `kind` it is under and the `value`. |
342
+ | `letters` | One row per entry of a language note's `data.sounds.letters`: `letters` as a list, `kind`, `like`, `place` and `tongue`. |
343
+ | `edges` | One row per entry of a language note's `data.sounds.edges`: the `edge` and its `sounds` as a list. |
344
+ | `clusters` | One row per language note stating `data.sounds.clusters`, with its `max`. |
345
+ | `cluster_groups` | One row per group of a language note's `data.sounds.clusters.groups`: `what`, `clusters` as a list, and `use`. |
346
+ | `stress` | One row per language note stating `data.sounds.stress`, with its `stress`. |
347
+ | `finals` | One row per entry of a language note's `data.sounds.finals`: `final` and `like`. |
348
+ | `syllables` | One row per language note stating `data.sounds.syllables`: each rule a column, the harmony as `harmony_back`, `harmony_front`, `harmony_neutral`, `harmony_neutralAs` and `harmony_partners`, a list of `{ vowel, partner }`. |
349
+ | `joining` | One row per entry of a language note's `data.sounds.joining`: `first`, `second`, `written` and `example`. |
350
+ | `joins` | One row per entry of a language note's `data.sounds.joins`: `meeting`, `rule`, `letters` and `"where"`. |
351
+ | `marks` | One row per entry of a language note's `data.sounds.marks`: `mark`, `letters`, `kind` and `"limit"`. |
352
+ | `skeleton` | One row per entry of a language note's `data.sounds.skeleton`: `second` and `between`. |
353
+ | `frames` | One row per frame of a language note's `data.sounds.frames`: `frame`, `shape`, `class`, `makes` and `examples` as `{ root, form }`. |
354
+ | `read_past` | One row per entry of a language note's `data.sounds.readPast`: `written`, `readAs` and `what`. |
355
+ | `mutations` | One row per entry of a language note's `data.sounds.mutations`: `mutation`, `sound`, `becomes` and `example`. |
356
+ | `sound_changes` | One row per step of each set in a language note's `data.soundChanges`: the set's `position`, `name`, `from_language`, `to_language` and `takes`, then `step_position`, `step`, `change` and `"where"`. |
357
+ | `elements` | One row per element of a language note's `data.elements`: `form`, `variants`, `element_position`, `kind`, `sense`, `examples`, `word` and `genitive`. |
358
+ | `roots` | One row per root of a language note's `data.roots`: `root`, `sense` and `field`. |
359
+ | `cognates` | One row per form of each set in a language note's `data.cognates`: the set's `position`, `gloss` and `proto`, the `form_language` and the `form`. |
360
+ | `calling_names` | One row per closing of a language note's `data.callingNames`: `form`, `doubled`, `usual` and the language's `min_syllables`. |
361
+ | `older` | One row per form of a language note's `data.older`: `form`, `gloss` and `layer`. |
362
+ | `negation` | One row per language note stating `data.negation`: its `particle` and `mutation`. |
363
+ | `name_shapes` | One row per entry of a language note's `data.naming.shapes`: `of`, `syllables_min`, `syllables_max`, `per_vowel_min` and `per_vowel_max`. |
364
+ | `name_edges` | One row per entry of a language note's `data.naming.edges`: `edge`, `sounds` as a list, `vowel` and `of`. |
365
+ | `name_draws` | One row per entry of a language note's `data.naming.draws`: `kind` and `elements` as a list. |
366
+ | `held_from` | One row per entry of a language note's `data.naming.heldFrom`: `source`, `takes`, `clip`, and `genitive` and `elements` as lists. |
367
+ | `near_names` | One row per worked example of a language note's `data.naming.nearName`: `near`, `given` and the language's `min_syllables`. |
368
+ | `market` | The market scale as three columns — `value`, `name`, `trade` — so a table can print `village` beside the number a note wrote. |
369
+ | `<package>.notes` | The same relations — `notes`, `entries`, `events`, `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. |
347
370
 
348
371
  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.
349
372
 
@@ -460,9 +483,41 @@ ORDER BY position
460
483
 
461
484
  `ORDER BY position` keeps the order the note states them in. The same shape serves `fields`, with `name` and `holds`.
462
485
 
486
+ An affix's `kind` picks one table out of the list. A table of case suffixes in a language with vowel harmony sets the back form beside its `front` form:
487
+
488
+ ````markdown
489
+ ```sql
490
+ SELECT gloss AS "Case", '_' || form || '_' AS "Back", '_' || coalesce(front, form) || '_' AS "Front",
491
+ array_to_string(list_transform(examples, x -> '_' || x || '_'), ', ') AS "Example"
492
+ FROM affixes
493
+ WHERE language = 'thalorna-note-skill-sinalelng' AND kind = 'case'
494
+ ORDER BY position
495
+ ```
496
+ ````
497
+
498
+ A given name's endings say who each is `given` to, and an affix written two ways keeps each form on its own row, the conditioned one saying `"where"` it stands; `string_agg` sets the two back on one line:
499
+
500
+ ````markdown
501
+ ```sql
502
+ SELECT given AS "Given to", string_agg('`' || form || '`', ', ' ORDER BY position) AS "Ending"
503
+ FROM affixes
504
+ WHERE language = 'thalorna-note-skill-nordmalng' AND kind = 'name'
505
+ GROUP BY given ORDER BY min(position)
506
+ ```
507
+
508
+ ```sql
509
+ SELECT gloss AS "Prefix",
510
+ coalesce(max('`' || form || '`') FILTER (WHERE "where" = 'before a stop'), max('`' || form || '`')) AS "Before a stop",
511
+ max('`' || form || '`') FILTER (WHERE "where" IS NULL) AS "Otherwise", kind AS "Kind"
512
+ FROM affixes
513
+ WHERE language = 'thalorna-note-skill-khazarlng' AND kind IN ('aspect', 'voice')
514
+ GROUP BY gloss, kind ORDER BY min(position)
515
+ ```
516
+ ````
517
+
463
518
  ### A language's register, sounds and history
464
519
 
465
- A language note states its register, tongues, the forms it does not write, scope, letters, sound rules, sound changes, name elements, roots and cognates in frontmatter, and each is a relation, so the page sets every table back with a fence. Each row carries the note as its `language`, and a list's entries their `position`, so `WHERE language = '…' ORDER BY position` keeps the order the note states. A list column, such as `sounds` or `instead`, prints its entries joined by commas; `array_to_string` and `list_transform` set them otherwise.
520
+ A language note states its register, tongues, the forms it does not write, scope, letters, sound rules, mutations, sound changes, name elements, roots, cognates, the forms older than its rules and its negation in frontmatter, and each is a relation, so the page sets every table back with a fence. Each row carries the note as its `language`, and a list's entries their `position`, so `WHERE language = '…' ORDER BY position` keeps the order the note states. A list column, such as `sounds` or `instead`, prints its entries joined by commas; `array_to_string` and `list_transform` set them otherwise.
466
521
 
467
522
  The tags the register uses:
468
523
 
@@ -529,6 +584,57 @@ FROM clusters WHERE language = 'thalorna-note-skill-dunharlng'
529
584
  ```
530
585
  ````
531
586
 
587
+ A letter that states its `place` sets a grid of consonants back by manner and place, one `kind` to a row:
588
+
589
+ ````markdown
590
+ ```sql
591
+ SELECT '**' || kind || '**' AS " ",
592
+ coalesce(string_agg(array_to_string(list_transform(letters, x -> '_' || x || '_'), ', '), ', ') FILTER (WHERE place = 'lips'), '') AS "lips",
593
+ coalesce(string_agg(array_to_string(list_transform(letters, x -> '_' || x || '_'), ', '), ', ') FILTER (WHERE place = 'tongue-tip'), '') AS "tongue-tip",
594
+ coalesce(string_agg(array_to_string(list_transform(letters, x -> '_' || x || '_'), ', '), ', ') FILTER (WHERE place = 'back'), '') AS "back"
595
+ FROM letters WHERE language = 'thalorna-note-skill-sinalelng' AND place IS NOT NULL
596
+ GROUP BY kind ORDER BY min(position)
597
+ ```
598
+ ````
599
+
600
+ The groups of clusters that stand inside a word, and the diphthongs by harmony set. A diphthong is a `vowelPairs` entry, and the set it belongs to is the harmony list holding it:
601
+
602
+ ````markdown
603
+ ```sql
604
+ SELECT what AS "Kind", array_to_string(list_transform(clusters, x -> '_' || x || '_'), ', ') AS "Clusters", use AS "Use"
605
+ FROM cluster_groups WHERE language = 'thalorna-note-skill-sinalelng' ORDER BY position
606
+ ```
607
+
608
+ ```sql
609
+ SELECT 'back' AS "Set", array_to_string(list_transform(list_filter(vowelPairs, p -> list_contains(harmony_back, p)), x -> '_' || x || '_'), ', ') AS "Diphthongs"
610
+ FROM syllables WHERE language = 'thalorna-note-skill-sinalelng'
611
+ UNION ALL
612
+ SELECT 'neutral', array_to_string(list_transform(list_filter(vowelPairs, p -> list_contains(harmony_neutral, p)), x -> '_' || x || '_'), ', ')
613
+ FROM syllables WHERE language = 'thalorna-note-skill-sinalelng'
614
+ UNION ALL
615
+ SELECT 'front', array_to_string(list_transform(list_filter(vowelPairs, p -> list_contains(harmony_front, p)), x -> '_' || x || '_'), ', ')
616
+ FROM syllables WHERE language = 'thalorna-note-skill-sinalelng'
617
+ ```
618
+ ````
619
+
620
+ The vowel partners an affix's front form takes. `harmony_partners` is a list of `{ vowel, partner }`, so `unnest` sets it out one row to a vowel:
621
+
622
+ ````markdown
623
+ ```sql
624
+ SELECT '_' || p.vowel || '_' AS "Vowel", '_' || p.partner || '_' AS "In a suffix's front form"
625
+ FROM (SELECT unnest(harmony_partners) AS p FROM syllables WHERE language = 'thalorna-note-skill-sinalelng')
626
+ ```
627
+ ````
628
+
629
+ A mutation, one table per name. `mutation` picks the rows:
630
+
631
+ ````markdown
632
+ ```sql
633
+ SELECT '_' || sound || '_' AS "Radical", '_' || becomes || '_' AS "Worn", coalesce(example, '—') AS "Example"
634
+ FROM mutations WHERE language = 'thalorna-note-skill-sinalelng' AND mutation = 'wearing' ORDER BY position
635
+ ```
636
+ ````
637
+
532
638
  The marks and spellings a word is read through:
533
639
 
534
640
  ````markdown
@@ -641,6 +747,93 @@ GROUP BY position, gloss ORDER BY position
641
747
  ```
642
748
  ````
643
749
 
750
+ An element's `kind` keeps one set of elements apart from another, and a place's generic gives its `genitive`:
751
+
752
+ ````markdown
753
+ ```sql
754
+ SELECT '`' || form || '`' AS "Generic", sense AS "What it names", coalesce('`' || genitive || '`', '—') AS "In the genitive"
755
+ FROM elements WHERE language = 'thalorna-note-skill-nordmalng' AND kind = 'place' ORDER BY position
756
+ ```
757
+ ````
758
+
759
+ A daughter's inheriting set says what it `takes`, so a parent's table of its daughters is read from `language_lineage`, which follows each daughter's `data.family`, and from that set. An empty label after the `|` prints the daughter's own name:
760
+
761
+ ````markdown
762
+ ```sql
763
+ SELECT '[[' || l.language || '|]]' AS "Daughter", l.depth AS "Generations",
764
+ coalesce(max(s.takes), 'nominative') AS "Takes"
765
+ FROM language_lineage l LEFT JOIN sound_changes s ON s.language = l.language AND s.takes IS NOT NULL
766
+ WHERE l.ancestor = 'thalorna-note-skill-vylarilng' AND l.depth > 0
767
+ GROUP BY l.language, l.depth ORDER BY l.depth, l.language
768
+ ```
769
+ ````
770
+
771
+ A zero affix has no `form`; `written` is the form as a table prints it, `∅` for a zero affix. Sinalë's case table, nominative first:
772
+
773
+ ````markdown
774
+ ```sql
775
+ SELECT gloss AS "Case", '_' || written || '_' AS "Back", '_' || coalesce(front, written) || '_' AS "Front"
776
+ FROM affixes WHERE language = 'thalorna-note-skill-sinalelng' AND kind = 'case' ORDER BY position
777
+ ```
778
+ ````
779
+
780
+ A language's names keep shapes and edges its words do not. A name's openings split by length, and `vowel` adds the row for a name that opens on a vowel; the shapes set a band table back, one row per class of name:
781
+
782
+ ````markdown
783
+ ```sql
784
+ SELECT consonants AS "consonants", openings AS "the openings" FROM (
785
+ SELECT CASE WHEN edge = 'openConsonant' THEN 'one sound' WHEN max(length(s)) = 2 THEN 'two' ELSE 'three' END AS consonants,
786
+ string_agg('`' || s || '`', ' ') AS openings, min(position) * 10 + max(length(s)) AS ord
787
+ FROM (SELECT edge, position, unnest(sounds) AS s FROM name_edges
788
+ WHERE language = 'thalorna-note-skill-nordmalng' AND edge IN ('openConsonant', 'openCluster') AND of = 'name')
789
+ GROUP BY edge, CASE WHEN edge = 'openCluster' THEN length(s) END
790
+ UNION ALL
791
+ SELECT 'none', 'a vowel stands first', 1000 FROM name_edges
792
+ WHERE language = 'thalorna-note-skill-nordmalng' AND edge = 'openConsonant' AND of = 'name' AND vowel
793
+ ) ORDER BY ord
794
+ ```
795
+
796
+ ```sql
797
+ SELECT of AS "class of name", '`' || printf('%.1f', per_vowel_min) || '` to `' || printf('%.1f', per_vowel_max) || '`' AS "consonants per vowel",
798
+ coalesce('`' || syllables_min || '` to `' || syllables_max || '`', '—') AS "syllables"
799
+ FROM name_shapes WHERE language = 'thalorna-note-skill-nordmalng' ORDER BY position
800
+ ```
801
+ ````
802
+
803
+ The sources a place name is held from, and the near names:
804
+
805
+ ````markdown
806
+ ```sql
807
+ SELECT source AS "held from",
808
+ CASE takes WHEN 'stem' THEN 'a name-stem, in any of its three grades'
809
+ WHEN 'element' THEN array_to_string(list_transform(elements, x -> '`' || x || '`'), ', ')
810
+ ELSE (CASE takes WHEN 'godName' THEN 'that god''s name' ELSE 'that founder''s given name' END)
811
+ || coalesce(', clipped to its ' || clip || ' element', '')
812
+ || coalesce(', with a genitive ' || array_to_string(list_transform(genitive, x -> '`' || x || '`'), ' or '), '')
813
+ END AS "what stands first"
814
+ FROM held_from WHERE language = 'thalorna-note-skill-nordmalng' ORDER BY position
815
+ ```
816
+
817
+ ```sql
818
+ SELECT '`' || near || '`' AS "Near name", '`' || given || '`' AS "Given name"
819
+ FROM near_names WHERE language = 'thalorna-note-skill-khelathlng' ORDER BY position
820
+ ```
821
+ ````
822
+
823
+ The forms older than the rules, and the negative particle:
824
+
825
+ ````markdown
826
+ ```sql
827
+ SELECT '_' || form || '_' AS "Form", coalesce(gloss, '—') AS "Gloss", coalesce(layer, '—') AS "Layer"
828
+ FROM older WHERE language = 'thalorna-note-skill-sinalelng' ORDER BY position
829
+ ```
830
+
831
+ ```sql
832
+ SELECT '_' || particle || '_' AS "Particle", coalesce(mutation, '—') AS "The verb takes"
833
+ FROM negation WHERE language = 'thalorna-note-skill-sinalelng'
834
+ ```
835
+ ````
836
+
644
837
  See [a language's register, sounds and history](../reference/format-details.md#a-languages-register-sounds-and-history) for every key and what the lint holds it to.
645
838
 
646
839
  ### Fence attributes
package/docs/commands.md CHANGED
@@ -1995,8 +1995,8 @@ package-build lint [root] [--no-references]
1995
1995
  **DESCRIPTION**
1996
1996
 
1997
1997
  Checks a content tree's addresses and frontmatter without compiling anything
1998
- — no LevelDB is opened and no Foundry manifest is needed, so it runs in
1999
- under a second and can gate a commit. Beyond address well-formedness, it
1998
+ — no LevelDB is opened and no Foundry manifest is needed. Beyond address
1999
+ well-formedness, it
2000
2000
  checks frontmatter against the declared vocabulary and each system's `sohl:`
2001
2001
  / `hm3:` block, the package's charset (so a book can pick a font that covers
2002
2002
  it), that an icon a note writes is one the registry declares, and that
@@ -2012,6 +2012,16 @@ dependencies; exact Item UUID validation remains part of compilation. Reads
2012
2012
  the content tree named by `root`, defaulting to
2013
2013
  `paths.content`; writes nothing.
2014
2014
 
2015
+ Content lint computes source positions when it emits findings and shares each
2016
+ note's YAML position document across consecutive lookups.
2017
+
2018
+ The final summary reports wall time in milliseconds for `index` (collecting
2019
+ content records), `SQL` (opening relations and preparing tables, inline queries
2020
+ and generated sections), and `validation` (building the link index and running
2021
+ the checks). These are elapsed durations from a monotonic clock, including any
2022
+ waits within each stage. They are prose summary output, separate from positioned
2023
+ findings, and appear whether completed validation passes or reports errors.
2024
+
2015
2025
  **OPTIONS**
2016
2026
 
2017
2027
  | Positional | Type | Default | Description |
@@ -91,6 +91,29 @@ errors, reported at the `ranks` key.
91
91
 
92
92
  An Item's `{#description}` page is its readable description. The note Address `<package>-note-<type>-<shortcode>#description` resolves to a JournalEntryPage UUID. The build writes the same `@UUID[...]` pointer to SoHL Item `system.docHtml` and HM3 Item `system.description`. An Item note without that anchor uses its first page as the description. Other pages and anchors remain available for links.
93
93
 
94
+ ## Suggesting another note
95
+
96
+ State deliberate cross-references in `data.seeAlso`, in reading order:
97
+
98
+ ```yaml
99
+ data:
100
+ seeAlso:
101
+ - address: lore-harborhistory
102
+ gloss: The founding of the port
103
+ - address: place-breakwater
104
+ - address: thalorna-note-lore-searoutes
105
+ gloss: Routes beyond the bay
106
+ ```
107
+
108
+ Each `address` names a whole note; `gloss` optionally explains why to read it.
109
+ The first entry points to the port's history, the second to its breakwater,
110
+ and the third to a note in the declared `thalorna` dependency. An address must
111
+ resolve, and neither a duplicate destination nor the note itself is allowed.
112
+ The site shows the list ahead of other Related links, without repeating their
113
+ destinations. Journals and books append See also; an omitted or empty list adds
114
+ nothing. See [suggested reading](authoring/frontmatter.md#suggested-reading)
115
+ for the Address rules.
116
+
94
117
  ## File and folder names
95
118
 
96
119
  Every character in the path of a content file or a picture is printable 7-bit ASCII, U+0020 to U+007E. The rule covers each file and folder name under the content tree and under the package's `icons/` and `images/` roots. Letters, digits, spaces, hyphens, underscores, dots and apostrophes are all inside it, so `Knight's_Shield.md` is a valid name. An accented letter, any other non-ASCII character, and a control character are outside it.
@@ -143,6 +166,6 @@ Places, affiliations, lore and beings may name their culture in `data.culture`,
143
166
 
144
167
  An affiliation may name the trade, temple or order tongue it keeps in its own `data.language`. An affiliation that reaches no culture is a warning, in a tree holding a culture note. A language needs no culture and no affiliation. See [type: affiliation](reference/format-details.md#type-affiliation).
145
168
 
146
- A language note (`type: skill`, `subType: language`) states its words in frontmatter: `data.words` states each word as a map of its form, class, field and gloss — one sense or a list of them, which a dictionary numbers — with its source, its variant forms, the pieces it is built from and a note, beside `data.classModifiers`, `data.fields` and `data.affixes`. The notes a word is cited from are derived, not written: a note cites a word by marking it in italics in its prose, matched by form or a variant with diacritics and case set aside. The language of a word is the note it sits on, and none of it reaches the compiled Item. A daughter language names its parent in `data.family` and reads the parent's words as its own; the `language_lineage` relation joins a language to its ancestors. Classes come from the [master word classes](reference/format-details.md#word-classes). A culture or an affiliation states its own glossary terms in `data.glossary`, and every entry resolves to a language — its own `language`, else the affiliation's, its parents' or its culture's, else the culture's — or is an error at the entry. The `words`, `affixes`, `fields`, `glossary` and `language_lineage` relations hold them for `sql` fences, the `dictionary` relation lists each word under every language that has it, inherited words included, and the `terms` relation each glossary entry, rank and office with the cultures it reaches; a fence sets them with `layout=dictionary` or `layout=glossary`. A language note names the tag a dictionary prints for it in `data.abbreviation`. Every fact a check reads about a language's names, sounds and history is frontmatter too: `data.tongues` and `data.register` (every name the setting gives in the language's reach, the notes carrying each derived from the index), `data.retired`, `data.scope`, `data.sounds` (letters, word edges from a closed list, the cluster limit, the stress rule, syllable finals, syllable rules and vowel harmony, the joining of parts, written marks, a root's skeleton vowel and frames, and the spellings read past), `data.soundChanges` (named sets of ordered steps), `data.elements` (each standing `opening`, `closing`, `either` or `link`), `data.roots`, `data.cognates` (with a reconstructed `proto` form) and `data.callingNames`, each a relation — `sounds` as `letters`, `edges`, `clusters`, `stress`, `finals`, `syllables`, `joining`, `joins`, `marks`, `skeleton`, `frames` and `read_past`, `soundChanges` as `sound_changes`, `callingNames` as `calling_names` — that a fence sets back on the page. A word's citations also come from a note whose name, alias or rank or office title spells it whole. See [a language's words](reference/format-details.md#a-languages-words), [a language's register, sounds and history](reference/format-details.md#a-languages-register-sounds-and-history), [glossary terms](reference/format-details.md#glossary-terms) and [dictionaries and glossaries](reference/format-details.md#dictionaries-and-glossaries).
169
+ A language note (`type: skill`, `subType: language`) states its words in frontmatter: `data.words` states each word as a map of its form, class, field and gloss — one sense or a list of them, which a dictionary numbers — with its source, its variant forms, the pieces it is built from and a note, beside `data.classModifiers`, `data.fields` and `data.affixes`, each affix with the `kind` it makes, for a given name's ending who the name is `given` to, and the stem closes it is barred after; a zero affix states `zero: true` and no form. The notes a word is cited from are derived, not written: a note cites a word by marking it in italics in its prose, matched by form or a variant with diacritics and case set aside. The language of a word is the note it sits on, and none of it reaches the compiled Item. A daughter language names its parent in `data.family` and reads the parent's words as its own; the `language_lineage` relation joins a language to its ancestors. Classes come from the [master word classes](reference/format-details.md#word-classes). A culture or an affiliation states its own glossary terms in `data.glossary`, and every entry resolves to a language — its own `language`, else the affiliation's, its parents' or its culture's, else the culture's — or is an error at the entry. The `words`, `affixes`, `fields`, `glossary` and `language_lineage` relations hold them for `sql` fences, the `dictionary` relation lists each word under every language that has it, inherited words included, and the `terms` relation each glossary entry, rank and office with the cultures it reaches; a fence sets them with `layout=dictionary` or `layout=glossary`. A language note names the tag a dictionary prints for it in `data.abbreviation`. Every fact a check reads about a language's names, sounds and history is frontmatter too: `data.tongues` and `data.register` (every name the setting gives in the language's reach, the notes carrying each derived from the index), `data.retired`, `data.scope`, `data.sounds` (letters with their place, word edges from a closed list, the cluster limit and the groups of clusters inside a word, the stress rule, syllable finals, syllable rules and vowel harmony with each vowel's front partner, the joining of parts, written marks, a root's skeleton vowel and frames, the spellings read past, and named mutations such as wearing and voicing), `data.soundChanges` (named sets of ordered steps), `data.elements` (each standing `opening`, `closing`, `either` or `link`, for one kind of name, with the word it is the stem of and its genitive), `data.roots`, `data.cognates` (with a reconstructed `proto` form), `data.callingNames`, `data.older` (the forms older than the rules), `data.negation` (the negative particle and the mutation it brings) and `data.naming` (how long each class of name runs, the edges names keep that words do not, the element kinds each kind of name draws on, what a place name takes from what it is held from, and the near name), each a relation — `sounds` as `letters`, `edges`, `clusters`, `cluster_groups`, `stress`, `finals`, `syllables`, `joining`, `joins`, `marks`, `skeleton`, `frames`, `read_past` and `mutations`, `soundChanges` as `sound_changes`, `callingNames` as `calling_names` — that a fence sets back on the page. A word's citations also come from a note whose name, alias or rank or office title spells it whole. See [a language's words](reference/format-details.md#a-languages-words), [a language's register, sounds and history](reference/format-details.md#a-languages-register-sounds-and-history), [glossary terms](reference/format-details.md#glossary-terms) and [dictionaries and glossaries](reference/format-details.md#dictionaries-and-glossaries).
147
170
 
148
171
  Blockquote alerts use `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, or `[!]`, with optional attributes after the marker. They replace the former info and warn div fences; secret blocks remain supported. See [alerts](authoring/links-and-markup.md#alerts).
package/docs/design.md CHANGED
@@ -30,3 +30,34 @@ Passes declare their inputs and outputs so compile order follows dependencies wh
30
30
  ## Verification
31
31
 
32
32
  The command and API references have tests that derive their surfaces from source. The note-format contract lives in structured data and code declarations so editorial changes to Markdown do not change validation. Generated field references are checked against those declarations. Tests check emitted documents as well as input acceptance: a build that exits successfully but drops a value is still wrong. A consumer regression run compares `build/packs-json` across toolchain versions against a real content tree.
33
+
34
+ The SQL scaling guard runs `prepareTreeSqlTables` over two sizes of a synthetic
35
+ linked note tree, with rewritten shortcodes and references. It profiles each
36
+ executed query through the supplied database, including local and dependency
37
+ relations, inline expressions and generated event sections. DuckDB's JSON
38
+ `EXPLAIN ANALYZE` reports the actual cardinality of file-reader operators before
39
+ downstream filters; these source rows must stay at zero after database creation.
40
+ A direct JSON scan is the positive control for that measurement. In-memory
41
+ scans may grow with the corpus, and elapsed time is not an assertion. Schema
42
+ inspection through `DESCRIBE` has no execution profile and is excluded.
43
+
44
+ Run the guard with `npm test -- tests/sql-scaling.test.ts`. A controlled local
45
+ change from `CREATE TABLE` to `CREATE VIEW` in `createTableFromFile` must make
46
+ it fail as source rows read per query increase with the larger tree; restore
47
+ the implementation after the probe. The companion relation tests check that
48
+ queries survive source-file removal and enumerate file-reading views.
49
+
50
+ A compile retains parsed frontmatter, body, raw source and body coordinates in
51
+ its corpus. SQL preparation, document passes, folder pins, event anchors,
52
+ culture inheritance and prose-presence checks consume that shared result.
53
+ The compile guard counts actual Markdown reads and YAML parses for every file
54
+ in its fixture, including documentation and folder notes, and requires one of
55
+ each. Diagnostic positions consume retained source text when supplied.
56
+
57
+ Standing checks locate only findings they emit. YAML path lookups retain the
58
+ most recently located text's document and line counter; another text replaces
59
+ that single cache entry, so an edited note cannot inherit stale coordinates.
60
+ SQL preparation caches authored answers and stub-inclusive comparisons in
61
+ separate fields per query. Generated event fences read `entries` and `events`
62
+ and skip the comparison; their authored/generated boundary is recorded when
63
+ the sections are appended, independently of query text.
@@ -128,6 +128,33 @@ for interactive testing. Arguments after `--` are passed to the suite, for
128
128
  example `package-build e2e run -- --browser chrome`. A port answering is not
129
129
  enough: the harness waits until Foundry's join page shows the world is active.
130
130
 
131
+ ## Choose a browser
132
+
133
+ For a Cypress suite, `e2e fast`, `e2e run` and `e2e open` use pinned
134
+ Chrome for Testing **155.0.8059.84** on macOS arm64 and Linux x64. The first run
135
+ downloads it; later runs reuse the cached executable. Each run prints the
136
+ resolved browser and version, and Cypress reports the browser in its header.
137
+ Other suite commands run as declared.
138
+
139
+ An explicit `--browser` in the configured suite or its extra arguments wins,
140
+ including `--browser=electron`. For example:
141
+
142
+ ```bash
143
+ package-build e2e fast -- --browser electron
144
+ package-build e2e run -- --browser /path/to/chrome
145
+ package-build e2e open -- --browser=chrome
146
+ ```
147
+
148
+ A platform without a configured build requires an explicit installed browser.
149
+ The automatic download needs network access on first use. The cache location is,
150
+ in order: `HL_BROWSER_CACHE`, `browser-cache` beside `FOUNDRYVTT_CACHE`,
151
+ `HL_E2E_ROOT/browser-cache`, or `heroiclands/chrome-for-testing` under the user's
152
+ cache directory (`XDG_CACHE_HOME`, macOS `~/Library/Caches`, otherwise
153
+ `~/.cache`). CI keeps it inside the job's persistent `HL_E2E_ROOT`; point
154
+ `FOUNDRYVTT_CACHE` there too, or set `HL_BROWSER_CACHE` explicitly. Reseeding
155
+ and container teardown leave the browser cache intact. Renovate tracks the
156
+ exact Chrome pin in `e2e.mjs` through the repository's custom datasource.
157
+
131
158
  ## Iterate without reseeding
132
159
 
133
160
  `fast` rebuilds declared targets, redeploys the full stage, cycles the world,
@@ -196,4 +223,13 @@ container started with the variable set joins the network but keeps the
196
223
  environment it was created with; `--recreate` applies `FOUNDRY_AWS_CONFIG`.
197
224
  `package-build container <stage> rm` with the variable set removes the server
198
225
  and the network along with the container. The server is otherwise left running
199
- between runs, bucket intact.
226
+ between runs, bucket intact. New and reused servers must answer the S3 HTTP
227
+ health check before Foundry starts; a ready filer alone is insufficient.
228
+
229
+ Both configuration paths must be readable regular files. When changing
230
+ worktrees, an existing server must bind the identities file from the current
231
+ checkout (symlinks to the same file are accepted). An incompatible or
232
+ uninspectable bind stops the start and names the server and expected path.
233
+ Restore the original checkout to keep its bucket data, or remove only the named
234
+ disposable S3 server with `docker rm -f <container>-s3` and rerun. Removing that
235
+ server discards its bucket data; the tool never removes it automatically.
@@ -78,24 +78,39 @@ who knows the groups can guess a name.
78
78
  ### `prepare` — the git hooks
79
79
 
80
80
  ```json
81
- "prepare": "git config core.hooksPath node_modules/@heroiclands/package-build/githooks || true"
81
+ "prepare": "git config core.hooksPath .githooks || true"
82
82
  ```
83
83
 
84
- npm runs `prepare` after every install, so this is what installs the hooks. It
85
- points git at the hook directory this package ships, which means a repository
86
- carries no hook files of its own and cannot drift from the others. `|| true` so
87
- that an install outside a git checkout — a Docker build, a CI cache step —
88
- does not fail on it.
84
+ npm runs `prepare` after every install. In this repository it points Git at the
85
+ committed `.githooks/`, including in a fresh worktree. Run `npm ci` for a fresh
86
+ checkout or `npm run prepare` to repeat setup. `|| true` allows an install
87
+ outside a Git checkout, such as a Docker build or CI cache step.
89
88
 
90
- Four hooks arrive with it, each with its own switch, read with git's normal
89
+ The installed `commit-msg` and `pre-commit` are byte-identical copies of the
90
+ [canonical POSIX hooks](https://github.com/HeroicLands/.github#local-git-hooks-and-actionsgithooks).
91
+ The message guard refuses assistant credits without requiring Node; the branch
92
+ guard refuses commits on `main` and `master`. These hooks honor
93
+ `hooks.noAttribution`, `hooks.protectedBranch`, and `hooks.allowCommitOnMain`.
94
+ CI compares every installed hook against the pinned canonical set. This setup
95
+ installs no pre-push or pre-merge-commit hook.
96
+
97
+ ### Optional published hook bundle
98
+
99
+ The package also ships `githooks/` for repositories that explicitly select its
100
+ additional checks. To select that bundle, point `core.hooksPath` at
101
+ `node_modules/@heroiclands/package-build/githooks` in that repository’s setup.
102
+ That bundle requires the installed package and Node; it is separate from this
103
+ repository’s canonical `.githooks/` installation.
104
+
105
+ Four hooks are available, each with its own switch, read with Git’s normal
91
106
  precedence: a plain `git config` sets one clone, `--global` sets a machine.
92
107
 
93
- | Hook | What it does | Key | Default |
94
- | -------------------------------- | --------------------------------------------------------------- | ------------------------------------ | ------- |
95
- | `pre-commit`, `pre-merge-commit` | Refuse a commit on a protected branch. | `hooks.allowCommitOnMain` (inverted) | on |
96
- | `pre-commit` | Lint any `.changeset/*.md` the commit stages. | `hooks.changelogCheck` | on |
97
- | `commit-msg` | Refuse AI attribution in a commit message. | `hooks.noAttribution` | on |
98
- | `pre-push` | Run this repository's own Build & Test workflow in a container. | `hooks.prePushCi` | off |
108
+ | Hook | What it does | Key | Default |
109
+ | -------------------------------- | -------------------------------------------------------------------- | ------------------------------------ | ------- |
110
+ | `pre-commit`, `pre-merge-commit` | Refuse a commit on a protected branch. | `hooks.allowCommitOnMain` (inverted) | on |
111
+ | `pre-commit` | Lint any `.changeset/*.md` the commit stages. | `hooks.changelogCheck` | on |
112
+ | `commit-msg` | Refuse AI attribution in a commit message. | `hooks.noAttribution` | on |
113
+ | `pre-push` | Run the consuming repository’s Build & Test workflow in a container. | `hooks.prePushCi` | off |
99
114
 
100
115
  The defaults differ deliberately: a guard that costs nothing is on unless
101
116
  refused, and one that runs a container for minutes is off unless asked for.
@@ -120,8 +135,8 @@ Git comment lines and the verbose diff after a scissors comment are excluded.
120
135
  Findings report the original message line and column. This local hook examines
121
136
  commit messages only; the shared No Attribution workflow also checks pull
122
137
  request titles and bodies. Neither examines repository files, issues, comments,
123
- or Git author/committer metadata. The package-build repository's own hook uses
124
- the same matching code and remains enabled independently of the consumer switch.
138
+ or Git author/committer metadata. The canonical shell hooks apply the same
139
+ credit grammar with their own POSIX implementation.
125
140
 
126
141
  The branch guard is the one you meet first:
127
142