@heroiclands/package-build 14.0.0 → 16.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 +327 -0
- package/CONTENT.md +331 -19
- package/MIGRATING.md +185 -0
- package/bin/content-build.mjs +54 -0
- package/content-config.mjs +10 -0
- package/docs/content-format.md +21 -0
- package/engine/content-address.mjs +21 -36
- package/engine/content-index.mjs +439 -0
- package/engine/field-reference.mjs +29 -0
- package/engine/field-spec.mjs +25 -0
- package/engine/frontmatter-lint.mjs +171 -1
- package/engine/helpers.mjs +38 -12
- package/engine/homepage.mjs +9 -4
- package/engine/index.mjs +3 -0
- package/engine/item-registry.mjs +4 -1
- package/engine/macros.mjs +3 -1
- package/engine/site-build.mjs +20 -14
- package/engine/system-block.mjs +29 -3
- package/package.json +1 -1
- package/sohl/actors.mjs +6 -3
- package/sohl/item-fields.mjs +6 -0
- package/sohl/items.mjs +4 -1
- package/types/content-config.d.mts +9 -0
- package/types/engine/content-address.d.mts +22 -34
- package/types/engine/content-index.d.mts +194 -0
- package/types/engine/field-spec.d.mts +53 -0
- package/types/engine/helpers.d.mts +34 -12
- package/types/engine/homepage.d.mts +8 -4
- package/types/engine/index.d.mts +1 -0
- package/types/engine/site-build.d.mts +11 -6
package/bin/content-build.mjs
CHANGED
|
@@ -97,6 +97,7 @@ import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
|
|
|
97
97
|
import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
|
|
98
98
|
import { checkFormatting, lintMarkdown } from "../engine/prose-lint.mjs";
|
|
99
99
|
import { emitLinkManifest } from "../engine/manifest-emit.mjs";
|
|
100
|
+
import { emitContentIndex } from "../engine/content-index.mjs";
|
|
100
101
|
import {
|
|
101
102
|
buildSite,
|
|
102
103
|
gatesFailed,
|
|
@@ -228,6 +229,7 @@ const argv = yargs(hideBin(process.argv))
|
|
|
228
229
|
.command(formatCommand())
|
|
229
230
|
.command(markdownCommand())
|
|
230
231
|
.command(manifestCommand())
|
|
232
|
+
.command(contentIndexCommand())
|
|
231
233
|
.command(siteCommand())
|
|
232
234
|
.command(reachabilityCommand())
|
|
233
235
|
.command(addressesCommand())
|
|
@@ -1195,6 +1197,58 @@ function manifestCommand() {
|
|
|
1195
1197
|
};
|
|
1196
1198
|
}
|
|
1197
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* `content-build content-index` — emit this package's note index.
|
|
1202
|
+
*
|
|
1203
|
+
* Every build already walks the tree and parses every note's frontmatter, then
|
|
1204
|
+
* throws the result away, so nothing outside a build can ask a question about
|
|
1205
|
+
* the content (#224). This publishes that walk as JSON Lines: one record per
|
|
1206
|
+
* note, carrying the whole frontmatter plus the note's place in the tree.
|
|
1207
|
+
*
|
|
1208
|
+
* It is a command of its own rather than only a build step because the point of
|
|
1209
|
+
* the artifact is that anyone can regenerate it at will — it costs a
|
|
1210
|
+
* frontmatter parse, not a build. That is also what lets it stay uncommitted:
|
|
1211
|
+
* something reproducible in under a second does not need to be kept.
|
|
1212
|
+
*
|
|
1213
|
+
* @returns {object} The yargs command module.
|
|
1214
|
+
*/
|
|
1215
|
+
// eslint-disable-next-line
|
|
1216
|
+
function contentIndexCommand() {
|
|
1217
|
+
return {
|
|
1218
|
+
command: "content-index [root]",
|
|
1219
|
+
describe: "Emit this package's note index as JSON Lines",
|
|
1220
|
+
builder: (yargs) => {
|
|
1221
|
+
yargs.positional("root", {
|
|
1222
|
+
describe: "Content tree to read. Defaults to the configured contentBase.",
|
|
1223
|
+
type: "string",
|
|
1224
|
+
});
|
|
1225
|
+
yargs.option("out", {
|
|
1226
|
+
describe:
|
|
1227
|
+
"Directory to write into. Defaults to the configured " +
|
|
1228
|
+
"`paths.contentIndex`.",
|
|
1229
|
+
type: "string",
|
|
1230
|
+
});
|
|
1231
|
+
},
|
|
1232
|
+
handler: (argv) => {
|
|
1233
|
+
try {
|
|
1234
|
+
const config = loadPackConfig();
|
|
1235
|
+
const { file, notes, bytes } = emitContentIndex({
|
|
1236
|
+
config,
|
|
1237
|
+
...(argv.root ? { contentBase: argv.root } : {}),
|
|
1238
|
+
...(argv.out ? { outDir: argv.out } : {}),
|
|
1239
|
+
});
|
|
1240
|
+
log.info(
|
|
1241
|
+
`${config.contentPackage} → ${path.relative(process.cwd(), file)} ` +
|
|
1242
|
+
`(${notes} notes, ${Math.round(bytes / 1024)} KiB)`,
|
|
1243
|
+
);
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
reportFailure(err);
|
|
1246
|
+
process.exitCode = 1;
|
|
1247
|
+
}
|
|
1248
|
+
},
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1198
1252
|
/**
|
|
1199
1253
|
* `content-build site` — publish the content tree as a website.
|
|
1200
1254
|
*
|
package/content-config.mjs
CHANGED
|
@@ -90,6 +90,11 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
|
|
|
90
90
|
content: "assets/content",
|
|
91
91
|
manifests: "assets/manifests",
|
|
92
92
|
manifestOut: "build/manifests",
|
|
93
|
+
// Where `content-index` writes this package's note index. Under `build/`
|
|
94
|
+
// because it is derived and disposable — regenerating it costs a
|
|
95
|
+
// frontmatter parse — and emphatically not under `stage`, which is mirrored
|
|
96
|
+
// into a Foundry data root (#224).
|
|
97
|
+
contentIndex: "build/content-index",
|
|
93
98
|
packJson: "build/packs-json",
|
|
94
99
|
stage: "build/stage/packs",
|
|
95
100
|
unpack: "build/tmp/packs",
|
|
@@ -305,6 +310,10 @@ export function publishesContentPages(config) {
|
|
|
305
310
|
* build artifact — the published copy is
|
|
306
311
|
* the one a consumer vendors into its
|
|
307
312
|
* `manifests` directory.
|
|
313
|
+
* @property {string} [contentIndex] Where `content-index` writes this
|
|
314
|
+
* package's note index. Outbound, and a
|
|
315
|
+
* derived artifact — never a source, and
|
|
316
|
+
* never inside `stage`.
|
|
308
317
|
* @property {string} [packJson] Build-only per-entry JSON intermediate.
|
|
309
318
|
* @property {string} [stage] Compiled LevelDB packs.
|
|
310
319
|
* @property {string} [unpack] Where `unpack` extracts JSON back to.
|
|
@@ -317,6 +326,7 @@ export function publishesContentPages(config) {
|
|
|
317
326
|
* @property {string} content
|
|
318
327
|
* @property {string} manifests
|
|
319
328
|
* @property {string} manifestOut
|
|
329
|
+
* @property {string} contentIndex
|
|
320
330
|
* @property {string} packJson
|
|
321
331
|
* @property {string} stage
|
|
322
332
|
* @property {string} unpack
|
package/docs/content-format.md
CHANGED
|
@@ -138,6 +138,17 @@ it _there_, and a system that disagrees is not in error. A weapon weighs what
|
|
|
138
138
|
This is the same rule as `hm3.type` overriding a derived document type, applied
|
|
139
139
|
to fields: derive from the shared source, and let the system state the exception.
|
|
140
140
|
|
|
141
|
+
**A field whose spelling means something else at the note level has no shared
|
|
142
|
+
source.** The fallback assumes the two vocabularies agree about what a name
|
|
143
|
+
means, and they do not always: a note's top-level `title` is the heading its page
|
|
144
|
+
publishes under, while an `affiliation` item's `system.title` is the style of
|
|
145
|
+
address an office carries. Where they diverge, the field declares what the
|
|
146
|
+
top-level key means instead, and the top level stops being read for it — leaving
|
|
147
|
+
`<system>.system.<field>` and the legacy in-block position, which describe the
|
|
148
|
+
document rather than the note. `title` is the one field this applies to; `subType`
|
|
149
|
+
is the other declared item field spelled like a note-level key, and there the two
|
|
150
|
+
levels mean the same thing by design.
|
|
151
|
+
|
|
141
152
|
**A `WikiLink` becomes a shortcode where the target field expects one.** SoHL
|
|
142
153
|
stores cross-references as shortcode strings, which is what the `Code` suffix
|
|
143
154
|
marks: `data.assocSkill` is a link to a skill note, and `system.assocSkillCode`
|
|
@@ -785,6 +796,16 @@ mapping for a field no schema declares is the drift these tables exist to catch.
|
|
|
785
796
|
likewise absent here: they are filled on an embedded membership, never from a
|
|
786
797
|
catalogue note's `data:`.
|
|
787
798
|
|
|
799
|
+
**`system.title` is not the note's `title`.** The two are unrelated quantities
|
|
800
|
+
that share a spelling. A note's top-level `title` is _the title of the note_ —
|
|
801
|
+
the heading its page is published under; an affiliation's `system.title` is _the
|
|
802
|
+
style of address the office carries_, Ajaw or Warden, which a being holds by
|
|
803
|
+
virtue of its rank. So the top-level key is **not** a shared source for this
|
|
804
|
+
field, and a note that writes one is stating its own heading and nothing else
|
|
805
|
+
(#218). Author the style of address on the membership — the `system.title` of the
|
|
806
|
+
entry in a being's `sohl.items` — or, on a catalogue note that genuinely carries
|
|
807
|
+
one, at `sohl.system.title`.
|
|
808
|
+
|
|
788
809
|
### type: affliction
|
|
789
810
|
|
|
790
811
|
Represents an affliction.
|
|
@@ -79,47 +79,32 @@ export function addressSlug(fm) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* A note's address
|
|
82
|
+
* A note's address: `<type>-<shortcode>/`, e.g. `affliction-aconite/`.
|
|
83
83
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* decides nothing about where it publishes.
|
|
88
|
-
*
|
|
89
|
-
* @param {object} fm - Parsed frontmatter.
|
|
90
|
-
* @returns {string} The mount-relative address, with a trailing slash.
|
|
91
|
-
* @throws {Error} When the note has no address.
|
|
92
|
-
*/
|
|
93
|
-
export function contentAddress(fm) {
|
|
94
|
-
return `${addressSlug(fm)}/`;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* A note's address relative to its **package**, e.g. `affliction-aconite/`.
|
|
99
|
-
*
|
|
100
|
-
* This is the form the link manifest records and the site build emits pages at,
|
|
101
|
-
* and it is one function because those two must agree — a manifest asserting an
|
|
84
|
+
* This is the one form a note is addressed by. It is what the link manifest
|
|
85
|
+
* records as an entry's `path` and what the site build emits the page at, and
|
|
86
|
+
* it is one function because those two must agree — a manifest asserting an
|
|
102
87
|
* address the site does not publish resolves at build time and 404s for the
|
|
103
88
|
* reader, which is the failure this module exists to prevent.
|
|
104
89
|
*
|
|
105
|
-
* **
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* is
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* of the package's fixed mounts — `/<package>/` for the landing,
|
|
116
|
-
* `/<package>/api/` for generated API docs, neither of which contains a hyphen
|
|
117
|
-
* or names a type.
|
|
90
|
+
* **The address is relative to the package**, and to nothing finer. A consumer
|
|
91
|
+
* composing a URL prepends where the package is served (`/<package>/`); a
|
|
92
|
+
* consumer composing a manifest entry measures against that same base. Nothing
|
|
93
|
+
* else is prepended: `prefix` says where the content tree *mounts inside the
|
|
94
|
+
* package* — the Hugo directory its pages are written under — and an address is
|
|
95
|
+
* `(type, shortcode)`, a package-wide identity that takes no mount, so `sohl`
|
|
96
|
+
* publishes `/sohl/affliction-aconite/` from a file written under `kb/`. The
|
|
97
|
+
* `type-` half is what keeps that flat namespace clear of the package's fixed
|
|
98
|
+
* mounts — `/<package>/` for the landing, `/<package>/api/` for generated API
|
|
99
|
+
* docs, neither of which contains a hyphen or names a type.
|
|
118
100
|
*
|
|
119
|
-
* **It
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
101
|
+
* **It is a pure function of the frontmatter**, and takes no options. Nothing
|
|
102
|
+
* about the file the note was read from reaches it: the `README.md` convention
|
|
103
|
+
* that made one note address a whole section is retired with the section itself
|
|
104
|
+
* (#204), so every note is addressed alike and there is one rule and no branch.
|
|
105
|
+
* It took an address scheme until #215, to validate a `landing` rule it then
|
|
106
|
+
* discarded; with that key retired, `prefix` was the only thing left in the
|
|
107
|
+
* scheme and the paragraph above is the reason it never applied.
|
|
123
108
|
*
|
|
124
109
|
* @param {object} fm - Parsed frontmatter.
|
|
125
110
|
* @returns {string} The package-relative address, with a trailing slash and no
|
|
@@ -0,0 +1,439 @@
|
|
|
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
|
+
* Emitting this package's content index (#224).
|
|
16
|
+
*
|
|
17
|
+
* Every content build already walks the whole note tree and parses every note's
|
|
18
|
+
* frontmatter — the pack compilers, the site build, and the content-table
|
|
19
|
+
* expander each do it — and every one of them throws the result away when it
|
|
20
|
+
* finishes. So nothing outside a build can ask a question about the content:
|
|
21
|
+
* "which beings carry no `kbcat`?", "what does this table actually select?",
|
|
22
|
+
* "did that type rename leave anything behind?" have no answer short of writing
|
|
23
|
+
* a throwaway script that re-walks the tree. Eight dead Bestiary tables shipped
|
|
24
|
+
* for weeks behind exactly that gap (#223).
|
|
25
|
+
*
|
|
26
|
+
* This module publishes the walk. One line of JSON per note, in
|
|
27
|
+
* [JSON Lines](https://jsonlines.org/) — the whole frontmatter, plus where the
|
|
28
|
+
* note sits in the tree.
|
|
29
|
+
*
|
|
30
|
+
* **The record is the note, not a projection of it.** Frontmatter is
|
|
31
|
+
* heterogeneous and open: in `sohl` it spreads 242 distinct leaf paths unevenly
|
|
32
|
+
* over 15 types, from 9 on a `macro` to 72 on a `being`, and adding a field to
|
|
33
|
+
* one type is ordinary authoring. Any format that fixes a column set would turn
|
|
34
|
+
* that authoring into a schema migration, so nothing here selects, flattens, or
|
|
35
|
+
* renames — a reader addresses `sohl.body.weight.base` because that is what the
|
|
36
|
+
* note says, which is also, not by accident, exactly what a `dataview` query
|
|
37
|
+
* writes.
|
|
38
|
+
*
|
|
39
|
+
* **JSON Lines rather than a database.** The artifact has to survive its build
|
|
40
|
+
* and be usable by anything — a person with `jq`, an editor, a CI check,
|
|
41
|
+
* another package's build. A line-per-note text file needs no server, no
|
|
42
|
+
* driver, and no schema; it diffs in a pull request, so a migration that
|
|
43
|
+
* quietly empties a category shows up as a diff rather than as a silently
|
|
44
|
+
* different binary; and it is readable by every language without an install.
|
|
45
|
+
* SQL is not forfeited by the choice — DuckDB reads JSON Lines directly, with
|
|
46
|
+
* nested access — whereas a stored schema would forfeit the open shape.
|
|
47
|
+
*
|
|
48
|
+
* **Byte-stable, because it is meant to be rebuilt.** {@link emitContentIndex}
|
|
49
|
+
* is reachable on its own (`content-build content-index`) and costs a
|
|
50
|
+
* frontmatter parse, not a build, so the honest expectation is that anyone
|
|
51
|
+
* regenerates it whenever they want rather than treating it as precious. That
|
|
52
|
+
* only holds if two runs over an unchanged tree produce an identical file, so
|
|
53
|
+
* records are ordered by content path with the note id breaking any tie — the
|
|
54
|
+
* same total order {@link selectRows} imposes for the same reason — and every
|
|
55
|
+
* object's keys are sorted, at every depth. A walk order is a directory-read
|
|
56
|
+
* order, and directory-read order is not a fact about the content.
|
|
57
|
+
*
|
|
58
|
+
* **Derived, never a source.** The index is written under `build/`, is
|
|
59
|
+
* gitignored with the rest of it, and nothing may be authored against it. It is
|
|
60
|
+
* emphatically not in `paths.stage`: that tree is mirrored destructively into a
|
|
61
|
+
* Foundry data root, so anything left there ships inside the installed system
|
|
62
|
+
* to every player, and a build artifact has no business there.
|
|
63
|
+
*
|
|
64
|
+
* @module
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import fs from "node:fs";
|
|
68
|
+
import path from "node:path";
|
|
69
|
+
|
|
70
|
+
import unidecode from "unidecode";
|
|
71
|
+
|
|
72
|
+
import { addressSlug } from "./content-address.mjs";
|
|
73
|
+
import { canonicalKey } from "./kb-manifest.mjs";
|
|
74
|
+
import { walkMarkdownTree } from "./helpers.mjs";
|
|
75
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The keys this module adds to a record, which a note therefore may not carry
|
|
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.*`.
|
|
86
|
+
*
|
|
87
|
+
* Both are checked rather than assumed: `folder` is real frontmatter on most
|
|
88
|
+
* notes, so the neighbouring names are close enough to a real key that a silent
|
|
89
|
+
* overwrite is a plausible future rather than a hypothetical one.
|
|
90
|
+
*
|
|
91
|
+
* @type {ReadonlyArray<string>}
|
|
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.
|
|
128
|
+
*/
|
|
129
|
+
export function collectAnchors(body, bodyLine = 1) {
|
|
130
|
+
const anchors = [];
|
|
131
|
+
let inCodeBlock = false;
|
|
132
|
+
const lines = String(body ?? "").split("\n");
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < lines.length; i++) {
|
|
135
|
+
// A fenced block's contents are not headings, and `#` is a comment in
|
|
136
|
+
// most of what gets fenced.
|
|
137
|
+
if (lines[i].trim().startsWith("```")) {
|
|
138
|
+
inCodeBlock = !inCodeBlock;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (inCodeBlock) continue;
|
|
142
|
+
|
|
143
|
+
const heading = HEADING.exec(lines[i]);
|
|
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
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The address a wikilink writes to reach a note, or `null` when it has none.
|
|
162
|
+
*
|
|
163
|
+
* A wikilink target is an address: `being-aurochs` locally, or
|
|
164
|
+
* `sohl-being-aurochs` from another package (`readQualifier` also accepts
|
|
165
|
+
* `being/aurochs`, the same two fields with a different separator). Both forms
|
|
166
|
+
* are already derivable from `type` and `shortcode`, which every record
|
|
167
|
+
* carries — so this field adds no information. What it adds is the *rule*:
|
|
168
|
+
* the lowercasing and the hyphen join live in one place, and a consumer that
|
|
169
|
+
* reimplements them slightly differently gets a lookup that matches nothing and
|
|
170
|
+
* says nothing about why. That is a real failure, not a hypothetical one — it
|
|
171
|
+
* is precisely how a resolver keyed on a bare `type/shortcode` silently misses
|
|
172
|
+
* every canonical `pkg-type-shortcode` entry.
|
|
173
|
+
*
|
|
174
|
+
* Derived by the same functions the link manifest and the site build use, so an
|
|
175
|
+
* index cannot disagree with either about where a note lives.
|
|
176
|
+
*
|
|
177
|
+
* @param {Record<string, any>} frontmatter - The note's parsed frontmatter.
|
|
178
|
+
* @param {string} contentPackage - The package the tree compiles as.
|
|
179
|
+
* @returns {{slug: string, canonical: string}|null} `slug` is what goes inside
|
|
180
|
+
* `[[…]]` within this package; `canonical` is the package-qualified key the
|
|
181
|
+
* manifest files the note under. `null` for a note with no type or no
|
|
182
|
+
* shortcode, which has no address at all and is stated as such rather than
|
|
183
|
+
* left for every reader to rediscover.
|
|
184
|
+
*/
|
|
185
|
+
export function noteAddress(frontmatter, contentPackage) {
|
|
186
|
+
let slug;
|
|
187
|
+
try {
|
|
188
|
+
slug = addressSlug(frontmatter);
|
|
189
|
+
} catch {
|
|
190
|
+
// Unaddressable is ordinary — a template, a stub, a note that carries
|
|
191
|
+
// no shortcode — and not this pass's business to report. The manifest
|
|
192
|
+
// emitter already reports it, where it means a missing published page.
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
slug,
|
|
197
|
+
canonical: canonicalKey(contentPackage, frontmatter.type, frontmatter.shortcode),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Recursively sort an object's keys, so serialization is order-independent.
|
|
203
|
+
*
|
|
204
|
+
* Arrays keep their order — it is authored — but every object inside one is
|
|
205
|
+
* sorted too. Anything that is not a plain object is returned as it is.
|
|
206
|
+
*
|
|
207
|
+
* @param {unknown} value - The value to normalize.
|
|
208
|
+
* @returns {unknown} The value with every plain object's keys in sorted order.
|
|
209
|
+
*/
|
|
210
|
+
export function sortKeysDeep(value) {
|
|
211
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
212
|
+
if (value === null || typeof value !== "object") return value;
|
|
213
|
+
// A Date or any other exotic object would lose itself in a rebuild from
|
|
214
|
+
// entries, and YAML frontmatter can produce one.
|
|
215
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
|
|
216
|
+
/** @type {Record<string, unknown>} */
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const key of Object.keys(value).sort()) out[key] = sortKeysDeep(value[key]);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A note's display name reduced to printable 7-bit ASCII.
|
|
224
|
+
*
|
|
225
|
+
* Content names carry the setting's orthography — `Kûrbúl Helm`, `Hârn`,
|
|
226
|
+
* `Kèthîra` — and nobody types them. A reader searching the index, or an editor
|
|
227
|
+
* completing a wikilink, needs a form that matches what a keyboard produces, so
|
|
228
|
+
* the record states one rather than leaving every consumer to invent it (and to
|
|
229
|
+
* invent a *different* one, which is how two searches over the same data come
|
|
230
|
+
* to disagree).
|
|
231
|
+
*
|
|
232
|
+
* **Transliterated, not stripped.** `unidecode` — the same table
|
|
233
|
+
* {@link slugify} already runs, so an ASCII name and a slug can never disagree
|
|
234
|
+
* about a character — carries a letter across rather than deleting it:
|
|
235
|
+
* diacritics fold (`â`→`a`, `è`→`e`), ligatures expand (`æ`→`ae`, `Œ`→`OE`,
|
|
236
|
+
* `ß`→`ss`), the runic letters spell out (`þ`→`th`, `Þ`→`Th`, `ð`→`d`), and
|
|
237
|
+
* even a vulgar fraction becomes readable (`¾`→`3/4`). Deleting them instead
|
|
238
|
+
* would collapse `Kûrbúl` to `Krbl`, which is worse than the original.
|
|
239
|
+
*
|
|
240
|
+
* Anything still outside printable ASCII after that becomes a space, and runs
|
|
241
|
+
* of whitespace collapse — a space rather than nothing, so a character that
|
|
242
|
+
* transliterates away cannot silently weld two words together.
|
|
243
|
+
*
|
|
244
|
+
* The value is emitted even when it equals the name, so a consumer matching on
|
|
245
|
+
* it never has to branch on whether the name happened to be ASCII already.
|
|
246
|
+
*
|
|
247
|
+
* @param {unknown} name - The note's `name.full`.
|
|
248
|
+
* @returns {string|null} The ASCII form, or `null` when there is no name, or
|
|
249
|
+
* nothing printable survives.
|
|
250
|
+
*/
|
|
251
|
+
export function asciiName(name) {
|
|
252
|
+
if (typeof name !== "string") return null;
|
|
253
|
+
const folded = unidecode(name)
|
|
254
|
+
.replace(/[^\x20-\x7E]/g, " ")
|
|
255
|
+
.replace(/\s+/g, " ")
|
|
256
|
+
.trim();
|
|
257
|
+
return folded === "" ? null : folded;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A note's `name.aliases` reduced to printable 7-bit ASCII, in order.
|
|
262
|
+
*
|
|
263
|
+
* An alias is the name a reader is at least as likely to reach for as the
|
|
264
|
+
* canonical one — `Killer Whale` for an orca, `Ice Bear` for a polar bear,
|
|
265
|
+
* `Ix'balam` for a jaguar — so anything searching or completing over the index
|
|
266
|
+
* has to match them too, and needs the same keyboard-typeable form
|
|
267
|
+
* {@link asciiName} gives the primary name.
|
|
268
|
+
*
|
|
269
|
+
* Order is the authored order, so a caller can pair an entry with the alias it
|
|
270
|
+
* came from. An alias that is not a non-empty string, or that leaves nothing
|
|
271
|
+
* printable behind, is dropped rather than left as a hole — the array is a set
|
|
272
|
+
* of names to match, and a null in it is not one.
|
|
273
|
+
*
|
|
274
|
+
* @param {unknown} aliases - The note's `name.aliases`; may be absent or null.
|
|
275
|
+
* @returns {Array<string>} Possibly empty, never null: a note with no aliases
|
|
276
|
+
* has an empty set of them, which is a fact rather than a missing value, and
|
|
277
|
+
* a consumer iterating it should not have to check first.
|
|
278
|
+
*/
|
|
279
|
+
export function asciiAliases(aliases) {
|
|
280
|
+
if (!Array.isArray(aliases)) return [];
|
|
281
|
+
return aliases.map((alias) => asciiName(alias)).filter((alias) => alias !== null);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Build one index record from a note's frontmatter and its place in the tree.
|
|
286
|
+
*
|
|
287
|
+
* @param {object} options - Options.
|
|
288
|
+
* @param {Record<string, any>} options.frontmatter - The note's parsed frontmatter.
|
|
289
|
+
* @param {string} options.relPath - Its path below the content root, POSIX-separated.
|
|
290
|
+
* @param {string} options.contentPackage - The package the tree compiles as.
|
|
291
|
+
* @param {string} [options.body] - The note's markdown body, for its anchors.
|
|
292
|
+
* @param {number} [options.bodyLine] - The 1-based file line the body starts on.
|
|
293
|
+
* @returns {Record<string, any>} The record, keys sorted at every depth.
|
|
294
|
+
* @throws {Error} When the note carries a key this module derives, which would
|
|
295
|
+
* otherwise be overwritten without a word.
|
|
296
|
+
*/
|
|
297
|
+
export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, bodyLine }) {
|
|
298
|
+
for (const key of DERIVED_KEYS) {
|
|
299
|
+
if (Object.hasOwn(frontmatter ?? {}, key)) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`${relPath}: \`${key}:\` is derived by the content index and ` +
|
|
302
|
+
`cannot be authored — rename the frontmatter field`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const posix = relPath.split(path.sep).join("/");
|
|
308
|
+
const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
|
|
309
|
+
const address = noteAddress(frontmatter, contentPackage);
|
|
310
|
+
|
|
311
|
+
return /** @type {Record<string, any>} */ (
|
|
312
|
+
sortKeysDeep({
|
|
313
|
+
...frontmatter,
|
|
314
|
+
package: contentPackage,
|
|
315
|
+
address,
|
|
316
|
+
nameAscii: asciiName(frontmatter?.name?.full),
|
|
317
|
+
aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
|
|
318
|
+
// Each anchor carries the link that reaches it, so a section is
|
|
319
|
+
// addressable from the index without anyone re-deriving how an
|
|
320
|
+
// anchor is spelled — and its file line, so an editor can jump
|
|
321
|
+
// there rather than search for the heading.
|
|
322
|
+
anchors: collectAnchors(body, bodyLine).map((a) => ({
|
|
323
|
+
...a,
|
|
324
|
+
link: address ? `${address.slug}#${a.slug}` : null,
|
|
325
|
+
})),
|
|
326
|
+
file: {
|
|
327
|
+
// Relative to the content root, and deliberately not absolute.
|
|
328
|
+
// An absolute path is a fact about the machine that built the
|
|
329
|
+
// index, not about the content: it would differ between two
|
|
330
|
+
// checkouts of the same tree, so the file would stop being
|
|
331
|
+
// byte-stable, and a published copy would carry someone's home
|
|
332
|
+
// directory and be wrong for every reader. Anyone holding the
|
|
333
|
+
// index knows the root it was built from, and `root + path` is
|
|
334
|
+
// the absolute form whenever it is wanted.
|
|
335
|
+
path: posix,
|
|
336
|
+
folder,
|
|
337
|
+
name: path.basename(posix, ".md"),
|
|
338
|
+
},
|
|
339
|
+
})
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Read a content tree into index records, in the order they will be written.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} contentBase - The content tree to walk.
|
|
347
|
+
* @param {object} options - Options.
|
|
348
|
+
* @param {string} options.contentPackage - The package the tree compiles as.
|
|
349
|
+
* @param {Array<string>} [options.skipDirectories] - Directory names to skip.
|
|
350
|
+
* @returns {Array<Record<string, any>>} The records, in a total order that does
|
|
351
|
+
* not depend on directory-read order.
|
|
352
|
+
*/
|
|
353
|
+
export function collectContentIndex(contentBase, { contentPackage, skipDirectories }) {
|
|
354
|
+
const records = [];
|
|
355
|
+
const walkOpts = skipDirectories ? { skipDirectories } : {};
|
|
356
|
+
|
|
357
|
+
for (const { frontmatter, body, bodyLine, absPath } of walkMarkdownTree(
|
|
358
|
+
contentBase,
|
|
359
|
+
walkOpts,
|
|
360
|
+
)) {
|
|
361
|
+
records.push(
|
|
362
|
+
buildIndexRecord({
|
|
363
|
+
frontmatter: frontmatter ?? {},
|
|
364
|
+
relPath: path.relative(contentBase, absPath),
|
|
365
|
+
contentPackage,
|
|
366
|
+
body,
|
|
367
|
+
bodyLine,
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Content path, then the note id. The walk yields in directory-read order,
|
|
373
|
+
// which is not a fact about the content, and a rebuild that reorders lines
|
|
374
|
+
// would make every regeneration look like a change.
|
|
375
|
+
records.sort(
|
|
376
|
+
(a, b) =>
|
|
377
|
+
String(a.file.path).localeCompare(String(b.file.path), "en") ||
|
|
378
|
+
String(a.id ?? "").localeCompare(String(b.id ?? ""), "en"),
|
|
379
|
+
);
|
|
380
|
+
return records;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Serialize records as JSON Lines.
|
|
385
|
+
*
|
|
386
|
+
* @param {Array<Record<string, any>>} records - From {@link collectContentIndex}.
|
|
387
|
+
* @returns {string} One compact JSON object per line, newline-terminated. An
|
|
388
|
+
* empty set serializes to the empty string rather than to a lone newline, so
|
|
389
|
+
* the file is exactly the lines it holds.
|
|
390
|
+
*/
|
|
391
|
+
export function serializeContentIndex(records) {
|
|
392
|
+
if (records.length === 0) return "";
|
|
393
|
+
return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Emit this package's content index.
|
|
398
|
+
*
|
|
399
|
+
* @param {object} [options] - Options.
|
|
400
|
+
* @param {string} [options.contentBase] - The content tree; defaults to the
|
|
401
|
+
* configured `paths.content`.
|
|
402
|
+
* @param {string} [options.outDir] - Where to write; defaults to the configured
|
|
403
|
+
* `paths.contentIndex`.
|
|
404
|
+
* @param {object} [options.config] - A resolved configuration; loaded when omitted.
|
|
405
|
+
* @returns {{file: string, notes: number, bytes: number}} Where it was written,
|
|
406
|
+
* how many notes it holds, and its size.
|
|
407
|
+
* @throws {Error} When the content tree is absent, or when it yields no note at
|
|
408
|
+
* all — an empty index is indistinguishable from a mis-pointed tree, and a
|
|
409
|
+
* reader would take it as the authoritative statement that this package has
|
|
410
|
+
* no content.
|
|
411
|
+
*/
|
|
412
|
+
export function emitContentIndex({ contentBase, outDir, config } = {}) {
|
|
413
|
+
const resolved = config ?? loadPackConfig();
|
|
414
|
+
const tree = contentBase ?? resolved.paths.content;
|
|
415
|
+
const dir = outDir ?? resolved.paths.contentIndex;
|
|
416
|
+
const contentPackage = resolved.contentPackage;
|
|
417
|
+
|
|
418
|
+
if (!fs.existsSync(tree)) {
|
|
419
|
+
throw new Error(`no content tree at ${tree}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const records = collectContentIndex(tree, {
|
|
423
|
+
contentPackage,
|
|
424
|
+
skipDirectories: resolved.skipDirectories,
|
|
425
|
+
});
|
|
426
|
+
if (records.length === 0) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`${tree} yielded no notes, so the index would state that this ` +
|
|
429
|
+
`package has no content`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const text = serializeContentIndex(records);
|
|
434
|
+
const file = path.join(dir, `${contentPackage}.jsonl`);
|
|
435
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
436
|
+
fs.writeFileSync(file, text);
|
|
437
|
+
|
|
438
|
+
return { file, notes: records.length, bytes: Buffer.byteLength(text) };
|
|
439
|
+
}
|