@heroiclands/package-build 3.4.0 → 5.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 +182 -0
- package/CONTENT.md +100 -22
- package/MIGRATING.md +120 -0
- package/bin/content-build.mjs +6 -2
- package/content-config.mjs +109 -6
- package/engine/base-compiler.mjs +33 -36
- package/engine/content-links.mjs +15 -13
- package/engine/content-package.mjs +3 -2
- package/engine/field-reference.mjs +2 -2
- package/engine/frontmatter-lint.mjs +26 -0
- package/engine/helpers.mjs +10 -12
- package/engine/homepage.mjs +150 -0
- package/engine/index.mjs +10 -1
- package/engine/journals.mjs +2 -3
- package/engine/macros.mjs +2 -3
- package/engine/manifest-emit.mjs +17 -12
- package/engine/note-package.mjs +75 -68
- package/engine/note-schemas.mjs +44 -0
- package/engine/pack-router.mjs +3 -3
- package/engine/retired-fields.mjs +123 -0
- package/engine/scenes.mjs +7 -11
- package/engine/site-build.mjs +151 -22
- package/engine/site-index.mjs +5 -5
- package/package.json +1 -1
- package/sohl/actors.mjs +2 -3
- package/sohl/items.mjs +2 -3
- package/sohl/note-schemas.mjs +8 -0
- package/types/content-config.d.mts +105 -4
- package/types/engine/base-compiler.d.mts +12 -17
- package/types/engine/content-package.d.mts +3 -2
- package/types/engine/helpers.d.mts +5 -8
- package/types/engine/homepage.d.mts +118 -0
- package/types/engine/index.d.mts +3 -0
- package/types/engine/manifest-emit.d.mts +7 -8
- package/types/engine/note-package.d.mts +29 -34
- package/types/engine/note-schemas.d.mts +6 -0
- package/types/engine/pack-router.d.mts +3 -3
- package/types/engine/retired-fields.d.mts +54 -0
- package/types/engine/site-build.d.mts +52 -4
- package/types/sohl/note-schemas.d.mts +6 -0
package/content-config.mjs
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* assets:
|
|
34
34
|
* - { from: assets/icons, to: assets/icons }
|
|
35
35
|
* publish:
|
|
36
|
-
* site:
|
|
36
|
+
* site: content
|
|
37
37
|
* manifests: { publish: true, consume: true }
|
|
38
38
|
* ```
|
|
39
39
|
*
|
|
@@ -153,6 +153,62 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
153
153
|
landing: "readme",
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* How much of a package reaches the web.
|
|
158
|
+
*
|
|
159
|
+
* Every HeroicLands package publishes something: a top-level, human-authored
|
|
160
|
+
* homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
|
|
161
|
+
* module is, which system it needs and how to install it (#50). So there is no
|
|
162
|
+
* value here meaning *no web presence at all* — homepage-only is the **floor**,
|
|
163
|
+
* and the default.
|
|
164
|
+
*
|
|
165
|
+
* - `homepage` — the authored homepage, and **no other page**. The content tree
|
|
166
|
+
* is not walked for pages, `site.sections` / `site.trees` / `site.landing`
|
|
167
|
+
* emit nothing, and link-manifest entries carry no web `path`.
|
|
168
|
+
* - `content` — the homepage *plus* every page the content tree publishes: the
|
|
169
|
+
* knowledgebase, the extra trees, the section landings.
|
|
170
|
+
*
|
|
171
|
+
* **Homepage-only is a first-class mode, not an accommodation.**
|
|
172
|
+
* `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
|
|
173
|
+
* Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's
|
|
174
|
+
* terms) must each publish a homepage and nothing beneath it — two packages
|
|
175
|
+
* under two different fan-content licences. The boundary is **published
|
|
176
|
+
* content**: journal text, artwork, item descriptions, compiled notes. A
|
|
177
|
+
* human-authored page announcing the module discloses none of it. Because the
|
|
178
|
+
* failure mode is silent — a `site:` block added later ships licensed content
|
|
179
|
+
* with nobody noticing — the mode fences the content surfaces off rather than
|
|
180
|
+
* trusting a configuration to stay empty.
|
|
181
|
+
*
|
|
182
|
+
* This was a boolean until 5.0.0, and `false` read as "no web presence", which
|
|
183
|
+
* no longer describes any package. Both spellings are refused rather than
|
|
184
|
+
* mapped: a value silently reinterpreted reads to its author as though it still
|
|
185
|
+
* means what it said.
|
|
186
|
+
*
|
|
187
|
+
* @typedef {"homepage" | "content"} SiteMode
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The publishing modes {@link PublishSwitches.site} may name, floor first.
|
|
192
|
+
*
|
|
193
|
+
* @satisfies {readonly SiteMode[]}
|
|
194
|
+
*/
|
|
195
|
+
export const SITE_MODES = /** @type {const} */ (["homepage", "content"]);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether this package publishes the pages its content tree compiles to.
|
|
199
|
+
*
|
|
200
|
+
* The one question every reader of the mode actually asks — the site build, to
|
|
201
|
+
* decide whether to walk the tree at all, and the link-manifest emitter, to
|
|
202
|
+
* decide whether an entry carries a web `path`. Written once here so the two
|
|
203
|
+
* cannot come to disagree about what a mode means.
|
|
204
|
+
*
|
|
205
|
+
* @param {{publish: {site: SiteMode}}} config - A resolved configuration.
|
|
206
|
+
* @returns {boolean} Whether content pages are published.
|
|
207
|
+
*/
|
|
208
|
+
export function publishesContentPages(config) {
|
|
209
|
+
return config.publish.site === "content";
|
|
210
|
+
}
|
|
211
|
+
|
|
156
212
|
/**
|
|
157
213
|
* @typedef {"systems" | "modules"} PackageKind
|
|
158
214
|
*/
|
|
@@ -300,7 +356,8 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
300
356
|
|
|
301
357
|
/**
|
|
302
358
|
* @typedef {object} PublishSwitches
|
|
303
|
-
* @property {
|
|
359
|
+
* @property {SiteMode} site How much of this package reaches the web.
|
|
360
|
+
* See {@link SITE_MODES}.
|
|
304
361
|
* @property {ManifestSwitches} manifests
|
|
305
362
|
*/
|
|
306
363
|
|
|
@@ -387,8 +444,15 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
387
444
|
|
|
388
445
|
/**
|
|
389
446
|
* @typedef {object} PublishSwitchesInput
|
|
390
|
-
* @property {
|
|
447
|
+
* @property {SiteMode} [site]
|
|
391
448
|
* @property {ManifestSwitchesInput} [manifests]
|
|
449
|
+
* @property {AddressSchemeInput} [address]
|
|
450
|
+
*/
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @typedef {object} AddressSchemeInput
|
|
454
|
+
* @property {string} [prefix] Where the content tree mounts inside the package.
|
|
455
|
+
* @property {string} [landing] Which note addresses a whole section.
|
|
392
456
|
*/
|
|
393
457
|
|
|
394
458
|
/**
|
|
@@ -454,7 +518,9 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
|
454
518
|
* which has none to invent.
|
|
455
519
|
* @property {Relationships} [relationships] What this package declares about
|
|
456
520
|
* others, in Foundry's own shape.
|
|
457
|
-
* @property {PublishSwitchesInput} [publish] Publishing switches.
|
|
521
|
+
* @property {PublishSwitchesInput} [publish] Publishing switches. The manifest
|
|
522
|
+
* switches default to off; `site`
|
|
523
|
+
* defaults to `homepage`, the floor.
|
|
458
524
|
*/
|
|
459
525
|
|
|
460
526
|
/**
|
|
@@ -1272,6 +1338,43 @@ function normalizeItemBuilders(value) {
|
|
|
1272
1338
|
};
|
|
1273
1339
|
}
|
|
1274
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* The publishing mode, refusing the boolean this setting used to be.
|
|
1343
|
+
*
|
|
1344
|
+
* A boolean is refused rather than mapped onto the nearest mode, because the
|
|
1345
|
+
* reading `false` invited — *this package has no web presence* — is exactly the
|
|
1346
|
+
* belief the change exists to correct, and a value quietly reinterpreted reads
|
|
1347
|
+
* to its author as though it still means what it said. So the message names the
|
|
1348
|
+
* mode to write instead of the value to fix.
|
|
1349
|
+
*
|
|
1350
|
+
* @param {unknown} value - The authored `publish.site`.
|
|
1351
|
+
* @returns {SiteMode} The mode.
|
|
1352
|
+
*/
|
|
1353
|
+
function normalizeSiteMode(value) {
|
|
1354
|
+
if (value === undefined) return "homepage";
|
|
1355
|
+
if (typeof value === "boolean") {
|
|
1356
|
+
fail(
|
|
1357
|
+
"publish.site",
|
|
1358
|
+
`is no longer a boolean — write \`site: ${value ? "content" : "homepage"}\`. ` +
|
|
1359
|
+
`Every package publishes an authored homepage at ` +
|
|
1360
|
+
`/<contentPackage>/, so no value means "no web presence": ` +
|
|
1361
|
+
`\`homepage\` publishes that page and nothing else, and ` +
|
|
1362
|
+
`\`content\` publishes it plus every page the content tree ` +
|
|
1363
|
+
`compiles to`,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
if (
|
|
1367
|
+
typeof value !== "string" ||
|
|
1368
|
+
!(/** @type {readonly string[]} */ (SITE_MODES).includes(value))
|
|
1369
|
+
) {
|
|
1370
|
+
fail(
|
|
1371
|
+
"publish.site",
|
|
1372
|
+
`must be one of ${SITE_MODES.join(", ")} (got ${JSON.stringify(value)})`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
return /** @type {SiteMode} */ (value);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1275
1378
|
/**
|
|
1276
1379
|
* @param {unknown} value
|
|
1277
1380
|
* @returns {Readonly<PublishSwitches>}
|
|
@@ -1279,7 +1382,7 @@ function normalizeItemBuilders(value) {
|
|
|
1279
1382
|
function normalizePublish(value) {
|
|
1280
1383
|
if (value === undefined) {
|
|
1281
1384
|
return Object.freeze({
|
|
1282
|
-
site:
|
|
1385
|
+
site: "homepage",
|
|
1283
1386
|
manifests: Object.freeze({ publish: false, consume: false }),
|
|
1284
1387
|
address: Object.freeze({ ...DEFAULT_ADDRESS_SCHEME }),
|
|
1285
1388
|
});
|
|
@@ -1332,7 +1435,7 @@ function normalizePublish(value) {
|
|
|
1332
1435
|
}
|
|
1333
1436
|
|
|
1334
1437
|
return Object.freeze({
|
|
1335
|
-
site:
|
|
1438
|
+
site: normalizeSiteMode(publish.site),
|
|
1336
1439
|
address: Object.freeze({ prefix, landing }),
|
|
1337
1440
|
manifests: Object.freeze({
|
|
1338
1441
|
publish: optionalBoolean(
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* `BasePackCompiler` — the one compile loop every pack pass runs.
|
|
16
16
|
*
|
|
17
|
-
* Walking the content tree, rejecting what this build does not own,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Walking the content tree, rejecting what this build does not own, expanding
|
|
18
|
+
* generated tables, converting wikilinks, writing the JSON and counting what
|
|
19
|
+
* failed are the same in every pass. They were written out
|
|
20
20
|
* once per pass — three times when this was filed, five by the time it landed —
|
|
21
21
|
* so a fix to any of them had to be made everywhere, and the passes drifted
|
|
22
22
|
* apart in exactly the places nobody was comparing (#1509).
|
|
@@ -73,26 +73,25 @@ import {
|
|
|
73
73
|
expandNoteTables,
|
|
74
74
|
} from "./helpers.mjs";
|
|
75
75
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
76
|
+
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
77
|
+
import { assertNoDraftField } from "./retired-fields.mjs";
|
|
78
78
|
import { assertTypeNotRetired, packForType } from "./ids.mjs";
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* The tallies one pass accumulates while walking the tree.
|
|
82
82
|
*
|
|
83
83
|
* `declined` and `skippedOther` are deliberately separate numbers. A declined
|
|
84
|
-
* note is one this build **refused** — it
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
84
|
+
* note is one this build **refused** — it declares a retired frontmatter field
|
|
85
|
+
* — and it is an error; a skipped one legitimately belongs to another pass, and
|
|
86
|
+
* there are thousands of those. Folding the first into the second is what let a
|
|
87
|
+
* whole tree be filtered out in silence (#56).
|
|
88
88
|
*
|
|
89
89
|
* @typedef {object} PassStats
|
|
90
90
|
* @property {number} compiled - Notes that became a document.
|
|
91
|
-
* @property {number} skippedDraft - Notes marked `draft: true`.
|
|
92
91
|
* @property {number} skippedNoId - Notes with no `id`, where that is tolerated.
|
|
93
92
|
* @property {number} skippedOther - Notes this pass does not claim.
|
|
94
|
-
* @property {number} declined - Notes refused because they declare
|
|
95
|
-
*
|
|
93
|
+
* @property {number} declined - Notes refused because they declare a retired
|
|
94
|
+
* frontmatter field. Counted as errors, never as skips.
|
|
96
95
|
*/
|
|
97
96
|
|
|
98
97
|
/**
|
|
@@ -336,9 +335,6 @@ export class BasePackCompiler {
|
|
|
336
335
|
const { markdown: tabulated, lineMap } = expandNoteTables(body, {
|
|
337
336
|
docs: this.contentDocs,
|
|
338
337
|
name,
|
|
339
|
-
// The repository's package, not the note's: every note in the tree
|
|
340
|
-
// is this package's note, whether or not it says so (#56).
|
|
341
|
-
pkg: contentPackage(),
|
|
342
338
|
fm,
|
|
343
339
|
bodyLine,
|
|
344
340
|
});
|
|
@@ -512,15 +508,13 @@ export class BasePackCompiler {
|
|
|
512
508
|
if (stats.skippedNoId) {
|
|
513
509
|
log.info(`Skipped ${stats.skippedNoId} note(s) missing id`);
|
|
514
510
|
}
|
|
515
|
-
if (stats.skippedDraft) {
|
|
516
|
-
log.info(`Skipped ${stats.skippedDraft} draft(s)`);
|
|
517
|
-
}
|
|
518
511
|
if (stats.declined) {
|
|
519
512
|
// Its own line, at error level: these are not skips, and burying
|
|
520
513
|
// them in the skipped tally is the defect (#56). Each one has
|
|
521
514
|
// already been named individually as a diagnostic.
|
|
522
515
|
log.error(
|
|
523
|
-
`Declined ${stats.declined} note(s) declaring
|
|
516
|
+
`Declined ${stats.declined} note(s) declaring a retired ` +
|
|
517
|
+
`frontmatter field`,
|
|
524
518
|
);
|
|
525
519
|
}
|
|
526
520
|
this.reportDetail(stats);
|
|
@@ -535,7 +529,6 @@ export class BasePackCompiler {
|
|
|
535
529
|
/** @type {PassStats} */
|
|
536
530
|
const stats = {
|
|
537
531
|
compiled: 0,
|
|
538
|
-
skippedDraft: 0,
|
|
539
532
|
skippedNoId: 0,
|
|
540
533
|
skippedOther: 0,
|
|
541
534
|
declined: 0,
|
|
@@ -560,18 +553,27 @@ export class BasePackCompiler {
|
|
|
560
553
|
stats.skippedOther++;
|
|
561
554
|
continue;
|
|
562
555
|
}
|
|
563
|
-
// The
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
556
|
+
// The retired frontmatter fields, refused before `selects` so a
|
|
557
|
+
// note is answered whichever pass would have claimed it — and
|
|
558
|
+
// whatever the declared value says.
|
|
559
|
+
//
|
|
560
|
+
// - `package:` (#56): a note's package is the repository's
|
|
561
|
+
// configured one, so declaring it restates a constant.
|
|
562
|
+
// - `draft:` (#69): it excluded the note from the packs, the
|
|
563
|
+
// manifest and the site, and no checker reported the links that
|
|
564
|
+
// left dangling.
|
|
565
|
+
//
|
|
566
|
+
// Both are reported and counted — never skipped, which is how a
|
|
567
|
+
// tree naming a package nothing answers to used to compile zero
|
|
568
|
+
// notes and exit 0. The file comes from the diagnostic locator, so
|
|
569
|
+
// neither message may repeat it.
|
|
569
570
|
try {
|
|
570
|
-
|
|
571
|
+
assertNoDeclaredPackage(fm, { absPath });
|
|
572
|
+
assertNoDraftField(fm, { absPath });
|
|
571
573
|
} catch (err) {
|
|
572
574
|
stats.declined++;
|
|
573
575
|
this.errorCount++;
|
|
574
|
-
this.noteError(err.message);
|
|
576
|
+
this.noteError(err.message, err.position);
|
|
575
577
|
continue;
|
|
576
578
|
}
|
|
577
579
|
// Checked before `selects`, and therefore for every note this
|
|
@@ -584,11 +586,6 @@ export class BasePackCompiler {
|
|
|
584
586
|
stats.skippedOther++;
|
|
585
587
|
continue;
|
|
586
588
|
}
|
|
587
|
-
if (fm.draft === true) {
|
|
588
|
-
stats.skippedDraft++;
|
|
589
|
-
log.debug(`Skipping draft: ${absPath}`);
|
|
590
|
-
continue;
|
|
591
|
-
}
|
|
592
589
|
if (!fm.id) {
|
|
593
590
|
if (this.constructor.requiresId) {
|
|
594
591
|
throw new Error(`${Label} missing id: ${absPath}`);
|
|
@@ -597,9 +594,9 @@ export class BasePackCompiler {
|
|
|
597
594
|
this.noteWarn(`${label} note has no id, skipping`);
|
|
598
595
|
continue;
|
|
599
596
|
}
|
|
600
|
-
// Which pack of this type takes it. Applied after the
|
|
601
|
-
//
|
|
602
|
-
//
|
|
597
|
+
// Which pack of this type takes it. Applied after the id check —
|
|
598
|
+
// a note with no id is nobody's document, so its routing is
|
|
599
|
+
// nobody's business — and before `skipNote`, so a note this pack
|
|
603
600
|
// does not own never reaches this pass's own rejection rules.
|
|
604
601
|
try {
|
|
605
602
|
if (!this.routesHere(fm)) {
|
package/engine/content-links.mjs
CHANGED
|
@@ -52,7 +52,8 @@ import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
|
52
52
|
import { expandContentTables } from "./content-tables.mjs";
|
|
53
53
|
import { walkMarkdownTree } from "./helpers.mjs";
|
|
54
54
|
import { hasDocEntry } from "./item-docs.mjs";
|
|
55
|
-
import {
|
|
55
|
+
import { contentPackage } from "./content-package.mjs";
|
|
56
|
+
import { searchableFrontmatter } from "./note-package.mjs";
|
|
56
57
|
import {
|
|
57
58
|
canonicalKey,
|
|
58
59
|
loadForeignManifests,
|
|
@@ -130,12 +131,13 @@ export function buildLinkIndex(
|
|
|
130
131
|
const byAlias = new Map();
|
|
131
132
|
const aliasCollide = new Set();
|
|
132
133
|
|
|
134
|
+
// The one package every note in this tree belongs to. Taken from the
|
|
135
|
+
// configuration, never from a note: `package:` is retired, so there is no
|
|
136
|
+
// second source an address could disagree with (#56).
|
|
137
|
+
const pkg = contentPackage();
|
|
138
|
+
|
|
133
139
|
for (const note of notes) {
|
|
134
140
|
const { fm, type } = note;
|
|
135
|
-
// Derived, never read out of frontmatter: `package:` is optional, and a
|
|
136
|
-
// note that declares nothing addresses exactly as one that declares the
|
|
137
|
-
// configured package (#56).
|
|
138
|
-
const pkg = notePackage(fm);
|
|
139
141
|
if (typeof fm.shortcode === "string" && fm.shortcode) {
|
|
140
142
|
byKey.set(`${type}/${fm.shortcode}`.toLowerCase(), note);
|
|
141
143
|
// The canonical, fully qualified address alongside the short one,
|
|
@@ -170,7 +172,7 @@ export function buildLinkIndex(
|
|
|
170
172
|
// A foreign package may use a type this tree has never seen, so its types
|
|
171
173
|
// join `types` — otherwise `readQualifier` reads the link as prose and it
|
|
172
174
|
// is never checked at all.
|
|
173
|
-
const localPackages = new Set(
|
|
175
|
+
const localPackages = new Set([pkg]);
|
|
174
176
|
const foreign =
|
|
175
177
|
manifestDir ?
|
|
176
178
|
loadForeignManifests(manifestDir, localPackages)
|
|
@@ -178,15 +180,15 @@ export function buildLinkIndex(
|
|
|
178
180
|
for (const v of foreign.index.values()) if (v.type) types.add(v.type);
|
|
179
181
|
|
|
180
182
|
const packages = new Set([
|
|
181
|
-
...
|
|
183
|
+
...(byKey.size ? [pkg] : []),
|
|
182
184
|
...foreign.packages,
|
|
183
185
|
]);
|
|
184
186
|
|
|
185
187
|
/** The searchable universe a `dataview` table draws its rows from. */
|
|
186
188
|
const tableDocs = notes.map((n) => ({
|
|
187
|
-
// Package present
|
|
188
|
-
// {@link searchableFrontmatter} (#56).
|
|
189
|
-
fm: searchableFrontmatter(n.fm),
|
|
189
|
+
// Package present for a `WHERE … package = "…"` clause, synthesised
|
|
190
|
+
// rather than authored — see {@link searchableFrontmatter} (#56).
|
|
191
|
+
fm: searchableFrontmatter(n.fm, pkg),
|
|
190
192
|
path: n.rel,
|
|
191
193
|
tld: n.rel.split("/")[0],
|
|
192
194
|
folder: path.dirname(n.rel).split("/").pop(),
|
|
@@ -205,9 +207,9 @@ export function buildLinkIndex(
|
|
|
205
207
|
let body = note.body;
|
|
206
208
|
if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*dataview\b/im.test(body)) {
|
|
207
209
|
body = expandContentTables(body, {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
// Unfiltered: every note in the tree is this package's, so
|
|
211
|
+
// there is no other package's note to exclude (#56).
|
|
212
|
+
docs: tableDocs,
|
|
211
213
|
linkable: (d) => Boolean(d.fm.shortcode),
|
|
212
214
|
source: note.file,
|
|
213
215
|
}).markdown;
|
|
@@ -38,8 +38,9 @@ import { loadPackConfig } from "./pack-config.mjs";
|
|
|
38
38
|
* `package:` frontmatter and the compilers kept the ones that matched. Every
|
|
39
39
|
* content tree is single-package — each is single-sourced in the repository that
|
|
40
40
|
* ships it — so the field restated this constant once per note while a value
|
|
41
|
-
* that matched nothing filtered the whole tree out in silence.
|
|
42
|
-
*
|
|
41
|
+
* that matched nothing filtered the whole tree out in silence. That field is
|
|
42
|
+
* retired and declaring it now fails the build; this value stays, here, where
|
|
43
|
+
* it is declared once.
|
|
43
44
|
*
|
|
44
45
|
* Stable across compilation targets. If this content were ever compiled for a
|
|
45
46
|
* second game system, it would still be published as `sohl` — only the Foundry
|
|
@@ -147,8 +147,8 @@ function workedExample(type, fields) {
|
|
|
147
147
|
`type: ${type}`,
|
|
148
148
|
"shortcode: xmpl",
|
|
149
149
|
// No `package:`. A note's package is the repository's configured
|
|
150
|
-
// `contentPackage`,
|
|
151
|
-
//
|
|
150
|
+
// `contentPackage`, and declaring the field is a build error (#56) —
|
|
151
|
+
// this example is the smallest note that compiles.
|
|
152
152
|
"id: <16-character id>",
|
|
153
153
|
"sohl:",
|
|
154
154
|
" archetype: null",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
import { authoredFields } from "./field-spec.mjs";
|
|
52
52
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
53
53
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
54
|
+
import { draftRetiredMessage } from "./retired-fields.mjs";
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* `sohl:` keys every type accepts, whatever its schema says.
|
|
@@ -211,6 +212,31 @@ export function lintNote(note, { schemas, index }) {
|
|
|
211
212
|
const at = (key, literal) =>
|
|
212
213
|
positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
213
214
|
|
|
215
|
+
// The retired top-level fields, checked before the type: a note may carry
|
|
216
|
+
// one whatever its type is, and each finding stands on its own. Reported
|
|
217
|
+
// here as well as refused at compile because this is where an author meets
|
|
218
|
+
// every finding in the tree at once, rather than one note at a time (#56).
|
|
219
|
+
if (Object.hasOwn(fm, "package")) {
|
|
220
|
+
findings.push({
|
|
221
|
+
file: note.file,
|
|
222
|
+
...at("package"),
|
|
223
|
+
severity: "error",
|
|
224
|
+
message:
|
|
225
|
+
"`package:` is a retired frontmatter field — delete it. A " +
|
|
226
|
+
"note's package is this repository's configured " +
|
|
227
|
+
"`contentPackage`, in package-build.config.yaml, and every " +
|
|
228
|
+
"note in the tree belongs to it",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (Object.hasOwn(fm, "draft")) {
|
|
232
|
+
findings.push({
|
|
233
|
+
file: note.file,
|
|
234
|
+
...at("draft"),
|
|
235
|
+
severity: "error",
|
|
236
|
+
message: draftRetiredMessage(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
214
240
|
const replacement = RETIRED_TYPES[type];
|
|
215
241
|
if (replacement) {
|
|
216
242
|
findings.push({
|
package/engine/helpers.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import log from "loglevel";
|
|
|
36
36
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
37
37
|
import { packRouter } from "./pack-router.mjs";
|
|
38
38
|
import { contentPackage, foundryPackageId } from "./content-package.mjs";
|
|
39
|
-
import {
|
|
39
|
+
import { searchableFrontmatter } from "./note-package.mjs";
|
|
40
40
|
import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
|
|
41
41
|
import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
|
|
42
42
|
import { expandContentTables } from "./content-tables.mjs";
|
|
@@ -602,8 +602,9 @@ export function collectContentDocs(contentBase) {
|
|
|
602
602
|
if (!fm) continue;
|
|
603
603
|
const segments = path.relative(contentBase, absPath).split(path.sep);
|
|
604
604
|
docs.push({
|
|
605
|
-
// With its package
|
|
606
|
-
//
|
|
605
|
+
// With its package supplied for a `WHERE … package = "…"` query —
|
|
606
|
+
// synthesised from the configuration, since no note declares it
|
|
607
|
+
// (#56).
|
|
607
608
|
fm: searchableFrontmatter(fm),
|
|
608
609
|
// POSIX-separated and relative to the content root — what a
|
|
609
610
|
// `path:` search term globs, on every platform.
|
|
@@ -636,17 +637,15 @@ const packLinkable = (doc) =>
|
|
|
636
637
|
* Expand the fenced `dataview` tables in one note's markdown, before wikilinks
|
|
637
638
|
* are resolved — so a generated cell may itself be a wikilink.
|
|
638
639
|
*
|
|
639
|
-
* A table searches
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* unswept — or every swept — note from the table (#56).
|
|
640
|
+
* A table searches the whole tree, which is one package's notes and nothing
|
|
641
|
+
* else — so there is no longer a package to scope on. It used to filter, back
|
|
642
|
+
* when a tree could hold several packages' notes and `package:` said which was
|
|
643
|
+
* which; that field is retired and the filter with it (#56).
|
|
644
644
|
*
|
|
645
645
|
* @param {string} body - The note's markdown body.
|
|
646
646
|
* @param {object} ctx
|
|
647
647
|
* @param {Array<object>} ctx.docs - From {@link collectContentDocs}.
|
|
648
648
|
* @param {string} ctx.name - The note, for the error message.
|
|
649
|
-
* @param {string} [ctx.pkg] - The source note's package.
|
|
650
649
|
* @param {object} [ctx.fm] - The source note's frontmatter, which is what a
|
|
651
650
|
* query's `this` reads. Its entry in `docs` supplies the path as well.
|
|
652
651
|
* @param {number} [ctx.bodyLine] - 1-based file line of the body's first line,
|
|
@@ -659,8 +658,7 @@ const packLinkable = (doc) =>
|
|
|
659
658
|
* compile rather than shipping a table-shaped hole. The error carries
|
|
660
659
|
* `position`, the directive's own line.
|
|
661
660
|
*/
|
|
662
|
-
export function expandNoteTables(body, { docs, name,
|
|
663
|
-
const scoped = pkg ? docs.filter((d) => notePackage(d.fm) === pkg) : docs;
|
|
661
|
+
export function expandNoteTables(body, { docs, name, fm, bodyLine }) {
|
|
664
662
|
const self =
|
|
665
663
|
fm ?
|
|
666
664
|
(docs.find((d) => d.fm?.id && d.fm.id === fm.id) ?? {
|
|
@@ -668,7 +666,7 @@ export function expandNoteTables(body, { docs, name, pkg, fm, bodyLine }) {
|
|
|
668
666
|
})
|
|
669
667
|
: undefined;
|
|
670
668
|
const { markdown, errors, lineMap } = expandContentTables(body ?? "", {
|
|
671
|
-
docs
|
|
669
|
+
docs,
|
|
672
670
|
linkable: packLinkable,
|
|
673
671
|
source: name,
|
|
674
672
|
self,
|
|
@@ -0,0 +1,150 @@
|
|
|
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 package homepage — a note that compiles to a **page** rather than to a
|
|
16
|
+
* compendium document (#51).
|
|
17
|
+
*
|
|
18
|
+
* Every HeroicLands package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
|
|
19
|
+
* and what a reader finds there is one markdown file in the content tree,
|
|
20
|
+
* written by a person: what the module is, which system it needs, how to install
|
|
21
|
+
* it, where its source lives. Nothing about it is derived.
|
|
22
|
+
*
|
|
23
|
+
* **Authored, not assembled.** An earlier sketch generated the page in tiers —
|
|
24
|
+
* identity and licence from the manifest, install URL from the release address,
|
|
25
|
+
* "requires" links from `relationships`, a card per configured section. It would
|
|
26
|
+
* have worked and needed almost no authoring, and it produces a page nobody
|
|
27
|
+
* chose the contents of. The things that matter most on these pages cannot be
|
|
28
|
+
* derived: that Kethira requires buying the book from Keléstia, what Thalorna's
|
|
29
|
+
* setting *is*, which of twenty sections a reader should start with. So the only
|
|
30
|
+
* thing defaulted here is the title, from `packageBuild.manifest.title`, so that
|
|
31
|
+
* the package's name is not written twice.
|
|
32
|
+
*
|
|
33
|
+
* **Dispatched by `type`, not by filename.** A fixed `homepage.md` the walker
|
|
34
|
+
* special-cased would be the anomaly: notes are routed by frontmatter, not by
|
|
35
|
+
* location, and `NOTE_SCHEMAS` already routes `doc`, `macro`, `being` and the
|
|
36
|
+
* map types. `homepage` is one more entry whose compile step emits a page. It is
|
|
37
|
+
* deliberately not `README.md`: `landing: readme` already means "a `README.md`
|
|
38
|
+
* is its section's landing page", and `sohl-thalorna/assets/content/README.md`
|
|
39
|
+
* is a developer explainer about the source tree — adopting that name would make
|
|
40
|
+
* Thalorna's public front page its build documentation.
|
|
41
|
+
*
|
|
42
|
+
* **Engine, not `sohl/`.** The `engine/` ÷ `sohl/` line separates *note-format*
|
|
43
|
+
* knowledge from *game-system* knowledge, and a homepage is note format: it
|
|
44
|
+
* carries no `system` block, mirrors no item builder, and would mean the same
|
|
45
|
+
* thing for a game system that is not SoHL. Reachability is the symptom that
|
|
46
|
+
* makes it obvious — `HarnMaster-3-FoundryVTT` declares no `itemBuilders`, so a
|
|
47
|
+
* type living in the SoHL registry would be unavailable to HM3 and to every HM3
|
|
48
|
+
* module, which is most of the packages that need a homepage and nothing else.
|
|
49
|
+
*
|
|
50
|
+
* **Its address is the package's, not the note's.** A homepage publishes at
|
|
51
|
+
* `/<contentPackage>/` because that is where the package is, so `name.full`,
|
|
52
|
+
* `shortcode` and `id` decide nothing on it (#53 refuses them outright; this
|
|
53
|
+
* module simply never reads them). It compiles into no document, so it carries
|
|
54
|
+
* no compendium UUID and appears in no pack and in no link-manifest entry.
|
|
55
|
+
*
|
|
56
|
+
* @module
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The note type that compiles to the package homepage.
|
|
61
|
+
*
|
|
62
|
+
* @type {string}
|
|
63
|
+
*/
|
|
64
|
+
export const HOMEPAGE_TYPE = "homepage";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What a homepage note may write under `sohl:` — nothing.
|
|
68
|
+
*
|
|
69
|
+
* Empty on purpose, and declared rather than omitted: a type with no vocabulary
|
|
70
|
+
* and a type that is unknown are different findings, and only the second is an
|
|
71
|
+
* authoring error. The whole envelope is the two top-level keys `type` and an
|
|
72
|
+
* optional `title`; there is no game-system data on a page that compiles to no
|
|
73
|
+
* document.
|
|
74
|
+
*
|
|
75
|
+
* @type {readonly import("./field-spec.mjs").FieldSpec[]}
|
|
76
|
+
*/
|
|
77
|
+
export const HOMEPAGE_FIELDS = Object.freeze([]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where a homepage is written, relative to the package's site root.
|
|
81
|
+
*
|
|
82
|
+
* Hugo's section landing, because the page *is* the package's landing: the
|
|
83
|
+
* package root is a section and this is its index.
|
|
84
|
+
*
|
|
85
|
+
* @type {string}
|
|
86
|
+
*/
|
|
87
|
+
export const HOMEPAGE_DESTINATION = "_index.md";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether a note's frontmatter declares the homepage type.
|
|
91
|
+
*
|
|
92
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
93
|
+
* @returns {boolean} Whether it is a homepage note.
|
|
94
|
+
*/
|
|
95
|
+
export function isHomepage(fm) {
|
|
96
|
+
return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The title a homepage publishes under.
|
|
101
|
+
*
|
|
102
|
+
* The one defaulted value on the page, and it defaults to the package's own
|
|
103
|
+
* `packageBuild.manifest.title` — the name Foundry already shows for the
|
|
104
|
+
* package — so a homepage that adds nothing to it need not restate it. An
|
|
105
|
+
* authored `title` wins, because a front page is allowed to greet a reader
|
|
106
|
+
* differently from a package browser.
|
|
107
|
+
*
|
|
108
|
+
* Falls back to `contentPackage` last, so a package that has no manifest of its
|
|
109
|
+
* own still yields a titled page rather than a blank heading.
|
|
110
|
+
*
|
|
111
|
+
* @param {object|null|undefined} fm - The note's frontmatter.
|
|
112
|
+
* @param {object} config - The resolved configuration.
|
|
113
|
+
* @returns {string} The title.
|
|
114
|
+
*/
|
|
115
|
+
export function homepageTitle(fm, config) {
|
|
116
|
+
const authored = fm?.title;
|
|
117
|
+
if (typeof authored === "string" && authored.trim()) return authored;
|
|
118
|
+
const manifest = /** @type {Record<string, unknown>|undefined} */ (
|
|
119
|
+
config?.packageBuild?.manifest
|
|
120
|
+
);
|
|
121
|
+
const title = manifest?.title;
|
|
122
|
+
return typeof title === "string" && title.trim() ?
|
|
123
|
+
title
|
|
124
|
+
: config.contentPackage;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The frontmatter a homepage publishes with.
|
|
129
|
+
*
|
|
130
|
+
* The note's own, plus the two derived values every emitted page carries: the
|
|
131
|
+
* resolved `title`, and the package the build derived — no note declares one
|
|
132
|
+
* (`package:` is retired, #56) and the theme's breadcrumb partial reads
|
|
133
|
+
* `.Params.package`.
|
|
134
|
+
*
|
|
135
|
+
* An authored `aliases` is dropped for the same reason it is on every other
|
|
136
|
+
* page: Obsidian reads it as names a reader might call the note, Hugo reads it
|
|
137
|
+
* as URL redirects, and passing it through would publish a redirect stub at
|
|
138
|
+
* each one.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} fm - The note's frontmatter.
|
|
141
|
+
* @param {object} options - Options.
|
|
142
|
+
* @param {string} options.contentPackage - The package this build publishes.
|
|
143
|
+
* @param {string} options.title - The resolved title.
|
|
144
|
+
* @returns {object} The frontmatter to write.
|
|
145
|
+
*/
|
|
146
|
+
export function homepageFrontmatter(fm, { contentPackage, title }) {
|
|
147
|
+
const data = { ...fm, package: contentPackage, title };
|
|
148
|
+
delete data.aliases;
|
|
149
|
+
return data;
|
|
150
|
+
}
|