@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.
- package/CHANGELOG.md +1773 -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 +394 -111
- 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} +78 -81
- 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/labels.mjs +191 -0
- package/manifest.mjs +43 -2
- package/package.json +19 -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 +64 -80
- 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} +16 -38
- 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/field-spec.mjs
CHANGED
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
* @module
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
import { resolveFieldValue, setPath } from "./system-block.mjs";
|
|
46
|
+
import { legacyKeyOf, resolveFieldValue, setPath } from "./system-block.mjs";
|
|
47
47
|
|
|
48
|
-
export { setPath };
|
|
48
|
+
export { legacyKeyOf, setPath };
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* @typedef {object} FieldSpec
|
|
@@ -62,9 +62,28 @@ export { setPath };
|
|
|
62
62
|
* to share a name. They constantly do not — one shared `data.portrait` feeds
|
|
63
63
|
* `sohl.system.portrait` *and* `hm3.system.bioImage` — so the source is
|
|
64
64
|
* declared rather than matched by spelling (#58). The in-block position is
|
|
65
|
-
* still read, second, until #126 moves the corpus off it
|
|
65
|
+
* still read, second, until #126 moves the corpus off it — keyed on
|
|
66
|
+
* `legacyKey` where the two spellings differ.
|
|
66
67
|
*
|
|
67
68
|
* Absent means the value is not authored at all — see `value`.
|
|
69
|
+
* @property {string} [legacyKey] - **The key this field is authored at inside
|
|
70
|
+
* the system block** — the second position of the resolution order — when
|
|
71
|
+
* that is not `name`. Absent, the position is keyed on `name`.
|
|
72
|
+
*
|
|
73
|
+
* The two were one property until #305, which held only while a field's
|
|
74
|
+
* shared source and its in-block key were the same word. `data:` (#128) ended
|
|
75
|
+
* that: a shared source is a path into a container, so `data.species` and
|
|
76
|
+
* `species` name two different places and no single value reached both.
|
|
77
|
+
* `name: "species"` could not see `data.species`; `name: "data.species"`
|
|
78
|
+
* could not see `hm3.species`; and each yielded the field's **default**
|
|
79
|
+
* wherever only the other position was authored, with the note compiling and
|
|
80
|
+
* the value simply gone.
|
|
81
|
+
*
|
|
82
|
+
* Declaring both restores the shape every other retirement in this package
|
|
83
|
+
* uses — read both spellings, let the current one win, and *report* the
|
|
84
|
+
* retiring one — so a field can move into `data:` while the corpus catches
|
|
85
|
+
* up, instead of on a flag day across four repositories. A declaration that
|
|
86
|
+
* names one is mid-sweep by construction; see {@link readsLegacyKey}.
|
|
68
87
|
* @property {string} [topLevelMeans] - **What the note's top-level key of this
|
|
69
88
|
* name means instead** — declared only where it means something else, and
|
|
70
89
|
* stating it removes the shared top-level position from this field's
|
|
@@ -187,6 +206,28 @@ export const BLANK_IS_DEFAULT = Object.freeze({
|
|
|
187
206
|
/* Applying a declaration */
|
|
188
207
|
/* --------------------------------------------------------------------- */
|
|
189
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Whether a resolution read a field from the position it is being swept off.
|
|
211
|
+
*
|
|
212
|
+
* The sweep's progress signal, in one predicate so the compile-time report and
|
|
213
|
+
* the frontmatter lint cannot disagree about what counts — the role
|
|
214
|
+
* {@link module:engine/retired-fields.declaresRetiredAlias} plays for a renamed
|
|
215
|
+
* field.
|
|
216
|
+
*
|
|
217
|
+
* **Only for a field that declares a `legacyKey`.** Every other field's
|
|
218
|
+
* in-block position is simply where it lives; reporting those would put a
|
|
219
|
+
* finding on every field of every note in every tree, which is #126's corpus
|
|
220
|
+
* migration rather than a signal anyone could act on.
|
|
221
|
+
*
|
|
222
|
+
* @param {FieldSpec} field - The declaration.
|
|
223
|
+
* @param {import("./system-block.mjs").FieldSource} from - Where
|
|
224
|
+
* {@link resolveFieldValue} said the value came from.
|
|
225
|
+
* @returns {boolean} True when the value came from the retiring position.
|
|
226
|
+
*/
|
|
227
|
+
export function readsLegacyKey(field, from) {
|
|
228
|
+
return field?.legacyKey !== undefined && from === "block";
|
|
229
|
+
}
|
|
230
|
+
|
|
190
231
|
/**
|
|
191
232
|
* Read one declared field out of a note's frontmatter.
|
|
192
233
|
*
|
|
@@ -203,11 +244,17 @@ export const BLANK_IS_DEFAULT = Object.freeze({
|
|
|
203
244
|
* @param {string} [options.block="sohl"] - Which system's block to resolve
|
|
204
245
|
* against. The default is the one block every existing tree authors; a
|
|
205
246
|
* second system passes its own.
|
|
247
|
+
* @param {(field: FieldSpec) => void} [options.onLegacyKey] - Called with each
|
|
248
|
+
* field read from the position it is being swept off (#305). A callback
|
|
249
|
+
* rather than a returned list because the caller is a compiler, which already
|
|
250
|
+
* knows the note and how to locate a key in it; this module knows neither and
|
|
251
|
+
* would have to invent a finding shape to say so.
|
|
206
252
|
* @returns {any} The value to emit.
|
|
207
253
|
*/
|
|
208
|
-
export function readField(field, fm, { block = "sohl" } = {}) {
|
|
254
|
+
export function readField(field, fm, { block = "sohl", onLegacyKey } = {}) {
|
|
209
255
|
const { value, from } = resolveFieldValue(field, fm, { block });
|
|
210
256
|
if (from === "value") return value;
|
|
257
|
+
if (onLegacyKey && readsLegacyKey(field, from)) onLegacyKey(field);
|
|
211
258
|
return field.read ? field.read(value, { fm, field }) : value;
|
|
212
259
|
}
|
|
213
260
|
|
|
@@ -219,13 +266,16 @@ export function readField(field, fm, { block = "sohl" } = {}) {
|
|
|
219
266
|
* @param {string} [options.block="sohl"] - Which system's block the builder
|
|
220
267
|
* reads. One declaration compiles against any block, which is what lets two
|
|
221
268
|
* systems declare the same shared source and different destinations.
|
|
269
|
+
* @param {(field: FieldSpec) => void} [options.onLegacyKey] - Passed through to
|
|
270
|
+
* {@link readField}: called with each field the note authored at the position
|
|
271
|
+
* it is being swept off (#305).
|
|
222
272
|
* @returns {(fm: object) => object} A `system`-block builder.
|
|
223
273
|
*/
|
|
224
|
-
export function buildFromFields(fields, { block = "sohl" } = {}) {
|
|
274
|
+
export function buildFromFields(fields, { block = "sohl", onLegacyKey } = {}) {
|
|
225
275
|
return function buildDeclaredSystem(fm) {
|
|
226
276
|
const out = {};
|
|
227
277
|
for (const field of fields) {
|
|
228
|
-
setPath(out, field.to, readField(field, fm, { block }));
|
|
278
|
+
setPath(out, field.to, readField(field, fm, { block, onLegacyKey }));
|
|
229
279
|
}
|
|
230
280
|
return out;
|
|
231
281
|
};
|
|
@@ -0,0 +1,470 @@
|
|
|
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
|
+
* Folders, as notes.
|
|
16
|
+
*
|
|
17
|
+
* A `Folder` is a real Foundry document, and it was the last kind this package
|
|
18
|
+
* compiled from bespoke configuration — `*-folders.yaml`, five files per tree —
|
|
19
|
+
* rather than from a note. That was the one hole in the rule #243 establishes,
|
|
20
|
+
* *the compiler follows the index*: a pass cannot follow the index for things
|
|
21
|
+
* the index does not contain (#256).
|
|
22
|
+
*
|
|
23
|
+
* Three things follow from a folder being a note, and each is a defect that
|
|
24
|
+
* becomes unrepresentable rather than a tidiness win:
|
|
25
|
+
*
|
|
26
|
+
* 1. **`parent` is an address**, resolved and checked like every other
|
|
27
|
+
* reference. A dangling parent stops being a special-cased
|
|
28
|
+
* `Unknown folder id` and becomes an ordinary dead-address finding.
|
|
29
|
+
* 2. **Where a folder materialises is derived from what references it** (#257).
|
|
30
|
+
* A documentation journal is filed beside the item it describes, which used
|
|
31
|
+
* to mean passing the *items* pack's folder id into the *journals* pack —
|
|
32
|
+
* verbatim, validated nowhere, and correct only where the two folder files
|
|
33
|
+
* happened to mirror each other. They did in one tree of three. With one
|
|
34
|
+
* folder note and one address there is no second file to disagree with the
|
|
35
|
+
* first, so a pack cannot fail to declare a folder something in it points at.
|
|
36
|
+
* 3. **The Foundry id is derived from the address** (#258), the way a
|
|
37
|
+
* `JournalEntryPage` id is already hashed from its anchor — with an authored
|
|
38
|
+
* `id` still winning, so a world already holding these folders keeps
|
|
39
|
+
* resolving them.
|
|
40
|
+
*
|
|
41
|
+
* A folder note is addressed `<package>-none-folder-<shortcode>`: **`none`**,
|
|
42
|
+
* because a `Folder` is a core Foundry document like a `JournalEntry` or a
|
|
43
|
+
* `Scene`, not a system's.
|
|
44
|
+
*
|
|
45
|
+
* **A folder note carries no prose.** It is structure, not content, so it wants
|
|
46
|
+
* no documentation journal and takes no part in `docEntryTypes` — the one
|
|
47
|
+
* decision #256 left open, settled the way it recommended.
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import path from "node:path";
|
|
53
|
+
|
|
54
|
+
import log from "loglevel";
|
|
55
|
+
|
|
56
|
+
import { NO_SYSTEM, canonicalKey } from "./content-address.mjs";
|
|
57
|
+
import { isAddressSegment } from "./address-charset.mjs";
|
|
58
|
+
import { makeId } from "./ids.mjs";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The note type a folder is authored as.
|
|
62
|
+
*
|
|
63
|
+
* @type {string}
|
|
64
|
+
*/
|
|
65
|
+
export const FOLDER_TYPE = "folder";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The id namespace a derived folder id is hashed under.
|
|
69
|
+
*
|
|
70
|
+
* Distinct from every other document's namespace so a folder and an item
|
|
71
|
+
* sharing a shortcode cannot derive the same id — the collision would be
|
|
72
|
+
* silent, since Foundry keys folders and documents in separate collections and
|
|
73
|
+
* neither would complain.
|
|
74
|
+
*
|
|
75
|
+
* @type {string}
|
|
76
|
+
*/
|
|
77
|
+
export const FOLDER_ID_NAMESPACE = "folder";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One folder note, read from the tree.
|
|
81
|
+
*
|
|
82
|
+
* @typedef {object} FolderNote
|
|
83
|
+
* @property {string} shortcode - Its `(type, shortcode)` identity.
|
|
84
|
+
* @property {string} address - The canonical `<pkg>-none-folder-<shortcode>`.
|
|
85
|
+
* @property {string} name - The display name.
|
|
86
|
+
* @property {string|null} color - CSS hex, or `null`.
|
|
87
|
+
* @property {Record<string, string|null>} parent - The parent's authored
|
|
88
|
+
* address per pack, keyed by pack name with {@link DEFAULT_PARENT} for the
|
|
89
|
+
* unstated case. A folder's identity is one thing; its hierarchy is per-pack.
|
|
90
|
+
* @property {string} id - The Foundry id: authored, or derived from `address`.
|
|
91
|
+
* @property {boolean} derivedId - Whether `id` was derived rather than authored.
|
|
92
|
+
* @property {string} absPath - The file it was read from, for diagnostics.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The key a per-pack `parent` map uses for "everywhere else".
|
|
97
|
+
*
|
|
98
|
+
* Spelled out rather than left as the absence of a key, so a map that states
|
|
99
|
+
* only exceptions still reads as a complete answer.
|
|
100
|
+
*
|
|
101
|
+
* @type {string}
|
|
102
|
+
*/
|
|
103
|
+
export const DEFAULT_PARENT = "default";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read one folder note's fields out of its frontmatter.
|
|
107
|
+
*
|
|
108
|
+
* `parent` and `color` are `data:` properties, which is where the
|
|
109
|
+
* specification's `### type: folder` table puts them — the closed container,
|
|
110
|
+
* so a misspelled `colour` is a finding rather than a theme parameter. They are
|
|
111
|
+
* accepted at the top level too, because that is where #256's own example wrote
|
|
112
|
+
* them and an author following the issue rather than the specification should
|
|
113
|
+
* get a folder, not a silent default.
|
|
114
|
+
*
|
|
115
|
+
* **`parent` may be a map keyed by pack**, because a folder's *hierarchy* is
|
|
116
|
+
* per-pack even though its identity is not. Both large trees rely on that
|
|
117
|
+
* deliberately: this repository files the three item roots one level deeper in
|
|
118
|
+
* the journals pack (under `Rules/Descriptions`, beside `Rules/Combat`), and
|
|
119
|
+
* `sohl-thalorna` groups the items pack by document kind and the journals pack
|
|
120
|
+
* by setting geography — 46 of its 75 shared folders sit under a different
|
|
121
|
+
* parent in each. A single scalar cannot express either, and flattening to one
|
|
122
|
+
* hierarchy would silently reorganise both compendiums.
|
|
123
|
+
*
|
|
124
|
+
* A scalar stays the everyday spelling, and is exactly `{ default: value }`.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} fm - Parsed frontmatter.
|
|
127
|
+
* @returns {{parent: Record<string, string|null>, color: string|null}} The
|
|
128
|
+
* parent by pack — always a map, with {@link DEFAULT_PARENT} for the
|
|
129
|
+
* unstated case — and the colour.
|
|
130
|
+
*/
|
|
131
|
+
function folderFields(fm) {
|
|
132
|
+
const data = fm?.data && typeof fm.data === "object" ? fm.data : {};
|
|
133
|
+
const read = (key) => data[key] ?? fm?.[key];
|
|
134
|
+
const text = (value) => {
|
|
135
|
+
if (value == null) return null;
|
|
136
|
+
const trimmed = String(value).trim();
|
|
137
|
+
return trimmed === "" ? null : trimmed;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const authored = read("parent");
|
|
141
|
+
/** @type {Record<string, string|null>} */
|
|
142
|
+
const parent = {};
|
|
143
|
+
if (authored != null && typeof authored === "object" && !Array.isArray(authored)) {
|
|
144
|
+
// An explicit `~` under a pack key means "at the root *there*", which
|
|
145
|
+
// is a different statement from saying nothing — so the key is kept
|
|
146
|
+
// with a null value rather than dropped.
|
|
147
|
+
for (const [pack, value] of Object.entries(authored)) parent[pack] = text(value);
|
|
148
|
+
} else {
|
|
149
|
+
parent[DEFAULT_PARENT] = text(authored);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { parent, color: text(read("color")) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* An authored `parent` with any wikilink brackets and label stripped.
|
|
157
|
+
*
|
|
158
|
+
* The specification types `parent` as a `WikiLink`, and a frontmatter link is
|
|
159
|
+
* written as a bare address — but `[[address]]` is what an author reaches for,
|
|
160
|
+
* and Obsidian wrote that form for years. Accepting both costs one regex and
|
|
161
|
+
* removes a failure whose message would have to explain the difference.
|
|
162
|
+
*
|
|
163
|
+
* @param {string|null} value - As authored.
|
|
164
|
+
* @returns {string|null} The bare address.
|
|
165
|
+
*/
|
|
166
|
+
export function bareAddress(value) {
|
|
167
|
+
if (value == null) return null;
|
|
168
|
+
const text = String(value).trim();
|
|
169
|
+
if (!text) return null;
|
|
170
|
+
const unwrapped = text.replace(/^\[\[(.*)\]\]$/s, "$1");
|
|
171
|
+
// A label is presentation; the address is everything before the pipe.
|
|
172
|
+
const [target] = unwrapped.split("|");
|
|
173
|
+
return target.trim() || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The canonical address of a folder note in this package.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} pkg - The content package.
|
|
180
|
+
* @param {string} shortcode - The folder's shortcode.
|
|
181
|
+
* @returns {string} `<pkg>-none-folder-<shortcode>`.
|
|
182
|
+
*/
|
|
183
|
+
export function folderAddress(pkg, shortcode) {
|
|
184
|
+
return canonicalKey(pkg, NO_SYSTEM, FOLDER_TYPE, shortcode);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Collect every folder note in a content tree.
|
|
189
|
+
*
|
|
190
|
+
* The walk is the caller's to supply, so this stays testable without a tree on
|
|
191
|
+
* disk and so one build cannot disagree with another about what the corpus is
|
|
192
|
+
* (#243).
|
|
193
|
+
*
|
|
194
|
+
* @param {Iterable<{frontmatter: object|null, absPath: string}>} notes - As
|
|
195
|
+
* yielded by `walkMarkdownTree`.
|
|
196
|
+
* @param {string} pkg - The content package, for the canonical address.
|
|
197
|
+
* @returns {FolderNote[]} One record per folder note, in walk order.
|
|
198
|
+
* @throws {Error} When a folder note is unusable on its own terms — no
|
|
199
|
+
* shortcode, a shortcode that is not an address segment, or no name.
|
|
200
|
+
*/
|
|
201
|
+
export function collectFolderNotes(notes, pkg) {
|
|
202
|
+
/** @type {FolderNote[]} */
|
|
203
|
+
const folders = [];
|
|
204
|
+
for (const { frontmatter: fm, absPath } of notes) {
|
|
205
|
+
if (!fm || String(fm.type ?? "").toLowerCase() !== FOLDER_TYPE) continue;
|
|
206
|
+
|
|
207
|
+
const shortcode = fm.shortcode == null ? "" : String(fm.shortcode).trim();
|
|
208
|
+
if (!shortcode) {
|
|
209
|
+
throw Object.assign(new Error(`folder note has no shortcode: ${absPath}`), {
|
|
210
|
+
absPath,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// The charset rule is load-bearing here rather than tidy: the address
|
|
214
|
+
// is parsed by counting separators, so a hyphenated shortcode would be
|
|
215
|
+
// read back as two segments and resolve to nothing, reporting nothing
|
|
216
|
+
// about why (#1397, #273).
|
|
217
|
+
if (!isAddressSegment(shortcode)) {
|
|
218
|
+
throw Object.assign(
|
|
219
|
+
new Error(
|
|
220
|
+
`folder shortcode "${shortcode}" is not strictly ` +
|
|
221
|
+
`alphanumeric, so its address would not parse: ${absPath}`,
|
|
222
|
+
),
|
|
223
|
+
{ absPath },
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const name = fm.name?.full ?? path.basename(absPath, ".md").replace(/_/g, " ");
|
|
228
|
+
if (!name) {
|
|
229
|
+
throw Object.assign(new Error(`folder note "${shortcode}" has no name`), { absPath });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { parent, color } = folderFields(fm);
|
|
233
|
+
const address = folderAddress(pkg, shortcode);
|
|
234
|
+
const authoredId = fm.id == null ? "" : String(fm.id).trim();
|
|
235
|
+
|
|
236
|
+
folders.push({
|
|
237
|
+
shortcode,
|
|
238
|
+
address,
|
|
239
|
+
name: String(name),
|
|
240
|
+
color,
|
|
241
|
+
parent: Object.fromEntries(
|
|
242
|
+
Object.entries(parent).map(([pack, value]) => [pack, bareAddress(value)]),
|
|
243
|
+
),
|
|
244
|
+
// An authored id is kept, and a folder without one derives a
|
|
245
|
+
// stable one from its address (#258). Keeping the authored id is
|
|
246
|
+
// what makes this a build change rather than a world migration: a
|
|
247
|
+
// world already holding these folders goes on resolving them.
|
|
248
|
+
id: authoredId || makeId(FOLDER_ID_NAMESPACE, address),
|
|
249
|
+
derivedId: !authoredId,
|
|
250
|
+
absPath,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return folders;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Index folder notes by every form an author may address one by, and check the
|
|
258
|
+
* invariants that make the index sound.
|
|
259
|
+
*
|
|
260
|
+
* Three keys per folder, and no more: the canonical address, the
|
|
261
|
+
* `folder-<shortcode>` short form, and the bare shortcode. They are the
|
|
262
|
+
* suffixes of the canonical address the grammar admits (#273) — a `packFolder`
|
|
263
|
+
* or `parent` field supplies the type itself, so a bare shortcode is a complete
|
|
264
|
+
* address there.
|
|
265
|
+
*
|
|
266
|
+
* @param {FolderNote[]} folders - From {@link collectFolderNotes}.
|
|
267
|
+
* @returns {{byKey: Map<string, FolderNote>, folders: FolderNote[],
|
|
268
|
+
* resolve: (value: string) => FolderNote, ancestorsOf: (folder: FolderNote)
|
|
269
|
+
* => FolderNote[], parentOf: (folder: FolderNote) => FolderNote|null}}
|
|
270
|
+
* @throws {Error} On a duplicate shortcode, a duplicate id, a dead `parent`,
|
|
271
|
+
* or a parent cycle.
|
|
272
|
+
*/
|
|
273
|
+
export function buildFolderNoteIndex(folders) {
|
|
274
|
+
/** @type {Map<string, FolderNote>} */
|
|
275
|
+
const byKey = new Map();
|
|
276
|
+
/** @type {Map<string, FolderNote>} */
|
|
277
|
+
const byShortcode = new Map();
|
|
278
|
+
/** @type {Map<string, FolderNote>} */
|
|
279
|
+
const byId = new Map();
|
|
280
|
+
|
|
281
|
+
for (const folder of folders) {
|
|
282
|
+
const key = folder.shortcode.toLowerCase();
|
|
283
|
+
const clash = byShortcode.get(key);
|
|
284
|
+
if (clash) {
|
|
285
|
+
throw Object.assign(
|
|
286
|
+
new Error(
|
|
287
|
+
`two folder notes share the shortcode "${folder.shortcode}" ` +
|
|
288
|
+
`— ${clash.absPath} and ${folder.absPath}`,
|
|
289
|
+
),
|
|
290
|
+
{ absPath: folder.absPath },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
byShortcode.set(key, folder);
|
|
294
|
+
|
|
295
|
+
// A collision here is a build error rather than a last-write-wins: two
|
|
296
|
+
// folders with one id are one folder in Foundry, and the documents
|
|
297
|
+
// filed in the loser would land somewhere their author never named.
|
|
298
|
+
const idClash = byId.get(folder.id);
|
|
299
|
+
if (idClash) {
|
|
300
|
+
const how =
|
|
301
|
+
folder.derivedId && idClash.derivedId ?
|
|
302
|
+
"both derived from their addresses"
|
|
303
|
+
: "one of them authored";
|
|
304
|
+
throw Object.assign(
|
|
305
|
+
new Error(
|
|
306
|
+
`folder id "${folder.id}" is claimed twice (${how}) — ` +
|
|
307
|
+
`"${idClash.shortcode}" (${idClash.absPath}) and ` +
|
|
308
|
+
`"${folder.shortcode}" (${folder.absPath})`,
|
|
309
|
+
),
|
|
310
|
+
{ absPath: folder.absPath },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
byId.set(folder.id, folder);
|
|
314
|
+
|
|
315
|
+
byKey.set(folder.address.toLowerCase(), folder);
|
|
316
|
+
byKey.set(`${FOLDER_TYPE}-${folder.shortcode}`.toLowerCase(), folder);
|
|
317
|
+
byKey.set(key, folder);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The folder an address names.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} value - A folder address, in any admitted form.
|
|
324
|
+
* @returns {FolderNote} The folder.
|
|
325
|
+
* @throws {Error} When nothing answers to it.
|
|
326
|
+
*/
|
|
327
|
+
function resolve(value) {
|
|
328
|
+
const address = bareAddress(value);
|
|
329
|
+
if (!address) {
|
|
330
|
+
throw new Error("a folder reference is blank");
|
|
331
|
+
}
|
|
332
|
+
const hit = byKey.get(address.toLowerCase());
|
|
333
|
+
if (!hit) {
|
|
334
|
+
const known = [...byShortcode.values()].map((f) => f.shortcode).sort();
|
|
335
|
+
throw new Error(
|
|
336
|
+
`no folder note is addressed "${address}" — this package ` +
|
|
337
|
+
`declares ${known.length} folder(s)` +
|
|
338
|
+
(known.length ? `: ${known.join(", ")}` : ""),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return hit;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The parent a folder has **in one pack**, or `null` at the root there.
|
|
346
|
+
*
|
|
347
|
+
* A folder's identity is one thing and its hierarchy is another: the same
|
|
348
|
+
* folder is filed under a different parent in the items pack and the
|
|
349
|
+
* journals pack throughout both large trees, deliberately. So every
|
|
350
|
+
* question about the chain is asked of a pack, and a folder note that
|
|
351
|
+
* states one scalar answers the same way for all of them.
|
|
352
|
+
*
|
|
353
|
+
* @param {FolderNote} folder - The folder.
|
|
354
|
+
* @param {string} [pack] - The pack being compiled.
|
|
355
|
+
* @returns {FolderNote|null} Its parent there.
|
|
356
|
+
*/
|
|
357
|
+
function parentOf(folder, pack) {
|
|
358
|
+
const authored =
|
|
359
|
+
pack != null && Object.hasOwn(folder.parent, pack) ?
|
|
360
|
+
folder.parent[pack]
|
|
361
|
+
: folder.parent[DEFAULT_PARENT];
|
|
362
|
+
if (!authored) return null;
|
|
363
|
+
try {
|
|
364
|
+
return resolve(authored);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
throw Object.assign(
|
|
367
|
+
new Error(
|
|
368
|
+
`folder "${folder.shortcode}" names a parent that ${err.message}` +
|
|
369
|
+
(pack != null ? ` (in pack "${pack}")` : ""),
|
|
370
|
+
),
|
|
371
|
+
{ absPath: folder.absPath },
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Every ancestor of a folder in one pack, nearest first.
|
|
378
|
+
*
|
|
379
|
+
* A folder cannot materialise without them: a `Folder` whose parent is
|
|
380
|
+
* absent from the pack is an orphan Foundry renders at the root, so the
|
|
381
|
+
* tree would be broken at the top rather than merely incomplete (#257).
|
|
382
|
+
*
|
|
383
|
+
* @param {FolderNote} folder - The folder.
|
|
384
|
+
* @param {string} [pack] - The pack being compiled.
|
|
385
|
+
* @returns {FolderNote[]} Its ancestors there.
|
|
386
|
+
* @throws {Error} On a parent cycle.
|
|
387
|
+
*/
|
|
388
|
+
function ancestorsOf(folder, pack) {
|
|
389
|
+
/** @type {FolderNote[]} */
|
|
390
|
+
const chain = [];
|
|
391
|
+
const seen = new Set([folder.address]);
|
|
392
|
+
let current = parentOf(folder, pack);
|
|
393
|
+
while (current) {
|
|
394
|
+
if (seen.has(current.address)) {
|
|
395
|
+
throw Object.assign(
|
|
396
|
+
new Error(
|
|
397
|
+
`folder "${folder.shortcode}" sits in a parent cycle` +
|
|
398
|
+
(pack != null ? ` in pack "${pack}"` : "") +
|
|
399
|
+
`: ${[...seen, current.address].join(" → ")}`,
|
|
400
|
+
),
|
|
401
|
+
{ absPath: folder.absPath },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
seen.add(current.address);
|
|
405
|
+
chain.push(current);
|
|
406
|
+
current = parentOf(current, pack);
|
|
407
|
+
}
|
|
408
|
+
return chain;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Every parent is resolved and every chain walked once, here, so a dead or
|
|
412
|
+
// circular `parent` is reported when the index is built rather than when
|
|
413
|
+
// some note happens to reference the folder that carries it. A tree whose
|
|
414
|
+
// folders are all reachable but one is still a broken tree.
|
|
415
|
+
//
|
|
416
|
+
// Every *declared* pack is walked, not just the default: a chain that is
|
|
417
|
+
// sound by default and circular in the journals pack is still a broken
|
|
418
|
+
// tree, and nothing else would look at it until that pack compiled.
|
|
419
|
+
const declaredPacks = new Set();
|
|
420
|
+
for (const folder of folders) {
|
|
421
|
+
for (const pack of Object.keys(folder.parent)) {
|
|
422
|
+
if (pack !== DEFAULT_PARENT) declaredPacks.add(pack);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
for (const folder of folders) {
|
|
426
|
+
ancestorsOf(folder);
|
|
427
|
+
for (const pack of declaredPacks) ancestorsOf(folder, pack);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
log.debug(
|
|
431
|
+
`Folder notes: ${folders.length} folder(s) indexed` +
|
|
432
|
+
(declaredPacks.size ? `, ${declaredPacks.size} with a per-pack parent` : ""),
|
|
433
|
+
);
|
|
434
|
+
return { byKey, folders, resolve, ancestorsOf, parentOf };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The Foundry `Folder` document one folder note compiles to, for one pack.
|
|
439
|
+
*
|
|
440
|
+
* The same folder materialises in several packs — the items pack and the
|
|
441
|
+
* journals pack both hold it when both hold something filed in it — and each
|
|
442
|
+
* copy differs only in `type`, which is the document class the folder holds.
|
|
443
|
+
* The `_id` is shared deliberately: a documentation journal filed beside its
|
|
444
|
+
* item is the whole point, and two ids would put them in two folders that
|
|
445
|
+
* merely looked alike.
|
|
446
|
+
*
|
|
447
|
+
* @param {FolderNote} folder - The folder note.
|
|
448
|
+
* @param {FolderNote|null} parent - Its parent, already resolved.
|
|
449
|
+
* @param {string} documentType - `"Item"`, `"JournalEntry"`, …
|
|
450
|
+
* @param {object} stats - The `_stats` block every emitted document carries.
|
|
451
|
+
* @returns {object} The Folder document.
|
|
452
|
+
*/
|
|
453
|
+
export function folderDocument(folder, parent, documentType, stats) {
|
|
454
|
+
return {
|
|
455
|
+
name: folder.name,
|
|
456
|
+
sorting: "a",
|
|
457
|
+
folder: parent ? parent.id : null,
|
|
458
|
+
type: documentType,
|
|
459
|
+
_id: folder.id,
|
|
460
|
+
sort: 0,
|
|
461
|
+
// `undefined`, not `null`, when a folder declares no colour: an absent
|
|
462
|
+
// key is what the YAML emitter produced and what Foundry reads as "no
|
|
463
|
+
// colour set". `JSON.stringify` drops the key entirely, so a swept tree
|
|
464
|
+
// emits the same bytes an unswept one does.
|
|
465
|
+
color: folder.color ?? undefined,
|
|
466
|
+
flags: {},
|
|
467
|
+
_stats: stats,
|
|
468
|
+
_key: `!folders!${folder.id}`,
|
|
469
|
+
};
|
|
470
|
+
}
|