@heroiclands/package-build 23.3.2 → 23.4.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Beings may give their hair as light brown or as greying in any color, and their eyes as red, gold, yellow, white, silver or pink.
8
+ - Silver hair reads as a natural color as well as an aged one.
9
+
10
+ ## 23.4.0
11
+
12
+ ### Minor Changes
13
+
14
+ - `package-build lint` requires a being's `complexion` to be a list of complexion words, `[]` where no condition is established, and refuses a single word.
15
+ - `extra_features` admits only short plain descriptions of visible physical marks; colours, skin conditions, gear, voice, manner, looks, handedness, sterility, apparent age and punctuation are errors.
16
+ - `package-build npc new` writes the complexion it draws as a list.
17
+
3
18
  ## 23.3.2
4
19
 
5
20
  ### Patch Changes
@@ -26,7 +26,7 @@ A sheltered place where ships load and unload.
26
26
 
27
27
  `shortcode` and `type` identify a HeroicLands note; `name.full` gives readers its name. The YAML ends at the second `---`. Everything after it is the shared Markdown body. Run `npm run lint` to check the note, then `npm run build:site` to build its web page. `npm run serve:site` serves the site locally; `npm run build:book` builds the book. A Foundry package's `npm run build:db` also compiles its configured packs. The generated project supplies these scripts and their configuration.
28
28
 
