@heroiclands/package-build 22.21.0 → 22.22.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 +191 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +36 -18
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +224 -46
- package/docs/reference/note-types.md +21 -4
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- 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/frontmatter-lint.mjs +47 -64
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +14 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +83 -8
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +172 -37
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- 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 +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +18 -2
- package/engine/site-preview.mjs +18 -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 +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- 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/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- 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/pdf-render.d.mts +72 -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 +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -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 +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-secrets.d.mts +0 -17
package/engine/social-ties.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { acceptsType, parseAddress, renderAddress } from "./address.mjs";
|
|
12
|
+
import { AddressEntries } from "./address-values.mjs";
|
|
12
13
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
13
14
|
import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
|
|
14
15
|
|
|
@@ -16,14 +17,51 @@ export { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES };
|
|
|
16
17
|
|
|
17
18
|
const TERMS = new Set(SOCIAL_TIES.map(({ term }) => term));
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A being's defining ties, in one shape whichever shape they reach here in.
|
|
22
|
+
*
|
|
23
|
+
* Two spellings reach this check, and both read the same entries from here
|
|
24
|
+
* on:
|
|
25
|
+
*
|
|
26
|
+
* - an **{@link AddressEntries}** — the map after the note boundary decoded
|
|
27
|
+
* its keys into typed Addresses, the shape every compile sees;
|
|
28
|
+
* - a **plain map**, the shape a caller reading raw YAML sees.
|
|
29
|
+
*
|
|
30
|
+
* `sourceKey` is always the authored key text, so a finding locates on what
|
|
31
|
+
* the file actually says even when `key` already carries a parsed Address.
|
|
32
|
+
*
|
|
33
|
+
* @param {unknown} value - The authored `data.socialTies`.
|
|
34
|
+
* @returns {{form: "absent"|"map"|"malformed",
|
|
35
|
+
* entries: Array<{key: unknown, term: unknown, sourceKey: string}>}}
|
|
36
|
+
*/
|
|
37
|
+
function readSocialTies(value) {
|
|
38
|
+
if (value === undefined || value === null) return { form: "absent", entries: [] };
|
|
39
|
+
if (value instanceof AddressEntries) {
|
|
40
|
+
return {
|
|
41
|
+
form: "map",
|
|
42
|
+
entries: value.entries.map((entry) => ({
|
|
43
|
+
key: entry.target,
|
|
44
|
+
term: entry.value,
|
|
45
|
+
sourceKey: entry.sourceKey,
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
50
|
+
return { form: "malformed", entries: [] };
|
|
51
|
+
return {
|
|
52
|
+
form: "map",
|
|
53
|
+
entries: Object.entries(value).map(([key, term]) => ({ key, term, sourceKey: key })),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
/**
|
|
20
58
|
* Validate a being's map of defining relationships.
|
|
21
59
|
* @param {object} note
|
|
22
60
|
* @param {{index?: object}} [options]
|
|
23
61
|
*/
|
|
24
62
|
export function checkSocialTies(note, { index } = {}) {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
63
|
+
const { form, entries } = readSocialTies(note.fm?.data?.socialTies);
|
|
64
|
+
if (form === "absent") return [];
|
|
27
65
|
const at = (key) => ({
|
|
28
66
|
file: note.file,
|
|
29
67
|
...positionOfFrontmatterPath(
|
|
@@ -33,7 +71,7 @@ export function checkSocialTies(note, { index } = {}) {
|
|
|
33
71
|
),
|
|
34
72
|
severity: "error",
|
|
35
73
|
});
|
|
36
|
-
if (
|
|
74
|
+
if (form === "malformed")
|
|
37
75
|
return [
|
|
38
76
|
{
|
|
39
77
|
...at(),
|
|
@@ -42,11 +80,11 @@ export function checkSocialTies(note, { index } = {}) {
|
|
|
42
80
|
];
|
|
43
81
|
const findings = [];
|
|
44
82
|
const seen = new Map();
|
|
45
|
-
for (const
|
|
83
|
+
for (const { key, term, sourceKey } of entries) {
|
|
46
84
|
const tuple = parseAddress(
|
|
47
85
|
key,
|
|
48
86
|
{
|
|
49
|
-
package: index?.contentPackage
|
|
87
|
+
package: index?.contentPackage,
|
|
50
88
|
system: "note",
|
|
51
89
|
types: index?.types,
|
|
52
90
|
packages: index?.packages,
|
|
@@ -55,43 +93,43 @@ export function checkSocialTies(note, { index } = {}) {
|
|
|
55
93
|
);
|
|
56
94
|
if (tuple.reason) {
|
|
57
95
|
findings.push({
|
|
58
|
-
...at(
|
|
59
|
-
message: `data.socialTies key ${JSON.stringify(
|
|
96
|
+
...at(sourceKey),
|
|
97
|
+
message: `data.socialTies key ${JSON.stringify(sourceKey)} is not a complete Address (${tuple.reason}); write being or affiliation as its type`,
|
|
60
98
|
});
|
|
61
99
|
} else if (!acceptsType(tuple, SOCIAL_TIE_TARGET_TYPES)) {
|
|
62
100
|
findings.push({
|
|
63
|
-
...at(
|
|
101
|
+
...at(sourceKey),
|
|
64
102
|
message: `data.socialTies accepts being or affiliation, not ${tuple.type}`,
|
|
65
103
|
});
|
|
66
104
|
} else {
|
|
67
105
|
const target = renderAddress(tuple);
|
|
68
106
|
if (seen.has(target)) {
|
|
69
107
|
findings.push({
|
|
70
|
-
...at(
|
|
108
|
+
...at(sourceKey),
|
|
71
109
|
message: `data.socialTies names the same target ${target} as ${JSON.stringify(seen.get(target))}; keep one tie per target`,
|
|
72
110
|
});
|
|
73
111
|
} else {
|
|
74
|
-
seen.set(target,
|
|
112
|
+
seen.set(target, sourceKey);
|
|
75
113
|
}
|
|
76
114
|
if (
|
|
77
|
-
tuple.package ===
|
|
115
|
+
tuple.package === index?.contentPackage &&
|
|
78
116
|
tuple.type === "being" &&
|
|
79
117
|
tuple.shortcode === note.fm?.shortcode
|
|
80
118
|
) {
|
|
81
119
|
findings.push({
|
|
82
|
-
...at(
|
|
120
|
+
...at(sourceKey),
|
|
83
121
|
message: `data.socialTies cannot address this being itself`,
|
|
84
122
|
});
|
|
85
123
|
} else if (index?.addressHit && !index.addressHit(target)) {
|
|
86
124
|
findings.push({
|
|
87
|
-
...at(
|
|
125
|
+
...at(sourceKey),
|
|
88
126
|
message: `data.socialTies target ${target} does not resolve`,
|
|
89
127
|
});
|
|
90
128
|
}
|
|
91
129
|
}
|
|
92
130
|
if (!TERMS.has(term))
|
|
93
131
|
findings.push({
|
|
94
|
-
...at(
|
|
132
|
+
...at(sourceKey),
|
|
95
133
|
message: `data.socialTies value ${JSON.stringify(term)} must be one of ${SOCIAL_TIES.map(({ term: value }) => value).join(", ")}`,
|
|
96
134
|
});
|
|
97
135
|
}
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import path from "node:path";
|
|
|
35
35
|
|
|
36
36
|
import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
|
|
37
37
|
import { booleanAttribute } from "./extension-attributes.mjs";
|
|
38
|
+
import { findPageListBlocks, preparePageLists } from "./page-lists.mjs";
|
|
38
39
|
import { MARKET_CLASSES } from "./market-class.mjs";
|
|
39
40
|
import { parseMarkdownFile } from "./helpers.mjs";
|
|
40
41
|
import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
|
|
@@ -53,6 +54,31 @@ const EMPTY_CELL = "—";
|
|
|
53
54
|
*/
|
|
54
55
|
export const RENDER_ALIASES = Object.freeze({ ref: "_ref", section: "_section" });
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The attributes an SQL fence takes, with what each one is for.
|
|
59
|
+
*
|
|
60
|
+
* **This is the one list.** The fence is validated against it and the authoring
|
|
61
|
+
* document is checked against it, so an attribute added here is one the document
|
|
62
|
+
* must describe, and anything else an author writes is reported by name.
|
|
63
|
+
*
|
|
64
|
+
* @type {Readonly<Record<string, Readonly<{value: string, default: string, summary: string}>>>}
|
|
65
|
+
*/
|
|
66
|
+
export const SQL_FENCE_ATTRIBUTES = Object.freeze({
|
|
67
|
+
"allow-empty": Object.freeze({
|
|
68
|
+
value: "`true` or `false`",
|
|
69
|
+
default: "`false`",
|
|
70
|
+
summary:
|
|
71
|
+
"Whether a query selecting no rows is allowed. A zero-row result is a finding " +
|
|
72
|
+
"otherwise, because a table that silently prints nothing is the commonest way a " +
|
|
73
|
+
"query goes wrong unnoticed.",
|
|
74
|
+
}),
|
|
75
|
+
"section-level": Object.freeze({
|
|
76
|
+
value: "an integer from 1 through 6",
|
|
77
|
+
default: "`2`",
|
|
78
|
+
summary: "The heading level given to each group a `_section` column creates.",
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
56
82
|
/** A table cell may not carry a raw `|` or a line break. */
|
|
57
83
|
const escapeCell = (text) =>
|
|
58
84
|
String(text)
|
|
@@ -92,7 +118,7 @@ export function findSqlBlocks(markdown) {
|
|
|
92
118
|
const level = Number(args["section-level"] ?? 2);
|
|
93
119
|
const errors = [...problems];
|
|
94
120
|
for (const key of Object.keys(args)) {
|
|
95
|
-
if (!
|
|
121
|
+
if (!Object.hasOwn(SQL_FENCE_ATTRIBUTES, key))
|
|
96
122
|
errors.push(`${key} is not an SQL fence attribute`);
|
|
97
123
|
}
|
|
98
124
|
let allowEmpty = false;
|
|
@@ -569,6 +595,10 @@ export function renderSqlTable(
|
|
|
569
595
|
* @param {object} [opts]
|
|
570
596
|
* @param {(ref: string) => boolean} [opts.linkable] - Passed to
|
|
571
597
|
* {@link renderSqlTable}.
|
|
598
|
+
* @param {object[]} [opts.records] - Content-index records, narrowed to the
|
|
599
|
+
* audience this surface publishes to. Given them, the result also carries
|
|
600
|
+
* `pageLists`, so one prepared object answers every corpus-reading directive
|
|
601
|
+
* a body can hold and a caller looks results up in one place.
|
|
572
602
|
* **Keyed by note, then by the directive's ordinal within it** — not by its
|
|
573
603
|
* line. The passes do not agree on what a body is: `walkMarkdownTree` trims it,
|
|
574
604
|
* while the link checker strips the frontmatter fence and leaves the newlines
|
|
@@ -578,8 +608,9 @@ export function renderSqlTable(
|
|
|
578
608
|
* @returns {Promise<Map<string, object[]>>} Note to results, in document order,
|
|
579
609
|
* each carrying either a rendered `markdown` and its `rows`, or a `reason`.
|
|
580
610
|
*/
|
|
581
|
-
export async function prepareSqlTables(db, sources, { linkable } = {}) {
|
|
611
|
+
export async function prepareSqlTables(db, sources, { linkable, records } = {}) {
|
|
582
612
|
const prepared = new Map();
|
|
613
|
+
if (records) prepared.pageLists = preparePageLists(records, sources);
|
|
583
614
|
for (const { source, markdown } of sources) {
|
|
584
615
|
const blocks = findSqlBlocks(markdown);
|
|
585
616
|
if (!blocks.length) continue;
|
|
@@ -658,10 +689,14 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
658
689
|
* shared index
|
|
659
690
|
* describes, where N passes each derive the corpus their own way.
|
|
660
691
|
*
|
|
661
|
-
* **
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
692
|
+
* **Every corpus-reading directive is answered here**, which is why one call
|
|
693
|
+
* precedes every pass: the `sql` tables, the inline scalar queries, and the
|
|
694
|
+
* page lists, whose results ride on the same object as `pageLists`.
|
|
695
|
+
*
|
|
696
|
+
* **Nothing is opened for a tree that asks no query.** A tree with no `sql`
|
|
697
|
+
* directive costs one walk and no database at all, and so does one whose only
|
|
698
|
+
* directive is a page list — a page list is a filter over the records. That is
|
|
699
|
+
* what lets every pass call this unconditionally.
|
|
665
700
|
*
|
|
666
701
|
* @param {string} contentBase - Root of the content tree.
|
|
667
702
|
* @param {object} [opts]
|
|
@@ -671,8 +706,9 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
671
706
|
* A command that also builds a link index holds them already, and deriving
|
|
672
707
|
* them twice is the duplicated-corpus failure this closes.
|
|
673
708
|
* @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
|
|
674
|
-
* @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
|
|
675
|
-
* nothing when the tree
|
|
709
|
+
* @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
|
|
710
|
+
* carrying `inline` and `pageLists` beside them, or nothing when the tree
|
|
711
|
+
* holds no such directive.
|
|
676
712
|
*/
|
|
677
713
|
export async function prepareTreeSqlTables(
|
|
678
714
|
contentBase,
|
|
@@ -701,10 +737,30 @@ export async function prepareTreeSqlTables(
|
|
|
701
737
|
if (!isNoteRecord(record)) continue;
|
|
702
738
|
const absPath = noteFile(contentBase, record);
|
|
703
739
|
const { body, frontmatter } = parseMarkdownFile(absPath);
|
|
704
|
-
if (
|
|
740
|
+
if (
|
|
741
|
+
body &&
|
|
742
|
+
(findSqlBlocks(body).length ||
|
|
743
|
+
findPageListBlocks(body).length ||
|
|
744
|
+
sqlQueriesInMarkdown(body, frontmatter).length)
|
|
745
|
+
)
|
|
705
746
|
sources.push({ source: absPath, markdown: body, frontmatter });
|
|
706
747
|
}
|
|
707
748
|
if (!sources.length) return undefined;
|
|
749
|
+
|
|
750
|
+
// A page list is a filter over the records, so it needs no database. A tree
|
|
751
|
+
// whose only corpus-reading directive is one therefore opens none — which
|
|
752
|
+
// is the same bargain the early return above makes for a tree with no
|
|
753
|
+
// directive at all.
|
|
754
|
+
const needsDatabase = sources.some(
|
|
755
|
+
({ markdown, frontmatter }) =>
|
|
756
|
+
findSqlBlocks(markdown).length || sqlQueriesInMarkdown(markdown, frontmatter).length,
|
|
757
|
+
);
|
|
758
|
+
if (!needsDatabase) {
|
|
759
|
+
const pageOnly = new Map();
|
|
760
|
+
pageOnly.inline = new Map();
|
|
761
|
+
pageOnly.pageLists = preparePageLists(indexRecords, sources);
|
|
762
|
+
return pageOnly;
|
|
763
|
+
}
|
|
708
764
|
// A cell links only where the address it would emit resolves, so a table
|
|
709
765
|
// never ships a link the wikilink pass will then report dead.
|
|
710
766
|
//
|
|
@@ -736,6 +792,7 @@ export async function prepareTreeSqlTables(
|
|
|
736
792
|
try {
|
|
737
793
|
const prepared = await prepareSqlTables(db, sources, {
|
|
738
794
|
linkable: (ref) => addresses.has(renderAddress(ref)),
|
|
795
|
+
records: indexRecords,
|
|
739
796
|
});
|
|
740
797
|
prepared.inline = await prepareInlineSqlExpressions(db, sources);
|
|
741
798
|
return prepared;
|
package/engine/standings.mjs
CHANGED
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
* what lets each be checked — a rung the body does not confer and an office it
|
|
41
41
|
* does not hold are both findings naming the file, line and column.
|
|
42
42
|
*
|
|
43
|
+
* **An entry states a rung, and `rank` is required.** Belonging to a body is
|
|
44
|
+
* holding some standing in it, so `1` is the ordinary member and `0` is the rung
|
|
45
|
+
* for someone cast out — between them they cover every standing a member can
|
|
46
|
+
* hold, which is what makes the requirement writable. `office` is the optional
|
|
47
|
+
* half, and an entry carrying one and no rung is the same fault rather than a
|
|
48
|
+
* second: an office distinguishes a person within a standing rather than
|
|
49
|
+
* standing in for one.
|
|
50
|
+
*
|
|
43
51
|
* @module
|
|
44
52
|
*/
|
|
45
53
|
|
|
@@ -122,6 +130,10 @@ export function readStandings(value) {
|
|
|
122
130
|
* than by its position in the list: a body may write its rungs in any order,
|
|
123
131
|
* and several start at zero.
|
|
124
132
|
*
|
|
133
|
+
* One title per level, which is what the rung check holds a ladder to: two
|
|
134
|
+
* rungs claiming one level is a finding on the second, so a ladder that reaches
|
|
135
|
+
* here carries at most one title for any number a member's `rank` can hold.
|
|
136
|
+
*
|
|
125
137
|
* @param {unknown} ranks - An affiliation's `data.governance.ranks`.
|
|
126
138
|
* @returns {Map<number, string>} Level → the title that level is called.
|
|
127
139
|
*/
|
|
@@ -177,6 +189,22 @@ export function officeAnchor(office) {
|
|
|
177
189
|
return slug ? `office-${slug}` : "";
|
|
178
190
|
}
|
|
179
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The anchor a rung's row is addressed by.
|
|
194
|
+
*
|
|
195
|
+
* The ladder's parallel to {@link officeAnchor}, prefixed apart from it so a
|
|
196
|
+
* body naming an office the same word as one of its ranks still gets two
|
|
197
|
+
* destinations rather than one row answering for both.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} title - The rung's title, as the body wrote it.
|
|
200
|
+
* @returns {string} The anchor, or `""` where the title carries nothing
|
|
201
|
+
* URL-safe.
|
|
202
|
+
*/
|
|
203
|
+
export function rankAnchor(title) {
|
|
204
|
+
const slug = slugify(title);
|
|
205
|
+
return slug ? `rank-${slug}` : "";
|
|
206
|
+
}
|
|
207
|
+
|
|
180
208
|
/**
|
|
181
209
|
* What an affiliation lends to a reference to it.
|
|
182
210
|
*
|
|
@@ -234,12 +262,14 @@ export function standingPhrase(standing, body, digest) {
|
|
|
234
262
|
/**
|
|
235
263
|
* Validate a being's memberships and the standing it holds in each.
|
|
236
264
|
*
|
|
237
|
-
*
|
|
265
|
+
* Five questions, and the last three are what keying the entry by its body
|
|
266
|
+
* buys:
|
|
238
267
|
*
|
|
239
268
|
* 1. the value is a map keyed by Address (or the list this window still takes);
|
|
240
269
|
* 2. every key names an affiliation that resolves, once;
|
|
241
|
-
* 3.
|
|
242
|
-
* 4.
|
|
270
|
+
* 3. the entry states a `rank`;
|
|
271
|
+
* 4. a `rank` is a level the named body's `governance.ranks` declares;
|
|
272
|
+
* 5. an `office` is a key of that body's `governance.offices`.
|
|
243
273
|
*
|
|
244
274
|
* Both of the last two are read from the named body's own frontmatter as the
|
|
245
275
|
* lint runs, so a renamed rung or a renamed post fails on every being pointing
|
|
@@ -287,9 +317,18 @@ export function checkStandings(note, { index } = {}) {
|
|
|
287
317
|
seen.set(resolved.address, sourceKey);
|
|
288
318
|
}
|
|
289
319
|
if (!isKey) continue;
|
|
290
|
-
if (standing === undefined || standing === null) continue;
|
|
291
|
-
if (Array.isArray(standing) && standing.length === 0) continue;
|
|
292
320
|
const named = resolved.address ?? String(body);
|
|
321
|
+
// An entry with nothing after its key, and an emptied map arriving from
|
|
322
|
+
// the property editor as `[]`, say exactly what an authored `{}` says:
|
|
323
|
+
// this being belongs to this body, and no standing in it is stated.
|
|
324
|
+
if (
|
|
325
|
+
standing === undefined ||
|
|
326
|
+
standing === null ||
|
|
327
|
+
(Array.isArray(standing) && standing.length === 0)
|
|
328
|
+
) {
|
|
329
|
+
findings.push(...absentRankFindings(note, {}, named, path));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
293
332
|
if (!mapping(standing)) {
|
|
294
333
|
findings.push({
|
|
295
334
|
...position(note, path),
|
|
@@ -309,6 +348,7 @@ export function checkStandings(note, { index } = {}) {
|
|
|
309
348
|
`standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
|
|
310
349
|
});
|
|
311
350
|
}
|
|
351
|
+
findings.push(...absentRankFindings(note, standing, named, path));
|
|
312
352
|
findings.push(...rankFindings(note, standing, named, path, resolved));
|
|
313
353
|
findings.push(...officeFindings(note, standing, named, path, resolved));
|
|
314
354
|
}
|
|
@@ -344,7 +384,7 @@ function resolveBody(note, index, body) {
|
|
|
344
384
|
const tuple = parseAddress(
|
|
345
385
|
body,
|
|
346
386
|
{
|
|
347
|
-
package: index?.contentPackage
|
|
387
|
+
package: index?.contentPackage,
|
|
348
388
|
system: "note",
|
|
349
389
|
type: "affiliation",
|
|
350
390
|
types: index?.types,
|
|
@@ -382,6 +422,49 @@ function bodyFindings(note, body, path, isKey, found) {
|
|
|
382
422
|
return [];
|
|
383
423
|
}
|
|
384
424
|
|
|
425
|
+
/**
|
|
426
|
+
* What an entry states no `rank` earns.
|
|
427
|
+
*
|
|
428
|
+
* Every member of a body holds some standing in it, and the ladders say so at
|
|
429
|
+
* both ends: `1` is the ordinary member, and `0` is a real rung rather than a
|
|
430
|
+
* default — `0 Níding`, `0 Outlaw`, `0 Vrystrith`, `0 Struck from the Roll` all
|
|
431
|
+
* name someone cast out, who still has a standing. So an entry naming a body
|
|
432
|
+
* and no rung is incomplete, and it is an **error**.
|
|
433
|
+
*
|
|
434
|
+
* **The message carries the rule, not just the gap.** "States no `rank`" sends a
|
|
435
|
+
* reader to find out which number to write; naming `1` and `0` is the whole
|
|
436
|
+
* answer, and the two ends are what makes the requirement writable — there is no
|
|
437
|
+
* standing a member can hold that one of them does not cover.
|
|
438
|
+
*
|
|
439
|
+
* **One finding per entry.** An entry carrying an office and no rung is the same
|
|
440
|
+
* fault and not a second one: an office distinguishes a person *within* a
|
|
441
|
+
* standing rather than standing in for one, so the office is named here as the
|
|
442
|
+
* reason the entry looked complete, rather than reported on its own line as well.
|
|
443
|
+
*
|
|
444
|
+
* @param {object} note - The note.
|
|
445
|
+
* @param {object} standing - The authored entry.
|
|
446
|
+
* @param {string} body - How the body is named in a message.
|
|
447
|
+
* @param {Array<string|number>} path - The entry's frontmatter path.
|
|
448
|
+
* @returns {object[]} The finding, or nothing.
|
|
449
|
+
*/
|
|
450
|
+
function absentRankFindings(note, standing, body, path) {
|
|
451
|
+
if (standing.rank !== undefined && standing.rank !== null) return [];
|
|
452
|
+
const office = typeof standing.office === "string" ? standing.office.trim() : "";
|
|
453
|
+
return [
|
|
454
|
+
{
|
|
455
|
+
...position(note, path, true),
|
|
456
|
+
message:
|
|
457
|
+
`\`data.affiliations\` entry for ${body} states no \`rank\`` +
|
|
458
|
+
(office ?
|
|
459
|
+
`, and the office ${JSON.stringify(office)} is not one — an office ` +
|
|
460
|
+
`distinguishes a person within a standing rather than standing in ` +
|
|
461
|
+
`for one`
|
|
462
|
+
: " — a being that belongs to a body holds some standing in it") +
|
|
463
|
+
". An ordinary member is `1`, and `0` is the rung for someone cast out",
|
|
464
|
+
},
|
|
465
|
+
];
|
|
466
|
+
}
|
|
467
|
+
|
|
385
468
|
/** Whether a `rank` is a rung the named body confers. */
|
|
386
469
|
function rankFindings(note, standing, body, path, resolved) {
|
|
387
470
|
if (standing.rank === undefined || standing.rank === null) return [];
|
package/engine/system-block.mjs
CHANGED
|
@@ -339,17 +339,18 @@ export function sharedProperty(fm, source, defaultValue = undefined) {
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
/**
|
|
342
|
-
* A property a system block
|
|
342
|
+
* A property a system block declares, else the default.
|
|
343
343
|
*
|
|
344
|
-
* This is what gives `
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* in
|
|
344
|
+
* This is what gives `effects` and `flags` their per-system form without
|
|
345
|
+
* inventing a mechanism for each: a note states them inside the block whose
|
|
346
|
+
* document carries them, which is the only position the format accepts for
|
|
347
|
+
* either. A note needing one value for both systems writes it in both blocks —
|
|
348
|
+
* they are properties of a document, and there are two documents.
|
|
348
349
|
*
|
|
349
350
|
* @param {object} fm - The note's frontmatter.
|
|
350
351
|
* @param {string} block - The block key.
|
|
351
352
|
* @param {string} key - The property.
|
|
352
|
-
* @param {any} [defaultValue] - Returned when
|
|
353
|
+
* @param {any} [defaultValue] - Returned when the block does not declare it.
|
|
353
354
|
* @returns {any} The value.
|
|
354
355
|
*/
|
|
355
356
|
export function blockProperty(fm, block, key, defaultValue = undefined) {
|
|
@@ -357,8 +358,7 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
|
|
|
357
358
|
if (declared && declared[key] !== undefined && declared[key] !== null) {
|
|
358
359
|
return declared[key];
|
|
359
360
|
}
|
|
360
|
-
|
|
361
|
-
return shared === undefined || shared === null ? defaultValue : shared;
|
|
361
|
+
return defaultValue;
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/**
|
|
@@ -411,12 +411,10 @@ const DATA_PREFIX = "data.";
|
|
|
411
411
|
/**
|
|
412
412
|
* The bare top-level key a `data:`-sourced field is being swept off — step 3b.
|
|
413
413
|
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* every other key that move relocated. Deriving it is what keeps the two
|
|
419
|
-
* spellings of one field from disagreeing the way two declarations would.
|
|
414
|
+
* The retiring spelling of `data.portrait` is not a second declaration anyone
|
|
415
|
+
* has to write — it is `portrait`, mechanically, and the same holds for every
|
|
416
|
+
* other key the container gathered. Deriving it is what keeps the two spellings
|
|
417
|
+
* of one field from disagreeing the way two declarations would.
|
|
420
418
|
*
|
|
421
419
|
* **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
|
|
422
420
|
* paths into containers a note has always written at the top level; they were
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -64,6 +64,26 @@ export { slugify };
|
|
|
64
64
|
import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
|
|
65
65
|
import { resolveEmbeds } from "./content-embeds.mjs";
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Whether a page's declared anchors include the slug a link names.
|
|
69
|
+
*
|
|
70
|
+
* A local page's entry carries a `Set` (built by {@link module:engine/site-index});
|
|
71
|
+
* a foreign one carries the `{slug: uuid}` map a fetched manifest publishes.
|
|
72
|
+
* `undefined` means neither build recorded an answer, which keeps a target
|
|
73
|
+
* this check cannot speak for exactly as unchecked as it always was.
|
|
74
|
+
*
|
|
75
|
+
* @param {Set<string>|Record<string, string>|undefined} anchors - The page's
|
|
76
|
+
* declared anchors, or `undefined` where none were recorded.
|
|
77
|
+
* @param {string} slug - The anchor a link names.
|
|
78
|
+
* @returns {boolean} Whether the anchor is declared, or `true` when `anchors`
|
|
79
|
+
* is `undefined` — which reads as "nothing to check against" rather than
|
|
80
|
+
* "declares none".
|
|
81
|
+
*/
|
|
82
|
+
function hasAnchor(anchors, slug) {
|
|
83
|
+
if (!anchors) return true;
|
|
84
|
+
return anchors instanceof Set ? anchors.has(slug) : Object.hasOwn(anchors, slug);
|
|
85
|
+
}
|
|
86
|
+
|
|
67
87
|
/**
|
|
68
88
|
* The index key a **piped** target resolves to, or `null` when it does not
|
|
69
89
|
* parse as an address at all.
|
|
@@ -315,7 +335,9 @@ function isPlainMap(value) {
|
|
|
315
335
|
* resolves against. `src` is the page's display
|
|
316
336
|
* path and `file` the source file a diagnostic should name — absent, `src`
|
|
317
337
|
* stands in. `resolved`, when supplied, is the array every resolved
|
|
318
|
-
* target's index entry is appended to.
|
|
338
|
+
* target's index entry is appended to. `anchors` is the `{#slug}` set
|
|
339
|
+
* *this* page declares, which a `[[#slug]]` self-link is checked against;
|
|
340
|
+
* absent, a self-link is not checked, exactly as before this existed.
|
|
319
341
|
* @returns {string} The body with embeds and wikilinks rewritten.
|
|
320
342
|
*/
|
|
321
343
|
export function resolveWebWikilinks(body, ctx) {
|
|
@@ -399,6 +421,16 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
399
421
|
|
|
400
422
|
// `[[#section-slug|Text]]` — a section of this same page.
|
|
401
423
|
if (isSamePage({ target, anchor })) {
|
|
424
|
+
// Checked against this page's own anchors, exactly as a link into
|
|
425
|
+
// another page is: a self-link naming a section the page does not
|
|
426
|
+
// declare is as dead as one naming a section nowhere does.
|
|
427
|
+
if (!hasAnchor(ctx.anchors, anchor)) {
|
|
428
|
+
return report(
|
|
429
|
+
all,
|
|
430
|
+
{ target, reason: "unknown-anchor", anchor, addressed: true },
|
|
431
|
+
label ?? anchor,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
402
434
|
return `[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`;
|
|
403
435
|
}
|
|
404
436
|
|
|
@@ -419,6 +451,21 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
419
451
|
lookupRead(ctx.foreign, read, ctx.contentPackage)
|
|
420
452
|
: undefined);
|
|
421
453
|
if (hit) {
|
|
454
|
+
// A `#section` the target declares no heading for. Checked only
|
|
455
|
+
// where there is a page to check against — a pack-only hit has no
|
|
456
|
+
// `url` and therefore nothing for the anchor to address, which is
|
|
457
|
+
// the asset-like case this must not touch. `anchorPageId` on the
|
|
458
|
+
// pack build and the KB manifest's own `{slug: uuid}` map both
|
|
459
|
+
// hash any slug into something that resolves, so an undeclared
|
|
460
|
+
// one otherwise joins onto the URL unchecked and dead-ends for
|
|
461
|
+
// the reader.
|
|
462
|
+
if (anchor && hit.url && !hasAnchor(hit.anchors, anchor)) {
|
|
463
|
+
return report(
|
|
464
|
+
all,
|
|
465
|
+
{ target, reason: "unknown-anchor", anchor, addressed: true },
|
|
466
|
+
label ?? hit.name ?? target,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
422
469
|
// The edge, for a caller reading the link graph off this pass.
|
|
423
470
|
ctx.resolved?.push(hit);
|
|
424
471
|
// An address with an *empty* label has no prose to show (a
|
package/hm3/actors.mjs
CHANGED
|
@@ -227,9 +227,9 @@ export class Hm3Actors extends SystemActorCompiler {
|
|
|
227
227
|
/**
|
|
228
228
|
* Build every embedded item an HM3 actor carries, from `hm3.items`.
|
|
229
229
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
230
|
+
* HM3 keeps a character's abilities in `system.abilities` rather than as
|
|
231
|
+
* embedded documents, so an ability reaches the document through the
|
|
232
|
+
* `hm3.system` passthrough and never through this list.
|
|
233
233
|
*
|
|
234
234
|
* @param {Map<string, object>} itemsMap - The predefined items, by address.
|
|
235
235
|
* @param {string} actorId - The owning actor's id, seeding embedded ids.
|
|
@@ -333,6 +333,9 @@ export class Hm3Actors extends SystemActorCompiler {
|
|
|
333
333
|
isAddress: true,
|
|
334
334
|
});
|
|
335
335
|
|
|
336
|
+
this.checkAnchoredSection(body || "", "appearance", ctx);
|
|
337
|
+
this.checkAnchoredSection(body || "", "dossier", ctx);
|
|
338
|
+
|
|
336
339
|
const system = {
|
|
337
340
|
// `system.bioImage` is **not** written here, for the reason SoHL's
|
|
338
341
|
// `system.portrait` is not: a being's portrait is the lead image of
|
package/manifest.mjs
CHANGED
|
@@ -52,6 +52,7 @@ import fs from "node:fs/promises";
|
|
|
52
52
|
import fsSync from "node:fs";
|
|
53
53
|
import path from "node:path";
|
|
54
54
|
|
|
55
|
+
import { withBaseStyle } from "./engine/base-styles.mjs";
|
|
55
56
|
import { emitDiagnostic, formatDiagnostic, positionOfYamlPath } from "./engine/diagnostics.mjs";
|
|
56
57
|
import { metadataFileName } from "./engine/metadata-index.mjs";
|
|
57
58
|
|
|
@@ -548,6 +549,18 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
|
|
|
548
549
|
|
|
549
550
|
const merged = { ...declared, ...derived };
|
|
550
551
|
|
|
552
|
+
// The shared base stylesheet, named ahead of whatever the package declares.
|
|
553
|
+
// Derived rather than declared, because every package needs it and four of
|
|
554
|
+
// the five that do have no stylesheet of their own to name it from. See
|
|
555
|
+
// `engine/base-styles.mjs` for the layer it loads into and why that is what
|
|
556
|
+
// lets a package override it.
|
|
557
|
+
const styles = withBaseStyle({
|
|
558
|
+
declared: declared.styles,
|
|
559
|
+
artifact: /** @type {"system"|"module"} */ (artifact),
|
|
560
|
+
enabled: config.packageBuild?.baseStyles !== false,
|
|
561
|
+
});
|
|
562
|
+
if (styles !== undefined) merged.styles = styles;
|
|
563
|
+
|
|
551
564
|
// The index every consumer resolves this package's addresses through.
|
|
552
565
|
// Written unconditionally, because a package that publishes no
|
|
553
566
|
// index is one nothing can link into — and the failure of an absent one is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.22.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",
|