@heroiclands/package-build 23.12.8 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +3 -2
- package/assets/preview/gitea.ini +12 -7
- package/assets/styles/content-base.css +293 -24
- package/bin/content-commands.mjs +370 -213
- package/bin/package-build-preview.mjs +2 -2
- package/bin/package-build.mjs +3 -2
- package/ci/check-banners.mjs +7 -7
- package/config.mjs +21 -6
- package/content-config.mjs +199 -80
- package/docs/api.md +110 -89
- package/docs/authoring/links-and-markup.md +469 -97
- package/docs/commands.md +259 -78
- package/docs/configuration.md +211 -103
- package/docs/content-format.md +4 -4
- package/docs/diagnostics.md +44 -2
- package/docs/getting-started.md +1 -1
- package/docs/guides/icons.md +1 -1
- package/docs/guides/module-project.md +5 -8
- package/docs/guides/release-and-deploy.md +2 -2
- package/docs/guides/site.md +112 -23
- package/docs/guides/system-project.md +1 -1
- package/docs/guides/writing-in-invented-scripts.md +1247 -0
- package/docs/index.md +1 -0
- package/docs/project-setup.md +28 -30
- package/docs/reference/format-details.md +677 -319
- package/docs/reference/note-types.md +47 -10
- package/docs/reference/theme-pages.md +178 -108
- package/docs/source-preview.md +16 -2
- package/engine/anchors.mjs +2 -2
- package/engine/asset-shipping.mjs +22 -10
- package/engine/base-compiler.mjs +62 -104
- package/engine/changelog-lint.mjs +1 -1
- package/engine/code-fences.mjs +3 -3
- package/engine/content-address.mjs +1 -1
- package/engine/content-alerts.mjs +8 -4
- package/engine/content-blocks.mjs +29 -14
- package/engine/content-embeds.mjs +1 -1
- package/engine/content-expand.mjs +376 -0
- package/engine/content-figures.mjs +18 -11
- package/engine/content-format.yaml +8 -0
- package/engine/content-glyphs.mjs +213 -0
- package/engine/content-images.mjs +6 -7
- package/engine/content-index.mjs +1 -1
- package/engine/content-links.mjs +3 -3
- package/engine/content-lint.mjs +1 -1
- package/engine/content-render.mjs +453 -0
- package/engine/content-scripts.mjs +376 -43
- package/engine/content-spans.mjs +6 -3
- package/engine/content-styles.mjs +91 -0
- package/engine/content-symbols.mjs +186 -0
- package/engine/content-verse.mjs +738 -0
- package/engine/core-report.mjs +169 -0
- package/engine/core-vocabulary.mjs +208 -0
- package/engine/dictionary-html.mjs +119 -0
- package/engine/dictionary-rows.mjs +15 -4
- package/engine/field-reference.mjs +5 -5
- package/engine/first-use.mjs +17 -27
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/heading-attributes.mjs +89 -0
- package/engine/helpers.mjs +61 -101
- package/engine/homepage.mjs +17 -29
- package/engine/html-minify.mjs +70 -0
- package/engine/index.mjs +8 -5
- package/engine/key-case-collisions.mjs +136 -0
- package/engine/language-data.mjs +203 -6
- package/engine/language-rows.mjs +8 -2
- package/engine/language-terms.mjs +186 -16
- package/engine/language-words.mjs +12 -0
- package/engine/logographic.mjs +255 -0
- package/engine/map-lexicon.mjs +262 -47
- package/engine/map-places.mjs +5 -4
- package/engine/markdown-expressions.mjs +0 -2
- package/engine/markdownlint-run.mjs +9 -3
- package/engine/markup-validation.mjs +21 -0
- package/engine/metre-declaration.mjs +134 -0
- package/engine/names.mjs +36 -9
- package/engine/note-matter.mjs +66 -0
- package/engine/note-vocabulary.mjs +187 -3
- package/engine/pdf-build.mjs +69 -133
- package/engine/pdf-render.mjs +205 -58
- package/engine/post-fields.mjs +157 -0
- package/engine/preview-index.mjs +130 -0
- package/engine/preview-source.mjs +450 -0
- package/engine/project-init.mjs +5 -12
- package/engine/prose-config.mjs +3 -3
- package/engine/read-stdin.mjs +29 -0
- package/engine/run-rows.mjs +2 -2
- package/engine/run-words.mjs +6 -112
- package/engine/script-emphasis-terms.mjs +38 -0
- package/engine/script-emphasis.mjs +295 -0
- package/engine/script-font-delivery.mjs +4 -4
- package/engine/site-build.mjs +260 -315
- package/engine/site-config.mjs +26 -238
- package/engine/site-files.mjs +175 -0
- package/engine/site-index.mjs +9 -4
- package/engine/site-preview.mjs +52 -525
- package/engine/site-render.mjs +593 -0
- package/engine/site-root.mjs +22 -13
- package/engine/site-search.mjs +161 -0
- package/engine/site-serve.mjs +273 -0
- package/engine/site-theme-banner.mjs +154 -0
- package/engine/site-theme-urls.mjs +125 -0
- package/engine/site-theme.mjs +926 -0
- package/engine/sql-tables.mjs +20 -3
- package/engine/text-maps.mjs +57 -10
- package/engine/typographer.mjs +248 -0
- package/engine/verse-metre.mjs +357 -0
- package/engine/wikilink-syntax.mjs +29 -47
- package/engine/wikilinks.mjs +758 -279
- package/engine/word-forms.mjs +364 -0
- package/engine/word-rows.mjs +12 -2
- package/engine/word-terms.mjs +38 -4
- package/manifest.mjs +3 -4
- package/package.json +11 -8
- package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
- package/site-theme/layouts/base.hbs +31 -0
- package/site-theme/layouts/blog-list.hbs +30 -0
- package/site-theme/layouts/blog-post.hbs +40 -0
- package/site-theme/layouts/home.hbs +35 -0
- package/site-theme/layouts/list.hbs +20 -0
- package/site-theme/layouts/not-found.hbs +31 -0
- package/site-theme/layouts/page.hbs +7 -0
- package/site-theme/partials/copy-buttons.hbs +37 -0
- package/site-theme/partials/draft-notice.hbs +5 -0
- package/site-theme/partials/exturl.hbs +2 -0
- package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
- package/site-theme/partials/header.hbs +44 -0
- package/site-theme/partials/hero-banner.hbs +11 -0
- package/site-theme/partials/infobox-entry.hbs +2 -0
- package/site-theme/partials/infobox-section.hbs +31 -0
- package/site-theme/partials/infobox-value.hbs +2 -0
- package/site-theme/partials/infobox.hbs +18 -0
- package/site-theme/partials/list-members.hbs +9 -0
- package/site-theme/partials/nav-links.hbs +5 -0
- package/site-theme/partials/page-body.hbs +48 -0
- package/site-theme/partials/page-list.hbs +14 -0
- package/site-theme/partials/related.hbs +24 -0
- package/site-theme/partials/search-meta.hbs +6 -0
- package/site-theme/partials/search-names.hbs +2 -0
- package/site-theme/partials/search.hbs +52 -0
- package/site-theme/partials/tags.hbs +4 -0
- package/site-theme/partials/toc.hbs +7 -0
- package/{hugo-theme → site-theme}/static/css/style.css +106 -14
- package/stage.mjs +1 -2
- package/types/config.d.mts +8 -4
- package/types/content-config.d.mts +15 -15
- package/types/engine/asset-shipping.d.mts +5 -2
- package/types/engine/base-compiler.d.mts +20 -2
- package/types/engine/code-fences.d.mts +3 -3
- package/types/engine/content-address.d.mts +1 -1
- package/types/engine/content-alerts.d.mts +8 -2
- package/types/engine/content-blocks.d.mts +11 -4
- package/types/engine/content-expand.d.mts +288 -0
- package/types/engine/content-figures.d.mts +11 -3
- package/types/engine/content-glyphs.d.mts +85 -0
- package/types/engine/content-images.d.mts +6 -7
- package/types/engine/content-links.d.mts +1 -1
- package/types/engine/content-render.d.mts +199 -0
- package/types/engine/content-scripts.d.mts +70 -4
- package/types/engine/content-spans.d.mts +1 -1
- package/types/engine/content-styles.d.mts +33 -0
- package/types/engine/content-symbols.d.mts +35 -0
- package/types/engine/content-verse.d.mts +240 -0
- package/types/engine/core-report.d.mts +45 -0
- package/types/engine/core-vocabulary.d.mts +53 -0
- package/types/engine/dictionary-html.d.mts +8 -0
- package/types/engine/dictionary-rows.d.mts +10 -0
- package/types/engine/field-reference.d.mts +2 -2
- package/types/engine/heading-attributes.d.mts +20 -0
- package/types/engine/helpers.d.mts +3 -10
- package/types/engine/homepage.d.mts +11 -24
- package/types/engine/html-minify.d.mts +9 -0
- package/types/engine/index.d.mts +2 -1
- package/types/engine/key-case-collisions.d.mts +28 -0
- package/types/engine/language-data.d.mts +0 -2
- package/types/engine/language-terms.d.mts +24 -2
- package/types/engine/logographic.d.mts +45 -0
- package/types/engine/map-lexicon.d.mts +66 -13
- package/types/engine/map-places.d.mts +8 -0
- package/types/engine/markdown-expressions.d.mts +0 -2
- package/types/engine/metre-declaration.d.mts +39 -0
- package/types/engine/names.d.mts +34 -3
- package/types/engine/note-matter.d.mts +48 -0
- package/types/engine/pdf-render.d.mts +6 -2
- package/types/engine/post-fields.d.mts +44 -0
- package/types/engine/preview-index.d.mts +72 -0
- package/types/engine/preview-source.d.mts +51 -0
- package/types/engine/read-stdin.d.mts +11 -0
- package/types/engine/run-rows.d.mts +2 -2
- package/types/engine/run-words.d.mts +0 -15
- package/types/engine/script-emphasis-terms.d.mts +21 -0
- package/types/engine/script-emphasis.d.mts +84 -0
- package/types/engine/script-font-delivery.d.mts +4 -4
- package/types/engine/site-build.d.mts +71 -113
- package/types/engine/site-config.d.mts +14 -134
- package/types/engine/site-files.d.mts +73 -0
- package/types/engine/site-index.d.mts +11 -3
- package/types/engine/site-preview.d.mts +15 -19
- package/types/engine/site-render.d.mts +171 -0
- package/types/engine/site-root.d.mts +12 -4
- package/types/engine/site-search.d.mts +54 -0
- package/types/engine/site-serve.d.mts +44 -0
- package/types/engine/site-theme-banner.d.mts +51 -0
- package/types/engine/site-theme-urls.d.mts +60 -0
- package/types/engine/site-theme.d.mts +127 -0
- package/types/engine/sql-tables.d.mts +4 -1
- package/types/engine/text-maps.d.mts +17 -3
- package/types/engine/typographer.d.mts +11 -0
- package/types/engine/verse-metre.d.mts +131 -0
- package/types/engine/wikilink-syntax.d.mts +30 -48
- package/types/engine/wikilinks.d.mts +330 -14
- package/types/engine/word-forms.d.mts +83 -0
- package/types/manifest.d.mts +3 -4
- package/types/stage.d.mts +1 -2
- package/types/vendor/markdownlint.d.mts +8 -0
- package/vendor/markdownlint.mjs +20388 -0
- package/engine/content-poetry.mjs +0 -240
- package/engine/web-wikilinks.mjs +0 -594
- package/hugo-theme/layouts/404.html +0 -85
- package/hugo-theme/layouts/_default/baseof.html +0 -67
- package/hugo-theme/layouts/_default/list.html +0 -139
- package/hugo-theme/layouts/_default/single.html +0 -4
- package/hugo-theme/layouts/blog/list.html +0 -54
- package/hugo-theme/layouts/blog-post/single.html +0 -36
- package/hugo-theme/layouts/index.html +0 -75
- package/hugo-theme/layouts/partials/banner-url.html +0 -141
- package/hugo-theme/layouts/partials/cdn-url.html +0 -36
- package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
- package/hugo-theme/layouts/partials/draft-notice.html +0 -41
- package/hugo-theme/layouts/partials/draft-state.html +0 -26
- package/hugo-theme/layouts/partials/header.html +0 -35
- package/hugo-theme/layouts/partials/hero-banner.html +0 -85
- package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
- package/hugo-theme/layouts/partials/infobox/items.html +0 -26
- package/hugo-theme/layouts/partials/infobox/section.html +0 -56
- package/hugo-theme/layouts/partials/infobox/value.html +0 -43
- package/hugo-theme/layouts/partials/infobox.html +0 -57
- package/hugo-theme/layouts/partials/page-body.html +0 -152
- package/hugo-theme/layouts/partials/related.html +0 -138
- package/hugo-theme/layouts/partials/search.html +0 -84
- package/hugo-theme/layouts/partials/site-url.html +0 -32
- package/hugo-theme/layouts/partials/tags.html +0 -30
- package/hugo-theme/layouts/partials/toc.html +0 -28
- package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
- package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
- package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
- package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
- package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
- package/hugo-theme/theme.toml +0 -11
- package/types/engine/content-poetry.d.mts +0 -58
- package/types/engine/web-wikilinks.d.mts +0 -107
- /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
- /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
- /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
- /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
- /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
- /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
* **What a note's page tells the site's search.** Pagefind reads the filters a
|
|
16
|
+
* page carries, so a reader can narrow a search to a package, a kind of note
|
|
17
|
+
* or a part of the world:
|
|
18
|
+
*
|
|
19
|
+
* | Filter | Value |
|
|
20
|
+
* | ------ | ----- |
|
|
21
|
+
* | `package` | the package that publishes the note |
|
|
22
|
+
* | `type` | the note's `type` |
|
|
23
|
+
* | `subtype` | the note's `subType` |
|
|
24
|
+
* | `culture` | the name of the culture `data.culture` names |
|
|
25
|
+
* | `continent` | the continent the note lies in |
|
|
26
|
+
* | `region` | the outermost region beneath that continent |
|
|
27
|
+
*
|
|
28
|
+
* A place lies where its `parents` lead; a being where its `homes` are; a
|
|
29
|
+
* scenario where its `locations` are. A continent is a region whose parent is
|
|
30
|
+
* the world. A note reached by more than one path carries every continent and
|
|
31
|
+
* region it reaches, so a being with homes in two regions is found under both.
|
|
32
|
+
*
|
|
33
|
+
* The note's name and its aliases are weighted, so a search for a name finds
|
|
34
|
+
* its note first. A note's tags and its draft state are not filters.
|
|
35
|
+
*
|
|
36
|
+
* @module
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { placeShortcodes } from "./map-places.mjs";
|
|
40
|
+
import { resolveInfoboxRef } from "./site-index.mjs";
|
|
41
|
+
|
|
42
|
+
/** The filters a note's page carries, in the order a reader is offered them. */
|
|
43
|
+
export const SEARCH_FILTERS = Object.freeze([
|
|
44
|
+
"package",
|
|
45
|
+
"type",
|
|
46
|
+
"subtype",
|
|
47
|
+
"culture",
|
|
48
|
+
"continent",
|
|
49
|
+
"region",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {unknown} value - Anything.
|
|
54
|
+
* @returns {boolean} Whether it is a mapping a field may be read out of.
|
|
55
|
+
*/
|
|
56
|
+
const isMapping = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The continents and regions every place lies in.
|
|
60
|
+
*
|
|
61
|
+
* @param {Map<string, {shortcode: string, name: string, subType?: string, parents: string[]}>} places -
|
|
62
|
+
* Every place, by shortcode, as {@link module:engine/map-places.mapWorld} reads them.
|
|
63
|
+
* @returns {(shortcodes: Iterable<string>) => {continents: string[], regions: string[]}}
|
|
64
|
+
* The names of the continents and the outermost regions the given places lie
|
|
65
|
+
* in, each sorted.
|
|
66
|
+
*/
|
|
67
|
+
export function placeGeography(places) {
|
|
68
|
+
const worlds = new Set(
|
|
69
|
+
[...places.values()].filter((p) => p.subType === "world").map((p) => p.shortcode),
|
|
70
|
+
);
|
|
71
|
+
const isContinent = (place) =>
|
|
72
|
+
place?.subType === "region" && place.parents.some((parent) => worlds.has(parent));
|
|
73
|
+
/** @type {Map<string, {continents: Set<string>, regions: Set<string>}>} */
|
|
74
|
+
const known = new Map();
|
|
75
|
+
|
|
76
|
+
const reach = (shortcode, visiting) => {
|
|
77
|
+
if (known.has(shortcode)) return known.get(shortcode);
|
|
78
|
+
const found = { continents: new Set(), regions: new Set() };
|
|
79
|
+
const place = places.get(shortcode);
|
|
80
|
+
if (!place || visiting.has(shortcode) || worlds.has(shortcode)) return found;
|
|
81
|
+
visiting.add(shortcode);
|
|
82
|
+
if (isContinent(place)) found.continents.add(shortcode);
|
|
83
|
+
for (const parent of place.parents) {
|
|
84
|
+
if (place.subType === "region" && isContinent(places.get(parent)))
|
|
85
|
+
found.regions.add(shortcode);
|
|
86
|
+
const above = reach(parent, visiting);
|
|
87
|
+
for (const c of above.continents) found.continents.add(c);
|
|
88
|
+
for (const r of above.regions) found.regions.add(r);
|
|
89
|
+
}
|
|
90
|
+
visiting.delete(shortcode);
|
|
91
|
+
known.set(shortcode, found);
|
|
92
|
+
return found;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const names = (shortcodes) =>
|
|
96
|
+
[...new Set([...shortcodes].map((sc) => places.get(sc)?.name ?? sc))].sort();
|
|
97
|
+
return (shortcodes) => {
|
|
98
|
+
const continents = new Set();
|
|
99
|
+
const regions = new Set();
|
|
100
|
+
for (const shortcode of shortcodes) {
|
|
101
|
+
const found = reach(shortcode, new Set());
|
|
102
|
+
for (const c of found.continents) continents.add(c);
|
|
103
|
+
for (const r of found.regions) regions.add(r);
|
|
104
|
+
}
|
|
105
|
+
return { continents: names(continents), regions: names(regions) };
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The places a note lies in: itself for a place, its `homes` and its
|
|
111
|
+
* `locations` otherwise.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} fm - The note's frontmatter.
|
|
114
|
+
* @returns {string[]} Shortcodes, lower case.
|
|
115
|
+
*/
|
|
116
|
+
export function notePlaces(fm) {
|
|
117
|
+
if (fm?.type === "place") return [String(fm.shortcode ?? "").toLowerCase()].filter(Boolean);
|
|
118
|
+
const data = isMapping(fm?.data) ? fm.data : {};
|
|
119
|
+
return [...placeShortcodes(data.homes), ...placeShortcodes(data.locations)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a note's page tells the search.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} fm - The page's frontmatter: the note's, with its `package`.
|
|
126
|
+
* @param {object} options - What the values resolve against.
|
|
127
|
+
* @param {(shortcodes: Iterable<string>) => {continents: string[], regions: string[]}} options.geography -
|
|
128
|
+
* From {@link placeGeography}.
|
|
129
|
+
* @param {object} [options.index] - The site's address index, which names the
|
|
130
|
+
* culture `data.culture` names.
|
|
131
|
+
* @returns {{filters: Array<{name: string, values: string[]}>, names: string[]}}
|
|
132
|
+
* Each filter with a value, in {@link SEARCH_FILTERS} order, and the names a
|
|
133
|
+
* search weights.
|
|
134
|
+
*/
|
|
135
|
+
export function searchMetadata(fm, { geography, index }) {
|
|
136
|
+
const data = isMapping(fm?.data) ? fm.data : {};
|
|
137
|
+
const text = (value) => (value === undefined || value === null ? "" : String(value).trim());
|
|
138
|
+
const culture =
|
|
139
|
+
data.culture !== undefined && data.culture !== null && data.culture !== "" ?
|
|
140
|
+
index && resolveInfoboxRef(index, data.culture, { type: "lore" })?.name
|
|
141
|
+
: undefined;
|
|
142
|
+
const { continents, regions } = geography(notePlaces(fm));
|
|
143
|
+
const values = {
|
|
144
|
+
package: [text(fm?.package)],
|
|
145
|
+
type: [text(fm?.type)],
|
|
146
|
+
subtype: [text(fm?.subType)],
|
|
147
|
+
culture: [text(culture)],
|
|
148
|
+
continent: continents,
|
|
149
|
+
region: regions,
|
|
150
|
+
};
|
|
151
|
+
const filters = SEARCH_FILTERS.map((name) => ({
|
|
152
|
+
name,
|
|
153
|
+
values: values[name].filter(Boolean),
|
|
154
|
+
})).filter((filter) => filter.values.length);
|
|
155
|
+
const name = isMapping(fm?.name) ? fm.name : {};
|
|
156
|
+
const names = [
|
|
157
|
+
text(name.full),
|
|
158
|
+
...(Array.isArray(name.aliases) ? name.aliases.map(text) : []),
|
|
159
|
+
].filter(Boolean);
|
|
160
|
+
return { filters, names: [...new Set(names)] };
|
|
161
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
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
|
+
* **A site served while it is written.** {@link serveSite} builds a package's
|
|
16
|
+
* site, serves it, and keeps it current: it watches the content tree and the
|
|
17
|
+
* theme, writes again what a change touches, and tells every open page to
|
|
18
|
+
* reload.
|
|
19
|
+
*
|
|
20
|
+
* | Change | What is written again |
|
|
21
|
+
* | ------ | --------------------- |
|
|
22
|
+
* | a note's body or frontmatter | that note's page |
|
|
23
|
+
* | a note's name, type or shortcode, a new or removed note, a note holding an `sql` table, any other file in the content tree | the whole site |
|
|
24
|
+
* | the theme's layouts, partials or files | every page, and the theme's files |
|
|
25
|
+
*
|
|
26
|
+
* A page that changes because another note changed — a table that lists the
|
|
27
|
+
* note, a link that carries its name — is written again by the next whole
|
|
28
|
+
* build. A note in error is written as its page shows it, and the error is
|
|
29
|
+
* reported; a build whose gates fire writes nothing and leaves the last good
|
|
30
|
+
* site in place.
|
|
31
|
+
*
|
|
32
|
+
* The site is served from the deployed root, so a page is at the address it is
|
|
33
|
+
* deployed at, its base path included. Every HTML page is sent with a script
|
|
34
|
+
* that listens on {@link RELOAD_PATH} for the server's `reload` event; the
|
|
35
|
+
* script is added as the page is sent and is never written to the site. The
|
|
36
|
+
* search index is built once, after the first build.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import http from "node:http";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
|
|
45
|
+
import { siteBuildFailed } from "./site-build.mjs";
|
|
46
|
+
import { SITE_THEME_DIR } from "./site-theme.mjs";
|
|
47
|
+
import { SITE_STATIC_DIR, buildPackageSite, writeSite, writeThemeFiles } from "./site-render.mjs";
|
|
48
|
+
import { SEARCH_DIR, indexSite } from "./site-root.mjs";
|
|
49
|
+
|
|
50
|
+
/** The address a served page listens on for the server's reloads. */
|
|
51
|
+
export const RELOAD_PATH = "/__package-build/reload";
|
|
52
|
+
|
|
53
|
+
/** How long a burst of changes is gathered before it is written, in milliseconds. */
|
|
54
|
+
const SETTLE_MS = 120;
|
|
55
|
+
|
|
56
|
+
/** What a file is sent as, by its extension. */
|
|
57
|
+
const CONTENT_TYPES = Object.freeze({
|
|
58
|
+
".html": "text/html; charset=utf-8",
|
|
59
|
+
".css": "text/css; charset=utf-8",
|
|
60
|
+
".js": "text/javascript; charset=utf-8",
|
|
61
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
62
|
+
".json": "application/json",
|
|
63
|
+
".xml": "application/xml",
|
|
64
|
+
".txt": "text/plain; charset=utf-8",
|
|
65
|
+
".svg": "image/svg+xml",
|
|
66
|
+
".png": "image/png",
|
|
67
|
+
".jpg": "image/jpeg",
|
|
68
|
+
".jpeg": "image/jpeg",
|
|
69
|
+
".webp": "image/webp",
|
|
70
|
+
".gif": "image/gif",
|
|
71
|
+
".woff2": "font/woff2",
|
|
72
|
+
".woff": "font/woff",
|
|
73
|
+
".wasm": "application/wasm",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** The script a served page is sent with. */
|
|
77
|
+
const RELOAD_SCRIPT = `<script>new EventSource(${JSON.stringify(RELOAD_PATH)}).addEventListener("reload", () => location.reload());</script>`;
|
|
78
|
+
|
|
79
|
+
/** Whether a note's text holds a query its build prepares ahead of the render. */
|
|
80
|
+
const holdsQuery = (text) => /```\s*sql\b|\{\{[^}]*\bsql\b/.test(text);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Serve a package's site, and keep it current.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} options - The inputs.
|
|
86
|
+
* @param {object} options.config - The resolved configuration.
|
|
87
|
+
* @param {string} options.out - The site's directory under the deployed
|
|
88
|
+
* root, which the site is written to.
|
|
89
|
+
* @param {string} [options.root] - The deployed root, which is served. The
|
|
90
|
+
* directory above `out` by default; `out` itself for a site served at `/`.
|
|
91
|
+
* @param {string} [options.host] - The address to listen on.
|
|
92
|
+
* @param {number} [options.port] - The port; `0` takes any free one.
|
|
93
|
+
* @param {boolean} [options.search] - Build the search index after the first
|
|
94
|
+
* build, where the site has one.
|
|
95
|
+
* @param {number} [options.year] - The year the footer states.
|
|
96
|
+
* @param {(event: {kind: string, built?: object, files?: string[], findings?: object[], error?: unknown}) => void} [options.report] -
|
|
97
|
+
* Told of each build: `built` for a whole build, `page` for one note's page,
|
|
98
|
+
* `theme` for the theme, `failed` with what the build found, `error` with
|
|
99
|
+
* what it threw.
|
|
100
|
+
* @returns {Promise<{url: string, close: () => Promise<void>, settled: () => Promise<void>}>}
|
|
101
|
+
* The address of the site's home page, how to stop serving, and a promise
|
|
102
|
+
* for the moment every change seen so far has been written.
|
|
103
|
+
*/
|
|
104
|
+
export async function serveSite({
|
|
105
|
+
config,
|
|
106
|
+
out,
|
|
107
|
+
root = path.dirname(out),
|
|
108
|
+
host = "127.0.0.1",
|
|
109
|
+
port = 1313,
|
|
110
|
+
search = true,
|
|
111
|
+
year,
|
|
112
|
+
report = () => {},
|
|
113
|
+
}) {
|
|
114
|
+
/** @type {Set<http.ServerResponse>} */
|
|
115
|
+
const listeners = new Set();
|
|
116
|
+
let state = null;
|
|
117
|
+
|
|
118
|
+
const reload = () => {
|
|
119
|
+
for (const res of listeners) res.write("event: reload\ndata: {}\n\n");
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const buildAll = async () => {
|
|
123
|
+
const result = await buildPackageSite({ config, out, year });
|
|
124
|
+
if (!result.written || siteBuildFailed(result.built))
|
|
125
|
+
report({ kind: "failed", built: result.built });
|
|
126
|
+
if (!result.written) return false;
|
|
127
|
+
state = result;
|
|
128
|
+
report({ kind: "built", built: result.built, findings: result.written.findings });
|
|
129
|
+
return true;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const redrawNotes = (files) => {
|
|
133
|
+
if (!state) return false;
|
|
134
|
+
const changed = new Set();
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
137
|
+
if (!file.endsWith(".md") || !text || holdsQuery(text)) return false;
|
|
138
|
+
const entry = state.built.site.renderNote(file);
|
|
139
|
+
if (!entry) return false;
|
|
140
|
+
if (entry.findings.length) {
|
|
141
|
+
report({ kind: "failed", findings: entry.findings });
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
const page = state.pageOf(entry);
|
|
145
|
+
state.pages = state.pages.map((old) => (old.url === page.url ? page : old));
|
|
146
|
+
changed.add(page.url);
|
|
147
|
+
}
|
|
148
|
+
const written = writeSite({ site: state.site, pages: state.pages, out, only: changed });
|
|
149
|
+
report({ kind: "page", files: written.files, findings: written.findings });
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const redrawTheme = () => {
|
|
154
|
+
if (!state) return false;
|
|
155
|
+
writeThemeFiles(out);
|
|
156
|
+
const written = writeSite({
|
|
157
|
+
site: state.site,
|
|
158
|
+
pages: state.pages,
|
|
159
|
+
out,
|
|
160
|
+
only: new Set(state.pages.map((page) => page.url)),
|
|
161
|
+
});
|
|
162
|
+
report({ kind: "theme", files: written.files, findings: written.findings });
|
|
163
|
+
return true;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const indexForSearch = async () => {
|
|
167
|
+
if (!search || config.site.search === false || !state) return;
|
|
168
|
+
await indexSite({ site: out, output: path.join(out, SEARCH_DIR) });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
await buildAll();
|
|
172
|
+
await indexForSearch();
|
|
173
|
+
|
|
174
|
+
/** @type {{content: Set<string>, theme: boolean}} */
|
|
175
|
+
let pending = { content: new Set(), theme: false };
|
|
176
|
+
let timer = null;
|
|
177
|
+
let work = Promise.resolve();
|
|
178
|
+
const flush = () => {
|
|
179
|
+
timer = null;
|
|
180
|
+
const batch = pending;
|
|
181
|
+
pending = { content: new Set(), theme: false };
|
|
182
|
+
work = work.then(async () => {
|
|
183
|
+
try {
|
|
184
|
+
let done = true;
|
|
185
|
+
if (batch.content.size && !redrawNotes([...batch.content])) {
|
|
186
|
+
done = await buildAll();
|
|
187
|
+
if (done) await indexForSearch();
|
|
188
|
+
} else if (batch.theme) done = redrawTheme();
|
|
189
|
+
if (done) reload();
|
|
190
|
+
} catch (error) {
|
|
191
|
+
report({ kind: "error", error });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
const queue = (kind, file) => {
|
|
196
|
+
if (kind === "theme") pending.theme = true;
|
|
197
|
+
else pending.content.add(file);
|
|
198
|
+
if (!timer) timer = setTimeout(flush, SETTLE_MS);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const watchers = [
|
|
202
|
+
fs.watch(config.paths.content, { recursive: true }, (_event, name) => {
|
|
203
|
+
if (name) queue("content", path.join(config.paths.content, String(name)));
|
|
204
|
+
}),
|
|
205
|
+
...[SITE_THEME_DIR, SITE_STATIC_DIR]
|
|
206
|
+
.filter((dir) => fs.existsSync(dir))
|
|
207
|
+
.map((dir) => fs.watch(dir, { recursive: true }, () => queue("theme"))),
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
const server = http.createServer((req, res) => {
|
|
211
|
+
const address = decodeURIComponent(
|
|
212
|
+
new URL(req.url ?? "/", "http://serve.invalid").pathname,
|
|
213
|
+
);
|
|
214
|
+
if (address === RELOAD_PATH) {
|
|
215
|
+
res.writeHead(200, {
|
|
216
|
+
"content-type": "text/event-stream",
|
|
217
|
+
"cache-control": "no-cache",
|
|
218
|
+
connection: "keep-alive",
|
|
219
|
+
});
|
|
220
|
+
res.write(": connected\n\n");
|
|
221
|
+
listeners.add(res);
|
|
222
|
+
req.on("close", () => listeners.delete(res));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
let file = path.join(root, address);
|
|
226
|
+
if (!file.startsWith(root)) {
|
|
227
|
+
res.writeHead(403).end();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (fs.statSync(file, { throwIfNoEntry: false })?.isDirectory())
|
|
231
|
+
file = path.join(file, "index.html");
|
|
232
|
+
let status = 200;
|
|
233
|
+
if (!fs.existsSync(file)) {
|
|
234
|
+
status = 404;
|
|
235
|
+
file = path.join(out, "404.html");
|
|
236
|
+
if (!fs.existsSync(file)) {
|
|
237
|
+
res.writeHead(404).end();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
242
|
+
let body = fs.readFileSync(file);
|
|
243
|
+
if (type.startsWith("text/html")) {
|
|
244
|
+
const html = body.toString("utf8");
|
|
245
|
+
const at = html.lastIndexOf("</body>");
|
|
246
|
+
body = Buffer.from(
|
|
247
|
+
at < 0 ? html + RELOAD_SCRIPT : html.slice(0, at) + RELOAD_SCRIPT + html.slice(at),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
251
|
+
res.end(body);
|
|
252
|
+
});
|
|
253
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
254
|
+
const listening = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
255
|
+
const base = new URL(config.homepage).pathname;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
url: `http://${host}:${listening.port}${base}`,
|
|
259
|
+
settled: async () => {
|
|
260
|
+
while (timer || pending.content.size || pending.theme) {
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
262
|
+
}
|
|
263
|
+
await work;
|
|
264
|
+
},
|
|
265
|
+
close: async () => {
|
|
266
|
+
if (timer) clearTimeout(timer);
|
|
267
|
+
for (const watcher of watchers) watcher.close();
|
|
268
|
+
for (const res of listeners) res.end();
|
|
269
|
+
await work;
|
|
270
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
* **A page's banner image.** {@link bannerUrl} turns a note's banner fields into
|
|
16
|
+
* the address of the hero image, or into `""` when the page has none.
|
|
17
|
+
*
|
|
18
|
+
* Resolution, in order:
|
|
19
|
+
*
|
|
20
|
+
* 1. `data.banner` — a note's own hero image, which the content build resolved
|
|
21
|
+
* from an asset address. It is used as given; a blank one declines an image.
|
|
22
|
+
* 2. `banner` — a hand-authored page's value. `none` or `false` declines an
|
|
23
|
+
* image, a full URL is used as written, and anything else is a fragment
|
|
24
|
+
* under `images/`.
|
|
25
|
+
* 3. `<dir>/<name>.webp` — the default for the page's kind: the banner
|
|
26
|
+
* `banner-kinds.yaml` maps its `type` and `subType` to, or its `category`
|
|
27
|
+
* for the types the mapping lists as taking theirs from there.
|
|
28
|
+
* 4. `<dir>/<fallback>` — whenever the name step 2 or 3 picked is not in the
|
|
29
|
+
* declared inventory of `banners.yaml`. An asset host cannot be asked
|
|
30
|
+
* whether a name exists, so the inventory stands in for the test, and each
|
|
31
|
+
* name that misses is reported once.
|
|
32
|
+
*
|
|
33
|
+
* The inventory is checked only for a relative path directly inside the banner
|
|
34
|
+
* directory; an absolute URL, a step 1 address and a fragment elsewhere under
|
|
35
|
+
* `images/` pass through.
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import fs from "node:fs";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
import YAML from "yaml";
|
|
44
|
+
|
|
45
|
+
import { cdnUrl } from "./site-theme-urls.mjs";
|
|
46
|
+
|
|
47
|
+
/** The directory holding the banner inventory and the kind mapping. */
|
|
48
|
+
export const BANNER_DATA_DIR = path.resolve(
|
|
49
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
50
|
+
"..",
|
|
51
|
+
"site-theme",
|
|
52
|
+
"data",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** @type {{banners: object, kinds: object}|undefined} */
|
|
56
|
+
let shipped;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The banner data the theme ships.
|
|
60
|
+
*
|
|
61
|
+
* @returns {{banners: object, kinds: object}} The inventory and the kind mapping.
|
|
62
|
+
*/
|
|
63
|
+
export function shippedBannerData() {
|
|
64
|
+
shipped ??= {
|
|
65
|
+
banners: YAML.parse(fs.readFileSync(path.join(BANNER_DATA_DIR, "banners.yaml"), "utf8")),
|
|
66
|
+
kinds: YAML.parse(fs.readFileSync(path.join(BANNER_DATA_DIR, "banner-kinds.yaml"), "utf8")),
|
|
67
|
+
};
|
|
68
|
+
return shipped;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The text of a value as a template prints it, with an unset value as `<nil>`. */
|
|
72
|
+
const printed = (value) => (value === undefined || value === null ? "<nil>" : String(value));
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The address of a page's banner.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} site - The site: `params.cdnBaseURL` and `basePath`.
|
|
78
|
+
* @param {object} fields - What the page states.
|
|
79
|
+
* @param {object} [fields.data] - The page's `data`, read for the resolved `banner`.
|
|
80
|
+
* @param {unknown} [fields.banner] - The hand-authored `banner` value.
|
|
81
|
+
* @param {string} [fields.type] - The note `type`.
|
|
82
|
+
* @param {string} [fields.subType] - The note `subType`.
|
|
83
|
+
* @param {string} [fields.category] - The page's `category`.
|
|
84
|
+
* @param {string} [fields.subtype] - The name to default to when no `type` is given.
|
|
85
|
+
* @param {string} [fields.context] - The page address, named in a report.
|
|
86
|
+
* @param {object} [options] - Options.
|
|
87
|
+
* @param {{banners: object, kinds: object}} [options.data] - The inventory and the
|
|
88
|
+
* kind mapping. The shipped ones by default.
|
|
89
|
+
* @param {(finding: {severity: string, message: string, page?: string}) => void} [options.report] -
|
|
90
|
+
* Receives each name that misses the inventory, once.
|
|
91
|
+
* @param {Set<string>} [options.reported] - The names already reported.
|
|
92
|
+
* @returns {string} The address, or `""` when the page has no banner.
|
|
93
|
+
*/
|
|
94
|
+
export function bannerUrl(site, fields, { data = shippedBannerData(), report, reported } = {}) {
|
|
95
|
+
const banners = data.banners ?? {};
|
|
96
|
+
const kinds = data.kinds ?? {};
|
|
97
|
+
const dir = banners.dir || "images/banners";
|
|
98
|
+
const fallback = banners.fallback || "default.webp";
|
|
99
|
+
const available = banners.available ?? [];
|
|
100
|
+
|
|
101
|
+
let subtype = fields.subtype ?? "";
|
|
102
|
+
if (fields.type) {
|
|
103
|
+
const entry = (kinds.kinds ?? {})[fields.type];
|
|
104
|
+
if ((kinds.categoryTypes ?? []).includes(fields.type)) {
|
|
105
|
+
subtype = fields.category || "default";
|
|
106
|
+
} else if (entry && typeof entry === "object") {
|
|
107
|
+
subtype = entry[String(fields.subType ?? "").toLowerCase()] || fields.type;
|
|
108
|
+
} else {
|
|
109
|
+
subtype = entry || fields.type;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const raw = printed(fields.banner);
|
|
114
|
+
const declined = raw.toLowerCase() === "none" || raw === "false";
|
|
115
|
+
const resolved = fields.data ? fields.data.banner : undefined;
|
|
116
|
+
const fromBuild = resolved !== undefined && resolved !== null;
|
|
117
|
+
|
|
118
|
+
let banner = "";
|
|
119
|
+
let absolute = false;
|
|
120
|
+
if (fromBuild) {
|
|
121
|
+
banner = String(resolved);
|
|
122
|
+
absolute = true;
|
|
123
|
+
} else if (!declined) {
|
|
124
|
+
if (fields.banner) {
|
|
125
|
+
const value = String(fields.banner);
|
|
126
|
+
banner = `images/${value.replace(/^\//, "")}`;
|
|
127
|
+
if (/^(?:https?:)?\/\//.test(value)) {
|
|
128
|
+
banner = value;
|
|
129
|
+
absolute = true;
|
|
130
|
+
}
|
|
131
|
+
} else if (subtype) {
|
|
132
|
+
banner = `${dir}/${subtype}.webp`;
|
|
133
|
+
}
|
|
134
|
+
if (banner && !absolute && path.posix.dirname(banner) === dir) {
|
|
135
|
+
const name = path.posix.basename(banner, path.posix.extname(banner));
|
|
136
|
+
if (!available.includes(name)) {
|
|
137
|
+
if (!reported?.has(name)) {
|
|
138
|
+
reported?.add(name);
|
|
139
|
+
report?.({
|
|
140
|
+
severity: "warning",
|
|
141
|
+
page: fields.context || undefined,
|
|
142
|
+
message:
|
|
143
|
+
`hero-banner: no banner named "${name}" in the shared artwork set; ` +
|
|
144
|
+
`falling back to ${dir}/${fallback}. Draw it and add "${name}" to ` +
|
|
145
|
+
"data/banners.yaml, or name a hero image with `data.banner`. " +
|
|
146
|
+
"Write `data.banner: none` to decline a hero image.",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
banner = `${dir}/${fallback}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return banner ? cdnUrl(site, banner) : "";
|
|
154
|
+
}
|