@heroiclands/package-build 21.1.0 → 22.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 +245 -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/bin/package-build.mjs +6 -5
- package/ci/emit-asset-index.mjs +32 -0
- package/content-config.mjs +30 -1
- package/docs/api.md +103 -27
- package/docs/commands.md +13 -11
- package/docs/configuration.md +16 -7
- package/docs/content-format.md +523 -111
- 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/dependency-bump.mjs +14 -16
- 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 +49 -12
- 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/manifest.mjs +67 -13
- 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/dependency-bump.d.mts +2 -2
- 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/manifest.d.mts +42 -1
- package/types/sohl/actors.d.mts +0 -1
package/engine/base-compiler.mjs
CHANGED
|
@@ -68,6 +68,7 @@ import log from "loglevel";
|
|
|
68
68
|
import {
|
|
69
69
|
parseMarkdownFile,
|
|
70
70
|
makeFilename,
|
|
71
|
+
resolveImg,
|
|
71
72
|
resolveName,
|
|
72
73
|
convertNoteWikilinks,
|
|
73
74
|
expandNoteTables,
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
// The record accessors only — see `engine/index-records.mjs` for why they live
|
|
77
78
|
// apart from the index that builds them.
|
|
78
79
|
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
80
|
+
import { artPathname, artSlot, unresolvedArtMessage } from "./art-fields.mjs";
|
|
79
81
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
80
82
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
81
83
|
import { assertNoDeclaredFolder } from "./folder-notes.mjs";
|
|
@@ -229,6 +231,13 @@ export class BasePackCompiler {
|
|
|
229
231
|
|
|
230
232
|
/** @type {string} */
|
|
231
233
|
contentBase;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The asset roots' parent, whose files the art addresses resolve against.
|
|
237
|
+
*
|
|
238
|
+
* @type {string|undefined}
|
|
239
|
+
*/
|
|
240
|
+
assetsBase;
|
|
232
241
|
/** @type {string} */
|
|
233
242
|
outputDir;
|
|
234
243
|
/** @type {(path: string|null) => string|null} */
|
|
@@ -296,6 +305,11 @@ export class BasePackCompiler {
|
|
|
296
305
|
/**
|
|
297
306
|
* @param {object} options
|
|
298
307
|
* @param {string} options.contentBase - Root of the content tree.
|
|
308
|
+
* @param {string} [options.assetsBase] - The asset roots' parent, whose
|
|
309
|
+
* files the art addresses resolve against. Stated beside `contentBase`
|
|
310
|
+
* because the two move independently: a caller compiling a tree it
|
|
311
|
+
* assembled states where that tree's files are, and one compiling the
|
|
312
|
+
* repository's own leaves it to the configuration.
|
|
299
313
|
* @param {string} options.dest - Where this pass writes its JSON.
|
|
300
314
|
* @param {readonly string[]} options.skipDirectories - Directories the walk
|
|
301
315
|
* never descends into. Required: see {@link assertStatedScope}.
|
|
@@ -317,6 +331,7 @@ export class BasePackCompiler {
|
|
|
317
331
|
*/
|
|
318
332
|
constructor({
|
|
319
333
|
contentBase,
|
|
334
|
+
assetsBase,
|
|
320
335
|
dest,
|
|
321
336
|
skipDirectories,
|
|
322
337
|
folderResolver = () => null,
|
|
@@ -344,6 +359,10 @@ export class BasePackCompiler {
|
|
|
344
359
|
value: contentBase,
|
|
345
360
|
writable: false,
|
|
346
361
|
});
|
|
362
|
+
Object.defineProperty(this, "assetsBase", {
|
|
363
|
+
value: assetsBase,
|
|
364
|
+
writable: false,
|
|
365
|
+
});
|
|
347
366
|
Object.defineProperty(this, "outputDir", {
|
|
348
367
|
value: dest,
|
|
349
368
|
writable: false,
|
|
@@ -542,6 +561,7 @@ export class BasePackCompiler {
|
|
|
542
561
|
if (!this.corpus) {
|
|
543
562
|
this.corpus = await buildCompileCorpus({
|
|
544
563
|
contentBase: this.contentBase,
|
|
564
|
+
assetsBase: this.assetsBase,
|
|
545
565
|
skipDirectories: this.skipDirectories,
|
|
546
566
|
router: this.router,
|
|
547
567
|
});
|
|
@@ -551,8 +571,11 @@ export class BasePackCompiler {
|
|
|
551
571
|
// the same thing six times.
|
|
552
572
|
this.reportsCorpusProblems = true;
|
|
553
573
|
}
|
|
574
|
+
// The index is taken whatever the pass does with a body: it answers
|
|
575
|
+
// the art fields, which name a file rather than a document, and a pass
|
|
576
|
+
// that converts no prose still compiles a document that carries art.
|
|
577
|
+
this.linkIndex = this.corpus.linkIndex;
|
|
554
578
|
if (this.constructor.convertsWikilinks) {
|
|
555
|
-
this.linkIndex = this.corpus.linkIndex;
|
|
556
579
|
this.contentDocs = this.corpus.contentDocs;
|
|
557
580
|
this.sqlTables = this.corpus.sqlTables;
|
|
558
581
|
}
|
|
@@ -602,6 +625,51 @@ export class BasePackCompiler {
|
|
|
602
625
|
return markdown;
|
|
603
626
|
}
|
|
604
627
|
|
|
628
|
+
/**
|
|
629
|
+
* The Foundry path one authored art address names, or `null`.
|
|
630
|
+
*
|
|
631
|
+
* **The two empties survive.** `null` and an absent key mean *no art named*,
|
|
632
|
+
* so the caller's own default applies with nullish coalescing; `""` means
|
|
633
|
+
* *ship blank on purpose* and no default may replace it. That is
|
|
634
|
+
* {@link module:engine/helpers.resolveImg}'s rule, reached through it rather
|
|
635
|
+
* than restated, which is also what puts an art address and a body image
|
|
636
|
+
* through one ownership rule.
|
|
637
|
+
*
|
|
638
|
+
* An address nothing answers is reported against the note and treated as
|
|
639
|
+
* unnamed, so the document takes its default rather than shipping a path
|
|
640
|
+
* that installs nowhere.
|
|
641
|
+
*
|
|
642
|
+
* @param {unknown} value - The value as authored.
|
|
643
|
+
* @param {string} key - The key it was authored at, for the message.
|
|
644
|
+
* @param {string} type - The asset type a bare value takes.
|
|
645
|
+
* @returns {string|null} The Foundry-relative path, `""` for a deliberate
|
|
646
|
+
* blank, or `null` where the note names none.
|
|
647
|
+
*/
|
|
648
|
+
artPathOf(value, key, type) {
|
|
649
|
+
const { pathname, resolved } = artPathname(this.linkIndex, value, type);
|
|
650
|
+
if (!resolved) {
|
|
651
|
+
this.noteWarn(unresolvedArtMessage(key, value, type));
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
return resolveImg(pathname);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The Foundry path one art slot of a note names, or `null`.
|
|
659
|
+
*
|
|
660
|
+
* @param {object} fm - The note's frontmatter.
|
|
661
|
+
* @param {string} key - The slot's key under `data:`.
|
|
662
|
+
* @returns {string|null} As {@link BasePackCompiler#artPathOf}.
|
|
663
|
+
*/
|
|
664
|
+
artPath(fm, key) {
|
|
665
|
+
const slot = artSlot(key);
|
|
666
|
+
if (!slot) throw new Error(`package-build: \`${key}\` is not an art slot`);
|
|
667
|
+
const data = fm?.data;
|
|
668
|
+
const value =
|
|
669
|
+
data && typeof data === "object" && !Array.isArray(data) ? data[key] : undefined;
|
|
670
|
+
return this.artPathOf(value, key, slot.type);
|
|
671
|
+
}
|
|
672
|
+
|
|
605
673
|
/**
|
|
606
674
|
* Reports a warning about the note being compiled.
|
|
607
675
|
*
|
package/engine/bundles.mjs
CHANGED
|
@@ -69,7 +69,7 @@ import { contentPackage } from "./content-package.mjs";
|
|
|
69
69
|
import { FOLDER_TYPE } from "./folder-notes.mjs";
|
|
70
70
|
import { HOMEPAGE_TYPE } from "./homepage.mjs";
|
|
71
71
|
import { itemDocEntryId } from "./item-docs.mjs";
|
|
72
|
-
import { folderField, md,
|
|
72
|
+
import { folderField, md, resolveName } from "./helpers.mjs";
|
|
73
73
|
import { packForType } from "./ids.mjs";
|
|
74
74
|
import { readQualifier } from "./wikilinks.mjs";
|
|
75
75
|
|
|
@@ -145,13 +145,13 @@ export class Bundles extends BasePackCompiler {
|
|
|
145
145
|
static readsPackOutputOf = Object.freeze(["Actor", "Item", "JournalEntry", "Macro", "Scene"]);
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
|
-
* An Adventure carries
|
|
149
|
-
* There is no default for it: a bundle naming none ships a blank tile,
|
|
148
|
+
* An Adventure carries one piece of art — what Foundry shows on the import
|
|
149
|
+
* card. There is no default for it: a bundle naming none ships a blank tile,
|
|
150
150
|
* deliberately, since no stand-in artwork means "a set of documents".
|
|
151
151
|
*
|
|
152
152
|
* @type {readonly string[]}
|
|
153
153
|
*/
|
|
154
|
-
static emitsArt = Object.freeze(["
|
|
154
|
+
static emitsArt = Object.freeze(["icon"]);
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
157
|
* The JSON directories this pass reads its members from, by document type.
|
|
@@ -309,7 +309,7 @@ export class Bundles extends BasePackCompiler {
|
|
|
309
309
|
// Foundry shows this on the import card, so a bundle without one is
|
|
310
310
|
// a blank tile. `null` rather than a stand-in: there is no sensible
|
|
311
311
|
// default artwork for "a set of documents".
|
|
312
|
-
img:
|
|
312
|
+
img: this.artPath(fm, "icon"),
|
|
313
313
|
// A bundle is something you hand someone, so its prose belongs on
|
|
314
314
|
// the document itself — `Adventure.description` is an `HTMLField`
|
|
315
315
|
// Foundry renders on the import card. That is why a bundle earns no
|
|
@@ -48,6 +48,8 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
|
|
|
48
48
|
*
|
|
49
49
|
* @param {object} opts - Options.
|
|
50
50
|
* @param {string} opts.contentBase - Root of the content tree.
|
|
51
|
+
* @param {string} [opts.assetsBase] - The asset roots' parent, whose files the
|
|
52
|
+
* art addresses resolve against. Defaults to the configured one.
|
|
51
53
|
* @param {readonly string[]} opts.skipDirectories - The scope, stated by the
|
|
52
54
|
* caller as every corpus read requires.
|
|
53
55
|
* @param {object} opts.router - The pack router this compile resolved. Shared
|
|
@@ -64,6 +66,7 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
|
|
|
64
66
|
*/
|
|
65
67
|
export async function buildCompileCorpus({
|
|
66
68
|
contentBase,
|
|
69
|
+
assetsBase,
|
|
67
70
|
skipDirectories,
|
|
68
71
|
router,
|
|
69
72
|
config,
|
|
@@ -73,6 +76,7 @@ export async function buildCompileCorpus({
|
|
|
73
76
|
const collected = problems ?? [];
|
|
74
77
|
const records = indexRecordsFor({
|
|
75
78
|
contentBase,
|
|
79
|
+
assetsBase,
|
|
76
80
|
config: resolved,
|
|
77
81
|
skipDirectories,
|
|
78
82
|
problems: collected,
|
|
@@ -46,6 +46,10 @@ import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
|
|
|
46
46
|
// `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
|
|
47
47
|
// the direction is toward the leaf and cannot close a cycle.
|
|
48
48
|
import { NO_SYSTEM, assertSystemSegment, isSystemSegment } from "./systems.mjs";
|
|
49
|
+
// The asset vocabulary, for the one thing the address grammar asks of it:
|
|
50
|
+
// whether a type's `<system>` segment is fixed. `engine/asset-types.mjs` is a
|
|
51
|
+
// leaf, so the direction cannot close a cycle.
|
|
52
|
+
import { isAssetType } from "./asset-types.mjs";
|
|
49
53
|
import { systemOf } from "./document-subtypes.mjs";
|
|
50
54
|
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
|
|
51
55
|
|
|
@@ -267,8 +271,16 @@ export function blockSystem(keyPath) {
|
|
|
267
271
|
export function expandAddress(read, where) {
|
|
268
272
|
const pkg = read.package ?? where.package;
|
|
269
273
|
// A documentation journal is a core document, so it is `none` however it was
|
|
270
|
-
// reached
|
|
271
|
-
|
|
274
|
+
// reached, and an **asset** is `none` for a stronger reason: a file belongs
|
|
275
|
+
// to no game system, so the segment is a property of the type rather than of
|
|
276
|
+
// where the reference was written. Without that, a `sohl:` block naming
|
|
277
|
+
// `icon-anvil` would expand to `sohl-sohl-icon-anvil` and resolve to
|
|
278
|
+
// nothing, which is exactly where an embedded item's art is written.
|
|
279
|
+
// Otherwise the block's system, which body prose reports as `none`.
|
|
280
|
+
const system =
|
|
281
|
+
read.itemDoc || isAssetType(read.type) ?
|
|
282
|
+
NO_SYSTEM
|
|
283
|
+
: (read.system ?? where.system ?? NO_SYSTEM);
|
|
272
284
|
const redirected = system === NO_SYSTEM && isSystemBearing(read.type);
|
|
273
285
|
const type = read.itemDoc || redirected ? `doc${read.type}` : read.type;
|
|
274
286
|
return canonicalKey(pkg, system, type, read.shortcode);
|
|
@@ -398,6 +398,10 @@ export function checkText(text, file) {
|
|
|
398
398
|
* theoretical exclusion — it was the first thing a run over `sohl-thalorna`
|
|
399
399
|
* reported before the skip existed.
|
|
400
400
|
*
|
|
401
|
+
* A finding names its file **relative to the working directory**, which is
|
|
402
|
+
* where a reader is standing and what `formatDiagnostic` emits. The content
|
|
403
|
+
* root is where the walk starts, not what a path is measured from.
|
|
404
|
+
*
|
|
401
405
|
* @param {string} contentBase - Root of the content tree.
|
|
402
406
|
* @param {object} [opts]
|
|
403
407
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
|
|
@@ -438,7 +442,7 @@ export function lintContentCharset(contentBase, { skipDirectories = [], extensio
|
|
|
438
442
|
continue;
|
|
439
443
|
}
|
|
440
444
|
files += 1;
|
|
441
|
-
findings.push(...checkText(text, path.relative(
|
|
445
|
+
findings.push(...checkText(text, path.relative(process.cwd(), full)));
|
|
442
446
|
}
|
|
443
447
|
};
|
|
444
448
|
|
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
* `![[address|label]]` — the wikilink that renders a picture where it stands.
|
|
16
|
+
*
|
|
17
|
+
* There is no image grammar here. An embed is the wikilink
|
|
18
|
+
* {@link module:engine/wikilink-syntax} already defines, with `!` meaning
|
|
19
|
+
* _render it here_ rather than _link to it_, and the syntax supplying the
|
|
20
|
+
* default type the way a field declaration does — `image`. The short-form
|
|
21
|
+
* ladder, the package and system defaults, the lowercase rule and the findings
|
|
22
|
+
* vocabulary all apply unchanged.
|
|
23
|
+
*
|
|
24
|
+
* ## An embed resolves to an ordinary image, and that is the whole design
|
|
25
|
+
*
|
|
26
|
+
* `![[thorn|Thorn]]{float: top-left}` becomes
|
|
27
|
+
* `{float: top-left}` in the
|
|
28
|
+
* source text, before any surface renders it. Everything downstream is machinery
|
|
29
|
+
* that already exists: {@link module:engine/content-images.imagePlugin} draws the
|
|
30
|
+
* figure for a Foundry journal and for the book's Typst walk,
|
|
31
|
+
* {@link module:engine/content-images.renderImageFigures} draws it for the
|
|
32
|
+
* website, and {@link module:engine/content-images.imageSourcesIn} is what tells
|
|
33
|
+
* the book which files to stage. One authored statement, three renderers, no
|
|
34
|
+
* fourth image path.
|
|
35
|
+
*
|
|
36
|
+
* **That is why the rewrite is a source-text pass rather than a markdown-it
|
|
37
|
+
* inline rule.** Two of the four readers have no render pass to put a token in:
|
|
38
|
+
* the website is handed *markdown* for Hugo to render, and the link checker
|
|
39
|
+
* parses nothing at all. A third, the book, reads its staging list out of the
|
|
40
|
+
* source text before it tokenises. A token would therefore serve one reader and
|
|
41
|
+
* leave the other three needing the grammar a second time, which is the drift
|
|
42
|
+
* the wikilink pattern was consolidated here to end.
|
|
43
|
+
*
|
|
44
|
+
* ## The label is the alt text, and it stays with the referrer
|
|
45
|
+
*
|
|
46
|
+
* One image serves many documents, and only the referrer knows what it means
|
|
47
|
+
* where it sits. `![[anvil|]]` is deliberately decorative, `![[anvil|An anvil]]`
|
|
48
|
+
* carries meaning, and `![[anvil]]` is unlabelled and a finding like any other
|
|
49
|
+
* link — the parser distinguishes a missing label from an empty one, so the two
|
|
50
|
+
* differ without an exemption.
|
|
51
|
+
*
|
|
52
|
+
* ## An embed reaches asset types only
|
|
53
|
+
*
|
|
54
|
+
* The syntax invites the broader reading — transclusion of an arbitrary note —
|
|
55
|
+
* so the restriction is a guard rather than a convention, and a `being` named
|
|
56
|
+
* where a picture belongs is refused with its own reason rather than reported as
|
|
57
|
+
* an address that resolves to nothing.
|
|
58
|
+
*
|
|
59
|
+
* @module
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
63
|
+
import { parseImageDirective, standsAlone } from "./content-images.mjs";
|
|
64
|
+
import { positionInBody } from "./diagnostics.mjs";
|
|
65
|
+
import { authoredLabel, linkFindingMessage, parseWikilink } from "./wikilink-syntax.mjs";
|
|
66
|
+
import { readAssetAddress } from "./art-fields.mjs";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The type a bare shortcode takes, supplied by the syntax itself.
|
|
70
|
+
*
|
|
71
|
+
* A picture is what `!` asks for, so `image` is what an unqualified embed names.
|
|
72
|
+
* An icon is reached by stating it — `![[icon-anvil|An anvil]]` — because the two
|
|
73
|
+
* have separate shortcode namespaces and neither is derivable from the other.
|
|
74
|
+
*
|
|
75
|
+
* @type {string}
|
|
76
|
+
*/
|
|
77
|
+
export const EMBED_DEFAULT_TYPE = "image";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* An embed, as authored, with the directive it may carry.
|
|
81
|
+
*
|
|
82
|
+
* The interior admits no `]` or newline, exactly as a wikilink's does: an embed
|
|
83
|
+
* is written on one line, and an unclosed `![[` is a typo rather than licence to
|
|
84
|
+
* consume the rest of the document looking for a closer.
|
|
85
|
+
*
|
|
86
|
+
* The directive is `{…}` immediately after the closing `]]`, holding no newline,
|
|
87
|
+
* for the reason {@link module:engine/content-images.IMAGE_PATTERN} gives: a
|
|
88
|
+
* brace that opens and never closes on its line is prose.
|
|
89
|
+
*
|
|
90
|
+
* @type {RegExp}
|
|
91
|
+
*/
|
|
92
|
+
export const EMBED_PATTERN = /!\[\[([^\]\n]+)\]\](\{[^}\n]*\})?/g;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One embed, parsed.
|
|
96
|
+
*
|
|
97
|
+
* @typedef {object} ParsedEmbed
|
|
98
|
+
* @property {string} all - The embed exactly as authored, directive included.
|
|
99
|
+
* @property {string} inner - The whole interior of the brackets.
|
|
100
|
+
* @property {string} written - The link part as authored, anchor included. An
|
|
101
|
+
* embed names a file and a file has no sections, so an anchor is carried into
|
|
102
|
+
* the address rather than stripped off it — where it fails the address charset
|
|
103
|
+
* and is reported as what it is.
|
|
104
|
+
* @property {string|null} display - The text after `|`, `null` when unlabelled.
|
|
105
|
+
* @property {boolean} labelled - Whether a `|` was present at all.
|
|
106
|
+
* @property {string} directive - The `{…}` as written, or `""`.
|
|
107
|
+
* @property {number} index - Where the embed begins in the body.
|
|
108
|
+
* @property {number} length - How much of the body it occupies.
|
|
109
|
+
* @property {boolean} block - Whether it stands alone in its own paragraph.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every embed in one body, in source order.
|
|
114
|
+
*
|
|
115
|
+
* Code is skipped, because an embed shown as an example is prose *about* an
|
|
116
|
+
* embed — which is what makes this module's own documentation writable.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} body - The note's markdown, without its frontmatter.
|
|
119
|
+
* @returns {ParsedEmbed[]} One entry per embed.
|
|
120
|
+
*/
|
|
121
|
+
export function embedsIn(body) {
|
|
122
|
+
const text = String(body ?? "");
|
|
123
|
+
return matchAllOutsideCode(text, new RegExp(EMBED_PATTERN.source, "g")).map((match) => {
|
|
124
|
+
const index = /** @type {number} */ (match.index);
|
|
125
|
+
const parsed = parseWikilink(match[1] ?? "");
|
|
126
|
+
const length = match[0].length;
|
|
127
|
+
return {
|
|
128
|
+
all: match[0],
|
|
129
|
+
inner: parsed.inner,
|
|
130
|
+
written: parsed.anchor ? `${parsed.target}#${parsed.anchor}` : parsed.target,
|
|
131
|
+
display: parsed.display,
|
|
132
|
+
labelled: parsed.labelled,
|
|
133
|
+
directive: match[2] ?? "",
|
|
134
|
+
index,
|
|
135
|
+
length,
|
|
136
|
+
block: standsAlone(text, index, index + length),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What one embed resolves to, or why it does not.
|
|
143
|
+
*
|
|
144
|
+
* The reasons are {@link module:engine/wikilink-syntax.LINK_FINDING_REASONS},
|
|
145
|
+
* the vocabulary every resolver shares, so an author meets one wording for one
|
|
146
|
+
* mistake whichever build they ran first.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex},
|
|
149
|
+
* or the equivalent the site and the book build.
|
|
150
|
+
* @param {ParsedEmbed} embed - The embed.
|
|
151
|
+
* @returns {{pathname: string}|{reason: string, target: string, type?: string}}
|
|
152
|
+
* The authored pathname the picture is at, or the finding.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveEmbed(index, embed) {
|
|
155
|
+
// **Every link carries a label**, and an embed is a link. Without one there
|
|
156
|
+
// is nothing to show and nothing to describe the picture with, so it is
|
|
157
|
+
// reported before anything is looked up.
|
|
158
|
+
if (!embed.labelled) return { reason: "unlabelled", target: embed.inner };
|
|
159
|
+
|
|
160
|
+
const read = readAssetAddress(index, embed.written, EMBED_DEFAULT_TYPE);
|
|
161
|
+
if (read.record) {
|
|
162
|
+
return { pathname: read.pathname };
|
|
163
|
+
}
|
|
164
|
+
return { reason: read.reason, target: embed.written, type: read.type };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What is wrong with one embed's directive or placement, if anything.
|
|
169
|
+
*
|
|
170
|
+
* Separate from the link findings beside it because the two speak different
|
|
171
|
+
* vocabularies: a link finding is a `reason` from the closed set every resolver
|
|
172
|
+
* shares, and this is an image's own complaint about a brace or a paragraph.
|
|
173
|
+
* Collapsing them would put a sentence where a `reason` belongs.
|
|
174
|
+
*
|
|
175
|
+
* @param {ParsedEmbed} embed - The embed.
|
|
176
|
+
* @returns {Array<{link: string, offset: number, message: string}>} One entry
|
|
177
|
+
* per defect. `link` is the embed exactly as authored, which is what lets a
|
|
178
|
+
* caller with no offsets locate it by searching the note for the literal.
|
|
179
|
+
*/
|
|
180
|
+
export function embedProblems(embed) {
|
|
181
|
+
/** @type {Array<{link: string, offset: number, message: string}>} */
|
|
182
|
+
const problems = [];
|
|
183
|
+
if (!embed.block) {
|
|
184
|
+
problems.push({
|
|
185
|
+
link: embed.all,
|
|
186
|
+
offset: embed.index,
|
|
187
|
+
message:
|
|
188
|
+
`\`![[${embed.inner}]]\` shares its paragraph with other text — an ` +
|
|
189
|
+
"embedded image is a block, standing alone with a blank line either " +
|
|
190
|
+
"side of it, because a width and a position mean nothing applied to a " +
|
|
191
|
+
"word in a sentence",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (!embed.directive) return problems;
|
|
195
|
+
// Located on the brace rather than on the embed: the brace is what the
|
|
196
|
+
// author edits, and two problems in one directive should not report at the
|
|
197
|
+
// same column twice.
|
|
198
|
+
const offset = embed.index + embed.length - embed.directive.length;
|
|
199
|
+
for (const message of parseImageDirective(embed.directive).problems) {
|
|
200
|
+
problems.push({ link: embed.all, offset, message });
|
|
201
|
+
}
|
|
202
|
+
return problems;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Rewrite every embed in one body into the image each surface already renders.
|
|
207
|
+
*
|
|
208
|
+
* An embed that does not resolve is left **exactly as authored** and reported,
|
|
209
|
+
* so a missing picture degrades to visible literal text rather than to a broken
|
|
210
|
+
* `src` or a swallowed paragraph. So is one whose directive does not parse: a
|
|
211
|
+
* directive holding a problem is not honoured at all, and the braces reaching
|
|
212
|
+
* the page as their own literal text is how the mistake is visible without a
|
|
213
|
+
* log.
|
|
214
|
+
*
|
|
215
|
+
* Call this **before** wikilink resolution. The rewrite consumes the `!` along
|
|
216
|
+
* with the brackets, which is what stops the link pass reading an embed's
|
|
217
|
+
* interior as an ordinary link to a note that does not exist.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} body - The note's markdown, tables already expanded.
|
|
220
|
+
* @param {object} ctx
|
|
221
|
+
* @param {object} ctx.index - The address index assets resolve through.
|
|
222
|
+
* @returns {{markdown: string, unresolved: Array<{link: string, target: string,
|
|
223
|
+
* offset: number, reason: string, type?: string}>,
|
|
224
|
+
* problems: Array<{link: string, offset: number, message: string}>,
|
|
225
|
+
* images: Array<{link: string, offset: number, pathname: string}>}} The body,
|
|
226
|
+
* the embeds that named nothing, the directives that could not be honoured,
|
|
227
|
+
* and the pathname each embed that did resolve now names. Every `offset` is
|
|
228
|
+
* 0-based in `body`, which is what lets a caller report a line and a column
|
|
229
|
+
* and tell two identical embeds apart.
|
|
230
|
+
*
|
|
231
|
+
* **`images` is how a surface holds an embed to its own rule.** The rewrite
|
|
232
|
+
* is surface-agnostic — a file the website serves and the book stages is not
|
|
233
|
+
* always one a Foundry install carries — so a caller that cares asks about
|
|
234
|
+
* the pathname while it still knows which embed produced it, rather than
|
|
235
|
+
* searching a rewritten body for a literal the note never wrote.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveEmbeds(body, { index }) {
|
|
238
|
+
const text = String(body ?? "");
|
|
239
|
+
/** @type {Array<{link: string, target: string, offset: number, reason: string, type?: string}>} */
|
|
240
|
+
const unresolved = [];
|
|
241
|
+
/** @type {Array<{link: string, offset: number, message: string}>} */
|
|
242
|
+
const problems = [];
|
|
243
|
+
/** @type {Array<{link: string, offset: number, pathname: string}>} */
|
|
244
|
+
const images = [];
|
|
245
|
+
let out = "";
|
|
246
|
+
let last = 0;
|
|
247
|
+
|
|
248
|
+
for (const embed of embedsIn(text)) {
|
|
249
|
+
problems.push(...embedProblems(embed));
|
|
250
|
+
const resolved = resolveEmbed(index, embed);
|
|
251
|
+
if (!("pathname" in resolved)) {
|
|
252
|
+
unresolved.push({
|
|
253
|
+
link: embed.all,
|
|
254
|
+
target: resolved.target,
|
|
255
|
+
offset: embed.index,
|
|
256
|
+
reason: resolved.reason,
|
|
257
|
+
...(resolved.type ? { type: resolved.type } : {}),
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (parseImageDirective(embed.directive).problems.length) continue;
|
|
262
|
+
|
|
263
|
+
out += text.slice(last, embed.index);
|
|
264
|
+
// The label is the alt text, and an empty one is deliberately
|
|
265
|
+
// decorative — {@link authoredLabel} is where that reading lives, so an
|
|
266
|
+
// embed and a link cannot draw the line in two places.
|
|
267
|
+
out += `${embed.directive}`;
|
|
268
|
+
images.push({ link: embed.all, offset: embed.index, pathname: resolved.pathname });
|
|
269
|
+
last = embed.index + embed.length;
|
|
270
|
+
}
|
|
271
|
+
return { markdown: out + text.slice(last), unresolved, problems, images };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Every defect in one note's embeds, located.
|
|
276
|
+
*
|
|
277
|
+
* {@link resolveEmbeds} with its two lists turned into the one shape a finding
|
|
278
|
+
* takes, for a caller that wants the report and not the rewrite.
|
|
279
|
+
*
|
|
280
|
+
* **Errors, not warnings**, for the reason an image's are: `reportFindings`
|
|
281
|
+
* fails on an error and not on a warning, so a refusal reported as advisory
|
|
282
|
+
* publishes anyway, looking exactly like a directive that worked.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} body - The note's markdown, without its frontmatter.
|
|
285
|
+
* @param {string} file - The note's path, for the finding.
|
|
286
|
+
* @param {object} [opts]
|
|
287
|
+
* @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
|
|
288
|
+
* @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
|
|
289
|
+
* @param {object} [opts.index] - The address index assets resolve through.
|
|
290
|
+
* @returns {Array<{file: string, line: number, column: number|undefined,
|
|
291
|
+
* severity: "error", message: string}>} One finding per defect, in source
|
|
292
|
+
* order.
|
|
293
|
+
*/
|
|
294
|
+
export function checkEmbeds(body, file, { bodyLine = 1, bodyColumn = 1, index } = {}) {
|
|
295
|
+
const text = String(body ?? "");
|
|
296
|
+
if (!text) return [];
|
|
297
|
+
|
|
298
|
+
const { unresolved, problems } = resolveEmbeds(text, { index });
|
|
299
|
+
return [
|
|
300
|
+
...unresolved.map((u) => ({ offset: u.offset, message: linkFindingMessage(u) })),
|
|
301
|
+
...problems,
|
|
302
|
+
]
|
|
303
|
+
.sort((a, b) => a.offset - b.offset)
|
|
304
|
+
.map(({ offset, message }) => {
|
|
305
|
+
const { line, column } = positionInBody(text, offset, { bodyLine, bodyColumn });
|
|
306
|
+
return {
|
|
307
|
+
file,
|
|
308
|
+
line,
|
|
309
|
+
column,
|
|
310
|
+
severity: /** @type {"error"} */ ("error"),
|
|
311
|
+
message,
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
}
|
package/engine/content-html.mjs
CHANGED
|
@@ -125,6 +125,10 @@ export function checkHtml(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
|
|
|
125
125
|
* whole: it is not a note, but a stray `.md` in the tree carrying markup is the
|
|
126
126
|
* same problem for the same reason.
|
|
127
127
|
*
|
|
128
|
+
* A finding names its file **relative to the working directory**, which is
|
|
129
|
+
* where a reader is standing and what `formatDiagnostic` emits. The content
|
|
130
|
+
* root is where the walk starts, not what a path is measured from.
|
|
131
|
+
*
|
|
128
132
|
* @param {string} contentBase - Root of the content tree.
|
|
129
133
|
* @param {object} [opts]
|
|
130
134
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
|
|
@@ -163,7 +167,7 @@ export function lintContentHtml(contentBase, { skipDirectories = [] } = {}) {
|
|
|
163
167
|
continue;
|
|
164
168
|
}
|
|
165
169
|
files += 1;
|
|
166
|
-
findings.push(...checkHtml(...bodyOf(content, path.relative(
|
|
170
|
+
findings.push(...checkHtml(...bodyOf(content, path.relative(process.cwd(), full))));
|
|
167
171
|
}
|
|
168
172
|
};
|
|
169
173
|
|
package/engine/content-icons.mjs
CHANGED
|
@@ -563,6 +563,10 @@ export function checkIconRegistry(registry, where = "icons") {
|
|
|
563
563
|
* with nothing imported between them. The cost is one extra pass over the tree,
|
|
564
564
|
* which is the cheaper half of a lint that already parses every note.
|
|
565
565
|
*
|
|
566
|
+
* A finding names its file **relative to the working directory**, which is
|
|
567
|
+
* where a reader is standing and what `formatDiagnostic` emits. The content
|
|
568
|
+
* root is where the walk starts, not what a path is measured from.
|
|
569
|
+
*
|
|
566
570
|
* @param {string} contentBase - Root of the content tree.
|
|
567
571
|
* @param {object} [opts]
|
|
568
572
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
|
|
@@ -599,7 +603,7 @@ export function lintContentIcons(contentBase, { skipDirectories = [], registry }
|
|
|
599
603
|
continue;
|
|
600
604
|
}
|
|
601
605
|
files += 1;
|
|
602
|
-
findings.push(...lintIcons(text, path.relative(
|
|
606
|
+
findings.push(...lintIcons(text, path.relative(process.cwd(), full), registry));
|
|
603
607
|
}
|
|
604
608
|
};
|
|
605
609
|
|
|
@@ -77,7 +77,7 @@ import path from "node:path";
|
|
|
77
77
|
|
|
78
78
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
79
79
|
import { positionInBody } from "./diagnostics.mjs";
|
|
80
|
-
import { pathnameProblem } from "./pathnames.mjs";
|
|
80
|
+
import { foundryAddressProblem, pathnameProblem, servesFoundry } from "./pathnames.mjs";
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* The width classes an image may carry, and what each means to a renderer.
|
|
@@ -436,11 +436,16 @@ export function imageSourcesIn(body) {
|
|
|
436
436
|
* @param {object} [opts]
|
|
437
437
|
* @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
|
|
438
438
|
* @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
|
|
439
|
+
* @param {object} [opts.config] - The resolved build configuration. Supplied,
|
|
440
|
+
* an address is also held to the one surface a pathname can be dead on
|
|
441
|
+
* without any other pass noticing — see the Foundry address below. Omitted,
|
|
442
|
+
* the config-free checks run alone, which is what lets a caller with no
|
|
443
|
+
* repository to resolve still read a body.
|
|
439
444
|
* @returns {Array<{file: string, line: number, column: number|undefined,
|
|
440
445
|
* severity: "error", message: string}>} One finding per defect, in source
|
|
441
446
|
* order.
|
|
442
447
|
*/
|
|
443
|
-
export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
|
|
448
|
+
export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config } = {}) {
|
|
444
449
|
const text = String(body ?? "");
|
|
445
450
|
if (!text) return [];
|
|
446
451
|
|
|
@@ -455,8 +460,17 @@ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
|
|
|
455
460
|
findings.push({ file, line, column, severity: /** @type {"error"} */ ("error"), message });
|
|
456
461
|
};
|
|
457
462
|
|
|
463
|
+
// The Foundry address is the one form a pathname can lack while every other
|
|
464
|
+
// surface resolves it, and the renderer that hands a journal its markup has
|
|
465
|
+
// no channel to say so — so it is asked here, where a line and a column are
|
|
466
|
+
// at hand. A build that installs nothing in Foundry has no such surface and
|
|
467
|
+
// is not asked.
|
|
468
|
+
const foundry = config && servesFoundry(config);
|
|
458
469
|
for (const image of imagesIn(text)) {
|
|
459
|
-
const problem =
|
|
470
|
+
const problem =
|
|
471
|
+
imageSourceProblem(image.src) ||
|
|
472
|
+
pathnameProblem(image.src) ||
|
|
473
|
+
(foundry ? foundryAddressProblem(image.src, config) : "");
|
|
460
474
|
if (problem) report(image.index, problem);
|
|
461
475
|
if (image.title) {
|
|
462
476
|
report(
|
|
@@ -521,15 +535,21 @@ function bodyOf(content, file) {
|
|
|
521
535
|
* Its own walk, like the icon and HTML checks beside it, so all three stay
|
|
522
536
|
* leaves with nothing imported between them.
|
|
523
537
|
*
|
|
538
|
+
* A finding names its file **relative to the working directory**, which is
|
|
539
|
+
* where a reader is standing and what `formatDiagnostic` emits. The content
|
|
540
|
+
* root is where the walk starts, not what a path is measured from.
|
|
541
|
+
*
|
|
524
542
|
* @param {string} contentBase - Root of the content tree.
|
|
525
543
|
* @param {object} [opts]
|
|
526
544
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
|
|
527
545
|
* in addition to the dot-directories always skipped.
|
|
546
|
+
* @param {object} [opts.config] - The resolved build configuration, passed to
|
|
547
|
+
* {@link checkImages} so an address is held to the Foundry surface too.
|
|
528
548
|
* @returns {{findings: Array<{file: string, line: number, column: number|undefined,
|
|
529
549
|
* severity: "error", message: string}>, files: number}} The findings, and how
|
|
530
550
|
* many files were read.
|
|
531
551
|
*/
|
|
532
|
-
export function lintContentImages(contentBase, { skipDirectories = [] } = {}) {
|
|
552
|
+
export function lintContentImages(contentBase, { skipDirectories = [], config } = {}) {
|
|
533
553
|
const skip = new Set(skipDirectories);
|
|
534
554
|
/** @type {Array<{file: string, line: number, column: number|undefined, severity: "error", message: string}>} */
|
|
535
555
|
const findings = [];
|
|
@@ -559,7 +579,8 @@ export function lintContentImages(contentBase, { skipDirectories = [] } = {}) {
|
|
|
559
579
|
continue;
|
|
560
580
|
}
|
|
561
581
|
files += 1;
|
|
562
|
-
|
|
582
|
+
const [body, rel, at] = bodyOf(content, path.relative(process.cwd(), full));
|
|
583
|
+
findings.push(...checkImages(body, rel, { ...at, config }));
|
|
563
584
|
}
|
|
564
585
|
};
|
|
565
586
|
|