@heroiclands/package-build 23.5.1 → 23.6.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 (43) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/content-commands.mjs +23 -5
  3. package/docs/api.md +9 -0
  4. package/docs/authoring/links-and-markup.md +77 -12
  5. package/docs/content-format.md +16 -0
  6. package/docs/guides/reference-pages.md +89 -0
  7. package/docs/reference/format-details.md +533 -84
  8. package/docs/reference/note-types.md +191 -149
  9. package/engine/base-compiler.mjs +21 -0
  10. package/engine/content-format.yaml +9 -0
  11. package/engine/content-index.mjs +19 -5
  12. package/engine/culture-reach.mjs +330 -0
  13. package/engine/dictionary-layout.mjs +340 -0
  14. package/engine/dictionary-rows.mjs +608 -0
  15. package/engine/glossary-entries.mjs +182 -0
  16. package/engine/glossary-language.mjs +99 -0
  17. package/engine/index.mjs +1 -0
  18. package/engine/infobox.mjs +8 -0
  19. package/engine/language-words.mjs +360 -0
  20. package/engine/literature-notes.mjs +10 -8
  21. package/engine/note-addresses.mjs +25 -0
  22. package/engine/note-vocabulary.mjs +161 -1
  23. package/engine/path-charset.mjs +120 -0
  24. package/engine/sql-tables.mjs +311 -21
  25. package/engine/word-classes.mjs +167 -0
  26. package/engine/word-rows.mjs +233 -0
  27. package/engine/word-terms.mjs +306 -0
  28. package/package.json +1 -1
  29. package/types/engine/content-index.d.mts +15 -5
  30. package/types/engine/culture-reach.d.mts +66 -0
  31. package/types/engine/dictionary-layout.d.mts +69 -0
  32. package/types/engine/dictionary-rows.d.mts +39 -0
  33. package/types/engine/glossary-entries.d.mts +12 -0
  34. package/types/engine/glossary-language.d.mts +42 -0
  35. package/types/engine/index.d.mts +1 -0
  36. package/types/engine/language-words.d.mts +33 -0
  37. package/types/engine/literature-notes.d.mts +2 -2
  38. package/types/engine/note-addresses.d.mts +17 -0
  39. package/types/engine/path-charset.d.mts +38 -0
  40. package/types/engine/sql-tables.d.mts +7 -2
  41. package/types/engine/word-classes.d.mts +87 -0
  42. package/types/engine/word-rows.d.mts +64 -0
  43. package/types/engine/word-terms.d.mts +68 -0
@@ -17,9 +17,10 @@
17
17
  * `data.culture`, is a field every lore note but a culture note may state,
18
18
  * declared in `culture-choice.mjs`.
19
19
  *
20
- * `data.language` is shared with the `culture` subType: a work names the
21
- * tongue it is composed in, and a culture names the tongue its people speak,
22
- * whose name lists `package-build npc new` draws from.
20
+ * `data.language` is shared beyond literature: a work names the tongue it is
21
+ * composed in, a culture the tongue its people speak, whose name lists
22
+ * `package-build npc new` draws from, and an affiliation the trade, temple or
23
+ * order tongue it keeps.
23
24
  *
24
25
  * `DataFieldSpec` declares the keys a type accepts, not the subType that may
25
26
  * write them, so {@link checkLiteratureNote} scopes these keys to the subTypes
@@ -74,8 +75,8 @@ export function checkLanguageChoice(note, { index } = {}) {
74
75
  }
75
76
 
76
77
  /**
77
- * `data.language`: the tongue a work is composed in, or the tongue a culture's
78
- * people speak. Optional on both.
78
+ * `data.language`: the tongue a work is composed in, the tongue a culture's
79
+ * people speak, or the tongue an affiliation keeps. Optional on all three.
79
80
  *
80
81
  * @type {object}
81
82
  */
