@heroiclands/package-build 10.0.1 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/CONTENT.md +218 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +59 -75
  5. package/docs/content-format.md +90 -67
  6. package/engine/base-compiler.mjs +7 -1
  7. package/engine/content-address.mjs +71 -18
  8. package/engine/content-format-check.mjs +1 -1
  9. package/engine/content-links.mjs +93 -112
  10. package/engine/content-lint.mjs +14 -10
  11. package/engine/content-slug.mjs +39 -105
  12. package/engine/diagnostics.mjs +16 -2
  13. package/engine/frontmatter-lint.mjs +26 -13
  14. package/engine/helpers.mjs +31 -68
  15. package/engine/homepage.mjs +131 -86
  16. package/engine/index.mjs +2 -5
  17. package/engine/manifest-emit.mjs +23 -4
  18. package/engine/note-vocabulary.mjs +58 -1
  19. package/engine/retired-fields.mjs +117 -6
  20. package/engine/site-build.mjs +182 -59
  21. package/engine/site-index.mjs +57 -102
  22. package/engine/web-wikilinks.mjs +183 -127
  23. package/engine/wikilink-syntax.mjs +174 -34
  24. package/engine/wikilinks.mjs +159 -117
  25. package/package.json +1 -1
  26. package/types/engine/base-compiler.d.mts +1 -1
  27. package/types/engine/content-address.d.mts +46 -14
  28. package/types/engine/content-links.d.mts +13 -17
  29. package/types/engine/content-slug.d.mts +11 -48
  30. package/types/engine/diagnostics.d.mts +14 -1
  31. package/types/engine/helpers.d.mts +4 -3
  32. package/types/engine/homepage.d.mts +96 -60
  33. package/types/engine/index.d.mts +0 -1
  34. package/types/engine/note-vocabulary.d.mts +43 -0
  35. package/types/engine/retired-fields.d.mts +78 -1
  36. package/types/engine/site-build.d.mts +70 -17
  37. package/types/engine/site-index.d.mts +19 -21
  38. package/types/engine/web-wikilinks.d.mts +29 -28
  39. package/types/engine/wikilink-syntax.d.mts +126 -40
  40. package/types/engine/wikilinks.d.mts +29 -24
  41. package/engine/abbreviations.mjs +0 -0
  42. package/engine/alias-index.mjs +0 -153
  43. package/types/engine/abbreviations.d.mts +0 -44
  44. package/types/engine/alias-index.d.mts +0 -122
@@ -26,7 +26,7 @@
26
26
  export function resolveItemDocType(qualifier: string, types: Set<string>): string | null;
27
27
  /**
28
28
  * Read a link target as a **qualified** `type-shortcode` reference, or report
29
- * that it is a bare alias instead.
29
+ * that it does not parse as one.
30
30
  *
31
31
  * Two separators are accepted, and they are **not** interchangeable in how
32
32
  * confidently they mark a target as qualified:
@@ -35,19 +35,20 @@ export function resolveItemDocType(qualifier: string, types: Set<string>): strin
35
35
  * a wikilink as a *path* and resolves it against the vault's folders, so a
36
36
  * slash-qualified link is a broken link in the editor where the content is now
37
37
  * authored. A hyphen qualifies **only when what precedes it is a known type**:
38
- * note names contain hyphens too (`Grukar-ahk`), and those must keep resolving
39
- * as aliases. The split is at the **first** hyphen, so a shortcode may itself
40
- * contain one (`trauma-self-pro` `trauma` + `self-pro`).
38
+ * note names contain hyphens too (`Grukar-ahk`), and a target that is one is
39
+ * reported as not an address rather than split at an arbitrary place. The
40
+ * split is at the **first** hyphen, so a shortcode may itself contain one
41
+ * (`trauma-self-pro` → `trauma` + `self-pro`).
41
42
  * - **`type/shortcode`** — the legacy form, still resolved so that a link
42
43
  * written before the vault migrated does not silently die. A slash is
43
44
  * *unconditionally* a qualifier: nothing else uses one, so an unknown type
44
- * before it is an error rather than an invitation to try the alias index. The
45
- * split is at the **last** slash, as it always was.
45
+ * before it is reported rather than guessed at. The split is at the **last**
46
+ * slash, as it always was.
46
47
  *
47
48
  * A leading **package** segment is optional and outermost: `sohl-skill-lang` is
48
49
  * `skill-lang` in the `sohl` package. It is read only when `packages` is given
49
50
  * and names the segment, and only when the remainder is itself a valid address,
50
- * so a note called "Grukar-ahk" stays an alias (#1499).
51
+ * so a note called "Grukar-ahk" is not mistaken for one (#1499).
51
52
  *
52
53
  * @param {string} target - The link target, anchor already removed.
53
54
  * @param {Set<string>} types - Every type the content tree contains.
@@ -56,7 +57,7 @@ export function resolveItemDocType(qualifier: string, types: Set<string>): strin
56
57
  * @returns {{type: string, shortcode: string, itemDoc: boolean,
57
58
  * package?: string, reason?: undefined} | {reason: "unknown-type"} | null}
58
59
  * The resolved qualifier; a `reason` when the target is definitely qualified
59
- * but names no known type; or `null` when it is a bare alias.
60
+ * but names no known type; or `null` when it is not an address at all.
60
61
  */
