@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
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
* Adventure pack compiler — `type: bundle` notes → Foundry `Adventure`
|
|
16
|
+
* documents (#259).
|
|
17
|
+
*
|
|
18
|
+
* The specification and the vocabulary landed in #263, which left the type
|
|
19
|
+
* declared and uncompiled: authoring one said so, in as many words. This is the
|
|
20
|
+
* pass, and the two decisions #263 recorded are settled here.
|
|
21
|
+
*
|
|
22
|
+
* **Which pack.** Not the `adventures` **companion**. The scenes pass already
|
|
23
|
+
* writes one Adventure per pinned place into that pack, and a companion is
|
|
24
|
+
* written by another pass's pass — the router refuses a note that names one in
|
|
25
|
+
* `pack:`, and it is right to. So a bundle lands in an ordinary Adventure pack,
|
|
26
|
+
* routed and defaulted exactly as items and actors are, and the place-adventures
|
|
27
|
+
* companion is left alone. A repository that authors bundles declares an
|
|
28
|
+
* Adventure pack of its own; one that does not is told so by name, because
|
|
29
|
+
* `bundle` is in `PACK_BY_TYPE` and the unclaimed-note check reads it.
|
|
30
|
+
*
|
|
31
|
+
* **What a `contents` address names: the note's own document.** That is already
|
|
32
|
+
* the router's rule for `pack:`, so there is one answer and not two. One note
|
|
33
|
+
* can compile into two documents — an item and the JournalEntry its prose
|
|
34
|
+
* became — and the second is bundled only when the note names it by its own
|
|
35
|
+
* `doc…` address, which is the address that already exists for it (#1362).
|
|
36
|
+
* Nothing is inferred: naming `miscgear-bowlcer` puts the *item* in the bundle
|
|
37
|
+
* and not its description page.
|
|
38
|
+
*
|
|
39
|
+
* **An Adventure holds copies, not references**, so `contents` resolves against
|
|
40
|
+
* *compiled output* rather than against the content tree. That makes this pass
|
|
41
|
+
* a reader of every other one, which it states in
|
|
42
|
+
* {@link Bundles.readsPackOutputOf} rather than leaving to the order a
|
|
43
|
+
* consumer happened to write its pack list in — the generator derives the
|
|
44
|
+
* compile order from that declaration (#73).
|
|
45
|
+
*
|
|
46
|
+
* **A pack's `system:` constrains what its Adventures may hold.** An
|
|
47
|
+
* `Adventure` has no `system` field, so a bundle spanning two systems cannot be
|
|
48
|
+
* one document that knows it spans them: it is one Adventure per system, and
|
|
49
|
+
* the pack each is written to is what carries the system. Which packs a note
|
|
50
|
+
* reaches is the shared routing field — `<system>.pack` overrides the top-level
|
|
51
|
+
* `pack:` for that system, as everywhere else — and a document belonging to
|
|
52
|
+
* neither `none` nor the pack's system is **left out rather than failing**,
|
|
53
|
+
* with a warning naming it so the omission is never silent.
|
|
54
|
+
*
|
|
55
|
+
* @module
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import fs from "node:fs";
|
|
59
|
+
import path from "node:path";
|
|
60
|
+
|
|
61
|
+
import { BasePackCompiler } from "./base-compiler.mjs";
|
|
62
|
+
import {
|
|
63
|
+
BUNDLE_TYPE,
|
|
64
|
+
buildAdventure,
|
|
65
|
+
bundleContents,
|
|
66
|
+
missingMemberVerdict,
|
|
67
|
+
} from "./bundle-notes.mjs";
|
|
68
|
+
import { contentPackage } from "./content-package.mjs";
|
|
69
|
+
import { FOLDER_TYPE } from "./folder-notes.mjs";
|
|
70
|
+
import { HOMEPAGE_TYPE } from "./homepage.mjs";
|
|
71
|
+
import { itemDocEntryId } from "./item-docs.mjs";
|
|
72
|
+
import { folderField, md, resolveImg, resolveName } from "./helpers.mjs";
|
|
73
|
+
import { packForType } from "./ids.mjs";
|
|
74
|
+
import { readQualifier } from "./wikilinks.mjs";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load every compiled document a bundle may hold, keyed `<docType>/<id>`.
|
|
78
|
+
*
|
|
79
|
+
* Keyed by id rather than by address because that is what the note resolves to:
|
|
80
|
+
* an address names a note, the note's id is derived once by `resolveNoteId`,
|
|
81
|
+
* and every pass files its document under it. Matching on the id is therefore
|
|
82
|
+
* an identity check rather than a second derivation that could disagree with
|
|
83
|
+
* the first.
|
|
84
|
+
*
|
|
85
|
+
* A **folder** document is skipped. It is emitted into every pack that holds
|
|
86
|
+
* something filed in it (#257), so it is not one pack's document and has no
|
|
87
|
+
* single note behind it; a bundle that wants folders is a question this pass
|
|
88
|
+
* refuses rather than guesses at — see {@link Bundles#resolveAddress}.
|
|
89
|
+
*
|
|
90
|
+
* @param {Readonly<Record<string, readonly string[]>>} sourceDirs - The JSON
|
|
91
|
+
* directories of every pack whose output may be bundled, by document type.
|
|
92
|
+
* @returns {Map<string, object>} The compiled documents.
|
|
93
|
+
* @throws {Error} When a declared directory does not exist — the generator
|
|
94
|
+
* orders this pass last, so what reaches this is a run restricted to one
|
|
95
|
+
* pack, which reordering cannot fix.
|
|
96
|
+
*/
|
|
97
|
+
export function loadBundleSources(sourceDirs) {
|
|
98
|
+
/** @type {Map<string, object>} */
|
|
99
|
+
const documents = new Map();
|
|
100
|
+
for (const [docType, dirs] of Object.entries(sourceDirs ?? {})) {
|
|
101
|
+
for (const dir of dirs) {
|
|
102
|
+
if (!fs.existsSync(dir)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Bundle source directory ${dir} does not exist — an ` +
|
|
105
|
+
`Adventure holds copies of compiled documents, so the ` +
|
|
106
|
+
`${docType} packs must be compiled before this one`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
for (const name of fs.readdirSync(dir)) {
|
|
110
|
+
if (!name.endsWith(".json")) continue;
|
|
111
|
+
// A folder document is written per pack, not per note.
|
|
112
|
+
if (name.startsWith("folder_")) continue;
|
|
113
|
+
const doc = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
114
|
+
if (!doc?._id) continue;
|
|
115
|
+
documents.set(`${docType}/${doc._id}`, doc);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return documents;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class Bundles extends BasePackCompiler {
|
|
123
|
+
static id = "bundles";
|
|
124
|
+
static label = "bundle";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every document type a bundle can hold a copy of.
|
|
128
|
+
*
|
|
129
|
+
* The declaration the generator orders passes by (#73): an Adventure holds
|
|
130
|
+
* *compiled* documents, so every pass that produces one runs first. Stated
|
|
131
|
+
* here, in the class that does the reading, rather than in each consuming
|
|
132
|
+
* repository's pack list.
|
|
133
|
+
*
|
|
134
|
+
* @type {readonly string[]}
|
|
135
|
+
*/
|
|
136
|
+
static readsPackOutputOf = Object.freeze(["Actor", "Item", "JournalEntry", "Macro", "Scene"]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The JSON directories this pass reads its members from, by document type.
|
|
140
|
+
*
|
|
141
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
142
|
+
*/
|
|
143
|
+
bundleSourceDirs;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {object} options - As {@link BasePackCompiler}, plus:
|
|
147
|
+
* @param {Record<string, readonly string[]>} [options.bundleSourceDirs] -
|
|
148
|
+
* Each bundleable pack's JSON tree, by document type. Supplied by the
|
|
149
|
+
* generator from the configured pack list, so the dependency is stated
|
|
150
|
+
* rather than assumed from a sibling directory.
|
|
151
|
+
*/
|
|
152
|
+
constructor({ bundleSourceDirs = {}, ...options }) {
|
|
153
|
+
super(options);
|
|
154
|
+
Object.defineProperty(this, "bundleSourceDirs", {
|
|
155
|
+
value: Object.freeze(
|
|
156
|
+
Object.fromEntries(
|
|
157
|
+
Object.entries(bundleSourceDirs).map(([type, dirs]) => [
|
|
158
|
+
type,
|
|
159
|
+
Object.freeze([...dirs]),
|
|
160
|
+
]),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
writable: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {object} fm - The note's frontmatter.
|
|
169
|
+
* @returns {boolean} True for a bundle note.
|
|
170
|
+
*/
|
|
171
|
+
selects(fm) {
|
|
172
|
+
return fm.type === BUNDLE_TYPE;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @inheritdoc */
|
|
176
|
+
async prepare() {
|
|
177
|
+
await super.prepare();
|
|
178
|
+
this.members = loadBundleSources(this.bundleSourceDirs);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The document one address in `contents` names.
|
|
183
|
+
*
|
|
184
|
+
* The address alone, resolved against the content tree — whether this pack
|
|
185
|
+
* actually *holds* that document is the caller's question, and the answer
|
|
186
|
+
* to it is what the system rule turns on.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} address - One bare address from `contents`.
|
|
189
|
+
* @param {string} bundleName - The bundle, for the message.
|
|
190
|
+
* @returns {{docType: string, id: string, name: string}} The document the
|
|
191
|
+
* address names.
|
|
192
|
+
* @throws {Error} When the address names nothing this build compiles.
|
|
193
|
+
*/
|
|
194
|
+
resolveAddress(address, bundleName) {
|
|
195
|
+
const index = this.linkIndex;
|
|
196
|
+
const read = readQualifier(address, index.types, index.packages);
|
|
197
|
+
if (!read || read.reason) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`bundle "${bundleName}" lists "${address}", which is not an ` +
|
|
200
|
+
`address — a member is named "<type>-<shortcode>", the ` +
|
|
201
|
+
`same form a wikilink uses`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
// A member is a *copy*, so it has to be a document this build compiled.
|
|
205
|
+
// A foreign package's address resolves to a UUID and to no JSON, which
|
|
206
|
+
// is a different fact from a typo and is worth its own sentence.
|
|
207
|
+
if (read.package && read.package !== contentPackage()) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`bundle "${bundleName}" lists "${address}", which belongs to ` +
|
|
210
|
+
`package "${read.package}" — an Adventure carries copies ` +
|
|
211
|
+
`of compiled documents, so a bundle can only hold this ` +
|
|
212
|
+
`package's own`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (read.type === FOLDER_TYPE) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`bundle "${bundleName}" lists the folder "${address}". A ` +
|
|
218
|
+
`folder is not a member: it materialises in every pack ` +
|
|
219
|
+
`holding something filed in it (#257), so it belongs to no ` +
|
|
220
|
+
`one pack and there is no single copy to bundle. List the ` +
|
|
221
|
+
`documents instead`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (read.type === HOMEPAGE_TYPE || read.type === BUNDLE_TYPE) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`bundle "${bundleName}" lists "${address}", a ${read.type} — ` +
|
|
227
|
+
`which compiles into no document an Adventure can hold`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const note = index.byShortcode.get(`${read.type}/${read.shortcode}`);
|
|
232
|
+
if (!note) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`bundle "${bundleName}" lists "${address}", which no note in ` +
|
|
235
|
+
`this tree publishes`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The note's own document, which is the router's rule for `pack:` too.
|
|
240
|
+
// Its documentation journal is a second document with a second address,
|
|
241
|
+
// and is bundled only when the note names that one.
|
|
242
|
+
const docType = read.itemDoc ? "JournalEntry" : packForType(read.type).docType;
|
|
243
|
+
const id = read.itemDoc ? itemDocEntryId(note.id) : note.id;
|
|
244
|
+
|
|
245
|
+
return { docType, id, name: note.name ?? address };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* One bundle note → one Adventure holding copies of what it names.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} fm - The note's frontmatter.
|
|
252
|
+
* @param {string} markdown - The body, tables expanded and wikilinks
|
|
253
|
+
* resolved.
|
|
254
|
+
* @returns {object} The Adventure document.
|
|
255
|
+
*/
|
|
256
|
+
buildEntry(fm, markdown) {
|
|
257
|
+
const name = resolveName(fm);
|
|
258
|
+
const { value: authoredFolder, isAddress: folderIsAddress } = folderField(fm);
|
|
259
|
+
/** @type {Array<{docType: string, document: object}>} */
|
|
260
|
+
const contents = [];
|
|
261
|
+
|
|
262
|
+
for (const address of bundleContents(fm)) {
|
|
263
|
+
const member = this.resolveAddress(address, name);
|
|
264
|
+
const document = this.members.get(`${member.docType}/${member.id}`);
|
|
265
|
+
if (!document) {
|
|
266
|
+
// Another system's document, or none at all — and which it is
|
|
267
|
+
// depends on whether this pack has a system to have scoped it
|
|
268
|
+
// away. See `missingMemberVerdict`.
|
|
269
|
+
if (missingMemberVerdict(this.packSystem) === "omit") {
|
|
270
|
+
this.noteWarn(
|
|
271
|
+
`bundle "${name}" leaves out "${address}": it publishes ` +
|
|
272
|
+
`no ${this.packSystem} ${member.docType}, and pack ` +
|
|
273
|
+
`"${this.packName}" declares ` +
|
|
274
|
+
`\`system: ${this.packSystem}\``,
|
|
275
|
+
);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
throw new Error(
|
|
279
|
+
`bundle "${name}" lists "${address}", which resolves to ` +
|
|
280
|
+
`${member.docType} ${member.id} — and no compiled ` +
|
|
281
|
+
`${member.docType} pack holds it. The note publishes ` +
|
|
282
|
+
`nothing into a pack this bundle can read`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
contents.push({ docType: member.docType, document });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return buildAdventure({
|
|
289
|
+
id: fm.id,
|
|
290
|
+
name,
|
|
291
|
+
// Foundry shows this on the import card, so a bundle without one is
|
|
292
|
+
// a blank tile. `null` rather than a stand-in: there is no sensible
|
|
293
|
+
// default artwork for "a set of documents".
|
|
294
|
+
img: resolveImg(fm.img),
|
|
295
|
+
// A bundle is something you hand someone, so its prose belongs on
|
|
296
|
+
// the document itself — `Adventure.description` is an `HTMLField`
|
|
297
|
+
// Foundry renders on the import card. That is why a bundle earns no
|
|
298
|
+
// separate documentation journal the way an item does: the document
|
|
299
|
+
// it compiles into already has somewhere to put the prose.
|
|
300
|
+
description: markdown.trim() ? md.render(markdown) : "",
|
|
301
|
+
folder: this.folderResolver(authoredFolder, { isAddress: folderIsAddress }),
|
|
302
|
+
flags: fm.flags,
|
|
303
|
+
stats: this.stats,
|
|
304
|
+
contents,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
package/engine/code-fences.mjs
CHANGED
|
@@ -49,6 +49,109 @@
|
|
|
49
49
|
/** A fence line, capturing its indent, its marker, and its info string. */
|
|
50
50
|
export const FENCE_LINE = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^\r\n]*)$/;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A fence's info string, read as **org-babel header arguments**.
|
|
54
|
+
*
|
|
55
|
+
* A directive fence carries statements *about the directive* that are no part of
|
|
56
|
+
* the query it holds — whether an empty result is intended, what heading level a
|
|
57
|
+
* section takes, later a caption. Those started as an ad-hoc bare word
|
|
58
|
+
* (`allow-empty`) and a `key=value` (`section-level=3`), each matched by its own
|
|
59
|
+
* regex: a grammar only in the sense that two regexes are one, and with no room
|
|
60
|
+
* to add a third property without adding a third spelling.
|
|
61
|
+
*
|
|
62
|
+
* Org-mode settled this long ago. A babel source block writes them after the
|
|
63
|
+
* language as `:key value`, which is a real grammar with a specification, a
|
|
64
|
+
* parser, and an editor that already completes it.
|
|
65
|
+
*
|
|
66
|
+
* ```text
|
|
67
|
+
* ```sql :section-level 3 :allow-empty
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* **The language word stays first and stays plain.** `sql` is what GitHub,
|
|
71
|
+
* Prettier and every other markdown reader match on to syntax-highlight the
|
|
72
|
+
* block, so it leads and the header args follow; a reader that does not know
|
|
73
|
+
* them sees an ordinary SQL block.
|
|
74
|
+
*
|
|
75
|
+
* The grammar, matching org's:
|
|
76
|
+
*
|
|
77
|
+
* - a key is `:name` **starting a word** — a colon inside or ending a word is
|
|
78
|
+
* text, so `:caption Gear: the tables` is one argument;
|
|
79
|
+
* - a value runs to the next key or the end of the string, spaces included, and
|
|
80
|
+
* is trimmed;
|
|
81
|
+
* - a key with no value is `true`, which is what a statement like
|
|
82
|
+
* `:allow-empty` is;
|
|
83
|
+
* - a value may be `"quoted"` to hold a word that would otherwise read as a
|
|
84
|
+
* key — the one ambiguity org has too;
|
|
85
|
+
* - a repeated key takes its last value.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} info - The text after the fence marker.
|
|
88
|
+
* @returns {{language: string, args: Record<string, string|true>}} The language,
|
|
89
|
+
* lowercased, and the header arguments in written order.
|
|
90
|
+
*/
|
|
91
|
+
export function parseHeaderArgs(info) {
|
|
92
|
+
const text = String(info ?? "").trim();
|
|
93
|
+
if (!text) return { language: "", args: {} };
|
|
94
|
+
|
|
95
|
+
// The language is the first word; everything after it is header arguments.
|
|
96
|
+
const firstKey = text.search(/(?:^|\s):[A-Za-z][\w-]*/);
|
|
97
|
+
const head = (firstKey === -1 ? text : text.slice(0, firstKey)).trim();
|
|
98
|
+
const language = head.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
99
|
+
if (firstKey === -1) return { language, args: {} };
|
|
100
|
+
|
|
101
|
+
const args = {};
|
|
102
|
+
const rest = text.slice(firstKey);
|
|
103
|
+
// Split before each key: a colon that starts a word. A colon inside or at
|
|
104
|
+
// the end of a word is ordinary text, which is what keeps `Gear:` a value.
|
|
105
|
+
const quoted = quotedSpans(rest);
|
|
106
|
+
const re = /(?:^|\s):([A-Za-z][\w-]*)/g;
|
|
107
|
+
const keys = [];
|
|
108
|
+
let m;
|
|
109
|
+
while ((m = re.exec(rest)) !== null) {
|
|
110
|
+
// A key-like word inside a quoted value is part of the value — that is
|
|
111
|
+
// the whole reason quoting exists here.
|
|
112
|
+
if (quoted.some(([from, to]) => m.index >= from && m.index < to)) continue;
|
|
113
|
+
keys.push({ name: m[1], at: m.index, end: re.lastIndex });
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
116
|
+
const from = keys[i].end;
|
|
117
|
+
const to = i + 1 < keys.length ? keys[i + 1].at : rest.length;
|
|
118
|
+
const raw = rest.slice(from, to).trim();
|
|
119
|
+
args[keys[i].name] = raw === "" ? true : unquote(raw);
|
|
120
|
+
}
|
|
121
|
+
return { language, args };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The `"…"` and `'…'` spans in a header-argument string, as [start, end) offsets.
|
|
126
|
+
*
|
|
127
|
+
* Scanned once before the keys are, so a key-like word inside a value is never
|
|
128
|
+
* read as the next key.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} text - The header-argument text.
|
|
131
|
+
* @returns {Array<[number, number]>} The quoted spans.
|
|
132
|
+
*/
|
|
133
|
+
function quotedSpans(text) {
|
|
134
|
+
const spans = [];
|
|
135
|
+
const re = /"[^"]*"|'[^']*'/g;
|
|
136
|
+
let m;
|
|
137
|
+
while ((m = re.exec(text)) !== null) spans.push([m.index, m.index + m[0].length]);
|
|
138
|
+
return spans;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A header-argument value with its surrounding quotes removed.
|
|
143
|
+
*
|
|
144
|
+
* Quoting is how a value holds a word that would otherwise read as the next key
|
|
145
|
+
* — the same escape hatch org offers, and the only one needed.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} value - The raw value.
|
|
148
|
+
* @returns {string} The value, unquoted.
|
|
149
|
+
*/
|
|
150
|
+
function unquote(value) {
|
|
151
|
+
const m = /^"([^"]*)"$|^'([^']*)'$/.exec(value);
|
|
152
|
+
return m ? (m[1] ?? m[2]) : value;
|
|
153
|
+
}
|
|
154
|
+
|
|
52
155
|
/** A list item's opening line, capturing the indent and the marker itself. */
|
|
53
156
|
const LIST_MARKER = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])(?:[ \t]+|$)/;
|
|
54
157
|
|
|
@@ -0,0 +1,89 @@
|
|
|
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 corpus one compile runs over, and everything derived from it (#243).
|
|
16
|
+
*
|
|
17
|
+
* **A compile read every note twenty times.** Measured over `sohl`'s 1,685
|
|
18
|
+
* notes: 33,700 reads, exactly twenty per note. Four per pass — the
|
|
19
|
+
* content-wide link index, the table-search corpus, the `sql` directive scan,
|
|
20
|
+
* and the pass's own walk — across five passes that convert wikilinks. Every
|
|
21
|
+
* one of those four is a pure function of the same three things: the tree, the
|
|
22
|
+
* scope, and the pack router. None of the three varies between the passes of a
|
|
23
|
+
* single compile, because `generatePacksJson` resolves one router and hands it
|
|
24
|
+
* to all of them.
|
|
25
|
+
*
|
|
26
|
+
* So they are derived **once**, here, and every pass is handed the result. That
|
|
27
|
+
* is #243's claim stated at the point where it costs the most: not that
|
|
28
|
+
* re-deriving is wasteful, but that N passes each answering "which files are
|
|
29
|
+
* the corpus?" is N answers that can differ — which is what #241 was, and what
|
|
30
|
+
* the twenty reads were paying for.
|
|
31
|
+
*
|
|
32
|
+
* **This module exists apart from the compilers for an import reason.**
|
|
33
|
+
* Deriving the index reaches the pack router and the manifest emitter, and
|
|
34
|
+
* those reach the compilers — so `base-compiler.mjs` cannot import
|
|
35
|
+
* `content-index.mjs` without closing a cycle. `generate.mjs` can and does; a
|
|
36
|
+
* compiler built directly by a consumer falls back to importing this module
|
|
37
|
+
* from inside its `async prepare`, where a dynamic import is free.
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { indexRecordsFor } from "./content-index.mjs";
|
|
43
|
+
import { buildContentLinkIndex, collectContentDocs } from "./helpers.mjs";
|
|
44
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
45
|
+
import { prepareTreeSqlTables } from "./sql-tables.mjs";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derive the corpus a compile runs over, and the three indexes built over it.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts - Options.
|
|
51
|
+
* @param {string} opts.contentBase - Root of the content tree.
|
|
52
|
+
* @param {readonly string[]} opts.skipDirectories - The scope, stated by the
|
|
53
|
+
* caller as every corpus read requires (#243).
|
|
54
|
+
* @param {object} opts.router - The pack router this compile resolved. Shared
|
|
55
|
+
* by every pass, which is what makes one link index correct for all of them.
|
|
56
|
+
* @param {object} [opts.config] - The resolved configuration.
|
|
57
|
+
* @param {object[]} [opts.problems] - Collects the notes the index cannot
|
|
58
|
+
* record, so one of them does not abort the compile before it reports. One is
|
|
59
|
+
* created when none is passed, and returned on the corpus either way — a note
|
|
60
|
+
* the index refuses is a note the compile must still *report*, exactly as the
|
|
61
|
+
* compile loop reported it when the loop was the first to see it.
|
|
62
|
+
* @returns {Promise<{records: object[], linkIndex: object, contentDocs: object[],
|
|
63
|
+
* sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
|
|
64
|
+
* its indexes, and the notes it could not record.
|
|
65
|
+
*/
|
|
66
|
+
export async function buildCompileCorpus({
|
|
67
|
+
contentBase,
|
|
68
|
+
skipDirectories,
|
|
69
|
+
router,
|
|
70
|
+
config,
|
|
71
|
+
problems,
|
|
72
|
+
}) {
|
|
73
|
+
const resolved = config ?? loadPackConfig();
|
|
74
|
+
const collected = problems ?? [];
|
|
75
|
+
const records = indexRecordsFor({
|
|
76
|
+
contentBase,
|
|
77
|
+
config: resolved,
|
|
78
|
+
skipDirectories,
|
|
79
|
+
problems: collected,
|
|
80
|
+
});
|
|
81
|
+
const scope = { skipDirectories, config: resolved, records };
|
|
82
|
+
return {
|
|
83
|
+
records,
|
|
84
|
+
problems: collected,
|
|
85
|
+
linkIndex: buildContentLinkIndex(contentBase, router, scope),
|
|
86
|
+
contentDocs: collectContentDocs(contentBase, scope),
|
|
87
|
+
sqlTables: await prepareTreeSqlTables(contentBase, scope),
|
|
88
|
+
};
|
|
89
|
+
}
|