@heroiclands/package-build 22.17.1 → 22.18.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.
@@ -20,10 +20,8 @@
20
20
  * address grammar asks on every note, and the issue's answer is that it comes
21
21
  * from a **registry rather than from a hardcoded set**: known system ids are
22
22
  * declared here, once, an unknown value is an error, and _adding a system is a
23
- * data change_. Before this module the same fact was spelled in as many places
24
- * as needed it — a `NO_SYSTEM` constant in the content index, a `map.system`
25
- * per document-subtype map, a system-keyed table per compiler — none of which
26
- * could refuse a value none of the others had heard of.
23
+ * data change_. The same fact is read by address parsing, configuration and
24
+ * linting.
27
25
  *
28
26
  * **`none` is a word, deliberately.** It is not a YAML null (`null`, `~`, or an
29
27
  * empty value), because those spell "nobody filled this in", and a note that
@@ -38,8 +36,8 @@
38
36
  * the configuration loader, by a compiler, and by a lint without closing a
39
37
  * cycle around any of them. In particular the ids are *declared* here rather
40
38
  * than derived from `KNOWN_DOCUMENT_SUBTYPE_MAPS` — deriving them would drag
41
- * `sohl/` and `hm3/` behind every import of this file, for a list of two
42
- * words. `tests/systems.test.ts` holds the two in step instead, which is where
39
+ * system-specific modules behind every import of this file. `tests/systems.test.ts`
40
+ * holds the registry and note-key vocabulary in step, which is where
43
41
  * the agreement between a registry and its implementations belongs.
44
42
  *
45
43
  * @module
@@ -66,11 +64,11 @@ export const NO_SYSTEM = "none";
66
64
  export const NOTE_SYSTEM = "note";
67
65
 
68
66
  /**
69
- * Every game system this toolchain compiles for.
67
+ * Every game system identifier this toolchain recognizes.
70
68
  *
71
- * The registry, and the only place the list is written down. Adding a system
72
- * is an edit to this set plus the map and compilers that make it real — a data
73
- * change, not a search for every place a name was spelled.
69
+ * The registry, and the only place the list is written down. A recognized id
70
+ * can have no system-specific document types; adding one of those requires a
71
+ * subtype map and compiler as well.
74
72
  *
75
73
  * `none` is **not** a member: it marks the absence of a system, and a caller
76
74
  * asking "is this a system" and a caller asking "is this a well-formed
@@ -79,7 +77,7 @@ export const NOTE_SYSTEM = "note";
79
77
  *
80
78
  * @type {ReadonlySet<string>}
81
79
  */
82
- export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3"]));
80
+ export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3", "dnd5e"]));
83
81
 
