@heroiclands/package-build 0.6.1 → 3.0.1
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-content-build.md +965 -0
- package/CHANGELOG.md +68 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -35
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- package/types/sohl/note-schemas.d.mts +7 -0
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
* Is there any content to compile?
|
|
16
|
+
*
|
|
17
|
+
* The pack build's worst failure is not an error but a **success on nothing**: a
|
|
18
|
+
* content tree that is absent or empty compiles zero documents, reports no
|
|
19
|
+
* error, and ships compendiums that are simply empty. Nothing downstream
|
|
20
|
+
* notices — the LevelDB packs are written, the deploy mirrors them, and the
|
|
21
|
+
* defect surfaces as "my compendiums are blank" long after the build went green.
|
|
22
|
+
*
|
|
23
|
+
* `assets/content/` is this repository's own source (#1445), but an empty or
|
|
24
|
+
* partial tree is still possible — a bad checkout, or a sparse clone. This
|
|
25
|
+
* counts what is actually there so the build can refuse.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Count the Markdown notes below a content tree.
|
|
33
|
+
*
|
|
34
|
+
* Only notes count. The per-pack folder manifests are not content, so a tree
|
|
35
|
+
* holding nothing but manifests counts zero — which is correct, because it
|
|
36
|
+
* compiles zero documents. Dot directories are skipped so stale editor caches
|
|
37
|
+
* cannot make an empty tree look populated.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} root - Absolute path to the content tree.
|
|
40
|
+
* @returns {number} The number of `.md` notes, or 0 when the tree is absent.
|
|
41
|
+
*/
|
|
42
|
+
export function countContentNotes(root) {
|
|
43
|
+
if (!fs.existsSync(root)) return 0;
|
|
44
|
+
|
|
45
|
+
let count = 0;
|
|
46
|
+
/** @param {string} dir */
|
|
47
|
+
const walk = (dir) => {
|
|
48
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
49
|
+
if (entry.name.startsWith(".")) continue;
|
|
50
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name));
|
|
51
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) count += 1;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(root);
|
|
55
|
+
return count;
|
|
56
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
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
|
+
* Build diagnostics that name the file, line and column they are about.
|
|
16
|
+
*
|
|
17
|
+
* A build that reports a problem by the **note's name** — `Unresolved wikilink
|
|
18
|
+
* in "The Capital Nome"` — has told the author what is wrong and not where it
|
|
19
|
+
* is. A name is not an address: finding it means searching the tree for a note
|
|
20
|
+
* whose `name.full` matches and then searching that file for the link, and
|
|
21
|
+
* four identical warnings on one note are indistinguishable from one another
|
|
22
|
+
* (#17).
|
|
23
|
+
*
|
|
24
|
+
* So every diagnostic about a note is emitted in the form every C-family
|
|
25
|
+
* compiler, `tsc` and ESLint already use:
|
|
26
|
+
*
|
|
27
|
+
* ```text
|
|
28
|
+
* assets/content/Nomes/Capital.md:42:17: warning: unresolved wikilink [[Kenbet_Pat]] (unknown)
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* `file:line:column: severity: message`. Nothing here is invented, which is
|
|
32
|
+
* the point: an editor, a CI annotator or a `grep` already parses it, with no
|
|
33
|
+
* knowledge of this build and nothing extra for it to emit.
|
|
34
|
+
*
|
|
35
|
+
* **Two rules keep the form parseable.**
|
|
36
|
+
*
|
|
37
|
+
* - _The locator starts the line._ Diagnostics deliberately bypass `loglevel`,
|
|
38
|
+
* whose `[timestamp] [WARN]:` prefix occupies exactly the position a parser
|
|
39
|
+
* reads the path from — a greedy path pattern swallows the prefix and yields
|
|
40
|
+
* a filename no editor can open.
|
|
41
|
+
* - _A field is dropped, never guessed._ A position that cannot be established
|
|
42
|
+
* honestly is omitted (`file: warning: …`) rather than defaulted to `1:1`,
|
|
43
|
+
* which would send a reader to the frontmatter every time.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The `file:line:column` locator, with whatever is known.
|
|
50
|
+
*
|
|
51
|
+
* The path is relativized against the working directory — during a build that
|
|
52
|
+
* is the consuming repository's root, so the result is both shorter to read
|
|
53
|
+
* and what an editor resolves a relative diagnostic against. A path outside
|
|
54
|
+
* the tree stays absolute, since a `../../..` locator helps nobody.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} at
|
|
57
|
+
* @param {string} [at.file] - Absolute or relative path to the source file.
|
|
58
|
+
* @param {number} [at.line] - 1-based line.
|
|
59
|
+
* @param {number} [at.column] - 1-based column. Ignored without a line.
|
|
60
|
+
* @returns {string} The locator, or `""` when not even a file is known.
|
|
61
|
+
*/
|
|
62
|
+
export function formatLocator({ file, line, column } = {}) {
|
|
63
|
+
if (!file) return "";
|
|
64
|
+
let shown = file;
|
|
65
|
+
if (path.isAbsolute(file)) {
|
|
66
|
+
const rel = path.relative(process.cwd(), file);
|
|
67
|
+
// `..` means the file sits outside the working directory; an absolute
|
|
68
|
+
// path is the more useful of the two there.
|
|
69
|
+
if (rel && !rel.startsWith("..")) shown = rel;
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isFinite(line)) return shown;
|
|
72
|
+
if (!Number.isFinite(column)) return `${shown}:${line}`;
|
|
73
|
+
return `${shown}:${line}:${column}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One diagnostic, as a parseable line.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} d
|
|
80
|
+
* @param {string} [d.file] - Source file the diagnostic is about.
|
|
81
|
+
* @param {number} [d.line] - 1-based line.
|
|
82
|
+
* @param {number} [d.column] - 1-based column.
|
|
83
|
+
* @param {"warning"|"error"} d.severity - Which of the two levels this is.
|
|
84
|
+
* @param {string} d.message - What is wrong, in one sentence.
|
|
85
|
+
* @returns {string} `file:line:column: severity: message`, with any unknown
|
|
86
|
+
* leading field omitted.
|
|
87
|
+
*/
|
|
88
|
+
export function formatDiagnostic({ file, line, column, severity, message }) {
|
|
89
|
+
const locator = formatLocator({ file, line, column });
|
|
90
|
+
return `${locator ? `${locator}: ` : ""}${severity}: ${message}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Prints one diagnostic on the console, unprefixed.
|
|
95
|
+
*
|
|
96
|
+
* **Both severities go to stderr**, which is what keeps findings clear of the
|
|
97
|
+
* progress and summary prose a build writes to stdout. That is Node's doing,
|
|
98
|
+
* not a choice made here: `console.warn` is an alias for `console.error` and
|
|
99
|
+
* writes to `process.stderr`, so the two branches below differ only in which
|
|
100
|
+
* severity word the line carries, never in the stream.
|
|
101
|
+
*
|
|
102
|
+
* Saying otherwise has already cost something — an earlier version of this
|
|
103
|
+
* comment claimed warnings went to stdout, and a consumer wrote a whole
|
|
104
|
+
* local wrapper to obtain the stderr routing it already had. Anything relying
|
|
105
|
+
* on the separation should split on the `severity` field, not on the stream.
|
|
106
|
+
*
|
|
107
|
+
* This deliberately sidesteps `loglevel`, for the reason given in the module
|
|
108
|
+
* docs.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} d - As {@link formatDiagnostic}.
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
export function emitDiagnostic(d) {
|
|
114
|
+
const line = formatDiagnostic(d);
|
|
115
|
+
if (d.severity === "error") console.error(line);
|
|
116
|
+
else console.warn(line);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Where a character offset within a note's **body** falls in its **file**.
|
|
121
|
+
*
|
|
122
|
+
* Three corrections separate the two, and each is applied only where it is
|
|
123
|
+
* true:
|
|
124
|
+
*
|
|
125
|
+
* 1. _The frontmatter._ A body offset is not a file line until the lines
|
|
126
|
+
* before the body are added — `bodyLine`.
|
|
127
|
+
* 2. _The trimmed first line._ `parseMarkdownFile` trims the body, so its
|
|
128
|
+
* first line may have lost indentation the file still has. `bodyColumn`
|
|
129
|
+
* restores it, and only on that line.
|
|
130
|
+
* 3. _Generated text._ A body is scanned **after** its content tables expand,
|
|
131
|
+
* so an offset may fall in text no one authored. `lineMap` maps each
|
|
132
|
+
* scanned line back to the line it came from; a generated line reports the
|
|
133
|
+
* directive that produced it and **no column**, since there is no authored
|
|
134
|
+
* character to point at.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} body - The text the offset indexes into.
|
|
137
|
+
* @param {number} offset - 0-based character offset within `body`.
|
|
138
|
+
* @param {object} [opts]
|
|
139
|
+
* @param {number} [opts.bodyLine=1] - 1-based file line of the body's line 0.
|
|
140
|
+
* @param {number} [opts.bodyColumn=1] - 1-based file column of the body's
|
|
141
|
+
* first character.
|
|
142
|
+
* @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - Per
|
|
143
|
+
* scanned line, the 0-based body line it came from. From
|
|
144
|
+
* {@link expandContentTables}.
|
|
145
|
+
* @returns {{line: number, column: number|undefined, generated: boolean}}
|
|
146
|
+
*/
|
|
147
|
+
export function positionInBody(
|
|
148
|
+
body,
|
|
149
|
+
offset,
|
|
150
|
+
{ bodyLine = 1, bodyColumn = 1, lineMap } = {},
|
|
151
|
+
) {
|
|
152
|
+
const upTo = String(body ?? "").slice(0, Math.max(0, offset));
|
|
153
|
+
const nl = upTo.lastIndexOf("\n");
|
|
154
|
+
const scannedLine = upTo.length === 0 ? 0 : upTo.split("\n").length - 1;
|
|
155
|
+
const column = upTo.length - nl; // 1-based: offset - (nl + 1) + 1
|
|
156
|
+
|
|
157
|
+
const mapped = lineMap?.[scannedLine];
|
|
158
|
+
const sourceLine = mapped ? mapped.line : scannedLine;
|
|
159
|
+
const generated = mapped ? mapped.generated : false;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
line: bodyLine + sourceLine,
|
|
163
|
+
// A generated line has no authored column, and the first line's
|
|
164
|
+
// column is the only one the trim can have moved.
|
|
165
|
+
column:
|
|
166
|
+
generated ? undefined
|
|
167
|
+
: sourceLine === 0 ? bodyColumn + column - 1
|
|
168
|
+
: column,
|
|
169
|
+
generated,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Where a **frontmatter key** is declared in a note's file.
|
|
175
|
+
*
|
|
176
|
+
* {@link positionInBody} answers the same question for the body, and the two
|
|
177
|
+
* are separate because the body is what the compilers scan while frontmatter is
|
|
178
|
+
* what the linters read — a key sits *before* the body, so a body offset can
|
|
179
|
+
* never reach it.
|
|
180
|
+
*
|
|
181
|
+
* The search is deliberately scoped to the frontmatter block rather than run
|
|
182
|
+
* over the whole file. A bare search for the key would match the first place
|
|
183
|
+
* the word appears anywhere, which for a key like `name` or `type` is routinely
|
|
184
|
+
* a line of prose — sending the reader to a position that is not the problem,
|
|
185
|
+
* which is the one thing the located form exists to prevent.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} raw - The file's full contents, frontmatter included.
|
|
188
|
+
* @param {string} key - The top-level frontmatter key.
|
|
189
|
+
* @param {string} [value] - When given, prefer the occurrence whose line also
|
|
190
|
+
* carries this text. A list-valued key (`aliases`) is reported at the entry
|
|
191
|
+
* that is wrong, not at the key that introduces it.
|
|
192
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields, empty
|
|
193
|
+
* when the key cannot be located — dropped rather than guessed, as
|
|
194
|
+
* {@link formatDiagnostic} requires.
|
|
195
|
+
*/
|
|
196
|
+
export function positionInFrontmatter(raw, key, value = undefined) {
|
|
197
|
+
if (typeof raw !== "string" || !key) return {};
|
|
198
|
+
const fence = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
199
|
+
if (!fence) return {};
|
|
200
|
+
const block = fence[1];
|
|
201
|
+
const lines = block.split("\n");
|
|
202
|
+
|
|
203
|
+
// A value locates the exact entry; the key alone locates its declaration.
|
|
204
|
+
const wanted = value == null ? undefined : String(value);
|
|
205
|
+
let keyLine = -1;
|
|
206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
207
|
+
if (wanted != null && lines[i].includes(wanted)) {
|
|
208
|
+
keyLine = i;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
keyLine === -1 &&
|
|
213
|
+
new RegExp(`^\\s*${escape(key)}\\s*:`).test(lines[i])
|
|
214
|
+
) {
|
|
215
|
+
keyLine = i;
|
|
216
|
+
if (wanted == null) break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (keyLine === -1) return {};
|
|
220
|
+
|
|
221
|
+
const needle = wanted != null ? wanted : key;
|
|
222
|
+
const column = lines[keyLine].indexOf(needle);
|
|
223
|
+
return {
|
|
224
|
+
// +2: the file's line 1 is the opening `---`, so the block's line 0 is
|
|
225
|
+
// the file's line 2.
|
|
226
|
+
line: keyLine + 2,
|
|
227
|
+
...(column === -1 ? {} : { column: column + 1 }),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Escape a literal for use inside a `RegExp`. */
|
|
232
|
+
function escape(literal) {
|
|
233
|
+
return String(literal).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Where a literal sits in a text, so a finding about it can be opened.
|
|
238
|
+
*
|
|
239
|
+
* {@link positionInBody} maps an offset within a parsed note body, and
|
|
240
|
+
* {@link positionInFrontmatter} finds a key in the fence. This is the plainer
|
|
241
|
+
* case: a finding about a string the reader can see in a file that is neither —
|
|
242
|
+
* a manifest, a lockfile, a config.
|
|
243
|
+
*
|
|
244
|
+
* `@heroiclands/package-build` carries the same arithmetic for the files *it*
|
|
245
|
+
* reads. That is a duplicate worth naming: unlike the diagnostic *format* or a
|
|
246
|
+
* validation *rule*, "which line and column is this substring on" has exactly
|
|
247
|
+
* one correct answer and cannot drift into disagreement. The tidier arrangement
|
|
248
|
+
* is for that package to re-export this one — the dependency runs that way — and
|
|
249
|
+
* it should, next time either is touched.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} text - The file's contents.
|
|
252
|
+
* @param {string} needle - The literal to locate.
|
|
253
|
+
* @param {number} [occurrence] - Which occurrence, 1-based. Repeats of one
|
|
254
|
+
* literal are otherwise indistinguishable.
|
|
255
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields, empty
|
|
256
|
+
* when the literal is not there — dropped rather than guessed.
|
|
257
|
+
*/
|
|
258
|
+
export function positionOfLiteral(text, needle, occurrence = 1) {
|
|
259
|
+
if (typeof text !== "string" || !needle) return {};
|
|
260
|
+
let at = -1;
|
|
261
|
+
for (let n = 0; n < occurrence; n++) {
|
|
262
|
+
at = text.indexOf(needle, at + 1);
|
|
263
|
+
if (at === -1) return {};
|
|
264
|
+
}
|
|
265
|
+
const before = text.slice(0, at);
|
|
266
|
+
return {
|
|
267
|
+
line: before.split("\n").length,
|
|
268
|
+
column: at - before.lastIndexOf("\n"),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
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 authoring reference, generated from the declared item fields.
|
|
16
|
+
*
|
|
17
|
+
* **Generated because a hand-written one would be wrong within a release.** The
|
|
18
|
+
* overwhelming majority of a content repository's compiled documents are items
|
|
19
|
+
* — 1,230 of SoHL's 1,362 when this was written — and a table spanning a dozen
|
|
20
|
+
* types, each with a shape, a default and a requiredness, is exactly the kind of
|
|
21
|
+
* prose that drifts silently from the code it describes. The declarations
|
|
22
|
+
* (`fields` on each `itemBuilders` entry) already *are* that table, so the page
|
|
23
|
+
* is rendered from them rather than transcribed (#22).
|
|
24
|
+
*
|
|
25
|
+
* **It ships here, not in the consumer.** Any repository can define an item
|
|
26
|
+
* type of its own, so "what frontmatter does this type accept?" is a question
|
|
27
|
+
* every consuming repository has. Rendering from the resolved configuration
|
|
28
|
+
* means each one documents *its own* registry with the same command, rather
|
|
29
|
+
* than SoHL owning a generator the others cannot run.
|
|
30
|
+
*
|
|
31
|
+
* Package-agnostic: it knows the declaration vocabulary, and nothing about any
|
|
32
|
+
* particular type. The types themselves come from configuration.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { authoredFields } from "./field-spec.mjs";
|
|
38
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render a value the way an author would write it in YAML frontmatter.
|
|
42
|
+
*
|
|
43
|
+
* @param {any} value - The default a field declares.
|
|
44
|
+
* @returns {string} An inline-code cell, or an em dash when there is no
|
|
45
|
+
* default to show.
|
|
46
|
+
*/
|
|
47
|
+
function renderDefault(value) {
|
|
48
|
+
if (value === undefined) return "—";
|
|
49
|
+
if (value === null) return "`null`";
|
|
50
|
+
if (typeof value === "string")
|
|
51
|
+
return value === "" ? '`""`' : `\`${value}\``;
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
return value.length === 0 ? "`[]`" : `\`${JSON.stringify(value)}\``;
|
|
54
|
+
if (typeof value === "object") {
|
|
55
|
+
const json = JSON.stringify(value);
|
|
56
|
+
return json === "{}" ? "`{}`" : `\`${json}\``;
|
|
57
|
+
}
|
|
58
|
+
return `\`${String(value)}\``;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Escape the pipe that would otherwise end a markdown table cell. */
|
|
62
|
+
function cell(text) {
|
|
63
|
+
return String(text).replace(/\|/g, "\\|");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A markdown table with every column padded to its widest cell.
|
|
68
|
+
*
|
|
69
|
+
* **Padded so the generated page is stable under Prettier.** A consumer commits
|
|
70
|
+
* this page and formats its repository; Prettier aligns markdown table columns,
|
|
71
|
+
* so an unpadded table is rewritten the moment the formatter runs — and the
|
|
72
|
+
* `--check` guard then reports the page stale on every clean checkout, with the
|
|
73
|
+
* formatter and the generator each undoing the other.
|
|
74
|
+
*
|
|
75
|
+
* Plain `.length` rather than a display-width measure, because that is what
|
|
76
|
+
* Prettier's alignment comes to for this content: every cell is Latin text,
|
|
77
|
+
* backticks and the odd em dash, each of which counts one. A cell holding a
|
|
78
|
+
* wide character would need the measure Prettier uses; there are none, and
|
|
79
|
+
* `tests/field-reference.test.ts` fails if the rendered page ever stops
|
|
80
|
+
* agreeing with Prettier.
|
|
81
|
+
*
|
|
82
|
+
* @param {string[][]} rows - The header row, then the body.
|
|
83
|
+
* @returns {string[]} Markdown lines.
|
|
84
|
+
*/
|
|
85
|
+
function padTable(rows) {
|
|
86
|
+
const widths = rows[0].map((_, column) =>
|
|
87
|
+
Math.max(...rows.map((row) => row[column].length)),
|
|
88
|
+
);
|
|
89
|
+
const line = (cells) =>
|
|
90
|
+
`| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
|
|
91
|
+
const [header, ...body] = rows;
|
|
92
|
+
return [
|
|
93
|
+
line(header),
|
|
94
|
+
`| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`,
|
|
95
|
+
...body.map(line),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The field table for one type.
|
|
101
|
+
*
|
|
102
|
+
* @param {readonly object[]} fields - The type's declaration.
|
|
103
|
+
* @returns {string[]} Markdown lines.
|
|
104
|
+
*/
|
|
105
|
+
function fieldTable(fields) {
|
|
106
|
+
const authored = authoredFields(fields);
|
|
107
|
+
if (!authored.length) {
|
|
108
|
+
return ["This type reads no `sohl:` fields of its own."];
|
|
109
|
+
}
|
|
110
|
+
const rows = [
|
|
111
|
+
["Field", "Shape", "Required", "Default", "Description"],
|
|
112
|
+
...authored.map((field) => [
|
|
113
|
+
`\`${field.name}\``,
|
|
114
|
+
cell(field.shape ?? "as authored"),
|
|
115
|
+
field.required ? "**yes**" : "no",
|
|
116
|
+
field.required ? "—" : renderDefault(field.default),
|
|
117
|
+
cell(field.describe ?? ""),
|
|
118
|
+
]),
|
|
119
|
+
];
|
|
120
|
+
return padTable(rows);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A minimal note for one type: the frontmatter envelope every note carries,
|
|
125
|
+
* plus exactly the `sohl:` fields the type requires.
|
|
126
|
+
*
|
|
127
|
+
* Minimal is the point — an example carrying every optional field would teach
|
|
128
|
+
* that they are expected, and would itself need maintaining. What is shown is
|
|
129
|
+
* the smallest note that compiles.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} type - The item type.
|
|
132
|
+
* @param {readonly object[]} fields - Its declaration.
|
|
133
|
+
* @returns {string[]} Markdown lines, a fenced YAML block.
|
|
134
|
+
*/
|
|
135
|
+
function workedExample(type, fields) {
|
|
136
|
+
const required = authoredFields(fields).filter((field) => field.required);
|
|
137
|
+
const lines = [
|
|
138
|
+
// `markdown`, not `yaml`: the block is a whole note — frontmatter *and*
|
|
139
|
+
// the prose beneath it — so labelling it YAML was wrong about the
|
|
140
|
+
// content. It also made the page unstable, because Prettier formats a
|
|
141
|
+
// fenced block in the language it declares, and reformatting this one as
|
|
142
|
+
// YAML dropped the blank line after the frontmatter.
|
|
143
|
+
"```markdown",
|
|
144
|
+
"---",
|
|
145
|
+
"name:",
|
|
146
|
+
` full: An Example ${type}`,
|
|
147
|
+
`type: ${type}`,
|
|
148
|
+
"shortcode: xmpl",
|
|
149
|
+
"package: <your content package>",
|
|
150
|
+
"id: <16-character id>",
|
|
151
|
+
"sohl:",
|
|
152
|
+
" archetype: null",
|
|
153
|
+
];
|
|
154
|
+
for (const field of required) {
|
|
155
|
+
lines.push(` ${field.name}: <${field.shape ?? "value"}>`);
|
|
156
|
+
}
|
|
157
|
+
lines.push(
|
|
158
|
+
"---",
|
|
159
|
+
"",
|
|
160
|
+
"The prose here compiles into the item's documentation.",
|
|
161
|
+
"```",
|
|
162
|
+
);
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Render the per-type item frontmatter reference.
|
|
168
|
+
*
|
|
169
|
+
* @param {object} [options] - Rendering options.
|
|
170
|
+
* @param {string} [options.title] - The page's H1.
|
|
171
|
+
* @param {string[]} [options.preamble] - Lines placed after the H1, before the
|
|
172
|
+
* first type. Written by the consumer, since only it knows what its page
|
|
173
|
+
* should link to.
|
|
174
|
+
* @param {string} [options.generatedBy] - What a reader should re-run to
|
|
175
|
+
* regenerate the page, named in the do-not-edit banner.
|
|
176
|
+
* @param {object} [options.config] - Resolved configuration. Defaults to the
|
|
177
|
+
* consumer's own.
|
|
178
|
+
* @returns {string} The complete markdown page.
|
|
179
|
+
*/
|
|
180
|
+
export function renderItemFieldReference({
|
|
181
|
+
title = "Item Note Frontmatter",
|
|
182
|
+
preamble = [],
|
|
183
|
+
generatedBy = "the content-build field reference generator",
|
|
184
|
+
config = loadPackConfig(),
|
|
185
|
+
} = {}) {
|
|
186
|
+
const declared = config.itemFields ?? {};
|
|
187
|
+
const types = [...config.itemTypes].sort();
|
|
188
|
+
const documented = types.filter((type) => declared[type]?.length);
|
|
189
|
+
const undocumented = types.filter((type) => !declared[type]?.length);
|
|
190
|
+
|
|
191
|
+
const lines = [
|
|
192
|
+
`# ${title}`,
|
|
193
|
+
"",
|
|
194
|
+
`<!-- AUTO-GENERATED FILE — do not edit by hand.`,
|
|
195
|
+
` Generated by ${generatedBy}.`,
|
|
196
|
+
` Every field comes from the \`fields\` declaration on that type's`,
|
|
197
|
+
` \`itemBuilders\` entry, which is also what builds the document. -->`,
|
|
198
|
+
"",
|
|
199
|
+
...preamble,
|
|
200
|
+
];
|
|
201
|
+
if (preamble.length) lines.push("");
|
|
202
|
+
|
|
203
|
+
lines.push(
|
|
204
|
+
`${documented.length} of the ${types.length} item types this ` +
|
|
205
|
+
`repository compiles declare their frontmatter below. Every field ` +
|
|
206
|
+
`is written under a note's \`sohl:\` block; a dotted name such as ` +
|
|
207
|
+
`\`impact.die\` is a nested key. A field a note does not carry ` +
|
|
208
|
+
`takes the default shown, and a **required** field has none — ` +
|
|
209
|
+
`omitting it fails the build rather than guessing.`,
|
|
210
|
+
"",
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
for (const type of documented) {
|
|
214
|
+
lines.push(
|
|
215
|
+
`## ${type}`,
|
|
216
|
+
"",
|
|
217
|
+
...fieldTable(declared[type]),
|
|
218
|
+
"",
|
|
219
|
+
...workedExample(type, declared[type]),
|
|
220
|
+
"",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (undocumented.length) {
|
|
225
|
+
lines.push(
|
|
226
|
+
"## Types that declare no fields",
|
|
227
|
+
"",
|
|
228
|
+
"These compile, but their `itemBuilders` entry declares no " +
|
|
229
|
+
"`fields`, so nothing here can say what they accept:",
|
|
230
|
+
"",
|
|
231
|
+
...undocumented.map((type) => `- \`${type}\``),
|
|
232
|
+
"",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Trailing blank lines trimmed, so a caller adding its own newline gets
|
|
237
|
+
// exactly one. Sections push a separating "" after themselves, which leaves
|
|
238
|
+
// one at the end; Prettier strips it, and a page that disagrees with the
|
|
239
|
+
// formatter by one character is rewritten on the consumer's next format run
|
|
240
|
+
// and then reported stale by `--check` forever after.
|
|
241
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
242
|
+
}
|