@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
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
|
@@ -104,21 +104,24 @@
|
|
|
104
104
|
*
|
|
105
105
|
* A field's `name` is both its identity and the shared property it draws from,
|
|
106
106
|
* and those coincide only while the two vocabularies agree about what the
|
|
107
|
-
* spelling means
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
107
|
+
* spelling means — or while the top-level vocabulary admits the spelling at
|
|
108
|
+
* all. They do not always. An `affiliation` item's `system.title` is the style
|
|
109
|
+
* of address an office carries — "Ajaw", "Warden"; a note's top-level `title`
|
|
110
|
+
* is refused outright by the closed top-level vocabulary, which has no `title`
|
|
111
|
+
* entry. Nothing relates them, and step 3 fed the first from the second anyway.
|
|
111
112
|
*
|
|
112
113
|
* It was not a harmless coincidence either, because step 3 answers **without**
|
|
113
114
|
* applying `field.default` — only step 2 does — so an authored `title: null`
|
|
114
115
|
* reached the field's coercion unguarded and shipped as the literal string
|
|
115
116
|
* `"null"` in fifteen documents.
|
|
116
117
|
*
|
|
117
|
-
* So a field may declare `topLevelMeans`:
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* the
|
|
118
|
+
* So a field may declare `topLevelMeans`: why the note's top-level key of this
|
|
119
|
+
* name is not this field's source — declared wherever that key's spelling
|
|
120
|
+
* means something else at the note level, or the closed top-level vocabulary
|
|
121
|
+
* refuses it outright. Declaring it removes the whole shared level — step 3
|
|
122
|
+
* and the retiring 3b alike, since both read the note's top level and the
|
|
123
|
+
* objection is to that level, not to a spelling — leaving the two positions
|
|
124
|
+
* that describe the document rather than the note. It is deliberately
|
|
122
125
|
* a per-field opt-out rather than a change to the order — step 3 is right
|
|
123
126
|
* wherever the two levels state the same quantity, which is nearly everywhere —
|
|
124
127
|
* and its value is the reason rather than a bare flag, so the collision is
|
|
@@ -339,17 +342,18 @@ export function sharedProperty(fm, source, defaultValue = undefined) {
|
|
|
339
342
|
}
|
|
340
343
|
|
|
341
344
|
/**
|
|
342
|
-
* A property a system block
|
|
345
|
+
* A property a system block declares, else the default.
|
|
343
346
|
*
|
|
344
|
-
* This is what gives `
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* in
|
|
347
|
+
* This is what gives `effects` and `flags` their per-system form without
|
|
348
|
+
* inventing a mechanism for each: a note states them inside the block whose
|
|
349
|
+
* document carries them, which is the only position the format accepts for
|
|
350
|
+
* either. A note needing one value for both systems writes it in both blocks —
|
|
351
|
+
* they are properties of a document, and there are two documents.
|
|
348
352
|
*
|
|
349
353
|
* @param {object} fm - The note's frontmatter.
|
|
350
354
|
* @param {string} block - The block key.
|
|
351
355
|
* @param {string} key - The property.
|
|
352
|
-
* @param {any} [defaultValue] - Returned when
|
|
356
|
+
* @param {any} [defaultValue] - Returned when the block does not declare it.
|
|
353
357
|
* @returns {any} The value.
|
|
354
358
|
*/
|
|
355
359
|
export function blockProperty(fm, block, key, defaultValue = undefined) {
|
|
@@ -357,27 +361,51 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
|
|
|
357
361
|
if (declared && declared[key] !== undefined && declared[key] !== null) {
|
|
358
362
|
return declared[key];
|
|
359
363
|
}
|
|
360
|
-
|
|
361
|
-
return shared === undefined || shared === null ? defaultValue : shared;
|
|
364
|
+
return defaultValue;
|
|
362
365
|
}
|
|
363
366
|
|
|
364
367
|
/**
|
|
365
|
-
* A system override followed by a shared `data:` value
|
|
366
|
-
*
|
|
368
|
+
* A system override followed by a shared `data:` value followed by the
|
|
369
|
+
* retiring top-level spelling `data:` gathered it off, reporting which one
|
|
370
|
+
* answered.
|
|
371
|
+
*
|
|
372
|
+
* {@link module:engine/field-spec.resolveFieldValue}'s counterpart for a
|
|
373
|
+
* universal `data:` key — `pack` and `packFolder`, declared for every note
|
|
374
|
+
* type rather than per type, so neither is a `FieldSpec` that resolver takes.
|
|
375
|
+
* The order is the same shape: a system's own override, then the shared
|
|
376
|
+
* `data:` position, then the bare top-level key a tree authored before
|
|
377
|
+
* `data:` existed. The top-level value stays readable while a tree still on
|
|
378
|
+
* it moves its authored fields.
|
|
367
379
|
*
|
|
368
380
|
* @param {object} fm - The note's frontmatter.
|
|
369
381
|
* @param {string} block - The system block key.
|
|
370
382
|
* @param {string} key - The shared property.
|
|
371
383
|
* @param {any} [defaultValue] - Returned when no position declares it.
|
|
372
|
-
* @returns {any} The
|
|
384
|
+
* @returns {{value: any, from: FieldSource}} The value, and where it came
|
|
385
|
+
* from.
|
|
373
386
|
*/
|
|
374
|
-
export function
|
|
387
|
+
export function resolveDataProperty(fm, block, key, defaultValue = undefined) {
|
|
375
388
|
const declared = systemBlock(fm, block)?.[key];
|
|
376
|
-
if (declared !== undefined && declared !== null) return declared;
|
|
389
|
+
if (declared !== undefined && declared !== null) return { value: declared, from: "block" };
|
|
377
390
|
const shared = isMapping(fm?.data) ? fm.data[key] : undefined;
|
|
378
|
-
if (shared !== undefined && shared !== null) return shared;
|
|
391
|
+
if (shared !== undefined && shared !== null) return { value: shared, from: "shared" };
|
|
379
392
|
const legacy = isMapping(fm) ? fm[key] : undefined;
|
|
380
|
-
|
|
393
|
+
if (legacy !== undefined && legacy !== null) return { value: legacy, from: "topLevel" };
|
|
394
|
+
return { value: defaultValue, from: "default" };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* {@link resolveDataProperty}'s value alone, for a caller that only compiles
|
|
399
|
+
* a document and has no use for which position answered.
|
|
400
|
+
*
|
|
401
|
+
* @param {object} fm - The note's frontmatter.
|
|
402
|
+
* @param {string} block - The system block key.
|
|
403
|
+
* @param {string} key - The shared property.
|
|
404
|
+
* @param {any} [defaultValue] - Returned when no position declares it.
|
|
405
|
+
* @returns {any} The selected value.
|
|
406
|
+
*/
|
|
407
|
+
export function blockDataProperty(fm, block, key, defaultValue = undefined) {
|
|
408
|
+
return resolveDataProperty(fm, block, key, defaultValue).value;
|
|
381
409
|
}
|
|
382
410
|
|
|
383
411
|
/**
|
|
@@ -411,12 +439,10 @@ const DATA_PREFIX = "data.";
|
|
|
411
439
|
/**
|
|
412
440
|
* The bare top-level key a `data:`-sourced field is being swept off — step 3b.
|
|
413
441
|
*
|
|
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.
|
|
442
|
+
* The retiring spelling of `data.portrait` is not a second declaration anyone
|
|
443
|
+
* has to write — it is `portrait`, mechanically, and the same holds for every
|
|
444
|
+
* other key the container gathered. Deriving it is what keeps the two spellings
|
|
445
|
+
* of one field from disagreeing the way two declarations would.
|
|
420
446
|
*
|
|
421
447
|
* **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
|
|
422
448
|
* 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
|