@heroiclands/package-build 22.21.0 → 22.23.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 +223 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +77 -17
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +153 -6
- package/docs/api.md +120 -89
- package/docs/authoring/assets.md +14 -0
- package/docs/authoring/dates-and-calendars.md +41 -3
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +325 -35
- package/docs/commands.md +12 -10
- package/docs/configuration.md +117 -14
- package/docs/content-format.md +4 -2
- package/docs/guides/book.md +5 -4
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +545 -104
- package/docs/reference/note-types.md +30 -13
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +26 -30
- package/engine/art-fields.mjs +118 -0
- package/engine/art-slots.mjs +7 -1
- package/engine/asset-index.mjs +160 -10
- package/engine/asset-replacement.mjs +93 -0
- package/engine/asset-types.mjs +240 -0
- package/engine/base-compiler.mjs +139 -22
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +13 -2
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +197 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +38 -12
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/field-reference.mjs +31 -10
- package/engine/field-spec.mjs +10 -8
- package/engine/foreign-catalog.mjs +143 -18
- package/engine/frontmatter-lint.mjs +120 -65
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +321 -0
- package/engine/helpers.mjs +109 -41
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +129 -15
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +294 -50
- package/engine/lore-events.mjs +197 -0
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +434 -59
- package/engine/note-dates.mjs +354 -12
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +183 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +431 -74
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +563 -66
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +26 -7
- package/engine/site-build.mjs +166 -23
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +90 -3
- package/engine/site-preview.mjs +20 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standings.mjs +89 -6
- package/engine/system-block.mjs +56 -30
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +8 -5
- package/manifest.mjs +43 -24
- package/package.json +1 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +5 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/content-config.d.mts +48 -1
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/anchors.d.mts +9 -4
- package/types/engine/art-fields.d.mts +64 -0
- package/types/engine/asset-index.d.mts +43 -0
- package/types/engine/asset-replacement.d.mts +22 -0
- package/types/engine/asset-types.d.mts +18 -0
- package/types/engine/base-compiler.d.mts +19 -17
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-embeds.d.mts +16 -0
- package/types/engine/content-figures.d.mts +101 -0
- package/types/engine/content-footnotes.d.mts +80 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/field-reference.d.mts +21 -0
- package/types/engine/field-spec.d.mts +20 -16
- package/types/engine/foreign-catalog.d.mts +39 -0
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +179 -0
- package/types/engine/helpers.d.mts +29 -10
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +41 -12
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +46 -2
- package/types/engine/note-dates.d.mts +64 -1
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +82 -6
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pathnames.d.mts +26 -0
- package/types/engine/pdf-build.d.mts +28 -6
- package/types/engine/pdf-images.d.mts +52 -0
- package/types/engine/pdf-render.d.mts +84 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +27 -4
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +67 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standings.d.mts +21 -3
- package/types/engine/system-block.d.mts +37 -14
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +21 -3
- package/types/manifest.d.mts +19 -23
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-captions.mjs +0 -181
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-captions.d.mts +0 -45
- package/types/engine/content-secrets.d.mts +0 -17
|
@@ -60,13 +60,16 @@ import {
|
|
|
60
60
|
CHARACTER_NAME_KEYS,
|
|
61
61
|
NOTE_NAME_KEYS,
|
|
62
62
|
NOTE_TOP_LEVEL_KEY_SET,
|
|
63
|
+
NOTE_TOP_LEVEL_KEYS,
|
|
63
64
|
} from "./note-frontmatter.mjs";
|
|
64
65
|
import { AddressEntries } from "./address-values.mjs";
|
|
65
66
|
import { addressPositions } from "./note-addresses.mjs";
|
|
66
|
-
import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
|
|
67
|
+
import { authoredFields, readsLegacyKey, readsRetiredTopLevel } from "./field-spec.mjs";
|
|
67
68
|
import {
|
|
68
69
|
legacyKeyOf,
|
|
70
|
+
resolveDataProperty,
|
|
69
71
|
resolveFieldValue,
|
|
72
|
+
retiredTopLevelKey,
|
|
70
73
|
systemBlock,
|
|
71
74
|
SYSTEM_BLOCK_KEYS,
|
|
72
75
|
unknownBlockKeys,
|
|
@@ -100,6 +103,7 @@ import {
|
|
|
100
103
|
legacyKeyMessage,
|
|
101
104
|
readAliasedField,
|
|
102
105
|
retiredAliasMessage,
|
|
106
|
+
retiredTopLevelMessage,
|
|
103
107
|
sectionRetiredMessage,
|
|
104
108
|
traitsRetiredMessage,
|
|
105
109
|
} from "./retired-fields.mjs";
|
|
@@ -136,6 +140,19 @@ export const UNIVERSAL_KEYS = Object.freeze(
|
|
|
136
140
|
new Set(["packFolder", "pack", "archetype", "templatePriority", "kbcat"]),
|
|
137
141
|
);
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The shared `data:` keys declared for every note type that still have a
|
|
145
|
+
* retiring top-level spelling — `pack` and `packFolder`, the two
|
|
146
|
+
* {@link module:engine/note-vocabulary.SHARED_DATA_FIELDS} entries
|
|
147
|
+
* {@link module:engine/system-block.resolveDataProperty} reads off a tree's
|
|
148
|
+
* bare top level. Unlike a type's own declarations, which the retiring set
|
|
149
|
+
* below derives from `schemas`, these apply to every type at once, so they
|
|
150
|
+
* are named once here rather than attached to one type's vocabulary.
|
|
151
|
+
*
|
|
152
|
+
* @type {readonly string[]}
|
|
153
|
+
*/
|
|
154
|
+
const UNIVERSAL_RETIRING_DATA_KEYS = Object.freeze(["pack", "packFolder"]);
|
|
155
|
+
|
|
139
156
|
/**
|
|
140
157
|
* The system blocks a build checks, and what each accepts beyond the shared
|
|
141
158
|
* vocabulary.
|
|
@@ -291,6 +308,21 @@ function distance(a, b) {
|
|
|
291
308
|
return prev[cols - 1];
|
|
292
309
|
}
|
|
293
310
|
|
|
311
|
+
/**
|
|
312
|
+
* A closed vocabulary as an English list, for the message that names it.
|
|
313
|
+
*
|
|
314
|
+
* Written from the declaration rather than spelled into the message, so a key
|
|
315
|
+
* the vocabulary gains cannot go unmentioned by the finding that refuses its
|
|
316
|
+
* neighbours.
|
|
317
|
+
*
|
|
318
|
+
* @param {readonly string[]} keys - The declared keys, in declared order.
|
|
319
|
+
* @returns {string} `"a, b, or c"`.
|
|
320
|
+
*/
|
|
321
|
+
function listed(keys) {
|
|
322
|
+
if (keys.length < 2) return keys.join("");
|
|
323
|
+
return `${keys.slice(0, -1).join(", ")}, or ${keys[keys.length - 1]}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
294
326
|
/**
|
|
295
327
|
* The declared key an unknown one was most likely meant to be.
|
|
296
328
|
*
|
|
@@ -357,6 +389,11 @@ export function matchesKind(value, kind, context = {}) {
|
|
|
357
389
|
// this answers is whether the value has one of the two shapes the
|
|
358
390
|
// field admits. A list has neither.
|
|
359
391
|
return matchesKind(value, "string") || matchesKind(value, "map");
|
|
392
|
+
case "string-or-list":
|
|
393
|
+
// One value or several of the same kind. Unlike `list-or-map` the
|
|
394
|
+
// two halves say the same thing at different lengths, so either is
|
|
395
|
+
// accepted here and the entries are read by the field's own check.
|
|
396
|
+
return matchesKind(value, "string") || Array.isArray(value);
|
|
360
397
|
case "list-or-map":
|
|
361
398
|
// A list, or a map. The two carry different facts rather than
|
|
362
399
|
// being two spellings of one — a map keyed by Address says what is
|
|
@@ -415,9 +452,8 @@ function dataBlock(fm) {
|
|
|
415
452
|
* Check a note's `data:` container against the closed vocabulary its type
|
|
416
453
|
* declares.
|
|
417
454
|
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* and every key of it is declared. An unrecognised key is therefore a finding
|
|
455
|
+
* `data:` holds the type-specific facts about the subject, and every key of it
|
|
456
|
+
* is declared by the note's type. An unrecognised key is therefore a finding
|
|
421
457
|
* naming the note, with the key it was most likely meant to be — the same
|
|
422
458
|
* capped edit distance {@link nearest} applies to a `sohl:` key, drawn from
|
|
423
459
|
* this type's own vocabulary rather than from every type's.
|
|
@@ -491,9 +527,12 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
|
|
|
491
527
|
message:
|
|
492
528
|
type === "affiliation" && key === "commonSkills" ?
|
|
493
529
|
"`data.commonSkills` is not an affiliation field; write skill Addresses at `sohl.system.commonSkills`"
|
|
530
|
+
: type === "lore" && key === "event" ?
|
|
531
|
+
"`data.event` is retired — write `data.events`, a list of occurrences, " +
|
|
532
|
+
"instead, each `{ when, until?, recurs? }`; a note may state several"
|
|
494
533
|
: `"${key}" is not a \`data:\` property declared by ${type}; ` +
|
|
495
|
-
`the container is closed, so
|
|
496
|
-
`
|
|
534
|
+
`the container is closed, so the key reaches no document ` +
|
|
535
|
+
`and no page` +
|
|
497
536
|
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
498
537
|
});
|
|
499
538
|
}
|
|
@@ -887,9 +926,9 @@ function checkSubType(note, { type, entry }) {
|
|
|
887
926
|
/**
|
|
888
927
|
* Check a note's `tags` for near misses against the tags that classify.
|
|
889
928
|
*
|
|
890
|
-
* `tags:` is
|
|
891
|
-
* **not** a finding: a theme, a region or a working state
|
|
892
|
-
* vocabulary and this build has no standing to refuse it.
|
|
929
|
+
* `tags:` is a closed key holding an open vocabulary of values, so an
|
|
930
|
+
* unrecognised tag is **not** a finding: a theme, a region or a working state
|
|
931
|
+
* is the author's own vocabulary and this build has no standing to refuse it.
|
|
893
932
|
*
|
|
894
933
|
* **Distance alone is not enough either**, which the corpus settles rather than
|
|
895
934
|
* argues: `azravan` on a faith, `barter` on an economy note and `secret` on
|
|
@@ -989,13 +1028,10 @@ function inBlockKeys(schema) {
|
|
|
989
1028
|
* note-level field either, and a check about the note-level field must not read
|
|
990
1029
|
* it.
|
|
991
1030
|
*
|
|
992
|
-
* `affiliation`'s `
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
* `sohl.title: ""` — an office with no style of address, which is ordinary —
|
|
997
|
-
* and every one of them was reported as publishing a page with no heading. None
|
|
998
|
-
* of them does; their pages take `name.full` exactly as intended.
|
|
1031
|
+
* `affiliation`'s `commonSkills` is the case to read it against: the skills
|
|
1032
|
+
* common among a society's members belong to the SoHL affiliation item, so
|
|
1033
|
+
* nothing outside that item supplies them and a note-level check must not go
|
|
1034
|
+
* looking in the block for one.
|
|
999
1035
|
*
|
|
1000
1036
|
* Keyed on the **in-block** key — `legacyKey` where a field declares one, and
|
|
1001
1037
|
* its first segment where that is dotted — because that is the position a note
|
|
@@ -1192,6 +1228,22 @@ export function lintNote(
|
|
|
1192
1228
|
const type = String(fm.type ?? "");
|
|
1193
1229
|
const raw = () => note.raw ?? "";
|
|
1194
1230
|
const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
1231
|
+
|
|
1232
|
+
// The keys the closed top-level region refuses in general, but a
|
|
1233
|
+
// declaration still reads at the retiring position — this type's own
|
|
1234
|
+
// fields, which `resolveFieldValue`'s step 3b resolves, plus `pack` and
|
|
1235
|
+
// `packFolder`, declared for every type and resolved the same way by
|
|
1236
|
+
// {@link resolveDataProperty}. Derived ahead of the schema lookup below,
|
|
1237
|
+
// which runs after the closed-region check, so that check can skip
|
|
1238
|
+
// exactly these and nothing else: a key no declaration names stays
|
|
1239
|
+
// refused.
|
|
1240
|
+
const retiringTopLevelKeys = new Set([
|
|
1241
|
+
...UNIVERSAL_RETIRING_DATA_KEYS,
|
|
1242
|
+
...authoredFields(schemas?.[currentType(type)] ?? [])
|
|
1243
|
+
.filter((field) => field.topLevelMeans === undefined)
|
|
1244
|
+
.map((field) => retiredTopLevelKey(field))
|
|
1245
|
+
.filter((key) => key !== undefined),
|
|
1246
|
+
]);
|
|
1195
1247
|
if (type === "being") {
|
|
1196
1248
|
const archetypes = fm.data?.archetypes;
|
|
1197
1249
|
if (
|
|
@@ -1362,26 +1414,16 @@ export function lintNote(
|
|
|
1362
1414
|
// it. It is transitional in the same sense — `""` is a legal thing to mean,
|
|
1363
1415
|
// and the message says so, but nothing in any tree means it yet.
|
|
1364
1416
|
//
|
|
1365
|
-
// **
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
// It once had a sharper reason: a note's top-level `title` was
|
|
1370
|
-
// simultaneously the shared source for an `affiliation` item's
|
|
1371
|
-
// `system.title`, so asking an author for `title: null` would have compiled
|
|
1372
|
-
// the literal string `"null"` into the document. The field declares
|
|
1373
|
-
// `topLevelMeans` now, so the top-level key is no longer a source for it and
|
|
1374
|
-
// `title: null` is harmless. `title: ""` is warned about on its own account
|
|
1375
|
-
// below, as the *page's* heading rather than as an art path.
|
|
1417
|
+
// **The art slots only.** The rule reads as a general one about optional
|
|
1418
|
+
// strings, and it is not — it belongs to `resolveImg`, and nothing else
|
|
1419
|
+
// goes through it.
|
|
1376
1420
|
//
|
|
1377
|
-
// **
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
// `blockCollisions`: a note-level check reads past a block key its type
|
|
1384
|
-
// claims for something else.
|
|
1421
|
+
// **A block key a type claims for something else is read past.**
|
|
1422
|
+
// `topLevelMeans` settles which position the *emitted field* reads; it says
|
|
1423
|
+
// nothing about which position a *check* reads, and `authoredValue` would
|
|
1424
|
+
// go on resolving through the block regardless — so an office with no style
|
|
1425
|
+
// of address would answer for a note's art, in twenty-eight
|
|
1426
|
+
// `sohl-kethira-basic` affiliations. Hence `blockCollisions`.
|
|
1385
1427
|
|
|
1386
1428
|
// The template priority is a *shared source* — the specification states it
|
|
1387
1429
|
// once for every type, as it does `pack` — so its retirement is reported
|
|
@@ -1508,35 +1550,6 @@ export function lintNote(
|
|
|
1508
1550
|
"meant to have no image",
|
|
1509
1551
|
});
|
|
1510
1552
|
}
|
|
1511
|
-
// `title: ""` publishes a blank heading. The rule the art fields
|
|
1512
|
-
// follow — `null` falls back, `""` is blank on purpose — reads the same way
|
|
1513
|
-
// here, and for a *page heading* the deliberate blank is almost never what
|
|
1514
|
-
// anyone wants: the emitter is `fm.title ?? name`, so `""` survives, the
|
|
1515
|
-
// page publishes with no name, and it sorts to the front of its section
|
|
1516
|
-
// landing ahead of every named page. Fifteen notes in `sohl-thalorna` are
|
|
1517
|
-
// in exactly that state.
|
|
1518
|
-
//
|
|
1519
|
-
// A warning rather than an error: the value is legal under the rule, and a
|
|
1520
|
-
// note that genuinely wants no heading may keep it — it just has to mean it.
|
|
1521
|
-
//
|
|
1522
|
-
// **The emitter reads `fm.title`, so this reads the note level.** On an
|
|
1523
|
-
// `affiliation` `sohl.title` is the office's style of address, which the
|
|
1524
|
-
// heading has nothing to do with — and `blockCollisions` is what keeps the
|
|
1525
|
-
// two apart. On every other type nothing claims the block key, so the
|
|
1526
|
-
// resolution is the unchanged one.
|
|
1527
|
-
if (authoredValue(fm, "title", { blockCollides: blockCollisions.has("title") }) === "") {
|
|
1528
|
-
findings.push({
|
|
1529
|
-
file: note.file,
|
|
1530
|
-
...at("title"),
|
|
1531
|
-
severity: "warning",
|
|
1532
|
-
message:
|
|
1533
|
-
'`title: ""` publishes a page with no heading, which sorts to ' +
|
|
1534
|
-
"the front of its section ahead of every named page. Write " +
|
|
1535
|
-
"`title: null` to fall back to `name.full`, or give the page a " +
|
|
1536
|
-
'heading; keep `""` only where the blank is meant',
|
|
1537
|
-
});
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
1553
|
if (Object.hasOwn(fm, "draft")) {
|
|
1541
1554
|
findings.push({
|
|
1542
1555
|
file: note.file,
|
|
@@ -1611,6 +1624,9 @@ export function lintNote(
|
|
|
1611
1624
|
|
|
1612
1625
|
for (const key of authoredNoteKeys(raw())) {
|
|
1613
1626
|
if (NOTE_TOP_LEVEL_KEY_SET.has(key)) continue;
|
|
1627
|
+
// Reported below, as the retiring position it is rather than a key
|
|
1628
|
+
// nobody recognises.
|
|
1629
|
+
if (retiringTopLevelKeys.has(key)) continue;
|
|
1614
1630
|
const position = positionInFrontmatter(raw(), key, undefined, { topLevel: true });
|
|
1615
1631
|
if (
|
|
1616
1632
|
findings.some(
|
|
@@ -1619,11 +1635,38 @@ export function lintNote(
|
|
|
1619
1635
|
) {
|
|
1620
1636
|
continue;
|
|
1621
1637
|
}
|
|
1638
|
+
const guess = nearest(key, NOTE_TOP_LEVEL_KEYS);
|
|
1622
1639
|
findings.push({
|
|
1623
1640
|
file: note.file,
|
|
1624
1641
|
...position,
|
|
1625
1642
|
severity: "error",
|
|
1626
|
-
message:
|
|
1643
|
+
message:
|
|
1644
|
+
`unknown top-level frontmatter key ${JSON.stringify(key)}; ` +
|
|
1645
|
+
`the region is closed, so the key reaches no document and no ` +
|
|
1646
|
+
`page. Use ${listed(NOTE_TOP_LEVEL_KEYS)}` +
|
|
1647
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// `pack` and `packFolder` are declared for every type, so each is
|
|
1652
|
+
// checked once here rather than in the per-type `fields` loop below,
|
|
1653
|
+
// which only iterates a type's own declarations. A **warning**, for the
|
|
1654
|
+
// reason the per-type retiring position above is one: the note compiles
|
|
1655
|
+
// to the correct document either way, so failing a build over it would
|
|
1656
|
+
// red a tree that has done nothing wrong yet. Checked against every
|
|
1657
|
+
// configured system block, because `resolveDataProperty` reads a
|
|
1658
|
+
// system's own override before the top level, and a note may configure
|
|
1659
|
+
// more than one.
|
|
1660
|
+
for (const key of UNIVERSAL_RETIRING_DATA_KEYS) {
|
|
1661
|
+
const retiring = Object.keys(systems ?? {}).some(
|
|
1662
|
+
(blockName) => resolveDataProperty(fm, blockName, key).from === "topLevel",
|
|
1663
|
+
);
|
|
1664
|
+
if (!retiring) continue;
|
|
1665
|
+
findings.push({
|
|
1666
|
+
file: note.file,
|
|
1667
|
+
...at(key),
|
|
1668
|
+
severity: "warning",
|
|
1669
|
+
message: retiredTopLevelMessage({ name: `data.${key}` }),
|
|
1627
1670
|
});
|
|
1628
1671
|
}
|
|
1629
1672
|
|
|
@@ -1844,6 +1887,18 @@ export function lintNote(
|
|
|
1844
1887
|
message: legacyKeyMessage("sohl", field),
|
|
1845
1888
|
});
|
|
1846
1889
|
}
|
|
1890
|
+
// `readsLegacyKey`'s sibling for the other retiring position — the
|
|
1891
|
+
// note's own top level, which `data:` gathered the fact off. Also a
|
|
1892
|
+
// **warning**: the closed-region check above already let this exact
|
|
1893
|
+
// key through for this exact reason, and the two must agree.
|
|
1894
|
+
if (readsRetiredTopLevel(field, from)) {
|
|
1895
|
+
findings.push({
|
|
1896
|
+
file: note.file,
|
|
1897
|
+
...at(retiredTopLevelKey(field)),
|
|
1898
|
+
severity: "warning",
|
|
1899
|
+
message: retiredTopLevelMessage(field),
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1847
1902
|
const absent = from === "default" || value === undefined || value === null;
|
|
1848
1903
|
// Where the field belongs, as a message names it: a shared field is not
|
|
1849
1904
|
// under `sohl:`, so telling an author to write `sohl.img` would send
|
package/engine/frontmatter.mjs
CHANGED
|
@@ -309,14 +309,14 @@ export function resolveRelation(fm, ctx = "item") {
|
|
|
309
309
|
*
|
|
310
310
|
* @param {object} fm - The item frontmatter.
|
|
311
311
|
* @param {string} [ctx] - Optional label for the error (defaults to the item's
|
|
312
|
-
*
|
|
312
|
+
* display name, else "item").
|
|
313
313
|
* @returns {string} The declared subType.
|
|
314
314
|
* @throws {Error} When `subType` is missing or blank.
|
|
315
315
|
*/
|
|
316
316
|
export function requireSubType(fm, ctx) {
|
|
317
317
|
const subType = sohlSystemField(fm, "subType", undefined);
|
|
318
318
|
if (subType == null || subType === "") {
|
|
319
|
-
const label = ctx || fm?.
|
|
319
|
+
const label = ctx || fm?.name?.full || "item";
|
|
320
320
|
throw new Error(
|
|
321
321
|
`${label}: missing required 'subType' — every subType-bearing item must declare its kind (the builder substitutes no default).`,
|
|
322
322
|
);
|
|
@@ -379,7 +379,7 @@ export function parseValueDesc(raw) {
|
|
|
379
379
|
* two spellings, and there is only one left to be.
|
|
380
380
|
*/
|
|
381
381
|
export function folderField(fm) {
|
|
382
|
-
const asAddress = fm?.sohl?.packFolder ?? fm?.data?.packFolder ??
|
|
382
|
+
const asAddress = fm?.sohl?.packFolder ?? fm?.data?.packFolder ?? null;
|
|
383
383
|
if (asAddress != null && asAddress !== "") return { value: asAddress, isAddress: true };
|
|
384
384
|
return { value: null, isAddress: true };
|
|
385
385
|
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The braced attribute block a heading may end with, read once.
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* # The Harbor {#harbor .wide}
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* The index, the link checker, the journal compiler, the figure pass and the
|
|
11
|
+
* book all ask what a heading declares, and they have to give the same answer:
|
|
12
|
+
* an id a surface publishes as an anchor, classes a surface may honour, and
|
|
13
|
+
* attributes an HTML surface writes onto the element. This is where that
|
|
14
|
+
* reading lives, so there is one grammar — the same one a `:::` block and a
|
|
15
|
+
* `:::figure` are written in.
|
|
16
|
+
*
|
|
17
|
+
* **A leaf, deliberately.** `anchors.mjs` is shared by the compilers, the index
|
|
18
|
+
* and the link checker and cannot import any of them; the figure pass is
|
|
19
|
+
* imported by `anchors.mjs` and so cannot import it back. A module that imports
|
|
20
|
+
* only the attribute grammar can be asked by all of them.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A heading line: its hashes and the text after them.
|
|
29
|
+
*
|
|
30
|
+
* Shared with every pass that has to find a heading before reading what it
|
|
31
|
+
* declares, so the passes cannot disagree about which lines are headings.
|
|
32
|
+
*/
|
|
33
|
+
export const HEADING_LINE = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
34
|
+
|
|
35
|
+
/** The braces a heading's text may end with, and what is inside them. */
|
|
36
|
+
const SUFFIX = /^(.*?)\s*\{([^}\n]*)\}\s*$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A heading's id.
|
|
40
|
+
*
|
|
41
|
+
* Looser than a figure's or a block's, which must begin with a letter: a
|
|
42
|
+
* heading's id is published as an HTML `id` and as a Foundry page anchor, both
|
|
43
|
+
* of which admit a digit first, and the corpus carries ids that are Foundry
|
|
44
|
+
* document ids and ids derived from a numbered heading.
|
|
45
|
+
*/
|
|
46
|
+
const HEADING_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read a heading's text and the attribute block it may end with.
|
|
50
|
+
*
|
|
51
|
+
* A suffix that is not an attribute block — a set written in braces, a die
|
|
52
|
+
* expression — is left in the text and reported, so no surface silently prints
|
|
53
|
+
* a heading with words missing and no author is told to fix nothing.
|
|
54
|
+
*
|
|
55
|
+
* **One mistake draws one finding.** Braces holding nothing the grammar
|
|
56
|
+
* recognises are reported once, naming the braces: an author who wrote
|
|
57
|
+
* `{a, b}` meant no attribute block at all, and a complaint per fragment inside
|
|
58
|
+
* it says the same thing twice in words that name neither the heading nor the
|
|
59
|
+
* fix. Braces that *are* an attribute block, carrying one bad attribute beside
|
|
60
|
+
* good ones, relay the grammar's own complaint about that attribute.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} heading - A heading's text, hashes already removed.
|
|
63
|
+
* @returns {{text: string, id: string, classes: string[],
|
|
64
|
+
* values: Record<string, string>, problems: string[], braces: boolean}}
|
|
65
|
+
* `text` is what a surface sets, `braces` whether a suffix was there at all.
|
|
66
|
+
*/
|
|
67
|
+
export function splitHeadingAttributes(heading) {
|
|
68
|
+
const raw = String(heading ?? "").trim();
|
|
69
|
+
const blank = { text: raw, id: "", classes: [], values: {}, problems: [], braces: false };
|
|
70
|
+
const match = SUFFIX.exec(raw);
|
|
71
|
+
if (!match) return blank;
|
|
72
|
+
const parsed = parseExtensionAttributes(match[2], { idPattern: HEADING_ID });
|
|
73
|
+
const recognised =
|
|
74
|
+
Boolean(parsed.id) || parsed.classes.length > 0 || Object.keys(parsed.values).length > 0;
|
|
75
|
+
let problems;
|
|
76
|
+
if (recognised) {
|
|
77
|
+
// An id, a class or an attribute the markup owns: refused here as it is
|
|
78
|
+
// on a named block, because a heading writes its attributes onto the
|
|
79
|
+
// element on both HTML surfaces.
|
|
80
|
+
problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
|
|
81
|
+
} else if (match[2].trim()) {
|
|
82
|
+
problems = [`{${match[2]}} is not an attribute block`];
|
|
83
|
+
} else {
|
|
84
|
+
problems = ["an attribute block states nothing"];
|
|
85
|
+
}
|
|
86
|
+
if (problems.length) return { ...blank, braces: true, problems };
|
|
87
|
+
return {
|
|
88
|
+
text: match[1].trim(),
|
|
89
|
+
id: parsed.id,
|
|
90
|
+
classes: parsed.classes,
|
|
91
|
+
values: parsed.values,
|
|
92
|
+
problems: [],
|
|
93
|
+
braces: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The class that withholds a section from players.
|
|
99
|
+
*
|
|
100
|
+
* Written on the heading that opens the section, so the section stays linkable:
|
|
101
|
+
* secrecy is a property of the section, not of the name a link resolves to.
|
|
102
|
+
*/
|
|
103
|
+
export const WITHHELD_CLASS = "secret";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a heading opens a journal page.
|
|
107
|
+
*
|
|
108
|
+
* A Foundry UUID addresses a page and nothing smaller, so a section a link can
|
|
109
|
+
* reach has to be one: a top-level heading starts a page, and so does any
|
|
110
|
+
* heading that names an anchor. {@link module:engine/journals.splitPages}
|
|
111
|
+
* decides *where* a page begins — a heading inside a fence or a named block is
|
|
112
|
+
* its business — and asks this which headings are eligible, so no caller
|
|
113
|
+
* re-decides it.
|
|
114
|
+
*
|
|
115
|
+
* @param {{level: number, id: string}} heading - From
|
|
116
|
+
* {@link scanHeadingAttributes}, or any `{level, id}` pair.
|
|
117
|
+
* @returns {boolean} Whether the heading opens a page.
|
|
118
|
+
*/
|
|
119
|
+
export function opensPage({ level, id }) {
|
|
120
|
+
return level === 1 || Boolean(id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Every heading in a body, with what its attribute block declares.
|
|
125
|
+
*
|
|
126
|
+
* Fenced code is skipped: `#` opens a comment in most of what gets fenced, and
|
|
127
|
+
* an example heading in a document is not a heading of it.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} source - A note's markdown body.
|
|
130
|
+
* @param {number} [bodyLine=1] - The 1-based file line the body starts on, so a
|
|
131
|
+
* finding lands where an editor can open it.
|
|
132
|
+
* @returns {{headings: Array<{level: number, line: number, text: string,
|
|
133
|
+
* id: string, classes: string[], values: Record<string, string>}>,
|
|
134
|
+
* errors: Array<{line: number, column: number, message: string}>}}
|
|
135
|
+
*/
|
|
136
|
+
export function scanHeadingAttributes(source, bodyLine = 1) {
|
|
137
|
+
const lines = String(source ?? "").split("\n");
|
|
138
|
+
const headings = [];
|
|
139
|
+
const errors = [];
|
|
140
|
+
let codeFence = null;
|
|
141
|
+
for (let i = 0; i < lines.length; i++) {
|
|
142
|
+
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
|
|
143
|
+
if (codeFence) {
|
|
144
|
+
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
|
|
145
|
+
codeFence = null;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (fence) {
|
|
149
|
+
codeFence = fence[1];
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const heading = HEADING_LINE.exec(lines[i]);
|
|
153
|
+
if (!heading) continue;
|
|
154
|
+
const parsed = splitHeadingAttributes(heading[2]);
|
|
155
|
+
const line = bodyLine + i;
|
|
156
|
+
if (parsed.problems.length) {
|
|
157
|
+
const column = lines[i].indexOf("{") + 1;
|
|
158
|
+
for (const message of parsed.problems) errors.push({ line, column, message });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
headings.push({
|
|
162
|
+
level: heading[1].length,
|
|
163
|
+
line,
|
|
164
|
+
text: parsed.text,
|
|
165
|
+
id: parsed.id,
|
|
166
|
+
classes: parsed.classes,
|
|
167
|
+
values: parsed.values,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return { headings, errors };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A markdown-it plugin writing a heading's attribute block onto its element.
|
|
175
|
+
*
|
|
176
|
+
* The website's renderer reads the block itself, so the surfaces that use
|
|
177
|
+
* markdown-it read it here and publish the same `id`, the same classes and the
|
|
178
|
+
* same attributes — rather than typesetting the braces an author wrote.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} md - A markdown-it instance.
|
|
181
|
+
* @returns {void}
|
|
182
|
+
*/
|
|
183
|
+
export function headingAttributesPlugin(md) {
|
|
184
|
+
md.core.ruler.push("heading_attributes", (state) => {
|
|
185
|
+
const tokens = state.tokens;
|
|
186
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
187
|
+
if (tokens[i].type !== "heading_open") continue;
|
|
188
|
+
const inline = tokens[i + 1];
|
|
189
|
+
const children = inline?.children ?? [];
|
|
190
|
+
const last = children[children.length - 1];
|
|
191
|
+
if (last?.type !== "text") continue;
|
|
192
|
+
const parsed = splitHeadingAttributes(String(last.content ?? ""));
|
|
193
|
+
if (!parsed.braces || parsed.problems.length) continue;
|
|
194
|
+
last.content = parsed.text;
|
|
195
|
+
inline.content = splitHeadingAttributes(String(inline.content ?? "")).text;
|
|
196
|
+
if (parsed.id) tokens[i].attrSet("id", parsed.id);
|
|
197
|
+
if (parsed.classes.length) tokens[i].attrJoin("class", parsed.classes.join(" "));
|
|
198
|
+
for (const [key, value] of Object.entries(parsed.values)) tokens[i].attrSet(key, value);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Parse one line as a heading, and say whether it starts a journal page.
|
|
205
|
+
*
|
|
206
|
+
* The single reading of that rule. {@link module:engine/journals.splitPages}
|
|
207
|
+
* builds its pages from it; `scanBlocks` and `scanFigures` refuse a heading
|
|
208
|
+
* that starts a page written where it cannot become one. All three ask this, so
|
|
209
|
+
* none of them can disagree about what a heading line means or about what starts
|
|
210
|
+
* a page — and the attribute block comes off here, so a class beside an anchor
|
|
211
|
+
* is not read as part of the anchor by any of them.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} line - One line of a note's body.
|
|
214
|
+
* @returns {{level: number, text: string, id: string, classes: string[],
|
|
215
|
+
* values: Record<string, string>, problems: string[], startsPage: boolean}|null}
|
|
216
|
+
* `null` when the line is not a heading.
|
|
217
|
+
*/
|
|
218
|
+
export function parseHeadingLine(line) {
|
|
219
|
+
const heading = HEADING_LINE.exec(line);
|
|
220
|
+
if (!heading) return null;
|
|
221
|
+
const level = heading[1].length;
|
|
222
|
+
const parsed = splitHeadingAttributes(heading[2]);
|
|
223
|
+
return {
|
|
224
|
+
level,
|
|
225
|
+
text: parsed.text,
|
|
226
|
+
id: parsed.id,
|
|
227
|
+
classes: parsed.classes,
|
|
228
|
+
values: parsed.values,
|
|
229
|
+
problems: parsed.problems,
|
|
230
|
+
startsPage: opensPage({ level, id: parsed.id }),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every heading that opens a journal page, by the line it sits on.
|
|
236
|
+
*
|
|
237
|
+
* The walk that decides it: fenced code holds no headings, a named block holds
|
|
238
|
+
* no page — the block is one passage of the page it is written in — and what is
|
|
239
|
+
* left is read by {@link opensPage}. {@link module:engine/journals.splitPages}
|
|
240
|
+
* builds the pages from this, and the passes that honour a class on a
|
|
241
|
+
* page-opening heading ask the same question of the same answer.
|
|
242
|
+
*
|
|
243
|
+
* A figure fence also opens a page, which is `splitPages`' business: a fence is
|
|
244
|
+
* found by the figure pass, and the page it opens is named for its label.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} source - A note's markdown body.
|
|
247
|
+
* @param {ReadonlySet<number>} [blockLines] - The 0-based lines sitting inside
|
|
248
|
+
* a named block, supplied by the caller. A named block is read by
|
|
249
|
+
* {@link module:engine/content-blocks.scanBlocks}, which knows every name in
|
|
250
|
+
* the registry, reads an opener's attribute block, and tracks nesting by
|
|
251
|
+
* depth — so the caller that already holds that answer passes it rather than
|
|
252
|
+
* a second reading of the same lines being kept here.
|
|
253
|
+
* @returns {Map<number, {level: number, line: number, text: string, id: string,
|
|
254
|
+
* classes: string[], values: Record<string, string>}>} Keyed by 0-based line.
|
|
255
|
+
*/
|
|
256
|
+
export function pageOpenings(source, blockLines = new Set()) {
|
|
257
|
+
const lines = String(source ?? "").split("\n");
|
|
258
|
+
const openings = new Map();
|
|
259
|
+
let codeFence = null;
|
|
260
|
+
for (const [index, line] of lines.entries()) {
|
|
261
|
+
// A fence's closer must carry the same character as its opener and
|
|
262
|
+
// be at least as long, same as {@link scanHeadingAttributes} tracks
|
|
263
|
+
// it below — a longer fence is what lets a fenced example carry a
|
|
264
|
+
// shorter fence of its own as literal content.
|
|
265
|
+
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
266
|
+
if (codeFence) {
|
|
267
|
+
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
|
|
268
|
+
codeFence = null;
|
|
269
|
+
}
|
|
270
|
+
} else if (fence) {
|
|
271
|
+
codeFence = fence[1];
|
|
272
|
+
}
|
|
273
|
+
if (codeFence !== null || blockLines.has(index)) continue;
|
|
274
|
+
const heading = parseHeadingLine(line);
|
|
275
|
+
if (!heading?.startsPage) continue;
|
|
276
|
+
openings.set(index, { ...heading, line: index + 1 });
|
|
277
|
+
}
|
|
278
|
+
return openings;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The spans a `.secret` heading withholds, and the classes written where no
|
|
283
|
+
* surface can honour them.
|
|
284
|
+
*
|
|
285
|
+
* A span runs from its heading to the line before the next heading that opens a
|
|
286
|
+
* page, which is the stretch Foundry makes one page of. A `.secret` on a heading
|
|
287
|
+
* that opens no page covers no page, so no surface can withhold it: that is a
|
|
288
|
+
* finding at the heading's own line rather than a class quietly ignored.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} source - A note's markdown body.
|
|
291
|
+
* @param {number} [bodyLine=1] - The 1-based file line the body starts on.
|
|
292
|
+
* @returns {{sections: Array<{start: number, end: number, heading: object}>,
|
|
293
|
+
* errors: Array<{line: number, column: number, message: string}>}} `start` and
|
|
294
|
+
* `end` are 0-based line indexes, `end` exclusive.
|
|
295
|
+
*/
|
|
296
|
+
export function withheldSections(source, bodyLine = 1) {
|
|
297
|
+
const lines = String(source ?? "").split("\n");
|
|
298
|
+
const openings = pageOpenings(source);
|
|
299
|
+
const starts = [...openings.keys()];
|
|
300
|
+
const sections = [];
|
|
301
|
+
const errors = [];
|
|
302
|
+
for (const [at, index] of starts.entries()) {
|
|
303
|
+
const heading = openings.get(index);
|
|
304
|
+
if (!heading.classes.includes(WITHHELD_CLASS)) continue;
|
|
305
|
+
sections.push({ start: index, end: starts[at + 1] ?? lines.length, heading });
|
|
306
|
+
}
|
|
307
|
+
// A heading that opens no page is not among the openings at all, so the
|
|
308
|
+
// class is looked for again across every heading the body states.
|
|
309
|
+
for (const heading of scanHeadingAttributes(source, bodyLine).headings) {
|
|
310
|
+
if (openings.has(heading.line - 1)) continue;
|
|
311
|
+
if (!heading.classes.includes(WITHHELD_CLASS)) continue;
|
|
312
|
+
errors.push({
|
|
313
|
+
line: heading.line,
|
|
314
|
+
column: (lines[heading.line - bodyLine]?.indexOf("{") ?? 0) + 1,
|
|
315
|
+
message:
|
|
316
|
+
`.${WITHHELD_CLASS} withholds the page a heading opens, and this heading ` +
|
|
317
|
+
"opens none — give it an anchor, or raise it to the top level",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return { sections, errors };
|
|
321
|
+
}
|