@heroiclands/package-build 22.0.3 → 22.1.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 +51 -0
- package/CONTENT.md +34 -18
- package/README.md +1 -1
- package/bin/content-build.mjs +40 -22
- package/bin/package-build.mjs +3 -2
- package/config.mjs +52 -1
- package/content-config.mjs +235 -8
- package/docs/api.md +8 -7
- package/docs/commands.md +68 -32
- package/docs/configuration.md +254 -61
- package/docs/getting-started.md +15 -4
- package/docs/project-setup.md +45 -20
- package/engine/pack-config.mjs +21 -2
- package/engine/site-build.mjs +15 -52
- package/engine/site-config.mjs +462 -0
- 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 +53 -0
- package/types/engine/site-build.d.mts +3 -25
- package/types/engine/site-config.d.mts +223 -0
- package/types/manifest.d.mts +4 -4
- package/types/stage.d.mts +4 -3
package/engine/site-build.mjs
CHANGED
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
isHomepage,
|
|
77
77
|
} from "./homepage.mjs";
|
|
78
78
|
import { publishesContentPages } from "../content-config.mjs";
|
|
79
|
+
import { HUGO_CONTENT } from "./site-config.mjs";
|
|
79
80
|
|
|
80
81
|
const require = createRequire(import.meta.url);
|
|
81
82
|
|
|
@@ -397,8 +398,8 @@ export function collectHomepages(contentBase, ctx) {
|
|
|
397
398
|
* has. Nothing is written at `/<package>/` itself: that becomes a redirect the
|
|
398
399
|
* package's own repository authors, which is a routing fact rather than a page.
|
|
399
400
|
*
|
|
400
|
-
* @param {string} outRoot - The package's site root — the
|
|
401
|
-
* one level above the
|
|
401
|
+
* @param {string} outRoot - The package's site root — the content mount's
|
|
402
|
+
* root, `build/hugo/content`, one level above the mount itself.
|
|
402
403
|
* @param {readonly object[]} pages - From {@link collectHomepages}.
|
|
403
404
|
* @param {object} config - The resolved configuration, for the package name and
|
|
404
405
|
* the default title.
|
|
@@ -1025,46 +1026,6 @@ export function resolveSitePass(name, options) {
|
|
|
1025
1026
|
return factory()(options);
|
|
1026
1027
|
}
|
|
1027
1028
|
|
|
1028
|
-
/**
|
|
1029
|
-
* The output root, having established that it is safe to delete.
|
|
1030
|
-
*
|
|
1031
|
-
* The whole tree is a build artifact and is wiped on every run, so this
|
|
1032
|
-
* resolution is the difference between clearing a build directory and clearing
|
|
1033
|
-
* the repository. An unset `site.out` resolves to `rootDir` itself, and the
|
|
1034
|
-
* wipe then deletes the working tree — which is not a hypothetical: it happened
|
|
1035
|
-
* while this module was being written, on a configuration that simply had no
|
|
1036
|
-
* `site` section yet.
|
|
1037
|
-
*
|
|
1038
|
-
* So the path is refused unless it is **strictly inside** the repository root.
|
|
1039
|
-
* Both failing shapes are ordinary rather than exotic — an absent setting, and a
|
|
1040
|
-
* `..` that climbs out — and neither should be recoverable by being careful.
|
|
1041
|
-
*
|
|
1042
|
-
* @param {string} rootDir - The repository root.
|
|
1043
|
-
* @param {string} out - The configured `site.out`.
|
|
1044
|
-
* @returns {string} The absolute output root.
|
|
1045
|
-
* @throws {Error} When it is unset, or is not below `rootDir`.
|
|
1046
|
-
*/
|
|
1047
|
-
export function resolveOutputRoot(rootDir, out) {
|
|
1048
|
-
if (!out) {
|
|
1049
|
-
throw new Error(
|
|
1050
|
-
"site.out is not set, so there is nowhere to write the site. " +
|
|
1051
|
-
"Refusing to continue: the output directory is wiped on every " +
|
|
1052
|
-
"run, and an unset one resolves to the repository root.",
|
|
1053
|
-
);
|
|
1054
|
-
}
|
|
1055
|
-
const root = path.resolve(rootDir);
|
|
1056
|
-
const resolved = path.resolve(root, out);
|
|
1057
|
-
const inside = resolved !== root && resolved.startsWith(root + path.sep);
|
|
1058
|
-
if (!inside) {
|
|
1059
|
-
throw new Error(
|
|
1060
|
-
`site.out (${JSON.stringify(out)}) resolves to ${resolved}, which ` +
|
|
1061
|
-
`is not inside ${root}. Refusing to continue: that directory ` +
|
|
1062
|
-
`is wiped on every run.`,
|
|
1063
|
-
);
|
|
1064
|
-
}
|
|
1065
|
-
return resolved;
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
1029
|
/**
|
|
1069
1030
|
* Builds a Hugo content tree from a content tree, and reports what it found.
|
|
1070
1031
|
*
|
|
@@ -1076,7 +1037,6 @@ export function resolveOutputRoot(rootDir, out) {
|
|
|
1076
1037
|
* @param {object} [options] - Options.
|
|
1077
1038
|
* @param {object} [options.config] - A resolved configuration; loaded when
|
|
1078
1039
|
* omitted.
|
|
1079
|
-
* @param {string} [options.outRoot] - Override the configured output mount.
|
|
1080
1040
|
* @param {Map<string, object[]>} [options.sqlTables] - Prepared `sql` results,
|
|
1081
1041
|
* keyed by the note's absolute file, from
|
|
1082
1042
|
* {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
|
|
@@ -1085,7 +1045,7 @@ export function resolveOutputRoot(rootDir, out) {
|
|
|
1085
1045
|
* @returns {{gates: object, stats: object|null, tableErrors: object[],
|
|
1086
1046
|
* wikiErrors: object[], imageErrors: object[], manifests: object|null}}
|
|
1087
1047
|
*/
|
|
1088
|
-
export function buildSite({ config,
|
|
1048
|
+
export function buildSite({ config, sqlTables } = {}) {
|
|
1089
1049
|
const resolved = config ?? loadPackConfig();
|
|
1090
1050
|
const site = resolved.site;
|
|
1091
1051
|
const scheme = resolved.publish.address;
|
|
@@ -1104,16 +1064,19 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
|
|
|
1104
1064
|
|
|
1105
1065
|
// The Hugo content tree mirrors that mount: a page written to
|
|
1106
1066
|
// `<out>/<prefix>/<section>/` publishes at `<base><prefix><section>/`.
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1067
|
+
// The root is fixed — `build/hugo/content`, beside the generated
|
|
1068
|
+
// `hugo.toml` — and resolved against the repository root for the same
|
|
1069
|
+
// reason every configured path is, so the build reads and writes the same
|
|
1070
|
+
// places whatever directory it was launched from. It is wiped on every
|
|
1071
|
+
// run, which is safe precisely because it is not configurable: nothing an
|
|
1072
|
+
// author writes can point it at the working tree.
|
|
1073
|
+
const outBase = path.join(resolved.rootDir, HUGO_CONTENT);
|
|
1111
1074
|
const out =
|
|
1112
|
-
|
|
1113
|
-
|
|
1075
|
+
publishesContent ?
|
|
1076
|
+
path.join(outBase, scheme.prefix.replace(/\/$/, ""))
|
|
1114
1077
|
// Homepage-only has no content mount, so the package's root *is*
|
|
1115
|
-
// the output root
|
|
1116
|
-
:
|
|
1078
|
+
// the output root.
|
|
1079
|
+
: outBase;
|
|
1117
1080
|
// The homepage publishes at `/<contentPackage>/<type>-<shortcode>/`, so its
|
|
1118
1081
|
// file goes at the package's own root — one level above the content mount,
|
|
1119
1082
|
// and the same directory in homepage-only mode.
|
|
@@ -0,0 +1,462 @@
|
|
|
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 no site renders.
|
|
87
|
+
*
|
|
88
|
+
* A section exists only where `site.sections` declares one, so a tree holding
|
|
89
|
+
* only the homepage emits nothing beyond it; taxonomies and feeds would be
|
|
90
|
+
* empty shells on every site.
|
|
91
|
+
*/
|
|
92
|
+
export const DISABLE_KINDS = Object.freeze(["taxonomy", "term", "RSS"]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The markup settings the toolchain's own output requires.
|
|
96
|
+
*
|
|
97
|
+
* Pages are written with raw HTML in them — a `<figure>` for every image, a
|
|
98
|
+
* `<span>` marking an unresolved link — and Goldmark drops raw HTML unless
|
|
99
|
+
* told otherwise. A theme cannot supply this: Hugo does not merge a theme's
|
|
100
|
+
* `markup` block.
|
|
101
|
+
*/
|
|
102
|
+
export const MARKUP = Object.freeze({
|
|
103
|
+
goldmark: Object.freeze({ renderer: Object.freeze({ unsafe: true }) }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** Where the navigation is published. */
|
|
107
|
+
export const NAVIGATION_URL = "https://www.heroiclands.org/nav.json";
|
|
108
|
+
|
|
109
|
+
/** The cached navigation's file name. */
|
|
110
|
+
export const NAVIGATION_FILE = "nav.json";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Written once a fetch completes, so a half-finished cache is never used —
|
|
114
|
+
* the convention every cache under `build/cache` follows.
|
|
115
|
+
*/
|
|
116
|
+
const STAMP = ".complete";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A navigation entry, as `nav.json` states one.
|
|
120
|
+
*
|
|
121
|
+
* @typedef {object} NavigationEntry
|
|
122
|
+
* @property {string} name - The entry's label.
|
|
123
|
+
* @property {string} url - Where it links, absolute.
|
|
124
|
+
* @property {NavigationEntry[]} [children] - A dropdown's entries.
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A Hugo menu entry, as `[[menu.main]]` states one.
|
|
129
|
+
*
|
|
130
|
+
* @typedef {object} MenuEntry
|
|
131
|
+
* @property {string} name
|
|
132
|
+
* @property {string} url
|
|
133
|
+
* @property {number} weight
|
|
134
|
+
* @property {string} [identifier] - Set on an entry that has children.
|
|
135
|
+
* @property {string} [parent] - Set on a child, naming its parent's identifier.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {unknown} value - Anything.
|
|
140
|
+
* @returns {value is Record<string, unknown>} Whether it is a plain mapping.
|
|
141
|
+
*/
|
|
142
|
+
function isPlainObject(value) {
|
|
143
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {unknown} value - Anything.
|
|
148
|
+
* @param {string} where - Dotted path, for the error.
|
|
149
|
+
* @returns {string} The value.
|
|
150
|
+
*/
|
|
151
|
+
function requireNonEmptyString(value, where) {
|
|
152
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
153
|
+
throw new TypeError(`${where} must be a non-empty string`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check a navigation's shape, and return it.
|
|
160
|
+
*
|
|
161
|
+
* `[{name, url, children?: [{name, url}]}]`, every `url` absolute. Checked
|
|
162
|
+
* on fetch and again on read: a file that is not a navigation would otherwise
|
|
163
|
+
* reach the generated menu as `undefined` labels and links.
|
|
164
|
+
*
|
|
165
|
+
* @param {unknown} value - The parsed document.
|
|
166
|
+
* @param {string} where - What is being checked, for the error.
|
|
167
|
+
* @returns {NavigationEntry[]} The navigation.
|
|
168
|
+
* @throws {TypeError} When the shape is not a navigation.
|
|
169
|
+
*/
|
|
170
|
+
export function checkNavigation(value, where = "the navigation") {
|
|
171
|
+
if (!Array.isArray(value)) {
|
|
172
|
+
throw new TypeError(`${where} must be a list of \`{name, url, children?}\` entries`);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* @param {unknown} entry - One entry.
|
|
176
|
+
* @param {string} at - Its dotted path.
|
|
177
|
+
* @param {boolean} nested - Whether it is a child, which may not nest.
|
|
178
|
+
* @returns {NavigationEntry} The checked entry.
|
|
179
|
+
*/
|
|
180
|
+
const check = (entry, at, nested) => {
|
|
181
|
+
if (!isPlainObject(entry)) throw new TypeError(`${at} must be a mapping`);
|
|
182
|
+
const out = {
|
|
183
|
+
name: requireNonEmptyString(entry.name, `${at}.name`),
|
|
184
|
+
url: requireNonEmptyString(entry.url, `${at}.url`),
|
|
185
|
+
};
|
|
186
|
+
if (entry.children === undefined) return out;
|
|
187
|
+
if (nested) throw new TypeError(`${at}.children: a dropdown's entry may not nest`);
|
|
188
|
+
if (!Array.isArray(entry.children)) throw new TypeError(`${at}.children must be a list`);
|
|
189
|
+
return {
|
|
190
|
+
...out,
|
|
191
|
+
children: entry.children.map((child, i) => check(child, `${at}.children[${i}]`, true)),
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
return value.map((entry, i) => check(entry, `${where}[${i}]`, false));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The `[[menu.main]]` entries a navigation renders as.
|
|
199
|
+
*
|
|
200
|
+
* Entry for entry, in order; a dropdown is an entry with an `identifier` and
|
|
201
|
+
* its children are entries naming it as `parent`, which is how the theme's
|
|
202
|
+
* header partial draws one. Weights count from one within each level, so the
|
|
203
|
+
* order is the navigation's and not Hugo's alphabetical fallback.
|
|
204
|
+
*
|
|
205
|
+
* @param {readonly NavigationEntry[]} navigation - The navigation.
|
|
206
|
+
* @returns {MenuEntry[]} The menu entries.
|
|
207
|
+
*/
|
|
208
|
+
export function menuEntries(navigation) {
|
|
209
|
+
/** @type {MenuEntry[]} */
|
|
210
|
+
const out = [];
|
|
211
|
+
navigation.forEach((entry, index) => {
|
|
212
|
+
const children = entry.children ?? [];
|
|
213
|
+
if (!children.length) {
|
|
214
|
+
out.push({ name: entry.name, url: entry.url, weight: index + 1 });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const identifier = slugify(entry.name);
|
|
218
|
+
out.push({ name: entry.name, url: entry.url, weight: index + 1, identifier });
|
|
219
|
+
children.forEach((child, i) => {
|
|
220
|
+
out.push({ name: child.name, url: child.url, weight: i + 1, parent: identifier });
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Where the fetched navigation sits.
|
|
228
|
+
*
|
|
229
|
+
* @param {object} config - The resolved build configuration.
|
|
230
|
+
* @returns {string} The cache directory.
|
|
231
|
+
*/
|
|
232
|
+
export function navigationCacheDir(config) {
|
|
233
|
+
return config.paths.navigationCache;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The cached navigation.
|
|
238
|
+
*
|
|
239
|
+
* **Reads the cache only.** A cold cache is an error naming the command that
|
|
240
|
+
* fills it, rather than a download nobody asked for: a site build that reaches
|
|
241
|
+
* the network is not reproducible and fails strangely offline. That is the
|
|
242
|
+
* content index's rule, and it holds here for the same reason. A half-finished
|
|
243
|
+
* fetch counts as cold.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} config - The resolved build configuration.
|
|
246
|
+
* @returns {NavigationEntry[]} The navigation.
|
|
247
|
+
* @throws {Error} When it has not been fetched, or is not a navigation.
|
|
248
|
+
*/
|
|
249
|
+
export function readCachedNavigation(config) {
|
|
250
|
+
const dir = navigationCacheDir(config);
|
|
251
|
+
const file = path.join(dir, NAVIGATION_FILE);
|
|
252
|
+
if (!fs.existsSync(path.join(dir, STAMP)) || !fs.existsSync(file)) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"the site navigation has not been fetched. Run `content-build deps fetch` first.",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return checkNavigation(JSON.parse(fs.readFileSync(file, "utf8")), file);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Fetch the navigation into the cache, and stamp it complete.
|
|
262
|
+
*
|
|
263
|
+
* Rebuilt from empty on every call rather than kept when present: the
|
|
264
|
+
* navigation carries no version to key a cache on, and a package added to the
|
|
265
|
+
* roster reaches a site on its next `deps fetch`.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} config - The resolved build configuration.
|
|
268
|
+
* @param {object} [options] - Options.
|
|
269
|
+
* @param {string} [options.url] - Where to fetch from. Defaults to
|
|
270
|
+
* {@link NAVIGATION_URL}.
|
|
271
|
+
* @param {typeof globalThis.fetch} [options.fetch] - The fetch to use.
|
|
272
|
+
* @returns {Promise<string>} The cached file.
|
|
273
|
+
* @throws {Error} When the download fails, or the document is not a navigation.
|
|
274
|
+
*/
|
|
275
|
+
export async function fetchNavigation(
|
|
276
|
+
config,
|
|
277
|
+
{ url = NAVIGATION_URL, fetch = globalThis.fetch } = {},
|
|
278
|
+
) {
|
|
279
|
+
const dir = navigationCacheDir(config);
|
|
280
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
281
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
282
|
+
|
|
283
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`could not download the site navigation at ${url}: HTTP ${res.status} ${res.statusText}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const navigation = checkNavigation(await res.json(), url);
|
|
290
|
+
const file = path.join(dir, NAVIGATION_FILE);
|
|
291
|
+
fs.writeFileSync(file, `${JSON.stringify(navigation, null, 4)}\n`);
|
|
292
|
+
fs.writeFileSync(path.join(dir, STAMP), "");
|
|
293
|
+
return file;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The `themesDir` for a repository, as the path from `build/hugo/` to the
|
|
298
|
+
* directory holding the installed theme.
|
|
299
|
+
*
|
|
300
|
+
* Resolved the way Node resolves a package — `node_modules/` in the
|
|
301
|
+
* repository root, then in each parent — and written as a path rather than
|
|
302
|
+
* assumed, so a worktree that resolves its parent's install says so in the
|
|
303
|
+
* generated file.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} rootDir - The repository root.
|
|
306
|
+
* @returns {string} The relative path, POSIX-separated.
|
|
307
|
+
* @throws {Error} When the theme is installed nowhere above the root.
|
|
308
|
+
*/
|
|
309
|
+
export function resolveThemesDir(rootDir) {
|
|
310
|
+
let dir = path.resolve(rootDir);
|
|
311
|
+
for (;;) {
|
|
312
|
+
const scope = path.join(dir, "node_modules", path.dirname(THEME_PACKAGE));
|
|
313
|
+
if (fs.existsSync(path.join(scope, path.basename(THEME_PACKAGE), "theme.toml"))) {
|
|
314
|
+
const rel = path.relative(path.resolve(rootDir, HUGO_SOURCE), scope);
|
|
315
|
+
return rel.split(path.sep).join("/");
|
|
316
|
+
}
|
|
317
|
+
const parent = path.dirname(dir);
|
|
318
|
+
if (parent === dir) break;
|
|
319
|
+
dir = parent;
|
|
320
|
+
}
|
|
321
|
+
throw new Error(
|
|
322
|
+
`${THEME_PACKAGE} is not installed anywhere above ${rootDir} — add it to ` +
|
|
323
|
+
"`devDependencies` and run `npm ci`",
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Deep-merge `overrides` over `base`, arrays replaced whole.
|
|
329
|
+
*
|
|
330
|
+
* @param {Record<string, unknown>} base - The generated configuration.
|
|
331
|
+
* @param {Record<string, unknown>} overrides - What `site.hugo` declares.
|
|
332
|
+
* @returns {Record<string, unknown>} A new object.
|
|
333
|
+
*/
|
|
334
|
+
function deepMerge(base, overrides) {
|
|
335
|
+
/** @type {Record<string, unknown>} */
|
|
336
|
+
const out = { ...base };
|
|
337
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
338
|
+
const current = out[key];
|
|
339
|
+
out[key] =
|
|
340
|
+
isPlainObject(current) && isPlainObject(value) ?
|
|
341
|
+
deepMerge(current, value)
|
|
342
|
+
: structuredClone(value);
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The Hugo configuration, as an object.
|
|
349
|
+
*
|
|
350
|
+
* Pure: every input is handed in, so a test can describe the generated shape
|
|
351
|
+
* without a repository on disk. {@link generateHugoConfig} is the same function
|
|
352
|
+
* with the reading put back.
|
|
353
|
+
*
|
|
354
|
+
* `checkHomepage` runs first, before any value is composed — a missing or
|
|
355
|
+
* mismatched `package.json` `homepage` is a finding on every site build.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} options - The sources.
|
|
358
|
+
* @param {object} options.config - The resolved build configuration.
|
|
359
|
+
* @param {string} [options.description] - `package.json`'s `description`.
|
|
360
|
+
* @param {readonly NavigationEntry[]} options.navigation - The navigation.
|
|
361
|
+
* @param {string} options.themesDir - From {@link resolveThemesDir}.
|
|
362
|
+
* @returns {Record<string, any>} The configuration Hugo reads.
|
|
363
|
+
* @throws {TypeError} When `homepage` fails `checkHomepage`, or the
|
|
364
|
+
* configuration declares no `packageBuild.manifest.title`.
|
|
365
|
+
*/
|
|
366
|
+
export function hugoConfig({ config, description, navigation, themesDir }) {
|
|
367
|
+
checkHomepage(config.homepage, config.contentPackage);
|
|
368
|
+
|
|
369
|
+
const title = config.packageBuild?.manifest?.title;
|
|
370
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
371
|
+
throw new TypeError(
|
|
372
|
+
"package-build config: `packageBuild.manifest.title` is not declared, " +
|
|
373
|
+
"and the site's `title` reads from it.",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** @type {Record<string, unknown>} */
|
|
378
|
+
const params = {};
|
|
379
|
+
if (typeof description === "string" && description.trim()) params.description = description;
|
|
380
|
+
if (config.author?.name) params.author = config.author.name;
|
|
381
|
+
if (config.site.assets) params.cdnBaseURL = config.site.assets;
|
|
382
|
+
params.brand = { ...BRAND };
|
|
383
|
+
params.list = { ...config.site.list };
|
|
384
|
+
if (config.site.notfound) params.notfound = structuredClone(config.site.notfound);
|
|
385
|
+
|
|
386
|
+
const generated = {
|
|
387
|
+
baseURL: config.homepage,
|
|
388
|
+
title,
|
|
389
|
+
locale: LOCALE,
|
|
390
|
+
publishDir: path.posix.relative(HUGO_SOURCE, `${DEPLOY_ROOT}/${config.contentPackage}`),
|
|
391
|
+
themesDir,
|
|
392
|
+
theme: THEME,
|
|
393
|
+
contentDir: path.posix.relative(HUGO_SOURCE, HUGO_CONTENT),
|
|
394
|
+
disableKinds: [...DISABLE_KINDS],
|
|
395
|
+
params,
|
|
396
|
+
markup: structuredClone(MARKUP),
|
|
397
|
+
menu: { main: menuEntries(navigation) },
|
|
398
|
+
};
|
|
399
|
+
return deepMerge(generated, config.site.hugo);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The configuration as the TOML Hugo reads.
|
|
404
|
+
*
|
|
405
|
+
* @param {Record<string, unknown>} generated - From {@link hugoConfig}.
|
|
406
|
+
* @returns {string} The file's contents.
|
|
407
|
+
*/
|
|
408
|
+
export function hugoToml(generated) {
|
|
409
|
+
return (
|
|
410
|
+
"# Generated by `content-build site` from package.json and " +
|
|
411
|
+
"package-build.config.yaml.\n# Every value here has a source there; edit " +
|
|
412
|
+
"the source, not this file.\n\n" +
|
|
413
|
+
`${stringifyToml(generated)}\n`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* `package.json`'s `description`, or `undefined` when it declares none.
|
|
419
|
+
*
|
|
420
|
+
* @param {string} rootDir - The repository root.
|
|
421
|
+
* @returns {string|undefined} The description.
|
|
422
|
+
*/
|
|
423
|
+
function packageDescription(rootDir) {
|
|
424
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
425
|
+
return typeof pkg.description === "string" ? pkg.description : undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The Hugo configuration, every source read from the repository.
|
|
430
|
+
*
|
|
431
|
+
* Reads `package.json`, the cached navigation and the installed theme's
|
|
432
|
+
* location, and composes them with {@link hugoConfig}. Nothing is written, so
|
|
433
|
+
* a caller can run this before touching the output tree and fail with it
|
|
434
|
+
* intact.
|
|
435
|
+
*
|
|
436
|
+
* @param {object} config - The resolved build configuration.
|
|
437
|
+
* @returns {Record<string, any>} The configuration Hugo reads.
|
|
438
|
+
* @throws {Error} When any source is missing or wrong.
|
|
439
|
+
*/
|
|
440
|
+
export function generateHugoConfig(config) {
|
|
441
|
+
return hugoConfig({
|
|
442
|
+
config,
|
|
443
|
+
description: packageDescription(config.rootDir),
|
|
444
|
+
navigation: readCachedNavigation(config),
|
|
445
|
+
themesDir: resolveThemesDir(config.rootDir),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Write `build/hugo/hugo.toml`.
|
|
451
|
+
*
|
|
452
|
+
* @param {object} config - The resolved build configuration.
|
|
453
|
+
* @param {Record<string, unknown>} [generated] - The configuration to write,
|
|
454
|
+
* when the caller already generated it. Generated here otherwise.
|
|
455
|
+
* @returns {{file: string}} The file written.
|
|
456
|
+
*/
|
|
457
|
+
export function writeHugoConfig(config, generated = generateHugoConfig(config)) {
|
|
458
|
+
const file = path.join(config.rootDir, HUGO_SOURCE, "hugo.toml");
|
|
459
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
460
|
+
fs.writeFileSync(file, hugoToml(generated));
|
|
461
|
+
return { file };
|
|
462
|
+
}
|
package/manifest.mjs
CHANGED
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
*
|
|
29
29
|
* - **Declared** — the `packageBuild.manifest` block, emitted unchanged, so a
|
|
30
30
|
* key Foundry adds in a later version needs no release of this package.
|
|
31
|
-
* - **Derived** — the identity, the
|
|
32
|
-
* compatibility ranges and the pack list. Declaring one of
|
|
33
|
-
* rather than an override: the authored copy would be
|
|
31
|
+
* - **Derived** — the identity, the description, the version, the release
|
|
32
|
+
* addresses, the compatibility ranges and the pack list. Declaring one of
|
|
33
|
+
* these is an error rather than an override: the authored copy would be
|
|
34
|
+
* silently overwritten.
|
|
34
35
|
* - **Computed** — namespaced `flags` a repository works out for itself.
|
|
35
36
|
*
|
|
36
37
|
* **Nothing here invents an address.** The repository URL is read from
|
|
@@ -459,10 +460,10 @@ function withoutBuildKeys(entry) {
|
|
|
459
460
|
*
|
|
460
461
|
* - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
|
|
461
462
|
* a key Foundry adds later needs no release of this package.
|
|
462
|
-
* - **Derived** — the identity, the
|
|
463
|
-
* and system compatibility ranges, and the pack list.
|
|
464
|
-
* also declared: an authored copy would be overwritten
|
|
465
|
-
* disagree with nothing to say so.
|
|
463
|
+
* - **Derived** — the identity, the description, the release addresses, the
|
|
464
|
+
* version, the Foundry and system compatibility ranges, and the pack list.
|
|
465
|
+
* These are refused if also declared: an authored copy would be overwritten
|
|
466
|
+
* and the two would disagree with nothing to say so.
|
|
466
467
|
* - **Computed** — namespaced `flags` a repository works out for itself, merged
|
|
467
468
|
* over any it declared.
|
|
468
469
|
*
|
|
@@ -492,6 +493,10 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
|
|
|
492
493
|
artifact,
|
|
493
494
|
}),
|
|
494
495
|
};
|
|
496
|
+
// Own-property presence, not just value, decides whether a key survives
|
|
497
|
+
// into `ordered` below — an explicit `undefined` would still occupy a slot
|
|
498
|
+
// in it. Set only when `package.json` actually declares one.
|
|
499
|
+
if (packageJson.description !== undefined) derived.description = packageJson.description;
|
|
495
500
|
if (config.compatibility) derived.compatibility = config.compatibility;
|
|
496
501
|
|
|
497
502
|
// `requiresSystem` is the gate half of the declare/require split. It
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.0
|
|
3
|
+
"version": "22.1.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -147,6 +147,7 @@
|
|
|
147
147
|
"markdown-it": "^15.0.0",
|
|
148
148
|
"markdownlint-cli2": "^0.23.2",
|
|
149
149
|
"prettier": "^3.9.6",
|
|
150
|
+
"smol-toml": "^1.7.0",
|
|
150
151
|
"ssh2-sftp-client": "^12.1.1",
|
|
151
152
|
"typescript": "^6.0.3",
|
|
152
153
|
"unidecode": "^1.1.0",
|
package/stage.mjs
CHANGED
|
@@ -42,9 +42,10 @@ import path from "node:path";
|
|
|
42
42
|
/**
|
|
43
43
|
* Directories every HeroicLands repository regenerates and none commits.
|
|
44
44
|
*
|
|
45
|
-
* A repository adds its own
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* A repository adds its own through `packageBuild.clean.extra`, but these four
|
|
46
|
+
* are common to all of them because they come from the shared toolchain rather
|
|
47
|
+
* than from any one package's layout. Everything the site build writes — the
|
|
48
|
+
* Hugo source tree, Hugo's cache and the rendered site — is under `build/`.
|
|
48
49
|
*/
|
|
49
50
|
export const BUILD_ARTIFACT_DIRS = Object.freeze(["build", ".vite", ".vitepress", ".rollup.cache"]);
|
|
50
51
|
|
package/types/config.d.mts
CHANGED
|
@@ -10,6 +10,30 @@
|
|
|
10
10
|
* @returns {string} An absolute path to import.
|
|
11
11
|
*/
|
|
12
12
|
export function resolveAssetTransform(declared: string, rootDir: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* `package.json`'s `homepage`, checked against the address it must be.
|
|
15
|
+
*
|
|
16
|
+
* A package's Foundry manifest already derives its own `url` from
|
|
17
|
+
* `contentPackage` (`packageHomepage` in `manifest.mjs`); `homepage` states
|
|
18
|
+
* the same address a second time, in `package.json`, for the generated Hugo
|
|
19
|
+
* configuration to read a `baseURL` from without knowing where each
|
|
20
|
+
* repository keeps its own site configuration.
|
|
21
|
+
*
|
|
22
|
+
* Required unconditionally: every package publishes a site, so there is no
|
|
23
|
+
* package this does not apply to.
|
|
24
|
+
*
|
|
25
|
+
* **Not called by {@link resolvePackageBuildConfig}.** Every packaging
|
|
26
|
+
* command — `clean`, `deploy`, `manifest` and the rest — resolves through it,
|
|
27
|
+
* and none of them reads `homepage`: the Foundry manifest's own `url` is
|
|
28
|
+
* `packageHomepage(contentPackage)`, independent of it. The right caller is
|
|
29
|
+
* whatever reads `homepage` to write a site's `baseURL`.
|
|
30
|
+
*
|
|
31
|
+
* @param {string|null} homepage - The resolved `package.json` `homepage`, or
|
|
32
|
+
* `null` when none is declared.
|
|
33
|
+
* @param {string} contentPackage - The resolved `contentPackage`.
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
export function checkHomepage(homepage: string | null, contentPackage: string): void;
|
|
13
37
|
/**
|
|
14
38
|
* Resolve a package-build configuration from an already-loaded shared one.
|
|
15
39
|
*
|