@heroiclands/package-build 17.1.0 → 18.0.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Every `sql` fence in a markdown body, with the position each occupies.
3
+ *
4
+ * Positions are 0-based lines into the body as given, which is what a diagnostic
5
+ * about a directive needs (#17) and what the expander uses to splice results
6
+ * back in.
7
+ *
8
+ * @param {string} markdown - The note body, frontmatter already stripped.
9
+ * @returns {Array<{line: number, close: number, indent: string, query: string,
10
+ * allowEmpty: boolean, sectionLevel: number, block: string}>} One entry per
11
+ * fence, in document order.
12
+ */
13
+ export function findSqlBlocks(markdown: string): Array<{
14
+ line: number;
15
+ close: number;
16
+ indent: string;
17
+ query: string;
18
+ allowEmpty: boolean;
19
+ sectionLevel: number;
20
+ block: string;
21
+ }>;
22
+ /**
23
+ * Open a DuckDB view over the content index.
24
+ *
25
+ * The records are written as JSON Lines to a temporary file and read with
26
+ * `read_json_auto`, rather than inserted row by row, because that is what makes
27
+ * the queries readable: DuckDB infers a `STRUCT` for every nested object, so a
28
+ * note's `sohl.weight` and `name.full` are addressed in a query exactly as they
29
+ * are authored in the note. A column-per-path table would force
30
+ * `"sohl.weight"` in quotes, and a JSON column would force `sohl->>'weight'`.
31
+ *
32
+ * `union_by_name` is what makes that work across a heterogeneous corpus: a
33
+ * `sohl:` block differs by note type, and the inferred struct is the union of
34
+ * every type's fields, with `NULL` where a record does not have one.
35
+ *
36
+ * `threads=1` so a result is byte-identical between runs. Rows tied under the
37
+ * authored `ORDER BY` then fall back to the index's own order, which is itself
38
+ * deterministic — the index is emitted sorted and byte-stable.
39
+ *
40
+ * ## A dependency is a schema
41
+ *
42
+ * A package that depends on another can tabulate what it depends on —
43
+ * `FROM sohl.notes` — because each declared dependency's published index is
44
+ * attached as a **schema** named after the package, with this package's own
45
+ * notes staying at the unqualified `notes`.
46
+ *
47
+ * It is `FROM` rather than a fence property naming a file, for two reasons. A
48
+ * path in authored content is a build artifact's name written into the corpus,
49
+ * so renaming the artifact means sweeping every note that cites it — the
50
+ * coupling #126 exists to undo. And *which dataset a query reads* is what
51
+ * `FROM` is for: the same rule that keeps `_ref` and `_section` ordinary SQL,
52
+ * visible where an author is already looking, rather than fence options.
53
+ *
54
+ * It costs no fetch. Every dependency's JSONL is already in the metadata cache
55
+ * when a compile starts, because resolving addresses across packages needs it.
56
+ *
57
+ * @param {object[]} records - Content-index records, as
58
+ * {@link module:engine/content-index.collectContentIndex} returns them.
59
+ * @param {object} [opts]
60
+ * @param {string} [opts.dir] - Directory for the temporary file.
61
+ * @param {Array<{id: string, file: string}>} [opts.dependencies] - Each
62
+ * declared dependency's cached index, attached as a schema named `id`.
63
+ * @returns {Promise<{query: (sql: string) => Promise<object[]>,
64
+ * close: () => Promise<void>}>} The open database.
65
+ */
66
+ export function openNotesDatabase(records: object[], { dir, dependencies }?: {
67
+ dir?: string | undefined;
68
+ dependencies?: {
69
+ id: string;
70
+ file: string;
71
+ }[] | undefined;
72
+ }): Promise<{
73
+ query: (sql: string) => Promise<object[]>;
74
+ close: () => Promise<void>;
75
+ }>;
76
+ /**
77
+ * Run one authored query and shape its result for the renderer.
78
+ *
79
+ * @param {object} db - From {@link openNotesDatabase}.
80
+ * @param {string} sql - The query, as authored.
81
+ * @returns {Promise<{columns: string[], rows: object[]}>} The rendered columns
82
+ * — every selected alias except the underscore-prefixed ones — and the rows.
83
+ */
84
+ export function runSqlQuery(db: object, sql: string): Promise<{
85
+ columns: string[];
86
+ rows: object[];
87
+ }>;
88
+ /**
89
+ * Render one query's result as markdown.
90
+ *
91
+ * A `_ref` alias makes the row's **first rendered column** a wikilink to that
92
+ * address; the reference is dropped when nothing can be linked, so a table never
93
+ * ships a link that does not resolve. A `_section` alias splits the result into
94
+ * a headed table per distinct value, in the order the rows arrive — so the
95
+ * authored `ORDER BY` decides the section order too, and one query replaces the
96
+ * forty near-identical blocks the language used to require.
97
+ *
98
+ * @param {{columns: string[], rows: object[]}} result - From
99
+ * {@link runSqlQuery}.
100
+ * @param {object} [opts]
101
+ * @param {(ref: string) => boolean} [opts.linkable] - Whether an address can be
102
+ * linked to; defaults to linking any non-empty `_ref`.
103
+ * @param {number} [opts.sectionLevel=2] - Heading level for `_section`.
104
+ * @returns {string} The markdown.
105
+ */
106
+ export function renderSqlTable(result: {
107
+ columns: string[];
108
+ rows: object[];
109
+ }, { linkable, sectionLevel }?: {
110
+ linkable?: ((ref: string) => boolean) | undefined;
111
+ sectionLevel?: number | undefined;
112
+ }): string;
113
+ /**
114
+ * Run every `sql` directive in a set of note bodies, ahead of expansion.
115
+ *
116
+ * **Why a separate pass.** DuckDB's API is asynchronous and
117
+ * {@link module:engine/content-tables.expandContentTables} is not — nor should
118
+ * it become so: two of its three callers are synchronous, and one of those,
119
+ * `renderPages`, is exported. Preparing the results first keeps every one of
120
+ * those signatures intact, and it is the shape #243 is heading for anyway —
121
+ * the corpus enumerated once, each pass reading the answer rather than
122
+ * deriving it again.
123
+ *
124
+ * A query that fails is recorded rather than thrown, so one bad directive costs
125
+ * its own table and not the whole build's report.
126
+ *
127
+ * @param {object} db - From {@link openNotesDatabase}.
128
+ * @param {Array<{source: string, markdown: string}>} sources - The bodies to
129
+ * scan.
130
+ * @param {object} [opts]
131
+ * @param {(ref: string) => boolean} [opts.linkable] - Passed to
132
+ * {@link renderSqlTable}.
133
+ * **Keyed by note, then by the directive's ordinal within it** — not by its
134
+ * line. The passes do not agree on what a body is: `walkMarkdownTree` trims it,
135
+ * while the link checker strips the frontmatter fence and leaves the newlines
136
+ * that followed, so the same directive sits on different lines in each. Its
137
+ * position in the sequence of fences is the same in both.
138
+ *
139
+ * @returns {Promise<Map<string, object[]>>} Note to results, in document order,
140
+ * each carrying either a rendered `markdown` and its `rows`, or a `reason`.
141
+ */
142
+ export function prepareSqlTables(db: object, sources: Array<{
143
+ source: string;
144
+ markdown: string;
145
+ }>, { linkable }?: {
146
+ linkable?: ((ref: string) => boolean) | undefined;
147
+ }): Promise<Map<string, object[]>>;
148
+ /**
149
+ * Answer every `sql` directive in a content tree.
150
+ *
151
+ * The one entry point each pass uses, so the compiler, the link checker and the
152
+ * site build cannot disagree about what a table selects — the failure mode #243
153
+ * describes, where N passes each derive the corpus their own way.
154
+ *
155
+ * **Nothing is opened for a tree with no `sql` directive.** The corpus is still
156
+ * written entirely in the retiring language, so until a table is converted this
157
+ * costs one walk and no database at all — which is what lets every pass call it
158
+ * unconditionally.
159
+ *
160
+ * @param {string} contentBase - Root of the content tree.
161
+ * @param {object} [opts]
162
+ * @param {object} [opts.config] - Resolved configuration, defaulting to ambient.
163
+ * @param {readonly string[]} [opts.skipDirectories] - The walk's scope.
164
+ * @param {object[]} [opts.records] - Index records the caller already derived.
165
+ * A command that also builds a link index holds them already, and deriving
166
+ * them twice is the duplicated-corpus failure #243 is closing.
167
+ * @returns {Promise<Map<string, object[]>|undefined>} Results by note path, or
168
+ * nothing when the tree has no such directive.
169
+ */
170
+ export function prepareTreeSqlTables(contentBase: string, { config, skipDirectories, records }?: {
171
+ config?: object | undefined;
172
+ skipDirectories?: readonly string[] | undefined;
173
+ records?: object[] | undefined;
174
+ }): Promise<Map<string, object[]> | undefined>;
175
+ /**
176
+ * Aliases the renderer reads rather than printing.
177
+ *
178
+ * `_ref` is the note a row points at, as the `type-shortcode` address a wikilink
179
+ * resolves — `address.slug` in the index. `_section` partitions the result into
180
+ * headed tables.
181
+ */
182
+ export const RENDER_ALIASES: Readonly<{
183
+ ref: "_ref";
184
+ section: "_section";
185
+ }>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The map one system ships, by its id.
3
+ *
4
+ * @param {string|undefined} system - The system id (`"sohl"`, `"hm3"`).
5
+ * @returns {import("./document-subtypes.mjs").DocumentSubtypeMap|undefined} Its
6
+ * map, or `undefined` where this toolchain ships none for it.
7
+ */
8
+ export function subtypeMapFor(system: string | undefined): import("./document-subtypes.mjs").DocumentSubtypeMap | undefined;
9
+ /**
10
+ * The document subtype a note type compiles into for one system — the
11
+ * translation the *schema* check needs, and the reason it needs one.
12
+ *
13
+ * A schema artifact is keyed by document subtype; a field declaration is keyed
14
+ * by note type. Those were the same string until #78 renamed three of them, and
15
+ * a check that went on joining them by name simply stopped reporting on
16
+ * `armorgear` — a warning that vanishes is worse than one that fires, because
17
+ * nothing says it went.
18
+ *
19
+ * Falls back to the note type on a one-to-many row, where nothing but a note
20
+ * can say which subtype is meant and this has none: that is the answer the
21
+ * identity default already gave, and HM3's four such rows all name the note
22
+ * type among their subtypes.
23
+ *
24
+ * @param {string|undefined} system - The system whose schema is being checked.
25
+ * @param {string} type - The note type a field declaration is keyed by.
26
+ * @returns {string} The document subtype to look up.
27
+ */
28
+ export function schemaSubtypeOf(system: string | undefined, type: string): string;
29
+ /**
30
+ * The note-type → document-subtype maps this toolchain ships.
31
+ *
32
+ * Two, since `hm3/` landed (#139) — and it joined this list rather than the
33
+ * claim table growing a second copy of the same fact, which is what the list
34
+ * was for.
35
+ *
36
+ * The union is what makes the vocabulary wider than any one repository's
37
+ * configuration: `armorlocation` is a real content type because HM3 maps it,
38
+ * however a given repository is configured, so a tree full of them is a
39
+ * repository that has not finished configuring itself rather than an author who
40
+ * invented a word.
41
+ *
42
+ * `engine/` importing from `sohl/` is the arrangement `generate.mjs` already
43
+ * has — its `COMPILERS` table names the SoHL compilers by class — and for the
44
+ * same reason: the engine owns the *mechanism* that asks each system what it
45
+ * compiles, and the systems own the answers.
46
+ *
47
+ * @type {readonly import("./document-subtypes.mjs").DocumentSubtypeMap[]}
48
+ */
49
+ export const KNOWN_DOCUMENT_SUBTYPE_MAPS: readonly import("./document-subtypes.mjs").DocumentSubtypeMap[];
@@ -93,6 +93,27 @@ export function sharedProperty(fm: object, source: string, defaultValue?: any):
93
93
  * @returns {any} The value.
94
94
  */
95
95
  export function blockProperty(fm: object, block: string, key: string, defaultValue?: any): any;
96
+ /**
97
+ * The key a field is authored at **inside** a system block — step 2.
98
+ *
99
+ * `legacyKey` when the field declares one, and `name` otherwise. The fallback
100
+ * is what makes this change invisible to every declaration written before it:
101
+ * a field whose shared source and in-block key are the same word says so once,
102
+ * as it always did.
103
+ *
104
+ * Named and exported rather than spelled inline because three readers ask the
105
+ * question and must agree — the resolver here, the frontmatter lint building
106
+ * the set of keys a block may carry, and the author-facing surfaces naming
107
+ * where a value was written.
108
+ *
109
+ * @param {{name?: string, legacyKey?: string}} field - The declaration.
110
+ * @returns {string|undefined} The in-block key, or `undefined` for a field that
111
+ * is not authored at all.
112
+ */
113
+ export function legacyKeyOf(field: {
114
+ name?: string;
115
+ legacyKey?: string;
116
+ }): string | undefined;
96
117
  /**
97
118
  * Where a declared field's value came from.
98
119
  *
@@ -151,6 +172,24 @@ export function systemDataPaths(data: Record<string, unknown>, prefix?: string):
151
172
  * this is the difference between "the field is lost at load" and "the build
152
173
  * told you where".
153
174
  *
175
+ * **A declared *leaf* holds values, not fields.** A schema declares a path that
176
+ * has no children of its own for two ordinary reasons — a map with **dynamic
177
+ * keys** (`mystery.skillAptitudes` is skill selector → modifier) and a
178
+ * **TypedSchemaField** (`strikeModes`, discriminated by `type`) — and in both
179
+ * the contents are data an author wrote, not paths the schema names. Walking
180
+ * into one reports every entry as an undeclared field: one finding per skill
181
+ * aptitude, per strike mode, per standing.
182
+ *
183
+ * It stayed invisible while those maps were authored *outside* `<system>.system`
184
+ * and so were never walked. The moment the corpus moves them to the destination
185
+ * (#126) every one of them lights up — 62 findings on `sohl-thalorna` alone,
186
+ * none of them a defect.
187
+ *
188
+ * So descent is conditional on the schema declaring something *beneath* the
189
+ * path. `body.structure` declares `parts` and `zones`, so it is a real
190
+ * container and an undeclared `adjacent` under it is a real finding;
191
+ * `skillAptitudes` declares nothing beneath it, so what is beneath is a value.
192
+ *
154
193
  * @param {Record<string, unknown>} data - The authored `system` data.
155
194
  * @param {ReadonlySet<string>} declared - Every field path the schema declares
156
195
  * for this subtype, inherited ones included.
@@ -265,7 +304,7 @@ export const BLOCK_DIRECTIVES: readonly string[];
265
304
  /**
266
305
  * Every key any system block may carry, whatever the system.
267
306
  *
268
- * A system adds its own on top — `archetype`, `kbcat` and the generators — and
307
+ * A system adds its own on top — `templatePriority`, `kbcat` and the generators — and
269
308
  * names them where it declares its vocabulary; see {@link unknownBlockKeys}.
270
309
  *
271
310
  * @type {ReadonlySet<string>}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Whether a value names a game system this toolchain knows.
3
+ *
4
+ * `none` is rejected: it is a real answer to "which system", but it is not a
5
+ * system, and a pack, a document-subtype map or a compiler table keyed by it
6
+ * would be keyed by nothing.
7
+ *
8
+ * @param {unknown} value - The candidate id.
9
+ * @returns {boolean} `true` when {@link SYSTEM_IDS} declares it.
10
+ */
11
+ export function isSystemId(value: unknown): boolean;
12
+ /**
13
+ * Whether a value is something the `<system>` segment may hold.
14
+ *
15
+ * The predicate an address parser wants: a declared system, or `none`.
16
+ *
17
+ * @param {unknown} value - The candidate segment.
18
+ * @returns {boolean} `true` when {@link SYSTEM_SEGMENTS} declares it.
19
+ */
20
+ export function isSystemSegment(value: unknown): boolean;
21
+ /**
22
+ * What a caller writing an unknown system is told.
23
+ *
24
+ * Separate from {@link assertSystemSegment} so that a pass collecting findings
25
+ * can report the same sentence it would have thrown — the message is the part
26
+ * worth sharing, and a reporter that had to invent its own wording is how two
27
+ * halves of a build come to explain one rule two ways.
28
+ *
29
+ * The nullish case is called out by name because it is the likely mistake and
30
+ * the least legible failure: a YAML `null`, a `~`, or a key written with no
31
+ * value at all reaches here as `undefined`, and "expected one of sohl, hm3,
32
+ * none" would leave a reader hunting for the value they cannot see.
33
+ *
34
+ * @param {unknown} value - The offending value.
35
+ * @param {string} [where] - What carried it, for the message — a note path, a
36
+ * configuration key, an address.
37
+ * @returns {string} The message, with no trailing period, in the style of the
38
+ * other vocabulary messages.
39
+ */
40
+ export function unknownSystemMessage(value: unknown, where?: string): string;
41
+ /**
42
+ * Refuse a value the `<system>` segment may not hold.
43
+ *
44
+ * Throws rather than returning a finding, matching the nearest neighbour —
45
+ * `assertVocabularyCharset` in `engine/note-vocabulary.mjs`, and the
46
+ * `configFromData` validators — because every caller of this is validating a
47
+ * declaration rather than surveying content. A note's bad value belongs in a
48
+ * report; a build asking to route, address or compile an undeclared system has
49
+ * nowhere to continue to.
50
+ *
51
+ * @param {unknown} value - The candidate segment.
52
+ * @param {string} [where] - What carried it, for the message.
53
+ * @returns {string} The value, unchanged, so a caller may validate inline.
54
+ * @throws {Error} Naming the value and the whole known vocabulary.
55
+ */
56
+ export function assertSystemSegment(value: unknown, where?: string): string;
57
+ /**
58
+ * Refuse a registry declaring an id that could not be an address segment.
59
+ *
60
+ * Run over {@link SYSTEM_SEGMENTS} as this module loads, so a declaration that
61
+ * breaks the charset cannot be imported, let alone shipped. Same reasoning as
62
+ * `assertVocabularyCharset`: a bad value in one note is one author's mistake,
63
+ * while a bad *declaration* puts an unreadable segment into every address that
64
+ * names the system.
65
+ *
66
+ * @param {Iterable<string>} segments - The declared ids, including `none`.
67
+ * @param {string} [where] - What declares them, for the message.
68
+ * @throws {Error} Naming every offending id at once — a reader fixing a list
69
+ * wants the whole list.
70
+ */
71
+ export function assertSystemCharset(segments: Iterable<string>, where?: string): void;
72
+ /**
73
+ * The `<system>` segment of a note that belongs to no system.
74
+ *
75
+ * Most notes are that: a `doc`, a `place`, a `macro` or a map compiles into a
76
+ * JournalEntry, a Scene or a Macro, which Foundry defines and no game system
77
+ * does. `none` is the format's word for it, in the address and in the content
78
+ * index alike, so the two say "no system" the same way.
79
+ *
80
+ * @type {string}
81
+ */
82
+ export const NO_SYSTEM: string;
83
+ /**
84
+ * Every game system this toolchain compiles for.
85
+ *
86
+ * The registry, and the only place the list is written down. Adding a system
87
+ * is an edit to this set plus the map and compilers that make it real — a data
88
+ * change, not a search for every place a name was spelled.
89
+ *
90
+ * `none` is **not** a member: it marks the absence of a system, and a caller
91
+ * asking "is this a system" and a caller asking "is this a well-formed
92
+ * segment" want different answers. See {@link isSystemId} and
93
+ * {@link isSystemSegment}.
94
+ *
95
+ * @type {ReadonlySet<string>}
96
+ */
97
+ export const SYSTEM_IDS: ReadonlySet<string>;
98
+ /**
99
+ * Everything the `<system>` segment of an address may say.
100
+ *
101
+ * The systems, plus {@link NO_SYSTEM}. Derived from the registry rather than
102
+ * listed again, so the two cannot drift.
103
+ *
104
+ * @type {ReadonlySet<string>}
105
+ */
106
+ export const SYSTEM_SEGMENTS: ReadonlySet<string>;
@@ -32,8 +32,10 @@ export function frontmatterWikilinks(fm: unknown): Array<{
32
32
  *
33
33
  * **Every target is an address**, parsed by {@link readQualifier} and looked up
34
34
  * case-insensitively in the KB-wide `ctx.index` (the canonical
35
- * `package-type-shortcode`, `type/shortcode`, and the site's own
36
- * `section/slug`), then in the vendored `ctx.foreign` manifests. A link written
35
+ * `package-system-type-shortcode`, `type/shortcode`, and the site's own
36
+ * `section/slug`), then in the vendored `ctx.foreign` manifests. A target is a
37
+ * *partial* address: an omitted package means this package, an omitted system
38
+ * is a wildcard, and exactly one match resolves — two are `ambiguous`. A link written
37
39
  * without a label addresses nothing at all and is reported as such (#180) —
38
40
  * there is no second namespace left for it to name.
39
41
  *
@@ -77,11 +77,11 @@ export function unlabelledLinkMessage(target: string): string;
77
77
  *
78
78
  * **Both corrections, because the author cannot tell which applies.** An
79
79
  * address lands nowhere either because the shortcode is wrong or because the
80
- * package publishing it has no manifest vendored here, and the link itself
81
- * looks identical in the two cases.
80
+ * package publishing it is not a declared dependency or is one whose index
81
+ * has not been fetched — and the link itself looks identical in every case.
82
82
  *
83
83
  * This used to be a **warning** in the checker and, in the site build, nothing
84
- * at all until every linkable package had vendored a manifest — on the
84
+ * at all until every linkable package's manifest was accounted for — on the
85
85
  * reasoning that a bare `[[Name]]` might be a placeholder for a note nobody had
86
86
  * written yet. That reasoning was a property of the bare form, which is retired
87
87
  * (#180); the intent behind it now has a real spelling, a `draft`-tagged note
@@ -101,6 +101,13 @@ export function unresolvedAddressMessage(target: string): string;
101
101
  * message names the claimants so the author can choose between them without
102
102
  * going looking.
103
103
  *
104
+ * **The correction is the canonical form**, all four segments (#59). Omission
105
+ * runs strictly left to right, so there is no `package-type-shortcode` to offer
106
+ * — naming a package means naming the system before the type. That used to be
107
+ * the correction here, back when a written target could state three segments at
108
+ * most; the grammar is positional now, and a three-segment target names a
109
+ * *system*, not a package.
110
+ *
104
111
  * @param {string} target - The address as authored.
105
112
  * @param {Iterable<string>} [packages] - The packages that publish it.
106
113
  * @returns {string} The message.
@@ -31,31 +31,58 @@ export function resolveItemDocType(qualifier: string, types: Set<string>): strin
31
31
  * Two separators are accepted, and they are **not** interchangeable in how
32
32
  * confidently they mark a target as qualified:
33
33
  *
34
- * - **`type-shortcode`** the canonical form (#1398). Obsidian reads `/` inside
35
- * a wikilink as a *path* and resolves it against the vault's folders, so a
36
- * slash-qualified link is a broken link in the editor where the content is now
37
- * authored. A hyphen qualifies **only when what precedes it is a known type**:
38
- * note names contain hyphens too (`Grukar-ahk`), and a target that is one is
39
- * reported as not an address rather than split at an arbitrary place. The
40
- * split is at the **first** hyphen, so a shortcode may itself contain one
41
- * (`trauma-self-pro` → `trauma` + `self-pro`).
34
+ * - **`type-shortcode`** and its qualified forms the canonical spelling
35
+ * (#1398). Obsidian reads `/` inside a wikilink as a *path* and resolves it
36
+ * against the vault's folders, so a slash-qualified link is a broken link in
37
+ * the editor where the content is now authored.
42
38
  * - **`type/shortcode`** — the legacy form, still resolved so that a link
43
39
  * written before the vault migrated does not silently die. A slash is
44
40
  * *unconditionally* a qualifier: nothing else uses one, so an unknown type
45
41
  * before it is reported rather than guessed at. The split is at the **last**
46
42
  * slash, as it always was.
47
43
  *
48
- * A leading **package** segment is optional and outermost: `sohl-skill-lang` is
49
- * `skill-lang` in the `sohl` package. It is read only when `packages` is given
50
- * and names the segment, and only when the remainder is itself a valid address,
51
- * so a note called "Grukar-ahk" is not mistaken for one (#1499).
44
+ * **The grammar is strict, and omission runs left to right** (#59):
45
+ *
46
+ * ```text
47
+ * [[[[<package>-]<system>-]<type>-]<shortcode>]
48
+ * ```
49
+ *
50
+ * So the written forms are exactly the suffixes of the canonical address —
51
+ * `type-shortcode`, `system-type-shortcode`, `package-system-type-shortcode` —
52
+ * and **`package-type-shortcode` is not one of them**. A link into another
53
+ * package must therefore be fully qualified, which is the price of the segment
54
+ * being positional rather than tagged.
55
+ *
56
+ * **Parsing is plain positional counting**, the same rule
57
+ * {@link readCanonicalKey} follows, and it is sound for the same reason: every
58
+ * segment is `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced on
59
+ * shortcodes by `content-lint.mjs` since #1397), so the hyphen is purely a
60
+ * separator and the count alone determines every field. Verified across the
61
+ * four content trees: 138,204 authored shortcodes, none carrying a separator.
62
+ *
63
+ * That replaced a first-hyphen split which let a shortcode contain a hyphen
64
+ * (`trauma-self-pro` → `trauma` + `self-pro`). The tolerance predates #1397 and
65
+ * outlived it; no tree has used it, and keeping it would make a three-segment
66
+ * target ambiguous between a system and a hyphenated shortcode.
67
+ *
68
+ * **`sohl` is both a package and a system**, and positional counting is what
69
+ * makes that harmless: three segments is `<system>-<type>-<shortcode>` whatever
70
+ * the first segment could also have named, and four is the full form. Nothing
71
+ * has to guess which sense was meant.
72
+ *
73
+ * **A partial address states what it states, and the rest is not invented.** An
74
+ * omitted **system** is a *wildcard* — most links target items, which belong to
75
+ * a system — so the caller matches on the segments supplied and requires
76
+ * exactly one hit. An omitted **package** is instead *defaulted* to the citing
77
+ * note's own, so an unqualified link resolves locally and only locally.
52
78
  *
53
79
  * @param {string} target - The link target, anchor already removed.
54
80
  * @param {Set<string>} types - Every type the content tree contains.
55
81
  * @param {Set<string>} [packages] - Every package an address may name. Omitted
56
82
  * by callers that resolve within one package, where the form cannot occur.
57
83
  * @returns {{type: string, shortcode: string, itemDoc: boolean,
58
- * package?: string, reason?: undefined} | {reason: "unknown-type"} | null}
84
+ * package?: string, system?: string, reason?: undefined}
85
+ * | {reason: "unknown-type"} | null}
59
86
  * The resolved qualifier; a `reason` when the target is definitely qualified
60
87
  * but names no known type; or `null` when it is not an address at all.
61
88
  */
@@ -64,6 +91,7 @@ export function readQualifier(target: string, types: Set<string>, packages?: Set
64
91
  shortcode: string;
65
92
  itemDoc: boolean;
66
93
  package?: string;
94
+ system?: string;
67
95
  reason?: undefined;
68
96
  } | {
69
97
  reason: "unknown-type";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The shared rule set, as an ESLint flat configuration.
3
+ *
4
+ * **Deliberately narrow, for the same reason the markdown and stylesheet rule
5
+ * sets are** — Prettier already owns YAML's whitespace, quoting and line
6
+ * breaks, including inside a frontmatter fence, so a rule about any of those
7
+ * would either duplicate the formatter or fight it. What is left is the class
8
+ * the formatter cannot see: text that parses to something other than what it
9
+ * looks like.
10
+ *
11
+ * - **A parse error is reported, not swallowed.** A duplicate key, a tab used
12
+ * as indentation, a mapping whose items start at different columns: the YAML
13
+ * parser detects all three and `parseMarkdownFile` discards all three.
14
+ * - `no-empty-mapping-value` — `folder:` and `folder: null` parse identically
15
+ * and read as opposites: one is a decision, the other is a key somebody began
16
+ * and did not finish. YAML cannot tell them apart, so the distinction has to
17
+ * be made where the text still exists. A key with a block under it is not
18
+ * empty, which is what separates 65 unfinished keys in `sohl-thalorna` from
19
+ * the tens of thousands that are ordinary containers.
20
+ * - `no-irregular-whitespace` — a non-breaking space in a key or an unquoted
21
+ * scalar is invisible in every editor and changes the value.
22
+ * - `no-empty-key`, `no-empty-document` — a fence or a file that parses to
23
+ * nothing at all.
24
+ *
25
+ * **GitHub workflows are exempt from `no-empty-mapping-value`**, because an
26
+ * empty value is the language there: `on:` `push:` and `workflow_dispatch:`
27
+ * carry their meaning by being present, and writing `push: null` to satisfy a
28
+ * linter would be worse YAML, not better.
29
+ *
30
+ * @param {object} plugin - The `eslint-plugin-yml` module.
31
+ * @returns {object[]} A complete flat config, for `overrideConfig`.
32
+ */
33
+ export function yamlLintConfig(plugin: object): object[];
34
+ /**
35
+ * The files to lint: every YAML file git would consider, and no other.
36
+ *
37
+ * `--cached --others --exclude-standard` is tracked files plus untracked ones
38
+ * that are not ignored, which is the same set `gitignore: true` gives the
39
+ * markdown linter — and it matters more than it sounds. Asking ESLint for
40
+ * `**\/*.md` in `Song-of-Heroic-Lands-FoundryVTT` offers it 60,792 files, almost
41
+ * all of them inside `nogit/` and `.claude/worktrees/`; this offers 1,888, in
42
+ * 20ms. Untracked-but-not-ignored is included so a note gets linted while it is
43
+ * being written, not only once it has been staged.
44
+ *
45
+ * `-z` because git otherwise quotes any path outside ASCII, and these trees are
46
+ * full of them.
47
+ *
48
+ * @param {string} root - Repository root.
49
+ * @param {readonly string[]} [globs] - Path globs, defaulting to
50
+ * {@link YAML_GLOBS}.
51
+ * @returns {string[]} Repository-relative paths.
52
+ */
53
+ export function candidateFiles(root: string, globs?: readonly string[]): string[];
54
+ /**
55
+ * Lint a repository's YAML against the shared rule set.
56
+ *
57
+ * ESLint is run through its Node API with `overrideConfigFile: true`, which
58
+ * stops it looking for an `eslint.config.js` at all. That is what lets this ship
59
+ * as a command rather than as a configuration a consumer has to adopt: a
60
+ * repository needs no ESLint of its own, and one that *has* an ESLint — as
61
+ * `Song-of-Heroic-Lands-FoundryVTT` does, for `src/` — keeps it untouched and
62
+ * unconsulted.
63
+ *
64
+ * @param {string} root - Repository to lint.
65
+ * @param {object} [opts]
66
+ * @param {readonly string[]} [opts.paths] - Globs to lint instead of every
67
+ * YAML file git would consider.
68
+ * @param {object} [opts.plugin] - The `eslint-plugin-yml` module, for tests.
69
+ * @param {Function} [opts.ESLint] - The `ESLint` class, for tests.
70
+ * @returns {Promise<{findings: object[], checked: number}>} The findings, and
71
+ * how many files were linted.
72
+ */
73
+ export function lintYaml(root: string, opts?: {
74
+ paths?: readonly string[] | undefined;
75
+ plugin?: object | undefined;
76
+ ESLint?: Function | undefined;
77
+ }): Promise<{
78
+ findings: object[];
79
+ checked: number;
80
+ }>;
81
+ /** File extensions that carry YAML, either wholly or in a frontmatter fence. */
82
+ export const YAML_GLOBS: readonly string[];
83
+ /**
84
+ * Present a markdown file to ESLint as the YAML it begins with.
85
+ *
86
+ * An ESLint *processor* carves virtual files out of a container file — the
87
+ * mechanism `eslint-plugin-markdown` uses for fenced code blocks. Frontmatter is
88
+ * the easy case of it: the block is always at the top of the file, so mapping a
89
+ * finding back to the file it came from is a constant `+1` for the opening
90
+ * `---`, with no offset table to keep.
91
+ *
92
+ * The virtual file is named `0.yaml` so the flat config's `**` + `*.yaml`
93
+ * patterns select it; ESLint addresses it as `<the note>.md/0.yaml`.
94
+ *
95
+ * @type {{meta: object, supportsAutofix: boolean,
96
+ * preprocess: (text: string) => Array<{text: string, filename: string}>,
97
+ * postprocess: (messages: object[][]) => object[]}}
98
+ */
99
+ export const frontmatterProcessor: {
100
+ meta: object;
101
+ supportsAutofix: boolean;
102
+ preprocess: (text: string) => Array<{
103
+ text: string;
104
+ filename: string;
105
+ }>;
106
+ postprocess: (messages: object[][]) => object[];
107
+ };