@heroiclands/package-build 17.1.0 → 18.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.
- package/CHANGELOG.md +1773 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +394 -111
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/labels.mjs +191 -0
- package/manifest.mjs +43 -2
- package/package.json +19 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +64 -80
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
package/engine/content-index.mjs
CHANGED
|
@@ -61,6 +61,28 @@
|
|
|
61
61
|
* Foundry data root, so anything left there ships inside the installed system
|
|
62
62
|
* to every player, and a build artifact has no business there.
|
|
63
63
|
*
|
|
64
|
+
* **What it deliberately does not carry: the note's text, and positions within
|
|
65
|
+
* it.** #243 asks whether the index should record a position for every
|
|
66
|
+
* frontmatter key, so that a pass reading the index could report a field defect
|
|
67
|
+
* without opening the file. It should not, and the numbers are not close: over
|
|
68
|
+
* `sohl`'s 1,685 notes the index is 3.0 MB and holds 50,598 leaf values, so a
|
|
69
|
+
* `{line, column}` on each would add roughly 1.6 MB — **a 54% larger artifact**
|
|
70
|
+
* — to carry data that is only ever read on the *failing* path.
|
|
71
|
+
*
|
|
72
|
+
* The rule that replaces it is the one this module was already built on:
|
|
73
|
+
* **the index carries what is _about_ a note; the file carries the note's text
|
|
74
|
+
* and every position within it.** Any pass needing either opens the file whose
|
|
75
|
+
* path the record already names ({@link noteFile}). That costs nothing it was
|
|
76
|
+
* not already paying — a check reads each note once for its body, and a
|
|
77
|
+
* compiler must read the prose regardless, so while it holds the bytes a
|
|
78
|
+
* position is free. Recording positions would charge every build, and every
|
|
79
|
+
* reader of the artifact, for something the passes that want them get for
|
|
80
|
+
* nothing.
|
|
81
|
+
*
|
|
82
|
+
* The exception proves the rule: an **anchor** carries its `line`, because an
|
|
83
|
+
* anchor is a fact about the note's structure that a consumer addresses
|
|
84
|
+
* directly, not a locator for a diagnostic about a key.
|
|
85
|
+
*
|
|
64
86
|
* @module
|
|
65
87
|
*/
|
|
66
88
|
|
|
@@ -69,107 +91,58 @@ import path from "node:path";
|
|
|
69
91
|
|
|
70
92
|
import unidecode from "unidecode";
|
|
71
93
|
|
|
72
|
-
import {
|
|
73
|
-
import { canonicalKey } from "./
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
import { metadataFileName } from "./metadata-index.mjs";
|
|
95
|
+
import { addressSlug, canonicalKey } from "./content-address.mjs";
|
|
96
|
+
// One reader for a note's anchors, shared with the link checker and with the
|
|
97
|
+
// builds that emit a link (#243). Re-exported because this is where callers
|
|
98
|
+
// have always addressed it.
|
|
99
|
+
import { collectAnchors } from "./anchors.mjs";
|
|
100
|
+
import { subtypeRow, NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
|
|
101
|
+
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
|
|
76
102
|
|
|
77
103
|
/**
|
|
78
|
-
* The
|
|
79
|
-
* itself.
|
|
80
|
-
*
|
|
81
|
-
* `package` is the note's distribution unit — the configured `contentPackage`,
|
|
82
|
-
* since a note declaring its own is a hard error (package-build#56) — and it
|
|
83
|
-
* matches what the content-table expander puts on the same field, so a query
|
|
84
|
-
* reads the same value from either. `file` namespaces the note's place in the
|
|
85
|
-
* tree, again matching the expander's `file.*`.
|
|
104
|
+
* The `<system>` a note belongs to when it belongs to none.
|
|
86
105
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
106
|
+
* The specification's word, not this module's: the canonical address carries it
|
|
107
|
+
* in the same position — `harnadventures-none-being-grod` — so the index and
|
|
108
|
+
* the address say "no system" the same way (#59).
|
|
90
109
|
*
|
|
91
|
-
* @type {
|
|
92
|
-
*/
|
|
93
|
-
export const DERIVED_KEYS = Object.freeze([
|
|
94
|
-
"package",
|
|
95
|
-
"file",
|
|
96
|
-
"address",
|
|
97
|
-
"anchors",
|
|
98
|
-
"nameAscii",
|
|
99
|
-
"aliasesAscii",
|
|
100
|
-
]);
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* A heading, and the `{#slug}` anchor it declares.
|
|
104
|
-
*
|
|
105
|
-
* Kept identical to the pair {@link splitPages} matches, because the two must
|
|
106
|
-
* agree about what an anchor is: that pass decides which sections become
|
|
107
|
-
* addressable journal pages, and an index naming an anchor it does not produce
|
|
108
|
-
* would advertise a link that resolves nowhere. `tests/content-index.test.ts`
|
|
109
|
-
* asserts the two find the same anchors, so drift fails the suite rather than
|
|
110
|
-
* shipping.
|
|
111
|
-
*/
|
|
112
|
-
const HEADING = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
113
|
-
const ANCHOR = /^(.*?)\s*\{#([^}]+)\}\s*$/;
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The `{#slug}` anchors a note's body declares, with where each one sits.
|
|
117
|
-
*
|
|
118
|
-
* Only headings carrying an explicit anchor are collected. A bare `#` heading
|
|
119
|
-
* also starts a journal page, but it declares no slug, so nothing can address
|
|
120
|
-
* it with `#…` — listing it would offer a link that cannot be written.
|
|
121
|
-
*
|
|
122
|
-
* @param {string} body - The note's markdown body, frontmatter already removed.
|
|
123
|
-
* @param {number} [bodyLine] - The 1-based file line the body starts on, from
|
|
124
|
-
* `parseMarkdownFile`. Anchors are reported at their position in the **file**,
|
|
125
|
-
* so an editor can jump straight to one; passing nothing numbers from the body.
|
|
126
|
-
* @returns {Array<{slug: string, name: string, level: number, line: number}>}
|
|
127
|
-
* In document order.
|
|
110
|
+
* @type {string}
|
|
128
111
|
*/
|
|
129
|
-
export function collectAnchors(body, bodyLine = 1) {
|
|
130
|
-
const anchors = [];
|
|
131
|
-
let inCodeBlock = false;
|
|
132
|
-
const lines = String(body ?? "").split("\n");
|
|
133
112
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
113
|
+
export { collectAnchors };
|
|
114
|
+
import { entriesForNote, foundryIdentities } from "./foundry-entries.mjs";
|
|
115
|
+
import { walkMarkdownTree } from "./helpers.mjs";
|
|
116
|
+
import { resolveNoteId } from "./note-ids.mjs";
|
|
117
|
+
// The retired-field refusal and the key locator, so a note authoring a derived
|
|
118
|
+
// key is reported where it is rather than as a bare abort (#243).
|
|
119
|
+
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
120
|
+
import { locateFrontmatterKey } from "./retired-fields.mjs";
|
|
121
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
122
|
+
// The record accessors, which live apart so that a module the compilers load
|
|
123
|
+
// can read a record without importing this one and closing a cycle (#243).
|
|
124
|
+
// Re-exported because this is where callers have always addressed them.
|
|
125
|
+
import { authoredFrontmatter, DERIVED_KEYS, isNoteRecord, noteFile } from "./index-records.mjs";
|
|
142
126
|
|
|
143
|
-
|
|
144
|
-
if (!heading) continue;
|
|
145
|
-
const anchor = ANCHOR.exec(heading[2].trim());
|
|
146
|
-
if (!anchor) continue;
|
|
147
|
-
|
|
148
|
-
const slug = anchor[2].trim();
|
|
149
|
-
if (!slug) continue;
|
|
150
|
-
anchors.push({
|
|
151
|
-
slug,
|
|
152
|
-
name: anchor[1].trim(),
|
|
153
|
-
level: heading[1].length,
|
|
154
|
-
line: bodyLine + i,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
return anchors;
|
|
158
|
-
}
|
|
127
|
+
export { authoredFrontmatter, DERIVED_KEYS, isNoteRecord, noteFile };
|
|
159
128
|
|
|
160
129
|
/**
|
|
161
130
|
* The address a wikilink writes to reach a note, or `null` when it has none.
|
|
162
131
|
*
|
|
163
132
|
* A wikilink target is an address: `being-aurochs` locally, or
|
|
164
133
|
* `sohl-being-aurochs` from another package (`readQualifier` also accepts
|
|
165
|
-
* `being/aurochs`, the same two fields with a different separator). Both
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
134
|
+
* `being/aurochs`, the same two fields with a different separator). Both are
|
|
135
|
+
* *partial* addresses — the canonical one this field records is
|
|
136
|
+
* `sohl-sohl-being-aurochs`, and a target that omits the system is matched
|
|
137
|
+
* with that segment wildcarded. Every form is already derivable from `type` and
|
|
138
|
+
* `shortcode`, which every record carries, plus the system the type compiles
|
|
139
|
+
* into — so this field adds no information. What it adds is the *rule*: the
|
|
140
|
+
* lowercasing, the hyphen join and the system lookup live in one place, and a
|
|
141
|
+
* consumer that reimplements them slightly differently gets a lookup that
|
|
142
|
+
* matches nothing and says nothing about why. That is a real failure, not a
|
|
143
|
+
* hypothetical one — it is precisely how a resolver keyed on a bare
|
|
144
|
+
* `type/shortcode` silently misses every canonical
|
|
145
|
+
* `pkg-system-type-shortcode` entry.
|
|
173
146
|
*
|
|
174
147
|
* Derived by the same functions the link manifest and the site build use, so an
|
|
175
148
|
* index cannot disagree with either about where a note lives.
|
|
@@ -177,8 +150,8 @@ export function collectAnchors(body, bodyLine = 1) {
|
|
|
177
150
|
* @param {Record<string, any>} frontmatter - The note's parsed frontmatter.
|
|
178
151
|
* @param {string} contentPackage - The package the tree compiles as.
|
|
179
152
|
* @returns {{slug: string, canonical: string}|null} `slug` is what goes inside
|
|
180
|
-
* `[[…]]` within this package; `canonical` is the
|
|
181
|
-
* manifest files the note under. `null` for a note with no type or no
|
|
153
|
+
* `[[…]]` within this package; `canonical` is the fully qualified key the
|
|
154
|
+
* manifest files the note under, carrying the package and the system as well. `null` for a note with no type or no
|
|
182
155
|
* shortcode, which has no address at all and is stated as such rather than
|
|
183
156
|
* left for every reader to rediscover.
|
|
184
157
|
*/
|
|
@@ -194,7 +167,12 @@ export function noteAddress(frontmatter, contentPackage) {
|
|
|
194
167
|
}
|
|
195
168
|
return {
|
|
196
169
|
slug,
|
|
197
|
-
canonical: canonicalKey(
|
|
170
|
+
canonical: canonicalKey(
|
|
171
|
+
contentPackage,
|
|
172
|
+
systemOf(frontmatter.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
|
|
173
|
+
frontmatter.type,
|
|
174
|
+
frontmatter.shortcode,
|
|
175
|
+
),
|
|
198
176
|
};
|
|
199
177
|
}
|
|
200
178
|
|
|
@@ -294,19 +272,159 @@ export function asciiAliases(aliases) {
|
|
|
294
272
|
* @throws {Error} When the note carries a key this module derives, which would
|
|
295
273
|
* otherwise be overwritten without a word.
|
|
296
274
|
*/
|
|
297
|
-
|
|
275
|
+
/**
|
|
276
|
+
* This note's Foundry addresses, or `null` where it has none.
|
|
277
|
+
*
|
|
278
|
+
* **Derived by the manifest's own code, not a second implementation of it.**
|
|
279
|
+
* {@link module:engine/manifest-emit.entriesForNote} is what the link manifest
|
|
280
|
+
* emits from, and a UUID is a function of the note's `type` and authored `id`
|
|
281
|
+
* plus the pack router — frontmatter and configuration, nothing from a compiled
|
|
282
|
+
* pack — so the index's frontmatter walk already has every input. Deriving it
|
|
283
|
+
* twice is how two artifacts describing one note start disagreeing, which is
|
|
284
|
+
* the failure the merge is meant to end (#239).
|
|
285
|
+
*
|
|
286
|
+
* The shape flattens the manifest's *two* entries for an item note onto the one
|
|
287
|
+
* record the index keeps per note. An item compiles into a document **and** a
|
|
288
|
+
* documentation journal, and both are addressable — so the item's own UUID sits
|
|
289
|
+
* at the top and the journal's beside it under `doc`, with the anchor map that
|
|
290
|
+
* addresses its pages. A note that is itself a journal carries that map
|
|
291
|
+
* directly.
|
|
292
|
+
*
|
|
293
|
+
* Every address is independently optional, exactly as the manifest has it: a
|
|
294
|
+
* note that compiles to no document has no UUID, and inventing one would assert
|
|
295
|
+
* a target that does not exist.
|
|
296
|
+
*
|
|
297
|
+
* @param {object} args - Arguments.
|
|
298
|
+
* @param {object} args.frontmatter - The note's frontmatter.
|
|
299
|
+
* @param {object|null} args.address - Its resolved address, or null.
|
|
300
|
+
* @param {string} args.body - The note body, for anchor discovery.
|
|
301
|
+
* @param {object|null} args.manifest - The manifest context, when available.
|
|
302
|
+
* @returns {object|null} `{ uuid?, anchors?, doc? }`, or null when the note has
|
|
303
|
+
* no Foundry address at all.
|
|
304
|
+
*/
|
|
305
|
+
function foundryEntries({ frontmatter, address, body, manifest }) {
|
|
306
|
+
// No address is not an error here — the index records every note, including
|
|
307
|
+
// ones that publish nothing, and the manifest reports that case separately.
|
|
308
|
+
if (!manifest || !address) return null;
|
|
309
|
+
|
|
310
|
+
let entries;
|
|
311
|
+
try {
|
|
312
|
+
// The slug, not the address object: the manifest emitter takes the
|
|
313
|
+
// published path as a string and builds its `url` from it.
|
|
314
|
+
entries = entriesForNote(
|
|
315
|
+
frontmatter,
|
|
316
|
+
frontmatter?.name?.full ?? "",
|
|
317
|
+
address.slug,
|
|
318
|
+
body ?? "",
|
|
319
|
+
manifest,
|
|
320
|
+
);
|
|
321
|
+
} catch {
|
|
322
|
+
// A note the manifest cannot address is still a note. The index says so
|
|
323
|
+
// by carrying no `foundry` block rather than by failing the walk.
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
if (!entries?.length) return null;
|
|
327
|
+
const [own, docEntry] = entries;
|
|
328
|
+
return { own: own ?? null, doc: docEntry ?? null };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The `foundry` block for one manifest entry, **keyed by the system that
|
|
333
|
+
* compiles it**.
|
|
334
|
+
*
|
|
335
|
+
* A note may declare more than one system — 2,497 of `harn-ensemble`'s carry
|
|
336
|
+
* both a `sohl:` and an `hm3:` block — and each compiles into its *own* Foundry
|
|
337
|
+
* document, of that system's document type, in that system's pack. One `uuid`
|
|
338
|
+
* on the record cannot name two documents, so it named whichever the single
|
|
339
|
+
* shipped map produced and said nothing about the other.
|
|
340
|
+
*
|
|
341
|
+
* Only `sohl` can appear today, because `KNOWN_DOCUMENT_SUBTYPE_MAPS` holds one
|
|
342
|
+
* map and #139 tracks the missing `hm3/` half. The shape is system-keyed now so
|
|
343
|
+
* that adding it is one more key rather than a second breaking change to an
|
|
344
|
+
* artifact consumers have already started reading.
|
|
345
|
+
*
|
|
346
|
+
* **Derived, and a sibling of the authored block rather than inside it.** The
|
|
347
|
+
* uuid could have been synthesized onto `sohl:`/`hm3:` themselves, but those are
|
|
348
|
+
* regions a note *authors*, and {@link DERIVED_KEYS} — which refuses a note that
|
|
349
|
+
* writes over derived data — reaches only the top level. A note authoring
|
|
350
|
+
* `sohl.uuid` would collide silently, which is the failure this index exists to
|
|
351
|
+
* stop rather than to add.
|
|
352
|
+
*
|
|
353
|
+
* @param {object|null} entry - A manifest entry.
|
|
354
|
+
* @param {string} system - The system whose document this is.
|
|
355
|
+
* @returns {object|null} `{ [system]: { uuid?, anchors? } }`, or null when it
|
|
356
|
+
* addresses nothing.
|
|
357
|
+
*/
|
|
358
|
+
|
|
359
|
+
function foundryBlock(entry, system) {
|
|
360
|
+
if (!entry) return null;
|
|
361
|
+
const block = {};
|
|
362
|
+
if (entry.uuid) block.uuid = entry.uuid;
|
|
363
|
+
if (entry.anchors) block.anchors = entry.anchors;
|
|
364
|
+
if (!Object.keys(block).length) return null;
|
|
365
|
+
return { [system || NO_SYSTEM]: block };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Refuse a note that authors a key the index derives, and say where.
|
|
370
|
+
*
|
|
371
|
+
* **Located, because every reader of the index is now a reporter of this.**
|
|
372
|
+
* Until #243 the only pass that built a record was the emitter, so aborting
|
|
373
|
+
* with a bare message was the whole story. Now the link check and the address
|
|
374
|
+
* diff read the index too, and a bare abort in one of them reports *nothing*
|
|
375
|
+
* about the tree — the one malformed note takes every other finding with it,
|
|
376
|
+
* and the reader is handed a stack instead of a line to open. So the error
|
|
377
|
+
* carries `file` and a `position`, and a pass that collects rather than throws
|
|
378
|
+
* can emit `file:line:column: error: …` like any other finding.
|
|
379
|
+
*
|
|
380
|
+
* **`package:` keeps its own words.** It is not a name collision but a *retired
|
|
381
|
+
* field* (#56), and the correction is to delete it, not to rename it — which is
|
|
382
|
+
* what {@link module:engine/note-package.assertNoDeclaredPackage} has always
|
|
383
|
+
* said, and had no caller to say it to. Deferring to it means one message for
|
|
384
|
+
* one mistake rather than two that contradict each other about the fix.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} frontmatter - The note's parsed frontmatter.
|
|
387
|
+
* @param {string} relPath - The note's path within the tree, for the message.
|
|
388
|
+
* @param {string} [absPath] - The file, read only on the failing path to locate
|
|
389
|
+
* the offending key.
|
|
390
|
+
* @param {string} [contentPackage] - The package this tree compiles as.
|
|
391
|
+
* @returns {void}
|
|
392
|
+
* @throws {Error} When the note authors a derived key. `file` and `position`
|
|
393
|
+
* ride on the error.
|
|
394
|
+
*/
|
|
395
|
+
function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
|
|
298
396
|
for (const key of DERIVED_KEYS) {
|
|
299
|
-
if (Object.hasOwn(frontmatter ?? {}, key))
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
);
|
|
397
|
+
if (!Object.hasOwn(frontmatter ?? {}, key)) continue;
|
|
398
|
+
if (key === "package") {
|
|
399
|
+
// Throws with its own wording, and its own position.
|
|
400
|
+
assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage });
|
|
304
401
|
}
|
|
402
|
+
const err = new Error(
|
|
403
|
+
`\`${key}:\` is derived by the content index and cannot be ` +
|
|
404
|
+
`authored — rename the frontmatter field`,
|
|
405
|
+
);
|
|
406
|
+
err.file = relPath;
|
|
407
|
+
const position = absPath ? locateFrontmatterKey(absPath, key) : undefined;
|
|
408
|
+
if (position) err.position = position;
|
|
409
|
+
throw err;
|
|
305
410
|
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function buildIndexRecord({
|
|
414
|
+
frontmatter,
|
|
415
|
+
relPath,
|
|
416
|
+
absPath,
|
|
417
|
+
contentPackage,
|
|
418
|
+
body,
|
|
419
|
+
bodyLine,
|
|
420
|
+
manifest,
|
|
421
|
+
}) {
|
|
422
|
+
assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
|
|
306
423
|
|
|
307
424
|
const posix = relPath.split(path.sep).join("/");
|
|
308
425
|
const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
|
|
309
426
|
const address = noteAddress(frontmatter, contentPackage);
|
|
427
|
+
const entries = foundryEntries({ frontmatter, address, body, manifest });
|
|
310
428
|
|
|
311
429
|
return /** @type {Record<string, any>} */ (
|
|
312
430
|
sortKeysDeep({
|
|
@@ -323,6 +441,14 @@ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, b
|
|
|
323
441
|
...a,
|
|
324
442
|
link: address ? `${address.slug}#${a.slug}` : null,
|
|
325
443
|
})),
|
|
444
|
+
foundry: foundryBlock(
|
|
445
|
+
entries?.own,
|
|
446
|
+
systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
|
|
447
|
+
),
|
|
448
|
+
// Forward link to the note's documentation journal, which is its
|
|
449
|
+
// own record. Named rather than nested, because the journal is a
|
|
450
|
+
// separate document with its own address — see `buildDocRecord`.
|
|
451
|
+
documentation: entries?.doc?.key ?? null,
|
|
326
452
|
file: {
|
|
327
453
|
// Relative to the content root, and deliberately not absolute.
|
|
328
454
|
// An absolute path is a fact about the machine that built the
|
|
@@ -350,31 +476,141 @@ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, b
|
|
|
350
476
|
* @returns {Array<Record<string, any>>} The records, in a total order that does
|
|
351
477
|
* not depend on directory-read order.
|
|
352
478
|
*/
|
|
353
|
-
|
|
479
|
+
/**
|
|
480
|
+
* The record for an item note's **documentation journal**.
|
|
481
|
+
*
|
|
482
|
+
* An item note compiles into two documents — the item, and a JournalEntry
|
|
483
|
+
* holding its prose — and the second is a document in its own right: its own
|
|
484
|
+
* canonical address (`doc<type>/<shortcode>`), its own UUID, its own pages.
|
|
485
|
+
* So it gets its own record, and resolving `docaffliction/blkdth` is the same
|
|
486
|
+
* lookup as resolving anything else. Nested inside the item's record it would
|
|
487
|
+
* be the one address in the index reachable only by knowing to look somewhere
|
|
488
|
+
* else, which every consumer would have to special-case.
|
|
489
|
+
*
|
|
490
|
+
* **Lean, and deliberately not the note's frontmatter.** The item's `sohl:`
|
|
491
|
+
* block describes the *item*; copying it onto the journal would assert things
|
|
492
|
+
* about the journal that are not true, and double the file to do it. What the
|
|
493
|
+
* journal has of its own is its addresses, its name, and the file it came from
|
|
494
|
+
* — plus `documents`, naming the record it is the documentation for, so the
|
|
495
|
+
* link is navigable in both directions.
|
|
496
|
+
*
|
|
497
|
+
* On the web both addresses resolve to one page — the item note renders as the
|
|
498
|
+
* page that *is* its documentation — so the slug is shared and only the
|
|
499
|
+
* canonical key differs.
|
|
500
|
+
*
|
|
501
|
+
* @param {object} args - Arguments.
|
|
502
|
+
* @param {object} args.frontmatter - The item note's frontmatter.
|
|
503
|
+
* @param {object} args.address - The item's own address.
|
|
504
|
+
* @param {object} args.entry - The manifest's doc entry.
|
|
505
|
+
* @param {object} args.file - The `file` block of the item's record.
|
|
506
|
+
* @param {string} args.contentPackage - The package the note belongs to.
|
|
507
|
+
* @param {Array<object>} args.anchors - The web anchors of the note body.
|
|
508
|
+
* @returns {Record<string, any>} The documentation journal's index record.
|
|
509
|
+
*/
|
|
510
|
+
function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anchors }) {
|
|
511
|
+
return /** @type {Record<string, any>} */ (
|
|
512
|
+
sortKeysDeep({
|
|
513
|
+
package: contentPackage,
|
|
514
|
+
type: `doc${frontmatter.type}`,
|
|
515
|
+
shortcode: frontmatter.shortcode,
|
|
516
|
+
name: frontmatter.name,
|
|
517
|
+
nameAscii: asciiName(frontmatter?.name?.full),
|
|
518
|
+
address: { slug: address.slug, canonical: entry.key },
|
|
519
|
+
// The record this is the documentation *for*. `documentation` is
|
|
520
|
+
// the forward link on that record, so either end reaches the other.
|
|
521
|
+
documents: address.canonical,
|
|
522
|
+
anchors,
|
|
523
|
+
foundry: foundryBlock(entry), // a journal: no system key
|
|
524
|
+
file,
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function collectContentIndex(
|
|
530
|
+
contentBase,
|
|
531
|
+
{ contentPackage, skipDirectories, manifest, problems },
|
|
532
|
+
) {
|
|
354
533
|
const records = [];
|
|
355
|
-
|
|
534
|
+
// Passed through rather than defaulted away: an absent scope is the
|
|
535
|
+
// caller's omission, and `walkMarkdownTree` says so (#243).
|
|
536
|
+
const walkOpts = { skipDirectories };
|
|
356
537
|
|
|
357
538
|
for (const { frontmatter, body, bodyLine, absPath } of walkMarkdownTree(
|
|
358
539
|
contentBase,
|
|
359
540
|
walkOpts,
|
|
360
541
|
)) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
542
|
+
const fm = frontmatter ?? {};
|
|
543
|
+
// The id the note's document is filed under (#270), resolved before
|
|
544
|
+
// the record is built so the index publishes the address *and* the id
|
|
545
|
+
// that address derives.
|
|
546
|
+
resolveNoteId(fm, { pkg: contentPackage });
|
|
547
|
+
const relPath = path.relative(contentBase, absPath);
|
|
548
|
+
let record;
|
|
549
|
+
try {
|
|
550
|
+
record = buildIndexRecord({
|
|
551
|
+
frontmatter: fm,
|
|
552
|
+
relPath,
|
|
553
|
+
absPath,
|
|
365
554
|
contentPackage,
|
|
366
555
|
body,
|
|
367
556
|
bodyLine,
|
|
368
|
-
|
|
369
|
-
|
|
557
|
+
manifest,
|
|
558
|
+
});
|
|
559
|
+
} catch (err) {
|
|
560
|
+
// No `problems` array means the caller wants the old contract: a
|
|
561
|
+
// note that cannot be recorded fails the derivation outright, which
|
|
562
|
+
// is right for the *emitter* — an index quietly missing a note
|
|
563
|
+
// would state that the note does not exist.
|
|
564
|
+
if (!problems) throw err;
|
|
565
|
+
// A reader, by contrast, reports it and carries on: one malformed
|
|
566
|
+
// note must not take every other finding in the tree with it.
|
|
567
|
+
problems.push({
|
|
568
|
+
file: absPath,
|
|
569
|
+
...(err.position ?? {}),
|
|
570
|
+
severity: "error",
|
|
571
|
+
message: String(err.message),
|
|
572
|
+
});
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
records.push(record);
|
|
576
|
+
|
|
577
|
+
// An item note is two documents, so it is two records (#239).
|
|
578
|
+
const doc = foundryEntries({
|
|
579
|
+
frontmatter: fm,
|
|
580
|
+
address: record.address,
|
|
581
|
+
body,
|
|
582
|
+
manifest,
|
|
583
|
+
})?.doc;
|
|
584
|
+
if (doc?.key && record.address) {
|
|
585
|
+
records.push(
|
|
586
|
+
buildDocRecord({
|
|
587
|
+
frontmatter: fm,
|
|
588
|
+
address: record.address,
|
|
589
|
+
entry: doc,
|
|
590
|
+
file: record.file,
|
|
591
|
+
contentPackage,
|
|
592
|
+
anchors: record.anchors,
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
}
|
|
370
596
|
}
|
|
371
597
|
|
|
372
598
|
// Content path, then the note id. The walk yields in directory-read order,
|
|
373
599
|
// which is not a fact about the content, and a rebuild that reorders lines
|
|
374
600
|
// would make every regeneration look like a change.
|
|
601
|
+
// Content path, then the canonical address, then the note id. The walk
|
|
602
|
+
// yields in directory-read order, which is not a fact about the content,
|
|
603
|
+
// and a rebuild that reordered lines would make every regeneration look
|
|
604
|
+
// like a change. The address comes before the id because an item note's two
|
|
605
|
+
// records share a file and only one of them carries an id — ordering on the
|
|
606
|
+
// id first would put the documentation ahead of the item it documents.
|
|
375
607
|
records.sort(
|
|
376
608
|
(a, b) =>
|
|
377
609
|
String(a.file.path).localeCompare(String(b.file.path), "en") ||
|
|
610
|
+
String(a.address?.canonical ?? "").localeCompare(
|
|
611
|
+
String(b.address?.canonical ?? ""),
|
|
612
|
+
"en",
|
|
613
|
+
) ||
|
|
378
614
|
String(a.id ?? "").localeCompare(String(b.id ?? ""), "en"),
|
|
379
615
|
);
|
|
380
616
|
return records;
|
|
@@ -393,6 +629,43 @@ export function serializeContentIndex(records) {
|
|
|
393
629
|
return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
|
|
394
630
|
}
|
|
395
631
|
|
|
632
|
+
/**
|
|
633
|
+
* The index records for a content tree, without writing anything.
|
|
634
|
+
*
|
|
635
|
+
* The half of {@link emitContentIndex} that derives rather than emits, so a
|
|
636
|
+
* pass that needs the corpus in memory — a SQL content table, the link check,
|
|
637
|
+
* and in time every reader #243 converts — builds it the same way the artifact
|
|
638
|
+
* is built, rather than by walking and parsing again with its own idea of the
|
|
639
|
+
* scope.
|
|
640
|
+
*
|
|
641
|
+
* @param {object} [opts]
|
|
642
|
+
* @param {string} [opts.contentBase] - The tree, defaulting to the configured one.
|
|
643
|
+
* @param {object} [opts.config] - Resolved configuration, defaulting to ambient.
|
|
644
|
+
* @param {readonly string[]} [opts.skipDirectories] - The walk's scope, for a
|
|
645
|
+
* caller that resolved one of its own; defaults to the resolved
|
|
646
|
+
* configuration's. Stated separately from `config` because a caller that was
|
|
647
|
+
* *handed* a scope must be able to pass it on rather than have it silently
|
|
648
|
+
* replaced by the one its configuration happens to carry (#243).
|
|
649
|
+
* @param {object[]} [opts.problems] - Supplied by a **reader**: a note that
|
|
650
|
+
* cannot be recorded is pushed here as a diagnostic and skipped, instead of
|
|
651
|
+
* aborting the derivation. Omitted, the note throws — which is the contract
|
|
652
|
+
* the emitter needs, since an index missing a note asserts that it does not
|
|
653
|
+
* exist.
|
|
654
|
+
* @returns {object[]} One record per note, plus one per documentation entry.
|
|
655
|
+
*/
|
|
656
|
+
export function indexRecordsFor({ contentBase, config, skipDirectories, problems } = {}) {
|
|
657
|
+
const resolved = config ?? loadPackConfig();
|
|
658
|
+
const tree = contentBase ?? resolved.paths.content;
|
|
659
|
+
if (!fs.existsSync(tree)) throw new Error(`no content tree at ${tree}`);
|
|
660
|
+
return collectContentIndex(tree, {
|
|
661
|
+
contentPackage: resolved.contentPackage,
|
|
662
|
+
skipDirectories: skipDirectories ?? resolved.skipDirectories,
|
|
663
|
+
// Only the identities a UUID is a function of — see emitContentIndex.
|
|
664
|
+
manifest: foundryIdentities(resolved),
|
|
665
|
+
problems,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
396
669
|
/**
|
|
397
670
|
* Emit this package's content index.
|
|
398
671
|
*
|
|
@@ -419,10 +692,16 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
|
|
|
419
692
|
throw new Error(`no content tree at ${tree}`);
|
|
420
693
|
}
|
|
421
694
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
695
|
+
// The identities a Foundry address is derived against. Resolved once and
|
|
696
|
+
// passed down, the way the manifest emission does it, so the walk stays a
|
|
697
|
+
// pure function of its context. A configuration that names no Foundry
|
|
698
|
+
// package yields a context whose notes simply carry no UUID.
|
|
699
|
+
// Only the identities a UUID is a function of — the package id and the pack
|
|
700
|
+
// router. Deliberately not the manifest's full context: whether a package
|
|
701
|
+
// publishes pages is no part of an address, and depending on it would make
|
|
702
|
+
// the index refuse to build for a configuration that is perfectly able to
|
|
703
|
+
// state one.
|
|
704
|
+
const records = indexRecordsFor({ contentBase: tree, config: resolved });
|
|
426
705
|
if (records.length === 0) {
|
|
427
706
|
throw new Error(
|
|
428
707
|
`${tree} yielded no notes, so the index would state that this ` +
|
|
@@ -431,9 +710,13 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
|
|
|
431
710
|
}
|
|
432
711
|
|
|
433
712
|
const text = serializeContentIndex(records);
|
|
434
|
-
const file = path.join(dir,
|
|
713
|
+
const file = path.join(dir, metadataFileName(contentPackage));
|
|
435
714
|
fs.mkdirSync(dir, { recursive: true });
|
|
436
715
|
fs.writeFileSync(file, text);
|
|
437
716
|
|
|
438
|
-
|
|
717
|
+
// Counted separately because they are genuinely different numbers: an item
|
|
718
|
+
// note yields a second record for its documentation journal, so reporting
|
|
719
|
+
// records as notes would overstate how large the tree is.
|
|
720
|
+
const notes = records.filter((r) => !r.documents).length;
|
|
721
|
+
return { file, notes, records: records.length, bytes: Buffer.byteLength(text) };
|
|
439
722
|
}
|