@heroiclands/package-build 22.9.1 → 22.10.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 +54 -0
- package/CONTENT.md +33 -0
- package/bin/content-build.mjs +3 -1
- package/docs/api.md +10 -6
- package/docs/content-format.md +384 -200
- package/docs/project-setup.md +23 -0
- package/engine/actor-compiler.mjs +20 -7
- package/engine/address-fields.mjs +404 -0
- package/engine/address-values.mjs +80 -0
- package/engine/address.mjs +769 -0
- package/engine/art-fields.mjs +112 -97
- package/engine/art-slots.mjs +81 -0
- package/engine/base-compiler.mjs +59 -7
- package/engine/bundle-notes.mjs +5 -2
- package/engine/bundles.mjs +17 -10
- package/engine/content-address.mjs +23 -186
- package/engine/content-embeds.mjs +18 -4
- package/engine/content-format-check.mjs +50 -22
- package/engine/content-index.mjs +85 -43
- package/engine/content-links.mjs +105 -104
- package/engine/content-lint.mjs +11 -1
- package/engine/content-tables.mjs +9 -1
- package/engine/field-spec.mjs +20 -4
- package/engine/folder-notes.mjs +44 -15
- package/engine/foundry-entries.mjs +3 -0
- package/engine/frontmatter-lint.mjs +144 -43
- package/engine/frontmatter.mjs +55 -10
- package/engine/generate.mjs +7 -1
- package/engine/helpers.mjs +37 -9
- package/engine/holdings.mjs +41 -12
- package/engine/index-records.mjs +2 -0
- package/engine/infobox-render.mjs +2 -1
- package/engine/infobox.mjs +26 -6
- package/engine/item-compiler.mjs +5 -0
- package/engine/item-docs.mjs +7 -44
- package/engine/map-dot.mjs +2 -0
- package/engine/map-notes.mjs +7 -2
- package/engine/map-places.mjs +42 -10
- package/engine/metadata-index.mjs +60 -5
- package/engine/note-addresses.mjs +310 -0
- package/engine/note-vocabulary.mjs +145 -26
- package/engine/pdf-build.mjs +1 -1
- package/engine/pdf-toc.mjs +17 -2
- package/engine/place-relations.mjs +160 -50
- package/engine/populations.mjs +17 -9
- package/engine/reference-targets.mjs +54 -0
- package/engine/scenes.mjs +5 -2
- package/engine/shortcode-references.mjs +68 -0
- package/engine/site-build.mjs +39 -15
- package/engine/site-index.mjs +57 -75
- package/engine/sql-tables.mjs +43 -9
- package/engine/system-block.mjs +21 -0
- package/engine/system-vocabulary.mjs +46 -0
- package/engine/web-wikilinks.mjs +11 -9
- package/engine/wikilink-syntax.mjs +21 -0
- package/engine/wikilinks.mjs +92 -306
- package/githooks/attribution.mjs +82 -0
- package/githooks/commit-message.mjs +26 -0
- package/githooks/commit-msg +3 -35
- package/hm3/actors.mjs +8 -24
- package/hm3/item-fields.mjs +15 -5
- package/package.json +1 -1
- package/sohl/infobox.mjs +80 -17
- package/sohl/item-fields.mjs +114 -41
- package/types/engine/address-fields.d.mts +90 -0
- package/types/engine/address-values.d.mts +34 -0
- package/types/engine/address.d.mts +452 -0
- package/types/engine/art-fields.d.mts +59 -53
- package/types/engine/art-slots.d.mts +40 -0
- package/types/engine/base-compiler.d.mts +24 -2
- package/types/engine/bundle-notes.d.mts +2 -2
- package/types/engine/content-address.d.mts +3 -139
- package/types/engine/content-embeds.d.mts +1 -1
- package/types/engine/content-format-check.d.mts +25 -0
- package/types/engine/content-index.d.mts +2 -2
- package/types/engine/content-links.d.mts +6 -5
- package/types/engine/field-spec.d.mts +44 -9
- package/types/engine/folder-notes.d.mts +11 -10
- package/types/engine/frontmatter-lint.d.mts +7 -97
- package/types/engine/frontmatter.d.mts +16 -1
- package/types/engine/helpers.d.mts +2 -2
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/item-docs.d.mts +1 -35
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-addresses.d.mts +41 -0
- package/types/engine/note-vocabulary.d.mts +20 -3
- package/types/engine/place-relations.d.mts +12 -1
- package/types/engine/populations.d.mts +17 -2
- package/types/engine/reference-targets.d.mts +9 -0
- package/types/engine/shortcode-references.d.mts +14 -0
- package/types/engine/site-build.d.mts +3 -3
- package/types/engine/site-index.d.mts +9 -30
- package/types/engine/sql-tables.d.mts +2 -2
- package/types/engine/system-block.d.mts +17 -0
- package/types/engine/system-vocabulary.d.mts +16 -0
- package/types/engine/wikilink-syntax.d.mts +7 -0
- package/types/engine/wikilinks.d.mts +6 -109
- package/types/hm3/item-fields.d.mts +5 -0
- package/types/sohl/infobox.d.mts +4 -5
- package/types/sohl/item-fields.d.mts +8 -0
package/docs/project-setup.md
CHANGED
|
@@ -95,6 +95,29 @@ precedence: a plain `git config` sets one clone, `--global` sets a machine.
|
|
|
95
95
|
The defaults differ deliberately: a guard that costs nothing is on unless
|
|
96
96
|
refused, and one that runs a container for minutes is off unless asked for.
|
|
97
97
|
|
|
98
|
+
The commit-message guard requires Node, like the rest of the toolchain. It checks
|
|
99
|
+
complete coauthor trailers and standalone credit clauses, with indentation,
|
|
100
|
+
bullets, emoji, Markdown emphasis and inline links permitted as decoration.
|
|
101
|
+
Supported identities are Codex, ChatGPT, OpenAI Codex, OpenAI ChatGPT, OpenAI,
|
|
102
|
+
Claude, Claude Code, Anthropic,
|
|
103
|
+
GitHub Copilot, Gemini, Cursor, and generic AI assistant credits. Signature forms
|
|
104
|
+
are generated by/with, assisted by, written by, AI-assisted and AI-generated.
|
|
105
|
+
Matching ignores case. Human coauthors such as Claudette Smith and Jean-Claude
|
|
106
|
+
Martin, explanatory prose, and ordinary mentions of these tools pass.
|
|
107
|
+
|
|
108
|
+
Specific assistant mailbox identities also identify a coauthor credit:
|
|
109
|
+
`codex@openai.com`, `chatgpt@openai.com`, `noreply@openai.com`,
|
|
110
|
+
`claude@anthropic.com` and `noreply@anthropic.com`. A provider's email domain
|
|
111
|
+
alone does not identify an assistant. This is an explicit set of credit forms,
|
|
112
|
+
not a classifier for arbitrary natural-language attribution.
|
|
113
|
+
|
|
114
|
+
Git comment lines and the verbose diff after a scissors comment are excluded.
|
|
115
|
+
Findings report the original message line and column. This local hook examines
|
|
116
|
+
commit messages only; the shared No Attribution workflow also checks pull
|
|
117
|
+
request titles and bodies. Neither examines repository files, issues, comments,
|
|
118
|
+
or Git author/committer metadata. The package-build repository's own hook uses
|
|
119
|
+
the same matching code and remains enabled independently of the consumer switch.
|
|
120
|
+
|
|
98
121
|
The branch guard is the one you meet first:
|
|
99
122
|
|
|
100
123
|
```console
|
|
@@ -50,6 +50,7 @@ import fs from "fs";
|
|
|
50
50
|
import path from "path";
|
|
51
51
|
import log from "loglevel";
|
|
52
52
|
|
|
53
|
+
import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
|
|
53
54
|
import { makeId } from "./helpers.mjs";
|
|
54
55
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
55
56
|
// The `{#appearance}` / `{#dossier}` convention is the note format's, so the
|
|
@@ -96,6 +97,7 @@ export function stripCompendiumFields(item) {
|
|
|
96
97
|
*/
|
|
97
98
|
export function isPlainObject(v) {
|
|
98
99
|
return (
|
|
100
|
+
!isAddressTuple(v) &&
|
|
99
101
|
v !== null &&
|
|
100
102
|
typeof v === "object" &&
|
|
101
103
|
!Array.isArray(v) &&
|
|
@@ -705,7 +707,7 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
705
707
|
const key = `${this.documentSubtypes.block}.items`;
|
|
706
708
|
const where = () =>
|
|
707
709
|
locateFrontmatterKey(this.currentNote?.absPath, "items", String(model ?? ""));
|
|
708
|
-
if (typeof model !== "string" || !model.trim()) {
|
|
710
|
+
if (!isAddressTuple(model) && (typeof model !== "string" || !model.trim())) {
|
|
709
711
|
this.noteError(`${ctx}: ${key}[${index}] \`model\` must be an address`, where());
|
|
710
712
|
this.errorCount++;
|
|
711
713
|
return null;
|
|
@@ -715,7 +717,11 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
715
717
|
// dependency whose item catalogue was loaded.
|
|
716
718
|
const types = new Set(Object.keys(this.documentSubtypes.types));
|
|
717
719
|
const packages = new Set([contentPackage(), ...this.foreignPackages]);
|
|
718
|
-
const read =
|
|
720
|
+
const read = parseAddress(
|
|
721
|
+
model,
|
|
722
|
+
{ types, packages, package: contentPackage(), system: this.system },
|
|
723
|
+
{ declared: true, legacyShortcodeCase: true },
|
|
724
|
+
);
|
|
719
725
|
if (!read || read.reason) {
|
|
720
726
|
const why =
|
|
721
727
|
read?.reason === "not-lowercase" ?
|
|
@@ -728,7 +734,15 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
728
734
|
this.errorCount++;
|
|
729
735
|
return null;
|
|
730
736
|
}
|
|
731
|
-
|
|
737
|
+
if (read.system !== this.system || !types.has(read.type)) {
|
|
738
|
+
this.noteError(
|
|
739
|
+
`${ctx}: ${key}[${index}] model must name a ${this.system} item, but names ${renderAddress(read)}`,
|
|
740
|
+
where(),
|
|
741
|
+
);
|
|
742
|
+
this.errorCount++;
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
return read;
|
|
732
746
|
}
|
|
733
747
|
|
|
734
748
|
/**
|
|
@@ -789,10 +803,8 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
789
803
|
this.errorCount++;
|
|
790
804
|
return null;
|
|
791
805
|
}
|
|
792
|
-
// A
|
|
793
|
-
//
|
|
794
|
-
// it; where it does not, the unqualified one is, and a local definition
|
|
795
|
-
// still wins over a dependency's as it always has.
|
|
806
|
+
// A model names one package. Omitted packages are completed at the
|
|
807
|
+
// read boundary; native embedded identities use the local catalogue.
|
|
796
808
|
const address = catalogueKey(
|
|
797
809
|
/** @type {string} */ (subType),
|
|
798
810
|
shortcode ?? "",
|
|
@@ -854,6 +866,7 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
854
866
|
const { data: _authored, ...fields } = overlay ?? {};
|
|
855
867
|
const merged = overlay ? deepMerge(base, fields) : base;
|
|
856
868
|
merged.type = subType;
|
|
869
|
+
if (this.reduceEmittedAddresses(merged.system, itemFields(type, this.system))) return null;
|
|
857
870
|
// The two art rules an item note compiles under, applied to an entry
|
|
858
871
|
// that is one in every respect but where it is written: the address it
|
|
859
872
|
// names wins, the template it copies answers next, and the type's own
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **An authored Address, an emitted Shortcode.**
|
|
16
|
+
*
|
|
17
|
+
* A declared field may hold an Address where the document it compiles into
|
|
18
|
+
* holds a **Shortcode**. An affiliation's `seat` is the worked case: the note
|
|
19
|
+
* names a place at whatever length says what it means — `tashal`,
|
|
20
|
+
* `place-tashal`, `none-place-tashal`, `kethira-none-place-tashal` — and the
|
|
21
|
+
* compiled Item carries `tashal`, which is what the runtime resolves among the
|
|
22
|
+
* items one actor holds, where packages do not exist.
|
|
23
|
+
*
|
|
24
|
+
* So the builder resolves the one into the other, and this module is that step.
|
|
25
|
+
* Storing the written form instead would store a value plus a context that does
|
|
26
|
+
* not travel with it: a four-segment string reaches a runtime that looks up one
|
|
27
|
+
* segment and returns nothing, silently.
|
|
28
|
+
*
|
|
29
|
+
* ## The two questions stay separate
|
|
30
|
+
*
|
|
31
|
+
* A position declares both halves, and they are not the same statement:
|
|
32
|
+
*
|
|
33
|
+
* - **`type`** is what an omitted `<type>` segment takes — the default
|
|
34
|
+
* {@link module:engine/address.parseAddress} fills in.
|
|
35
|
+
* - **`accepts`** is the set of types the position takes at all, tested by
|
|
36
|
+
* {@link module:engine/address.acceptsType} once the value parses.
|
|
37
|
+
*
|
|
38
|
+
* They coincide at every position here, and that is the common case rather than
|
|
39
|
+
* the rule: an art slot defaults to `icon` and accepts `icon` or `image`. So the
|
|
40
|
+
* set is read as its own input and never derived from the default, and a
|
|
41
|
+
* position accepting several types is expressible without touching this module.
|
|
42
|
+
*
|
|
43
|
+
* The distinction is what a value like `being-foobar` turns on. With the package
|
|
44
|
+
* and the system supplied from context it parses to a perfectly good Address —
|
|
45
|
+
* and it is unacceptable to a field that takes a place. The parser does not
|
|
46
|
+
* refuse it, because it is an Address; the **field** refuses it, with an error
|
|
47
|
+
* naming what it accepts.
|
|
48
|
+
*
|
|
49
|
+
* ## Authored defaults belong to the read boundary
|
|
50
|
+
*
|
|
51
|
+
* Parsed Address tuples retain the enclosing block's system. This reduction
|
|
52
|
+
* projects their Shortcode without changing that identity. Direct callers
|
|
53
|
+
* supplying strings provide the position's package and type vocabulary.
|
|
54
|
+
*
|
|
55
|
+
* ## The reduction is lossy, and the collision it loses is legal
|
|
56
|
+
*
|
|
57
|
+
* A shortcode is unique only within one `package-system-type` space, so
|
|
58
|
+
* `foo-sohl-affiliation-b` and `bar-sohl-affiliation-b` are both valid and are
|
|
59
|
+
* two different affiliations. Reducing either to `b` throws away the only thing
|
|
60
|
+
* that told them apart, and the emitted field cannot represent both.
|
|
61
|
+
*
|
|
62
|
+
* That is **an error naming both Addresses**, rather than a silent merge:
|
|
63
|
+
* shipping a standing nobody authored is the worse outcome. It fires on content
|
|
64
|
+
* that is entirely valid, so the message says so instead of implying a mistake.
|
|
65
|
+
*
|
|
66
|
+
* One Address written twice is a different finding, and only where the position
|
|
67
|
+
* holds a map: two keys reducing to one emitted key claim two values for one
|
|
68
|
+
* target, and the field carries one. In a **list** the same Address twice is an
|
|
69
|
+
* ordinary duplicate — nothing is lost, so nothing is reported.
|
|
70
|
+
*
|
|
71
|
+
* ## What this module does not do
|
|
72
|
+
*
|
|
73
|
+
* It resolves nothing against the index: the shortcode is a segment of the
|
|
74
|
+
* Address, so reducing one needs the grammar and the position's declarations
|
|
75
|
+
* and nothing else. Whether the target exists is a separate question, asked
|
|
76
|
+
* where the index is.
|
|
77
|
+
*
|
|
78
|
+
* A failure is a **finding**, never a diagnostic: the caller owns the file and
|
|
79
|
+
* the severity, and locates the finding at the value inside the note.
|
|
80
|
+
*
|
|
81
|
+
* @module
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
import { acceptsType, parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
|
|
85
|
+
import { AddressEntries } from "./address-values.mjs";
|
|
86
|
+
import { systemOf } from "./document-subtypes.mjs";
|
|
87
|
+
import { getFrontmatter } from "./frontmatter.mjs";
|
|
88
|
+
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
|
|
89
|
+
import { authoredKey, setPath } from "./system-block.mjs";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* How many values a position holds.
|
|
93
|
+
*
|
|
94
|
+
* - `value` — the field's value is one Address.
|
|
95
|
+
* - `items` — every element of a list is one.
|
|
96
|
+
* - `keys` — every **key** of a map is one, and the values are untouched.
|
|
97
|
+
*
|
|
98
|
+
* @type {readonly string[]}
|
|
99
|
+
*/
|
|
100
|
+
export const ADDRESS_FIELD_SHAPES = Object.freeze(["value", "items", "keys"]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* An Address position, as a field declaration states it.
|
|
104
|
+
*
|
|
105
|
+
* @typedef {object} AddressPosition
|
|
106
|
+
* @property {string} type - The type an omitted `<type>` segment takes.
|
|
107
|
+
* @property {readonly string[]} accepts - Every type the position accepts.
|
|
108
|
+
* @property {"value"|"items"|"keys"} [holds] - Where the Addresses are.
|
|
109
|
+
* Defaults to `value`.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What a reduction could not do, for a caller to report.
|
|
114
|
+
*
|
|
115
|
+
* @typedef {object} AddressFinding
|
|
116
|
+
* @property {string} key - The field, named as an author writes it.
|
|
117
|
+
* @property {string} written - The authored value, so the caller can locate the
|
|
118
|
+
* finding at the line carrying it rather than at the key.
|
|
119
|
+
* @property {string} message - One sentence, naming the fix.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The declarations that state an Address position.
|
|
124
|
+
*
|
|
125
|
+
* @param {readonly object[]} fields - A field declaration.
|
|
126
|
+
* @returns {object[]} Only the fields carrying an `address` statement.
|
|
127
|
+
*/
|
|
128
|
+
export function addressFields(fields) {
|
|
129
|
+
return (fields ?? []).filter((field) => Boolean(field?.address));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reduce every Address a compiled block holds to the Shortcode it names.
|
|
134
|
+
*
|
|
135
|
+
* The block is edited in place, as {@link
|
|
136
|
+
* module:engine/system-block.mergeSystemData} edits it: what the caller holds is
|
|
137
|
+
* the document being assembled, and a field the reduction cannot read is left
|
|
138
|
+
* exactly as authored — the findings stop the build, so nothing ships either
|
|
139
|
+
* way, and an untouched value is what a reader of the failing note sees.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} emitted - The `system` block, already built.
|
|
142
|
+
* @param {readonly object[]} fields - The type's field declaration.
|
|
143
|
+
* @param {import("./address.mjs").AddressVocabulary & {package?: string}}
|
|
144
|
+
* vocabulary - The tree's vocabularies and the package the values are written
|
|
145
|
+
* in.
|
|
146
|
+
* @returns {AddressFinding[]} Every value that could not be reduced.
|
|
147
|
+
*/
|
|
148
|
+
export function reduceAddressFields(emitted, fields, vocabulary = {}) {
|
|
149
|
+
const findings = [];
|
|
150
|
+
for (const field of addressFields(fields)) {
|
|
151
|
+
const value = getFrontmatter(emitted, field.to, undefined);
|
|
152
|
+
const reduced = reduceOne(value, field, vocabulary, findings);
|
|
153
|
+
if (reduced !== undefined) setPath(emitted, field.to, reduced);
|
|
154
|
+
}
|
|
155
|
+
return findings;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reduce one field's value, by the shape its position holds.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown} value - The emitted value.
|
|
162
|
+
* @param {object} field - The declaration.
|
|
163
|
+
* @param {object} vocabulary - As {@link reduceAddressFields} takes it.
|
|
164
|
+
* @param {AddressFinding[]} findings - Collected, in the order they are found.
|
|
165
|
+
* @returns {unknown} The reduced value, or `undefined` to leave it alone.
|
|
166
|
+
*/
|
|
167
|
+
function reduceOne(value, field, vocabulary, findings) {
|
|
168
|
+
const position = field.address;
|
|
169
|
+
const defaults = positionDefaults(position, vocabulary);
|
|
170
|
+
const shape = position.holds ?? "value";
|
|
171
|
+
// Two keys, or two list entries, reducing to one emitted shortcode: the
|
|
172
|
+
// Address each of them named, by the shortcode all of them land on.
|
|
173
|
+
const seen = new Map();
|
|
174
|
+
const read = (written) => readOne(written, field, defaults, seen, findings, shape);
|
|
175
|
+
|
|
176
|
+
if (shape === "keys") {
|
|
177
|
+
if (!isMapping(value)) return undefined;
|
|
178
|
+
const out = {};
|
|
179
|
+
for (const [written, standing] of value instanceof AddressEntries ?
|
|
180
|
+
value.entries.map((e) => [e.target, e.value])
|
|
181
|
+
: Object.entries(value)) {
|
|
182
|
+
const code = read(written);
|
|
183
|
+
// The first standing stands, so the emitted map does not depend on
|
|
184
|
+
// which key the reduction reached first.
|
|
185
|
+
if (code !== undefined && !Object.hasOwn(out, code)) out[code] = standing;
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (shape === "items") {
|
|
191
|
+
if (!Array.isArray(value)) return undefined;
|
|
192
|
+
return value.map((written) => read(written) ?? written);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One Address, and the two empties of a scalar field: `null` and an absent
|
|
196
|
+
// key mean *names nothing*, and `""` is the same statement a property editor
|
|
197
|
+
// writes. Neither is an Address, and neither is a mistake.
|
|
198
|
+
if (value == null || value === "") return undefined;
|
|
199
|
+
return read(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read one written Address, reporting what stops it.
|
|
204
|
+
*
|
|
205
|
+
* @param {unknown} written - The value as authored.
|
|
206
|
+
* @param {object} field - The declaration.
|
|
207
|
+
* @param {object} defaults - What {@link positionDefaults} returns.
|
|
208
|
+
* @param {Map<string, {address: string, written: string}>} seen - The Addresses
|
|
209
|
+
* already reduced at this position, by emitted shortcode.
|
|
210
|
+
* @param {AddressFinding[]} findings - Collected.
|
|
211
|
+
* @param {string} shape - What the position holds.
|
|
212
|
+
* @returns {string|undefined} The shortcode, or `undefined` when nothing is
|
|
213
|
+
* emitted for this value.
|
|
214
|
+
*/
|
|
215
|
+
function readOne(written, field, defaults, seen, findings, shape) {
|
|
216
|
+
const key = findingKey(field);
|
|
217
|
+
const value =
|
|
218
|
+
isAddressTuple(written) ? renderAddress(written)
|
|
219
|
+
: typeof written === "string" ? written
|
|
220
|
+
: String(written ?? "");
|
|
221
|
+
const report = (message) => void findings.push({ key, written: value, message });
|
|
222
|
+
|
|
223
|
+
const tuple = parseAddress(written, defaults);
|
|
224
|
+
if (tuple.reason) {
|
|
225
|
+
report(unreadableMessage(key, value, tuple, defaults));
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
if (!acceptsType(tuple, defaults.accepts)) {
|
|
229
|
+
report(unacceptedMessage(key, value, defaults.accepts));
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const address = renderAddress(tuple);
|
|
234
|
+
const code = tuple.shortcode;
|
|
235
|
+
const first = seen.get(code);
|
|
236
|
+
if (first && first.address !== address) {
|
|
237
|
+
report(collidingMessage(key, first.address, address, code));
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
if (first && shape === "keys") {
|
|
241
|
+
report(repeatedMessage(key, address, first.written, value));
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
if (!first) seen.set(code, { address, written: value });
|
|
245
|
+
return code;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The defaults and vocabularies a position reads its values against.
|
|
250
|
+
*
|
|
251
|
+
* **The position's own types belong in the vocabulary.** A position declares
|
|
252
|
+
* what it names, so a tree that happens to carry no note of that type still
|
|
253
|
+
* reads the value: without this, an affiliation naming a `seat` in a tree whose
|
|
254
|
+
* places are all in a dependency stops parsing, and the refusal describes the
|
|
255
|
+
* tree rather than the value.
|
|
256
|
+
*
|
|
257
|
+
* @param {AddressPosition} position - The declaration's `address` statement.
|
|
258
|
+
* @param {object} vocabulary - As {@link reduceAddressFields} takes it.
|
|
259
|
+
* @returns {object} The defaults {@link parseAddress} takes, plus the accepted
|
|
260
|
+
* set the acceptability step reads.
|
|
261
|
+
*/
|
|
262
|
+
function positionDefaults(position, vocabulary) {
|
|
263
|
+
const accepts = position.accepts ?? [position.type];
|
|
264
|
+
return {
|
|
265
|
+
...vocabulary,
|
|
266
|
+
types: new Set([...(vocabulary.types ?? []), position.type, ...accepts]),
|
|
267
|
+
// Derived from the type rather than taken from the block the value sits
|
|
268
|
+
// in: an affiliation address names a SoHL Item, a place address names a
|
|
269
|
+
// core document, and both are written inside the same `sohl:` block.
|
|
270
|
+
system: systemOf(position.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
|
|
271
|
+
type: position.type,
|
|
272
|
+
accepts,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Whether a value is an authored map rather than a list or a scalar. */
|
|
277
|
+
function isMapping(value) {
|
|
278
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The key a finding names, as the note's own line spells it.
|
|
283
|
+
*
|
|
284
|
+
* A declaration's shared source is a path into the `data:` container —
|
|
285
|
+
* `data.seat` — and the note writes `seat:` one level inside it. The finding
|
|
286
|
+
* names that key for two reasons at once: it is the word on the line the
|
|
287
|
+
* diagnostic opens, and it is what {@link
|
|
288
|
+
* module:engine/retired-fields.locateFrontmatterKey} matches when the authored
|
|
289
|
+
* value cannot be found in the fence, which a dotted path never would.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} field - The declaration.
|
|
292
|
+
* @returns {string} The key, without the container holding it.
|
|
293
|
+
*/
|
|
294
|
+
function findingKey(field) {
|
|
295
|
+
return authoredKey(field.name ?? field.to);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Format a closed set for a message: `a, b or c`.
|
|
300
|
+
*
|
|
301
|
+
* @param {readonly unknown[]} values - The set.
|
|
302
|
+
* @returns {string} The values, joined.
|
|
303
|
+
*/
|
|
304
|
+
function oneOf(values) {
|
|
305
|
+
const all = [...values].map((value) => `\`${String(value)}\``);
|
|
306
|
+
return all.length < 2 ?
|
|
307
|
+
all.join("")
|
|
308
|
+
: `${all.slice(0, -1).join(", ")} or ${all[all.length - 1]}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* What a value the grammar cannot read is reported as.
|
|
313
|
+
*
|
|
314
|
+
* @param {string} key - The field, as an author writes it.
|
|
315
|
+
* @param {string} written - The authored value.
|
|
316
|
+
* @param {import("./address.mjs").AddressProblem} problem - Why it does not
|
|
317
|
+
* parse.
|
|
318
|
+
* @param {object} defaults - What {@link positionDefaults} returns.
|
|
319
|
+
* @returns {string} The message.
|
|
320
|
+
*/
|
|
321
|
+
function unreadableMessage(key, written, problem, defaults) {
|
|
322
|
+
if (problem.reason === "no-content-index") {
|
|
323
|
+
return (
|
|
324
|
+
`\`${key}\` names package "${problem.package}", which is declared ` +
|
|
325
|
+
`\`contentIndex: false\` — a Foundry dependency only, with no content index ` +
|
|
326
|
+
`fetched for it, so nothing it publishes can be named here`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (problem.reason === "not-lowercase") {
|
|
330
|
+
return (
|
|
331
|
+
`\`${key}\` names \`${written}\`, whose package, system or type segment ` +
|
|
332
|
+
`carries a capital — those segments are lowercase, an address having one ` +
|
|
333
|
+
`spelling`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return (
|
|
337
|
+
`\`${key}\` must name ${oneOf(defaults.accepts)} — its \`shortcode\`, or that ` +
|
|
338
|
+
`address qualified by type, by system and type, or by package, system and ` +
|
|
339
|
+
`type — but reads \`${written}\``
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* What a value naming a type the position does not take is reported as.
|
|
345
|
+
*
|
|
346
|
+
* An **error**: the value is a real Address, so no default repairs it, and the
|
|
347
|
+
* position cannot do anything with what it names.
|
|
348
|
+
*
|
|
349
|
+
* **The value states its own type, and the message leaves it there.** A value
|
|
350
|
+
* omitting the `<type>` segment takes the position's default and is therefore
|
|
351
|
+
* acceptable, so this fires only where the author wrote a type — and naming the
|
|
352
|
+
* parsed one instead would name the `doc<type>` a system-bearing type is
|
|
353
|
+
* redirected to under `none`, sending a reader to look for a segment nobody
|
|
354
|
+
* wrote.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} key - The field, as an author writes it.
|
|
357
|
+
* @param {string} written - The authored value.
|
|
358
|
+
* @param {readonly string[]} accepts - The types the position accepts.
|
|
359
|
+
* @returns {string} The message.
|
|
360
|
+
*/
|
|
361
|
+
function unacceptedMessage(key, written, accepts) {
|
|
362
|
+
return (
|
|
363
|
+
`\`${key}\` names \`${written}\`, whose type this field does not accept — it ` +
|
|
364
|
+
`accepts ${oneOf(accepts)}`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* What two Addresses reducing to one shortcode are reported as.
|
|
370
|
+
*
|
|
371
|
+
* The message states that both values are valid, because they are: a shortcode
|
|
372
|
+
* is unique within one package's address space and these are two packages'. The
|
|
373
|
+
* fault is in what the emitted field can hold, which is why the finding names
|
|
374
|
+
* both Addresses rather than one of them.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} key - The field, as an author writes it.
|
|
377
|
+
* @param {string} first - The Address already reduced.
|
|
378
|
+
* @param {string} second - The Address colliding with it.
|
|
379
|
+
* @param {string} code - The shortcode they share.
|
|
380
|
+
* @returns {string} The message.
|
|
381
|
+
*/
|
|
382
|
+
function collidingMessage(key, first, second, code) {
|
|
383
|
+
return (
|
|
384
|
+
`\`${key}\` names both \`${first}\` and \`${second}\`, which share the shortcode ` +
|
|
385
|
+
`"${code}" — both addresses are valid, and the compiled field is keyed by the ` +
|
|
386
|
+
`shortcode alone, so it carries one of the two and the other is lost`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* What one Address written twice at a keyed position is reported as.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} key - The field, as an author writes it.
|
|
394
|
+
* @param {string} address - The Address both keys name.
|
|
395
|
+
* @param {string} first - How the first key was written.
|
|
396
|
+
* @param {string} second - How the second was written.
|
|
397
|
+
* @returns {string} The message.
|
|
398
|
+
*/
|
|
399
|
+
function repeatedMessage(key, address, first, second) {
|
|
400
|
+
return (
|
|
401
|
+
`\`${key}\` names \`${address}\` twice, as \`${first}\` and as \`${second}\` — the ` +
|
|
402
|
+
`compiled field carries one value per target, so the second is lost`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isAddressTuple, renderAddress } from "./address.mjs";
|
|
9
|
+
|
|
10
|
+
/** Address-keyed properties hold typed entries rather than string object keys. */
|
|
11
|
+
export class AddressEntries {
|
|
12
|
+
/** @param {Array<{target: object, value: unknown, sourceKey: string}>} entries - Typed entries and diagnostic source keys. */
|
|
13
|
+
constructor(entries) {
|
|
14
|
+
if (!entries.every((entry) => isAddressTuple(entry.target)))
|
|
15
|
+
throw new TypeError("Address map keys must be complete tuples");
|
|
16
|
+
this.entries = entries;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** An Address and the section within its document. */
|
|
21
|
+
export class AddressLink {
|
|
22
|
+
/** @param {object} target - Complete tuple. @param {string} anchor - Section slug. */
|
|
23
|
+
constructor(target, anchor) {
|
|
24
|
+
if (!isAddressTuple(target)) throw new TypeError("An Address link needs a complete tuple");
|
|
25
|
+
this.target = target;
|
|
26
|
+
this.anchor = anchor;
|
|
27
|
+
Object.freeze(this);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render typed properties at a generated-output boundary.
|
|
33
|
+
* @param {any} value - A tuple-bearing tree.
|
|
34
|
+
* @param {WeakMap<object, object>} [seen] - Shared containers in this output.
|
|
35
|
+
* @returns {any} A serializable tree containing full Addresses.
|
|
36
|
+
*/
|
|
37
|
+
export function encodeAddresses(value, seen = new WeakMap()) {
|
|
38
|
+
if (value && typeof value === "object" && seen.has(value)) return seen.get(value);
|
|
39
|
+
if (isAddressTuple(value)) return renderAddress(value);
|
|
40
|
+
if (value instanceof AddressLink) return `${renderAddress(value.target)}#${value.anchor}`;
|
|
41
|
+
if (value instanceof AddressEntries) {
|
|
42
|
+
const out = {};
|
|
43
|
+
seen.set(value, out);
|
|
44
|
+
for (const { target, value: entry } of value.entries) {
|
|
45
|
+
const key = renderAddress(target);
|
|
46
|
+
if (Object.hasOwn(out, key)) throw new Error(`Repeated Address map key: ${key}`);
|
|
47
|
+
out[key] = encodeAddresses(entry, seen);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
const out = [];
|
|
53
|
+
seen.set(value, out);
|
|
54
|
+
for (const entry of value) out.push(encodeAddresses(entry, seen));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype)
|
|
58
|
+
return value;
|
|
59
|
+
const out = {};
|
|
60
|
+
seen.set(value, out);
|
|
61
|
+
for (const [key, entry] of Object.entries(value)) out[key] = encodeAddresses(entry, seen);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Clone mutable note state while retaining immutable Address identities.
|
|
67
|
+
* @param {any} value - Typed note state.
|
|
68
|
+
* @returns {any} An independent mutable container tree.
|
|
69
|
+
*/
|
|
70
|
+
export function cloneAddressState(value) {
|
|
71
|
+
if (isAddressTuple(value)) return value;
|
|
72
|
+
if (value instanceof AddressEntries)
|
|
73
|
+
return new AddressEntries(
|
|
74
|
+
value.entries.map((entry) => ({ ...entry, value: cloneAddressState(entry.value) })),
|
|
75
|
+
);
|
|
76
|
+
if (Array.isArray(value)) return value.map(cloneAddressState);
|
|
77
|
+
if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype)
|
|
78
|
+
return value;
|
|
79
|
+
return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, cloneAddressState(v)]));
|
|
80
|
+
}
|