84
82
  /**
85
83
  * Everything the `<system>` segment of an address may say.
@@ -127,8 +125,8 @@ export function isSystemSegment(value) {
127
125
  *
128
126
  * The nullish case is called out by name because it is the likely mistake and
129
127
  * the least legible failure: a YAML `null`, a `~`, or a key written with no
130
- * value at all reaches here as `undefined`, and "expected one of sohl, hm3,
131
- * none" would leave a reader hunting for the value they cannot see.
128
+ * value at all reaches here as `undefined`, and a list of accepted values
129
+ * would leave a reader hunting for the value they cannot see.
132
130
  *
133
131
  * @param {unknown} value - The offending value.
134
132
  * @param {string} [where] - What carried it, for the message — a note path, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.17.1",
3
+ "version": "22.18.0",
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",
@@ -230,11 +230,10 @@ export class SystemActorCompiler extends BasePackCompiler {
230
230
  * The actor's two pieces of art, resolved, with the being default beneath
231
231
  * them.
232
232
  *
233
- * **The default is chosen from the note's tags**, which only a compiler can
234
- * read: a `character` falls back to one file and a `creature` to another,
235
- * and both are addresses in the package that ships them. A tree whose index
236
- * cannot answer that address — a dependency not yet fetched — falls through
237
- * to the subtype's own default, so the document is never left with no art.
233
+ * **The default follows the note's `subType`**. Both addresses belong to
234
+ * the package that ships them. A tree whose index cannot answer an
235
+ * address — a dependency not yet fetched — falls through to the document
236
+ * subtype's own default, so the document is never left with no art.
238
237
  *
239
238
  * `tokenIcon` unset follows `icon`, and the fallback is applied after
240
239
  * resolution rather than before: a note naming an icon and no token icon
@@ -1,12 +1,9 @@
1
1
  /**
2
2
  * The one spelling of an address in this space: `type:shortcode`.
3
3
  *
4
- * Written by both readers — the compiled packs on each side of the diff, and
5
- * the declarations read out of the tree — so a predecessor a note names and an
6
- * address a pack publishes cannot come apart over punctuation or case. The
7
- * shortcode is used **verbatim**, not lowercased: `readItemAddresses` reads it
8
- * off the compiled document, where `Tabri` is stored as authored, and folding
9
- * case here would join two addresses the packs keep apart.
4
+ * Used for addresses read from compiled documents. The shortcode is used
5
+ * **verbatim**, not lowercased: folding case would join two addresses the packs
6
+ * keep apart.
10
7
  *
11
8
  * @param {string} type - The Foundry document subtype, not the note type.
12
9
  * @param {string} shortcode - The address's `system.shortcode`.
@@ -39,89 +36,24 @@ export function readItemAddresses(dirs: readonly string[]): Map<string, {
39
36
  shortcode: string;
40
37
  file: string;
41
38
  }>;
42
- /**
43
- * Every rename the tree's notes **declare**, as old address → where it went.
44
- *
45
- * Read from the content tree rather than from compiled output, because a
46
- * declaration is authored and the compiled document does not carry it: nothing
47
- * downstream consumes `renamedFrom:`, so emitting it into every pack to let one
48
- * diagnostic read it back would put a build-time note in shipped data forever.
49
- * The tree is already read by this module for the same reason
50
- * ({@link noteFilesById}) — to place a finding where its author can fix it.
51
- *
52
- * **A declaration is keyed by document subtype, not by note type.** The address
53
- * space is the one consumers resolve against, and it is spelled in compiled
54
- * documents: `hm3` compiles a `projectile` note into a `missilegear` item,
55
- * so that is the address a rename of it moves. {@link referencedSubtype} is the
56
- * function that already answers this for a being's embedded `(type, shortcode)`
57
- * references, so both sides read the same rule rather than a second copy of it.
58
- *
59
- * **An entry is emitted for every system that maps the type**, whether or not
60
- * the note declares that system's block. Over-emitting is inert — the diff uses
61
- * an entry only when the baseline published the old address *and* this build
62
- * publishes the new one, and a system the note does not compile for satisfies
63
- * neither — while asking which blocks a note declares would put a second,
64
- * subtly different answer to that question in a third place.
65
- *
66
- * **First claim wins on a collision.** Two notes naming one predecessor is a
67
- * contradiction — an address has one successor — and it is reported as an error
68
- * by `engine/content-lint.mjs`, where both notes are in hand and can both be
69
- * named. Picking one here keeps this a map; it is not a resolution, and nothing
70
- * rests on which one it picked.
71
- *
72
- * @param {string} contentBase - Root of the content tree.
73
- * @param {object} opts
74
- * @param {readonly string[]} [opts.skipDirectories] - The corpus scope. Stated
75
- * by the caller, never defaulted — see {@link addressCorpus}.
76
- * @param {readonly object[]} [opts.maps] - The document-subtype maps.
77
- * @param {object} [opts.config] - The resolved build configuration.
78
- * @param {readonly object[]} [opts.records] - Index records the caller already
79
- * derived, shared with {@link noteFilesById} so one command reads one corpus.
80
- * @param {object[]} [opts.problems] - Collects the notes the index cannot
81
- * record, so one of them does not abort the diff before it reports.
82
- * @returns {Map<string, {to: string, file: string, shortcode: string}>} Old
83
- * address → the address the declaring note publishes at now, and that note.
84
- */
85
- export function declaredPredecessors(contentBase: string, { skipDirectories, maps, config, records, problems }?: {
86
- skipDirectories?: readonly string[] | undefined;
87
- maps?: readonly object[] | undefined;
88
- config?: object | undefined;
89
- records?: readonly object[] | undefined;
90
- problems?: object[] | undefined;
91
- }): Map<string, {
92
- to: string;
93
- file: string;
94
- shortcode: string;
95
- }>;
96
39
  /**
97
40
  * Every address the baseline published that this build does not.
98
41
  *
99
- * An address that merely *arrived* is not a finding: adding one breaks nobody.
100
- * The arrivals are read only to answer the one question that matters about a
101
- * departure — is the document still here under another name?
42
+ * An address that arrived is not a finding. A departed address is a rename
43
+ * only when its document id appears at another current address; otherwise it
44
+ * is a withdrawal.
102
45
  *
103
46
  * @param {Map<string, object>} baseline - The released address space.
104
47
  * @param {Map<string, object>} current - This build's address space.
105
48
  * @param {object} opts
106
- * @param {string} opts.baseline - What the baseline is, for the message —
107
- * conventionally `<package>@<version>`.
108
- * @param {Map<string, {to: string, file: string}>} [opts.predecessors] - The
109
- * declared renames, from {@link declaredPredecessors}. Omitted, the diff
110
- * falls back to the id join alone and reports an unpinned rename as a
111
- * withdrawal, which is what it did before.
112
- * @returns {Array<object>} One finding per departed address, in address order
113
- * so two runs read the same. `kind` is `"renamed"` (with `to`, and `declared`
114
- * when it was the note's word rather than an id match) or `"withdrawn"`.
49
+ * @param {string} opts.baseline - What the baseline is, for the message.
50
+ * @returns {Array<object>} One finding per departed address, in address order.
115
51
  */
