@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,926 @@
|
|
|
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 site layouts.** {@link createSiteTheme} turns a page's data into a
|
|
16
|
+
* complete HTML document through the Handlebars layouts in `site-theme/`.
|
|
17
|
+
*
|
|
18
|
+
* ## What a layout is given
|
|
19
|
+
*
|
|
20
|
+
* A page is the data of one note, or of one generated page, after the renderer
|
|
21
|
+
* has made its body HTML:
|
|
22
|
+
*
|
|
23
|
+
* | Field | Meaning |
|
|
24
|
+
* | ----- | ------- |
|
|
25
|
+
* | `kind` | `home`, `page`, `section`, `taxonomy`, `term` or `404` |
|
|
26
|
+
* | `title`, `url` | The page's title and its address under the site's base path |
|
|
27
|
+
* | `params` | The note's frontmatter: `type`, `subType`, `category`, `description`, `banner`, `data`, `tags`, `date`, `series`, `package`, `infoboxes`, `related` |
|
|
28
|
+
* | `content` | The body as HTML |
|
|
29
|
+
* | `section` | A section page's name |
|
|
30
|
+
* | `members` | A listing's pages: `{url, title, description, content, count}` |
|
|
31
|
+
* | `siblings` | The pages beside this one, in the order a reader walks them: `{url, title, type, subType, category}` |
|
|
32
|
+
* | `source` | The file the page was written in, named in a finding |
|
|
33
|
+
* | `search` | What the page tells the site's search; see below |
|
|
34
|
+
*
|
|
35
|
+
* The site is its title, its `baseURL`, the `params` a consumer configures
|
|
36
|
+
* (`description`, `cdnBaseURL`, `brand`, `home`, `notfound`, `search`,
|
|
37
|
+
* `draftNotice`, `scriptFonts`), its `menu`, and `pages`, every regular page
|
|
38
|
+
* as `{url, title, section, type, subType, date, content}`. `tagPages` says the site
|
|
39
|
+
* writes a page for each tag, and `pageExists` answers whether a path inside
|
|
40
|
+
* the site is published. `feed` is the site's Atom feed as `{title, url,
|
|
41
|
+
* section}`, `section` being the one its posts are listed in, and
|
|
42
|
+
* `mergeIndexes` the search bundles of other sites its search reads beside
|
|
43
|
+
* its own.
|
|
44
|
+
*
|
|
45
|
+
* A page's `search` is what it tells the site's search: `filters` as
|
|
46
|
+
* `{name, values}`, and the `names` a search weights. A page's `title` is its
|
|
47
|
+
* result's title, its icon or banner the result's picture. A post — a `doc` of
|
|
48
|
+
* `subType: post` — carries `author` and `tags` filters and sorts by its
|
|
49
|
+
* `date`; its `author` is `data.author`, or the site's byline where it names
|
|
50
|
+
* none.
|
|
51
|
+
*
|
|
52
|
+
* ## Which layout draws a page
|
|
53
|
+
*
|
|
54
|
+
* | Page | Layout |
|
|
55
|
+
* | ---- | ------ |
|
|
56
|
+
* | `kind` `home` | `home` |
|
|
57
|
+
* | `kind` `404` | `not-found` |
|
|
58
|
+
* | a post: `type: doc`, `subType: post` | `blog-post` |
|
|
59
|
+
* | the section the feed lists | `blog-list` |
|
|
60
|
+
* | any other listing | `list` |
|
|
61
|
+
* | any other page | `page` |
|
|
62
|
+
*
|
|
63
|
+
* A page that names its `layout` is drawn by that one.
|
|
64
|
+
*
|
|
65
|
+
* ## Disappearing sections
|
|
66
|
+
*
|
|
67
|
+
* A section with nothing to show is not drawn: the decision is made where the
|
|
68
|
+
* data is read, and the templates write a section only when its data is there.
|
|
69
|
+
* `tests/helpers/empty-shells.ts` holds the check that proves it.
|
|
70
|
+
*
|
|
71
|
+
* ## Where the code stylesheet is
|
|
72
|
+
*
|
|
73
|
+
* {@link siteThemeAssets} lists the files a layout links that the theme
|
|
74
|
+
* generates rather than ships.
|
|
75
|
+
*
|
|
76
|
+
* @module
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import fs from "node:fs";
|
|
80
|
+
import path from "node:path";
|
|
81
|
+
import { fileURLToPath } from "node:url";
|
|
82
|
+
import Handlebars from "handlebars";
|
|
83
|
+
import MarkdownIt from "markdown-it";
|
|
84
|
+
|
|
85
|
+
import { siteCodeStylesheet } from "./content-styles.mjs";
|
|
86
|
+
import { groupDictionaryLists } from "./dictionary-html.mjs";
|
|
87
|
+
import { bannerUrl, shippedBannerData } from "./site-theme-banner.mjs";
|
|
88
|
+
import { cdnUrl, relUrl, safeUrl, siteUrl, tagUrl, isAbsoluteUrl } from "./site-theme-urls.mjs";
|
|
89
|
+
import { isPost } from "./post-fields.mjs";
|
|
90
|
+
|
|
91
|
+
/** The directory the layouts and partials are read from. */
|
|
92
|
+
export const SITE_THEME_DIR = path.resolve(
|
|
93
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
94
|
+
"..",
|
|
95
|
+
"site-theme",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/** The layouts a page can be drawn by. */
|
|
99
|
+
export const LAYOUTS = Object.freeze([
|
|
100
|
+
"base",
|
|
101
|
+
"page",
|
|
102
|
+
"home",
|
|
103
|
+
"list",
|
|
104
|
+
"blog-list",
|
|
105
|
+
"blog-post",
|
|
106
|
+
"not-found",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/** The label each page type is grouped under in a related-pages block. */
|
|
110
|
+
const TYPE_LABELS = Object.freeze({
|
|
111
|
+
affiliation: "Affiliations",
|
|
112
|
+
being: "Beings",
|
|
113
|
+
character: "People",
|
|
114
|
+
continent: "Continents",
|
|
115
|
+
creature: "Creatures",
|
|
116
|
+
faith: "Faiths",
|
|
117
|
+
language: "Languages",
|
|
118
|
+
lore: "Lore",
|
|
119
|
+
organization: "Organizations",
|
|
120
|
+
page: "Pages",
|
|
121
|
+
pantheon: "Pantheons",
|
|
122
|
+
people: "Peoples",
|
|
123
|
+
polity: "Polities",
|
|
124
|
+
post: "Posts",
|
|
125
|
+
"project-page": "Projects",
|
|
126
|
+
region: "Regions",
|
|
127
|
+
religion: "Religions",
|
|
128
|
+
settlement: "Settlements",
|
|
129
|
+
site: "Places",
|
|
130
|
+
world: "Worlds",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** The tag that marks a note as unfinished. */
|
|
134
|
+
const DRAFT_TAG = "draft";
|
|
135
|
+
|
|
136
|
+
/** The sentence a draft page states when the site supplies none. */
|
|
137
|
+
const DRAFT_NOTICE =
|
|
138
|
+
"This entry is unfinished. What it states may change, and nothing in it is settled.";
|
|
139
|
+
|
|
140
|
+
/** The layouts of an infobox section, and the key each carries its content under. */
|
|
141
|
+
const SECTION_KEYS = Object.freeze({
|
|
142
|
+
rows: "rows",
|
|
143
|
+
grid: "cells",
|
|
144
|
+
runin: "groups",
|
|
145
|
+
list: "entries",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const { unescapeAll } = new MarkdownIt().utils;
|
|
149
|
+
|
|
150
|
+
/** @param {unknown} value */
|
|
151
|
+
const text = (value) => (value === undefined || value === null ? "" : String(value));
|
|
152
|
+
|
|
153
|
+
/** A string with its first letter capitalised and its separators read as spaces. */
|
|
154
|
+
export function humanize(value) {
|
|
155
|
+
const spaced = text(value).replace(/[-_]+/g, " ").trim();
|
|
156
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The text of HTML: tags dropped, character references decoded, whitespace collapsed. */
|
|
160
|
+
export function plainText(html) {
|
|
161
|
+
const stripped = text(html).replace(/<[^>]*>/g, " ");
|
|
162
|
+
return unescapeAll(stripped.replace(/\\/g, "\\\\")).replace(/\s+/g, " ").trim();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @param {unknown} value @returns {Date|undefined} */
|
|
166
|
+
function toDate(value) {
|
|
167
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
168
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
169
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A date as `YYYY-MM-DD` and as `January 2, 2006`, in UTC. */
|
|
173
|
+
function dateParts(value) {
|
|
174
|
+
const date = toDate(value);
|
|
175
|
+
if (!date) return undefined;
|
|
176
|
+
return {
|
|
177
|
+
iso: date.toISOString().slice(0, 10),
|
|
178
|
+
text: date.toLocaleDateString("en-US", {
|
|
179
|
+
timeZone: "UTC",
|
|
180
|
+
year: "numeric",
|
|
181
|
+
month: "long",
|
|
182
|
+
day: "numeric",
|
|
183
|
+
}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether a page states it is a draft. */
|
|
188
|
+
export function isDraft(params = {}) {
|
|
189
|
+
return (params.tags ?? []).some((tag) => text(tag).toLowerCase() === DRAFT_TAG);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The subject tags of a page: every tag but the one that marks a draft.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} params - The page's frontmatter.
|
|
196
|
+
* @returns {string[]} The tags.
|
|
197
|
+
*/
|
|
198
|
+
export function subjectTags(params = {}) {
|
|
199
|
+
return (params.tags ?? []).map(text).filter((tag) => tag.toLowerCase() !== DRAFT_TAG);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The headings of rendered HTML that a contents list names.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} html - A page's body.
|
|
206
|
+
* @returns {Array<{level: number, id: string, text: string}>} The `h2` and `h3`
|
|
207
|
+
* headings in document order, the footnote list's own heading left out; `id`
|
|
208
|
+
* is empty for a heading that has none.
|
|
209
|
+
*/
|
|
210
|
+
export function pageHeadings(html) {
|
|
211
|
+
const prose = text(html).replace(/<section class="footnotes">[^]*?<\/section>/gi, "");
|
|
212
|
+
return [...prose.matchAll(/<h([23])\b([^>]*)>([^]*?)<\/h\1>/gi)].map((match) => ({
|
|
213
|
+
level: Number(match[1]),
|
|
214
|
+
id: /\bid\s*=\s*"([^"]*)"/i.exec(match[2])?.[1] ?? "",
|
|
215
|
+
text: plainText(match[3]),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @param {string} value */
|
|
220
|
+
const escapeHtml = (value) =>
|
|
221
|
+
text(value)
|
|
222
|
+
.replace(/&/g, "&")
|
|
223
|
+
.replace(/</g, "<")
|
|
224
|
+
.replace(/>/g, ">")
|
|
225
|
+
.replace(/"/g, """);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A contents list for a page's headings.
|
|
229
|
+
*
|
|
230
|
+
* @param {Array<{level: number, id: string, text: string}>} headings - From {@link pageHeadings}.
|
|
231
|
+
* @returns {string} A `nav` of nested lists, or `""` when there are no headings. A
|
|
232
|
+
* heading with no id is listed without a link.
|
|
233
|
+
*/
|
|
234
|
+
export function tableOfContents(headings) {
|
|
235
|
+
if (!headings.length) return "";
|
|
236
|
+
const entry = (heading) =>
|
|
237
|
+
heading.id ?
|
|
238
|
+
`<a href="#${escapeHtml(heading.id)}">${escapeHtml(heading.text)}</a>`
|
|
239
|
+
: escapeHtml(heading.text);
|
|
240
|
+
const lines = ['<nav id="TableOfContents">', " <ul>"];
|
|
241
|
+
let open = false;
|
|
242
|
+
let nested = false;
|
|
243
|
+
for (const heading of headings) {
|
|
244
|
+
if (heading.level === 2 || !open) {
|
|
245
|
+
if (nested) lines.push(" </ul>", " </li>");
|
|
246
|
+
else if (open) lines.push(" </li>");
|
|
247
|
+
nested = false;
|
|
248
|
+
lines.push(` <li>${entry(heading)}`);
|
|
249
|
+
open = true;
|
|
250
|
+
} else {
|
|
251
|
+
if (!nested) lines.push(" <ul>");
|
|
252
|
+
nested = true;
|
|
253
|
+
lines.push(` <li>${entry(heading)}</li>`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (nested) lines.push(" </ul>", " </li>");
|
|
257
|
+
else if (open) lines.push(" </li>");
|
|
258
|
+
lines.push(" </ul>", "</nav>");
|
|
259
|
+
return lines.join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** How much more a page's name and aliases count in a search than a word of its body. */
|
|
263
|
+
const NAME_WEIGHT = 10;
|
|
264
|
+
|
|
265
|
+
/** The most `h2` headings a page may have before it gets a contents list. */
|
|
266
|
+
const TOC_THRESHOLD = 3;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The page before and the page after this one.
|
|
270
|
+
*
|
|
271
|
+
* `siblings` is in the order a reader walks. Where the section holds more than
|
|
272
|
+
* one catalog, the siblings are narrowed to the pages sharing this page's
|
|
273
|
+
* catalog key: `type`, or for a `doc` its `subType` or `category`. A section
|
|
274
|
+
* holding one catalog is left alone, as is a page declaring none.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} page - The page.
|
|
277
|
+
* @param {boolean} [narrow] - Whether to narrow to the page's catalog.
|
|
278
|
+
* @returns {{prev?: object, next?: object}} The neighbours; `prev` is the entry
|
|
279
|
+
* after this one in `siblings`, and `next` the entry before it.
|
|
280
|
+
*/
|
|
281
|
+
export function neighbours(page, narrow = true) {
|
|
282
|
+
const siblings = page.siblings ?? [];
|
|
283
|
+
const keyOf = (item) => {
|
|
284
|
+
const type = item.type ?? "";
|
|
285
|
+
return type === "doc" ? (item.subType ?? item.category ?? "") : type;
|
|
286
|
+
};
|
|
287
|
+
const own = {
|
|
288
|
+
type: page.params?.type,
|
|
289
|
+
subType: page.params?.subType,
|
|
290
|
+
category: page.params?.category,
|
|
291
|
+
};
|
|
292
|
+
const key = keyOf(own);
|
|
293
|
+
let list = siblings;
|
|
294
|
+
if (narrow && key) {
|
|
295
|
+
const matching = siblings.filter((item) => keyOf(item) === key);
|
|
296
|
+
const mixed = siblings.some((item) => keyOf(item) !== key && keyOf(item) !== "");
|
|
297
|
+
if (mixed) list = matching;
|
|
298
|
+
}
|
|
299
|
+
const index = list.findIndex((item) => item.url === page.url);
|
|
300
|
+
if (index < 0) return {};
|
|
301
|
+
return { prev: list[index + 1], next: list[index - 1] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whether a string has anything other than whitespace in it. */
|
|
305
|
+
const present = (value) => text(value).trim() !== "";
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @typedef {object} Finding
|
|
309
|
+
* @property {"warning"|"error"} severity
|
|
310
|
+
* @property {string} message
|
|
311
|
+
* @property {string} [page] - The address of the page the finding is about.
|
|
312
|
+
* @property {string} [file] - The file the page was written in.
|
|
313
|
+
* @property {Array<string|number>} [param] - The path of the site parameter
|
|
314
|
+
* the finding is about, such as `["notfound", "links", 1, "url"]`.
|
|
315
|
+
*/
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create the layouts for one site.
|
|
319
|
+
*
|
|
320
|
+
* @param {object} options - The inputs.
|
|
321
|
+
* @param {object} options.site - The site; see the module description.
|
|
322
|
+
* @param {string} [options.dir] - The directory holding `layouts/` and
|
|
323
|
+
* `partials/`. The shipped theme by default.
|
|
324
|
+
* @param {{banners: object, kinds: object}} [options.bannerData] - The banner
|
|
325
|
+
* inventory and kind mapping. The shipped ones by default.
|
|
326
|
+
* @param {(finding: Finding) => void} [options.report] - Receives each finding
|
|
327
|
+
* as it is made. Every finding is also kept in `findings`.
|
|
328
|
+
* @returns {{
|
|
329
|
+
* render: (page: object) => string,
|
|
330
|
+
* layoutFor: (page: object) => string,
|
|
331
|
+
* partial: (name: string, context?: object) => string,
|
|
332
|
+
* findings: Finding[],
|
|
333
|
+
* }} The theme.
|
|
334
|
+
*/
|
|
335
|
+
export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report }) {
|
|
336
|
+
const hb = Handlebars.create();
|
|
337
|
+
const findings = [];
|
|
338
|
+
const reportedBanners = new Set();
|
|
339
|
+
const emit = (finding, page) => {
|
|
340
|
+
const made = { ...finding };
|
|
341
|
+
if (page?.source) made.file = page.source;
|
|
342
|
+
findings.push(made);
|
|
343
|
+
report?.(made);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
hb.registerHelper("cssUrl", (value) =>
|
|
347
|
+
text(value).replace(/[\s'"()\\]/g, (char) => encodeURIComponent(char)),
|
|
348
|
+
);
|
|
349
|
+
hb.registerHelper("safeUrl", (value) => safeUrl(value));
|
|
350
|
+
// JSON a `<script>` can hold: nothing in it closes the element.
|
|
351
|
+
hb.registerHelper(
|
|
352
|
+
"json",
|
|
353
|
+
(value) =>
|
|
354
|
+
new Handlebars.SafeString(
|
|
355
|
+
JSON.stringify(value ?? null)
|
|
356
|
+
.replace(/</g, "\\u003c")
|
|
357
|
+
.replace(/\u2028/g, "\\u2028")
|
|
358
|
+
.replace(/\u2029/g, "\\u2029"),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const read = (folder) => {
|
|
363
|
+
const base = path.join(dir, folder);
|
|
364
|
+
return fs
|
|
365
|
+
.readdirSync(base)
|
|
366
|
+
.filter((file) => file.endsWith(".hbs"))
|
|
367
|
+
.sort()
|
|
368
|
+
.map((file) => [file.slice(0, -4), fs.readFileSync(path.join(base, file), "utf8")]);
|
|
369
|
+
};
|
|
370
|
+
// A partial ends where its last tag does, so one used inside a line adds no break.
|
|
371
|
+
for (const [name, source] of read("partials"))
|
|
372
|
+
hb.registerPartial(name, source.replace(/\n$/, ""));
|
|
373
|
+
const compiled = Object.fromEntries(
|
|
374
|
+
read("layouts").map(([name, source]) => [name, hb.compile(source)]),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const params = site.params ?? {};
|
|
378
|
+
const banners = bannerData ?? shippedBannerData();
|
|
379
|
+
|
|
380
|
+
const banner = (page, fields) =>
|
|
381
|
+
bannerUrl(site, fields, {
|
|
382
|
+
data: banners,
|
|
383
|
+
reported: reportedBanners,
|
|
384
|
+
report: (finding) => emit(finding, page),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/** The chrome every page shares. */
|
|
388
|
+
const chrome = (page) => {
|
|
389
|
+
const menu = (site.menu ?? []).map((item) => ({
|
|
390
|
+
name: item.name,
|
|
391
|
+
url: safeUrl(item.url),
|
|
392
|
+
active: Boolean(item.active) || (page.permalink && item.url === page.permalink),
|
|
393
|
+
children: (item.children ?? []).map((child) => ({
|
|
394
|
+
name: child.name,
|
|
395
|
+
url: safeUrl(child.url),
|
|
396
|
+
})),
|
|
397
|
+
hasChildren: (item.children ?? []).length > 0,
|
|
398
|
+
}));
|
|
399
|
+
const brand = params.brand ?? {};
|
|
400
|
+
return {
|
|
401
|
+
title: site.title,
|
|
402
|
+
homeUrl: relUrl(site, ""),
|
|
403
|
+
menu,
|
|
404
|
+
hasMenu: menu.length > 0,
|
|
405
|
+
search: Boolean(params.search),
|
|
406
|
+
searchOptions: {
|
|
407
|
+
element: "#search",
|
|
408
|
+
showSubResults: true,
|
|
409
|
+
showImages: true,
|
|
410
|
+
...(site.mergeIndexes?.length ?
|
|
411
|
+
{ mergeIndex: site.mergeIndexes.map((bundlePath) => ({ bundlePath })) }
|
|
412
|
+
: {}),
|
|
413
|
+
},
|
|
414
|
+
feed:
|
|
415
|
+
site.feed ?
|
|
416
|
+
{ title: text(site.feed.title), url: safeUrl(site.feed.url) }
|
|
417
|
+
: undefined,
|
|
418
|
+
hasActions: menu.length > 0 || Boolean(params.search),
|
|
419
|
+
brand: {
|
|
420
|
+
logoUrl: brand.logo ? cdnUrl(site, brand.logo) : "",
|
|
421
|
+
licenseUrl: brand.licenseURL ? safeUrl(brand.licenseURL) : "",
|
|
422
|
+
discordUrl: brand.discordURL ? safeUrl(brand.discordURL) : "",
|
|
423
|
+
},
|
|
424
|
+
year: site.year ?? new Date().getFullYear(),
|
|
425
|
+
urls: {
|
|
426
|
+
style: relUrl(site, "css/style.css"),
|
|
427
|
+
icons: relUrl(site, "css/sohl-icons.css"),
|
|
428
|
+
code: relUrl(site, "css/code.css"),
|
|
429
|
+
scriptFonts: params.scriptFonts ? relUrl(site, params.scriptFonts) : "",
|
|
430
|
+
pagefindCss: relUrl(site, "pagefind/pagefind-ui.css"),
|
|
431
|
+
pagefindJs: relUrl(site, "pagefind/pagefind-ui.js"),
|
|
432
|
+
spoilers: relUrl(site, "js/inline-spoilers.js"),
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/** The hero band of a page. */
|
|
438
|
+
const heroOf = (page) => {
|
|
439
|
+
const frontmatter = page.params ?? {};
|
|
440
|
+
const type = frontmatter.type ?? "";
|
|
441
|
+
const url = banner(page, {
|
|
442
|
+
data: frontmatter.data,
|
|
443
|
+
banner: frontmatter.banner,
|
|
444
|
+
type,
|
|
445
|
+
subType: frontmatter.subType,
|
|
446
|
+
category: frontmatter.category,
|
|
447
|
+
subtype: type === "" ? "default" : "",
|
|
448
|
+
context: page.url,
|
|
449
|
+
});
|
|
450
|
+
const title = text(page.title);
|
|
451
|
+
const tagline = text(frontmatter.description);
|
|
452
|
+
if (!title && !tagline && !url) return undefined;
|
|
453
|
+
return { bannerUrl: url, title, tagline };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** One infobox entry. */
|
|
457
|
+
const entryOf = (entry) => ({
|
|
458
|
+
lead: text(entry?.lead),
|
|
459
|
+
text: text(entry?.text),
|
|
460
|
+
url: entry?.url ? safeUrl(entry.url) : "",
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/** The value of one row, by its kind. */
|
|
464
|
+
const valueOf = (row) => {
|
|
465
|
+
const kind = row.kind || "text";
|
|
466
|
+
if (kind === "link") return { isLink: true, entry: entryOf(row.value) };
|
|
467
|
+
if (kind === "links") return { isLinks: true, entries: (row.value ?? []).map(entryOf) };
|
|
468
|
+
if (kind === "list") return { text: (row.value ?? []).join(", ") };
|
|
469
|
+
if (kind === "number") {
|
|
470
|
+
const digits = text(row.value);
|
|
471
|
+
return {
|
|
472
|
+
text:
|
|
473
|
+
/^-?[0-9]+$/.test(digits) ?
|
|
474
|
+
new Intl.NumberFormat("en-US").format(Number(digits))
|
|
475
|
+
: digits,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return { text: text(row.value) };
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/** One infobox section, or `undefined` when it holds nothing to draw. */
|
|
482
|
+
const sectionOf = (section, page) => {
|
|
483
|
+
const layout = section.layout ?? "";
|
|
484
|
+
const key = SECTION_KEYS[layout];
|
|
485
|
+
if (!key) {
|
|
486
|
+
emit(
|
|
487
|
+
{
|
|
488
|
+
severity: "warning",
|
|
489
|
+
page: page.url,
|
|
490
|
+
message:
|
|
491
|
+
`infobox: section layout "${layout}" is not one of rows, grid, runin or ` +
|
|
492
|
+
"list, so the section is not drawn.",
|
|
493
|
+
},
|
|
494
|
+
page,
|
|
495
|
+
);
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
const items = section[key];
|
|
499
|
+
if (!Array.isArray(items) || !items.length) return undefined;
|
|
500
|
+
const view = {
|
|
501
|
+
id: text(section.id),
|
|
502
|
+
label: text(section.label),
|
|
503
|
+
[layout]: true,
|
|
504
|
+
};
|
|
505
|
+
if (layout === "rows") {
|
|
506
|
+
view.rows = items
|
|
507
|
+
.map((row) => ({ label: text(row.label), ...valueOf(row) }))
|
|
508
|
+
.filter((row) => row.isLink || row.isLinks || present(row.text));
|
|
509
|
+
if (!view.rows.length) return undefined;
|
|
510
|
+
} else if (layout === "grid") {
|
|
511
|
+
view.cells = items.map((cell) => ({
|
|
512
|
+
label: text(cell.label),
|
|
513
|
+
value: text(cell.value),
|
|
514
|
+
}));
|
|
515
|
+
} else if (layout === "runin") {
|
|
516
|
+
view.groups = items
|
|
517
|
+
.filter((group) => (group.entries ?? []).length)
|
|
518
|
+
.map((group) => ({
|
|
519
|
+
label: text(group.label),
|
|
520
|
+
entries: group.entries.map(entryOf),
|
|
521
|
+
}));
|
|
522
|
+
if (!view.groups.length) return undefined;
|
|
523
|
+
} else {
|
|
524
|
+
view.entries = items.map(entryOf);
|
|
525
|
+
}
|
|
526
|
+
return view;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/** The boxes a page declares, with the sections that hold something. */
|
|
530
|
+
const infoboxesOf = (page) => {
|
|
531
|
+
const boxes = [];
|
|
532
|
+
for (const box of page.params?.infoboxes ?? []) {
|
|
533
|
+
const sections = (box.sections ?? [])
|
|
534
|
+
.map((section) => sectionOf(section, page))
|
|
535
|
+
.filter(Boolean);
|
|
536
|
+
const statement = text(box.statement);
|
|
537
|
+
if (!sections.length && !(box.kind === "system" && statement)) continue;
|
|
538
|
+
boxes.push({
|
|
539
|
+
id: text(box.id),
|
|
540
|
+
title: text(box.title),
|
|
541
|
+
statement: box.kind === "system" ? statement : "",
|
|
542
|
+
sections: box.kind === "system" && statement ? [] : sections,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return boxes;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/** The related-pages block, or `undefined` when there is nothing in it. */
|
|
549
|
+
const relatedOf = (page) => {
|
|
550
|
+
const related = page.params?.related ?? {};
|
|
551
|
+
const seen = new Set();
|
|
552
|
+
const deliberate = [];
|
|
553
|
+
const merged = [];
|
|
554
|
+
for (const ref of related.seeAlso ?? []) {
|
|
555
|
+
if (seen.has(ref.url)) continue;
|
|
556
|
+
seen.add(ref.url);
|
|
557
|
+
deliberate.push({
|
|
558
|
+
url: safeUrl(ref.url),
|
|
559
|
+
title: text(ref.title),
|
|
560
|
+
gloss: text(ref.gloss),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
for (const ref of [...(related.mentions ?? []), ...(related.backlinks ?? [])]) {
|
|
564
|
+
if (seen.has(ref.url)) continue;
|
|
565
|
+
seen.add(ref.url);
|
|
566
|
+
merged.push(ref);
|
|
567
|
+
}
|
|
568
|
+
const groups = new Map();
|
|
569
|
+
for (const ref of merged) {
|
|
570
|
+
const type = isPost(ref) ? "post" : ref.type || "page";
|
|
571
|
+
if (!groups.has(type)) groups.set(type, []);
|
|
572
|
+
groups.get(type).push({ url: safeUrl(ref.url), title: text(ref.title) });
|
|
573
|
+
}
|
|
574
|
+
const rows = [...groups].map(([type, refs]) => ({
|
|
575
|
+
label: TYPE_LABELS[type] ?? humanize(type),
|
|
576
|
+
refs,
|
|
577
|
+
}));
|
|
578
|
+
rows.sort((a, b) =>
|
|
579
|
+
a.label < b.label ? -1
|
|
580
|
+
: a.label > b.label ? 1
|
|
581
|
+
: 0,
|
|
582
|
+
);
|
|
583
|
+
if (!rows.length && !deliberate.length) return undefined;
|
|
584
|
+
return { seeAlso: deliberate, groups: rows };
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/** The tag chips of a page. */
|
|
588
|
+
const tagsOf = (page) => {
|
|
589
|
+
const items = subjectTags(page.params).map((name) => ({
|
|
590
|
+
name,
|
|
591
|
+
url: site.tagPages ? tagUrl(site, name) : "",
|
|
592
|
+
}));
|
|
593
|
+
return items.length ? { items } : undefined;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/** The prev/next links of a page. */
|
|
597
|
+
const navOf = (page, narrow) => {
|
|
598
|
+
const { prev, next } = neighbours(page, narrow);
|
|
599
|
+
if (!prev && !next) return undefined;
|
|
600
|
+
const link = (item) => item && { url: safeUrl(item.url), title: text(item.title) };
|
|
601
|
+
return { prev: link(prev), next: link(next) };
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* What a page tells the search: its title, filters and weighted names,
|
|
606
|
+
* and its icon or banner as the result's picture.
|
|
607
|
+
*/
|
|
608
|
+
const searchOf = (page, hero, extra = {}) => {
|
|
609
|
+
const frontmatter = page.params ?? {};
|
|
610
|
+
const search = page.search ?? {};
|
|
611
|
+
const icon = frontmatter.data?.icon;
|
|
612
|
+
const image =
|
|
613
|
+
(typeof icon === "string" && icon ? cdnUrl(site, icon) : "") || hero?.bannerUrl;
|
|
614
|
+
const names = (search.names ?? []).map(text).filter(present);
|
|
615
|
+
return {
|
|
616
|
+
title: text(page.title),
|
|
617
|
+
filters: [...(search.filters ?? []), ...(extra.filters ?? [])]
|
|
618
|
+
.map((filter) => ({
|
|
619
|
+
name: text(filter.name),
|
|
620
|
+
values: (filter.values ?? []).map(text).filter(present),
|
|
621
|
+
}))
|
|
622
|
+
.filter((filter) => filter.name && filter.values.length),
|
|
623
|
+
aliases: names.slice(1).join(", "),
|
|
624
|
+
names: names.join(" "),
|
|
625
|
+
weight: NAME_WEIGHT,
|
|
626
|
+
image: image ? safeUrl(image) : "",
|
|
627
|
+
date: extra.date ?? "",
|
|
628
|
+
};
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/** The article of a note's page: the rail, the prose and the links around them. */
|
|
632
|
+
const articleOf = (page, hero) => {
|
|
633
|
+
const frontmatter = page.params ?? {};
|
|
634
|
+
const content = groupDictionaryLists(page.content);
|
|
635
|
+
const boxes = infoboxesOf(page);
|
|
636
|
+
const headings = pageHeadings(content);
|
|
637
|
+
const hasToc = headings.filter((heading) => heading.level === 2).length > TOC_THRESHOLD;
|
|
638
|
+
const hasSidebar = boxes.length > 0;
|
|
639
|
+
const draft = isDraft(frontmatter);
|
|
640
|
+
const date = dateParts(frontmatter.date ?? page.date);
|
|
641
|
+
const tags = tagsOf(page);
|
|
642
|
+
const subtype =
|
|
643
|
+
frontmatter.type === "being" && frontmatter.subType ?
|
|
644
|
+
frontmatter.subType === "npc" ?
|
|
645
|
+
"NPC"
|
|
646
|
+
: humanize(frontmatter.subType)
|
|
647
|
+
: "";
|
|
648
|
+
const hasBody = present(content);
|
|
649
|
+
const related = relatedOf(page);
|
|
650
|
+
const nav = navOf(page, true);
|
|
651
|
+
if (!hasBody && !hasSidebar && !date && !tags && !subtype && !related && !nav && !draft)
|
|
652
|
+
return undefined;
|
|
653
|
+
return {
|
|
654
|
+
articleClass:
|
|
655
|
+
"single" +
|
|
656
|
+
(hasSidebar ? " single-with-sidebar"
|
|
657
|
+
: hasToc ? " single-with-toc"
|
|
658
|
+
: "") +
|
|
659
|
+
(draft ? " single-with-notice" : ""),
|
|
660
|
+
isDraft: draft,
|
|
661
|
+
notice: text(params.draftNotice) || DRAFT_NOTICE,
|
|
662
|
+
search: searchOf(page, hero),
|
|
663
|
+
boxes,
|
|
664
|
+
hasSidebar,
|
|
665
|
+
toc: hasToc ? tableOfContents(headings) : "",
|
|
666
|
+
tocBeside: hasToc && hasSidebar,
|
|
667
|
+
tocBelow: hasToc && !hasSidebar,
|
|
668
|
+
meta: date || tags || subtype ? { date, subtype, tags } : undefined,
|
|
669
|
+
content,
|
|
670
|
+
hasBody,
|
|
671
|
+
hasMain: Boolean(date || tags || subtype || hasBody || related || nav),
|
|
672
|
+
related,
|
|
673
|
+
nav,
|
|
674
|
+
};
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
/** The members of a listing that no page already reached is linked from. */
|
|
678
|
+
const unlistedMembers = (page) => {
|
|
679
|
+
const members = page.members ?? [];
|
|
680
|
+
if (page.kind !== "section" || !present(page.content)) return members;
|
|
681
|
+
let haystack = page.content;
|
|
682
|
+
const reached = new Set();
|
|
683
|
+
let growing = true;
|
|
684
|
+
for (let pass = 0; pass < 12 && growing; pass++) {
|
|
685
|
+
growing = false;
|
|
686
|
+
for (const member of members) {
|
|
687
|
+
if (reached.has(member.url)) continue;
|
|
688
|
+
if (haystack.includes(`href="${member.url}`)) {
|
|
689
|
+
reached.add(member.url);
|
|
690
|
+
haystack += text(member.content);
|
|
691
|
+
growing = true;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return members.filter((member) => !reached.has(member.url));
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/** A page of links. */
|
|
699
|
+
const listOf = (items) => (items.length ? { items } : undefined);
|
|
700
|
+
|
|
701
|
+
const views = {
|
|
702
|
+
page: (page) => {
|
|
703
|
+
const hero = heroOf(page);
|
|
704
|
+
return { hero, article: articleOf(page, hero) };
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
home: (page) => {
|
|
708
|
+
const frontmatter = page.params ?? {};
|
|
709
|
+
if ((frontmatter.type ?? "") === "homepage")
|
|
710
|
+
return { ...views.page(page), isHomepage: true };
|
|
711
|
+
const home = params.home;
|
|
712
|
+
if (!home) {
|
|
713
|
+
emit(
|
|
714
|
+
{
|
|
715
|
+
severity: "warning",
|
|
716
|
+
page: page.url,
|
|
717
|
+
message:
|
|
718
|
+
"the home page is not a `type: homepage` note and the site configures " +
|
|
719
|
+
"no `home`, so there is nothing to draw",
|
|
720
|
+
},
|
|
721
|
+
page,
|
|
722
|
+
);
|
|
723
|
+
return {};
|
|
724
|
+
}
|
|
725
|
+
const heading = (item) => ({ title: text(item.title), class: text(item.class) });
|
|
726
|
+
const card = (item) => ({
|
|
727
|
+
title: text(item.title),
|
|
728
|
+
url: safeUrl(item.url),
|
|
729
|
+
text: text(item.text),
|
|
730
|
+
key: text(item.key),
|
|
731
|
+
imageUrl: item.image ? cdnUrl(site, item.image) : "",
|
|
732
|
+
alt: text(item.alt) || text(item.title),
|
|
733
|
+
});
|
|
734
|
+
const headings = (home.headings ?? []).map(heading);
|
|
735
|
+
const cards = (home.cards ?? []).map(card);
|
|
736
|
+
return {
|
|
737
|
+
grid: {
|
|
738
|
+
hero:
|
|
739
|
+
present(home.herotitle) || home.heroimage ?
|
|
740
|
+
{
|
|
741
|
+
bannerUrl: home.heroimage ? cdnUrl(site, home.heroimage) : "",
|
|
742
|
+
title: text(home.herotitle),
|
|
743
|
+
tagline: text(home.herotagline),
|
|
744
|
+
}
|
|
745
|
+
: undefined,
|
|
746
|
+
headings,
|
|
747
|
+
cards,
|
|
748
|
+
hasItems: headings.length > 0 || cards.length > 0,
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
},
|
|
752
|
+
|
|
753
|
+
list: (page) => {
|
|
754
|
+
const unlisted = unlistedMembers(page);
|
|
755
|
+
const labelled = page.kind === "section" && present(page.content);
|
|
756
|
+
const taxonomy = page.kind === "taxonomy";
|
|
757
|
+
const items = unlisted.map((member) => ({
|
|
758
|
+
url: safeUrl(member.url),
|
|
759
|
+
title: text(member.title),
|
|
760
|
+
text: taxonomy ? "" : text(member.description),
|
|
761
|
+
count: taxonomy ? String(member.count ?? 0) : "",
|
|
762
|
+
}));
|
|
763
|
+
const body = present(page.content) ? groupDictionaryLists(page.content) : "";
|
|
764
|
+
const empty = !items.length && !labelled;
|
|
765
|
+
return {
|
|
766
|
+
hero: heroOf(page),
|
|
767
|
+
body,
|
|
768
|
+
hasBody: body !== "",
|
|
769
|
+
listing: listOf(items),
|
|
770
|
+
heading: items.length && labelled && !taxonomy ? "Orphaned Pages" : "",
|
|
771
|
+
empty,
|
|
772
|
+
hasMembers: items.length > 0 || empty,
|
|
773
|
+
};
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
"blog-list": (page) => {
|
|
777
|
+
const posts = (site.pages ?? [])
|
|
778
|
+
.filter((entry) => entry.section === page.section && isPost(entry))
|
|
779
|
+
.sort((a, b) => {
|
|
780
|
+
const left = toDate(a.date)?.getTime() ?? 0;
|
|
781
|
+
const right = toDate(b.date)?.getTime() ?? 0;
|
|
782
|
+
return right - left;
|
|
783
|
+
});
|
|
784
|
+
const items = posts.map((post) => {
|
|
785
|
+
const words = plainText(post.content).split(" ").filter(Boolean);
|
|
786
|
+
const preview =
|
|
787
|
+
words.length > 100 ? `${words.slice(0, 100).join(" ")}…` : words.join(" ");
|
|
788
|
+
return {
|
|
789
|
+
url: safeUrl(post.url),
|
|
790
|
+
title: text(post.title),
|
|
791
|
+
date: dateParts(post.date),
|
|
792
|
+
preview,
|
|
793
|
+
};
|
|
794
|
+
});
|
|
795
|
+
return {
|
|
796
|
+
hero: heroOf(page),
|
|
797
|
+
body: present(page.content) ? groupDictionaryLists(page.content) : "",
|
|
798
|
+
posts: items.length ? items : undefined,
|
|
799
|
+
empty: !items.length,
|
|
800
|
+
};
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
"blog-post": (page) => {
|
|
804
|
+
const frontmatter = page.params ?? {};
|
|
805
|
+
const date = dateParts(frontmatter.date ?? page.date);
|
|
806
|
+
const tags = tagsOf(page);
|
|
807
|
+
const series = text(frontmatter.series);
|
|
808
|
+
const content = present(page.content) ? groupDictionaryLists(page.content) : "";
|
|
809
|
+
const meta = date || tags || series ? { series, date, tags } : undefined;
|
|
810
|
+
const related = relatedOf(page);
|
|
811
|
+
const nav = navOf(page, false);
|
|
812
|
+
const hero = heroOf(page);
|
|
813
|
+
const author = text(frontmatter.data?.author) || text(params.author);
|
|
814
|
+
const search = searchOf(page, hero, {
|
|
815
|
+
filters: [
|
|
816
|
+
{ name: "author", values: author ? [author] : [] },
|
|
817
|
+
{ name: "tags", values: subjectTags(frontmatter) },
|
|
818
|
+
],
|
|
819
|
+
date: date?.iso,
|
|
820
|
+
});
|
|
821
|
+
return {
|
|
822
|
+
hero,
|
|
823
|
+
article:
|
|
824
|
+
meta || content || related || nav ?
|
|
825
|
+
{ meta, content, related, nav, search }
|
|
826
|
+
: undefined,
|
|
827
|
+
};
|
|
828
|
+
},
|
|
829
|
+
|
|
830
|
+
"not-found": () => {
|
|
831
|
+
const notfound = params.notfound ?? {};
|
|
832
|
+
const hero = cdnUrl(site, notfound.heroimage || "images/banners/default.webp");
|
|
833
|
+
const links = [];
|
|
834
|
+
let skipped = 0;
|
|
835
|
+
for (const link of notfound.links ?? []) {
|
|
836
|
+
const internal = !isAbsoluteUrl(link.url);
|
|
837
|
+
const url = siteUrl(site, link.url);
|
|
838
|
+
if (internal && site.pageExists && !site.pageExists(url)) {
|
|
839
|
+
emit({
|
|
840
|
+
severity: "warning",
|
|
841
|
+
page: relUrl(site, "404.html"),
|
|
842
|
+
param: ["notfound", "links", links.length + skipped, "url"],
|
|
843
|
+
message: `the page-not-found links to ${url}, which the site does not publish, so the link is left out`,
|
|
844
|
+
});
|
|
845
|
+
skipped++;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
links.push({ url: safeUrl(url), title: text(link.title), text: text(link.text) });
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
heroUrl: hero,
|
|
852
|
+
tagline: text(notfound.tagline) || "This site has no page at",
|
|
853
|
+
noun: text(notfound.sitenoun) || "site",
|
|
854
|
+
links: listOf(links),
|
|
855
|
+
};
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Which layout draws a page.
|
|
861
|
+
*
|
|
862
|
+
* @param {object} page - The page.
|
|
863
|
+
* @returns {string} A name from {@link LAYOUTS}.
|
|
864
|
+
*/
|
|
865
|
+
const layoutFor = (page) => {
|
|
866
|
+
if (page.layout) return page.layout;
|
|
867
|
+
if (page.kind === "home") return "home";
|
|
868
|
+
if (page.kind === "404") return "not-found";
|
|
869
|
+
if (page.kind === "section" && site.feed?.section && page.section === site.feed.section)
|
|
870
|
+
return "blog-list";
|
|
871
|
+
if (["section", "taxonomy", "term"].includes(page.kind)) return "list";
|
|
872
|
+
if (isPost(page.params)) return "blog-post";
|
|
873
|
+
return "page";
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Draw a page as a complete document.
|
|
878
|
+
*
|
|
879
|
+
* @param {object} page - The page; see the module description.
|
|
880
|
+
* @returns {string} The HTML.
|
|
881
|
+
*/
|
|
882
|
+
const render = (page) => {
|
|
883
|
+
const name = layoutFor(page);
|
|
884
|
+
const layout = compiled[name];
|
|
885
|
+
if (!layout) throw new Error(`the site theme has no layout named "${name}"`);
|
|
886
|
+
const view = views[name](page);
|
|
887
|
+
const isHome = page.kind === "home";
|
|
888
|
+
const common = chrome(page);
|
|
889
|
+
const body = layout({ ...view, site: common });
|
|
890
|
+
const description = text(page.params?.description) || text(params.description);
|
|
891
|
+
return compiled.base({
|
|
892
|
+
site: common,
|
|
893
|
+
head: {
|
|
894
|
+
title: isHome ? text(site.title) : `${text(page.title)} — ${text(site.title)}`,
|
|
895
|
+
description,
|
|
896
|
+
},
|
|
897
|
+
body,
|
|
898
|
+
});
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Draw one partial on its own, such as the copyable address a note can hold.
|
|
903
|
+
*
|
|
904
|
+
* @param {string} name - The partial's file name without its extension.
|
|
905
|
+
* @param {object} [context] - What the partial is given.
|
|
906
|
+
* @returns {string} The HTML.
|
|
907
|
+
* @throws {Error} When the theme has no partial of that name.
|
|
908
|
+
*/
|
|
909
|
+
const partial = (name, context = {}) => {
|
|
910
|
+
const source = hb.partials[name];
|
|
911
|
+
if (source === undefined) throw new Error(`the site theme has no partial named "${name}"`);
|
|
912
|
+
return hb.compile(source)(context);
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
return { render, layoutFor, partial, findings };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The files the layouts link that the theme generates.
|
|
920
|
+
*
|
|
921
|
+
* @returns {Array<{path: string, content: string}>} Each file's path under the
|
|
922
|
+
* site and its contents.
|
|
923
|
+
*/
|
|
924
|
+
export function siteThemeAssets() {
|
|
925
|
+
return [{ path: "css/code.css", content: siteCodeStylesheet() }];
|
|
926
|
+
}
|