@heroiclands/package-build 21.1.0 → 21.2.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 +204 -0
- package/CONTENT.md +107 -3
- package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
- package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
- package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
- package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
- package/assets/fonts/libertinus/OFL.txt +94 -0
- package/assets/fonts/libertinus/provenance.yaml +11 -0
- package/assets/images/banners/afflictionbnr.webp +0 -0
- package/assets/images/banners/armorclothingbnr.webp +0 -0
- package/assets/images/banners/attributebnr.webp +0 -0
- package/assets/images/banners/containerbnr.webp +0 -0
- package/assets/images/banners/devdocsbnr.webp +0 -0
- package/assets/images/banners/miscgearbnr.webp +0 -0
- package/assets/images/banners/mysticalabilitybnr.webp +0 -0
- package/assets/images/banners/projectilebnr.webp +0 -0
- package/assets/images/banners/rulesbnr.webp +0 -0
- package/assets/images/banners/skillbnr.webp +0 -0
- package/assets/images/banners/traumabnr.webp +0 -0
- package/assets/images/banners/userguidebnr.webp +0 -0
- package/assets/images/banners/weapongearbnr.webp +0 -0
- package/assets/images/provenance.yaml +4 -0
- package/bin/content-build.mjs +19 -5
- package/ci/emit-asset-index.mjs +32 -0
- package/content-config.mjs +30 -1
- package/docs/api.md +100 -26
- package/docs/configuration.md +16 -7
- package/docs/content-format.md +512 -109
- package/engine/actor-compiler.mjs +58 -9
- package/engine/art-fields.mjs +292 -0
- package/engine/asset-index.mjs +397 -0
- package/engine/asset-types.mjs +192 -0
- package/engine/base-compiler.mjs +69 -1
- package/engine/bundles.mjs +5 -5
- package/engine/compile-corpus.mjs +4 -0
- package/engine/content-address.mjs +14 -2
- package/engine/content-charset.mjs +5 -1
- package/engine/content-embeds.mjs +314 -0
- package/engine/content-html.mjs +5 -1
- package/engine/content-icons.mjs +5 -1
- package/engine/content-images.mjs +26 -5
- package/engine/content-index.mjs +68 -33
- package/engine/content-links.mjs +164 -24
- package/engine/frontmatter-lint.mjs +36 -23
- package/engine/helpers.mjs +83 -31
- package/engine/index-records.mjs +63 -4
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +50 -3
- package/engine/infobox.mjs +12 -8
- package/engine/item-compiler.mjs +10 -10
- package/engine/journals.mjs +3 -0
- package/engine/macros.mjs +16 -13
- package/engine/map-notes.mjs +29 -22
- package/engine/metadata-index.mjs +44 -36
- package/engine/note-vocabulary.mjs +71 -29
- package/engine/pack-router.mjs +14 -0
- package/engine/packagebuild-index.mjs +124 -0
- package/engine/packages.mjs +83 -0
- package/engine/pathnames.mjs +74 -0
- package/engine/pdf-build.mjs +133 -8
- package/engine/pdf-render.mjs +9 -2
- package/engine/scenes.mjs +12 -9
- package/engine/site-build.mjs +43 -16
- package/engine/site-index.mjs +10 -1
- package/engine/subtype-registry.mjs +21 -0
- package/engine/web-wikilinks.mjs +46 -12
- package/engine/wikilink-syntax.mjs +20 -2
- package/engine/wikilinks.mjs +15 -2
- package/hm3/actors.mjs +11 -38
- package/package.json +5 -2
- package/packagebuild-metadata.jsonl +13 -0
- package/sohl/actors.mjs +12 -86
- package/sohl/note-schemas.mjs +4 -16
- package/types/content-config.d.mts +8 -0
- package/types/engine/actor-compiler.d.mts +29 -0
- package/types/engine/art-fields.d.mts +192 -0
- package/types/engine/asset-index.d.mts +92 -0
- package/types/engine/asset-types.d.mts +110 -0
- package/types/engine/base-compiler.d.mts +42 -1
- package/types/engine/compile-corpus.d.mts +4 -1
- package/types/engine/content-charset.d.mts +4 -0
- package/types/engine/content-embeds.d.mts +218 -0
- package/types/engine/content-html.d.mts +4 -0
- package/types/engine/content-icons.d.mts +4 -0
- package/types/engine/content-images.d.mts +15 -2
- package/types/engine/content-index.d.mts +27 -15
- package/types/engine/frontmatter-lint.d.mts +11 -14
- package/types/engine/helpers.d.mts +7 -2
- package/types/engine/index-records.d.mts +44 -3
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -1
- package/types/engine/infobox.d.mts +6 -4
- package/types/engine/macros.d.mts +9 -5
- package/types/engine/map-notes.d.mts +10 -6
- package/types/engine/metadata-index.d.mts +2 -17
- package/types/engine/note-vocabulary.d.mts +30 -0
- package/types/engine/packagebuild-index.d.mts +56 -0
- package/types/engine/packages.d.mts +62 -0
- package/types/engine/pathnames.d.mts +41 -81
- package/types/engine/pdf-build.d.mts +58 -1
- package/types/engine/pdf-render.d.mts +3 -1
- package/types/engine/scenes.d.mts +2 -1
- package/types/engine/site-build.d.mts +5 -1
- package/types/engine/site-index.d.mts +4 -1
- package/types/engine/subtype-registry.d.mts +20 -0
- package/types/engine/web-wikilinks.d.mts +14 -4
- package/types/engine/wikilink-syntax.d.mts +13 -1
- package/types/engine/wikilinks.d.mts +7 -1
- package/types/sohl/actors.d.mts +0 -1
package/engine/content-index.mjs
CHANGED
|
@@ -92,6 +92,7 @@ import path from "node:path";
|
|
|
92
92
|
import unidecode from "unidecode";
|
|
93
93
|
|
|
94
94
|
import { metadataFileName } from "./metadata-index.mjs";
|
|
95
|
+
import { collectAssetRecords } from "./asset-index.mjs";
|
|
95
96
|
import { addressSlug, canonicalKey } from "./content-address.mjs";
|
|
96
97
|
// One reader for a note's anchors, shared with the link checker and with the
|
|
97
98
|
// builds that emit a link. Re-exported because this is where callers
|
|
@@ -122,9 +123,25 @@ import { loadPackConfig } from "./pack-config.mjs";
|
|
|
122
123
|
// The record accessors, which live apart so that a module the compilers load
|
|
123
124
|
// can read a record without importing this one and closing a cycle.
|
|
124
125
|
// Re-exported because this is where callers have always addressed them.
|
|
125
|
-
import {
|
|
126
|
+
import {
|
|
127
|
+
authoredFrontmatter,
|
|
128
|
+
DERIVED_KEYS,
|
|
129
|
+
isAssetRecord,
|
|
130
|
+
isNoteRecord,
|
|
131
|
+
noteFile,
|
|
132
|
+
recordPath,
|
|
133
|
+
sortKeysDeep,
|
|
134
|
+
} from "./index-records.mjs";
|
|
126
135
|
|
|
127
|
-
export {
|
|
136
|
+
export {
|
|
137
|
+
authoredFrontmatter,
|
|
138
|
+
DERIVED_KEYS,
|
|
139
|
+
isAssetRecord,
|
|
140
|
+
isNoteRecord,
|
|
141
|
+
noteFile,
|
|
142
|
+
recordPath,
|
|
143
|
+
sortKeysDeep,
|
|
144
|
+
};
|
|
128
145
|
|
|
129
146
|
/**
|
|
130
147
|
* The address a wikilink writes to reach a note, or `null` when it has none.
|
|
@@ -176,27 +193,6 @@ export function noteAddress(frontmatter, contentPackage) {
|
|
|
176
193
|
};
|
|
177
194
|
}
|
|
178
195
|
|
|
179
|
-
/**
|
|
180
|
-
* Recursively sort an object's keys, so serialization is order-independent.
|
|
181
|
-
*
|
|
182
|
-
* Arrays keep their order — it is authored — but every object inside one is
|
|
183
|
-
* sorted too. Anything that is not a plain object is returned as it is.
|
|
184
|
-
*
|
|
185
|
-
* @param {unknown} value - The value to normalize.
|
|
186
|
-
* @returns {unknown} The value with every plain object's keys in sorted order.
|
|
187
|
-
*/
|
|
188
|
-
export function sortKeysDeep(value) {
|
|
189
|
-
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
190
|
-
if (value === null || typeof value !== "object") return value;
|
|
191
|
-
// A Date or any other exotic object would lose itself in a rebuild from
|
|
192
|
-
// entries, and YAML frontmatter can produce one.
|
|
193
|
-
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
|
|
194
|
-
/** @type {Record<string, unknown>} */
|
|
195
|
-
const out = {};
|
|
196
|
-
for (const key of Object.keys(value).sort()) out[key] = sortKeysDeep(value[key]);
|
|
197
|
-
return out;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
196
|
/**
|
|
201
197
|
* A note's display name reduced to printable 7-bit ASCII.
|
|
202
198
|
*
|
|
@@ -537,12 +533,22 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
|
|
|
537
533
|
* An item note yields two records — the item, and the documentation journal
|
|
538
534
|
* that is a document in its own right.
|
|
539
535
|
*
|
|
536
|
+
* **The asset roots are walked in the same pass.** A package's addressable files
|
|
537
|
+
* sit beside `content/` rather than inside it, and they publish into the same
|
|
538
|
+
* index under the same address grammar — so there is no second walk, no second
|
|
539
|
+
* artifact, and no notion of an "art module" anywhere in the toolchain. A
|
|
540
|
+
* package whose tree holds only assets is one by consequence.
|
|
541
|
+
*
|
|
540
542
|
* @param {string} contentBase - The content tree to walk.
|
|
541
543
|
* @param {object} options - Options.
|
|
542
544
|
* @param {string} options.contentPackage - The package the tree compiles as.
|
|
543
545
|
* @param {readonly string[]} options.skipDirectories - The walk's scope, stated
|
|
544
546
|
* by the caller. An absent one is the caller's omission, and
|
|
545
547
|
* {@link module:engine/helpers.walkMarkdownTree} throws on it.
|
|
548
|
+
* @param {string} [options.assetsBase] - The package's asset directory, holding
|
|
549
|
+
* the three asset roots. Omitted, no asset is indexed — which is what a caller
|
|
550
|
+
* walking a bare content fixture wants, and what an asset-free package gets
|
|
551
|
+
* anyway.
|
|
546
552
|
* @param {object} [options.manifest] - The package manifest, which the Foundry
|
|
547
553
|
* entries are derived against.
|
|
548
554
|
* @param {object[]} [options.problems] - Supplied by a **reader**: a note that
|
|
@@ -554,7 +560,7 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
|
|
|
554
560
|
*/
|
|
555
561
|
export function collectContentIndex(
|
|
556
562
|
contentBase,
|
|
557
|
-
{ contentPackage, skipDirectories, manifest, problems },
|
|
563
|
+
{ contentPackage, skipDirectories, assetsBase, manifest, problems },
|
|
558
564
|
) {
|
|
559
565
|
const records = [];
|
|
560
566
|
// Passed through rather than defaulted away: an absent scope is the
|
|
@@ -621,16 +627,31 @@ export function collectContentIndex(
|
|
|
621
627
|
}
|
|
622
628
|
}
|
|
623
629
|
|
|
624
|
-
|
|
630
|
+
if (assetsBase) {
|
|
631
|
+
// Sorted at every depth like a note's record, and for the same reason:
|
|
632
|
+
// the declaration order of the `asset` fields is a fact about the
|
|
633
|
+
// emitter, not about the content, and the artifact is meant to be
|
|
634
|
+
// byte-identical across two runs over an unchanged tree.
|
|
635
|
+
for (const record of collectAssetRecords(assetsBase, { contentPackage, problems })) {
|
|
636
|
+
records.push(/** @type {Record<string, any>} */ (sortKeysDeep(record)));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Source path, then the canonical address, then the note id. The walk
|
|
625
641
|
// yields in directory-read order, which is not a fact about the content,
|
|
626
642
|
// and a rebuild that reordered lines would make every regeneration look
|
|
627
643
|
// like a change. The address comes before the id because an item note's two
|
|
628
644
|
// records share a file and carry two different ids — ordering on the id
|
|
629
645
|
// first would sort the documentation against the item it documents by a
|
|
630
646
|
// pair of hashes, which is no order at all.
|
|
647
|
+
//
|
|
648
|
+
// The path is read through {@link recordPath} because the two record shapes
|
|
649
|
+
// state it differently — a note names the `.md` it was parsed from, an asset
|
|
650
|
+
// the file it *is* — and both are paths within the package, so one order
|
|
651
|
+
// covers them.
|
|
631
652
|
records.sort(
|
|
632
653
|
(a, b) =>
|
|
633
|
-
|
|
654
|
+
recordPath(a).localeCompare(recordPath(b), "en") ||
|
|
634
655
|
String(a.address?.canonical ?? "").localeCompare(
|
|
635
656
|
String(b.address?.canonical ?? ""),
|
|
636
657
|
"en",
|
|
@@ -664,6 +685,10 @@ export function serializeContentIndex(records) {
|
|
|
664
685
|
*
|
|
665
686
|
* @param {object} [opts]
|
|
666
687
|
* @param {string} [opts.contentBase] - The tree, defaulting to the configured one.
|
|
688
|
+
* @param {string} [opts.assetsBase] - The asset roots' parent, defaulting to
|
|
689
|
+
* the configured one. Stated separately from `contentBase` because the two
|
|
690
|
+
* move independently — a caller walking an assembled fixture tree says where
|
|
691
|
+
* that fixture's files are.
|
|
667
692
|
* @param {object} [opts.config] - Resolved configuration, defaulting to ambient.
|
|
668
693
|
* @param {readonly string[]} [opts.skipDirectories] - The walk's scope, for a
|
|
669
694
|
* caller that resolved one of its own; defaults to the resolved
|
|
@@ -677,13 +702,20 @@ export function serializeContentIndex(records) {
|
|
|
677
702
|
* exist.
|
|
678
703
|
* @returns {object[]} One record per note, plus one per documentation entry.
|
|
679
704
|
*/
|
|
680
|
-
export function indexRecordsFor({
|
|
705
|
+
export function indexRecordsFor({
|
|
706
|
+
contentBase,
|
|
707
|
+
assetsBase,
|
|
708
|
+
config,
|
|
709
|
+
skipDirectories,
|
|
710
|
+
problems,
|
|
711
|
+
} = {}) {
|
|
681
712
|
const resolved = config ?? loadPackConfig();
|
|
682
713
|
const tree = contentBase ?? resolved.paths.content;
|
|
683
714
|
if (!fs.existsSync(tree)) throw new Error(`no content tree at ${tree}`);
|
|
684
715
|
return collectContentIndex(tree, {
|
|
685
716
|
contentPackage: resolved.contentPackage,
|
|
686
717
|
skipDirectories: skipDirectories ?? resolved.skipDirectories,
|
|
718
|
+
assetsBase: assetsBase ?? resolved.paths.assets,
|
|
687
719
|
// Only the identities a UUID is a function of — see emitContentIndex.
|
|
688
720
|
manifest: foundryIdentities(resolved),
|
|
689
721
|
problems,
|
|
@@ -699,8 +731,9 @@ export function indexRecordsFor({ contentBase, config, skipDirectories, problems
|
|
|
699
731
|
* @param {string} [options.outDir] - Where to write; defaults to the configured
|
|
700
732
|
* `paths.contentIndex`.
|
|
701
733
|
* @param {object} [options.config] - A resolved configuration; loaded when omitted.
|
|
702
|
-
* @returns {{file: string, notes: number,
|
|
703
|
-
*
|
|
734
|
+
* @returns {{file: string, notes: number, assets: number, records: number,
|
|
735
|
+
* bytes: number}} Where it was written, how many notes and how many assets it
|
|
736
|
+
* holds, how many records that is in all, and its size.
|
|
704
737
|
* @throws {Error} When the content tree is absent, or when it yields no note at
|
|
705
738
|
* all — an empty index is indistinguishable from a mis-pointed tree, and a
|
|
706
739
|
* reader would take it as the authoritative statement that this package has
|
|
@@ -739,8 +772,10 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
|
|
|
739
772
|
fs.writeFileSync(file, text);
|
|
740
773
|
|
|
741
774
|
// Counted separately because they are genuinely different numbers: an item
|
|
742
|
-
// note yields a second record for its documentation journal
|
|
743
|
-
// records as notes would overstate how
|
|
744
|
-
|
|
745
|
-
|
|
775
|
+
// note yields a second record for its documentation journal and a file
|
|
776
|
+
// yields an asset record, so reporting records as notes would overstate how
|
|
777
|
+
// large the tree is.
|
|
778
|
+
const notes = records.filter(isNoteRecord).length;
|
|
779
|
+
const assets = records.filter(isAssetRecord).length;
|
|
780
|
+
return { file, notes, assets, records: records.length, bytes: Buffer.byteLength(text) };
|
|
746
781
|
}
|
package/engine/content-links.mjs
CHANGED
|
@@ -64,7 +64,16 @@ import { collectAnchors } from "./anchors.mjs";
|
|
|
64
64
|
// derives it. Nothing in the index's own import graph reaches this
|
|
65
65
|
// module, so this is a plain static import rather than the deferred one
|
|
66
66
|
// `sql-tables` needs to keep out of the compilers' cycle.
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
authoredFrontmatter,
|
|
69
|
+
indexRecordsFor,
|
|
70
|
+
isAssetRecord,
|
|
71
|
+
isNoteRecord,
|
|
72
|
+
noteFile,
|
|
73
|
+
} from "./content-index.mjs";
|
|
74
|
+
import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
|
|
75
|
+
import { resolveEmbeds } from "./content-embeds.mjs";
|
|
76
|
+
import { foundryAddressProblem, servesFoundry } from "./pathnames.mjs";
|
|
68
77
|
import { hasDocEntry } from "./item-docs.mjs";
|
|
69
78
|
import { NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
|
|
70
79
|
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
|
|
@@ -173,9 +182,20 @@ export function buildLinkIndex(
|
|
|
173
182
|
records ?? indexRecordsFor({ contentBase, config: resolved, skipDirectories, problems });
|
|
174
183
|
|
|
175
184
|
const byKey = new Map();
|
|
185
|
+
/** Canonical address to asset record, for the files this package ships. */
|
|
186
|
+
const byAssetKey = new Map();
|
|
176
187
|
const anchors = new Map();
|
|
177
188
|
|
|
178
189
|
for (const record of indexRecords) {
|
|
190
|
+
// An asset's record addresses a file rather than a note: there is no
|
|
191
|
+
// body to read links out of and no anchor to resolve one against, so it
|
|
192
|
+
// is keyed for resolution and nothing else. Keyed here rather than
|
|
193
|
+
// alongside the notes because `byKey` holds notes, and a caller that
|
|
194
|
+
// reaches for `.fm` or `.body` on one must not be handed a file.
|
|
195
|
+
if (isAssetRecord(record)) {
|
|
196
|
+
if (record.address?.canonical) byAssetKey.set(record.address.canonical, record);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
179
199
|
// A documentation journal has a record of its own but no file and no
|
|
180
200
|
// authored frontmatter — it is a document this tree emits, not a note
|
|
181
201
|
// in it. Its addresses are keyed below, from the note it documents.
|
|
@@ -228,7 +248,14 @@ export function buildLinkIndex(
|
|
|
228
248
|
// documentation journal's `doc<type>` is deliberately not among them: it is
|
|
229
249
|
// virtual, and `readQualifier` resolves it from the base type rather than
|
|
230
250
|
// from a type any tree declares.
|
|
231
|
-
|
|
251
|
+
//
|
|
252
|
+
// The **asset** types join unconditionally, whether or not this tree holds
|
|
253
|
+
// a file of each. They are a closed vocabulary rather than a census of what
|
|
254
|
+
// was found, and the difference is the whole diagnostic: an unknown type
|
|
255
|
+
// does not parse, so a tree with no `audio/` directory would read
|
|
256
|
+
// `audio-swoosh` as prose and say nothing, where an address the vocabulary
|
|
257
|
+
// knows and nothing answers is reported as the dead reference it is.
|
|
258
|
+
const types = new Set([...notes.map((n) => n.type), ...ASSET_TYPE_NAMES]);
|
|
232
259
|
|
|
233
260
|
// A foreign package may use a type this tree has never seen, so its types
|
|
234
261
|
// join `types` — otherwise `readQualifier` reads the link as prose and it
|
|
@@ -242,6 +269,17 @@ export function buildLinkIndex(
|
|
|
242
269
|
|
|
243
270
|
const packages = new Set([...(byKey.size ? [pkg] : []), ...foreign.packages]);
|
|
244
271
|
|
|
272
|
+
// The address space an `![[…]]` embed resolves against, shaped as every
|
|
273
|
+
// other asset resolver reads one so the checker cannot answer an authored
|
|
274
|
+
// embed differently from the builds that emit it.
|
|
275
|
+
const assetIndex = {
|
|
276
|
+
types,
|
|
277
|
+
packages,
|
|
278
|
+
contentPackage: pkg,
|
|
279
|
+
assets: byAssetKey,
|
|
280
|
+
foreign: foreign.index,
|
|
281
|
+
};
|
|
282
|
+
|
|
245
283
|
/** The searchable universe a `dataview` table draws its rows from. */
|
|
246
284
|
const tableDocs = notes.map((n) => ({
|
|
247
285
|
// Package present for a `WHERE … package = "…"` clause, synthesised
|
|
@@ -252,9 +290,93 @@ export function buildLinkIndex(
|
|
|
252
290
|
folder: path.dirname(n.rel).split("/").pop(),
|
|
253
291
|
}));
|
|
254
292
|
|
|
293
|
+
/**
|
|
294
|
+
* One note's body with its `dataview` and `sql` tables expanded.
|
|
295
|
+
*
|
|
296
|
+
* The body every body-level check reads, so a link and an embed in one note
|
|
297
|
+
* are found in the same text — a generated table is as free to carry either
|
|
298
|
+
* as prose is.
|
|
299
|
+
*
|
|
300
|
+
* @param {object} note - A note from this index.
|
|
301
|
+
* @returns {string} The markdown.
|
|
302
|
+
*/
|
|
303
|
+
function expandedBody(note) {
|
|
304
|
+
const body = note.body;
|
|
305
|
+
if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:dataview|sql)\b/im.test(body)) return body;
|
|
306
|
+
return expandContentTables(body, {
|
|
307
|
+
// Unfiltered: every note in the tree is this package's, so
|
|
308
|
+
// there is no other package's note to exclude.
|
|
309
|
+
docs: tableDocs,
|
|
310
|
+
linkable: (d) => Boolean(d.fm.shortcode),
|
|
311
|
+
source: note.file,
|
|
312
|
+
// A `sql` table's links are checked like an authored one's, so
|
|
313
|
+
// its rows are prepared ahead of this walk — see
|
|
314
|
+
// {@link module:engine/sql-tables.prepareTreeSqlTables}.
|
|
315
|
+
sqlTables: sqlTables?.get(note.file),
|
|
316
|
+
}).markdown;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Every `![[…]]` embed in a note body, resolved against the files this tree
|
|
321
|
+
* and its dependencies ship.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} note - A note from this index.
|
|
324
|
+
* @returns {Array<{text: string, occurrence: number, reason?: string,
|
|
325
|
+
* target?: string, type?: string, message?: string}>} One entry per
|
|
326
|
+
* defect, in the shape the finding reporter reads.
|
|
327
|
+
*/
|
|
328
|
+
function embedsOf(note) {
|
|
329
|
+
const { unresolved, problems, images } = resolveEmbeds(expandedBody(note), {
|
|
330
|
+
index: assetIndex,
|
|
331
|
+
});
|
|
332
|
+
// **The address an embed resolved to, held to the Foundry surface.** An
|
|
333
|
+
// embed becomes the ordinary image every surface renders, so a file
|
|
334
|
+
// the website serves and the book stages can still be one no Foundry
|
|
335
|
+
// install carries — and the journal would take the pathname as
|
|
336
|
+
// authored. Reported against the embed the note actually wrote, which
|
|
337
|
+
// is what a reader can open and edit. A build that installs nothing in
|
|
338
|
+
// Foundry has no such surface and is not asked.
|
|
339
|
+
const dead =
|
|
340
|
+
servesFoundry(resolved) ?
|
|
341
|
+
images
|
|
342
|
+
.map((image) => ({
|
|
343
|
+
text: image.link,
|
|
344
|
+
message: foundryAddressProblem(image.pathname, resolved),
|
|
345
|
+
}))
|
|
346
|
+
.filter((finding) => finding.message)
|
|
347
|
+
: [];
|
|
348
|
+
const seen = new Map();
|
|
349
|
+
/**
|
|
350
|
+
* @param {string} text - The embed exactly as authored.
|
|
351
|
+
* @returns {number} Its nth appearance in the note.
|
|
352
|
+
*/
|
|
353
|
+
const at = (text) => {
|
|
354
|
+
const occurrence = (seen.get(text) ?? 0) + 1;
|
|
355
|
+
seen.set(text, occurrence);
|
|
356
|
+
return occurrence;
|
|
357
|
+
};
|
|
358
|
+
return [
|
|
359
|
+
...unresolved.map((u) => ({
|
|
360
|
+
text: u.link,
|
|
361
|
+
target: u.target,
|
|
362
|
+
reason: u.reason,
|
|
363
|
+
...(u.type ? { type: u.type } : {}),
|
|
364
|
+
})),
|
|
365
|
+
...problems.map((problem) => ({
|
|
366
|
+
text: problem.link,
|
|
367
|
+
message: problem.message,
|
|
368
|
+
})),
|
|
369
|
+
...dead,
|
|
370
|
+
].map((finding) => ({ ...finding, occurrence: at(finding.text) }));
|
|
371
|
+
}
|
|
372
|
+
|
|
255
373
|
/**
|
|
256
374
|
* Every wikilink in a note body, with its `dataview` tables expanded.
|
|
257
375
|
*
|
|
376
|
+
* An `![[…]]` embed is not one: it names a file rather than a note, and
|
|
377
|
+
* {@link module:engine/wikilink-syntax.WIKILINK} excludes it so that no
|
|
378
|
+
* reader can take one for the other.
|
|
379
|
+
*
|
|
258
380
|
* @param {object} note - A note from this index.
|
|
259
381
|
* @returns {Array<{target: string, anchor: string, text: string,
|
|
260
382
|
* occurrence: number, labelled: boolean}>} `target` is `""` for a
|
|
@@ -262,26 +384,13 @@ export function buildLinkIndex(
|
|
|
262
384
|
* `|` every link must have.
|
|
263
385
|
*/
|
|
264
386
|
function linksOf(note) {
|
|
265
|
-
|
|
266
|
-
if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:dataview|sql)\b/im.test(body)) {
|
|
267
|
-
body = expandContentTables(body, {
|
|
268
|
-
// Unfiltered: every note in the tree is this package's, so
|
|
269
|
-
// there is no other package's note to exclude.
|
|
270
|
-
docs: tableDocs,
|
|
271
|
-
linkable: (d) => Boolean(d.fm.shortcode),
|
|
272
|
-
source: note.file,
|
|
273
|
-
// A `sql` table's links are checked like an authored one's, so
|
|
274
|
-
// its rows are prepared ahead of this walk — see
|
|
275
|
-
// {@link module:engine/sql-tables.prepareTreeSqlTables}.
|
|
276
|
-
sqlTables: sqlTables?.get(note.file),
|
|
277
|
-
}).markdown;
|
|
278
|
-
}
|
|
387
|
+
const body = expandedBody(note);
|
|
279
388
|
const out = [];
|
|
280
389
|
// How many times each authored link has been seen, so two identical
|
|
281
390
|
// links in one note are reported at their own positions.
|
|
282
391
|
const seen = new Map();
|
|
283
|
-
// Code is verbatim, so a `[[…]]` inside a fence, an indented block or
|
|
284
|
-
//
|
|
392
|
+
// Code is verbatim, so a `[[…]]` inside a fence, an indented block or an
|
|
393
|
+
// inline span is not a link — the compilers make none of it either.
|
|
285
394
|
for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
|
|
286
395
|
const parsed = parseWikilink(rawInner);
|
|
287
396
|
const { target, anchor } = parsed;
|
|
@@ -366,7 +475,11 @@ export function buildLinkIndex(
|
|
|
366
475
|
// plain `Map.set` — so two notes in one package sharing a
|
|
367
476
|
// `(type, shortcode)` across systems silently overwrote each other, and
|
|
368
477
|
// a bare link resolved to whichever was indexed second.
|
|
369
|
-
|
|
478
|
+
const canonical = expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) });
|
|
479
|
+
// Assets are consulted after notes and never instead of them: the two
|
|
480
|
+
// namespaces cannot collide — an address carries its type — so the order
|
|
481
|
+
// is about which map holds the answer, not about precedence.
|
|
482
|
+
return byKey.get(canonical) ?? byAssetKey.get(canonical);
|
|
370
483
|
}
|
|
371
484
|
|
|
372
485
|
/**
|
|
@@ -431,13 +544,17 @@ export function buildLinkIndex(
|
|
|
431
544
|
* to this one. That is the rule for a link, whose target is a document to
|
|
432
545
|
* point at; a reference names an item to stand beside.
|
|
433
546
|
*
|
|
547
|
+
* The files this package ships answer here too, and by the same rule: an
|
|
548
|
+
* asset's address is a `(type, shortcode)` pair like any other, so a field
|
|
549
|
+
* naming `icon-anvil` resolves to the record that carries the file's path.
|
|
550
|
+
*
|
|
434
551
|
* @param {string} target - The reference as `type-shortcode`.
|
|
435
|
-
* @returns {object|null} The note or foreign entry declaring it.
|
|
552
|
+
* @returns {object|null} The note, asset record or foreign entry declaring it.
|
|
436
553
|
*/
|
|
437
554
|
function referenceHit(target) {
|
|
438
555
|
const q = readQualifier(target, types, packages);
|
|
439
556
|
if (!q || q.reason) return null;
|
|
440
|
-
const local = matchAddress([...byKey], q);
|
|
557
|
+
const local = matchAddress([...byKey, ...byAssetKey], q);
|
|
441
558
|
if (local.length) return local[0][1];
|
|
442
559
|
const abroad = matchAddress([...foreign.index], q);
|
|
443
560
|
return abroad.length ? abroad[0][1] : null;
|
|
@@ -449,6 +566,17 @@ export function buildLinkIndex(
|
|
|
449
566
|
anchors,
|
|
450
567
|
types,
|
|
451
568
|
packages,
|
|
569
|
+
/**
|
|
570
|
+
* The files this package ships, by canonical address. Separate from the
|
|
571
|
+
* notes because the two record shapes are read differently, and exposed
|
|
572
|
+
* because a pass resolving art needs the address set without walking the
|
|
573
|
+
* index again.
|
|
574
|
+
*/
|
|
575
|
+
assets: byAssetKey,
|
|
576
|
+
/**
|
|
577
|
+
* The address space an `![[…]]` embed resolves against.
|
|
578
|
+
*/
|
|
579
|
+
assetIndex,
|
|
452
580
|
/**
|
|
453
581
|
* The one package this tree publishes. Distinct from `packages`, which
|
|
454
582
|
* is the set an address may name and which a homepage-only tree leaves
|
|
@@ -457,6 +585,7 @@ export function buildLinkIndex(
|
|
|
457
585
|
contentPackage: pkg,
|
|
458
586
|
foreign,
|
|
459
587
|
linksOf,
|
|
588
|
+
embedsOf,
|
|
460
589
|
/**
|
|
461
590
|
* Resolve a link target the way both builds do, or `undefined`. Every
|
|
462
591
|
* link is an address, so this is {@link resolveAddress} under the name
|
|
@@ -797,7 +926,15 @@ export function auditHomepageLinks(index) {
|
|
|
797
926
|
* the three resolvers agree on severity for every class.
|
|
798
927
|
*/
|
|
799
928
|
export function auditLinks(index) {
|
|
800
|
-
const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
|
|
929
|
+
const { notes, anchors, linksOf, embedsOf, resolve, manifestHit, isAddress } = index;
|
|
930
|
+
|
|
931
|
+
// An embed names a file, and is checked here rather than by the image pass
|
|
932
|
+
// because its grammar is the wikilink's: the same short-form ladder, the
|
|
933
|
+
// same package defaults and the same findings vocabulary.
|
|
934
|
+
const deadEmbeds = [];
|
|
935
|
+
for (const note of notes) {
|
|
936
|
+
for (const finding of embedsOf(note)) deadEmbeds.push({ note, ...finding });
|
|
937
|
+
}
|
|
801
938
|
|
|
802
939
|
const deadAnchors = [];
|
|
803
940
|
for (const note of notes) {
|
|
@@ -805,9 +942,11 @@ export function auditLinks(index) {
|
|
|
805
942
|
if (!anchor || !labelled) continue;
|
|
806
943
|
const dest = target ? resolve(target) : note;
|
|
807
944
|
// An unresolvable target is reported by the pass below; its anchor
|
|
808
|
-
// has nothing to be checked against.
|
|
945
|
+
// has nothing to be checked against. Neither has a file: an asset
|
|
946
|
+
// resolves but has no body, so it declares the empty set of anchors
|
|
947
|
+
// and every `#section` on one is dead.
|
|
809
948
|
if (!dest) continue;
|
|
810
|
-
if (!anchors.get(dest).has(slugify(anchor))) {
|
|
949
|
+
if (!(anchors.get(dest) ?? new Set()).has(slugify(anchor))) {
|
|
811
950
|
deadAnchors.push({
|
|
812
951
|
note,
|
|
813
952
|
link: `${target}#${anchor}`,
|
|
@@ -876,6 +1015,7 @@ export function auditLinks(index) {
|
|
|
876
1015
|
return {
|
|
877
1016
|
deadAnchors,
|
|
878
1017
|
deadAddresses,
|
|
1018
|
+
deadEmbeds,
|
|
879
1019
|
unlabelledLinks,
|
|
880
1020
|
frontmatterLinks: index.frontmatterLinks,
|
|
881
1021
|
homepageLinks: auditHomepageLinks(index),
|
|
@@ -72,11 +72,14 @@ import { isAddressSegment } from "./address-charset.mjs";
|
|
|
72
72
|
// reads is exactly the disagreement to avoid.
|
|
73
73
|
import { DEFAULT_PARENT } from "./folder-notes.mjs";
|
|
74
74
|
import {
|
|
75
|
+
dataFields,
|
|
75
76
|
declaredTags,
|
|
76
77
|
exclusiveTagGroups,
|
|
77
78
|
subTypeCharsetMessage,
|
|
78
79
|
typeCharsetMessage,
|
|
79
80
|
} from "./note-vocabulary.mjs";
|
|
81
|
+
// The art slots, declared once. The linter states no art key of its own.
|
|
82
|
+
import { ART_SLOTS } from "./art-fields.mjs";
|
|
80
83
|
import {
|
|
81
84
|
RETIRED_FIELD_ALIASES,
|
|
82
85
|
declaresRetiredAlias,
|
|
@@ -770,28 +773,25 @@ function checkExclusiveTags(note, { type }) {
|
|
|
770
773
|
}
|
|
771
774
|
|
|
772
775
|
/**
|
|
773
|
-
* The frontmatter fields that name artwork
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
* does not — and a check that read only the two they share would pass a
|
|
787
|
-
* `data.portrait: ""` it could not see.
|
|
788
|
-
*
|
|
789
|
-
* @type {readonly {key: string, inData: boolean}[]}
|
|
776
|
+
* The frontmatter fields that name artwork.
|
|
777
|
+
*
|
|
778
|
+
* {@link module:engine/art-fields.ART_SLOTS} is the declaration; this is that
|
|
779
|
+
* list in the shape the checks below read, so the linter states no art key of
|
|
780
|
+
* its own and a slot added there is checked here with no second edit. All four
|
|
781
|
+
* are authored under `data:`, which is what `inData` records.
|
|
782
|
+
*
|
|
783
|
+
* `document` separates the three that reach a compiled document from `banner`,
|
|
784
|
+
* which reaches none by design. The inert-art check reads it: a key that is
|
|
785
|
+
* *meant* to reach no document is not an inert key, and reporting every note
|
|
786
|
+
* that names a hero image would bury the finding that matters.
|
|
787
|
+
*
|
|
788
|
+
* @type {readonly {key: string, inData: boolean, document: boolean}[]}
|
|
790
789
|
*/
|
|
791
|
-
export const ART_FIELDS = Object.freeze(
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
790
|
+
export const ART_FIELDS = Object.freeze(
|
|
791
|
+
ART_SLOTS.map((slot) =>
|
|
792
|
+
Object.freeze({ key: slot.key, inData: true, document: slot.document }),
|
|
793
|
+
),
|
|
794
|
+
);
|
|
795
795
|
|
|
796
796
|
/**
|
|
797
797
|
* The keys a field declaration is authored at **inside a system block**.
|
|
@@ -1159,7 +1159,11 @@ export function lintNote(
|
|
|
1159
1159
|
const emitted = emittedArt ? emittedArt(currentType(type)) : null;
|
|
1160
1160
|
/** The art fields this note's type reaches nothing through. */
|
|
1161
1161
|
const inertArt = new Set(
|
|
1162
|
-
emitted ?
|
|
1162
|
+
emitted ?
|
|
1163
|
+
ART_FIELDS.filter(({ key, document }) => document && !emitted.art.includes(key)).map(
|
|
1164
|
+
(f) => f.key,
|
|
1165
|
+
)
|
|
1166
|
+
: [],
|
|
1163
1167
|
);
|
|
1164
1168
|
for (const { key, inData } of ART_FIELDS) {
|
|
1165
1169
|
if (!inertArt.has(key)) continue;
|
|
@@ -1400,7 +1404,16 @@ export function lintNote(
|
|
|
1400
1404
|
// to recognise it would be worse than not checking.
|
|
1401
1405
|
const entry = vocabulary?.[current];
|
|
1402
1406
|
if (entry) {
|
|
1403
|
-
findings.push(
|
|
1407
|
+
findings.push(
|
|
1408
|
+
...checkDataContainer(note, {
|
|
1409
|
+
type,
|
|
1410
|
+
// Read through `dataFields`, so the keys **every** type accepts
|
|
1411
|
+
// are part of what this type declares rather than a second list
|
|
1412
|
+
// the container check would have to be told about.
|
|
1413
|
+
fields: dataFields(current, vocabulary) ?? [],
|
|
1414
|
+
packs,
|
|
1415
|
+
}),
|
|
1416
|
+
);
|
|
1404
1417
|
findings.push(...checkSubType(note, { type, entry }));
|
|
1405
1418
|
}
|
|
1406
1419
|
|