116
- export function diffItemAddresses(baseline: Map<string, object>, current: Map<string, object>, { baseline: label, predecessors }: {
52
+ export function diffItemAddresses(baseline: Map<string, object>, current: Map<string, object>, { baseline: label }: {
117
53
  baseline: string;
118
- predecessors?: Map<string, {
119
- to: string;
120
- file: string;
121
- }> | undefined;
122
54
  }): Array<object>;
123
55
  /**
124
- * Every content note in a tree, indexed by the document id it authors.
56
+ * Every content note in a tree, indexed by the document id it compiles under.
125
57
  *
126
58
  * The address space is read from compiled output because that is what actually
127
59
  * ships; the tree is read only to place a finding somewhere a reader can open
@@ -135,7 +67,7 @@ export function diffItemAddresses(baseline: Map<string, object>, current: Map<st
135
67
  * @param {object} [opts.config] - The resolved build configuration, which the
136
68
  * id is derived against. See {@link addressCorpus} for why that matters.
137
69
  * @param {readonly object[]} [opts.records] - Index records the caller already
138
- * derived, shared with {@link declaredPredecessors}.
70
+ * derived by the caller.
139
71
  * @param {object[]} [opts.problems] - Collects the notes the index cannot
140
72
  * record, so one of them does not abort the diff before it reports.
141
73
  * @returns {Map<string, string>} Document id → the note's absolute path.
@@ -149,22 +81,13 @@ export function noteFilesById(contentBase: string, { skipDirectories, config, re
149
81
  /**
150
82
  * Where to send the reader for one finding.
151
83
  *
152
- * A rename is fixed in the note that made it, so a finding whose id is still in
153
- * this tree is reported at that note's `shortcode:` line — the line the author
154
- * just edited. A withdrawal has no such note by definition, so it degrades to
155
- * the baseline document, which is the only artefact left that records the
156
- * address existing. When neither is readable the position is **dropped**, never
157
- * defaulted to `1:1`.
158
- *
159
- * A **declared** rename knows its note without any lookup — the declaration is
160
- * how it was found — and is reported at the `renamedFrom:` line rather than the
161
- * `shortcode:` line, because that is the line the finding is about and the one
162
- * the author deletes once the declaration has done its work.
84
+ * A matched document is located at its current `shortcode:` line. A withdrawal
85
+ * is located in the baseline artifact, the remaining evidence for its address.
86
+ * When neither source has a usable position, the location is dropped.
163
87
  *
164
88
  * @param {object} finding - One finding from {@link diffItemAddresses}.
165
89
  * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
166
- * @returns {{file?: string, line?: number, column?: number}} Spreadable
167
- * position fields for {@link formatDiagnostic}.
90
+ * @returns {{file?: string, line?: number, column?: number}} Position fields.
168
91
  */
169
92
  export function locateAddressFinding(finding: object, noteFiles: Map<string, string>): {
170
93
  file?: string;
@@ -174,21 +97,9 @@ export function locateAddressFinding(finding: object, noteFiles: Map<string, str
174
97
  /**
175
98
  * What one finding says, without a locator or a severity.
176
99
  *
177
- * The rename message names the identity it matched on, because that is what
178
- * separates this from a spelling suggestion: the reader can check the id in
179
- * both artefacts. The withdrawal message names no successor, because none is
180
- * known — and says so, rather than leaving the reader to wonder whether one was
181
- * looked for.
182
- *
183
- * **A declared rename says it is declared**. The two claims are not
184
- * equally checkable: an id match is a fact in the artefacts, while a
185
- * declaration is an author's word, and a reader deciding whether to trust the
186
- * successor needs to know which one they have. Saying "the same document" of a
187
- * declared rename would spend the id match's credibility on it.
188
- *
189
- * The consequence sentence is the same for both, because it is the same
190
- * consequence: the old address stops resolving either way, and that is what
191
- * the reader has to act on.
100
+ * A matched document names the address where it now appears. A withdrawal
101
+ * names no successor because the current build publishes no document with
102
+ * that identity.
192
103
  *
193
104
  * @param {object} finding - One finding from {@link diffItemAddresses}.
194
105
  * @returns {string} The message.
@@ -142,14 +142,12 @@ export function artPathname(index: object, value: unknown, defaultType: string,
142
142
  type?: string;
143
143
  };
144
144
  /**
145
- * The default art address a being's own tags choose, or `null`.
145
+ * The default art address a being's subtype chooses, or `null`.
146
146
  *
147
- * `character` means a **person**, not a human, and `creature` everything else;
148
- * a being carries exactly one of the two, which is what makes the choice a
149
- * lookup rather than a precedence rule.
147
+ * An NPC and a character use person art; a creature uses creature art.
150
148
  *
151
149
  * @param {object} fm - The note's frontmatter.
152
- * @returns {string|null} The address, or `null` for a note carrying neither tag.
150
+ * @returns {string|null} The address, or `null` when no subtype selects one.
153
151
  */
154
152
  export function beingDefaultArt(fm: object): string | null;
155
153
  /**
@@ -181,7 +179,7 @@ export function unresolvedArtMessage(key: string, value: unknown): string;
181
179
  export function unacceptedArtMessage(key: string, value: unknown, type: string, accepts: Iterable<string>): string;
182
180
  export { ART_SLOTS } from "./art-slots.mjs";
183
181
  /**
184
- * The art a being falls back to, by the kind it is tagged.
182
+ * The art a being falls back to, by its subtype.
185
183
  *
186
184
  * Both files ship in `sohl`, under `assets/icons/other/`, and both are named
187
185
  * here as addresses rather than as paths for the reason every art reference is:
@@ -189,8 +187,8 @@ export { ART_SLOTS } from "./art-slots.mjs";
189
187
  * package borrowing the default gets the same file the system ships.
190
188
  *
191
189
  * **Only the compiler can choose between them**, because only the compiler
192
- * reads the note's tags. A schema default is the last resort beneath this one,
193
- * and covers a world document created by hand, which no note describes.
190
+ * reads the note's subtype. A schema default is the last resort beneath this
191
+ * one, and covers a world document created by hand, which no note describes.
194
192
  *
195
193
  * @type {Readonly<Record<string, string>>}
196
194
  */
@@ -12,6 +12,20 @@
12
12
  * @returns {boolean} Whether it is acceptable.
13
13
  */
14
14
  export function matchesKind(value: unknown, kind: string, context?: object): boolean;
15
+ /**
16
+ * Resolve system-block addresses declared by the system's own field schema.
17
+ * @param {object} note - A note from the link index.
18
+ * @param {object} opts
19
+ * @param {object} opts.index - The address index.
20
+ * @param {Record<string, readonly object[]>} [opts.schemas] - Note schemas.
21
+ * @param {Readonly<Record<string, object>>} [opts.systems] - Declared system blocks.
22
+ * @returns {object[]} Located findings for unresolved addresses.
23
+ */
24
+ export function systemAddressFindings(note: object, { index, schemas, systems }?: {
25
+ index: object;
26
+ schemas?: Record<string, readonly object[]> | undefined;
27
+ systems?: Readonly<Record<string, object>> | undefined;
28
+ }): object[];
15
29
  /**
16
30
  * Check one note against its type's schema.
17
31
  *
@@ -14,7 +14,8 @@ export function renderFoundryMarkdown(body: any, captions: any, footnoteNumbers:
14
14
  * @param {string} filePath - Markdown file to read.
15
15
  * @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
16
16
  * @returns {{frontmatter: object|null, body: string, description: string,
17
- * bodyLine?: number, bodyColumn?: number}} Parsed note and body position.
17
+ * bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
18
+ * line?: number, column?: number}}} Parsed note and body position.
18
19
  */
19
20
  export function parseMarkdownFile(filePath: string, { addressContext }?: {
20
21
  addressContext?: object;
@@ -24,6 +25,11 @@ export function parseMarkdownFile(filePath: string, { addressContext }?: {
24
25
  description: string;
25
26
  bodyLine?: number;
26
27
  bodyColumn?: number;
28
+ parseError?: {
29
+ message: string;
30
+ line?: number;
31
+ column?: number;
32
+ };
27
33
  };
28
34
  /**
29
35
  * Refuse a corpus read whose scope its caller did not state.
@@ -98,6 +104,11 @@ export function walkMarkdownTree(rootDir: string, { skipDirectories, addressCont
98
104
  description: string;
99
105
  bodyLine?: number;
100
106
  bodyColumn?: number;
107
+ parseError?: {
108
+ message: string;
109
+ line?: number;
110
+ column?: number;
111
+ };
101
112
  }, void, unknown>;
102
113
  /**
103
114
  * The template priority a note states, for a system that treats an unstated one
@@ -47,7 +47,6 @@ export * as infobox from "./infobox.mjs";
47
47
  export * as infoboxRegistry from "./infobox-registry.mjs";
48
48
  export * as infoboxRender from "./infobox-render.mjs";
49
49
  export * as noteIds from "./note-ids.mjs";
50
- export * as noteRenames from "./note-renames.mjs";
51
50
  export * as metadataIndex from "./metadata-index.mjs";
52
51
  export * as foundryEntries from "./foundry-entries.mjs";
53
52
  export * as contentIndex from "./content-index.mjs";
@@ -146,8 +146,9 @@ export function linkValue(ref: unknown, resolve: (ref: unknown, hint?: object) =
146
146
  *
147
147
  * The rows are {@link NOTE_VOCABULARY}'s declaration for the type, in its
148
148
  * order, minus what {@link NOTE_FIELD_PRESENTATION} withholds and minus every
149
- * field the note left empty. A type that declares no `data:` fields still gets
150
- * the box, because `Name` is a fact about every note.
149
+ * field the note left empty. A being's top-level `subType` adds its profile
150
+ * type. A type that declares no `data:` fields still gets the box, because
151
+ * `Name` is a fact about every note.
151
152
  *
152
153
  * @param {object} fm - The note's frontmatter.
153
154
  * @param {object} [options] - Options.
@@ -233,15 +233,6 @@ export const DECLARED_TAGS: Readonly<{
233
233
  types: string[];
234
234
  tags: readonly string[];
235
235
  }>;
236
- /**
237
- * What kind of being this is — a person, or one of the beasts and made
238
- * things. A being is one or the other, so the group is a slot.
239
- */
240
- beingKind: Readonly<{
241
- types: string[];
242
- exclusive: "kind";
243
- tags: readonly string[];
244
- }>;
245
236
  /** A note's working state, which any note may carry. */
246
237
  state: Readonly<{
247
238
  types: null;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The map one system ships, by its id.
3
3
  *
4
- * @param {string|undefined} system - The system id (`"sohl"`, `"hm3"`).
4
+ * @param {string|undefined} system - The system id.
5
5
  * @returns {import("./document-subtypes.mjs").DocumentSubtypeMap|undefined} Its
6
6
  * map, or `undefined` where this toolchain ships none for it.
7
7
  */
@@ -29,10 +29,6 @@ export function schemaSubtypeOf(system: string | undefined, type: string): strin
29
29
  /**
30
30
  * The note-type → document-subtype maps this toolchain ships.
31
31
  *
32
- * Two, since `hm3/` landed — 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
32
  * The union is what makes the vocabulary wider than any one repository's
37
33
  * configuration: `armorlocation` is a real content type because HM3 maps it,
38
34
  * however a given repository is configured, so a tree full of them is a
@@ -71,7 +67,7 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS: readonly import("./document-subtypes.m
71
67
  */
72
68
  export const DEFAULT_DOCUMENT_SUBTYPES: import("./document-subtypes.mjs").DocumentSubtypeMap;
73
69
  /**
74
- * Every system this toolchain ships a map for, by id.
70
+ * Every system identifier with declared system-specific document types.
75
71
  *
76
72
  * Derived from {@link KNOWN_DOCUMENT_SUBTYPE_MAPS}, so registering a map is the
77
73
  * whole of adding a system to every reader that asks "which systems are there?"
@@ -28,8 +28,8 @@ export function isSystemSegment(value: unknown): boolean;
28
28
  *
29
29
  * The nullish case is called out by name because it is the likely mistake and
30
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.
31
+ * value at all reaches here as `undefined`, and a list of accepted values
32
+ * would leave a reader hunting for the value they cannot see.
33
33
  *
34
34
  * @param {unknown} value - The offending value.
35
35
  * @param {string} [where] - What carried it, for the message — a note path, a
@@ -83,11 +83,11 @@ export const NO_SYSTEM: string;
83
83
  /** The Address segment for a note's readable content. */
84
84
  export const NOTE_SYSTEM: "note";
85
85
  /**
86
- * Every game system this toolchain compiles for.
86
+ * Every game system identifier this toolchain recognizes.
87
87
  *
88
- * The registry, and the only place the list is written down. Adding a system
89
- * is an edit to this set plus the map and compilers that make it real — a data
90
- * change, not a search for every place a name was spelled.
88
+ * The registry, and the only place the list is written down. A recognized id
89
+ * can have no system-specific document types; adding one of those requires a
90
+ * subtype map and compiler as well.
91
91
  *
92
92
  * `none` is **not** a member: it marks the absence of a system, and a caller
93
93
  * asking "is this a system" and a caller asking "is this a well-formed
@@ -1,134 +0,0 @@
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 shortcodes a note declares it used to be published under.
16
- *
17
- * A package's `(type, shortcode)` addresses are a published interface, and
18
- * `addresses diff` reports what a build stopped publishing — telling a
19
- * **rename** from a **withdrawal** by matching document ids across two
20
- * releases. The property that rested on is gone: an id is derived from
21
- * the canonical address, which carries the shortcode, so renaming a shortcode
22
- * moves the id too: both sides of the join move together, the match finds
23
- * nothing, and a rename is reported as a withdrawal with no successor named.
24
- *
25
- * **The remaining lever needed foresight, which is the wrong thing to ask for.**
26
- * An authored `id:` still wins, and pinning one does keep a document's identity
27
- * across a rename — but it has to be written *before* the rename, by an author
28
- * who does not yet know they will make one. An author who has just renamed a
29
- * shortcode knows exactly what the old one was, and that is the only moment
30
- * anyone does.
31
- *
32
- * So a note states it:
33
- *
34
- * ```yaml
35
- * type: weapongear
36
- * shortcode: Taburi
37
- * renamedFrom: Tabri
38
- * ```
39
- *
40
- * **A declaration is an assertion, not a match, and the diagnostic says which
41
- * it had.** That distinction is the whole ethos of `address-diff.mjs`: it
42
- * refuses to guess a successor from a similar-looking string, because a wrong
43
- * one sends the reader to the wrong fix. An author's declaration is neither a
44
- * guess nor an identity match — it is testimony from the one party that knows —
45
- * so it is used, attributed, and never silently blended with an id match.
46
- *
47
- * **It is a list, because renames chain.** The diff is release-to-release and a
48
- * shortcode may be renamed more than once between two releases; a baseline
49
- * several releases back published a name two renames ago. A single value would
50
- * report that as a withdrawal again, which is the bug this closes.
51
- *
52
- * **It is transient.** Once every baseline a build is compared against post-dates
53
- * the rename, the declaration has nothing left to say and may be deleted. That
54
- * is the difference from an `id:` pin, which is permanent, opaque, and a second
55
- * identity for a thing that already has one.
56
- *
57
- * **It is one key per note, at the top level, however many systems the note
58
- * compiles into.** A shortcode is the note's, not a system block's: a note
59
- * carrying `sohl:` and `hm3:` blocks compiles into two documents that share one
60
- * shortcode, so a rename moves both and one declaration covers both.
61
- *
62
- * This module reads the key and says nothing about whether the values are
63
- * *sound* — that a value is a well-formed shortcode, is not the note's own, and
64
- * does not name an address some live note still publishes are cross-note
65
- * questions, and `engine/content-lint.mjs` owns them. Composing a value into an
66
- * address key is likewise not here: the diff defines that space and spells the
67
- * key once, in `engine/address-diff.mjs`.
68
- *
69
- * @module
70
- */
71
-
72
- /**
73
- * The `renamedFrom:` entries a note authors, exactly as authored.
74
- *
75
- * Scalar or list, because one predecessor is the overwhelmingly common case and
76
- * requiring `- ` on it would be friction with nothing behind it; both normalize
77
- * here so no reader has to ask which form it got.
78
- *
79
- * Entries are returned **unvalidated** — a number, a nested list, a blank
80
- * string all come back as they were written. The lint needs to see them to
81
- * report them, and a reader that quietly dropped them would report a correct
82
- * tree clean while a typo silently did nothing.
83
- *
84
- * @param {object|null|undefined} fm - Parsed frontmatter.
85
- * @returns {readonly unknown[]} The authored entries, in authored order; empty
86
- * when the note declares none.
87
- */
88
- export function renamedFromEntries(fm) {
89
- if (!fm || typeof fm !== "object") return [];
90
- const raw = /** @type {{renamedFrom?: unknown}} */ (fm).renamedFrom;
91
- if (raw == null) return [];
92
- return Array.isArray(raw) ? raw : [raw];
93
- }
94
-
95
- /**
96
- * The well-formed shortcodes among a note's `renamedFrom:` entries.
97
- *
98
- * Trimmed, de-duplicated, and in authored order. Anything that is not a
99
- * non-blank string is skipped rather than coerced: it is reported by the lint,
100
- * and a diff that guessed at what a number meant would claim a rename nobody
101
- * declared.
102
- *
103
- * De-duplicating here rather than leaving it to callers is not tidiness — the
104
- * predecessor index is a map, so a repeated entry would otherwise be indexed
105
- * twice and the second write would look like a conflicting claim.
106
- *
107
- * @param {object|null|undefined} fm - Parsed frontmatter.
108
- * @returns {string[]} The declared predecessor shortcodes.
109
- */
110
- export function renamedFrom(fm) {
111
- const seen = new Set();
112
- for (const entry of renamedFromEntries(fm)) {
113
- if (typeof entry !== "string") continue;
114
- const trimmed = entry.trim();
115
- if (trimmed) seen.add(trimmed);
116
- }
117
- return [...seen];
118
- }
119
-
120
- /**
121
- * Whether a note declares the key at all, however malformed its value.
122
- *
123
- * Separate from {@link renamedFrom} returning nothing, because the two mean
124
- * opposite things to a lint: a note declaring no key is silent and correct,
125
- * while one declaring `renamedFrom: []` — or a single blank string — has said
126
- * something that does nothing, which is worth a word.
127
- *
128
- * @param {object|null|undefined} fm - Parsed frontmatter.
129
- * @returns {boolean} `true` when the key is present and not null.
130
- */
131
- export function declaresRenamedFrom(fm) {
132
- if (!fm || typeof fm !== "object") return false;
133
- return /** @type {{renamedFrom?: unknown}} */ (fm).renamedFrom != null;
134
- }