29
- For optional writing suggestions, run `npx package-build prose lint assets/content/Places/Harbor.md`. This reports difficult sentences and simpler word choices with line and column numbers. It does not change the note or run as part of `npm run lint`. The [prose analysis command](../commands.md#package-build-prose-lint-path) explains the settings and how to read each suggestion.
29
+ For optional writing suggestions, run `npx package-build prose lint assets/content/Places/Harbor.md`. This reports difficult sentences and simpler word choices with line and column numbers. It does not change the note or run as part of `npm run lint`. The [prose analysis command](../commands.md#package-build-prose-lint-path-and-prose-score-path) explains the settings and how to read each suggestion.
30
30
 
31
31
  ## What the note becomes
32
32
 
@@ -35,7 +35,7 @@ The [note-type reference](../reference/note-types.md) lists every field accepted
35
35
 
36
36
  A being's `data.archetypes` names the roles it can fill in an adventure. Choose every specific role that fits, such as `entertainer` for a performer or `guildsperson` for someone whose professional training or connections matter. Use `[commoner]` when no specific archetype fits. `commoner` cannot share the list with another value. The [archetype reference](../reference/format-details.md#actors) defines the available roles.
37
37
 
38
- A being's look is stated in a closed vocabulary. `data.gender` and `data.frame` take one word each, as do `data.appearance.eye_color`, `hair_color` and `skin_color`; `data.appearance.complexion` takes one word or a list of them. A value outside a field's list — a variant spelling such as `gray`, a word from another field's list, or an empty string — is an error naming the words the field takes. Leave a field out when the fact is unrecorded. `data.appearance.extra_features` is a free-text list for the marks no word covers. The [look reference](../reference/format-details.md#a-beings-look) gives every word and what it means.
38
+ A being's look is stated in a closed vocabulary. `data.gender` and `data.frame` take one word each, as do `data.appearance.eye_color`, `hair_color` and `skin_color`; `data.appearance.complexion` takes a list of words, `[]` where no condition is established, and is required once a being states an appearance. A value outside a field's list — a variant spelling such as `gray`, a word from another field's list, or an empty string — is an error naming the words the field takes. Leave a field out when the fact is unrecorded. `data.appearance.extra_features` lists the visible physical marks no word covers, each a short plain description of one, such as `a burn scar on the left forearm`. The [look reference](../reference/format-details.md#a-beings-look) gives every word and what it means.
39
39
 
40
40
  A `sohl:`, `hm3:` or `dnd5e:` block holds values for that Foundry system. Its `system:` child contains values sent to the system's DataModel. The system block can override shared routing and art where the declared field permits it. `data.pack` and `data.packFolder` are shared routes; a system-specific route belongs in that system block. The [detailed mapping reference](../reference/format-details.md#mappings-every-type-shares) and [system-block explanation](../reference/format-details.md#frontmatter-has-three-regions) cover precedence and emitted fields.
41
41
 
package/docs/commands.md CHANGED
@@ -1898,7 +1898,10 @@ checks frontmatter against the declared vocabulary and each system's `sohl:`
1898
1898
  / `hm3:` block, the package's charset (so a book can pick a font that covers
1899
1899
  it), that an icon a note writes is one the registry declares, and that
1900
1900
  markup inside a note's body does not smuggle in a character the charset
1901
- check cannot see. `--no-references` turns off the check that a frontmatter
1901
+ check cannot see. A being's appearance is held to
1902
+ [a being's look](reference/format-details.md#a-beings-look): `complexion` a
1903
+ list, and `extra_features` only short plain descriptions of physical marks.
1904
+ `--no-references` turns off the check that a frontmatter
1902
1905
  shortcode reference lands, including references in schema-declared system
1903
1906
  fields, for a tree whose cross-package references it cannot see. Address
1904
1907
  references in system fields resolve against this package and its declared
@@ -2923,7 +2926,7 @@ data:
2923
2926
  eye_color: brown
2924
2927
  hair_color: black
2925
2928
  skin_color: brown
2926
- complexion: clear
2929
+ complexion: [clear]
2927
2930
  extra_features: []
2928
2931
  ---
2929
2932
 
@@ -13,15 +13,15 @@ Every typed note requires a nonempty body, including drafts, folders and homepag
13
13
 
14
14
  Start with [your first note](authoring/first-note.md). It explains the smallest valid note and shows the JournalEntry, web page, book entry, and content-index record made from it. Then use the chapters below in the order your note needs them.
15
15
 
16
- | Need | Read |
17
- | --------------------------------------------------------- | ----------------------------------------------------------- |
18
- | Frontmatter, `data`, system blocks, and infoboxes | [Frontmatter](authoring/frontmatter.md) |
19
- | Addresses, links, anchors, fences, and inline expressions | [Links and markup](authoring/links-and-markup.md) |
20
- | Images, icons, audio, provenance, and sidecars | [Assets](authoring/assets.md) |
21
- | Canonical dates, calendars, eras, and conversions | [Dates and calendars](authoring/dates-and-calendars.md) |
22
- | All accepted note types, subtypes, and fields | [Note type reference](reference/note-types.md) |
23
- | Exact format details and system mappings | [Detailed reference](reference/format-details.md) |
24
- | Optional readability and wording suggestions | [Prose analysis](commands.md#package-build-prose-lint-path) |
16
+ | Need | Read |
17
+ | --------------------------------------------------------- | -------------------------------------------------------------------------------- |
18
+ | Frontmatter, `data`, system blocks, and infoboxes | [Frontmatter](authoring/frontmatter.md) |
19
+ | Addresses, links, anchors, fences, and inline expressions | [Links and markup](authoring/links-and-markup.md) |
20
+ | Images, icons, audio, provenance, and sidecars | [Assets](authoring/assets.md) |
21
+ | Canonical dates, calendars, eras, and conversions | [Dates and calendars](authoring/dates-and-calendars.md) |
22
+ | All accepted note types, subtypes, and fields | [Note type reference](reference/note-types.md) |
23
+ | Exact format details and system mappings | [Detailed reference](reference/format-details.md) |
24
+ | Optional readability and wording suggestions | [Prose analysis](commands.md#package-build-prose-lint-path-and-prose-score-path) |
25
25
 
26
26
  ## The shared note
27
27
 
@@ -92,7 +92,7 @@ Place notes may declare a governing affiliation with `data.government`, or expli
92
92
 
93
93
  A region may list its settlements in a table under `## Settlements` — name, market, people and holder, one row each — tagged with the holding model it is written in. A row links the settlement's note where one exists, and `package-build lint` holds the two to agreement. See [a region's settlements table](reference/format-details.md#a-regions-settlements-table).
94
94
 
95
- A being states its gender, frame and appearance in words from one closed vocabulary: `data.gender`, `data.frame`, and `data.appearance.eye_color`, `hair_color`, `skin_color` and `complexion` each take only the words listed for them, and any other value is an error at the value. `data.appearance.extra_features` stays free text. See [a being's look](reference/format-details.md#a-beings-look).
95
+ A being states its gender, frame and appearance in words from one closed vocabulary: `data.gender`, `data.frame`, and `data.appearance.eye_color`, `hair_color`, `skin_color` and `complexion` each take only the words listed for them, and any other value is an error at the value. `complexion` is a list, required once a being states an appearance. `data.appearance.extra_features` lists the being's visible physical marks, each a short plain description of one, and an entry stating a colour, a skin condition or anything that is not a mark is an error. See [a being's look](reference/format-details.md#a-beings-look).
96
96
 
97
97
  A culture note may state what its people look like in `data.appearanceProfile`: weighted words for frame, eyes, hair, skin and complexion, and height ranges by gender, all from the same vocabulary. A culture names its tongue in `data.language`, and that language skill note may state `data.names`: the patterns its speakers' names are built by and the lists each part is drawn from. `package-build npc new` writes a complete npc note from a spec of a culture and a gender, drawing the name from those lists and everything else the spec leaves out from the profile. See [appearance profiles](reference/format-details.md#appearance-profiles), [name lists](reference/format-details.md#name-lists) and [the command](commands.md#package-build-npc-new).
98
98
 
@@ -31,7 +31,7 @@ document ID appears under another address, and **withdrawn** when that ID
31
31
  appears under none. Findings are warnings by default. `--strict` turns them
32
32
  into errors for a release gate. The comparison reads shipped Item packs, so it
33
33
  reports the surface consumers actually resolve rather than inferring changes
34
- from filenames. Use the [Address reference](../content-format.md#addresses)
34
+ from filenames. Use the [Address reference](../reference/format-details.md#addresses)
35
35
  when choosing a durable shortcode.
36
36
 
37
37
  ## Build the release files
@@ -1916,7 +1916,7 @@ largest print slot needs. Both exempt a vector asset, which carries no pixel
1916
1916
  dimensions and no aspect that survives being drawn at a nominal size.
1917
1917
 
1918
1918
  `role: map` also selects a captioned image's counter — see
1919
- [figures and numbered references](../authoring/links-and-markup.md#figures-and-numbered-references).
1919
+ [captions and numbered references](../authoring/links-and-markup.md#captions-and-numbered-references).
1920
1920
  A fence whose picture draws an address with that role counts as `Map` rather
1921
1921
  than `Figure`, on every surface that numbers it, because the role is the one
1922
1922
  fact the fence's own markup cannot state.
@@ -3616,8 +3616,8 @@ See [a being's subtype describes the subject](#a-beings-subtype-describes-the-su
3616
3616
  | `appearance.eye_color` | a word | Eye colour — see [a being's look](#a-beings-look) |
3617
3617
  | `appearance.hair_color` | a word | Hair colour — see [a being's look](#a-beings-look) |
3618
3618
  | `appearance.skin_color` | a word | Skin colour — see [a being's look](#a-beings-look) |
3619
- | `appearance.complexion` | `word \| word[]` | The skin's condition, one word or several — see [a being's look](#a-beings-look) |
3620
- | `appearance.extra_features` | `string[]` | Anything else a stranger would notice, in free text |
3619
+ | `appearance.complexion` | `word[]`, required with `appearance` | The skin's condition, a list of words — see [a being's look](#a-beings-look) |
3620
+ | `appearance.extra_features` | `string[]` | The visible physical marks no word covers — see [a being's look](#a-beings-look) |
3621
3621
 
3622
3622
  Author `data.height` as a string of decimal metres (`1.91m` or `1.91 m`) or
3623
3623
  whole feet with optional inches (`6'`, `6'3"`, or `6' 3"`). Inches must be
@@ -3648,15 +3648,52 @@ vocabulary**, so every package describes a face in the same terms and a
3648
3648
  misspelt or invented word is caught. `data.gender`, `data.frame`,
3649
3649
  `data.appearance.eye_color`, `data.appearance.hair_color` and
3650
3650
  `data.appearance.skin_color` each take one word from their own list.
3651
- `data.appearance.complexion` takes one word or a list of them, since a face
3652
- carries more than one condition at once; a single word means a list of one.
3653
3651
  Any other value — a word from another field's list, a variant spelling such as
3654
3652
  `gray` for `grey`, or an empty string — is an error at the value, naming the
3655
3653
  words the field takes. Leave a field out when the fact is unrecorded.
3656
3654
 
3657
- `data.appearance.extra_features` is not closed. It is a list of free-text
3658
- marks — a scar and where it came from, a missing finger, a limp — for what no
3659
- word in the vocabulary covers.
3655
+ **`data.appearance.complexion` is a list, and a being that states an appearance
3656
+ must state it.** A face carries more than one condition at once, so the field
3657
+ is always a list of complexion words: `[weathered, freckled]`, `[clear]` for
3658
+ skin with nothing remarkable about it, or `[]` where no condition is
3659
+ established. A single word written outside a list, such as `complexion: clear`,
3660
+ is an error that asks for the list, and so is an `appearance` map with no
3661
+ `complexion` or a `complexion` of `null`.
3662
+
3663
+ **`data.appearance.extra_features` lists the visible physical marks no word
3664
+ covers** — a scar and where it is, a missing finger, a tattoo, a limp. Each
3665
+ entry is a short plain description of one mark. The closed fields above carry
3666
+ everything else, so an entry is an error at that entry when it:
3667
+
3668
+ - states a hair, eye or skin colour or a skin condition, which belong in
3669
+ `hair_color`, `eye_color`, `skin_color` and `complexion` — `auburn hair`,
3670
+ `red eyes`, `milky white eyes`, `brown and black hair`, `pale skin`,
3671
+ `weathered complexion`, `freckled`;
3672
+ - names gear, clothing or a weapon — `a mail shirt kept oiled`,
3673
+ `carries a heavy battleaxe`;
3674
+ - describes a voice or speech — `speaks with a lisp`;
3675
+ - describes behaviour, manner or temperament — `fidgets constantly`,
3676
+ `an infectious grin`, `hyperactive`;
3677
+ - gives an impression of looks or presence — `startlingly beautiful`,
3678
+ `a commanding presence`, and `a beautiful tattoo of a crane` too, mark or not;
3679
+ - states handedness, sterility or an apparent age — `left handed`, `sterile`,
3680
+ `a boyish face that makes him look younger`;
3681
+ - carries any punctuation, apart from a hyphen or an apostrophe between two
3682
+ letters. `ink-stained fingers` and `a lion's mane` are marks; `Scar: left
3683
+ cheek` and `a scar on the cheek, from a duel` are not.
3684
+
3685
+ A mark that names the skin it sits on is still a mark: `a burn scar on the left
3686
+ forearm` and `a brand scarred into the skin above the heart` are features. A
3687
+ tattoo, scar or brand may show gear or a gesture, as in `a tattoo of a knight's
3688
+ helmet`, and stands; the voice, impression, handedness, sterility and age rules
3689
+ hold even for a mark.
3690
+
3691
+ A condition is a feature only through what it shows. Its visible sign is a mark
3692
+ — `trembling hands`, `one eye missing`, `a withered left arm` — and a skin sign
3693
+ the complexion words cover goes in `complexion`, so healed pox is `pockmarked`
3694
+ and acne scarring `scarred`. A condition with no visible sign, such as epilepsy,
3695
+ haemophilia or a parasite, stays in the being's prose and is not an appearance
3696
+ fact at all.
3660
3697
 
3661
3698
  ```yaml
3662
3699
  data:
@@ -3669,6 +3706,7 @@ data:
3669
3706
  complexion: [weathered, freckled]
3670
3707
  extra_features:
3671
3708
  - a burn scar across the back of the left hand
3709
+ - ink-stained fingers
3672
3710
  ```
3673
3711
 
3674
3712
  The words, and what each means:
@@ -3704,29 +3742,44 @@ The words, and what each means:
3704
3742
  | `blue` | Blue | Unmixed blue, from pale ice to deep sea. |
3705
3743
  | `brown` | Brown | Mid-brown, the iris pattern still legible in ordinary light. |
3706
3744
  | `dark_brown` | Dark Brown | Brown so deep the iris barely separates from the pupil. |
3745
+ | `gold` | Gold | A bright metallic yellow iris, brighter and colder than `amber`. |
3707
3746
  | `grey` | Grey | Grey or grey-blue, cool and without green. |
3708
3747
  | `green` | Green | Green dominant, with no brown ring worth naming. |
3709
3748
  | `hazel` | Hazel | Brown and green together, often shifting with the light; the commonest mixed eye. |
3749
+ | `red` | Red | An iris that is red, as in some undead and elder folk. |
3750
+ | `pink` | Pink | A pale pink or rose iris, as in albinism. |
3751
+ | `silver` | Silver | A grey iris with a bright metallic sheen rather than a dull cast. |
3710
3752
  | `violet` | Violet | The pink-violet of an iris carrying no pigment. |
3753
+ | `white` | White | An iris white or near-white, so that the eye reads pale throughout. |
3754
+ | `yellow` | Yellow | A pale, flat yellow iris without the warmth of `amber`. |
3711
3755
 
3712
3756
  **`data.appearance.hair_color`**
3713
3757
 
3714
- | Word | Label | Meaning |
3715
- | ------------------ | ---------------- | ------------------------------------------------------------------------- |
3716
- | `auburn` | Auburn | Red-brown with the red dominant. |
3717
- | `black` | Black | True black, with no brown cast in sunlight. |
3718
- | `blonde` | Blonde | Light yellow, from near-white to wheat. |
3719
- | `brown` | Brown | Mid-brown, neither dark nor reddish. |
3720
- | `chestnut` | Chestnut | Mid-brown with a red cast, the brown dominant — between brown and auburn. |
3721
- | `dark_blonde` | Dark Blonde | Blonde darkened towards light brown. |
3722
- | `dark_brown` | Dark Brown | Brown approaching black, still brown in sunlight. |
3723
- | `grey` | Grey | Grey throughout, with nothing of the original colour left to read. |
3724
- | `greying_black` | Greying Black | Black still dominant, grey coming in. |
3725
- | `greying_brown` | Greying Brown | Brown still dominant, grey coming in. |
3726
- | `red` | Red | Ginger or copper, the red unmixed with brown. |
3727
- | `silver` | Silver | Grey with a bright metallic sheen rather than a dull cast. |
3728
- | `strawberry_blond` | Strawberry Blond | Pale red-gold: blonde with a red cast, lighter than red. |
3729
- | `white` | White | Wholly white, whether from age or from carrying no pigment. |
3758
+ | Word | Label | Meaning |
3759
+ | -------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
3760
+ | `auburn` | Auburn | Red-brown with the red dominant. |
3761
+ | `black` | Black | True black, with no brown cast in sunlight. |
3762
+ | `blonde` | Blonde | Light yellow, from near-white to wheat. |
3763
+ | `brown` | Brown | Mid-brown, neither dark nor reddish. |
3764
+ | `chestnut` | Chestnut | Mid-brown with a red cast, the brown dominant — between brown and auburn. |
3765
+ | `dark_blonde` | Dark Blonde | Blonde darkened towards light brown. |
3766
+ | `dark_brown` | Dark Brown | Brown approaching black, still brown in sunlight. |
3767
+ | `grey` | Grey | Grey throughout, with nothing of the original colour left to read. |
3768
+ | `greying_auburn` | Greying Auburn | Auburn still dominant, grey coming in. |
3769
+ | `greying_black` | Greying Black | Black still dominant, grey coming in. |
3770
+ | `greying_blonde` | Greying Blonde | Blonde still dominant, grey coming in. |
3771
+ | `greying_brown` | Greying Brown | Brown still dominant, grey coming in. |
3772
+ | `greying_chestnut` | Greying Chestnut | Chestnut still dominant, grey coming in. |
3773
+ | `greying_dark_blonde` | Greying Dark Blonde | Dark Blonde still dominant, grey coming in. |
3774
+ | `greying_dark_brown` | Greying Dark Brown | Dark Brown still dominant, grey coming in. |
3775
+ | `greying_light_brown` | Greying Light Brown | Light Brown still dominant, grey coming in. |
3776
+ | `light_brown` | Light Brown | Brown lightened toward dark blonde: paler than `brown`, darker than `dark_blonde`. |
3777
+ | `greying_red` | Greying Red | Red still dominant, grey coming in. |
3778
+ | `greying_strawberry_blond` | Greying Strawberry Blond | Strawberry Blond still dominant, grey coming in. |
3779
+ | `red` | Red | Ginger or copper, the red unmixed with brown. |
3780
+ | `silver` | Silver | A bright metallic pale between white and grey: the silver of great age, or the natural silver some elder folk are born with. |
3781
+ | `strawberry_blond` | Strawberry Blond | Pale red-gold: blonde with a red cast, lighter than red. |
3782
+ | `white` | White | Wholly white, whether from age or from carrying no pigment. |
3730
3783
 
3731
3784
  **`data.appearance.skin_color`**
3732
3785
 
@@ -53,33 +53,33 @@ These fields are accepted by every note type. A field's value can still be irrel
53
53
 
54
54
  **Subtypes:** `npc`, `character`, `creature`.
55
55
 
56
- | Field | Shape | Meaning |
57
- | -------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
58
- | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
59
- | `data.tokenIcon` | an Address | What a token on the canvas wears — an `icon` address; unset, it follows `icon`. |
60
- | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
61
- | `data.archetypes` | list of strings | Archetypal behaviours the being fits. |
62
- | `data.occupation` | string | What the being does for a living. |
63
- | `data.lore` | list of Addresses | Lore concerning this being — the law it lives under, the customs it is subject to, the traditions it was raised in. |
64
- | `data.culture` | an Address | The being's primary culture, as a culture lore note. |
65
- | `data.homes` | list of Addresses | Places the being calls home. |
66
- | `data.affiliations` | a standing map keyed by Address | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, required, a level on that body's own ladder, and `office`, optional, a post that body names. |
67
- | `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
68
- | `data.gender` | string | One of `female`, `male`, `nonbinary`, `none` or `other`. `none` says the being has no gender; an absent field says its gender is unrecorded. See [a being's look](format-details.md#a-beings-look) for what each word means. |
69
- | `data.species` | an Address | The being's species, as a lore note. |
70
- | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
71
- | `data.calendar` | an Address | Calendar that displays this being's dates. |
72
- | `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
73
- | `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
74
- | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
75
- | `data.height` | metres or feet and inches | Height in metres or feet and inches. |
76
- | `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
77
- | `data.frame` | string | Relative frame — one of `scant`, `light`, `medium`, `heavy` or `massive`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
78
- | `data.appearance.eye_color` | string | Eye colour: one of `amber`, `black`, `blue`, `brown`, `dark_brown`, `grey`, `green`, `hazel` or `violet`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
79
- | `data.appearance.hair_color` | string | Hair colour: one of `auburn`, `black`, `blonde`, `brown`, `chestnut`, `dark_blonde`, `dark_brown`, `grey`, `greying_black`, `greying_brown`, `red`, `silver`, `strawberry_blond` or `white`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
80
- | `data.appearance.skin_color` | string | Skin colour: one of `pale`, `fair`, `light`, `medium`, `olive`, `tawny`, `golden`, `tanned`, `swarthy`, `brown`, `dark`, `dark_brown` or `ebony`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
81
- | `data.appearance.complexion` | string or list | The skin's condition: `ashen`, `blotchy`, `chapped`, `clear`, `freckled`, `jaundiced`, `leathery`, `oily`, `pasty`, `pimpled`, `pockmarked`, `rough`, `ruddy`, `sallow`, `scabrous`, `scarred`, `smooth`, `sun_kissed`, `sunburnt`, `translucent`, `vitiligo`, `wan`, `weathered` or `wrinkled`, one word or a list of several, because a face carries more than one at once. A single word means a list of one. See [a being's look](format-details.md#a-beings-look) for what each word means. |
82
- | `data.appearance.extra_features` | list of strings | Anything else a stranger would notice, in free text. |
56
+ | Field | Shape | Meaning |
57
+ | -------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
59
+ | `data.tokenIcon` | an Address | What a token on the canvas wears — an `icon` address; unset, it follows `icon`. |
60
+ | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
61
+ | `data.archetypes` | list of strings | Archetypal behaviours the being fits. |
62
+ | `data.occupation` | string | What the being does for a living. |
63
+ | `data.lore` | list of Addresses | Lore concerning this being — the law it lives under, the customs it is subject to, the traditions it was raised in. |
64
+ | `data.culture` | an Address | The being's primary culture, as a culture lore note. |
65
+ | `data.homes` | list of Addresses | Places the being calls home. |
66
+ | `data.affiliations` | a standing map keyed by Address | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, required, a level on that body's own ladder, and `office`, optional, a post that body names. |
67
+ | `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
68
+ | `data.gender` | string | One of `female`, `male`, `nonbinary`, `none` or `other`. `none` says the being has no gender; an absent field says its gender is unrecorded. See [a being's look](format-details.md#a-beings-look) for what each word means. |
69
+ | `data.species` | an Address | The being's species, as a lore note. |
70
+ | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
71
+ | `data.calendar` | an Address | Calendar that displays this being's dates. |
72
+ | `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
73
+ | `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
74
+ | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
75
+ | `data.height` | metres or feet and inches | Height in metres or feet and inches. |
76
+ | `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
77
+ | `data.frame` | string | Relative frame — one of `scant`, `light`, `medium`, `heavy` or `massive`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
78
+ | `data.appearance.eye_color` | string | Eye colour: one of `amber`, `black`, `blue`, `brown`, `dark_brown`, `gold`, `grey`, `green`, `hazel`, `red`, `pink`, `silver`, `violet`, `white` or `yellow`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
79
+ | `data.appearance.hair_color` | string | Hair colour: one of `auburn`, `black`, `blonde`, `brown`, `chestnut`, `dark_blonde`, `dark_brown`, `grey`, `greying_auburn`, `greying_black`, `greying_blonde`, `greying_brown`, `greying_chestnut`, `greying_dark_blonde`, `greying_dark_brown`, `greying_light_brown`, `light_brown`, `greying_red`, `greying_strawberry_blond`, `red`, `silver`, `strawberry_blond` or `white`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
80
+ | `data.appearance.skin_color` | string | Skin colour: one of `pale`, `fair`, `light`, `medium`, `olive`, `tawny`, `golden`, `tanned`, `swarthy`, `brown`, `dark`, `dark_brown` or `ebony`. See [a being's look](format-details.md#a-beings-look) for what each word means. |
81
+ | `data.appearance.complexion` | a list of words, such as `[weathered]` or `[]` | The skin's condition, as a list of `ashen`, `blotchy`, `chapped`, `clear`, `freckled`, `jaundiced`, `leathery`, `oily`, `pasty`, `pimpled`, `pockmarked`, `rough`, `ruddy`, `sallow`, `scabrous`, `scarred`, `smooth`, `sun_kissed`, `sunburnt`, `translucent`, `vitiligo`, `wan`, `weathered` or `wrinkled`: `[weathered, freckled]`, `[clear]` for unremarked skin, or `[]` where no condition is established. Required once a being states an appearance; a single word outside a list is an error. See [a being's look](format-details.md#a-beings-look) for what each word means. |
82
+ | `data.appearance.extra_features` | list of strings | The visible physical marks no word covers, each a short plain description of one: `a burn scar on the left forearm`, `a missing finger`. An entry stating a hair, eye or skin colour or a skin condition, gear or clothing, a voice, behaviour or manner, an impression, handedness, sterility or an apparent age is an error, as is any punctuation but a hyphen or apostrophe inside a word. See [a being's look](format-details.md#a-beings-look). |
83
83
 
84
84
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
85
85
 
@@ -127,7 +127,7 @@ of two ways, and the difference is who resolved it: a content page carries
127
127
  `data.banner`, which the build resolved from the asset Address the note
128
128
  declared into the address its asset host serves; a hand-authored page carries
129
129
  a top-level `banner:`, which the theme resolves against `params.cdnBaseURL`
130
- (see [`params.cdnBaseURL`](../configuration.md#what-a-consumer-must-supply)).
130
+ (see [`params.cdnBaseURL`](../configuration.md#the-generated-hugo-configuration)).
131
131
 
132
132
  **Resolution order.**
133
133
 
@@ -303,7 +303,7 @@ fails this theme's own check until the slot table gives it a maximum.
303
303
  wrapping. On narrow screens, figures remain in document order without text
304
304
  wrapping. An image's label is alternative text, carried on the image itself
305
305
  for a reader who cannot see it, and is not drawn beneath the picture. See
306
- [figures and numbered references](../authoring/links-and-markup.md#figures-and-numbered-references)
306
+ [captions and numbered references](../authoring/links-and-markup.md#captions-and-numbered-references)
307
307
  for how an author writes `size`, `float` and a role.
308
308
 
309
309
  ## A numbered figure
@@ -479,8 +479,9 @@ The home page declares no `infoboxes:`, `related:`, `date:` or `tags:`, so
479
479
  those pieces of a page stay silent.
480
480
 
481
481
  **No `type` at all** — a site whose home page is a set of entry points into
482
- everything it publishes — renders the featured grid documented under
483
- [`params.home`](../configuration.md#what-a-consumer-must-supply).
482
+ everything it publishes — renders the featured grid from `params.home`.
483
+ A package-build configuration supplies those values through
484
+ [`site.hugo`](../configuration.md#site).
484
485
 
485
486
  ### A section landing
486
487
 
@@ -0,0 +1,418 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **What a being's `data.appearance.extra_features` admits.**
16
+ *
17
+ * Each entry is a short plain description of one visible physical mark: a
18
+ * scar, a missing finger, a tattoo, a limp. The closed look fields carry
19
+ * everything else, so an entry is refused when it states:
20
+ *
21
+ * - **a colour or skin condition** — a hair colour beside the word `hair`, an
22
+ * eye colour beside `eye` or `eyes`, a skin colour beside `skin` or
23
+ * `complexion`, or a skin condition. These belong in `hair_color`,
24
+ * `eye_color`, `skin_color` and `complexion`. The colour and condition words
25
+ * are the being-look vocabulary's own, read at run time, plus the spellings
26
+ * in {@link COLOUR_VARIANTS} and {@link CONDITION_PHRASES}. A localised mark
27
+ * that only mentions skin — a burn scar on the forearm — is a mark;
28
+ * - **anything that is not a physical mark** — the categories of
29
+ * {@link NON_PHYSICAL_FEATURES}: gear, clothing or a weapon; a voice or
30
+ * speech; behaviour, manner or temperament; an impression; handedness; sterility; an
31
+ * apparent-age reading;
32
+ * - **punctuation** — any punctuation or symbol character, apart from a hyphen
33
+ * or an apostrophe standing between two letters (`ink-stained fingers`, `a
34
+ * lion's mane`). A long punctuated entry is several facts, or a labelled one,
35
+ * where a single mark belongs.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import { isPlainMap } from "./data-keys.mjs";
41
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
42
+
43
+ /** A vocabulary word as an author writes it: `dark_brown` is "dark brown" or "dark-brown". */
44
+ const phrase = (word) => word.replaceAll("_", "[ -]");
45
+
46
+ /** A case-insensitive whole-word alternation. */
47
+ const anyOf = (alternatives) => new RegExp(`\\b(?:${alternatives.join("|")})\\b`, "iu");
48
+
49
+ /**
50
+ * Hair and eye colour spellings the vocabulary does not take but an author
51
+ * writes, as regular-expression alternatives. Beside `hair` or `eyes` any of
52
+ * them, or any hair or eye colour word, states a colour.
53
+ */
54
+ export const COLOUR_VARIANTS = Object.freeze([
55
+ "gr[ae]y(?:ing|ed)?",
56
+ "blond",
57
+ "ginger",
58
+ "milky",
59
+ "yellow",
60
+ "gold(?:en)?",
61
+ "pink",
62
+ "orange",
63
+ "purple",
64
+ "copper",
65
+ "mixed",
66
+ "streaked",
67
+ "dyed",
68
+ ]);
69
+
70
+ /**
71
+ * Skin-condition phrasings that are not complexion words, as
72
+ * regular-expression alternatives. Any of them states a complexion.
73
+ */
74
+ export const CONDITION_PHRASES = Object.freeze([
75
+ "freckles",
76
+ "pox marks?",
77
+ "pocked",
78
+ "pitted skin",
79
+ "acne",
80
+ "pimples",
81
+ "sunburn",
82
+ "wrinkles",
83
+ "pallid",
84
+ "flushed",
85
+ "weather-beaten",
86
+ ]);
87
+
88
+ /**
89
+ * Complexion words with an everyday sense beyond the skin: refused when the
90
+ * entry is the word alone, or names the skin and describes no mark.
91
+ */
92
+ const BROAD_CONDITIONS = Object.freeze(["scarred", "rough", "clear", "smooth", "oily", "wan"]);
93
+
94
+ /** An entry describing one of these is a physical mark, whatever else it mentions. */
95
+ const MARK =
96
+ /\b(?:tattoos?|tatoo|scars?|scarred|scarring|scarification|brand(?:ed)?|birthmark|burn marks?|pox marks?)\b/iu;
97
+
98
+ const SKIN = /\b(?:skin|complexion|complected)\b/iu;
99
+
100
+ /**
101
+ * The terms that make an `extra_features` entry something other than a
102
+ * physical mark, by category, as case-insensitive whole-word patterns.
103
+ *
104
+ * `exemptMarks` lets an entry describing a tattoo, scar, brand or burn mark
105
+ * pass the category, so "a tattoo of a knight's helmet" stands while "wears a
106
+ * helmet" does not. The voice, impression, handedness, sterility and age
107
+ * categories admit no such exemption: "a beautiful tattoo of a crane" is
108
+ * refused.
109
+ *
110
+ * @type {ReadonlyArray<{rule: string, category: string, pattern: RegExp, exemptMarks: boolean}>}
111
+ */
112
+ export const NON_PHYSICAL_FEATURES = Object.freeze([
113
+ {
114
+ rule: "gear",
115
+ category: "gear, clothing or a weapon",
116
+ pattern: anyOf([
117
+ "wears?",
118
+ "worn",
119
+ "wearing",
120
+ "carr(?:ies|ied|ying)(?! (?:him|her|them|it)self)",
121
+ "cloak(?:ed|s)?",
122
+ "clothing",
123
+ "clothes",
124
+ "robes?",
125
+ "vestments",
126
+ "tunic",
127
+ "mail",
128
+ "armou?r",
129
+ "shirt",
130
+ "axe",
131
+ "sword",
132
+ "dagger",
133
+ "staff",
134
+ "bag",
135
+ "chain",
136
+ "cord",
137
+ "thong",
138
+ "boots?",
139
+ "hat",
140
+ "helm(?:et)?",
141
+ "ring",
142
+ "amulet",
143
+ "pendant",
144
+ "belt",
145
+ "weapons?",
146
+ "garb",
147
+ "dress(?:ed|es)?",
148
+ "gloves?",
149
+ ]),
150
+ exemptMarks: true,
151
+ },
152
+ {
153
+ rule: "voice",
154
+ category: "a voice or speech",
155
+ pattern: anyOf([
156
+ "voice",
157
+ "speech",
158
+ "speaks?",
159
+ "spoken",
160
+ "accent",
161
+ "lisp",
162
+ "stutter\\w*",
163
+ "stammer\\w*",
164
+ "whisper\\w*",
165
+ "tone",
166
+ "laugh\\w*",
167
+ "sings?",
168
+ "singing",
169
+ ]),
170
+ exemptMarks: false,
171
+ },
172
+ {
173
+ rule: "behaviour",
174
+ category: "behaviour, manner or temperament",
175
+ pattern: anyOf([
176
+ "moves?",
177
+ "moving",
178
+ "fidgets?",
179
+ "smil\\w+",
180
+ "grin\\w*",
181
+ "scowl\\w*",
182
+ "frown\\w*",
183
+ "gaze",
184
+ "stares?",
185
+ "glare\\w*",
186
+ "poise",
187
+ "bearing",
188
+ "demeanou?r",
189
+ "manner",
190
+ "habits?",
191
+ "grooming",
192
+ "expressive",
193
+ "intensity",
194
+ "shakes?",
195
+ "air of",
196
+ "carr(?:y|ies|ied|ying) (?:him|her|them|it)self",
197
+ "hyperactive",
198
+ "restless\\w*",
199
+ "jitter\\w*",
200
+ "twitch\\w*",
201
+ "nervous\\w*",
202
+ "anxious\\w*",
203
+ "energetic",
204
+ "lively",
205
+ "cheerful",
206
+ "sullen",
207
+ "shy",
208
+ "calm",
209
+ "placid",
210
+ "fidgety",
211
+ ]),
212
+ exemptMarks: true,
213
+ },
214
+ {
215
+ rule: "impression",
216
+ category: "an impression rather than a mark",
217
+ pattern: anyOf([
218
+ "beautiful",
219
+ "handsome",
220
+ "attractive",
221
+ "ugly",
222
+ "imposing",
223
+ "menacing",
224
+ "unnerv\\w+",
225
+ "disarming",
226
+ "commanding",
227
+ "presence",
228
+ "authority",
229
+ "pretty",
230
+ "lovely",
231
+ "gorgeous",
232
+ "stunning",
233
+ "comely",
234
+ "homely",
235
+ "hideous",
236
+ "striking\\w*",
237
+ "alluring",
238
+ "dashing",
239
+ "rugged",
240
+ "elegant\\w*",
241
+ "grace\\w*",
242
+ "feminine",
243
+ "masculine",
244
+ "girly",
245
+ "good looks",
246
+ ]),
247
+ exemptMarks: false,
248
+ },
249
+ {
250
+ rule: "handedness",
251
+ category: "handedness",
252
+ pattern: /\b(?:left|right)[- ]handed\b|\bambidextrous\b|\bhandedness\b|\bsouthpaw\b/iu,
253
+ exemptMarks: false,
254
+ },
255
+ {
256
+ rule: "sterility",
257
+ category: "sterility",
258
+ pattern: anyOf(["steril\\w*", "infertil\\w*", "barren", "childless"]),
259
+ exemptMarks: false,
260
+ },
261
+ {
262
+ rule: "age",
263
+ category: "an apparent-age reading",
264
+ pattern: anyOf([
265
+ "look(?:s|ing)? (?:younger|older|young|old)",
266
+ "younger than",
267
+ "older than",
268
+ "appears? (?:\\w+ )?(?:years|young|old)",
269
+ "apparent age",
270
+ "youthful",
271
+ "boyish",
272
+ "girlish",
273
+ "childlike",
274
+ "ageless",
275
+ "elderly",
276
+ ]),
277
+ exemptMarks: false,
278
+ },
279
+ ]);
280
+
281
+ /** A hyphen or an apostrophe standing between two letters, which an entry may carry. */
282
+ const JOINER = /(?<=\p{L})[-‐'’](?=\p{L})/gu;
283
+
284
+ /** Any punctuation or symbol character. */
285
+ const PUNCTUATION = /[\p{P}\p{S}]/gu;
286
+
287
+ /**
288
+ * The punctuation an entry carries, a hyphen or apostrophe between letters
289
+ * excepted.
290
+ *
291
+ * @param {string} entry - The entry as written.
292
+ * @returns {string[]} Each distinct character, in order of appearance.
293
+ */
294
+ export function featurePunctuation(entry) {
295
+ return [...new Set(entry.replace(JOINER, "").match(PUNCTUATION) ?? [])];
296
+ }
297
+
298
+ /** The colour and condition patterns, built once per vocabulary. */
299
+ const patterns = new WeakMap();
300
+
301
+ /**
302
+ * The colour and condition patterns of one being-look vocabulary.
303
+ *
304
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look
305
+ * @returns {{colour: RegExp, skin: RegExp, condition: RegExp, broad: RegExp, bareBroad: RegExp}}
306
+ */
307
+ function lookPatterns(look) {
308
+ let built = patterns.get(look);
309
+ if (built) return built;
310
+ const words = (field) => (look[field] ?? []).map(({ value }) => value);
311
+ const complexion = words("appearance.complexion");
312
+ const broad = BROAD_CONDITIONS.filter((word) => complexion.includes(word));
313
+ built = {
314
+ colour: anyOf(
315
+ [...new Set([...words("appearance.hair_color"), ...words("appearance.eye_color")])]
316
+ .map(phrase)
317
+ .concat(COLOUR_VARIANTS),
318
+ ),
319
+ skin: anyOf(words("appearance.skin_color").map(phrase)),
320
+ condition: anyOf([
321
+ ...complexion.filter((word) => !broad.includes(word)).map(phrase),
322
+ ...CONDITION_PHRASES,
323
+ ]),
324
+ broad: anyOf(broad.map(phrase)),
325
+ bareBroad: new RegExp(`^(?:${broad.map(phrase).join("|")})$`, "iu"),
326
+ };
327
+ patterns.set(look, built);
328
+ return built;
329
+ }
330
+
331
+ /**
332
+ * Why an `extra_features` entry is not a short plain description of one
333
+ * physical mark.
334
+ *
335
+ * @param {string} entry - The entry as written.
336
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
337
+ * being-look vocabulary.
338
+ * @returns {Array<{rule: string, message: string}>} One problem per rule the
339
+ * entry breaks; empty when it is a mark. `rule` is `hair-colour`,
340
+ * `eye-colour`, `skin-colour`, `complexion`, `punctuation`, or a rule of
341
+ * {@link NON_PHYSICAL_FEATURES}.
342
+ */
343
+ export function featureProblems(entry, look) {
344
+ const p = lookPatterns(look);
345
+ const has = (re) => re.test(entry);
346
+ const isMark = has(MARK);
347
+ const problems = [];
348
+ if (has(/\bhair\b/iu) && has(p.colour))
349
+ problems.push({
350
+ rule: "hair-colour",
351
+ message: "states a hair colour, which belongs in `appearance.hair_color`",
352
+ });
353
+ if (has(/\beyes?\b/iu) && has(p.colour))
354
+ problems.push({
355
+ rule: "eye-colour",
356
+ message: "states an eye colour, which belongs in `appearance.eye_color`",
357
+ });
358
+ if (has(SKIN) && has(p.skin))
359
+ problems.push({
360
+ rule: "skin-colour",
361
+ message: "states a skin colour, which belongs in `appearance.skin_color`",
362
+ });
363
+ if (
364
+ has(p.condition) ||
365
+ p.bareBroad.test(entry.trim()) ||
366
+ (has(p.broad) && has(SKIN) && !isMark)
367
+ )
368
+ problems.push({
369
+ rule: "complexion",
370
+ message: "states a skin condition, which belongs in `appearance.complexion`",
371
+ });
372
+ for (const { rule, category, pattern, exemptMarks } of NON_PHYSICAL_FEATURES) {
373
+ if (exemptMarks && isMark) continue;
374
+ if (has(pattern))
375
+ problems.push({ rule, message: `names ${category}, which is not a physical mark` });
376
+ }
377
+ const marks = featurePunctuation(entry);
378
+ if (marks.length)
379
+ problems.push({
380
+ rule: "punctuation",
381
+ message:
382
+ "carries punctuation " +
383
+ marks.map((mark) => `\`${mark}\``).join(" ") +
384
+ "; an extra feature is a short plain description of one visible mark, " +
385
+ "with no punctuation but a hyphen or apostrophe inside a word",
386
+ });
387
+ return problems;
388
+ }
389
+
390
+ /**
391
+ * The frontmatter lint's check of `data.appearance.extra_features`.
392
+ *
393
+ * @param {object} note - The note: `file`, `raw` and parsed `fm`.
394
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
395
+ * being-look vocabulary.
396
+ * @returns {object[]} Error findings, each located at the entry at fault.
397
+ */
398
+ export function checkExtraFeatures(note, look) {
399
+ const appearance = note?.fm?.data?.appearance;
400
+ if (!isPlainMap(appearance)) return [];
401
+ const features = appearance.extra_features;
402
+ if (!Array.isArray(features)) return [];
403
+ const raw = note.raw ?? "";
404
+ const findings = [];
405
+ features.forEach((entry, index) => {
406
+ if (typeof entry !== "string") return;
407
+ const path = ["data", "appearance", "extra_features", index];
408
+ for (const { message } of featureProblems(entry, look)) {
409
+ findings.push({
410
+ file: note.file,
411
+ ...positionOfFrontmatterPath(raw, path),
412
+ severity: "error",
413
+ message: `\`data.appearance.extra_features[${index}]\` ${message}, but reads ${JSON.stringify(entry)}`,
414
+ });
415
+ }
416
+ });
417
+ return findings;
418
+ }
@@ -185,7 +185,8 @@ export function mapEntriesOf(value) {
185
185
  *
186
186
  * @param {readonly object[]} fields - The type's `data:` declaration.
187
187
  * @returns {object[]} One spec per top-level key, a dotted group as a `map`
188
- * whose `fields` are its members, with `grouped: true`.
188
+ * whose `fields` are its members, with `grouped: true`, and each member
189
+ * marked `kindChecked`.
189
190
  */
190
191
  export function topLevelSpecs(fields) {
191
192
  const out = [];
@@ -202,7 +203,10 @@ export function topLevelSpecs(fields) {
202
203
  groups.set(head, group);
203
204
  out.push(group);
204
205
  }
205
- group.members.push({ ...field, name: rest.join(".") });
206
+ // The frontmatter lint's container check has already held a dotted
207
+ // field's leaf to its kind; `kindChecked` keeps the walk from saying
208
+ // so a second time.
209
+ group.members.push({ ...field, name: rest.join("."), kindChecked: true });
206
210
  }
207
211
  for (const group of groups.values()) {
208
212
  group.fields = topLevelSpecs(group.members);
@@ -285,6 +289,7 @@ export function checkDataKeys(note, fields) {
285
289
  const visit = (spec, value, path) => {
286
290
  if (value === undefined || value === null) return;
287
291
  if (!fitsKind(value, spec.kind)) {
292
+ if (spec.kindChecked && !spec.fields) return;
288
293
  findings.push(
289
294
  finding(
290
295
  path,
@@ -29,11 +29,39 @@ function* markdownFiles(root) {
29
29
 
30
30
  /** Preserve offsets while excluding literal examples from link scanning. */
31
31
  export function maskMarkdownCode(source, spans = true) {
32
- const pattern =
33
- spans ?
34
- /```[\s\S]*?```|~~~[\s\S]*?~~~|``[^`]*``|`[^`]*`/g
35
- : /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
36
- return source.replace(pattern, (match) => match.replace(/[^\n]/g, " "));
32
+ const mask = (value) => value.replace(/[^\n]/g, " ");
33
+ let fence;
34
+ let body = "";
35
+ for (const line of source.match(/[^\n]*\n|[^\n]+$/g) || []) {
36
+ if (fence) {
37
+ body += mask(line);
38
+ const close = line.match(/^ {0,3}(`+|~+)[ \t]*\r?\n?$/);
39
+ if (close && close[1][0] === fence[0] && close[1].length >= fence.length)
40
+ fence = undefined;
41
+ } else {
42
+ const open = line.match(/^ {0,3}(`{3,}|~{3,})(.*)/);
43
+ if (open && (open[1][0] !== "`" || !open[2].includes("`"))) {
44
+ fence = open[1];
45
+ body += mask(line);
46
+ } else body += line;
47
+ }
48
+ }
49
+ if (!spans) return body;
50
+ const delimiters = [...body.matchAll(/`+/g)];
51
+ let offset = 0;
52
+ let result = "";
53
+ for (let i = 0; i < delimiters.length; i++) {
54
+ const start = delimiters[i];
55
+ const end = delimiters.findIndex(
56
+ (candidate, index) => index > i && candidate[0].length === start[0].length,
57
+ );
58
+ if (end < 0) continue;
59
+ const stop = delimiters[end].index + delimiters[end][0].length;
60
+ result += body.slice(offset, start.index) + mask(body.slice(start.index, stop));
61
+ offset = stop;
62
+ i = end;
63
+ }
64
+ return result + body.slice(offset);
37
65
  }
38
66
 
39
67
  /** GitHub heading slug, retaining separate spaces after punctuation removal. */
@@ -50,11 +78,16 @@ export function docHeadingSlug(value) {
50
78
  export function docAnchors(source) {
51
79
  const body = maskMarkdownCode(source, false);
52
80
  const anchors = new Set();
81
+ const suffixes = new Map();
53
82
  for (const [, heading] of body.matchAll(/^#{1,6}\s+(.+?)\s*#*$/gm)) {
54
83
  const explicit = splitHeadingAttributes(heading);
55
- anchors.add(
56
- explicit.id || docHeadingSlug(explicit.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")),
57
- );
84
+ const base =
85
+ explicit.id || docHeadingSlug(explicit.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"));
86
+ let anchor = base;
87
+ let suffix = suffixes.get(base) || 0;
88
+ while (anchors.has(anchor)) anchor = `${base}-${++suffix}`;
89
+ suffixes.set(base, suffix);
90
+ anchors.add(anchor);
58
91
  }
59
92
  for (const [, anchor] of body.matchAll(/<a\s[^>]*(?:id|name)="([^"]+)"/g)) {
60
93
  anchors.add(anchor);
@@ -506,15 +506,15 @@ export function humanizeValue(value) {
506
506
  /**
507
507
  * Several enumerated values as one clause — `weathered, ruddy and scarred`.
508
508
  *
509
- * A field that admits one value or a list of them reads as a single phrase, so
510
- * the noun it qualifies is said once rather than once per value. Internal: the
511
- * phrase that wants it is declared in this module.
509
+ * A list field reads as a single phrase, so the noun it qualifies is said
510
+ * once rather than once per value. Internal: the phrase that wants it is
511
+ * declared in this module.
512
512
  *
513
- * @param {unknown} value - One value, or a list of them.
513
+ * @param {unknown} value - A list of values.
514
514
  * @returns {string} The text.
515
515
  */
516
516
  function humanizedList(value) {
517
- const parts = (Array.isArray(value) ? value : [value])
517
+ const parts = (Array.isArray(value) ? value : [])
518
518
  .filter((entry) => entry !== undefined && entry !== null && entry !== "")
519
519
  .map((entry) => humanizeValue(entry));
520
520
  if (parts.length < 2) return parts[0] ?? "";
@@ -117,6 +117,7 @@ import {
117
117
  profileLookKeys,
118
118
  requiredHeightGenders,
119
119
  } from "./appearance-profile.mjs";
120
+ import { checkExtraFeatures } from "./appearance-features.mjs";
120
121
  import { LANGUAGE_FIELD, LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
121
122
  import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
122
123
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
@@ -272,9 +273,9 @@ function lookWords(rows) {
272
273
  *
273
274
  * Each field is closed to its list: any other value, an empty one included, is
274
275
  * a finding at the value naming the words it takes. `appearance.complexion`
275
- * takes one word or a list of them, and each entry is checked on its own.
276
- * `appearance.extra_features` is not here: it is free text, a list of the marks
277
- * no word covers.
276
+ * is a list of words, each entry checked on its own. `appearance.extra_features`
277
+ * is not here: it is a list of the physical marks no word covers, held to the
278
+ * rules of `appearance-features.mjs`.
278
279
  *
279
280
  * @type {Readonly<Record<string, readonly LookWord[]>>}
280
281
  */
@@ -303,6 +304,7 @@ export const BEING_LOOK_VOCABULARY = Object.freeze({
303
304
  ["blue", "Blue", "Unmixed blue, from pale ice to deep sea."],
304
305
  ["brown", "Brown", "Mid-brown, the iris pattern still legible in ordinary light."],
305
306
  ["dark_brown", "Dark Brown", "Brown so deep the iris barely separates from the pupil."],
307
+ ["gold", "Gold", "A bright metallic yellow iris, brighter and colder than `amber`."],
306
308
  ["grey", "Grey", "Grey or grey-blue, cool and without green."],
307
309
  ["green", "Green", "Green dominant, with no brown ring worth naming."],
308
310
  [
@@ -310,7 +312,12 @@ export const BEING_LOOK_VOCABULARY = Object.freeze({
310
312
  "Hazel",
311
313
  "Brown and green together, often shifting with the light; the commonest mixed eye.",
312
314
  ],
315
+ ["red", "Red", "An iris that is red, as in some undead and elder folk."],
316
+ ["pink", "Pink", "A pale pink or rose iris, as in albinism."],
317
+ ["silver", "Silver", "A grey iris with a bright metallic sheen rather than a dull cast."],
313
318
  ["violet", "Violet", "The pink-violet of an iris carrying no pigment."],
319
+ ["white", "White", "An iris white or near-white, so that the eye reads pale throughout."],
320
+ ["yellow", "Yellow", "A pale, flat yellow iris without the warmth of `amber`."],
314
321
  ]),
315
322
  "appearance.hair_color": lookWords([
316
323
  ["auburn", "Auburn", "Red-brown with the red dominant."],
@@ -325,10 +332,39 @@ export const BEING_LOOK_VOCABULARY = Object.freeze({
325
332
  ["dark_blonde", "Dark Blonde", "Blonde darkened towards light brown."],
326
333
  ["dark_brown", "Dark Brown", "Brown approaching black, still brown in sunlight."],
327
334
  ["grey", "Grey", "Grey throughout, with nothing of the original colour left to read."],
335
+ ["greying_auburn", "Greying Auburn", "Auburn still dominant, grey coming in."],
328
336
  ["greying_black", "Greying Black", "Black still dominant, grey coming in."],
337
+ ["greying_blonde", "Greying Blonde", "Blonde still dominant, grey coming in."],
329
338
  ["greying_brown", "Greying Brown", "Brown still dominant, grey coming in."],
339
+ ["greying_chestnut", "Greying Chestnut", "Chestnut still dominant, grey coming in."],
340
+ [
341
+ "greying_dark_blonde",
342
+ "Greying Dark Blonde",
343
+ "Dark Blonde still dominant, grey coming in.",
344
+ ],
345
+ ["greying_dark_brown", "Greying Dark Brown", "Dark Brown still dominant, grey coming in."],
346
+ [
347
+ "greying_light_brown",
348
+ "Greying Light Brown",
349
+ "Light Brown still dominant, grey coming in.",
350
+ ],
351
+ [
352
+ "light_brown",
353
+ "Light Brown",
354
+ "Brown lightened toward dark blonde: paler than `brown`, darker than `dark_blonde`.",
355
+ ],
356
+ ["greying_red", "Greying Red", "Red still dominant, grey coming in."],
357
+ [
358
+ "greying_strawberry_blond",
359
+ "Greying Strawberry Blond",
360
+ "Strawberry Blond still dominant, grey coming in.",
361
+ ],
330
362
  ["red", "Red", "Ginger or copper, the red unmixed with brown."],
331
- ["silver", "Silver", "Grey with a bright metallic sheen rather than a dull cast."],
363
+ [
364
+ "silver",
365
+ "Silver",
366
+ "A bright metallic pale between white and grey: the silver of great age, or the natural silver some elder folk are born with.",
367
+ ],
332
368
  [
333
369
  "strawberry_blond",
334
370
  "Strawberry Blond",
@@ -596,15 +632,18 @@ const TEXT_LIST = Object.freeze({
596
632
  });
597
633
 
598
634
  /**
599
- * One value, or several.
600
- *
601
- * The two are the same fact at different lengths rather than two different
602
- * facts, so a single value means a list of one and a consumer reads one shape.
635
+ * A being's complexion: a list of complexion words, `[]` included. The shape
636
+ * names the list form, because the finding an author meets for a single word
637
+ * quotes it.
603
638
  */
604
- const TEXT_OR_LIST = Object.freeze({
605
- shape: "string or list",
606
- kind: "string-or-list",
607
- entries: Object.freeze({ kind: "string", shape: "a string" }),
639
+ const COMPLEXION_LIST = Object.freeze({
640
+ shape: "a list of words, such as `[weathered]` or `[]`",
641
+ kind: "list",
642
+ entries: Object.freeze({
643
+ kind: "string",
644
+ shape: "a string",
645
+ allowed: lookValues("appearance.complexion"),
646
+ }),
608
647
  });
609
648
 
610
649
  /** A single Address. */
@@ -1454,22 +1493,26 @@ export const NOTE_VOCABULARY = Object.freeze({
1454
1493
  lookField("appearance.skin_color", "Skin colour"),
1455
1494
  {
1456
1495
  ...lookField("appearance.complexion", "The skin's condition"),
1457
- ...TEXT_OR_LIST,
1496
+ ...COMPLEXION_LIST,
1497
+ required: true,
1458
1498
  describe:
1459
- `The skin's condition: ${wordList(lookValues("appearance.complexion"))}, ` +
1460
- "one word or a list of several, because a face carries more than one at " +
1461
- "once. A single word means a list of one. See " +
1499
+ `The skin's condition, as a list of ${wordList(lookValues("appearance.complexion"))}: ` +
1500
+ "`[weathered, freckled]`, `[clear]` for unremarked skin, or `[]` where no " +
1501
+ "condition is established. Required once a being states an appearance; a " +
1502
+ "single word outside a list is an error. See " +
1462
1503
  "[a being's look](format-details.md#a-beings-look) for what each word means.",
1463
- entries: Object.freeze({
1464
- kind: "string",
1465
- shape: "a string",
1466
- allowed: lookValues("appearance.complexion"),
1467
- }),
1468
1504
  },
1469
1505
  {
1470
1506
  name: "appearance.extra_features",
1471
1507
  ...TEXT_LIST,
1472
- describe: "Anything else a stranger would notice, in free text.",
1508
+ check: (note) => checkExtraFeatures(note, BEING_LOOK_VOCABULARY),
1509
+ describe:
1510
+ "The visible physical marks no word covers, each a short plain description " +
1511
+ "of one: `a burn scar on the left forearm`, `a missing finger`. An entry " +
1512
+ "stating a hair, eye or skin colour or a skin condition, gear or clothing, a " +
1513
+ "voice, behaviour or manner, an impression, handedness, sterility or an " +
1514
+ "apparent age is an error, as is any punctuation but a hyphen or apostrophe " +
1515
+ "inside a word. See [a being's look](format-details.md#a-beings-look).",
1473
1516
  },
1474
1517
  ]),
1475
1518
  }),
@@ -40,7 +40,8 @@
40
40
  * being in the package already holds it.
41
41
  * - `type: being`, `subType: npc`, `tags: [generated, draft]`.
42
42
  * - `frame`, and `appearance.eye_color`, `hair_color`, `skin_color` and
43
- * `complexion` — weighted draws from the culture's appearance profile.
43
+ * `complexion` — weighted draws from the culture's appearance profile;
44
+ * `complexion` is a list of the one word drawn.
44
45
  * - `height` — from the profile's range for the gender, in feet and inches.
45
46
  * - `weight` — from the height and the frame's body-mass band, in pounds.
46
47
  * - `born` — an adult age of {@link ADULT_AGE} years before the package's
@@ -346,8 +347,7 @@ export function appearanceParagraph({ name, age, gender, height, frame, appearan
346
347
  const sentences = [`${first}.`];
347
348
 
348
349
  const look = appearance ?? {};
349
- const complexion = [look.complexion ?? []]
350
- .flat()
350
+ const complexion = (Array.isArray(look.complexion) ? look.complexion : [])
351
351
  .filter(Boolean)
352
352
  .map((w) => wordLabel("appearance.complexion", w));
353
353
  const parts = [
@@ -491,7 +491,8 @@ export function generateNpc(spec, context) {
491
491
  const appearance = { ...look };
492
492
  for (const key of profileLookKeys(BEING_LOOK_VOCABULARY)) {
493
493
  if (key === "frame" || given(look[key])) continue;
494
- appearance[key] = weighted(profile[key], draw(`data.appearance.${key}`));
494
+ const word = weighted(profile[key], draw(`data.appearance.${key}`));
495
+ appearance[key] = key === "complexion" ? [word] : word;
495
496
  }
496
497
  if (!given(look.extra_features)) appearance.extra_features = [];
497
498
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.3.2",
3
+ "version": "23.4.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -191,8 +191,9 @@
191
191
  "build:asset-index": "node ci/emit-asset-index.mjs",
192
192
  "format": "prettier --write .",
193
193
  "format:check": "prettier --check .",
194
- "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:image-roles lint:banners lint:content-format",
194
+ "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:image-roles lint:banners lint:content-format lint:docs-links",
195
195
  "lint:markdown": "node bin/package-build.mjs markdown",
196
+ "lint:docs-links": "node bin/package-build.mjs docs links",
196
197
  "lint:yaml": "node bin/package-build.mjs yaml",
197
198
  "lint:labels": "node bin/package-build.mjs labels check",
198
199
  "lint:image-roles": "vitest run tests/theme-image-roles.test.ts",
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The punctuation an entry carries, a hyphen or apostrophe between letters
3
+ * excepted.
4
+ *
5
+ * @param {string} entry - The entry as written.
6
+ * @returns {string[]} Each distinct character, in order of appearance.
7
+ */
8
+ export function featurePunctuation(entry: string): string[];
9
+ /**
10
+ * Why an `extra_features` entry is not a short plain description of one
11
+ * physical mark.
12
+ *
13
+ * @param {string} entry - The entry as written.
14
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
15
+ * being-look vocabulary.
16
+ * @returns {Array<{rule: string, message: string}>} One problem per rule the
17
+ * entry breaks; empty when it is a mark. `rule` is `hair-colour`,
18
+ * `eye-colour`, `skin-colour`, `complexion`, `punctuation`, or a rule of
19
+ * {@link NON_PHYSICAL_FEATURES}.
20
+ */
21
+ export function featureProblems(entry: string, look: Readonly<Record<string, readonly {
22
+ value: string;
23
+ }[]>>): Array<{
24
+ rule: string;
25
+ message: string;
26
+ }>;
27
+ /**
28
+ * The frontmatter lint's check of `data.appearance.extra_features`.
29
+ *
30
+ * @param {object} note - The note: `file`, `raw` and parsed `fm`.
31
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
32
+ * being-look vocabulary.
33
+ * @returns {object[]} Error findings, each located at the entry at fault.
34
+ */
35
+ export function checkExtraFeatures(note: object, look: Readonly<Record<string, readonly {
36
+ value: string;
37
+ }[]>>): object[];
38
+ /**
39
+ * Hair and eye colour spellings the vocabulary does not take but an author
40
+ * writes, as regular-expression alternatives. Beside `hair` or `eyes` any of
41
+ * them, or any hair or eye colour word, states a colour.
42
+ */
43
+ export const COLOUR_VARIANTS: readonly string[];
44
+ /**
45
+ * Skin-condition phrasings that are not complexion words, as
46
+ * regular-expression alternatives. Any of them states a complexion.
47
+ */
48
+ export const CONDITION_PHRASES: readonly string[];
49
+ /**
50
+ * The terms that make an `extra_features` entry something other than a
51
+ * physical mark, by category, as case-insensitive whole-word patterns.
52
+ *
53
+ * `exemptMarks` lets an entry describing a tattoo, scar, brand or burn mark
54
+ * pass the category, so "a tattoo of a knight's helmet" stands while "wears a
55
+ * helmet" does not. The voice, impression, handedness, sterility and age
56
+ * categories admit no such exemption: "a beautiful tattoo of a crane" is
57
+ * refused.
58
+ *
59
+ * @type {ReadonlyArray<{rule: string, category: string, pattern: RegExp, exemptMarks: boolean}>}
60
+ */
61
+ export const NON_PHYSICAL_FEATURES: ReadonlyArray<{
62
+ rule: string;
63
+ category: string;
64
+ pattern: RegExp;
65
+ exemptMarks: boolean;
66
+ }>;
@@ -60,7 +60,8 @@ export function mapEntriesOf(value: unknown): Array<[string, unknown]>;
60
60
  *
61
61
  * @param {readonly object[]} fields - The type's `data:` declaration.
62
62
  * @returns {object[]} One spec per top-level key, a dotted group as a `map`
63
- * whose `fields` are its members, with `grouped: true`.
63
+ * whose `fields` are its members, with `grouped: true`, and each member
64
+ * marked `kindChecked`.
64
65
  */
65
66
  export function topLevelSpecs(fields: readonly object[]): object[];
66
67
  /**
@@ -1,5 +1,5 @@
1
1
  /** Preserve offsets while excluding literal examples from link scanning. */
2
- export function maskMarkdownCode(source: any, spans?: boolean): any;
2
+ export function maskMarkdownCode(source: any, spans?: boolean): string;
3
3
  /** GitHub heading slug, retaining separate spaces after punctuation removal. */
4
4
  export function docHeadingSlug(value: any): any;
5
5
  /** @param {string} source */
@@ -136,9 +136,9 @@ export function subTypes(type: string, vocabulary?: Readonly<Record<string, Type
136
136
  *
137
137
  * Each field is closed to its list: any other value, an empty one included, is
138
138
  * a finding at the value naming the words it takes. `appearance.complexion`
139
- * takes one word or a list of them, and each entry is checked on its own.
140
- * `appearance.extra_features` is not here: it is free text, a list of the marks
141
- * no word covers.
139
+ * is a list of words, each entry checked on its own. `appearance.extra_features`
140
+ * is not here: it is a list of the physical marks no word covers, held to the
141
+ * rules of `appearance-features.mjs`.
142
142
  *
143
143
  * @type {Readonly<Record<string, readonly LookWord[]>>}
144
144
  */