@heroiclands/package-build 22.0.3 → 22.1.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.md +66 -0
- package/CONTENT.md +56 -18
- package/README.md +1 -1
- package/bin/content-build.mjs +67 -23
- package/bin/package-build.mjs +3 -2
- package/config.mjs +52 -1
- package/content-config.mjs +264 -8
- package/docs/api.md +33 -30
- package/docs/commands.md +68 -32
- package/docs/configuration.md +276 -62
- package/docs/getting-started.md +15 -4
- package/docs/project-setup.md +45 -20
- package/engine/content-links.mjs +19 -9
- package/engine/helpers.mjs +2 -1
- package/engine/metadata-index.mjs +32 -0
- package/engine/note-vocabulary.mjs +20 -0
- package/engine/pack-config.mjs +21 -2
- package/engine/site-build.mjs +29 -55
- package/engine/site-config.mjs +503 -0
- package/engine/site-index.mjs +10 -1
- package/engine/web-wikilinks.mjs +16 -6
- package/engine/wikilink-syntax.mjs +10 -0
- package/engine/wikilinks.mjs +34 -7
- package/manifest.mjs +12 -7
- package/package.json +2 -1
- package/stage.mjs +4 -3
- package/types/config.d.mts +24 -0
- package/types/content-config.d.mts +64 -0
- package/types/engine/content-links.d.mts +3 -2
- package/types/engine/metadata-index.d.mts +22 -0
- package/types/engine/note-vocabulary.d.mts +12 -0
- package/types/engine/site-build.d.mts +8 -26
- package/types/engine/site-config.d.mts +236 -0
- package/types/engine/site-index.d.mts +6 -1
- package/types/engine/web-wikilinks.d.mts +9 -5
- package/types/engine/wikilink-syntax.d.mts +3 -0
- package/types/engine/wikilinks.d.mts +16 -5
- package/types/manifest.d.mts +4 -4
- package/types/stage.d.mts +4 -3
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Hugo configuration a site build generates.
|
|
16
|
+
*
|
|
17
|
+
* `content-build site` writes the whole Hugo source tree under `build/hugo/`
|
|
18
|
+
* — `hugo.toml`, the content mount, Hugo's own cache — as a sibling of the
|
|
19
|
+
* deployment root `build/site/`, so nothing Hugo reads lands in what is
|
|
20
|
+
* published. The consumer's script runs Hugo over it; the toolchain never
|
|
21
|
+
* does.
|
|
22
|
+
*
|
|
23
|
+
* Every value in the generated file has one source. The package's identity
|
|
24
|
+
* (`baseURL`, `title`, `params.description`, `params.author`) is read from
|
|
25
|
+
* `package.json` and `package-build.config.yaml`, where it is already stated.
|
|
26
|
+
* The organisation's constants — the brand links, the locale — are stated once
|
|
27
|
+
* here, because they are the same on every site and a copy per repository is
|
|
28
|
+
* a copy that drifts. The navigation is neither: it is the list of packages
|
|
29
|
+
* the organisation publishes, which lives in one place, heroiclands-site's
|
|
30
|
+
* roster, and reaches every site as the `nav.json` that site publishes.
|
|
31
|
+
* package-build carries no package list; `deps fetch` caches the navigation
|
|
32
|
+
* and the site build writes `[menu.main]` from the cache.
|
|
33
|
+
*
|
|
34
|
+
* What a repository still says for itself is the residue that is genuinely
|
|
35
|
+
* its own — the wording of its "page not found" page, whether its listings
|
|
36
|
+
* show shortcodes — and, through `site.hugo`, the one key nobody anticipated.
|
|
37
|
+
* {@link module:content-config.DERIVED_HUGO_KEYS} refuses everything the
|
|
38
|
+
* generator writes from being authored there too.
|
|
39
|
+
*
|
|
40
|
+
* @module
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { stringify as stringifyToml } from "smol-toml";
|
|
46
|
+
|
|
47
|
+
import { checkHomepage } from "../config.mjs";
|
|
48
|
+
import { slugify } from "./content-slug.mjs";
|
|
49
|
+
|
|
50
|
+
/** The Hugo source directory, relative to the repository root. */
|
|
51
|
+
export const HUGO_SOURCE = "build/hugo";
|
|
52
|
+
|
|
53
|
+
/** The content mount `content-build site` writes, relative to the repository root. */
|
|
54
|
+
export const HUGO_CONTENT = `${HUGO_SOURCE}/content`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The directory that is deployed, relative to the repository root.
|
|
58
|
+
*
|
|
59
|
+
* Hugo renders into `<DEPLOY_ROOT>/<contentPackage>/`; `package-build
|
|
60
|
+
* site-root` writes `_headers` and `_redirects` beside it.
|
|
61
|
+
*/
|
|
62
|
+
export const DEPLOY_ROOT = "build/site";
|
|
63
|
+
|
|
64
|
+
/** The npm package the shared theme arrives as. */
|
|
65
|
+
export const THEME_PACKAGE = "@heroiclands/hugo-theme";
|
|
66
|
+
|
|
67
|
+
/** The theme's name under `themesDir`, which is the package's unscoped name. */
|
|
68
|
+
export const THEME = "hugo-theme";
|
|
69
|
+
|
|
70
|
+
/** The locale every site renders in. */
|
|
71
|
+
export const LOCALE = "en-us";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The brand chrome's own links, the same on every site.
|
|
75
|
+
*
|
|
76
|
+
* `logo` is resolved through the theme's `cdn-url.html`, so it is a path on
|
|
77
|
+
* the asset host rather than an address.
|
|
78
|
+
*/
|
|
79
|
+
export const BRAND = Object.freeze({
|
|
80
|
+
logo: "images/brand/sohl-icon-white.webp",
|
|
81
|
+
licenseURL: "https://www.heroiclands.org/license/",
|
|
82
|
+
discordURL: "https://discord.gg/EwMfkNd3az",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The kinds a site with no tagged notes renders.
|
|
87
|
+
*
|
|
88
|
+
* A section exists only where `site.sections` declares one, so a tree holding
|
|
89
|
+
* only the homepage emits nothing beyond it; a taxonomy nobody's notes fill
|
|
90
|
+
* and a feed would be empty shells. A site whose notes carry `tags:` emits
|
|
91
|
+
* `taxonomy` and `term` after all — see {@link hugoConfig} — but `RSS` is
|
|
92
|
+
* disabled either way: nothing here publishes a feed.
|
|
93
|
+
*/
|
|
94
|
+
export const DISABLE_KINDS = Object.freeze(["taxonomy", "term", "RSS"]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The kinds a site with at least one tagged note renders — everything but
|
|
98
|
+
* `RSS`.
|
|
99
|
+
*/
|
|
100
|
+
const DISABLE_KINDS_TAGGED = Object.freeze(["RSS"]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The single taxonomy a tagged site declares.
|
|
104
|
+
*
|
|
105
|
+
* Only `tag` — Hugo's default pair also declares `category`, which nothing
|
|
106
|
+
* here authors and which would publish an empty `/categories/`.
|
|
107
|
+
*/
|
|
108
|
+
const TAXONOMIES = Object.freeze({ tag: "tags" });
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The taxonomy output formats a tagged site declares — `HTML` only, so no
|
|
112
|
+
* feed is produced for `/tags/` or a single tag.
|
|
113
|
+
*/
|
|
114
|
+
const TAXONOMY_OUTPUTS = Object.freeze({
|
|
115
|
+
taxonomy: Object.freeze(["HTML"]),
|
|
116
|
+
term: Object.freeze(["HTML"]),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The markup settings the toolchain's own output requires.
|
|
121
|
+
*
|
|
122
|
+
* Pages are written with raw HTML in them — a `<figure>` for every image, a
|
|
123
|
+
* `<span>` marking an unresolved link — and Goldmark drops raw HTML unless
|
|
124
|
+
* told otherwise. A theme cannot supply this: Hugo does not merge a theme's
|
|
125
|
+
* `markup` block.
|
|
126
|
+
*/
|
|
127
|
+
export const MARKUP = Object.freeze({
|
|
128
|
+
goldmark: Object.freeze({ renderer: Object.freeze({ unsafe: true }) }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** Where the navigation is published. */
|
|
132
|
+
export const NAVIGATION_URL = "https://www.heroiclands.org/nav.json";
|
|
133
|
+
|
|
134
|
+
/** The cached navigation's file name. */
|
|
135
|
+
export const NAVIGATION_FILE = "nav.json";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Written once a fetch completes, so a half-finished cache is never used —
|
|
139
|
+
* the convention every cache under `build/cache` follows.
|
|
140
|
+
*/
|
|
141
|
+
const STAMP = ".complete";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A navigation entry, as `nav.json` states one.
|
|
145
|
+
*
|
|
146
|
+
* @typedef {object} NavigationEntry
|
|
147
|
+
* @property {string} name - The entry's label.
|
|
148
|
+
* @property {string} url - Where it links, absolute.
|
|
149
|
+
* @property {NavigationEntry[]} [children] - A dropdown's entries.
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A Hugo menu entry, as `[[menu.main]]` states one.
|
|
154
|
+
*
|
|
155
|
+
* @typedef {object} MenuEntry
|
|
156
|
+
* @property {string} name
|
|
157
|
+
* @property {string} url
|
|
158
|
+
* @property {number} weight
|
|
159
|
+
* @property {string} [identifier] - Set on an entry that has children.
|
|
160
|
+
* @property {string} [parent] - Set on a child, naming its parent's identifier.
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {unknown} value - Anything.
|
|
165
|
+
* @returns {value is Record<string, unknown>} Whether it is a plain mapping.
|
|
166
|
+
*/
|
|
167
|
+
function isPlainObject(value) {
|
|
168
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {unknown} value - Anything.
|
|
173
|
+
* @param {string} where - Dotted path, for the error.
|
|
174
|
+
* @returns {string} The value.
|
|
175
|
+
*/
|
|
176
|
+
function requireNonEmptyString(value, where) {
|
|
177
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
178
|
+
throw new TypeError(`${where} must be a non-empty string`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check a navigation's shape, and return it.
|
|
185
|
+
*
|
|
186
|
+
* `[{name, url, children?: [{name, url}]}]`, every `url` absolute. Checked
|
|
187
|
+
* on fetch and again on read: a file that is not a navigation would otherwise
|
|
188
|
+
* reach the generated menu as `undefined` labels and links.
|
|
189
|
+
*
|
|
190
|
+
* @param {unknown} value - The parsed document.
|
|
191
|
+
* @param {string} where - What is being checked, for the error.
|
|
192
|
+
* @returns {NavigationEntry[]} The navigation.
|
|
193
|
+
* @throws {TypeError} When the shape is not a navigation.
|
|
194
|
+
*/
|
|
195
|
+
export function checkNavigation(value, where = "the navigation") {
|
|
196
|
+
if (!Array.isArray(value)) {
|
|
197
|
+
throw new TypeError(`${where} must be a list of \`{name, url, children?}\` entries`);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* @param {unknown} entry - One entry.
|
|
201
|
+
* @param {string} at - Its dotted path.
|
|
202
|
+
* @param {boolean} nested - Whether it is a child, which may not nest.
|
|
203
|
+
* @returns {NavigationEntry} The checked entry.
|
|
204
|
+
*/
|
|
205
|
+
const check = (entry, at, nested) => {
|
|
206
|
+
if (!isPlainObject(entry)) throw new TypeError(`${at} must be a mapping`);
|
|
207
|
+
const out = {
|
|
208
|
+
name: requireNonEmptyString(entry.name, `${at}.name`),
|
|
209
|
+
url: requireNonEmptyString(entry.url, `${at}.url`),
|
|
210
|
+
};
|
|
211
|
+
if (entry.children === undefined) return out;
|
|
212
|
+
if (nested) throw new TypeError(`${at}.children: a dropdown's entry may not nest`);
|
|
213
|
+
if (!Array.isArray(entry.children)) throw new TypeError(`${at}.children must be a list`);
|
|
214
|
+
return {
|
|
215
|
+
...out,
|
|
216
|
+
children: entry.children.map((child, i) => check(child, `${at}.children[${i}]`, true)),
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
return value.map((entry, i) => check(entry, `${where}[${i}]`, false));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The `[[menu.main]]` entries a navigation renders as.
|
|
224
|
+
*
|
|
225
|
+
* Entry for entry, in order; a dropdown is an entry with an `identifier` and
|
|
226
|
+
* its children are entries naming it as `parent`, which is how the theme's
|
|
227
|
+
* header partial draws one. Weights count from one within each level, so the
|
|
228
|
+
* order is the navigation's and not Hugo's alphabetical fallback.
|
|
229
|
+
*
|
|
230
|
+
* @param {readonly NavigationEntry[]} navigation - The navigation.
|
|
231
|
+
* @returns {MenuEntry[]} The menu entries.
|
|
232
|
+
*/
|
|
233
|
+
export function menuEntries(navigation) {
|
|
234
|
+
/** @type {MenuEntry[]} */
|
|
235
|
+
const out = [];
|
|
236
|
+
navigation.forEach((entry, index) => {
|
|
237
|
+
const children = entry.children ?? [];
|
|
238
|
+
if (!children.length) {
|
|
239
|
+
out.push({ name: entry.name, url: entry.url, weight: index + 1 });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const identifier = slugify(entry.name);
|
|
243
|
+
out.push({ name: entry.name, url: entry.url, weight: index + 1, identifier });
|
|
244
|
+
children.forEach((child, i) => {
|
|
245
|
+
out.push({ name: child.name, url: child.url, weight: i + 1, parent: identifier });
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Where the fetched navigation sits.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} config - The resolved build configuration.
|
|
255
|
+
* @returns {string} The cache directory.
|
|
256
|
+
*/
|
|
257
|
+
export function navigationCacheDir(config) {
|
|
258
|
+
return config.paths.navigationCache;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The cached navigation.
|
|
263
|
+
*
|
|
264
|
+
* **Reads the cache only.** A cold cache is an error naming the command that
|
|
265
|
+
* fills it, rather than a download nobody asked for: a site build that reaches
|
|
266
|
+
* the network is not reproducible and fails strangely offline. That is the
|
|
267
|
+
* content index's rule, and it holds here for the same reason. A half-finished
|
|
268
|
+
* fetch counts as cold.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} config - The resolved build configuration.
|
|
271
|
+
* @returns {NavigationEntry[]} The navigation.
|
|
272
|
+
* @throws {Error} When it has not been fetched, or is not a navigation.
|
|
273
|
+
*/
|
|
274
|
+
export function readCachedNavigation(config) {
|
|
275
|
+
const dir = navigationCacheDir(config);
|
|
276
|
+
const file = path.join(dir, NAVIGATION_FILE);
|
|
277
|
+
if (!fs.existsSync(path.join(dir, STAMP)) || !fs.existsSync(file)) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"the site navigation has not been fetched. Run `content-build deps fetch` first.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return checkNavigation(JSON.parse(fs.readFileSync(file, "utf8")), file);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fetch the navigation into the cache, and stamp it complete.
|
|
287
|
+
*
|
|
288
|
+
* Rebuilt from empty on every call rather than kept when present: the
|
|
289
|
+
* navigation carries no version to key a cache on, and a package added to the
|
|
290
|
+
* roster reaches a site on its next `deps fetch`.
|
|
291
|
+
*
|
|
292
|
+
* @param {object} config - The resolved build configuration.
|
|
293
|
+
* @param {object} [options] - Options.
|
|
294
|
+
* @param {string} [options.url] - Where to fetch from. Defaults to
|
|
295
|
+
* {@link NAVIGATION_URL}.
|
|
296
|
+
* @param {typeof globalThis.fetch} [options.fetch] - The fetch to use.
|
|
297
|
+
* @returns {Promise<string>} The cached file.
|
|
298
|
+
* @throws {Error} When the download fails, or the document is not a navigation.
|
|
299
|
+
*/
|
|
300
|
+
export async function fetchNavigation(
|
|
301
|
+
config,
|
|
302
|
+
{ url = NAVIGATION_URL, fetch = globalThis.fetch } = {},
|
|
303
|
+
) {
|
|
304
|
+
const dir = navigationCacheDir(config);
|
|
305
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
306
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
307
|
+
|
|
308
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`could not download the site navigation at ${url}: HTTP ${res.status} ${res.statusText}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const navigation = checkNavigation(await res.json(), url);
|
|
315
|
+
const file = path.join(dir, NAVIGATION_FILE);
|
|
316
|
+
fs.writeFileSync(file, `${JSON.stringify(navigation, null, 4)}\n`);
|
|
317
|
+
fs.writeFileSync(path.join(dir, STAMP), "");
|
|
318
|
+
return file;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The `themesDir` for a repository, as the path from `build/hugo/` to the
|
|
323
|
+
* directory holding the installed theme.
|
|
324
|
+
*
|
|
325
|
+
* Resolved the way Node resolves a package — `node_modules/` in the
|
|
326
|
+
* repository root, then in each parent — and written as a path rather than
|
|
327
|
+
* assumed, so a worktree that resolves its parent's install says so in the
|
|
328
|
+
* generated file.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} rootDir - The repository root.
|
|
331
|
+
* @returns {string} The relative path, POSIX-separated.
|
|
332
|
+
* @throws {Error} When the theme is installed nowhere above the root.
|
|
333
|
+
*/
|
|
334
|
+
export function resolveThemesDir(rootDir) {
|
|
335
|
+
let dir = path.resolve(rootDir);
|
|
336
|
+
for (;;) {
|
|
337
|
+
const scope = path.join(dir, "node_modules", path.dirname(THEME_PACKAGE));
|
|
338
|
+
if (fs.existsSync(path.join(scope, path.basename(THEME_PACKAGE), "theme.toml"))) {
|
|
339
|
+
const rel = path.relative(path.resolve(rootDir, HUGO_SOURCE), scope);
|
|
340
|
+
return rel.split(path.sep).join("/");
|
|
341
|
+
}
|
|
342
|
+
const parent = path.dirname(dir);
|
|
343
|
+
if (parent === dir) break;
|
|
344
|
+
dir = parent;
|
|
345
|
+
}
|
|
346
|
+
throw new Error(
|
|
347
|
+
`${THEME_PACKAGE} is not installed anywhere above ${rootDir} — add it to ` +
|
|
348
|
+
"`devDependencies` and run `npm ci`",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Deep-merge `overrides` over `base`, arrays replaced whole.
|
|
354
|
+
*
|
|
355
|
+
* @param {Record<string, unknown>} base - The generated configuration.
|
|
356
|
+
* @param {Record<string, unknown>} overrides - What `site.hugo` declares.
|
|
357
|
+
* @returns {Record<string, unknown>} A new object.
|
|
358
|
+
*/
|
|
359
|
+
function deepMerge(base, overrides) {
|
|
360
|
+
/** @type {Record<string, unknown>} */
|
|
361
|
+
const out = { ...base };
|
|
362
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
363
|
+
const current = out[key];
|
|
364
|
+
out[key] =
|
|
365
|
+
isPlainObject(current) && isPlainObject(value) ?
|
|
366
|
+
deepMerge(current, value)
|
|
367
|
+
: structuredClone(value);
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The Hugo configuration, as an object.
|
|
374
|
+
*
|
|
375
|
+
* Pure: every input is handed in, so a test can describe the generated shape
|
|
376
|
+
* without a repository on disk. {@link generateHugoConfig} is the same function
|
|
377
|
+
* with the reading put back.
|
|
378
|
+
*
|
|
379
|
+
* `checkHomepage` runs first, before any value is composed — a missing or
|
|
380
|
+
* mismatched `package.json` `homepage` is a finding on every site build.
|
|
381
|
+
*
|
|
382
|
+
* @param {object} options - The sources.
|
|
383
|
+
* @param {object} options.config - The resolved build configuration.
|
|
384
|
+
* @param {string} [options.description] - `package.json`'s `description`.
|
|
385
|
+
* @param {readonly NavigationEntry[]} options.navigation - The navigation.
|
|
386
|
+
* @param {string} options.themesDir - From {@link resolveThemesDir}.
|
|
387
|
+
* @param {boolean} [options.hasTags] - Whether any note the site build walked
|
|
388
|
+
* carries `tags:`, from {@link module:engine/site-build.buildSite}'s
|
|
389
|
+
* `hasTags`. Defaults to `false` — no tagged note, no taxonomy pages.
|
|
390
|
+
* @returns {Record<string, any>} The configuration Hugo reads.
|
|
391
|
+
* @throws {TypeError} When `homepage` fails `checkHomepage`, or the
|
|
392
|
+
* configuration declares no `packageBuild.manifest.title`.
|
|
393
|
+
*/
|
|
394
|
+
export function hugoConfig({ config, description, navigation, themesDir, hasTags = false }) {
|
|
395
|
+
checkHomepage(config.homepage, config.contentPackage);
|
|
396
|
+
|
|
397
|
+
const title = config.packageBuild?.manifest?.title;
|
|
398
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
399
|
+
throw new TypeError(
|
|
400
|
+
"package-build config: `packageBuild.manifest.title` is not declared, " +
|
|
401
|
+
"and the site's `title` reads from it.",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** @type {Record<string, unknown>} */
|
|
406
|
+
const params = {};
|
|
407
|
+
if (typeof description === "string" && description.trim()) params.description = description;
|
|
408
|
+
if (config.author?.name) params.author = config.author.name;
|
|
409
|
+
if (config.site.assets) params.cdnBaseURL = config.site.assets;
|
|
410
|
+
params.brand = { ...BRAND };
|
|
411
|
+
params.list = { ...config.site.list };
|
|
412
|
+
if (config.site.notfound) params.notfound = structuredClone(config.site.notfound);
|
|
413
|
+
|
|
414
|
+
const generated = {
|
|
415
|
+
baseURL: config.homepage,
|
|
416
|
+
title,
|
|
417
|
+
locale: LOCALE,
|
|
418
|
+
publishDir: path.posix.relative(HUGO_SOURCE, `${DEPLOY_ROOT}/${config.contentPackage}`),
|
|
419
|
+
themesDir,
|
|
420
|
+
theme: THEME,
|
|
421
|
+
contentDir: path.posix.relative(HUGO_SOURCE, HUGO_CONTENT),
|
|
422
|
+
disableKinds: hasTags ? [...DISABLE_KINDS_TAGGED] : [...DISABLE_KINDS],
|
|
423
|
+
params,
|
|
424
|
+
markup: structuredClone(MARKUP),
|
|
425
|
+
menu: { main: menuEntries(navigation) },
|
|
426
|
+
};
|
|
427
|
+
if (hasTags) {
|
|
428
|
+
generated.taxonomies = { ...TAXONOMIES };
|
|
429
|
+
generated.outputs = {
|
|
430
|
+
taxonomy: [...TAXONOMY_OUTPUTS.taxonomy],
|
|
431
|
+
term: [...TAXONOMY_OUTPUTS.term],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return deepMerge(generated, config.site.hugo);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The configuration as the TOML Hugo reads.
|
|
439
|
+
*
|
|
440
|
+
* @param {Record<string, unknown>} generated - From {@link hugoConfig}.
|
|
441
|
+
* @returns {string} The file's contents.
|
|
442
|
+
*/
|
|
443
|
+
export function hugoToml(generated) {
|
|
444
|
+
return (
|
|
445
|
+
"# Generated by `content-build site` from package.json and " +
|
|
446
|
+
"package-build.config.yaml.\n# Every value here has a source there; edit " +
|
|
447
|
+
"the source, not this file.\n\n" +
|
|
448
|
+
`${stringifyToml(generated)}\n`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* `package.json`'s `description`, or `undefined` when it declares none.
|
|
454
|
+
*
|
|
455
|
+
* @param {string} rootDir - The repository root.
|
|
456
|
+
* @returns {string|undefined} The description.
|
|
457
|
+
*/
|
|
458
|
+
function packageDescription(rootDir) {
|
|
459
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
460
|
+
return typeof pkg.description === "string" ? pkg.description : undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The Hugo configuration, every source read from the repository.
|
|
465
|
+
*
|
|
466
|
+
* Reads `package.json`, the cached navigation and the installed theme's
|
|
467
|
+
* location, and composes them with {@link hugoConfig}. Nothing is written, so
|
|
468
|
+
* a caller can run this before touching the output tree and fail with it
|
|
469
|
+
* intact.
|
|
470
|
+
*
|
|
471
|
+
* @param {object} config - The resolved build configuration.
|
|
472
|
+
* @param {object} [options] - Options.
|
|
473
|
+
* @param {boolean} [options.hasTags] - Whether any note the site build walked
|
|
474
|
+
* carries `tags:`. Defaults to `false`, so a caller generating the
|
|
475
|
+
* configuration before the walk (to fail fast on a missing source) gets the
|
|
476
|
+
* untagged shape; pass the site build's own `hasTags` once it is known.
|
|
477
|
+
* @returns {Record<string, any>} The configuration Hugo reads.
|
|
478
|
+
* @throws {Error} When any source is missing or wrong.
|
|
479
|
+
*/
|
|
480
|
+
export function generateHugoConfig(config, { hasTags = false } = {}) {
|
|
481
|
+
return hugoConfig({
|
|
482
|
+
config,
|
|
483
|
+
description: packageDescription(config.rootDir),
|
|
484
|
+
navigation: readCachedNavigation(config),
|
|
485
|
+
themesDir: resolveThemesDir(config.rootDir),
|
|
486
|
+
hasTags,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Write `build/hugo/hugo.toml`.
|
|
492
|
+
*
|
|
493
|
+
* @param {object} config - The resolved build configuration.
|
|
494
|
+
* @param {Record<string, unknown>} [generated] - The configuration to write,
|
|
495
|
+
* when the caller already generated it. Generated here otherwise.
|
|
496
|
+
* @returns {{file: string}} The file written.
|
|
497
|
+
*/
|
|
498
|
+
export function writeHugoConfig(config, generated = generateHugoConfig(config)) {
|
|
499
|
+
const file = path.join(config.rootDir, HUGO_SOURCE, "hugo.toml");
|
|
500
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
501
|
+
fs.writeFileSync(file, hugoToml(generated));
|
|
502
|
+
return { file };
|
|
503
|
+
}
|
package/engine/site-index.mjs
CHANGED
|
@@ -166,9 +166,16 @@ function mergeForeign(index, foreignIndex) {
|
|
|
166
166
|
* @param {Map<string, {package: string, type?: string}>} [options.foreignIndex]
|
|
167
167
|
* The merged index from `loadForeignIndexes`. Omit when the build publishes
|
|
168
168
|
* no cross-package links.
|
|
169
|
+
* @param {Set<string>} [options.noIndexPackages] - Packages declared
|
|
170
|
+
* `contentIndex: false` — a Foundry dependency only, with no fetched index.
|
|
171
|
+
* A link naming one fails naming the key, rather than reading as prose or an
|
|
172
|
+
* ordinary dead address.
|
|
169
173
|
* @returns {SiteIndex} The index, and what could not be addressed unambiguously.
|
|
170
174
|
*/
|
|
171
|
-
export function buildSiteIndex(
|
|
175
|
+
export function buildSiteIndex(
|
|
176
|
+
entries,
|
|
177
|
+
{ foreignIndex = new Map(), noIndexPackages = new Set() } = {},
|
|
178
|
+
) {
|
|
172
179
|
const index = new Map();
|
|
173
180
|
const contentTypes = new Set();
|
|
174
181
|
const sections = new Set();
|
|
@@ -298,6 +305,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
298
305
|
contentTypes,
|
|
299
306
|
sections,
|
|
300
307
|
packages,
|
|
308
|
+
noIndexPackages,
|
|
301
309
|
refIndex,
|
|
302
310
|
conflicts,
|
|
303
311
|
};
|
|
@@ -347,6 +355,7 @@ export function wikiContext(
|
|
|
347
355
|
sections: built.sections,
|
|
348
356
|
contentTypes: built.contentTypes,
|
|
349
357
|
packages: built.packages,
|
|
358
|
+
noIndexPackages: built.noIndexPackages,
|
|
350
359
|
// The package a link written on this page defaults to when it names
|
|
351
360
|
// none. Taken from the resolved configuration, the same source
|
|
352
361
|
// the index's own addresses are built from, so a bare link cannot
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -279,9 +279,10 @@ function isPlainMap(value) {
|
|
|
279
279
|
*
|
|
280
280
|
* **Every target that resolves nowhere fails the build**, and is
|
|
281
281
|
* classified into the vocabulary all three resolvers share — `unlabelled`,
|
|
282
|
-
* `not-an-address`, `unknown-type`, `ambiguous`, `unresolved
|
|
283
|
-
* collected in `ctx.errors`, each carrying
|
|
284
|
-
* `occurrence` so a caller can report the line and
|
|
282
|
+
* `not-an-address`, `unknown-type`, `ambiguous`, `unresolved`,
|
|
283
|
+
* `no-content-index`. Failures are collected in `ctx.errors`, each carrying
|
|
284
|
+
* the authored `link` and its `occurrence` so a caller can report the line and
|
|
285
|
+
* column it sits on.
|
|
285
286
|
*
|
|
286
287
|
* There is deliberately no exception letting a hyphen-form address through while
|
|
287
288
|
* any linkable package had no vendored manifest, since a real cross-package
|
|
@@ -312,9 +313,12 @@ function isPlainMap(value) {
|
|
|
312
313
|
*
|
|
313
314
|
* @param {string} body - The markdown body.
|
|
314
315
|
* @param {object} ctx - `{ index, assets, collide, sections, contentTypes,
|
|
315
|
-
* packages, foreign, type, errors, src, file }`.
|
|
316
|
+
* packages, noIndexPackages, foreign, type, errors, src, file }`.
|
|
316
317
|
* `packages` is every package an address may name, without which the leading
|
|
317
|
-
* package segment of a canonical address reads as an unknown type;
|
|
318
|
+
* package segment of a canonical address reads as an unknown type;
|
|
319
|
+
* `noIndexPackages` is every package declared `contentIndex: false`, so a
|
|
320
|
+
* qualified address naming one fails with `no-content-index` rather than
|
|
321
|
+
* `not-an-address`; `foreign`
|
|
318
322
|
* is the cross-package manifest index; `assets` is the address space an embed
|
|
319
323
|
* resolves against. `src` is the page's display
|
|
320
324
|
* path and `file` the source file a diagnostic should name — absent, `src`
|
|
@@ -402,7 +406,12 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
402
406
|
// The canonical separator has to be resolved, not merely
|
|
403
407
|
// recognised. `null` here means the target is not an address at all,
|
|
404
408
|
// which is a defect: there is no other namespace to try.
|
|
405
|
-
const read = readQualifier(
|
|
409
|
+
const read = readQualifier(
|
|
410
|
+
target,
|
|
411
|
+
ctx.contentTypes ?? new Set(),
|
|
412
|
+
ctx.packages,
|
|
413
|
+
ctx.noIndexPackages,
|
|
414
|
+
);
|
|
406
415
|
const rawKey = target.toLowerCase();
|
|
407
416
|
const hit =
|
|
408
417
|
lookupRead(ctx.index, read, ctx.contentPackage) ??
|
|
@@ -478,6 +487,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
478
487
|
// a key: a partial address has no single key to be non-null.
|
|
479
488
|
: (read && !read.reason) || siteAddress ? "unresolved"
|
|
480
489
|
: read?.reason === "unknown-type" ? "unknown-type"
|
|
490
|
+
: read?.reason === "no-content-index" ? "no-content-index"
|
|
481
491
|
// Every link is an address, and this is not one. Distinct from
|
|
482
492
|
// a dead address, because the fix is different: a name has to
|
|
483
493
|
// become an address, not be corrected.
|
|
@@ -184,6 +184,9 @@ export function unlabelledLinkMessage(target) {
|
|
|
184
184
|
* - `unresolved` — parses as an address, and nothing publishes it.
|
|
185
185
|
* - `ambiguous` — more than one package publishes the short address.
|
|
186
186
|
* - `unknown-anchor` — the address resolved, the `#section` it names did not.
|
|
187
|
+
* - `no-content-index` — the address names a package declared
|
|
188
|
+
* `contentIndex: false`, a Foundry dependency only, so no index was fetched
|
|
189
|
+
* for it to resolve against.
|
|
187
190
|
*
|
|
188
191
|
* @type {ReadonlySet<string>}
|
|
189
192
|
*/
|
|
@@ -197,6 +200,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
|
|
|
197
200
|
"unresolved",
|
|
198
201
|
"ambiguous",
|
|
199
202
|
"unknown-anchor",
|
|
203
|
+
"no-content-index",
|
|
200
204
|
]),
|
|
201
205
|
);
|
|
202
206
|
|
|
@@ -312,6 +316,12 @@ export function linkFindingMessage({ reason, target, packages, anchor, type }) {
|
|
|
312
316
|
);
|
|
313
317
|
case "unresolved":
|
|
314
318
|
return unresolvedAddressMessage(target);
|
|
319
|
+
case "no-content-index":
|
|
320
|
+
return (
|
|
321
|
+
`address [[${target}]] names a package declared \`contentIndex: false\` — ` +
|
|
322
|
+
`it is a Foundry dependency only, and no content index was fetched for it, ` +
|
|
323
|
+
`so nothing it publishes can be cited`
|
|
324
|
+
);
|
|
315
325
|
default:
|
|
316
326
|
throw new Error(
|
|
317
327
|
`linkFindingMessage: "${reason}" is not one of ` +
|