@heroiclands/package-build 6.0.0 → 7.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 +798 -0
- package/CONTENT.md +228 -4
- package/bin/content-build.mjs +196 -10
- package/bin/package-build.mjs +8 -1
- package/config.mjs +25 -3
- package/content-config.mjs +283 -29
- package/engine/address-diff.mjs +290 -0
- package/engine/base-compiler.mjs +25 -0
- package/engine/content-links.mjs +132 -27
- package/engine/content-lint.mjs +23 -2
- package/engine/diagnostics.mjs +61 -1
- package/engine/frontmatter-lint.mjs +22 -0
- package/engine/generate.mjs +10 -5
- package/engine/helpers.mjs +38 -0
- package/engine/homepage.mjs +206 -2
- package/engine/journals.mjs +8 -1
- package/engine/macros.mjs +2 -0
- package/engine/pack-config.mjs +143 -13
- package/engine/prose-lint.mjs +10 -2
- package/engine/scenes.mjs +2 -2
- package/engine/site-build.mjs +74 -19
- package/engine/web-wikilinks.mjs +13 -4
- package/engine/wikilink-syntax.mjs +25 -0
- package/engine/wikilinks.mjs +6 -3
- package/manifest.mjs +37 -2
- package/package.json +5 -3
- package/sohl/actors.mjs +23 -10
- package/sohl/items.mjs +1 -1
- package/types/content-config.d.mts +14 -0
- package/types/engine/address-diff.d.mts +108 -0
- package/types/engine/base-compiler.d.mts +18 -1
- package/types/engine/content-links.d.mts +11 -3
- package/types/engine/content-lint.d.mts +4 -1
- package/types/engine/diagnostics.d.mts +33 -1
- package/types/engine/generate.d.mts +3 -2
- package/types/engine/helpers.d.mts +29 -3
- package/types/engine/homepage.d.mts +117 -0
- package/types/engine/journals.d.mts +7 -1
- package/types/engine/pack-config.d.mts +22 -0
- package/types/engine/prose-lint.d.mts +10 -2
- package/types/engine/site-build.d.mts +28 -3
- package/types/engine/wikilink-syntax.d.mts +24 -0
- package/types/sohl/actors.d.mts +3 -3
package/engine/pack-config.mjs
CHANGED
|
@@ -80,7 +80,12 @@ import path from "node:path";
|
|
|
80
80
|
import { createRequire } from "node:module";
|
|
81
81
|
import YAML from "yaml";
|
|
82
82
|
|
|
83
|
-
import { defineConfig } from "../content-config.mjs";
|
|
83
|
+
import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
|
|
84
|
+
import {
|
|
85
|
+
formatDiagnostic,
|
|
86
|
+
positionOfYamlPath,
|
|
87
|
+
yamlKeyPath,
|
|
88
|
+
} from "./diagnostics.mjs";
|
|
84
89
|
|
|
85
90
|
/** The stem every consuming repository declares its build under. */
|
|
86
91
|
export const CONFIG_BASENAME = "package-build.config";
|
|
@@ -276,6 +281,33 @@ function shippedSystemVersion(rootDir, input) {
|
|
|
276
281
|
input.relationships ?? {}
|
|
277
282
|
).systems;
|
|
278
283
|
|
|
284
|
+
// The `systems:` block declares without requiring (#48), so it is consulted
|
|
285
|
+
// first: a package that has adopted it needs no relationship, and one that
|
|
286
|
+
// ships for two systems could not express itself through a relationship at
|
|
287
|
+
// all. `requiresSystem` names the package-wide default when there is one;
|
|
288
|
+
// otherwise a single declared system is unambiguous. With several and no
|
|
289
|
+
// gate, there is no package-wide answer — each pack carries its own, and
|
|
290
|
+
// {@link statsForPack} is what reads it.
|
|
291
|
+
const systemsBlock =
|
|
292
|
+
/** @type {Record<string, {compatibility?: {verified?: string}}>} */ (
|
|
293
|
+
input.systems ?? {}
|
|
294
|
+
);
|
|
295
|
+
const systemIds = Object.keys(systemsBlock);
|
|
296
|
+
if (systemIds.length) {
|
|
297
|
+
const chosen =
|
|
298
|
+
typeof input.requiresSystem === "string" ? input.requiresSystem
|
|
299
|
+
: systemIds.length === 1 ? systemIds[0]
|
|
300
|
+
: null;
|
|
301
|
+
if (chosen) {
|
|
302
|
+
const verified = systemsBlock[chosen]?.compatibility?.verified;
|
|
303
|
+
if (typeof verified === "string" && verified.length)
|
|
304
|
+
return verified;
|
|
305
|
+
}
|
|
306
|
+
// Several declared and none required: the package-wide value is
|
|
307
|
+
// deliberately absent rather than one of them picked arbitrarily.
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
279
311
|
// A module that names neither a system nor a relationship with one is
|
|
280
312
|
// system-agnostic on purpose: its packs are core document types carrying no
|
|
281
313
|
// system data, and it installs under any system. There is no version to
|
|
@@ -314,6 +346,95 @@ function shippedSystemVersion(rootDir, input) {
|
|
|
314
346
|
return verified;
|
|
315
347
|
}
|
|
316
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Where in the configuration file a dotted field path was written.
|
|
351
|
+
*
|
|
352
|
+
* Only a YAML configuration has text to resolve a path against. An `.mjs` one
|
|
353
|
+
* is deliberately not searched: JavaScript source fed to a YAML parser is not
|
|
354
|
+
* an error — it parses as *something*, and a path could resolve to a line that
|
|
355
|
+
* has nothing to do with the key. A position that is wrong is worse than none,
|
|
356
|
+
* so the extension decides.
|
|
357
|
+
*
|
|
358
|
+
* A path that names a key the file never declared — a missing required one —
|
|
359
|
+
* has no node of its own. The position then names the **mapping it belongs
|
|
360
|
+
* in**, one level up and no further: that entry is a real node, and it is the
|
|
361
|
+
* one the reader has to edit. Walking further would drift away from the key
|
|
362
|
+
* with each step, so a top-level key that is simply absent gets no position at
|
|
363
|
+
* all.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} configPath - Absolute path of the configuration file.
|
|
366
|
+
* @param {string} field - The dotted path the diagnostic names.
|
|
367
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields,
|
|
368
|
+
* empty when nothing can be established honestly.
|
|
369
|
+
*/
|
|
370
|
+
function positionInConfig(configPath, field) {
|
|
371
|
+
if (!/\.ya?ml$/i.test(configPath)) return {};
|
|
372
|
+
|
|
373
|
+
let text;
|
|
374
|
+
try {
|
|
375
|
+
text = fs.readFileSync(configPath, "utf8");
|
|
376
|
+
} catch {
|
|
377
|
+
return {};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const keyPath = yamlKeyPath(field);
|
|
381
|
+
if (keyPath.length === 0) return {};
|
|
382
|
+
|
|
383
|
+
const declared = positionOfYamlPath(text, keyPath, { key: true });
|
|
384
|
+
if (declared.line !== undefined) return declared;
|
|
385
|
+
if (keyPath.length === 1) return {};
|
|
386
|
+
return positionOfYamlPath(text, keyPath.slice(0, -1), { key: true });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Attach the position of the key a configuration error names.
|
|
391
|
+
*
|
|
392
|
+
* Eighty-one checks across `content-config.mjs` and `config.mjs` report through
|
|
393
|
+
* one `fail()`, which knows the offending key's dotted path and nothing
|
|
394
|
+
* about where it was written. Locating one of them and not the rest would be
|
|
395
|
+
* worse than locating none — a reader would learn that some configuration
|
|
396
|
+
* errors carry a position and could not predict which — so the path rides on
|
|
397
|
+
* the error and every one of them is located here, at the boundary that knows
|
|
398
|
+
* which file was read (#95).
|
|
399
|
+
*
|
|
400
|
+
* The message keeps its body and gains the `file:line:column: error: ` prefix
|
|
401
|
+
* every other finding in this build already uses, so nothing a reader has today
|
|
402
|
+
* is lost. `located` marks it done, so an error crossing two boundaries is
|
|
403
|
+
* decorated once; the fields are also left on the error, for a caller that
|
|
404
|
+
* wants to re-render it.
|
|
405
|
+
*
|
|
406
|
+
* @param {unknown} err - What was thrown.
|
|
407
|
+
* @param {string} [configPath] - The configuration file that was read.
|
|
408
|
+
* @returns {unknown} The same error, decorated when it named a field.
|
|
409
|
+
*/
|
|
410
|
+
export function locateConfigError(err, configPath) {
|
|
411
|
+
const failure =
|
|
412
|
+
/** @type {{field?: unknown, located?: boolean, message?: string, file?: string, line?: number, column?: number}} */ (
|
|
413
|
+
err
|
|
414
|
+
);
|
|
415
|
+
if (!(err instanceof Error)) return err;
|
|
416
|
+
if (
|
|
417
|
+
failure.located ||
|
|
418
|
+
typeof failure.field !== "string" ||
|
|
419
|
+
!failure.field
|
|
420
|
+
) {
|
|
421
|
+
return err;
|
|
422
|
+
}
|
|
423
|
+
if (!configPath) return err;
|
|
424
|
+
|
|
425
|
+
const at = {
|
|
426
|
+
file: configPath,
|
|
427
|
+
...positionInConfig(configPath, failure.field),
|
|
428
|
+
};
|
|
429
|
+
Object.assign(failure, at, { located: true });
|
|
430
|
+
failure.message = formatDiagnostic({
|
|
431
|
+
...at,
|
|
432
|
+
severity: "error",
|
|
433
|
+
message: /** @type {string} */ (failure.message),
|
|
434
|
+
});
|
|
435
|
+
return err;
|
|
436
|
+
}
|
|
437
|
+
|
|
317
438
|
/**
|
|
318
439
|
* Turn a parsed YAML configuration into the frozen one the engine reads.
|
|
319
440
|
*
|
|
@@ -400,23 +521,28 @@ export function configFromData(data, configPath) {
|
|
|
400
521
|
const stats = input.stats;
|
|
401
522
|
if (stats !== null && typeof stats === "object" && !Array.isArray(stats)) {
|
|
402
523
|
const declared = /** @type {Record<string, unknown>} */ (stats);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
524
|
+
// `stats.systemId` and `stats.systemVersion` are both refused by
|
|
525
|
+
// `defineConfig`, which reports them with a locator — so nothing is
|
|
526
|
+
// rejected here. This half only supplies the value the validator cannot
|
|
527
|
+
// compute: resolving a system package's version means reading the
|
|
528
|
+
// adjacent `package.json`, and `defineConfig` performs no I/O.
|
|
529
|
+
//
|
|
530
|
+
// Passed under a symbol so the channel is not a second, forgeable
|
|
531
|
+
// spelling of the key that was just refused (see
|
|
532
|
+
// {@link DERIVED_SYSTEM_VERSION}).
|
|
413
533
|
input.stats = {
|
|
414
534
|
...declared,
|
|
415
|
-
|
|
535
|
+
[DERIVED_SYSTEM_VERSION]: shippedSystemVersion(rootDir, input),
|
|
416
536
|
};
|
|
417
537
|
}
|
|
418
538
|
|
|
419
|
-
|
|
539
|
+
try {
|
|
540
|
+
return defineConfig(/** @type {never} */ (input));
|
|
541
|
+
} catch (err) {
|
|
542
|
+
// Every `fail()` in the validator names a key and knows nothing about
|
|
543
|
+
// the file; this is where the two meet.
|
|
544
|
+
throw locateConfigError(err, configPath);
|
|
545
|
+
}
|
|
420
546
|
}
|
|
421
547
|
|
|
422
548
|
/**
|
|
@@ -432,6 +558,10 @@ function loadCodeConfig(configPath) {
|
|
|
432
558
|
try {
|
|
433
559
|
module = require(configPath);
|
|
434
560
|
} catch (err) {
|
|
561
|
+
// A code configuration calls `defineConfig` itself, so its rejections
|
|
562
|
+
// arrive here. There is no YAML to locate into, but the file is known —
|
|
563
|
+
// `locateConfigError` names it and drops the line.
|
|
564
|
+
locateConfigError(err, configPath);
|
|
435
565
|
if (
|
|
436
566
|
/** @type {{ code?: string }} */ (err)?.code ===
|
|
437
567
|
"ERR_REQUIRE_ASYNC_MODULE"
|
package/engine/prose-lint.mjs
CHANGED
|
@@ -240,8 +240,16 @@ function toDiagnostic(directory, result) {
|
|
|
240
240
|
*
|
|
241
241
|
* {@link MARKDOWNLINT_CONFIG} is passed as markdownlint's `optionsDefault`,
|
|
242
242
|
* which is precisely the "shipped default, consumer overrides" behaviour the
|
|
243
|
-
* command promises: a
|
|
244
|
-
* and a
|
|
243
|
+
* command promises: a repository with no configuration of its own gets these
|
|
244
|
+
* rules, and a `.markdownlint-cli2.jsonc` found in the tree overrides them.
|
|
245
|
+
*
|
|
246
|
+
* The override is **key by key, and each key wholesale** — which is not the same
|
|
247
|
+
* as "replaces it", and the difference is the one worth stating. A consumer file
|
|
248
|
+
* declaring only `ignores` keeps this rule set intact, including `default: false`
|
|
249
|
+
* and every per-rule option; but its `ignores` *replaces*
|
|
250
|
+
* {@link MARKDOWN_IGNORES} rather than extending it, so such a file must restate
|
|
251
|
+
* every shared entry it still wants. Omitting `CHANGELOG.md` there silently
|
|
252
|
+
* starts linting a generated file.
|
|
245
253
|
*
|
|
246
254
|
* @param {string} root - Repository to lint.
|
|
247
255
|
* @param {object} [opts]
|
package/engine/scenes.mjs
CHANGED
|
@@ -414,7 +414,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
414
414
|
packageId: foundryPackageId(),
|
|
415
415
|
name,
|
|
416
416
|
folder,
|
|
417
|
-
stats:
|
|
417
|
+
stats: this.stats,
|
|
418
418
|
journalEntryId: entryId,
|
|
419
419
|
// A map note's prose is a derived JournalEntry: it lands in the
|
|
420
420
|
// default JournalEntry pack, not in whichever Scene pack the map
|
|
@@ -521,7 +521,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
521
521
|
sort: 0,
|
|
522
522
|
flags: {},
|
|
523
523
|
_id: id,
|
|
524
|
-
_stats:
|
|
524
|
+
_stats: this.stats,
|
|
525
525
|
_key: `!adventures!${id}`,
|
|
526
526
|
};
|
|
527
527
|
}
|
package/engine/site-build.mjs
CHANGED
|
@@ -65,6 +65,7 @@ import { loadPackConfig } from "./pack-config.mjs";
|
|
|
65
65
|
import { searchableFrontmatter } from "./note-package.mjs";
|
|
66
66
|
import {
|
|
67
67
|
HOMEPAGE_DESTINATION,
|
|
68
|
+
checkHomepageCount,
|
|
68
69
|
homepageFrontmatter,
|
|
69
70
|
homepageTitle,
|
|
70
71
|
isHomepage,
|
|
@@ -269,9 +270,9 @@ export function collectTreePages(tree, ctx) {
|
|
|
269
270
|
* packages ship under is a property of the code path rather than of a
|
|
270
271
|
* configuration that happens to be empty (#55).
|
|
271
272
|
*
|
|
272
|
-
* Returned as a list rather than as the one note there should be
|
|
273
|
-
*
|
|
274
|
-
* found
|
|
273
|
+
* Returned as a list rather than as the one note there should be, because the
|
|
274
|
+
* count is what {@link checkHomepageCount} judges (#52) — this walk reports
|
|
275
|
+
* what it found, and {@link buildSite} decides whether that is one.
|
|
275
276
|
*
|
|
276
277
|
* @param {string} contentBase - Absolute path to the content tree.
|
|
277
278
|
* @param {object} ctx - `{ skipDirectories }`.
|
|
@@ -345,6 +346,10 @@ export function writeHomepages(outRoot, pages, config) {
|
|
|
345
346
|
*/
|
|
346
347
|
export function siteGates(pages, findings, { manifestDir }) {
|
|
347
348
|
const out = {
|
|
349
|
+
// Always empty here: the homepage count is decided in `buildSite`
|
|
350
|
+
// before the content walk, and a failing count returns without ever
|
|
351
|
+
// reaching these gates (#52). Present so every caller reads one shape.
|
|
352
|
+
homepages: [],
|
|
348
353
|
frontmatterLinks: findings.fmLinkFindings ?? [],
|
|
349
354
|
slugErrors: findings.slugFindings ?? [],
|
|
350
355
|
collisions: [],
|
|
@@ -403,6 +408,7 @@ export function siteGates(pages, findings, { manifestDir }) {
|
|
|
403
408
|
*/
|
|
404
409
|
export function emptyGates() {
|
|
405
410
|
return {
|
|
411
|
+
homepages: [],
|
|
406
412
|
frontmatterLinks: [],
|
|
407
413
|
slugErrors: [],
|
|
408
414
|
collisions: [],
|
|
@@ -418,6 +424,7 @@ export function emptyGates() {
|
|
|
418
424
|
/** Whether any gate produced a finding. */
|
|
419
425
|
export function gatesFailed(gates) {
|
|
420
426
|
return Boolean(
|
|
427
|
+
gates.homepages.length ||
|
|
421
428
|
gates.frontmatterLinks.length ||
|
|
422
429
|
gates.slugErrors.length ||
|
|
423
430
|
gates.collisions.length ||
|
|
@@ -455,6 +462,39 @@ export function tableUniverse(pages) {
|
|
|
455
462
|
return byPackage;
|
|
456
463
|
}
|
|
457
464
|
|
|
465
|
+
/**
|
|
466
|
+
* The front matter a section's landing states about itself.
|
|
467
|
+
*
|
|
468
|
+
* The section metadata a configuration resolved, ready to be written or merged
|
|
469
|
+
* onto a page. Two things happen here and nothing else does:
|
|
470
|
+
*
|
|
471
|
+
* - **`title` leads.** It is the one key every landing has carried since the
|
|
472
|
+
* first one, and a landing whose block opened with `banner:` would be a
|
|
473
|
+
* gratuitous diff on every consumer's tree.
|
|
474
|
+
* - **An absent value is left off**, not written as `undefined` — which is not
|
|
475
|
+
* a value YAML can carry, and would abort the serializer.
|
|
476
|
+
*
|
|
477
|
+
* Everything else the section declared is passed through. That is the point of
|
|
478
|
+
* the function: before #91 both writers transcribed `title` and `banner` by
|
|
479
|
+
* name, so the vocabulary lived in three places — the schema that admits a key
|
|
480
|
+
* and the two writers that copy it — and a key added to the schema alone
|
|
481
|
+
* validated cleanly and then reached no page. The *schema* is the bound worth
|
|
482
|
+
* keeping (see `normalizeSectionMeta`, which refuses a key it does not know and
|
|
483
|
+
* names it); a second, silent bound in the writers is not.
|
|
484
|
+
*
|
|
485
|
+
* @param {object} meta - A resolved `site.sections` / `site.readmeSections`
|
|
486
|
+
* entry.
|
|
487
|
+
* @returns {object} Its front matter, `title` first.
|
|
488
|
+
*/
|
|
489
|
+
export function sectionFrontmatter(meta) {
|
|
490
|
+
const data = { title: meta.title };
|
|
491
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
492
|
+
if (key === "title" || value === undefined) continue;
|
|
493
|
+
data[key] = value;
|
|
494
|
+
}
|
|
495
|
+
return data;
|
|
496
|
+
}
|
|
497
|
+
|
|
458
498
|
/**
|
|
459
499
|
* The frontmatter a page publishes with.
|
|
460
500
|
*
|
|
@@ -494,12 +534,11 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
494
534
|
if (decorate) decorate(data, page);
|
|
495
535
|
if (isReadme) {
|
|
496
536
|
const meta = readmeSections[sec];
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
537
|
+
// What the section says about itself wins over what its README
|
|
538
|
+
// happens to carry — the landing has to match the card linking to
|
|
539
|
+
// it. Assigned rather than transcribed key by key, so a section's
|
|
540
|
+
// vocabulary is decided in one place (#91).
|
|
541
|
+
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
503
542
|
}
|
|
504
543
|
} else {
|
|
505
544
|
// A tree's own landing describes the *mount*, and nothing beneath it. A
|
|
@@ -509,7 +548,7 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
509
548
|
const isSectionRoot = path.posix.dirname(page.rel) === ".";
|
|
510
549
|
const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
|
|
511
550
|
data = { ...fm, title: meta?.title ?? fm.title ?? name };
|
|
512
|
-
if (meta
|
|
551
|
+
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
513
552
|
}
|
|
514
553
|
delete data.aliases;
|
|
515
554
|
return data;
|
|
@@ -660,15 +699,11 @@ export function writeSectionLandings(
|
|
|
660
699
|
for (const [sec, meta] of Object.entries(sections)) {
|
|
661
700
|
const dir = path.join(outRoot, sec);
|
|
662
701
|
fs.mkdirSync(dir, { recursive: true });
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// can carry, so the key is left off entirely.
|
|
702
|
+
// Whatever the section declared, not a list of keys named here — see
|
|
703
|
+
// {@link sectionFrontmatter} for why the two lists were one too many.
|
|
666
704
|
fs.writeFileSync(
|
|
667
705
|
path.join(dir, "_index.md"),
|
|
668
|
-
matter.stringify("",
|
|
669
|
-
title: meta.title,
|
|
670
|
-
...(meta.banner ? { banner: meta.banner } : {}),
|
|
671
|
-
}),
|
|
706
|
+
matter.stringify("", sectionFrontmatter(meta)),
|
|
672
707
|
);
|
|
673
708
|
written += 1;
|
|
674
709
|
}
|
|
@@ -848,12 +883,32 @@ export function buildSite({ config, outRoot } = {}) {
|
|
|
848
883
|
scheme,
|
|
849
884
|
};
|
|
850
885
|
|
|
886
|
+
const homepages = collectHomepages(resolved.paths.content, ctx).pages;
|
|
887
|
+
|
|
888
|
+
// Exactly one homepage, and checked here — before the output tree is
|
|
889
|
+
// cleared and before either mode branches (#52). Before the clear, because
|
|
890
|
+
// a gate that fired after it would have destroyed a good site to report a
|
|
891
|
+
// bad tree. Before the branch, because the requirement does not vary by
|
|
892
|
+
// mode: `publish.site` chooses whether the *content* surfaces are
|
|
893
|
+
// published, and the homepage is the floor beneath both.
|
|
894
|
+
const homepageFindings = checkHomepageCount(homepages, {
|
|
895
|
+
contentBase: resolved.paths.content,
|
|
896
|
+
contentPackage: resolved.contentPackage,
|
|
897
|
+
});
|
|
898
|
+
if (homepageFindings.length) {
|
|
899
|
+
return {
|
|
900
|
+
gates: { ...emptyGates(), homepages: homepageFindings },
|
|
901
|
+
manifests: null,
|
|
902
|
+
tableErrors: [],
|
|
903
|
+
wikiErrors: [],
|
|
904
|
+
stats: null,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
851
908
|
// The whole tree is a build artifact, regenerated every run: a page whose
|
|
852
909
|
// note was deleted or renamed would otherwise linger and keep publishing.
|
|
853
910
|
fs.rmSync(outBase, { recursive: true, force: true });
|
|
854
911
|
|
|
855
|
-
const homepages = collectHomepages(resolved.paths.content, ctx).pages;
|
|
856
|
-
|
|
857
912
|
// Homepage-only stops here, and stopping is the point: nothing below reads
|
|
858
913
|
// the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
|
|
859
914
|
// cannot publish one whatever else their `site:` block declares (#55).
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -46,7 +46,12 @@ import { slugify } from "./content-slug.mjs";
|
|
|
46
46
|
// Re-exported so a site build keeps one import path for the whole of link
|
|
47
47
|
// resolution: the same rule that names a page also names an anchor within it.
|
|
48
48
|
export { slugify };
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
authoredLabel,
|
|
51
|
+
WIKILINK,
|
|
52
|
+
isSamePage,
|
|
53
|
+
parseWikilink,
|
|
54
|
+
} from "./wikilink-syntax.mjs";
|
|
50
55
|
|
|
51
56
|
/** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
|
|
52
57
|
|
|
@@ -240,10 +245,14 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
240
245
|
// inline span is source text, not a link (#1505).
|
|
241
246
|
return replaceOutsideCode(body, WIKILINK, (_m, rawInner) => {
|
|
242
247
|
const { target, anchor, display } = parseWikilink(rawInner);
|
|
248
|
+
// An empty label is not a label: `[[x|]]` addresses the target and
|
|
249
|
+
// shows its name, so `""` falls through to the same place `null` does
|
|
250
|
+
// (#113). One reading, from {@link authoredLabel}.
|
|
251
|
+
const label = authoredLabel({ display });
|
|
243
252
|
|
|
244
253
|
// `[[#section-slug|Text]]` — a section of this same page.
|
|
245
254
|
if (isSamePage({ target, anchor })) {
|
|
246
|
-
return `[${
|
|
255
|
+
return `[${label ?? anchor}](#${slugify(anchor)})`;
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
const key = target.toLowerCase();
|
|
@@ -272,7 +281,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
272
281
|
// (#1398), and a hyphen inside a note *name* ("Grukar-ahk") is not
|
|
273
282
|
// one, which is why the rule is the packs' own (#1409).
|
|
274
283
|
const text =
|
|
275
|
-
|
|
284
|
+
label ??
|
|
276
285
|
(isAddress(target, ctx.contentTypes) ? hit.name : target);
|
|
277
286
|
// A pack-only package publishes Foundry addresses and no pages
|
|
278
287
|
// (#1516), so its entries carry no `path` and resolve to no URL.
|
|
@@ -325,6 +334,6 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
325
334
|
reason: "broken type/shortcode",
|
|
326
335
|
});
|
|
327
336
|
}
|
|
328
|
-
return unresolvedLink(
|
|
337
|
+
return unresolvedLink(label ?? target, target);
|
|
329
338
|
});
|
|
330
339
|
}
|
|
@@ -91,6 +91,31 @@ export function parseWikilink(rawInner) {
|
|
|
91
91
|
return { inner: inner.trim(), target, anchor, display, labelled };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The label an author actually supplied, or `null` when they supplied none.
|
|
96
|
+
*
|
|
97
|
+
* **An empty label is not a label.** `[[x|]]` is deliberately writable — it
|
|
98
|
+
* means "address this target, and show the target's own name" — so `display:
|
|
99
|
+
* ""` has to read as *absent* everywhere a fallback is chosen, exactly as
|
|
100
|
+
* `display: null` does. The two are still distinguishable through
|
|
101
|
+
* {@link ParsedWikilink.labelled}, which is the thing that genuinely differs
|
|
102
|
+
* and which #1409 depends on.
|
|
103
|
+
*
|
|
104
|
+
* Stated here because the two resolvers had already drawn the line in two
|
|
105
|
+
* places and drawn it differently: the packs tested falsiness and were right,
|
|
106
|
+
* the web tested `??` — which falls through on `null` only — and emitted
|
|
107
|
+
* `[](/url/)`, a link with no clickable text, through every build (#113). That
|
|
108
|
+
* is the same drift this module exists to prevent, in the case its own
|
|
109
|
+
* {@link ParsedWikilink} docstring calls out. One reading, one place.
|
|
110
|
+
*
|
|
111
|
+
* @param {{display: string|null}} parsed - A parsed wikilink, or anything
|
|
112
|
+
* carrying its `display`.
|
|
113
|
+
* @returns {string|null} The label, or `null` when there is none to show.
|
|
114
|
+
*/
|
|
115
|
+
export function authoredLabel({ display }) {
|
|
116
|
+
return display ? display : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
/**
|
|
95
120
|
* Whether a parsed link addresses a section of the page it is written on.
|
|
96
121
|
*
|
package/engine/wikilinks.mjs
CHANGED
|
@@ -93,7 +93,7 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
|
93
93
|
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
94
94
|
// The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
|
|
95
95
|
// one cannot disagree about what counts as a link.
|
|
96
|
-
import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
|
|
96
|
+
import { authoredLabel, WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
|
|
97
97
|
|
|
98
98
|
export { ITEM_PACK, PACK_BY_TYPE, packForType };
|
|
99
99
|
|
|
@@ -498,8 +498,11 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
498
498
|
const { labelled } = parsed;
|
|
499
499
|
let target = parsed.target;
|
|
500
500
|
// An unlabelled link shows its interior verbatim, anchor included;
|
|
501
|
-
// a labelled one shows its label.
|
|
502
|
-
|
|
501
|
+
// a labelled one shows its label. An *empty* label is not a label
|
|
502
|
+
// — `[[x|]]` means "show the target's name" — and that reading
|
|
503
|
+
// comes from {@link authoredLabel} so the web resolver cannot draw
|
|
504
|
+
// the line somewhere else (#113).
|
|
505
|
+
let text = labelled ? (authoredLabel(parsed) ?? "") : parsed.inner;
|
|
503
506
|
const slug = parsed.anchor || null;
|
|
504
507
|
|
|
505
508
|
// Resolve the document: same-page (empty target), type-shortcode, or alias.
|
package/manifest.mjs
CHANGED
|
@@ -430,8 +430,43 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
|
|
|
430
430
|
...releaseUrls({ repoUrl, version: packageJson.version, artifact }),
|
|
431
431
|
};
|
|
432
432
|
if (config.compatibility) derived.compatibility = config.compatibility;
|
|
433
|
-
|
|
434
|
-
|
|
433
|
+
|
|
434
|
+
// `requiresSystem` is the gate half of the declare/require split (#48). It
|
|
435
|
+
// emits the `relationships.systems` entry Foundry's `supportsSystem` reads,
|
|
436
|
+
// reusing the `systems:` declaration rather than restating it — a second
|
|
437
|
+
// transcription is free to disagree with what it copied, which is how
|
|
438
|
+
// `stats.systemVersion` came to sit at `0.6.0` for four releases.
|
|
439
|
+
//
|
|
440
|
+
// Declaring a system emits nothing on its own. That is the point: a module
|
|
441
|
+
// shipping content for two systems names both under `systems:`, stamps each
|
|
442
|
+
// pack accordingly, and stays loadable everywhere because it requires
|
|
443
|
+
// neither.
|
|
444
|
+
const relationships = { ...(config.relationships ?? {}) };
|
|
445
|
+
if (config.requiresSystem) {
|
|
446
|
+
const declaredSystem = config.systems?.[config.requiresSystem];
|
|
447
|
+
const entry = {
|
|
448
|
+
id: config.requiresSystem,
|
|
449
|
+
type: "system",
|
|
450
|
+
...(declaredSystem?.manifest ?
|
|
451
|
+
{ manifest: declaredSystem.manifest }
|
|
452
|
+
: {}),
|
|
453
|
+
...(declaredSystem?.compatibility ?
|
|
454
|
+
{
|
|
455
|
+
compatibility: Object.fromEntries(
|
|
456
|
+
Object.entries(declaredSystem.compatibility).filter(
|
|
457
|
+
([, v]) => v != null,
|
|
458
|
+
),
|
|
459
|
+
),
|
|
460
|
+
}
|
|
461
|
+
: {}),
|
|
462
|
+
};
|
|
463
|
+
// An explicit `relationships.systems` still wins, so a repository
|
|
464
|
+
// mid-migration is never told two different things about itself.
|
|
465
|
+
relationships.systems =
|
|
466
|
+
relationships.systems?.length ? relationships.systems : [entry];
|
|
467
|
+
}
|
|
468
|
+
if (Object.keys(relationships).length) {
|
|
469
|
+
derived.relationships = publishedRelationships(relationships);
|
|
435
470
|
}
|
|
436
471
|
|
|
437
472
|
const merged = { ...declared, ...derived };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -122,12 +122,12 @@
|
|
|
122
122
|
"classic-level": "^3.0.0",
|
|
123
123
|
"dotenv": "^17.2.3",
|
|
124
124
|
"fflate": "^0.8.3",
|
|
125
|
-
"glob": "^
|
|
125
|
+
"glob": "^13.0.6",
|
|
126
126
|
"gray-matter": "^4.0.3",
|
|
127
127
|
"handlebars": "^4.7.9",
|
|
128
128
|
"loglevel": "^1.9.2",
|
|
129
129
|
"loglevel-plugin-prefix": "^0.8.4",
|
|
130
|
-
"markdown-it": "^
|
|
130
|
+
"markdown-it": "^15.0.0",
|
|
131
131
|
"markdownlint-cli2": "^0.23.2",
|
|
132
132
|
"prettier": "^3.9.6",
|
|
133
133
|
"ssh2-sftp-client": "^12.1.1",
|
|
@@ -148,7 +148,9 @@
|
|
|
148
148
|
"build:types": "tsc -p tsconfig.dts.json",
|
|
149
149
|
"format": "prettier --write .",
|
|
150
150
|
"format:check": "prettier --check .",
|
|
151
|
+
"lint": "npm run format:check && npm run lint:markdown",
|
|
151
152
|
"lint:markdown": "node bin/content-build.mjs markdown",
|
|
153
|
+
"lint:markdown:fix": "node bin/content-build.mjs markdown --fix",
|
|
152
154
|
"changeset": "changeset",
|
|
153
155
|
"changeset:check": "changeset status --since=origin/main",
|
|
154
156
|
"changeset:version": "changeset version && npm install --package-lock-only",
|
package/sohl/actors.mjs
CHANGED
|
@@ -327,20 +327,33 @@ export class Actors extends BasePackCompiler {
|
|
|
327
327
|
itemsSourceDirs;
|
|
328
328
|
foreignSourceDirs;
|
|
329
329
|
|
|
330
|
-
constructor({ itemsSourceDirs, foreignSourceDirs = [], ...options }) {
|
|
330
|
+
constructor({ itemsSourceDirs = [], foreignSourceDirs = [], ...options }) {
|
|
331
331
|
super(options);
|
|
332
332
|
// Where the items passes wrote their JSON. Stated by the caller rather
|
|
333
333
|
// than assumed to be this pack's sibling: the packs' locations are
|
|
334
334
|
// configuration, and a consumer may put them anywhere (#1508). Every
|
|
335
335
|
// Item pack, because a repository may ship more than one (#1566).
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
336
|
+
//
|
|
337
|
+
// **Optional, and empty is a legitimate package (#49).** This used to
|
|
338
|
+
// throw unless at least one Item pack was declared, which asked a
|
|
339
|
+
// package to declare the very thing it may exist not to have. An Item
|
|
340
|
+
// pack is system-bound by construction — Foundry requires `system` on
|
|
341
|
+
// Item packs — so a deliberately system-agnostic module could satisfy
|
|
342
|
+
// the guard only by naming a system. `harn-ensemble` is the case:
|
|
343
|
+
// 2,512 beings whose embedded items address the `sohl` and `hm3`
|
|
344
|
+
// catalogues, and five affiliation notes of its own.
|
|
345
|
+
//
|
|
346
|
+
// The guard also did not test what it claimed. It counted *declared
|
|
347
|
+
// directories*, not resolvable items, so an empty Item pack satisfied
|
|
348
|
+
// it while a being naming a missing item still failed later. The
|
|
349
|
+
// condition actually cared about is checked where it can be reported
|
|
350
|
+
// precisely: {@link Actors#resolveEmbedded} already errors per
|
|
351
|
+
// unresolved `(type, shortcode)`, naming the being. A package whose
|
|
352
|
+
// beings embed nothing, or whose every address resolves against a
|
|
353
|
+
// dependency catalogue through `foreignSourceDirs`, now compiles with
|
|
354
|
+
// no Item pack at all — and one that is genuinely missing an item
|
|
355
|
+
// still fails, saying which item and which actor rather than which
|
|
356
|
+
// pack is absent.
|
|
344
357
|
Object.defineProperty(this, "itemsSourceDirs", {
|
|
345
358
|
value: Object.freeze([...itemsSourceDirs]),
|
|
346
359
|
writable: false,
|
|
@@ -641,7 +654,7 @@ export class Actors extends BasePackCompiler {
|
|
|
641
654
|
// `sohl.archetype` (required nullable number) drives
|
|
642
655
|
// `flags.sohl.docArchetype` (#640 / archetype contract #604).
|
|
643
656
|
flags: withArchetypeFlag(fm, fm.flags, ctx),
|
|
644
|
-
_stats:
|
|
657
|
+
_stats: this.stats,
|
|
645
658
|
_key: `!actors!${id}`,
|
|
646
659
|
};
|
|
647
660
|
}
|
package/sohl/items.mjs
CHANGED
|
@@ -167,7 +167,7 @@ export class Items extends BasePackCompiler {
|
|
|
167
167
|
// `sohl.archetype` (required nullable number) drives
|
|
168
168
|
// `flags.sohl.docArchetype` (#640 / archetype contract #604).
|
|
169
169
|
flags: withArchetypeFlag(fm, fm.flags, `item "${name}"`),
|
|
170
|
-
_stats:
|
|
170
|
+
_stats: this.stats,
|
|
171
171
|
ownership: { default: 0 },
|
|
172
172
|
folder,
|
|
173
173
|
_key: `!items!${id}`,
|