@heroiclands/package-build 0.6.1 → 3.0.1
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-content-build.md +965 -0
- package/CHANGELOG.md +68 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -35
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- package/types/sohl/note-schemas.d.mts +7 -0
|
@@ -0,0 +1,778 @@
|
|
|
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
|
+
* Publishing a content tree as a website (#63).
|
|
16
|
+
*
|
|
17
|
+
* Compiling a content tree into compendium packs is `content-build package
|
|
18
|
+
* compile`. Publishing the *same tree* as a website was a script each consumer
|
|
19
|
+
* wrote for itself — 473 code lines in `sohl` and 462 in `sohl-thalorna`, 87 of
|
|
20
|
+
* them identical — and the copies drifted in ways neither repository could see.
|
|
21
|
+
* `sohl-thalorna` reimplemented four things this package already exported, not
|
|
22
|
+
* because it needed different behaviour but because its script predates the
|
|
23
|
+
* extraction. That is the failure a command removes: a consumer cannot
|
|
24
|
+
* accidentally reimplement one.
|
|
25
|
+
*
|
|
26
|
+
* **What is here is the pass, not the framing.** The walk, the frontmatter read,
|
|
27
|
+
* the address derivation, the address index, table expansion, wikilink
|
|
28
|
+
* resolution, code-fence protection, the foreign-manifest merge, the page
|
|
29
|
+
* emission and the section-landing backfill are the same job everywhere. Where a
|
|
30
|
+
* page's address comes from (`publish.address`), what a section is called, and
|
|
31
|
+
* what a repository's own rewrites are, are not — they arrive as configuration
|
|
32
|
+
* and as a **named** pass bundle, since a configuration is data and cannot hold
|
|
33
|
+
* a function.
|
|
34
|
+
*
|
|
35
|
+
* **Every gate reports; none exits.** The integrity checks a site build needs —
|
|
36
|
+
* a wikilink authored in frontmatter, a name that yields no slug, two pages
|
|
37
|
+
* claiming one URL, an unusable or unaddressable foreign manifest, an address
|
|
38
|
+
* two packages both claim, a table directive that cannot be honoured, a dead
|
|
39
|
+
* wikilink — were inline `process.exit` calls in both scripts, with no test
|
|
40
|
+
* between them. Here each returns its findings and the command decides. That is
|
|
41
|
+
* the rule `engine/site-index.mjs` already follows, and it is the only reason
|
|
42
|
+
* these cases can be tested at all.
|
|
43
|
+
*
|
|
44
|
+
* @module
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import fs from "node:fs";
|
|
48
|
+
import path from "node:path";
|
|
49
|
+
import { createRequire } from "node:module";
|
|
50
|
+
import matter from "gray-matter";
|
|
51
|
+
|
|
52
|
+
import { contentSlug, findSlugCollisions, slugify } from "./content-slug.mjs";
|
|
53
|
+
import { sectionOf } from "./content-address.mjs";
|
|
54
|
+
import { protectCode } from "./code-fences.mjs";
|
|
55
|
+
import { expandContentTables } from "./content-tables.mjs";
|
|
56
|
+
import { buildSiteIndex, wikiContext } from "./site-index.mjs";
|
|
57
|
+
import { frontmatterWikilinks, resolveWebWikilinks } from "./web-wikilinks.mjs";
|
|
58
|
+
import { loadForeignManifests, manifestsComplete } from "./kb-manifest.mjs";
|
|
59
|
+
import {
|
|
60
|
+
formatUnaddressableFinding,
|
|
61
|
+
unaddressableForeignPackages,
|
|
62
|
+
} from "./foreign-manifests.mjs";
|
|
63
|
+
import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
|
|
64
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
65
|
+
|
|
66
|
+
const require = createRequire(import.meta.url);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every `.md` file under `dir`, depth-first in directory order.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately *not* {@link walkMarkdownTree}, whose stack-based walk yields a
|
|
72
|
+
* tree in reverse. Order is load-bearing here and nowhere else: the address
|
|
73
|
+
* index resolves a bare `[[Name]]` on a first-writer-wins basis, so reversing
|
|
74
|
+
* the walk silently changes which page an ambiguous name resolves to. A pack
|
|
75
|
+
* compile has no such dependency, which is why the two walks can differ.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} dir - Directory to walk.
|
|
78
|
+
* @param {readonly string[]} skip - Directory names to ignore at any depth.
|
|
79
|
+
* @returns {string[]} Absolute paths.
|
|
80
|
+
*/
|
|
81
|
+
export function walkSiteTree(dir, skip = []) {
|
|
82
|
+
const out = [];
|
|
83
|
+
if (!fs.existsSync(dir)) return out;
|
|
84
|
+
const skipped = new Set(skip);
|
|
85
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
86
|
+
const full = path.join(dir, e.name);
|
|
87
|
+
if (e.isDirectory()) {
|
|
88
|
+
if (skipped.has(e.name)) continue;
|
|
89
|
+
out.push(...walkSiteTree(full, skip));
|
|
90
|
+
} else if (e.isFile() && e.name.endsWith(".md")) {
|
|
91
|
+
out.push(full);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads a note, returning `null` for one that cannot be parsed.
|
|
99
|
+
*
|
|
100
|
+
* A file that is not front-mattered markdown is skipped rather than reported: a
|
|
101
|
+
* content tree is edited in Obsidian, which leaves files of its own about, and
|
|
102
|
+
* failing on one would make the build hostage to the editor.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} file - Absolute path.
|
|
105
|
+
* @returns {{fm: object, body: string}|null}
|
|
106
|
+
*/
|
|
107
|
+
function readNote(file) {
|
|
108
|
+
try {
|
|
109
|
+
const { data, content } = matter(fs.readFileSync(file, "utf8"));
|
|
110
|
+
return { fm: data, body: content };
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The content tree's pages, and what could not be addressed.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} contentBase - Absolute path to the content tree.
|
|
120
|
+
* @param {object} ctx - `{ packages, skipDirectories, mount, scheme }`.
|
|
121
|
+
* @returns {{pages: object[], slugFindings: object[], fmLinkFindings: object[]}}
|
|
122
|
+
*/
|
|
123
|
+
export function collectContentPages(contentBase, ctx) {
|
|
124
|
+
const pages = [];
|
|
125
|
+
const slugFindings = [];
|
|
126
|
+
const fmLinkFindings = [];
|
|
127
|
+
|
|
128
|
+
for (const file of walkSiteTree(contentBase, ctx.skipDirectories)) {
|
|
129
|
+
const note = readNote(file);
|
|
130
|
+
if (!note) continue;
|
|
131
|
+
const { fm, body } = note;
|
|
132
|
+
if (!ctx.packages.has(fm.package) || !fm.type) continue;
|
|
133
|
+
|
|
134
|
+
for (const hit of frontmatterWikilinks(fm)) {
|
|
135
|
+
fmLinkFindings.push({ file, ...hit });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const name = fm.name?.full ?? path.basename(file, ".md");
|
|
139
|
+
// The URL segment derives from the name (#1278), never from the
|
|
140
|
+
// shortcode, which is identity referenced by saved world data rather
|
|
141
|
+
// than presentation.
|
|
142
|
+
let slug;
|
|
143
|
+
try {
|
|
144
|
+
slug = contentSlug(name);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
slugFindings.push({ file, reason: err.message });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const base = path.basename(file);
|
|
151
|
+
const isReadme = base.toLowerCase() === "readme.md";
|
|
152
|
+
const sec = sectionOf(fm);
|
|
153
|
+
const rel = path.relative(contentBase, file);
|
|
154
|
+
pages.push({
|
|
155
|
+
kind: "content",
|
|
156
|
+
fm,
|
|
157
|
+
body,
|
|
158
|
+
name,
|
|
159
|
+
slug,
|
|
160
|
+
base,
|
|
161
|
+
// Top-level content directory ("Rules", "Skills", …). Wikilinks do
|
|
162
|
+
// not use it: a note is addressed as `type/shortcode` wherever it
|
|
163
|
+
// is filed.
|
|
164
|
+
tld: rel.split(path.sep)[0],
|
|
165
|
+
// Location below the content root, POSIX-separated — what a
|
|
166
|
+
// generated table reads as `file.path` and scopes on with `FROM`.
|
|
167
|
+
relPath: rel.split(path.sep).join("/"),
|
|
168
|
+
// The immediate source subfolder, the only surviving record of the
|
|
169
|
+
// authoring folder, for grouped landings.
|
|
170
|
+
folder: path.basename(path.dirname(file)),
|
|
171
|
+
sec,
|
|
172
|
+
url:
|
|
173
|
+
isReadme ?
|
|
174
|
+
`${ctx.mount}${sec}/`
|
|
175
|
+
: `${ctx.mount}${sec}/${slug}/`,
|
|
176
|
+
isReadme,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return { pages, slugFindings, fmLinkFindings };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* An extra tree's pages — a documentation tree published alongside the content.
|
|
184
|
+
*
|
|
185
|
+
* These preserve their **source layout** below the section rather than being
|
|
186
|
+
* addressed by type and slug: they are a book with chapters, and a reader
|
|
187
|
+
* follows their paths. A `README` is its directory's landing.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} tree - `{ from, rel, section, route }`.
|
|
190
|
+
* @param {object} ctx - `{ mount }`.
|
|
191
|
+
* @returns {{pages: object[], fmLinkFindings: object[]}}
|
|
192
|
+
*/
|
|
193
|
+
export function collectTreePages(tree, ctx) {
|
|
194
|
+
const pages = [];
|
|
195
|
+
const fmLinkFindings = [];
|
|
196
|
+
|
|
197
|
+
for (const file of walkSiteTree(tree.from)) {
|
|
198
|
+
const note = readNote(file);
|
|
199
|
+
if (!note) continue;
|
|
200
|
+
const { fm, body } = note;
|
|
201
|
+
|
|
202
|
+
for (const hit of frontmatterWikilinks(fm)) {
|
|
203
|
+
fmLinkFindings.push({ file, ...hit });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const rel = path.relative(tree.from, file).replace(/\\/g, "/");
|
|
207
|
+
const base = path.basename(rel);
|
|
208
|
+
const isReadme = base.toLowerCase() === "readme.md";
|
|
209
|
+
const sec = fm.category ?? tree.section;
|
|
210
|
+
const h1 = /^#\s+(.+?)\s*$/m.exec(body);
|
|
211
|
+
const h1Title =
|
|
212
|
+
h1 ? h1[1].replace(/\{@link\s+[^}]*\}/g, "").trim() : null;
|
|
213
|
+
const name =
|
|
214
|
+
fm.name?.full ?? fm.title ?? h1Title ?? path.basename(base, ".md");
|
|
215
|
+
const slug = fm.slug ?? slugify(path.basename(base, ".md"));
|
|
216
|
+
const relNoExt = rel.slice(0, -3).toLowerCase();
|
|
217
|
+
const dir = path.posix.dirname(relNoExt);
|
|
218
|
+
pages.push({
|
|
219
|
+
kind: "tree",
|
|
220
|
+
tree,
|
|
221
|
+
fm,
|
|
222
|
+
// The H1 is stripped: the page title renders it.
|
|
223
|
+
body: body.replace(/^\s*#\s+.*$\r?\n?/m, ""),
|
|
224
|
+
name,
|
|
225
|
+
slug,
|
|
226
|
+
base,
|
|
227
|
+
rel,
|
|
228
|
+
sec,
|
|
229
|
+
url:
|
|
230
|
+
isReadme ?
|
|
231
|
+
dir === "." ?
|
|
232
|
+
`${ctx.mount}${sec}/`
|
|
233
|
+
: `${ctx.mount}${sec}/${dir}/`
|
|
234
|
+
: `${ctx.mount}${sec}/${relNoExt}/`,
|
|
235
|
+
isReadme,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return { pages, fmLinkFindings };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The integrity gates a site build runs before it writes anything.
|
|
243
|
+
*
|
|
244
|
+
* Every one of these was an inline `process.exit` in both consumer scripts, so
|
|
245
|
+
* none of them had a test. They are grouped here, reporting rather than exiting,
|
|
246
|
+
* because the order matters and the reasons are worth stating once:
|
|
247
|
+
*
|
|
248
|
+
* - **Frontmatter wikilinks** first, because frontmatter is copied to the page
|
|
249
|
+
* verbatim and a link written in one reaches the reader as literal `[[…]]`.
|
|
250
|
+
* - **Slugs and collisions** next: a note that derives no URL, or two that
|
|
251
|
+
* derive the same one, would silently drop or overwrite a page.
|
|
252
|
+
* - **Foreign manifests** last, in two steps. *Unusable* is a file this build
|
|
253
|
+
* cannot read; *unaddressable* is one it can read but cannot look anything up
|
|
254
|
+
* in — a distinction worth keeping, because the second surfaces as a pile of
|
|
255
|
+
* dead links blaming the notes that cite them rather than the file at fault.
|
|
256
|
+
*
|
|
257
|
+
* @param {object[]} pages - Every page, from both walks.
|
|
258
|
+
* @param {object} findings - `{ slugFindings, fmLinkFindings }` from collection.
|
|
259
|
+
* @param {object} options - `{ manifestDir }`.
|
|
260
|
+
* @returns {object} The gate results and, when they pass, the built index.
|
|
261
|
+
*/
|
|
262
|
+
export function siteGates(pages, findings, { manifestDir }) {
|
|
263
|
+
const out = {
|
|
264
|
+
frontmatterLinks: findings.fmLinkFindings ?? [],
|
|
265
|
+
slugErrors: findings.slugFindings ?? [],
|
|
266
|
+
collisions: [],
|
|
267
|
+
staleManifests: [],
|
|
268
|
+
unaddressable: [],
|
|
269
|
+
conflicts: [],
|
|
270
|
+
index: null,
|
|
271
|
+
foreign: null,
|
|
272
|
+
manifests: null,
|
|
273
|
+
};
|
|
274
|
+
if (out.frontmatterLinks.length || out.slugErrors.length) return out;
|
|
275
|
+
|
|
276
|
+
const content = pages.filter((p) => p.kind === "content");
|
|
277
|
+
out.collisions = findSlugCollisions(
|
|
278
|
+
content.map((p) => ({
|
|
279
|
+
sec: p.sec,
|
|
280
|
+
slug: p.slug,
|
|
281
|
+
src: `${p.tld}/${p.base}`,
|
|
282
|
+
})),
|
|
283
|
+
);
|
|
284
|
+
if (out.collisions.length) return out;
|
|
285
|
+
|
|
286
|
+
// Which packages are *local* is what decides which manifests are foreign,
|
|
287
|
+
// and that is only known once the tree is walked — reading it from a
|
|
288
|
+
// configured list instead silently discarded the manifest of any package
|
|
289
|
+
// the list named but the tree did not contain.
|
|
290
|
+
const localPackages = new Set(content.map((p) => p.fm.package));
|
|
291
|
+
const foreign = loadForeignManifests(manifestDir, localPackages);
|
|
292
|
+
out.foreign = foreign;
|
|
293
|
+
if (foreign.stale.length) {
|
|
294
|
+
out.staleManifests = foreign.stale;
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
out.unaddressable = unaddressableForeignPackages(foreign.index);
|
|
299
|
+
if (out.unaddressable.length) return out;
|
|
300
|
+
|
|
301
|
+
out.manifests = manifestsComplete(localPackages, foreign.packages);
|
|
302
|
+
const index = buildSiteIndex(pages, { foreignIndex: foreign.index });
|
|
303
|
+
out.conflicts = index.conflicts;
|
|
304
|
+
if (out.conflicts.length) return out;
|
|
305
|
+
|
|
306
|
+
out.index = index;
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Whether any gate produced a finding. */
|
|
311
|
+
export function gatesFailed(gates) {
|
|
312
|
+
return Boolean(
|
|
313
|
+
gates.frontmatterLinks.length ||
|
|
314
|
+
gates.slugErrors.length ||
|
|
315
|
+
gates.collisions.length ||
|
|
316
|
+
gates.staleManifests.length ||
|
|
317
|
+
gates.unaddressable.length ||
|
|
318
|
+
gates.conflicts.length,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The universe a generated table searches, grouped by package.
|
|
324
|
+
*
|
|
325
|
+
* Reference pages only, and grouped so a page never tabulates another package's
|
|
326
|
+
* content: a table is a claim about what this package ships.
|
|
327
|
+
*
|
|
328
|
+
* @param {object[]} pages - Every page.
|
|
329
|
+
* @returns {Map<string, object[]>} Package → the notes it may tabulate.
|
|
330
|
+
*/
|
|
331
|
+
export function tableUniverse(pages) {
|
|
332
|
+
const byPackage = new Map();
|
|
333
|
+
for (const p of pages) {
|
|
334
|
+
if (p.kind !== "content") continue;
|
|
335
|
+
const pkg = p.fm.package;
|
|
336
|
+
if (!byPackage.has(pkg)) byPackage.set(pkg, []);
|
|
337
|
+
byPackage.get(pkg).push({
|
|
338
|
+
fm: p.fm,
|
|
339
|
+
path: p.relPath,
|
|
340
|
+
tld: p.tld,
|
|
341
|
+
folder: p.folder,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return byPackage;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The frontmatter a page publishes with.
|
|
349
|
+
*
|
|
350
|
+
* An authored `aliases` is Obsidian's — a list of *names* a reader might call
|
|
351
|
+
* the note, which is vault addressing and stays in the vault. Hugo reads
|
|
352
|
+
* `aliases` as **URL redirects**, so passing them through would publish a
|
|
353
|
+
* redirect stub at each name. They are dropped, and this build emits no
|
|
354
|
+
* redirects of its own.
|
|
355
|
+
*
|
|
356
|
+
* @param {object} page - The page.
|
|
357
|
+
* @param {object} options - `{ sections, readmeSections, decorate }`.
|
|
358
|
+
* @returns {object} The frontmatter to write.
|
|
359
|
+
*/
|
|
360
|
+
export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
361
|
+
const { fm, name, slug, sec, isReadme } = page;
|
|
362
|
+
let data;
|
|
363
|
+
if (page.kind === "content") {
|
|
364
|
+
data = { ...fm, slug, title: fm.title ?? name, kbfolder: page.folder };
|
|
365
|
+
if (decorate) decorate(data, page);
|
|
366
|
+
if (isReadme) {
|
|
367
|
+
const meta = readmeSections[sec];
|
|
368
|
+
if (meta) {
|
|
369
|
+
data.title = meta.title;
|
|
370
|
+
// Guarded: a title-only entry would otherwise emit
|
|
371
|
+
// `banner: undefined`, which the YAML serializer rejects.
|
|
372
|
+
if (meta.banner) data.banner = meta.banner;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
// A tree's own landing describes the *mount*, and nothing beneath it. A
|
|
377
|
+
// nested README is a sub-section's landing, and reading the section's
|
|
378
|
+
// entry for it would title every one of them alike and hang the section
|
|
379
|
+
// hero on each. Its title comes from its H1, like any other page's.
|
|
380
|
+
const isSectionRoot = path.posix.dirname(page.rel) === ".";
|
|
381
|
+
const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
|
|
382
|
+
data = { ...fm, title: meta?.title ?? fm.title ?? name };
|
|
383
|
+
if (meta?.banner) data.banner = meta.banner;
|
|
384
|
+
}
|
|
385
|
+
delete data.aliases;
|
|
386
|
+
return data;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Where a page is written, relative to the output root. */
|
|
390
|
+
export function pageDestination(page) {
|
|
391
|
+
if (page.kind === "content") {
|
|
392
|
+
return page.isReadme ?
|
|
393
|
+
path.join(page.sec, "_index.md")
|
|
394
|
+
: path.join(page.sec, `${page.slug}.md`);
|
|
395
|
+
}
|
|
396
|
+
const rel =
|
|
397
|
+
page.isReadme ?
|
|
398
|
+
path.posix.join(path.posix.dirname(page.rel), "_index.md")
|
|
399
|
+
: page.rel;
|
|
400
|
+
return path.join(page.sec, rel);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Renders and writes every page.
|
|
405
|
+
*
|
|
406
|
+
* The order inside a page is load-bearing and is the same order the pack
|
|
407
|
+
* compilers use:
|
|
408
|
+
*
|
|
409
|
+
* 1. **Tables expand first**, and outside code-fence protection. A table is
|
|
410
|
+
* authored as a fenced `dataview` block, which `protectCode` would otherwise
|
|
411
|
+
* stash away before the expander saw it. Expanding first leaves an ordinary
|
|
412
|
+
* markdown table to walk, with every other fence still protected.
|
|
413
|
+
* 2. **Then, inside protection**: the consumer's `beforeLinks` pass, wikilink
|
|
414
|
+
* resolution, and the consumer's `afterLinks` pass. A `{@link}` tag may sit
|
|
415
|
+
* in prose a wikilink also touches, so the repository's own rewrites bracket
|
|
416
|
+
* the shared one rather than replacing it.
|
|
417
|
+
*
|
|
418
|
+
* @param {object[]} pages - Every page.
|
|
419
|
+
* @param {object} options - Everything the render needs.
|
|
420
|
+
* @returns {{written: number, byKind: Record<string, number>, tableErrors: object[], wikiErrors: object[]}}
|
|
421
|
+
*/
|
|
422
|
+
export function renderPages(pages, options) {
|
|
423
|
+
const {
|
|
424
|
+
outRoot,
|
|
425
|
+
index,
|
|
426
|
+
foreign,
|
|
427
|
+
manifests,
|
|
428
|
+
universe,
|
|
429
|
+
pass = {},
|
|
430
|
+
readmeSections,
|
|
431
|
+
decorate,
|
|
432
|
+
linkable = (d) => Boolean(d.fm.shortcode),
|
|
433
|
+
} = options;
|
|
434
|
+
|
|
435
|
+
const tableErrors = [];
|
|
436
|
+
const wikiErrors = [];
|
|
437
|
+
const byKind = {};
|
|
438
|
+
|
|
439
|
+
for (const page of pages) {
|
|
440
|
+
const src = page.rel ?? `${page.sec}/${page.base}`;
|
|
441
|
+
const ctx = wikiContext(index, {
|
|
442
|
+
src,
|
|
443
|
+
type: page.fm.type ?? null,
|
|
444
|
+
errors: wikiErrors,
|
|
445
|
+
foreignIndex: foreign.index,
|
|
446
|
+
manifestsComplete: manifests.complete,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const resolve = (text) => {
|
|
450
|
+
let t = text;
|
|
451
|
+
if (pass.beforeLinks) t = pass.beforeLinks(t, page);
|
|
452
|
+
t = resolveWebWikilinks(t, ctx);
|
|
453
|
+
if (pass.afterLinks) t = pass.afterLinks(t, page);
|
|
454
|
+
return t;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
let body = page.body;
|
|
458
|
+
if (page.kind === "content") {
|
|
459
|
+
const { markdown, errors } = expandContentTables(body, {
|
|
460
|
+
docs: universe.get(page.fm.package) ?? [],
|
|
461
|
+
linkable,
|
|
462
|
+
source: src,
|
|
463
|
+
self: { fm: page.fm, path: page.relPath },
|
|
464
|
+
});
|
|
465
|
+
tableErrors.push(...errors);
|
|
466
|
+
body = markdown;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const data = pageFrontmatter(page, { readmeSections, decorate });
|
|
470
|
+
const dest = path.join(outRoot, pageDestination(page));
|
|
471
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
472
|
+
fs.writeFileSync(
|
|
473
|
+
dest,
|
|
474
|
+
matter.stringify(protectCode(body, resolve), data),
|
|
475
|
+
);
|
|
476
|
+
byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { written: pages.length, byKind, tableErrors, wikiErrors };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Writes the section landings a published tree needs but no note supplies.
|
|
484
|
+
*
|
|
485
|
+
* Two separate jobs, and both exist because of how Hugo decides what a section
|
|
486
|
+
* is:
|
|
487
|
+
*
|
|
488
|
+
* - **Declared sections** get a titled `_index.md` with their hero, so a landing
|
|
489
|
+
* matches the card that links to it instead of showing Hugo's auto-humanised
|
|
490
|
+
* directory name. The body is empty, which lets the theme list the section's
|
|
491
|
+
* children — or say it is empty, for a section whose content has not shipped.
|
|
492
|
+
* - **Every other section directly under the mount** gets a bare `_index.md`,
|
|
493
|
+
* or its own address publishes nothing. Hugo generates a section page
|
|
494
|
+
* automatically only for a *top-level* content directory; below that, a
|
|
495
|
+
* directory without an `_index.md` is not a section, so its URL 404s while
|
|
496
|
+
* its children publish normally. Mounting a tree one level down demotes every
|
|
497
|
+
* section it holds, and the ones with no landing of their own quietly stop
|
|
498
|
+
* existing while every page inside them keeps working.
|
|
499
|
+
*
|
|
500
|
+
* Scoped to one level on purpose. A directory further down was not a section
|
|
501
|
+
* before the move either, and giving it one here would silently re-scope the
|
|
502
|
+
* prev/next navigation of every page inside it.
|
|
503
|
+
*
|
|
504
|
+
* @param {string} outRoot - The mount directory.
|
|
505
|
+
* @param {object} options - `{ sections, landing, sectionTitle }`.
|
|
506
|
+
* @returns {number} How many landings were written.
|
|
507
|
+
*/
|
|
508
|
+
export function writeSectionLandings(
|
|
509
|
+
outRoot,
|
|
510
|
+
{ sections = {}, landing, sectionTitle },
|
|
511
|
+
) {
|
|
512
|
+
let written = 0;
|
|
513
|
+
|
|
514
|
+
// The mount's own landing carries a `type` of its own. Hugo's template
|
|
515
|
+
// lookup walks up a page's path, so a landing template at the mount would
|
|
516
|
+
// also serve every section below it that has no template of its own —
|
|
517
|
+
// each would render the mount's front page. Typing the landing moves its
|
|
518
|
+
// template out of the path where it could be inherited.
|
|
519
|
+
if (landing) {
|
|
520
|
+
fs.mkdirSync(outRoot, { recursive: true });
|
|
521
|
+
fs.writeFileSync(
|
|
522
|
+
path.join(outRoot, "_index.md"),
|
|
523
|
+
matter.stringify("", landing),
|
|
524
|
+
);
|
|
525
|
+
written += 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
for (const [sec, meta] of Object.entries(sections)) {
|
|
529
|
+
const dir = path.join(outRoot, sec);
|
|
530
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
531
|
+
// A section may have no hero: the images are CDN assets and not every
|
|
532
|
+
// section has one. An explicit `banner: undefined` is not a value YAML
|
|
533
|
+
// can carry, so the key is left off entirely.
|
|
534
|
+
fs.writeFileSync(
|
|
535
|
+
path.join(dir, "_index.md"),
|
|
536
|
+
matter.stringify("", {
|
|
537
|
+
title: meta.title,
|
|
538
|
+
...(meta.banner ? { banner: meta.banner } : {}),
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
written += 1;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!sectionTitle) return written;
|
|
545
|
+
for (const entry of fs.readdirSync(outRoot, { withFileTypes: true })) {
|
|
546
|
+
if (!entry.isDirectory()) continue;
|
|
547
|
+
const index = path.join(outRoot, entry.name, "_index.md");
|
|
548
|
+
if (fs.existsSync(index)) continue;
|
|
549
|
+
fs.writeFileSync(
|
|
550
|
+
index,
|
|
551
|
+
matter.stringify("", { title: sectionTitle(entry.name) }),
|
|
552
|
+
);
|
|
553
|
+
written += 1;
|
|
554
|
+
}
|
|
555
|
+
return written;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A section landing's title, from its directory name — `macro` → `Macros`.
|
|
560
|
+
*
|
|
561
|
+
* Hugo derives exactly this for a section page it generates itself, but not for
|
|
562
|
+
* one backed by an `_index.md`: an explicit file with no `title` renders a blank
|
|
563
|
+
* heading. So a backfilled landing states its own, in plain English
|
|
564
|
+
* pluralisation rather than Hugo's inflector, which spells that section
|
|
565
|
+
* "Macroes".
|
|
566
|
+
*
|
|
567
|
+
* @param {string} name - The directory name.
|
|
568
|
+
* @returns {string} The display title.
|
|
569
|
+
*/
|
|
570
|
+
export function pluralTitle(name) {
|
|
571
|
+
const plural =
|
|
572
|
+
/(?:s|x|z|ch|sh)$/.test(name) ? `${name}es`
|
|
573
|
+
: /[^aeiou]y$/.test(name) ? `${name.slice(0, -1)}ies`
|
|
574
|
+
: `${name}s`;
|
|
575
|
+
return plural
|
|
576
|
+
.split("-")
|
|
577
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
578
|
+
.join(" ");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The named pass bundles a configuration may select.
|
|
583
|
+
*
|
|
584
|
+
* `site.pass` is the one part of the site contract that is *code* — a
|
|
585
|
+
* repository's own body rewrites — so a configuration names a bundle instead of
|
|
586
|
+
* supplying one, exactly as `itemBuilders` names an item registry. Required
|
|
587
|
+
* lazily, so importing this module does not drag a package-specific half of the
|
|
588
|
+
* toolchain in behind it.
|
|
589
|
+
*
|
|
590
|
+
* @type {Readonly<Record<string, () => Function>>}
|
|
591
|
+
*/
|
|
592
|
+
const SITE_PASSES = Object.freeze({
|
|
593
|
+
sohlKb: () => require("../sohl/kb-passes.mjs").sohlKbPass,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Resolves `site.pass` to its bundle.
|
|
598
|
+
*
|
|
599
|
+
* @param {string|undefined} name - The configured name.
|
|
600
|
+
* @param {object} options - The configured options, plus `repoRoot`.
|
|
601
|
+
* @returns {{beforeLinks?: Function, afterLinks?: Function}} The bundle.
|
|
602
|
+
*/
|
|
603
|
+
export function resolveSitePass(name, options) {
|
|
604
|
+
if (!name) return {};
|
|
605
|
+
const factory = SITE_PASSES[name];
|
|
606
|
+
if (!factory) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`unknown site pass ${JSON.stringify(name)} — expected one of ` +
|
|
609
|
+
`${Object.keys(SITE_PASSES).join(", ")}`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
return factory()(options);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The output root, having established that it is safe to delete.
|
|
617
|
+
*
|
|
618
|
+
* The whole tree is a build artifact and is wiped on every run, so this
|
|
619
|
+
* resolution is the difference between clearing a build directory and clearing
|
|
620
|
+
* the repository. An unset `site.out` resolves to `rootDir` itself, and the
|
|
621
|
+
* wipe then deletes the working tree — which is not a hypothetical: it happened
|
|
622
|
+
* while this module was being written, on a configuration that simply had no
|
|
623
|
+
* `site` section yet.
|
|
624
|
+
*
|
|
625
|
+
* So the path is refused unless it is **strictly inside** the repository root.
|
|
626
|
+
* Both failing shapes are ordinary rather than exotic — an absent setting, and a
|
|
627
|
+
* `..` that climbs out — and neither should be recoverable by being careful.
|
|
628
|
+
*
|
|
629
|
+
* @param {string} rootDir - The repository root.
|
|
630
|
+
* @param {string} out - The configured `site.out`.
|
|
631
|
+
* @returns {string} The absolute output root.
|
|
632
|
+
* @throws {Error} When it is unset, or is not below `rootDir`.
|
|
633
|
+
*/
|
|
634
|
+
export function resolveOutputRoot(rootDir, out) {
|
|
635
|
+
if (!out) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
"site.out is not set, so there is nowhere to write the site. " +
|
|
638
|
+
"Refusing to continue: the output directory is wiped on every " +
|
|
639
|
+
"run, and an unset one resolves to the repository root.",
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
const root = path.resolve(rootDir);
|
|
643
|
+
const resolved = path.resolve(root, out);
|
|
644
|
+
const inside = resolved !== root && resolved.startsWith(root + path.sep);
|
|
645
|
+
if (!inside) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`site.out (${JSON.stringify(out)}) resolves to ${resolved}, which ` +
|
|
648
|
+
`is not inside ${root}. Refusing to continue: that directory ` +
|
|
649
|
+
`is wiped on every run.`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
return resolved;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Builds a Hugo content tree from a content tree, and reports what it found.
|
|
657
|
+
*
|
|
658
|
+
* Returns rather than exits, in every case. A caller — the command, or a test —
|
|
659
|
+
* decides what a finding means; the gates below are grouped so it can report
|
|
660
|
+
* the first that fired and stop, which is what keeps a wall of dead links from
|
|
661
|
+
* burying the one manifest that caused them.
|
|
662
|
+
*
|
|
663
|
+
* @param {object} [options] - Options.
|
|
664
|
+
* @param {object} [options.config] - A resolved configuration; loaded when
|
|
665
|
+
* omitted.
|
|
666
|
+
* @param {string} [options.outRoot] - Override the configured output mount.
|
|
667
|
+
* @returns {{gates: object, stats: object|null, tableErrors: object[],
|
|
668
|
+
* wikiErrors: object[], manifests: object|null}}
|
|
669
|
+
*/
|
|
670
|
+
export function buildSite({ config, outRoot } = {}) {
|
|
671
|
+
const resolved = config ?? loadPackConfig();
|
|
672
|
+
const site = resolved.site;
|
|
673
|
+
const scheme = resolved.publish.address;
|
|
674
|
+
|
|
675
|
+
// Where the package is served, and where its content mounts inside it. The
|
|
676
|
+
// two are separate facts: `base` is the package's own address on the site
|
|
677
|
+
// that publishes it, and `prefix` is the content tree's mount within the
|
|
678
|
+
// package — the same `prefix` the link manifest records against, so a page
|
|
679
|
+
// and its manifest entry cannot disagree.
|
|
680
|
+
const base = site.base || `/${resolved.contentPackage}/`;
|
|
681
|
+
const mount = `${base}${scheme.prefix}`;
|
|
682
|
+
|
|
683
|
+
// The Hugo content tree mirrors that mount: a page written to
|
|
684
|
+
// `<out>/<prefix>/<section>/` publishes at `<base><prefix><section>/`.
|
|
685
|
+
// Resolved against the repository root for the same reason every configured
|
|
686
|
+
// path is — so the build reads and writes the same places whatever
|
|
687
|
+
// directory it was launched from (#1508).
|
|
688
|
+
const outBase = resolveOutputRoot(resolved.rootDir, site.out);
|
|
689
|
+
const out =
|
|
690
|
+
outRoot ?
|
|
691
|
+
path.resolve(outRoot)
|
|
692
|
+
: path.join(outBase, scheme.prefix.replace(/\/$/, ""));
|
|
693
|
+
|
|
694
|
+
const packages = new Set(
|
|
695
|
+
site.packages.length ? site.packages : [resolved.contentPackage],
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
const ctx = {
|
|
699
|
+
packages,
|
|
700
|
+
skipDirectories: resolved.skipDirectories,
|
|
701
|
+
mount,
|
|
702
|
+
scheme,
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// The whole tree is a build artifact, regenerated every run: a page whose
|
|
706
|
+
// note was deleted or renamed would otherwise linger and keep publishing.
|
|
707
|
+
fs.rmSync(outBase, { recursive: true, force: true });
|
|
708
|
+
|
|
709
|
+
const content = collectContentPages(resolved.paths.content, ctx);
|
|
710
|
+
const pages = [...content.pages];
|
|
711
|
+
const fmLinkFindings = [...content.fmLinkFindings];
|
|
712
|
+
|
|
713
|
+
const trees = site.trees.map((t) => ({
|
|
714
|
+
...t,
|
|
715
|
+
from: path.resolve(resolved.rootDir, t.from),
|
|
716
|
+
route: `${mount}${t.section}/`,
|
|
717
|
+
}));
|
|
718
|
+
for (const tree of trees) {
|
|
719
|
+
const got = collectTreePages(tree, ctx);
|
|
720
|
+
pages.push(...got.pages);
|
|
721
|
+
fmLinkFindings.push(...got.fmLinkFindings);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const gates = siteGates(
|
|
725
|
+
pages,
|
|
726
|
+
{ ...content, fmLinkFindings },
|
|
727
|
+
{ manifestDir: resolved.paths.manifests },
|
|
728
|
+
);
|
|
729
|
+
if (gatesFailed(gates)) {
|
|
730
|
+
return {
|
|
731
|
+
gates,
|
|
732
|
+
stats: null,
|
|
733
|
+
tableErrors: [],
|
|
734
|
+
wikiErrors: [],
|
|
735
|
+
manifests: gates.manifests,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const pass = resolveSitePass(site.pass, {
|
|
740
|
+
...site.passOptions,
|
|
741
|
+
repoRoot: resolved.rootDir,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const rendered = renderPages(pages, {
|
|
745
|
+
outRoot: out,
|
|
746
|
+
index: gates.index,
|
|
747
|
+
foreign: gates.foreign,
|
|
748
|
+
manifests: gates.manifests,
|
|
749
|
+
universe: tableUniverse(pages),
|
|
750
|
+
pass,
|
|
751
|
+
readmeSections: site.readmeSections,
|
|
752
|
+
// What counts as a being is the toolchain's to say, not a consumer's.
|
|
753
|
+
// Asking in a consumer's script is how one came to still be checking
|
|
754
|
+
// `character` and `creature` months after they were retired, and to
|
|
755
|
+
// publish 95 pages with empty sidebars for months without noticing.
|
|
756
|
+
decorate: (data, page) => {
|
|
757
|
+
if (isBeing(page.fm)) {
|
|
758
|
+
data.sohl = deriveBeingInfo(page.fm.sohl, gates.index.refIndex);
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const landings = writeSectionLandings(out, {
|
|
764
|
+
sections: site.sections,
|
|
765
|
+
landing: site.landing,
|
|
766
|
+
sectionTitle: site.backfillSections ? pluralTitle : null,
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
return {
|
|
770
|
+
gates,
|
|
771
|
+
manifests: gates.manifests,
|
|
772
|
+
tableErrors: rendered.tableErrors,
|
|
773
|
+
wikiErrors: rendered.wikiErrors,
|
|
774
|
+
stats: { ...rendered.byKind, landings, out },
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export { formatUnaddressableFinding };
|