61
62
  export function readQualifier(target: string, types: Set<string>, packages?: Set<string>): {
62
63
  type: string;
@@ -86,33 +87,32 @@ export function anchorPageId(noteId: string, anchorSlug: string): string;
86
87
  * Builds the link-resolution tables for a content tree.
87
88
  *
88
89
  * @param {Array<{type: string, id: string, shortcode?: string|null,
89
- * aliases?: string[], name?: string, pack?: string, docPack?: string}>} docs -
90
+ * name?: string, pack?: string, docPack?: string,
91
+ * draft?: boolean}>} docs -
90
92
  * One entry per content note. `pack` / `docPack` name the packs the note's
91
93
  * document and its documentation entry landed in; omitted, the conventional
92
- * one-pack-per-type names stand in.
94
+ * one-pack-per-type names stand in. `draft` says the note carries the `draft`
95
+ * tag, which marks links *into* it and changes nothing else (#183).
93
96
  * @param {string} packageId - The Foundry package shipping the packs; the first
94
97
  * segment of every emitted UUID.
95
98
  * @param {Map<string, object>} [foreign] - Canonically keyed entries from
96
99
  * vendored manifests of packages this build links into but does not publish.
97
100
  * @param {string} [contentPackage] - This build's *content* package, which an
98
101
  * authored address may name explicitly. Defaults to `packageId`.
99
- * @returns {{byShortcode: Map<string, object>, byAlias: Map<string, object|null>,
100
- * types: Set<string>}} `byAlias` holds `null` where a type-scoped alias is
101
- * claimed by more than one document, which makes the bare `[[Text]]` form
102
- * unusable for it. `types` is every type the tree actually contains, so a
103
- * qualifier naming no real type can be told apart from a missing target.
102
+ * @returns {{byShortcode: Map<string, object>, types: Set<string>}} `types` is
103
+ * every type the tree actually contains, so a qualifier naming no real type
104
+ * can be told apart from a missing target.
104
105
  */
105
106
  export function buildWikilinkIndex(docs: Array<{
106
107
  type: string;
107
108
  id: string;
108
109
  shortcode?: string | null;
109
- aliases?: string[];
110
110
  name?: string;
111
111
  pack?: string;
112
112
  docPack?: string;
113
+ draft?: boolean;
113
114
  }>, packageId: string, foreign?: Map<string, object>, contentPackage?: string): {
114
115
  byShortcode: Map<string, object>;
115
- byAlias: Map<string, object | null>;
116
116
  types: Set<string>;
117
117
  };
118
118
  /** Matches a whole wikilink, capturing its inner text. */
@@ -133,19 +133,23 @@ export function buildWikilinkIndex(docs: Array<{
133
133
  *
134
134
  * @param {string} markdown - The note body (frontmatter already stripped).
135
135
  * @param {object} ctx
136
- * @param {string} ctx.type - The source note's `type`, which scopes a bare `[[Text]]`.
136
+ * @param {string} ctx.type - The source note's `type`, which addresses a
137
+ * `[[#slug]]` self-link.
137
138
  * @param {string} ctx.id - The source note's document id.
138
139
  * @param {string} [ctx.pack] - The pack the source note's own document landed
139
140
  * in, which addresses a `[[#slug]]` self-link — the one target with no index
140
141
  * entry.
141
142
  * @param {string} [ctx.docPack] - The pack the source note's documentation
142
143
  * entry landed in.
143
- * @param {{byShortcode: Map, byAlias: Map, types: Set}} ctx.index - From
144
+ * @param {{byShortcode: Map, types: Set}} ctx.index - From
144
145
  * {@link buildWikilinkIndex}.
145
146
  * @returns {{markdown: string, unresolved: Array<{link: string, target: string,
146
- * offset: number, reason: "unknown"|"ambiguous"|"unknown-type"}>}} `offset`
147
- * is the link's 0-based position in `markdown`, which is what lets a caller
148
- * report the line and column it sits on (#17).
147
+ * offset: number, reason: string, packages?: string[], anchor?: string}>}}
148
+ * Each `reason` is one of {@link LINK_FINDING_REASONS}, the vocabulary all
149
+ * three resolvers share (#184) `ambiguous` carries the claiming `packages`
150
+ * and `unknown-anchor` the section it named. `offset` is the link's 0-based
151
+ * position in `markdown`, which is what lets a caller report the line and
152
+ * column it sits on (#17).
149
153
  */
150
154
  export function convertWikilinks(markdown: string, { type, id, pack, docPack, index }: {
151
155
  type: string;
@@ -154,7 +158,6 @@ export function convertWikilinks(markdown: string, { type, id, pack, docPack, in
154
158
  docPack?: string | undefined;
155
159
  index: {
156
160
  byShortcode: Map<any, any>;
157
- byAlias: Map<any, any>;
158
161
  types: Set<any>;
159
162
  };
160
163
  }): {
@@ -163,7 +166,9 @@ export function convertWikilinks(markdown: string, { type, id, pack, docPack, in
163
166
  link: string;
164
167
  target: string;
165
168
  offset: number;
166
- reason: "unknown" | "ambiguous" | "unknown-type";
169
+ reason: string;
170
+ packages?: string[];
171
+ anchor?: string;
167
172
  }>;
168
173
  };
169
174
  import { ITEM_PACK } from "./ids.mjs";
Binary file
@@ -1,153 +0,0 @@
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
- * The **alias** namespace: what a note can be called, and who may claim a name.
16
- *
17
- * A wikilink resolves through one of two namespaces, and the pipe chooses
18
- * which (#131): `[[x|…]]` is an *address*, parsed by the address grammar;
19
- * `[[x]]` is an *alias*, looked up here. This module owns the second half —
20
- * what goes into the index, how a key is spelled, and what happens when two
21
- * notes claim one name.
22
- *
23
- * **An alias is scoped to the claiming note's own type.** The key is
24
- * `(type, alias)`, so `Shock` may be a `skill` in one place and a `trauma` in
25
- * another without the two ever meeting. A link resolves against the *source*
26
- * note's type, which is why a bare name reaches a sibling and never a
27
- * cross-type target — that one is written as an address.
28
- *
29
- * **Three sources, all authored.** `aliases`, `name.aliases`, and `name.full`.
30
- * Each is something a person wrote down as a name for the note, which is
31
- * exactly what a bare `[[…]]` cites.
32
- *
33
- * **The filename is deliberately not one of them**, and it used to be — every
34
- * one of the three copies of this index added `basename(file, ".md")` with
35
- * underscores turned to spaces. That admitted keys no author could ever cite
36
- * and no author had ever written:
37
- *
38
- * - `_Introduction.md` yields the alias `" introduction"`, *with a leading
39
- * space*. A wikilink target is trimmed, so nothing can ever match it. In one
40
- * repository thirteen notes — one per documentation section — claimed that
41
- * key, making it the largest alias collision in the corpus and every one of
42
- * its claimants blameless.
43
- * - `README.md` yields `readme`, claimed once per section for the same reason.
44
- *
45
- * Since a collision is now a build failure rather than a silent deletion, an
46
- * index entry that cannot be cited can only ever *cause* one. Removing the
47
- * source was measured first, across all five content trees: not one link that
48
- * resolves today resolves through the filename alone, so nothing loses a
49
- * target — while the collision count falls without a note being edited.
50
- *
51
- * @module
52
- */
53
-
54
- /**
55
- * Every alias a note claims, in the order the sources are consulted.
56
- *
57
- * @param {object} fm - Parsed frontmatter.
58
- * @returns {string[]} The claimed aliases, each a non-empty string.
59
- */
60
- export function aliasesOf(fm) {
61
- return [
62
- ...(Array.isArray(fm?.aliases) ? fm.aliases : []),
63
- ...(Array.isArray(fm?.name?.aliases) ? fm.name.aliases : []),
64
- fm?.name?.full,
65
- ].filter((a) => typeof a === "string" && a);
66
- }
67
-
68
- /**
69
- * The index key one note's claim on one alias is filed under.
70
- *
71
- * Stated here so the three indexes — the pack build's, the site build's and
72
- * the link checker's — cannot spell it differently. All three already used
73
- * `type|alias`, lowercased; the risk was never that they disagreed today.
74
- *
75
- * @param {string} type - The claiming note's content type.
76
- * @param {string} alias - The alias, as authored.
77
- * @returns {string} The key.
78
- */
79
- export function aliasKey(type, alias) {
80
- return `${String(type).trim()}|${String(alias).trim()}`.toLowerCase();
81
- }
82
-
83
- /**
84
- * One alias claimed by more than one note of a single type.
85
- *
86
- * @typedef {object} AliasCollision
87
- * @property {string} key - The index key, `type|alias`.
88
- * @property {string} type - The type both claimants share.
89
- * @property {string} alias - The alias, as the first claimant wrote it.
90
- * @property {unknown[]} claimants - Every note claiming it, in walk order.
91
- */
92
-
93
- /**
94
- * Build the type-scoped alias index, and report every collision in it.
95
- *
96
- * **A collision resolves to nothing, and is reported naming every claimant.**
97
- * Both halves matter. Resolving to whichever note happened to be walked first
98
- * makes a link silently point at the wrong document, and which one it is
99
- * depends on directory order. Reporting it at the *citing* note blames a file
100
- * whose author did nothing wrong — whoever added the second claimant broke
101
- * every existing citation (#13) — so the claimants are kept rather than
102
- * discarded along with the entry.
103
- *
104
- * @template T
105
- * @param {Iterable<{type: string, aliases: Iterable<string>, value: T}>} entries
106
- * One per note: the type that scopes its claims, the aliases it claims, and
107
- * whatever the caller wants an alias to resolve to.
108
- * @param {object} [opts]
109
- * @param {(a: T, b: T) => boolean} [opts.same] - Whether two values are the
110
- * same note. Defaults to identity; a caller whose values are freshly built
111
- * records supplies its own.
112
- * @returns {{byKey: Map<string, T>, claims: Map<string, T[]>,
113
- * collisions: AliasCollision[]}} `byKey` omits every colliding key, so a
114
- * lookup in it can never resolve an ambiguous alias.
115
- */
116
- export function indexAliases(entries, { same = Object.is } = {}) {
117
- const claims = new Map();
118
- /** The alias as first written, per key, for a message that reads. */
119
- const written = new Map();
120
- const typeOf = new Map();
121
-
122
- for (const { type, aliases, value } of entries) {
123
- for (const alias of aliases ?? []) {
124
- if (typeof alias !== "string" || !alias) continue;
125
- const key = aliasKey(type, alias);
126
- const claimants = claims.get(key);
127
- if (!claimants) {
128
- claims.set(key, [value]);
129
- written.set(key, alias);
130
- typeOf.set(key, String(type).toLowerCase());
131
- } else if (!claimants.some((c) => same(c, value))) {
132
- claimants.push(value);
133
- }
134
- }
135
- }
136
-
137
- const byKey = new Map();
138
- const collisions = [];
139
- for (const [key, claimants] of claims) {
140
- if (claimants.length === 1) {
141
- byKey.set(key, claimants[0]);
142
- } else {
143
- collisions.push({
144
- key,
145
- type: typeOf.get(key),
146
- alias: written.get(key),
147
- claimants,
148
- });
149
- }
150
- }
151
-
152
- return { byKey, claims, collisions };
153
- }
@@ -1,44 +0,0 @@
1
- /**
2
- * Replace whole words with their abbreviations.
3
- *
4
- * Greedy and longest-first: at each position the longest run of tokens that
5
- * names an entry wins, so `tribunus militum` never abbreviates as
6
- * `trib militum`, and `countess` is never reached by `count`'s rule.
7
- *
8
- * @param {readonly string[]} tokens - Lowercased, alphanumeric-only tokens.
9
- * @returns {string[]} The tokens, with each matched run replaced by one token.
10
- */
11
- export function abbreviateTokens(tokens: readonly string[]): string[];
12
- /**
13
- * The abbreviations a slug may use to stay short.
14
- *
15
- * Names in this setting are long and formulaic — a rank, an office, a material,
16
- * a unit — so a slug built from one runs to a mouthful that says little more
17
- * than a short one would. These are the conventional shortenings for that
18
- * vocabulary.
19
- *
20
- * **Whole words only.** `count` abbreviates, `countess` does not become
21
- * `ctess`: it has its own entry. Matching is greedy and longest-first, so a
22
- * multi-word phrase wins over its own first word — `tribunus militum` is
23
- * `tribmil`, a bare `tribunus` is `trib` — and a longer word wins over a
24
- * shorter one that prefixes it.
25
- *
26
- * **Abbreviations are not unique, and that is the caller's problem.** Several
27
- * words share one: `abbess` and `abbot` are both `abb`, `monk` and `brother`
28
- * both `br`, `emperor` and `empress` both `emp`. Two names that differ only in
29
- * such a word therefore slug alike, which for a URL is a collision — the
30
- * build's existing slug-collision guard is what catches it, and the fix is a
31
- * more specific name.
32
- *
33
- * @module
34
- */
35
- /**
36
- * Word (or phrase) → abbreviation.
37
- *
38
- * Keys are lowercase and space-separated as they read; a hyphenated key like
39
- * `shire-reeve` is stored with its hyphen because that is how the word is
40
- * written, and the matcher tokenises the same way it tokenises a name.
41
- *
42
- * @type {Readonly<Record<string, string>>}
43
- */
44
- export const ABBREVIATIONS: Readonly<Record<string, string>>;
@@ -1,122 +0,0 @@
1
- /**
2
- * The **alias** namespace: what a note can be called, and who may claim a name.
3
- *
4
- * A wikilink resolves through one of two namespaces, and the pipe chooses
5
- * which (#131): `[[x|…]]` is an *address*, parsed by the address grammar;
6
- * `[[x]]` is an *alias*, looked up here. This module owns the second half —
7
- * what goes into the index, how a key is spelled, and what happens when two
8
- * notes claim one name.
9
- *
10
- * **An alias is scoped to the claiming note's own type.** The key is
11
- * `(type, alias)`, so `Shock` may be a `skill` in one place and a `trauma` in
12
- * another without the two ever meeting. A link resolves against the *source*
13
- * note's type, which is why a bare name reaches a sibling and never a
14
- * cross-type target — that one is written as an address.
15
- *
16
- * **Three sources, all authored.** `aliases`, `name.aliases`, and `name.full`.
17
- * Each is something a person wrote down as a name for the note, which is
18
- * exactly what a bare `[[…]]` cites.
19
- *
20
- * **The filename is deliberately not one of them**, and it used to be — every
21
- * one of the three copies of this index added `basename(file, ".md")` with
22
- * underscores turned to spaces. That admitted keys no author could ever cite
23
- * and no author had ever written:
24
- *
25
- * - `_Introduction.md` yields the alias `" introduction"`, *with a leading
26
- * space*. A wikilink target is trimmed, so nothing can ever match it. In one
27
- * repository thirteen notes — one per documentation section — claimed that
28
- * key, making it the largest alias collision in the corpus and every one of
29
- * its claimants blameless.
30
- * - `README.md` yields `readme`, claimed once per section for the same reason.
31
- *
32
- * Since a collision is now a build failure rather than a silent deletion, an
33
- * index entry that cannot be cited can only ever *cause* one. Removing the
34
- * source was measured first, across all five content trees: not one link that
35
- * resolves today resolves through the filename alone, so nothing loses a
36
- * target — while the collision count falls without a note being edited.
37
- *
38
- * @module
39
- */
40
- /**
41
- * Every alias a note claims, in the order the sources are consulted.
42
- *
43
- * @param {object} fm - Parsed frontmatter.
44
- * @returns {string[]} The claimed aliases, each a non-empty string.
45
- */
46
- export function aliasesOf(fm: object): string[];
47
- /**
48
- * The index key one note's claim on one alias is filed under.
49
- *
50
- * Stated here so the three indexes — the pack build's, the site build's and
51
- * the link checker's — cannot spell it differently. All three already used
52
- * `type|alias`, lowercased; the risk was never that they disagreed today.
53
- *
54
- * @param {string} type - The claiming note's content type.
55
- * @param {string} alias - The alias, as authored.
56
- * @returns {string} The key.
57
- */
58
- export function aliasKey(type: string, alias: string): string;
59
- /**
60
- * One alias claimed by more than one note of a single type.
61
- *
62
- * @typedef {object} AliasCollision
63
- * @property {string} key - The index key, `type|alias`.
64
- * @property {string} type - The type both claimants share.
65
- * @property {string} alias - The alias, as the first claimant wrote it.
66
- * @property {unknown[]} claimants - Every note claiming it, in walk order.
67
- */
68
- /**
69
- * Build the type-scoped alias index, and report every collision in it.
70
- *
71
- * **A collision resolves to nothing, and is reported naming every claimant.**
72
- * Both halves matter. Resolving to whichever note happened to be walked first
73
- * makes a link silently point at the wrong document, and which one it is
74
- * depends on directory order. Reporting it at the *citing* note blames a file
75
- * whose author did nothing wrong — whoever added the second claimant broke
76
- * every existing citation (#13) — so the claimants are kept rather than
77
- * discarded along with the entry.
78
- *
79
- * @template T
80
- * @param {Iterable<{type: string, aliases: Iterable<string>, value: T}>} entries
81
- * One per note: the type that scopes its claims, the aliases it claims, and
82
- * whatever the caller wants an alias to resolve to.
83
- * @param {object} [opts]
84
- * @param {(a: T, b: T) => boolean} [opts.same] - Whether two values are the
85
- * same note. Defaults to identity; a caller whose values are freshly built
86
- * records supplies its own.
87
- * @returns {{byKey: Map<string, T>, claims: Map<string, T[]>,
88
- * collisions: AliasCollision[]}} `byKey` omits every colliding key, so a
89
- * lookup in it can never resolve an ambiguous alias.
90
- */
91
- export function indexAliases<T>(entries: Iterable<{
92
- type: string;
93
- aliases: Iterable<string>;
94
- value: T;
95
- }>, { same }?: {
96
- same?: ((a: T, b: T) => boolean) | undefined;
97
- }): {
98
- byKey: Map<string, T>;
99
- claims: Map<string, T[]>;
100
- collisions: AliasCollision[];
101
- };
102
- /**
103
- * One alias claimed by more than one note of a single type.
104
- */
105
- export type AliasCollision = {
106
- /**
107
- * - The index key, `type|alias`.
108
- */
109
- key: string;
110
- /**
111
- * - The type both claimants share.
112
- */
113
- type: string;
114
- /**
115
- * - The alias, as the first claimant wrote it.
116
- */
117
- alias: string;
118
- /**
119
- * - Every note claiming it, in walk order.
120
- */
121
- claimants: unknown[];
122
- };