@@ -87,9 +88,10 @@ export const LANGUAGE_FIELD = Object.freeze({
87
88
  accepts: Object.freeze(["skill"]),
88
89
  check: checkLanguageChoice,
89
90
  describe:
90
- "The tongue the work is composed in, or on a culture note the tongue its people " +
91
- "speak, as a language skill note. `package-build npc new` draws a culture's names " +
92
- "from its language's `data.names`. A `literature` or `culture` note only.",
91
+ "The tongue the note is in, as a language skill note: the tongue a work is composed " +
92
+ "in, a culture's people speak, or an affiliation keeps as its trade, temple or order " +
93
+ "tongue. `package-build npc new` draws a culture's names from its language's " +
94
+ "`data.names`. A `literature` or `culture` lore note, or an affiliation, only.",
93
95
  });
94
96
 
95
97
  /**
@@ -69,6 +69,31 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
69
69
  type: "place",
70
70
  accepts: ["place"],
71
71
  },
72
+ // A language's word Addresses and a glossary entry's language, read
73
+ // leniently: a value that is no Address stays as written, for the words or
74
+ // glossary check to refuse at its own key.
75
+ {
76
+ noteType: "skill",
77
+ path: ["data", "words", "*", "attested"],
78
+ shape: "list",
79
+ lenient: true,
80
+ },
81
+ {
82
+ noteType: "skill",
83
+ path: ["data", "words", "*", "source", "language"],
84
+ shape: "value",
85
+ type: "skill",
86
+ accepts: ["skill"],
87
+ lenient: true,
88
+ },
89
+ ...["lore", "affiliation"].map((noteType) => ({
90
+ noteType,
91
+ path: ["data", "glossary", "*", "language"],
92
+ shape: "value",
93
+ type: "skill",
94
+ accepts: ["skill"],
95
+ lenient: true,
96
+ })),
72
97
  ]);
73
98
 
74
99
  /**
@@ -119,6 +119,20 @@ import {
119
119
  } from "./appearance-profile.mjs";
120
120
  import { checkExtraFeatures } from "./appearance-features.mjs";
121
121
  import { LANGUAGE_FIELD, LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
122
+ import {
123
+ AFFIX_FIELDS,
124
+ GLOSSARY_ENTRY_FIELDS,
125
+ WORD_FIELDS,
126
+ WORD_FIELD_FIELDS,
127
+ } from "./word-terms.mjs";
128
+ import { WORD_CLASS_MODIFIERS } from "./word-classes.mjs";
129
+ import {
130
+ checkLanguageAbbreviation,
131
+ checkLanguageFamily,
132
+ checkLanguageWords,
133
+ } from "./language-words.mjs";
134
+ import { checkGlossary } from "./glossary-entries.mjs";
135
+ import { checkAffiliationReach } from "./culture-reach.mjs";
122
136
  import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
123
137
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
124
138
  import { parseNoteDate } from "./note-dates.mjs";
@@ -540,6 +554,126 @@ const APPEARANCE_PROFILE_FIELD = Object.freeze({
540
554
  ]),
541
555
  });
542
556
 
557
+ /**
558
+ * A language's words and what they are read against: the class modifiers it
559
+ * uses, the fields its words are grouped under, and the affixes a word's
560
+ * pieces may name. A language skill note only; {@link checkLanguageWords}
561
+ * checks what the values mean.
562
+ *
563
+ * @type {ReadonlyArray<DataFieldSpec>}
564
+ */
565
+ const WORD_DATA_FIELDS = Object.freeze([
566
+ Object.freeze({
567
+ name: "classModifiers",
568
+ kind: "list",
569
+ shape: "list of word class modifiers",
570
+ entries: Object.freeze({
571
+ kind: "string",
572
+ allowed: Object.freeze(WORD_CLASS_MODIFIERS.map((item) => item.token)),
573
+ shape: "a word class modifier",
574
+ }),
575
+ describe:
576
+ "The class modifiers the language uses, from the master word classes. A word's " +
577
+ "class may carry only these. Empty when unstated. A language skill note only.",
578
+ }),
579
+ Object.freeze({
580
+ name: "fields",
581
+ kind: "list",
582
+ shape: "list of `{ name, holds }`",
583
+ entries: Object.freeze({
584
+ kind: "map",
585
+ shape: "a map — `{ name, holds }`",
586
+ fields: WORD_FIELD_FIELDS,
587
+ }),
588
+ describe: "The fields the language's words are grouped under. A language skill note only.",
589
+ }),
590
+ Object.freeze({
591
+ name: "words",
592
+ kind: "list",
593
+ shape: "list of `{ form, class, field, gloss, … }`",
594
+ entries: Object.freeze({
595
+ kind: "map",
596
+ shape: "a map — `{ form, class, field, gloss, … }`",
597
+ fields: WORD_FIELDS,
598
+ }),
599
+ describe:
600
+ "The language's words, one map each, in the order they print. A language skill " +
601
+ "note only. See [a language's words](format-details.md#a-languages-words).",
602
+ }),
603
+ Object.freeze({
604
+ name: "affixes",
605
+ kind: "list",
606
+ shape: "list of `{ form, gloss, attaches?, examples? }`",
607
+ entries: Object.freeze({
608
+ kind: "map",
609
+ shape: "a map — `{ form, gloss, attaches?, examples? }`",
610
+ fields: AFFIX_FIELDS,
611
+ }),
612
+ describe:
613
+ "The affixes a word's `built` pieces may name, each with its gloss. A language " +
614
+ "skill note only.",
615
+ }),
616
+ ]);
617
+
618
+ /**
619
+ * A language's `data.family`: the parent language whose words it reads as its
620
+ * own. {@link checkLanguageFamily} checks it.
621
+ *
622
+ * @type {DataFieldSpec}
623
+ */
624
+ const FAMILY_FIELD = Object.freeze({
625
+ name: "family",
626
+ kind: "address",
627
+ shape: "an Address",
628
+ ref: "skill",
629
+ accepts: Object.freeze(["skill"]),
630
+ check: checkLanguageFamily,
631
+ describe:
632
+ "The parent language, as a language skill note: this language reads the parent's " +
633
+ "words, and its parents' in turn, as its own. A language skill note only. See " +
634
+ "[a language's words](format-details.md#a-languages-words).",
635
+ });
636
+
637
+ /**
638
+ * A language's `data.abbreviation`: the tag a dictionary prints beside an
639
+ * entry's class. {@link checkLanguageAbbreviation} checks it.
640
+ *
641
+ * @type {DataFieldSpec}
642
+ */
643
+ const ABBREVIATION_FIELD = Object.freeze({
644
+ name: "abbreviation",
645
+ kind: "string",
646
+ shape: "a string, such as `Prov.`",
647
+ check: checkLanguageAbbreviation,
648
+ describe:
649
+ "The tag a dictionary prints for the language, beside an entry's class and in an " +
650
+ "etymology, as `Prov.`; the language's full name where unstated. A language skill " +
651
+ "note only. See [a language's words](format-details.md#a-languages-words).",
652
+ });
653
+
654
+ /**
655
+ * A culture's or an affiliation's `data.glossary`: the terms that belong to
656
+ * its people or members rather than to a language's grammar.
657
+ * {@link checkGlossary} checks what an entry means.
658
+ *
659
+ * @type {DataFieldSpec}
660
+ */
661
+ const GLOSSARY_FIELD = Object.freeze({
662
+ name: "glossary",
663
+ kind: "list",
664
+ shape: "list of `{ name, name_sort?, common?, definition?, pronunciation?, language? }`",
665
+ entries: Object.freeze({
666
+ kind: "map",
667
+ shape: "a map — `{ name, name_sort?, common?, definition?, pronunciation?, language? }`",
668
+ fields: GLOSSARY_ENTRY_FIELDS,
669
+ }),
670
+ describe:
671
+ "Its own glossary terms: euphemisms, idioms, terms of art and titles that are not " +
672
+ "ranks. An entry with a `definition` creates a term or replaces its senses; one " +
673
+ "without marks a term the note already has. Every entry resolves to a language. " +
674
+ "Empty when unstated. See [glossary terms](format-details.md#glossary-terms).",
675
+ });
676
+
543
677
  /**
544
678
  * A language's `data.names`: the pattern its speakers' names are built by and
545
679
  * the lists each part is drawn from. Its keys beyond `pattern` and `given` are
@@ -1527,7 +1661,14 @@ export const NOTE_VOCABULARY = Object.freeze({
1527
1661
 
1528
1662
  affiliation: Object.freeze({
1529
1663
  stubbable: true,
1530
- check: checkAffiliationRankFloor,
1664
+ // A body's rank floor, whether it reaches a culture at all — a body
1665
+ // reaching none puts its ranks, offices and language in no culture's
1666
+ // glossary — and its own glossary.
1667
+ check: (note, context) => [
1668
+ ...checkAffiliationRankFloor(note, context),
1669
+ ...checkAffiliationReach(note, context),
1670
+ ...checkGlossary(note, context),
1671
+ ],
1531
1672
  subTypes: Object.freeze([
1532
1673
  "guild",
1533
1674
  "order",
@@ -1645,6 +1786,14 @@ export const NOTE_VOCABULARY = Object.freeze({
1645
1786
  ...CULTURE_FIELD,
1646
1787
  describe: "The people this affiliation belongs to, as a culture lore note.",
1647
1788
  },
1789
+ {
1790
+ ...LANGUAGE_FIELD,
1791
+ describe:
1792
+ "The trade, temple or order tongue the affiliation keeps, as a language " +
1793
+ "skill note. It reaches the affiliation's culture, and its words enter that " +
1794
+ "culture's glossary. A glossary entry that states no `language` is in it.",
1795
+ },
1796
+ GLOSSARY_FIELD,
1648
1797
  {
1649
1798
  name: "relations",
1650
1799
  kind: "map",
@@ -1839,6 +1988,9 @@ export const NOTE_VOCABULARY = Object.freeze({
1839
1988
 
1840
1989
  skill: Object.freeze({
1841
1990
  stubbable: true,
1991
+ // A language note's words, held to the master word classes and to what
1992
+ // the note declares; no other skill states them.
1993
+ check: checkLanguageWords,
1842
1994
  subTypes: Object.freeze([
1843
1995
  "social",
1844
1996
  "nature",
@@ -1862,6 +2014,9 @@ export const NOTE_VOCABULARY = Object.freeze({
1862
2014
  describe: "The skill this one specialises.",
1863
2015
  },
1864
2016
  NAMES_FIELD,
2017
+ FAMILY_FIELD,
2018
+ ABBREVIATION_FIELD,
2019
+ ...WORD_DATA_FIELDS,
1865
2020
  ]),
1866
2021
  }),
1867
2022
 
@@ -2045,6 +2200,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2045
2200
  ...checkCalendarNote(note, context),
2046
2201
  ...checkLiteratureNote(note),
2047
2202
  ...checkCultureSubType(note, context),
2203
+ ...checkGlossary(note, context),
2048
2204
  ],
2049
2205
  data: Object.freeze([
2050
2206
  ...CALENDAR_FIELDS,
@@ -2068,6 +2224,10 @@ export const NOTE_VOCABULARY = Object.freeze({
2068
2224
  LANGUAGE_FIELD,
2069
2225
  EVENTS_FIELD,
2070
2226
  APPEARANCE_PROFILE_FIELD,
2227
+ {
2228
+ ...GLOSSARY_FIELD,
2229
+ describe: `\`culture\` only: ${GLOSSARY_FIELD.describe}`,
2230
+ },
2071
2231
  ]),
2072
2232
  }),
2073
2233
 
@@ -0,0 +1,120 @@
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
+ * The characters a content or picture path may hold.
16
+ *
17
+ * Every character of a path is printable 7-bit ASCII, U+0020 to U+007E. A note's
18
+ * identity comes from its `shortcode` and a picture's from its basename, so a
19
+ * path's spelling changes no address; what a non-ASCII path does change is how
20
+ * the file travels. Filesystems disagree on Unicode normalisation, shells and
21
+ * `git ls-files` quote the bytes, and archive tools and CDN keys each encode
22
+ * the name their own way.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+
30
+ import { DEPLOYED_ASSET_TYPES } from "./asset-types.mjs";
31
+
32
+ /**
33
+ * The distinct characters of a string outside U+0020 to U+007E, in order of
34
+ * first appearance.
35
+ *
36
+ * @param {string} text - A path, or one component of one.
37
+ * @returns {string[]} The offending characters; empty when the text is clean.
38
+ */
39
+ export function offendingPathCharacters(text) {
40
+ const found = [];
41
+ for (const ch of text) {
42
+ const point = ch.codePointAt(0) ?? 0;
43
+ if ((point < 0x20 || point > 0x7e) && !found.includes(ch)) found.push(ch);
44
+ }
45
+ return found;
46
+ }
47
+
48
+ /**
49
+ * The message for a path holding characters outside printable ASCII.
50
+ *
51
+ * @param {string[]} characters - The offending characters.
52
+ * @returns {string} The finding's message.
53
+ */
54
+ function pathMessage(characters) {
55
+ const named = characters.map((ch) => {
56
+ const point = ch.codePointAt(0) ?? 0;
57
+ const code = `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
58
+ return point < 0x20 || point === 0x7f ? code : `${code} (${ch})`;
59
+ });
60
+ return (
61
+ `the path holds ${named.join(", ")}, outside printable ASCII; ` +
62
+ "rename the file using ASCII only"
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Walk the content tree and the package's picture roots, reporting each entry
68
+ * whose name holds a character outside printable ASCII.
69
+ *
70
+ * An entry is checked by its own name as the walk reaches it, so a directory
71
+ * with a bad name is reported once and not again for every file beneath it.
72
+ * Dot-entries and `skipDirectories` are skipped, as in the other content walks.
73
+ * A finding names its file relative to the working directory, with no line or
74
+ * column.
75
+ *
76
+ * @param {object} opts - Options.
77
+ * @param {string} opts.contentBase - Root of the content tree.
78
+ * @param {string} opts.assetsBase - The package's asset directory, whose
79
+ * picture roots are walked.
80
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
81
+ * @returns {{findings: Array<{file: string, severity: "error", message: string}>,
82
+ * files: number}} The findings, and how many paths were checked.
83
+ */
84
+ export function lintPathCharset({ contentBase, assetsBase, skipDirectories = [] }) {
85
+ const skip = new Set(skipDirectories);
86
+ const findings = [];
87
+ let files = 0;
88
+
89
+ /** @param {string} dir - Directory to descend into. */
90
+ const walk = (dir) => {
91
+ /** @type {import("node:fs").Dirent[]} */
92
+ let entries;
93
+ try {
94
+ entries = fs.readdirSync(dir, { withFileTypes: true });
95
+ } catch {
96
+ return;
97
+ }
98
+ for (const entry of entries) {
99
+ if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
100
+ const full = path.join(dir, entry.name);
101
+ files += 1;
102
+ const bad = offendingPathCharacters(entry.name);
103
+ if (bad.length) {
104
+ findings.push({
105
+ file: path.relative(process.cwd(), full),
106
+ severity: /** @type {const} */ ("error"),
107
+ message: pathMessage(bad),
108
+ });
109
+ }
110
+ if (entry.isDirectory()) walk(full);
111
+ }
112
+ };
113
+
114
+ const roots = new Set([
115
+ path.resolve(contentBase),
116
+ ...DEPLOYED_ASSET_TYPES.map((entry) => path.resolve(assetsBase, entry.root)),
117
+ ]);
118
+ for (const root of roots) walk(root);
119
+ return { findings, files };
120
+ }