@heroiclands/package-build 24.0.0 → 24.0.2
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 +28 -0
- package/assets/preview/gitea.ini +1 -1
- package/assets/styles/content-base.css +33 -0
- package/bin/content-commands.mjs +9 -7
- package/docs/api.md +6 -5
- package/docs/authoring/links-and-markup.md +164 -16
- package/docs/commands.md +23 -19
- package/docs/configuration.md +16 -0
- package/docs/content-format.md +4 -2
- package/docs/guides/site.md +43 -0
- package/docs/guides/writing-in-invented-scripts.md +68 -6
- package/docs/reference/format-details.md +143 -49
- package/docs/reference/note-types.md +47 -28
- package/docs/source-preview.md +2 -2
- package/engine/content-blocks.mjs +6 -2
- package/engine/content-format.yaml +2 -0
- package/engine/content-index.mjs +5 -1
- package/engine/content-render.mjs +2 -1
- package/engine/content-scripts.mjs +268 -21
- package/engine/content-scroll.mjs +389 -0
- package/engine/font-outlines.mjs +394 -0
- package/engine/language-data.mjs +154 -63
- package/engine/language-rows.mjs +32 -6
- package/engine/language-terms.mjs +32 -8
- package/engine/map-lexicon.mjs +31 -6
- package/engine/note-vocabulary.mjs +58 -6
- package/engine/pdf-fonts.mjs +1 -1
- package/engine/pdf-render.mjs +52 -11
- package/engine/run-rows.mjs +45 -3
- package/engine/script-direction.mjs +67 -0
- package/engine/script-emphasis-terms.mjs +11 -3
- package/engine/script-font-delivery.mjs +42 -9
- package/engine/script-fonts.mjs +7 -0
- package/engine/site-build.mjs +8 -1
- package/engine/site-reach.mjs +213 -0
- package/engine/site-render.mjs +120 -45
- package/engine/site-root.mjs +68 -26
- package/engine/site-serve.mjs +2 -1
- package/engine/site-theme.mjs +19 -6
- package/engine/sql-tables.mjs +9 -6
- package/engine/verse-metre.mjs +1 -0
- package/engine/written-in.mjs +208 -0
- package/package.json +1 -1
- package/site-theme/static/css/style.css +7 -0
- package/types/engine/content-scripts.d.mts +31 -6
- package/types/engine/content-scroll.d.mts +179 -0
- package/types/engine/font-outlines.d.mts +67 -0
- package/types/engine/language-data.d.mts +13 -0
- package/types/engine/language-rows.d.mts +4 -3
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/map-lexicon.d.mts +4 -3
- package/types/engine/pdf-fonts.d.mts +10 -0
- package/types/engine/run-rows.d.mts +28 -6
- package/types/engine/script-direction.d.mts +10 -0
- package/types/engine/script-emphasis-terms.d.mts +10 -3
- package/types/engine/script-font-delivery.d.mts +5 -3
- package/types/engine/script-fonts.d.mts +9 -0
- package/types/engine/site-reach.d.mts +76 -0
- package/types/engine/site-render.d.mts +4 -2
- package/types/engine/site-root.d.mts +2 -0
- package/types/engine/written-in.d.mts +76 -0
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* **Delivering the installed faces to each target.** A consumer that installs
|
|
16
|
-
* `@heroiclands/fonts` and sets text in one of its faces with `font=`
|
|
17
|
-
* fonts on the site, in the Foundry
|
|
18
|
-
* nothing ships and nothing links to it.
|
|
16
|
+
* `@heroiclands/fonts` and sets text in one of its faces, with `font=` or by a
|
|
17
|
+
* language note's `data.writtenIn`, gets the fonts on the site, in the Foundry
|
|
18
|
+
* module and in its books; without both, nothing ships and nothing links to it.
|
|
19
19
|
*
|
|
20
20
|
* - **Site and Foundry** receive `fonts.css`, listing only the WOFF2 sources,
|
|
21
21
|
* the WOFF2 files and `LICENSE`.
|
|
@@ -27,9 +27,12 @@
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
|
|
30
|
+
import YAML from "yaml";
|
|
31
|
+
|
|
30
32
|
import { baseStyleLayer } from "./base-styles.mjs";
|
|
31
33
|
import { scanRuns } from "./content-scripts.mjs";
|
|
32
34
|
import { FONTS_MANIFEST, resolveScriptFonts } from "./script-fonts.mjs";
|
|
35
|
+
import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Where the files land in the staged Foundry module, relative to its root.
|
|
@@ -65,10 +68,37 @@ const LICENSE = "LICENSE";
|
|
|
65
68
|
/** The faces each configuration's notes set text in, found once. */
|
|
66
69
|
const usedFaces = new WeakMap();
|
|
67
70
|
|
|
71
|
+
/** A note's frontmatter block. */
|
|
72
|
+
const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The face a language note names in `data.writtenIn`, where it is installed.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} source - The note's file.
|
|
78
|
+
* @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
|
|
79
|
+
* @returns {string|undefined} The face's slug.
|
|
80
|
+
*/
|
|
81
|
+
function writtenInFace(source, installed) {
|
|
82
|
+
const yaml = FRONTMATTER.exec(source)?.[1];
|
|
83
|
+
if (yaml === undefined) return undefined;
|
|
84
|
+
let fm;
|
|
85
|
+
try {
|
|
86
|
+
fm = YAML.parse(yaml);
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (fm?.type !== "skill" || String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
|
|
91
|
+
return undefined;
|
|
92
|
+
const font = fm.data?.writtenIn?.font;
|
|
93
|
+
return typeof font === "string" && installed.faces.has(font) ? font : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
/**
|
|
69
|
-
* The faces the package's notes set text in
|
|
70
|
-
*
|
|
71
|
-
*
|
|
97
|
+
* The faces the package's notes set text in, read from the notes
|
|
98
|
+
* themselves: every Markdown file under the content tree that writes `font=`
|
|
99
|
+
* is scanned for its runs, and the face every language note names in
|
|
100
|
+
* `data.writtenIn` counts, since a run marked with the language alone is set
|
|
101
|
+
* in it.
|
|
72
102
|
*
|
|
73
103
|
* @param {DeliveryConfig} config - The resolved configuration.
|
|
74
104
|
* @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
|
|
@@ -90,10 +120,13 @@ export function facesUsed(config, installed) {
|
|
|
90
120
|
if (entry.isDirectory()) walk(full);
|
|
91
121
|
else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
92
122
|
const source = fs.readFileSync(full, "utf8");
|
|
123
|
+
if (/^\s*writtenIn:/m.test(source)) {
|
|
124
|
+
const face = writtenInFace(source, installed);
|
|
125
|
+
if (face) used.add(face);
|
|
126
|
+
}
|
|
93
127
|
if (!/\bfont=/.test(source)) continue;
|
|
94
|
-
const body = source.replace(
|
|
95
|
-
|
|
96
|
-
(frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
|
|
128
|
+
const body = source.replace(FRONTMATTER, (frontmatter) =>
|
|
129
|
+
frontmatter.replace(/[^\r\n]/g, " "),
|
|
97
130
|
);
|
|
98
131
|
const ctx = { languages: config.languages ?? {}, faces: installed.faces };
|
|
99
132
|
for (const run of scanRuns(body, ctx).runs)
|
package/engine/script-fonts.mjs
CHANGED
|
@@ -68,6 +68,9 @@ export const FONTS_MANIFEST = "fonts.json";
|
|
|
68
68
|
* @property {string} direction - `ltr` or `rtl`.
|
|
69
69
|
* @property {string[]} features - OpenType features it turns on.
|
|
70
70
|
* @property {ReadonlySet<number>} draws - Every code point it draws.
|
|
71
|
+
* @property {string|undefined} file - The absolute path of its TrueType file,
|
|
72
|
+
* which the book draws a `scroll` fence's outlines from; `undefined` when the
|
|
73
|
+
* manifest lists none.
|
|
71
74
|
*/
|
|
72
75
|
|
|
73
76
|
/**
|
|
@@ -164,6 +167,10 @@ export function resolveScriptFonts(rootDir) {
|
|
|
164
167
|
direction: entry.direction === "rtl" ? "rtl" : "ltr",
|
|
165
168
|
features: Object.freeze([...(entry.features ?? [])]),
|
|
166
169
|
draws,
|
|
170
|
+
file:
|
|
171
|
+
typeof entry.files?.ttf === "string" ?
|
|
172
|
+
path.join(root, entry.files.ttf)
|
|
173
|
+
: undefined,
|
|
167
174
|
}),
|
|
168
175
|
);
|
|
169
176
|
}
|
package/engine/site-build.mjs
CHANGED
|
@@ -99,6 +99,7 @@ import { checkHomepageCount, homepageFrontmatter, homepageTitle, isHomepage } fr
|
|
|
99
99
|
import { publishesContentPages, siteBase } from "../content-config.mjs";
|
|
100
100
|
import { seeAlsoPages } from "./see-also.mjs";
|
|
101
101
|
import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
|
|
102
|
+
import { GENERATED_MARK } from "./site-reach.mjs";
|
|
102
103
|
import { drawSiteMaps, inlineDrawingFigure } from "./site-maps.mjs";
|
|
103
104
|
import { generatedDrawing } from "./generated-sections.mjs";
|
|
104
105
|
import { IMAGE_PATTERN } from "./content-images.mjs";
|
|
@@ -784,7 +785,13 @@ export function renderSitePage(
|
|
|
784
785
|
const tableErrors = expanded.findings.tables;
|
|
785
786
|
const expressionErrors = expanded.findings.expressions;
|
|
786
787
|
const wikiErrors = expanded.findings.links;
|
|
787
|
-
|
|
788
|
+
// The author's text and the sections the build generated after it are
|
|
789
|
+
// told apart by a comment, so the site writer can leave the generated
|
|
790
|
+
// links out of what makes a page reachable.
|
|
791
|
+
const resolvedBody =
|
|
792
|
+
expanded.generated ?
|
|
793
|
+
`${expanded.authored}\n\n${GENERATED_MARK}\n\n${expanded.generated}`
|
|
794
|
+
: expanded.markdown;
|
|
788
795
|
for (const error of expanded.findings.captions)
|
|
789
796
|
captionErrors.push({
|
|
790
797
|
file: page.file,
|
|
@@ -0,0 +1,213 @@
|
|
|
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 pages a reader can reach.** Every note is written as a page, but what
|
|
16
|
+
* a reader finds is what the homepage leads to: the sitemap and the search
|
|
17
|
+
* index cover the pages reachable from the homepage by the links the rendered
|
|
18
|
+
* HTML carries, and no others. An unlinked page is still served at its
|
|
19
|
+
* address, so an author reads a page in place before linking it to publish it.
|
|
20
|
+
*
|
|
21
|
+
* Reachability is read from the rendered pages, never from the source notes:
|
|
22
|
+
* the `<a>` and `<link>` targets that lie inside the site's base path, the
|
|
23
|
+
* menu and the chrome included. A link that lands on a feed makes the pages
|
|
24
|
+
* the feed lists reachable too, because a feed is a list of links.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A URL with a scheme, or a protocol-relative one: not a path inside the site. */
|
|
30
|
+
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
31
|
+
|
|
32
|
+
/** An `<a>` or `<link>` and its `href`, quoted either way or bare. */
|
|
33
|
+
const LINK = /<(?:a|link)\b[^>]*?\shref=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The internal targets a page links.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} html - A rendered page.
|
|
39
|
+
* @param {string} from - The page's own address, beginning with `/`.
|
|
40
|
+
* @param {string} base - The site's base path, beginning and ending with `/`.
|
|
41
|
+
* @returns {string[]} Absolute paths inside the base, without fragment or query.
|
|
42
|
+
*/
|
|
43
|
+
export function internalLinks(html, from, base) {
|
|
44
|
+
const found = [];
|
|
45
|
+
for (const match of html.matchAll(LINK)) {
|
|
46
|
+
const href = (match[1] ?? match[2] ?? match[3] ?? "").trim().replace(/&/g, "&");
|
|
47
|
+
if (!href || href.startsWith("#") || EXTERNAL.test(href)) continue;
|
|
48
|
+
let pathname;
|
|
49
|
+
try {
|
|
50
|
+
pathname = new URL(href, `http://site.invalid${from}`).pathname;
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (pathname.startsWith(base)) found.push(decodeURI(pathname));
|
|
55
|
+
}
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The address a link to a file names a page by: `…/index.html` is its
|
|
61
|
+
* directory, and a bare directory ends in a slash.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} pathname - An absolute path.
|
|
64
|
+
* @returns {string} The page address it would be keyed by.
|
|
65
|
+
*/
|
|
66
|
+
export function pageAddress(pathname) {
|
|
67
|
+
if (pathname.endsWith("/index.html")) return pathname.slice(0, -"index.html".length);
|
|
68
|
+
return pathname.endsWith("/") || /\.[a-z0-9]+$/i.test(pathname) ? pathname : `${pathname}/`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The pages reachable from the homepage.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} options - The inputs.
|
|
75
|
+
* @param {string} options.base - The site's base path, beginning and ending
|
|
76
|
+
* with `/`; the homepage is at the base.
|
|
77
|
+
* @param {(url: string) => string|undefined} options.html - A page's rendered
|
|
78
|
+
* HTML by its address, or `undefined` where the site has no such page.
|
|
79
|
+
* @param {(pathname: string) => readonly string[]} [options.expand] - The page
|
|
80
|
+
* addresses a linked non-page file, such as a feed, lists.
|
|
81
|
+
* @returns {Set<string>} The address of every reachable page, the homepage
|
|
82
|
+
* included.
|
|
83
|
+
*/
|
|
84
|
+
export function reachablePages({ base, html, expand = () => [] }) {
|
|
85
|
+
/** @type {Set<string>} */
|
|
86
|
+
const reached = new Set();
|
|
87
|
+
const queue = [base];
|
|
88
|
+
const seenFiles = new Set();
|
|
89
|
+
while (queue.length) {
|
|
90
|
+
const url = queue.pop();
|
|
91
|
+
if (reached.has(url)) continue;
|
|
92
|
+
const body = html(url);
|
|
93
|
+
if (body === undefined) continue;
|
|
94
|
+
reached.add(url);
|
|
95
|
+
for (const target of internalLinks(body, url, base)) {
|
|
96
|
+
const address = pageAddress(target);
|
|
97
|
+
if (html(address) !== undefined) {
|
|
98
|
+
queue.push(address);
|
|
99
|
+
} else if (!seenFiles.has(target)) {
|
|
100
|
+
seenFiles.add(target);
|
|
101
|
+
for (const listed of expand(target)) queue.push(pageAddress(listed));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return reached;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The page addresses an Atom feed lists, as paths.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} xml - The feed.
|
|
112
|
+
* @returns {string[]} The `href` of every `<link>` that is an absolute URL,
|
|
113
|
+
* as its path.
|
|
114
|
+
*/
|
|
115
|
+
export function feedPaths(xml) {
|
|
116
|
+
const paths = [];
|
|
117
|
+
for (const match of xml.matchAll(/<link\b[^>]*?\shref="([^"]*)"/g)) {
|
|
118
|
+
try {
|
|
119
|
+
paths.push(new URL(match[1].replace(/&/g, "&")).pathname);
|
|
120
|
+
} catch {
|
|
121
|
+
// A relative or malformed link names no page.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return paths;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The comment that separates a page's authored text from the sections the
|
|
129
|
+
* build generated after it, in the HTML the renderer writes.
|
|
130
|
+
*
|
|
131
|
+
* @type {string}
|
|
132
|
+
*/
|
|
133
|
+
export const GENERATED_MARK = "<!--generated-sections-->";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A page body split at {@link GENERATED_MARK}.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} html - A page body.
|
|
139
|
+
* @returns {{authored: string, generated: string}} The author's text, and the
|
|
140
|
+
* generated sections after it, `""` where there are none.
|
|
141
|
+
*/
|
|
142
|
+
export function splitContent(html) {
|
|
143
|
+
const text = String(html ?? "");
|
|
144
|
+
const at = text.indexOf(GENERATED_MARK);
|
|
145
|
+
if (at < 0) return { authored: text, generated: "" };
|
|
146
|
+
return { authored: text.slice(0, at), generated: text.slice(at + GENERATED_MARK.length) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** An `<a>` element with an `href`, whole. */
|
|
150
|
+
const ANCHOR = /<a\b[^>]*?\shref=(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>[\s\S]*?<\/a>/gi;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generated sections with the entries that lead outside a set of pages taken
|
|
154
|
+
* out.
|
|
155
|
+
*
|
|
156
|
+
* An entry is a list item, a table row, or a link in a paragraph's comma
|
|
157
|
+
* separated run; a paragraph, list or table left with no entry goes, and so
|
|
158
|
+
* does a section left with nothing under its heading. A link to anything that
|
|
159
|
+
* is not a page of the site is kept, and so is text that was never a link.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} html - The generated sections, rendered.
|
|
162
|
+
* @param {(href: string) => boolean} outside - Whether an `href` names a page
|
|
163
|
+
* of the site that is not reachable.
|
|
164
|
+
* @returns {string} The sections, or `""` when nothing is left.
|
|
165
|
+
*/
|
|
166
|
+
export function pruneGenerated(html, outside) {
|
|
167
|
+
const dead = (anchor) => {
|
|
168
|
+
const href = new RegExp(ANCHOR.source, "i").exec(anchor);
|
|
169
|
+
const value = href ? (href[1] ?? href[2] ?? href[3] ?? "").replace(/&/g, "&") : "";
|
|
170
|
+
return value !== "" && outside(value);
|
|
171
|
+
};
|
|
172
|
+
const holdsDead = (fragment) => [...fragment.matchAll(ANCHOR)].some((match) => dead(match[0]));
|
|
173
|
+
|
|
174
|
+
// Each section runs from its heading to the next one.
|
|
175
|
+
const parts = String(html ?? "").split(/(?=<h1\b)/);
|
|
176
|
+
const kept = parts.map((part) => {
|
|
177
|
+
if (!holdsDead(part)) return part;
|
|
178
|
+
let out = part
|
|
179
|
+
.replace(/<li\b[^>]*>(?:(?!<\/li>)[\s\S])*<\/li>\s*/g, (item) =>
|
|
180
|
+
holdsDead(item) ? "" : item,
|
|
181
|
+
)
|
|
182
|
+
.replace(/<tr\b[^>]*>(?:(?!<\/tr>)[\s\S])*<\/tr>\s*/g, (row) =>
|
|
183
|
+
holdsDead(row) ? "" : row,
|
|
184
|
+
)
|
|
185
|
+
.replace(/<p\b[^>]*>(?:(?!<\/p>)[\s\S])*<\/p>\s*/g, (paragraph) => {
|
|
186
|
+
if (!holdsDead(paragraph)) return paragraph;
|
|
187
|
+
const trimmed = paragraph.replace(ANCHOR, (anchor) =>
|
|
188
|
+
dead(anchor) ? "\u0000" : anchor,
|
|
189
|
+
);
|
|
190
|
+
const entries = trimmed
|
|
191
|
+
.replace(/<\/?p\b[^>]*>/g, "")
|
|
192
|
+
.replace(/^\s*<strong>[^<]*<\/strong>/, "")
|
|
193
|
+
.replace(/\u0000/g, "")
|
|
194
|
+
.replace(/[\s,]+/g, " ")
|
|
195
|
+
.replace(/<[^>]*>/g, "")
|
|
196
|
+
.trim();
|
|
197
|
+
if (!entries) return "";
|
|
198
|
+
return trimmed
|
|
199
|
+
.replace(/\u0000,\s*/g, "")
|
|
200
|
+
.replace(/,\s*\u0000/g, "")
|
|
201
|
+
.replace(/\u0000/g, "");
|
|
202
|
+
});
|
|
203
|
+
out = out
|
|
204
|
+
.replace(/<(ul|ol)\b[^>]*>\s*<\/\1>\s*/g, "")
|
|
205
|
+
.replace(/<tbody\b[^>]*>\s*<\/tbody>\s*/g, "")
|
|
206
|
+
.replace(/<table\b[^>]*>(?:(?!<tr\b)[\s\S])*<\/table>\s*/g, (table) =>
|
|
207
|
+
/<tr\b/.test(table) ? table : "",
|
|
208
|
+
);
|
|
209
|
+
const rest = out.replace(/^<h1\b[^>]*>[\s\S]*?<\/h1>/, "").replace(/<!--[\s\S]*?-->/g, "");
|
|
210
|
+
return rest.trim() === "" ? "" : out;
|
|
211
|
+
});
|
|
212
|
+
return kept.join("");
|
|
213
|
+
}
|
package/engine/site-render.mjs
CHANGED
|
@@ -50,6 +50,12 @@
|
|
|
50
50
|
* | `feed: {section, title}` | `<section>/atom.xml`, the section's pages, newest first |
|
|
51
51
|
* | `sitemaps: [...]` | `sitemap-index.xml`, naming this site's sitemap and each one listed, and `robots.txt` naming them all |
|
|
52
52
|
*
|
|
53
|
+
* Every page is written, but `sitemap.xml` lists only the pages reachable from
|
|
54
|
+
* the homepage by the links the rendered pages carry; see
|
|
55
|
+
* {@link module:engine/site-reach}. A feed is a link, so the pages it lists
|
|
56
|
+
* are reachable when a page links the feed, and a section or tag page lists
|
|
57
|
+
* pages it links to the same way.
|
|
58
|
+
*
|
|
53
59
|
* A sitemap index and `robots.txt` belong at the root of an origin, so a site
|
|
54
60
|
* served under a path refuses `sitemaps`.
|
|
55
61
|
*
|
|
@@ -76,6 +82,7 @@ import {
|
|
|
76
82
|
import { buildSite, gatesFailed } from "./site-build.mjs";
|
|
77
83
|
import { readCachedNavigation, siteSettings } from "./site-config.mjs";
|
|
78
84
|
import { atomFeed, robotsTxt, sitemapIndexXml, sitemapXml } from "./site-files.mjs";
|
|
85
|
+
import { pageAddress, pruneGenerated, reachablePages, splitContent } from "./site-reach.mjs";
|
|
79
86
|
import { prepareTreeSqlTables } from "./sql-tables.mjs";
|
|
80
87
|
import { placeGeography, searchMetadata } from "./site-search.mjs";
|
|
81
88
|
import {
|
|
@@ -298,8 +305,9 @@ export function writeThemeFiles(out, staticDir = SITE_STATIC_DIR) {
|
|
|
298
305
|
* @param {string} [options.staticDir] - The theme's own files, copied first.
|
|
299
306
|
* @param {ReadonlySet<string>} [options.only] - The addresses of the pages to
|
|
300
307
|
* draw again into a site already written. Nothing else is touched.
|
|
301
|
-
* @returns {{files: string[], pages: string[],
|
|
302
|
-
* written, the address of every page,
|
|
308
|
+
* @returns {{files: string[], pages: string[], reachable: string[],
|
|
309
|
+
* findings: object[]}} Every file written, the address of every page, the
|
|
310
|
+
* address of each page reachable from the homepage, and the layouts' findings.
|
|
303
311
|
* @throws {Error} When a page's address is outside the site, or a site served
|
|
304
312
|
* under a path asks for a sitemap index.
|
|
305
313
|
*/
|
|
@@ -311,32 +319,95 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
|
|
|
311
319
|
const tree = siteTree(site, pages);
|
|
312
320
|
const published = new Set(tree.map((page) => page.url));
|
|
313
321
|
const feedUrl = site.feed ? relUrl(site, `${site.feed.section}/atom.xml`) : "";
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
322
|
+
const base = basePath(site);
|
|
323
|
+
const origin = (url) => new URL(url, site.baseURL).href;
|
|
324
|
+
|
|
325
|
+
// The address of the page a URL names, or undefined where it names none of
|
|
326
|
+
// this site's.
|
|
327
|
+
const pageOfUrl = (url) => {
|
|
328
|
+
let pathname;
|
|
329
|
+
try {
|
|
330
|
+
const resolved = new URL(String(url), origin(base));
|
|
331
|
+
if (resolved.origin !== new URL(site.baseURL).origin) return undefined;
|
|
332
|
+
pathname = decodeURI(resolved.pathname);
|
|
333
|
+
} catch {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
const address = pageAddress(pathname);
|
|
337
|
+
return published.has(address) ? address : undefined;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// A page is drawn twice. The first draw has no generated link to a page,
|
|
341
|
+
// and the pages it links are the reachable ones; the second draws the
|
|
342
|
+
// generated sections again with the links that stay inside that set.
|
|
343
|
+
const makeTheme = ({ visible, contentOf, report: sink }) =>
|
|
344
|
+
createSiteTheme({
|
|
345
|
+
site: {
|
|
346
|
+
...site,
|
|
347
|
+
visible,
|
|
348
|
+
feed:
|
|
349
|
+
site.feed ?
|
|
350
|
+
{
|
|
351
|
+
title: site.feed.title ?? site.title,
|
|
352
|
+
url: feedUrl,
|
|
353
|
+
section: text(site.feed.section),
|
|
354
|
+
}
|
|
355
|
+
: undefined,
|
|
356
|
+
pages: tree
|
|
357
|
+
.filter((page) => page.kind === "page")
|
|
358
|
+
.map((page) => ({
|
|
359
|
+
url: page.url,
|
|
360
|
+
title: page.title,
|
|
361
|
+
section: text(page.section),
|
|
362
|
+
type: page.params?.type,
|
|
363
|
+
subType: page.params?.subType,
|
|
364
|
+
date: page.date ?? page.params?.date,
|
|
365
|
+
content: contentOf(page),
|
|
366
|
+
})),
|
|
367
|
+
pageExists: (url) =>
|
|
368
|
+
published.has(url) || published.has(`${url.replace(/\/?$/, "/")}`),
|
|
369
|
+
},
|
|
370
|
+
report: sink,
|
|
371
|
+
});
|
|
372
|
+
const authoredOf = (page) => splitContent(page.content).authored;
|
|
373
|
+
const first = makeTheme({
|
|
374
|
+
visible: (url) => pageOfUrl(url) === undefined,
|
|
375
|
+
contentOf: authoredOf,
|
|
376
|
+
});
|
|
377
|
+
const firstPass = new Map();
|
|
378
|
+
for (const page of tree)
|
|
379
|
+
firstPass.set(
|
|
380
|
+
page.url,
|
|
381
|
+
first.render({
|
|
382
|
+
...page,
|
|
383
|
+
content: page.content === undefined ? undefined : authoredOf(page),
|
|
384
|
+
permalink: origin(page.url),
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const section = site.feed ? text(site.feed.section) : "";
|
|
389
|
+
const time = (page) => new Date(text(page.date ?? page.params?.date)).getTime() || 0;
|
|
390
|
+
const posts =
|
|
391
|
+
site.feed ?
|
|
392
|
+
tree
|
|
393
|
+
.filter((page) => page.kind === "page" && text(page.section) === section)
|
|
394
|
+
.sort((a, b) => time(b) - time(a))
|
|
395
|
+
: [];
|
|
396
|
+
const reachable = reachablePages({
|
|
397
|
+
base,
|
|
398
|
+
html: (url) => firstPass.get(url),
|
|
399
|
+
expand: (target) => (site.feed && target === feedUrl ? posts.map((post) => post.url) : []),
|
|
339
400
|
});
|
|
401
|
+
const outside = (url) => {
|
|
402
|
+
const address = pageOfUrl(url);
|
|
403
|
+
return address !== undefined && !reachable.has(address);
|
|
404
|
+
};
|
|
405
|
+
const contentOf = (page) => {
|
|
406
|
+
if (page.content === undefined) return page.content;
|
|
407
|
+
const { authored, generated } = splitContent(page.content);
|
|
408
|
+
return authored + pruneGenerated(generated, outside);
|
|
409
|
+
};
|
|
410
|
+
const theme = makeTheme({ visible: (url) => !outside(url), contentOf, report });
|
|
340
411
|
|
|
341
412
|
const files = [];
|
|
342
413
|
const write = (file, content) => {
|
|
@@ -344,15 +415,22 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
|
|
|
344
415
|
fs.writeFileSync(file, content);
|
|
345
416
|
files.push(file);
|
|
346
417
|
};
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
pageFile(site, out, page.url),
|
|
351
|
-
minifyHtml(theme.render({ ...page, permalink: origin(page.url) })),
|
|
418
|
+
const draw = (page) => {
|
|
419
|
+
const html = minifyHtml(
|
|
420
|
+
theme.render({ ...page, content: contentOf(page), permalink: origin(page.url) }),
|
|
352
421
|
);
|
|
422
|
+
write(pageFile(site, out, page.url), html);
|
|
423
|
+
};
|
|
424
|
+
const result = (extra = {}) => ({
|
|
425
|
+
files,
|
|
426
|
+
pages: tree.map((page) => page.url),
|
|
427
|
+
reachable: [...reachable],
|
|
428
|
+
findings: theme.findings,
|
|
429
|
+
...extra,
|
|
430
|
+
});
|
|
353
431
|
if (only) {
|
|
354
432
|
for (const page of tree) if (only.has(page.url)) draw(page);
|
|
355
|
-
return
|
|
433
|
+
return result();
|
|
356
434
|
}
|
|
357
435
|
|
|
358
436
|
fs.rmSync(out, { recursive: true, force: true });
|
|
@@ -373,19 +451,16 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
|
|
|
373
451
|
write(
|
|
374
452
|
path.join(out, "sitemap.xml"),
|
|
375
453
|
sitemapXml(
|
|
376
|
-
tree
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
454
|
+
tree
|
|
455
|
+
.filter((page) => reachable.has(page.url))
|
|
456
|
+
.map((page) => ({
|
|
457
|
+
loc: origin(page.url),
|
|
458
|
+
lastmod: page.params?.lastmod ?? page.date ?? page.params?.date,
|
|
459
|
+
})),
|
|
380
460
|
),
|
|
381
461
|
);
|
|
382
462
|
|
|
383
463
|
if (site.feed) {
|
|
384
|
-
const section = text(site.feed.section);
|
|
385
|
-
const time = (page) => new Date(text(page.date ?? page.params?.date)).getTime() || 0;
|
|
386
|
-
const posts = tree
|
|
387
|
-
.filter((page) => page.kind === "page" && text(page.section) === section)
|
|
388
|
-
.sort((a, b) => time(b) - time(a));
|
|
389
464
|
write(
|
|
390
465
|
path.join(out, ...section.split("/").filter(Boolean), "atom.xml"),
|
|
391
466
|
atomFeed({
|
|
@@ -394,7 +469,7 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
|
|
|
394
469
|
self: origin(feedUrl),
|
|
395
470
|
author: site.params?.author,
|
|
396
471
|
entries: posts.map((post) => {
|
|
397
|
-
const words = plainText(post
|
|
472
|
+
const words = plainText(contentOf(post)).split(" ").filter(Boolean);
|
|
398
473
|
return {
|
|
399
474
|
title: post.title,
|
|
400
475
|
url: origin(post.url),
|
|
@@ -405,7 +480,7 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
|
|
|
405
480
|
(words.length > SUMMARY_WORDS ?
|
|
406
481
|
`${words.slice(0, SUMMARY_WORDS).join(" ")}…`
|
|
407
482
|
: words.join(" ")),
|
|
408
|
-
content: post
|
|
483
|
+
content: contentOf(post),
|
|
409
484
|
};
|
|
410
485
|
}),
|
|
411
486
|
}),
|
|
@@ -420,7 +495,7 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
|
|
|
420
495
|
write(path.join(out, "robots.txt"), robotsTxt([index, ...all]));
|
|
421
496
|
}
|
|
422
497
|
|
|
423
|
-
return
|
|
498
|
+
return result();
|
|
424
499
|
}
|
|
425
500
|
|
|
426
501
|
/**
|