@heroiclands/package-build 17.2.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.
- package/CHANGELOG.md +1711 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +225 -127
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +55 -83
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/manifest.mjs +43 -2
- package/package.json +18 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +54 -49
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
package/engine/address-diff.mjs
CHANGED
|
@@ -40,15 +40,37 @@
|
|
|
40
40
|
* string. Here both sides are whole documents, so the question is decidable.
|
|
41
41
|
*
|
|
42
42
|
* **A rename is told from a removal by the document id, and that is an identity
|
|
43
|
-
* match rather than an inference.**
|
|
44
|
-
* is
|
|
45
|
-
* the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
43
|
+
* match rather than an inference.** An address that disappeared while its
|
|
44
|
+
* document is still published elsewhere *is* a rename — not "probably" one.
|
|
45
|
+
* When the id is published under no address at all, that is all this can say:
|
|
46
|
+
* **withdrawn**, with no successor named. A split, a deletion and a merge are
|
|
47
|
+
* indistinguishable from one another at that point, and inventing a "did you
|
|
48
|
+
* mean" from string similarity would be worse than saying nothing, because a
|
|
49
|
+
* wrong one sends the reader to the wrong fix.
|
|
50
|
+
*
|
|
51
|
+
* **#270 narrowed that match, and a declaration is what makes up the
|
|
52
|
+
* difference.** The join rested on the id being independent of the shortcode: a
|
|
53
|
+
* note authored its `_id`, so the `Tabri` → `Taburi` commit changed the
|
|
54
|
+
* shortcode alone and left the id to join the two sides. Since #270 an id is
|
|
55
|
+
* *derived from the canonical address*, which carries the shortcode — so
|
|
56
|
+
* renaming a shortcode moves the id too, both sides of the join move together,
|
|
57
|
+
* and the match finds nothing. It stays exact for a note that **pins** an `id`,
|
|
58
|
+
* and pinning is still how a document keeps its identity across a rename; what
|
|
59
|
+
* it cannot do is help the author who did not pin, because pinning has to
|
|
60
|
+
* happen before the rename, by someone who does not yet know they will make
|
|
61
|
+
* one.
|
|
62
|
+
*
|
|
63
|
+
* So an author who has just renamed a shortcode **says so**, with
|
|
64
|
+
* `renamedFrom:` on the note that made the change (#278, and see
|
|
65
|
+
* `engine/note-renames.mjs`). That is neither a guess nor an identity match but
|
|
66
|
+
* testimony from the only party that knows, and the diagnostic reports which of
|
|
67
|
+
* the two it had rather than blending them — a reader can verify a matched id
|
|
68
|
+
* in both artefacts, and can only take a declaration on its author's word.
|
|
69
|
+
*
|
|
70
|
+
* **Three joins, tried in that order of authority**: the document id, then a
|
|
71
|
+
* declaration, then nothing — which remains **withdrawn**. Nothing here
|
|
72
|
+
* infers, so a rename that is neither pinned nor declared is still reported as
|
|
73
|
+
* a withdrawal; that is the honest answer, not a gap.
|
|
52
74
|
*
|
|
53
75
|
* **Severity is decided per case.** A withdrawal is legitimate — content is
|
|
54
76
|
* allowed to be retired — so it is reported and does not fail a build. A rename
|
|
@@ -68,7 +90,32 @@ import path from "node:path";
|
|
|
68
90
|
|
|
69
91
|
import { formatDiagnostic, positionInFrontmatter } from "./diagnostics.mjs";
|
|
70
92
|
import { positionOfLiteral } from "./diagnostics.mjs";
|
|
71
|
-
import {
|
|
93
|
+
import { assertStatedScope } from "./helpers.mjs";
|
|
94
|
+
// The corpus, read from the one pass that derives it (#243). Nothing in the
|
|
95
|
+
// index's import graph reaches this module — only `bin/` imports it — so this
|
|
96
|
+
// is a plain static import, as in the link checker.
|
|
97
|
+
import { indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
|
|
98
|
+
import { renamedFrom } from "./note-renames.mjs";
|
|
99
|
+
import { referencedSubtype } from "./document-subtypes.mjs";
|
|
100
|
+
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The one spelling of an address in this space: `type:shortcode`.
|
|
104
|
+
*
|
|
105
|
+
* Written by both readers — the compiled packs on each side of the diff, and
|
|
106
|
+
* the declarations read out of the tree — so a predecessor a note names and an
|
|
107
|
+
* address a pack publishes cannot come apart over punctuation or case. The
|
|
108
|
+
* shortcode is used **verbatim**, not lowercased: `readItemAddresses` reads it
|
|
109
|
+
* off the compiled document, where `Tabri` is stored as authored, and folding
|
|
110
|
+
* case here would join two addresses the packs keep apart.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} type - The Foundry document subtype, not the note type.
|
|
113
|
+
* @param {string} shortcode - The address's `system.shortcode`.
|
|
114
|
+
* @returns {string} The address key.
|
|
115
|
+
*/
|
|
116
|
+
export function itemAddressKey(type, shortcode) {
|
|
117
|
+
return `${type}:${shortcode}`;
|
|
118
|
+
}
|
|
72
119
|
|
|
73
120
|
/**
|
|
74
121
|
* The address space a set of compiled Item pack directories publishes.
|
|
@@ -114,7 +161,7 @@ export function readItemAddresses(dirs) {
|
|
|
114
161
|
}
|
|
115
162
|
const shortcode = doc?.system?.shortcode;
|
|
116
163
|
if (!doc?.type || !shortcode || !doc?._id) continue;
|
|
117
|
-
space.set(
|
|
164
|
+
space.set(itemAddressKey(doc.type, shortcode), {
|
|
118
165
|
id: doc._id,
|
|
119
166
|
name: doc.name ?? "",
|
|
120
167
|
type: doc.type,
|
|
@@ -126,6 +173,133 @@ export function readItemAddresses(dirs) {
|
|
|
126
173
|
return space;
|
|
127
174
|
}
|
|
128
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Every rename the tree's notes **declare**, as old address → where it went.
|
|
178
|
+
*
|
|
179
|
+
* Read from the content tree rather than from compiled output, because a
|
|
180
|
+
* declaration is authored and the compiled document does not carry it: nothing
|
|
181
|
+
* downstream consumes `renamedFrom:`, so emitting it into every pack to let one
|
|
182
|
+
* diagnostic read it back would put a build-time note in shipped data forever.
|
|
183
|
+
* The tree is already read by this module for the same reason
|
|
184
|
+
* ({@link noteFilesById}) — to place a finding where its author can fix it.
|
|
185
|
+
*
|
|
186
|
+
* **A declaration is keyed by document subtype, not by note type.** The address
|
|
187
|
+
* space is the one consumers resolve against, and it is spelled in compiled
|
|
188
|
+
* documents: `hm3` compiles a `projectile` note into a `missilegear` item,
|
|
189
|
+
* so that is the address a rename of it moves. {@link referencedSubtype} is the
|
|
190
|
+
* function that already answers this for a being's embedded `(type, shortcode)`
|
|
191
|
+
* references, so both sides read the same rule rather than a second copy of it.
|
|
192
|
+
*
|
|
193
|
+
* **An entry is emitted for every system that maps the type**, whether or not
|
|
194
|
+
* the note declares that system's block. Over-emitting is inert — the diff uses
|
|
195
|
+
* an entry only when the baseline published the old address *and* this build
|
|
196
|
+
* publishes the new one, and a system the note does not compile for satisfies
|
|
197
|
+
* neither — while asking which blocks a note declares would put a second,
|
|
198
|
+
* subtly different answer to that question in a third place.
|
|
199
|
+
*
|
|
200
|
+
* **First claim wins on a collision.** Two notes naming one predecessor is a
|
|
201
|
+
* contradiction — an address has one successor — and it is reported as an error
|
|
202
|
+
* by `engine/content-lint.mjs`, where both notes are in hand and can both be
|
|
203
|
+
* named. Picking one here keeps this a map; it is not a resolution, and nothing
|
|
204
|
+
* rests on which one it picked.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} contentBase - Root of the content tree.
|
|
207
|
+
* @param {object} opts
|
|
208
|
+
* @param {readonly string[]} [opts.skipDirectories] - The corpus scope. Stated
|
|
209
|
+
* by the caller, never defaulted — see {@link addressCorpus}.
|
|
210
|
+
* @param {readonly object[]} [opts.maps] - The document-subtype maps.
|
|
211
|
+
* @param {object} [opts.config] - The resolved build configuration.
|
|
212
|
+
* @param {readonly object[]} [opts.records] - Index records the caller already
|
|
213
|
+
* derived, shared with {@link noteFilesById} so one command reads one corpus.
|
|
214
|
+
* @returns {Map<string, {to: string, file: string, shortcode: string}>} Old
|
|
215
|
+
* address → the address the declaring note publishes at now, and that note.
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* The corpus both reads below share, as content-index records.
|
|
219
|
+
*
|
|
220
|
+
* **One walk, not two.** `addresses diff` reads the tree twice — once for the
|
|
221
|
+
* declarations and once to place its findings — and until #243 those were two
|
|
222
|
+
* independent walks that each parsed every note. They are now one derivation,
|
|
223
|
+
* shared: the caller derives the records and hands them to both, so the two
|
|
224
|
+
* halves of a single command cannot disagree about which files the corpus is,
|
|
225
|
+
* or about the ids in it.
|
|
226
|
+
*
|
|
227
|
+
* **The id is why it matters, and not only tidiness.** `noteFilesById` joins
|
|
228
|
+
* tree-side ids against ids read out of the *compiled packs*. Since #270 an id
|
|
229
|
+
* is derived from the canonical address, whose first segment is the content
|
|
230
|
+
* package — and the tree side used to derive it through `resolveNoteId(fm)`
|
|
231
|
+
* with no package, which falls back to `contentPackage()` and so to whichever
|
|
232
|
+
* configuration the working directory answers with. The compiled side is
|
|
233
|
+
* produced by a compiler running on the configuration the *build* resolved. Let
|
|
234
|
+
* those differ — under `PACKAGE_BUILD_CONFIG`, in a worktree, in a test — and
|
|
235
|
+
* every id fails to join, so every rename degrades to a withdrawal and every
|
|
236
|
+
* finding loses the note it should have been reported against. Reading the
|
|
237
|
+
* index derives both sides from the one resolved configuration.
|
|
238
|
+
*
|
|
239
|
+
* A tree that is not there yields no records rather than throwing, which is
|
|
240
|
+
* what the walk this replaces did: an absent tree is a caller's business, and
|
|
241
|
+
* these two functions have never been the ones to report it.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} contentBase - Root of the content tree.
|
|
244
|
+
* @param {object} opts - Options.
|
|
245
|
+
* @param {readonly string[]} [opts.skipDirectories] - The scope, required
|
|
246
|
+
* unless `records` supplies the corpus outright.
|
|
247
|
+
* @param {object} [opts.config] - The resolved configuration.
|
|
248
|
+
* @param {readonly object[]} [opts.records] - Records the caller derived.
|
|
249
|
+
* @param {object[]} [opts.problems] - Collects the notes the index cannot
|
|
250
|
+
* record, so one of them does not abort the diff before it reports.
|
|
251
|
+
* @returns {readonly object[]} The index records.
|
|
252
|
+
*/
|
|
253
|
+
function addressCorpus(contentBase, { skipDirectories, config, records, problems } = {}) {
|
|
254
|
+
if (records) return records;
|
|
255
|
+
assertStatedScope(skipDirectories, "reading the address corpus");
|
|
256
|
+
if (!fs.existsSync(contentBase)) return [];
|
|
257
|
+
return indexRecordsFor({ contentBase, config, skipDirectories, problems });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function declaredPredecessors(
|
|
261
|
+
contentBase,
|
|
262
|
+
{ skipDirectories, maps = KNOWN_DOCUMENT_SUBTYPE_MAPS, config, records, problems } = {},
|
|
263
|
+
) {
|
|
264
|
+
const byOldAddress = new Map();
|
|
265
|
+
for (const record of addressCorpus(contentBase, {
|
|
266
|
+
skipDirectories,
|
|
267
|
+
config,
|
|
268
|
+
records,
|
|
269
|
+
problems,
|
|
270
|
+
})) {
|
|
271
|
+
// A documentation journal is a document this tree emits, not a note in
|
|
272
|
+
// it: it has no file and declares nothing.
|
|
273
|
+
if (!isNoteRecord(record)) continue;
|
|
274
|
+
// The record carries the note's frontmatter, so a declaration is read
|
|
275
|
+
// off it exactly as it was read off the parse.
|
|
276
|
+
const declared = renamedFrom(record);
|
|
277
|
+
if (!declared.length) continue;
|
|
278
|
+
const shortcode = typeof record.shortcode === "string" ? record.shortcode.trim() : "";
|
|
279
|
+
// A note with no address of its own has nowhere for a predecessor to
|
|
280
|
+
// have gone, so it declares a rename to nothing. Reported by the lint;
|
|
281
|
+
// silently skipped here rather than indexed as a rename to `type:`.
|
|
282
|
+
if (!shortcode) continue;
|
|
283
|
+
const absPath = noteFile(contentBase, record);
|
|
284
|
+
for (const map of maps) {
|
|
285
|
+
const { subType } = referencedSubtype(map, record.type, "Item");
|
|
286
|
+
if (!subType) continue;
|
|
287
|
+
const to = itemAddressKey(subType, shortcode);
|
|
288
|
+
for (const old of declared) {
|
|
289
|
+
const from = itemAddressKey(subType, old);
|
|
290
|
+
// A note naming its own current address declares nothing, and
|
|
291
|
+
// indexing it would make every such address look renamed to
|
|
292
|
+
// itself. The lint reports it.
|
|
293
|
+
if (from === to) continue;
|
|
294
|
+
if (!byOldAddress.has(from)) {
|
|
295
|
+
byOldAddress.set(from, { to, file: absPath, shortcode: old });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return byOldAddress;
|
|
301
|
+
}
|
|
302
|
+
|
|
129
303
|
/**
|
|
130
304
|
* Every address the baseline published that this build does not.
|
|
131
305
|
*
|
|
@@ -138,11 +312,15 @@ export function readItemAddresses(dirs) {
|
|
|
138
312
|
* @param {object} opts
|
|
139
313
|
* @param {string} opts.baseline - What the baseline is, for the message —
|
|
140
314
|
* conventionally `<package>@<version>`.
|
|
315
|
+
* @param {Map<string, {to: string, file: string}>} [opts.predecessors] - The
|
|
316
|
+
* declared renames, from {@link declaredPredecessors}. Omitted, the diff
|
|
317
|
+
* falls back to the id join alone and reports an unpinned rename as a
|
|
318
|
+
* withdrawal, which is what it did before #278.
|
|
141
319
|
* @returns {Array<object>} One finding per departed address, in address order
|
|
142
|
-
* so two runs read the same. `kind` is `"renamed"` (with `to`
|
|
143
|
-
* `"withdrawn"`.
|
|
320
|
+
* so two runs read the same. `kind` is `"renamed"` (with `to`, and `declared`
|
|
321
|
+
* when it was the note's word rather than an id match) or `"withdrawn"`.
|
|
144
322
|
*/
|
|
145
|
-
export function diffItemAddresses(baseline, current, { baseline: label }) {
|
|
323
|
+
export function diffItemAddresses(baseline, current, { baseline: label, predecessors }) {
|
|
146
324
|
// A baseline that yields no address at all cannot produce a finding, so it
|
|
147
325
|
// reports a clean result for every possible input — the one failure a check
|
|
148
326
|
// like this can never catch, and the same one `foreign-manifests.mjs` exists
|
|
@@ -166,11 +344,28 @@ export function diffItemAddresses(baseline, current, { baseline: label }) {
|
|
|
166
344
|
const findings = [];
|
|
167
345
|
for (const [address, entry] of baseline) {
|
|
168
346
|
if (current.has(address)) continue;
|
|
169
|
-
|
|
347
|
+
|
|
348
|
+
// The id first, because it is the strongest answer available: a match
|
|
349
|
+
// is an identity, and a reader can check it in both artefacts.
|
|
350
|
+
const matched = currentById.get(entry.id);
|
|
351
|
+
// Then the author's word, and only where it checks out — the note that
|
|
352
|
+
// claims this address as a predecessor must itself be publishing now.
|
|
353
|
+
// A declaration pointing at an address this build does not publish
|
|
354
|
+
// describes a rename that did not survive to the packs, and naming it
|
|
355
|
+
// as the successor would send the reader somewhere nothing is.
|
|
356
|
+
const claim = matched ? undefined : predecessors?.get(address);
|
|
357
|
+
const declared = claim && current.has(claim.to) ? claim : undefined;
|
|
358
|
+
const to = matched ?? declared?.to;
|
|
359
|
+
|
|
170
360
|
findings.push({
|
|
171
361
|
kind: to ? "renamed" : "withdrawn",
|
|
172
362
|
address,
|
|
173
363
|
...(to ? { to } : {}),
|
|
364
|
+
// Set only on a declared rename, so a reader of a finding can tell
|
|
365
|
+
// testimony from an identity match without re-deriving which it was.
|
|
366
|
+
...(matched ? {}
|
|
367
|
+
: declared ? { declared: true, noteFile: declared.file }
|
|
368
|
+
: {}),
|
|
174
369
|
id: entry.id,
|
|
175
370
|
name: entry.name,
|
|
176
371
|
shortcode: entry.shortcode,
|
|
@@ -192,14 +387,33 @@ export function diffItemAddresses(baseline, current, { baseline: label }) {
|
|
|
192
387
|
*
|
|
193
388
|
* @param {string} contentBase - Root of the content tree.
|
|
194
389
|
* @param {object} [opts]
|
|
195
|
-
* @param {readonly string[]} [opts.skipDirectories] -
|
|
390
|
+
* @param {readonly string[]} [opts.skipDirectories] - The corpus scope, stated
|
|
391
|
+
* by the caller — see {@link addressCorpus}.
|
|
392
|
+
* @param {object} [opts.config] - The resolved build configuration, which the
|
|
393
|
+
* id is derived against. See {@link addressCorpus} for why that matters.
|
|
394
|
+
* @param {readonly object[]} [opts.records] - Index records the caller already
|
|
395
|
+
* derived, shared with {@link declaredPredecessors}.
|
|
196
396
|
* @returns {Map<string, string>} Document id → the note's absolute path.
|
|
197
397
|
*/
|
|
198
|
-
export function noteFilesById(contentBase, { skipDirectories } = {}) {
|
|
398
|
+
export function noteFilesById(contentBase, { skipDirectories, config, records, problems } = {}) {
|
|
199
399
|
const byId = new Map();
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
400
|
+
for (const record of addressCorpus(contentBase, {
|
|
401
|
+
skipDirectories,
|
|
402
|
+
config,
|
|
403
|
+
records,
|
|
404
|
+
problems,
|
|
405
|
+
})) {
|
|
406
|
+
// A documentation journal shares its note's file and has no id of its
|
|
407
|
+
// own, so indexing it would file one path under two identities.
|
|
408
|
+
if (!isNoteRecord(record)) continue;
|
|
409
|
+
// Derived by the index, against the configuration this build resolved —
|
|
410
|
+
// which is the same configuration the compiled ids on the other side of
|
|
411
|
+
// the join were produced under. See {@link addressCorpus}.
|
|
412
|
+
//
|
|
413
|
+
// First record wins, and the records are in content-path order, so
|
|
414
|
+
// which note answers for a duplicated id is now a stable fact about the
|
|
415
|
+
// tree rather than an artefact of directory-read order.
|
|
416
|
+
if (record.id && !byId.has(record.id)) byId.set(record.id, noteFile(contentBase, record));
|
|
203
417
|
}
|
|
204
418
|
return byId;
|
|
205
419
|
}
|
|
@@ -214,12 +428,28 @@ export function noteFilesById(contentBase, { skipDirectories } = {}) {
|
|
|
214
428
|
* address existing. When neither is readable the position is **dropped**, never
|
|
215
429
|
* defaulted to `1:1`.
|
|
216
430
|
*
|
|
431
|
+
* A **declared** rename knows its note without any lookup — the declaration is
|
|
432
|
+
* how it was found — and is reported at the `renamedFrom:` line rather than the
|
|
433
|
+
* `shortcode:` line, because that is the line the finding is about and the one
|
|
434
|
+
* the author deletes once the declaration has done its work (#278).
|
|
435
|
+
*
|
|
217
436
|
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
218
437
|
* @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
|
|
219
438
|
* @returns {{file?: string, line?: number, column?: number}} Spreadable
|
|
220
439
|
* position fields for {@link formatDiagnostic}.
|
|
221
440
|
*/
|
|
222
441
|
export function locateAddressFinding(finding, noteFiles) {
|
|
442
|
+
if (finding.declared && finding.noteFile) {
|
|
443
|
+
try {
|
|
444
|
+
const raw = fs.readFileSync(finding.noteFile, "utf8");
|
|
445
|
+
return {
|
|
446
|
+
file: finding.noteFile,
|
|
447
|
+
...positionInFrontmatter(raw, "renamedFrom", finding.shortcode),
|
|
448
|
+
};
|
|
449
|
+
} catch {
|
|
450
|
+
return { file: finding.noteFile };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
223
453
|
const note = noteFiles?.get(finding.id);
|
|
224
454
|
if (note) {
|
|
225
455
|
try {
|
|
@@ -250,15 +480,29 @@ export function locateAddressFinding(finding, noteFiles) {
|
|
|
250
480
|
* known — and says so, rather than leaving the reader to wonder whether one was
|
|
251
481
|
* looked for.
|
|
252
482
|
*
|
|
483
|
+
* **A declared rename says it is declared** (#278). The two claims are not
|
|
484
|
+
* equally checkable: an id match is a fact in the artefacts, while a
|
|
485
|
+
* declaration is an author's word, and a reader deciding whether to trust the
|
|
486
|
+
* successor needs to know which one they have. Saying "the same document" of a
|
|
487
|
+
* declared rename would spend the id match's credibility on it.
|
|
488
|
+
*
|
|
489
|
+
* The consequence sentence is the same for both, because it is the same
|
|
490
|
+
* consequence: the old address stops resolving either way, and that is what
|
|
491
|
+
* the reader has to act on.
|
|
492
|
+
*
|
|
253
493
|
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
254
494
|
* @returns {string} The message.
|
|
255
495
|
*/
|
|
256
496
|
export function addressFindingMessage(finding) {
|
|
257
497
|
if (finding.kind === "renamed") {
|
|
498
|
+
const how =
|
|
499
|
+
finding.declared ?
|
|
500
|
+
`the note now published as ${finding.to} declares it was ` +
|
|
501
|
+
`renamed from ${finding.shortcode}`
|
|
502
|
+
: `the same document (${finding.id}) is now published as ${finding.to}`;
|
|
258
503
|
return (
|
|
259
504
|
`since ${finding.baseline}, ${finding.address} is no longer ` +
|
|
260
|
-
`published;
|
|
261
|
-
`as ${finding.to}. Every package that resolves ${finding.address} ` +
|
|
505
|
+
`published; ${how}. Every package that resolves ${finding.address} ` +
|
|
262
506
|
`breaks when it moves past ${finding.baseline}`
|
|
263
507
|
);
|
|
264
508
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
* **Anchored body sections** — the `# Heading {#anchor}` convention, and how a
|
|
16
|
+
* compiler pulls one section out of a note's prose.
|
|
17
|
+
*
|
|
18
|
+
* The content format gives three anchors a document meaning: `{#appearance}`,
|
|
19
|
+
* `{#dossier}` and `{#spoilers}`. *Which field* each lands in is a system's
|
|
20
|
+
* business — SoHL writes the first to an actor's `appearance`, HM3 to an
|
|
21
|
+
* actor's and an item's `description` — but *finding* it is not, so the
|
|
22
|
+
* extraction lives here where every compiler reaches it (#139).
|
|
23
|
+
*
|
|
24
|
+
* It was a pair of private functions inside the SoHL actors pass, which is
|
|
25
|
+
* where the convention was first needed and not where it belongs: the anchors
|
|
26
|
+
* are stated in `docs/content-format.md`, alongside secret fences and heading
|
|
27
|
+
* attributes, as part of the format every note is written in.
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { md } from "./helpers.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract the body of an H1 section whose heading carries the explicit
|
|
36
|
+
* anchor decorator `{#<anchorId>}`. Captures every line after the H1 up
|
|
37
|
+
* to (but not including) the next H1 — nested H2/H3 etc. and their bodies
|
|
38
|
+
* are included. The H1 line itself is discarded. Returns "" if no such
|
|
39
|
+
* heading exists. Fenced code blocks are respected so `# foo` inside
|
|
40
|
+
* ``` blocks does not trigger a match.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} body - The note body.
|
|
43
|
+
* @param {string} anchorId - The anchor to find.
|
|
44
|
+
* @returns {string} The section's markdown, or "".
|
|
45
|
+
*/
|
|
46
|
+
export function extractAnchorSection(body, anchorId) {
|
|
47
|
+
const lines = String(body ?? "").split("\n");
|
|
48
|
+
const captured = [];
|
|
49
|
+
let inCodeBlock = false;
|
|
50
|
+
let capturing = false;
|
|
51
|
+
const wanted = String(anchorId).toLowerCase();
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
if (line.trim().startsWith("```")) {
|
|
54
|
+
inCodeBlock = !inCodeBlock;
|
|
55
|
+
if (capturing) captured.push(line);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const h1Match = !inCodeBlock ? line.match(/^\s*#\s+(.+?)\s*#*\s*$/) : null;
|
|
59
|
+
if (h1Match) {
|
|
60
|
+
const anchor = h1Match[1].match(/\{#([^}]+)\}\s*$/);
|
|
61
|
+
const id = anchor?.[1]?.trim().toLowerCase() || null;
|
|
62
|
+
if (capturing) break;
|
|
63
|
+
if (id === wanted) {
|
|
64
|
+
capturing = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (capturing) captured.push(line);
|
|
69
|
+
}
|
|
70
|
+
return captured.join("\n").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Render an extracted markdown section to HTML, or "" if empty.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} body - The note body.
|
|
77
|
+
* @param {string} anchorId - The anchor to find.
|
|
78
|
+
* @returns {string} The rendered HTML, or "".
|
|
79
|
+
*/
|
|
80
|
+
export function renderSection(body, anchorId) {
|
|
81
|
+
const slice = extractAnchorSection(body, anchorId);
|
|
82
|
+
return slice ? md.render(slice) : "";
|
|
83
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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 anchors a note declares on its headings — read in one place (#243).
|
|
16
|
+
*
|
|
17
|
+
* **A leaf, deliberately.** This is asked by the link checker, by the content
|
|
18
|
+
* index, and by the builds that emit a link, and they cannot all import one
|
|
19
|
+
* another: `helpers.mjs` is imported by the compilers, while the index imports
|
|
20
|
+
* the manifest emitter, which imports them back. A reader that imports nothing
|
|
21
|
+
* can be shared by all three — which is the point, because the question "what
|
|
22
|
+
* anchors does this note declare?" had two answers and they disagreed on
|
|
23
|
+
* `{#CalendarFormat}`.
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A heading, and the `{#slug}` anchor it declares.
|
|
30
|
+
*
|
|
31
|
+
* Kept identical to the pair {@link splitPages} matches, because the two must
|
|
32
|
+
* agree about what an anchor is: that pass decides which sections become
|
|
33
|
+
* addressable journal pages, and an index naming an anchor it does not produce
|
|
34
|
+
* would advertise a link that resolves nowhere. `tests/content-index.test.ts`
|
|
35
|
+
* asserts the two find the same anchors, so drift fails the suite rather than
|
|
36
|
+
* shipping.
|
|
37
|
+
*/
|
|
38
|
+
const HEADING = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
39
|
+
const ANCHOR = /^(.*?)\s*\{#([^}]+)\}\s*$/;
|
|
40
|
+
/**
|
|
41
|
+
* The `{#slug}` anchors a note's body declares, with where each one sits.
|
|
42
|
+
*
|
|
43
|
+
* Only headings carrying an explicit anchor are collected. A bare `#` heading
|
|
44
|
+
* also starts a journal page, but it declares no slug, so nothing can address
|
|
45
|
+
* it with `#…` — listing it would offer a link that cannot be written.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} body - The note's markdown body, frontmatter already removed.
|
|
48
|
+
* @param {number} [bodyLine] - The 1-based file line the body starts on, from
|
|
49
|
+
* `parseMarkdownFile`. Anchors are reported at their position in the **file**,
|
|
50
|
+
* so an editor can jump straight to one; passing nothing numbers from the body.
|
|
51
|
+
* @returns {Array<{slug: string, name: string, level: number, line: number}>}
|
|
52
|
+
* In document order.
|
|
53
|
+
*/
|
|
54
|
+
export function collectAnchors(body, bodyLine = 1) {
|
|
55
|
+
const anchors = [];
|
|
56
|
+
let inCodeBlock = false;
|
|
57
|
+
const lines = String(body ?? "").split("\n");
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
// A fenced block's contents are not headings, and `#` is a comment in
|
|
61
|
+
// most of what gets fenced.
|
|
62
|
+
if (lines[i].trim().startsWith("```")) {
|
|
63
|
+
inCodeBlock = !inCodeBlock;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (inCodeBlock) continue;
|
|
67
|
+
|
|
68
|
+
const heading = HEADING.exec(lines[i]);
|
|
69
|
+
if (!heading) continue;
|
|
70
|
+
const anchor = ANCHOR.exec(heading[2].trim());
|
|
71
|
+
if (!anchor) continue;
|
|
72
|
+
|
|
73
|
+
const slug = anchor[2].trim();
|
|
74
|
+
if (!slug) continue;
|
|
75
|
+
anchors.push({
|
|
76
|
+
slug,
|
|
77
|
+
name: anchor[1].trim(),
|
|
78
|
+
level: heading[1].length,
|
|
79
|
+
line: bodyLine + i,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return anchors;
|
|
83
|
+
}
|