@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +9 -7
  5. package/docs/api.md +6 -5
  6. package/docs/authoring/links-and-markup.md +164 -16
  7. package/docs/commands.md +23 -19
  8. package/docs/configuration.md +16 -0
  9. package/docs/content-format.md +4 -2
  10. package/docs/guides/site.md +43 -0
  11. package/docs/guides/writing-in-invented-scripts.md +68 -6
  12. package/docs/reference/format-details.md +143 -49
  13. package/docs/reference/note-types.md +47 -28
  14. package/docs/source-preview.md +2 -2
  15. package/engine/content-blocks.mjs +6 -2
  16. package/engine/content-format.yaml +2 -0
  17. package/engine/content-index.mjs +5 -1
  18. package/engine/content-render.mjs +2 -1
  19. package/engine/content-scripts.mjs +268 -21
  20. package/engine/content-scroll.mjs +389 -0
  21. package/engine/font-outlines.mjs +394 -0
  22. package/engine/language-data.mjs +154 -63
  23. package/engine/language-rows.mjs +32 -6
  24. package/engine/language-terms.mjs +32 -8
  25. package/engine/map-lexicon.mjs +31 -6
  26. package/engine/note-vocabulary.mjs +58 -6
  27. package/engine/pdf-fonts.mjs +1 -1
  28. package/engine/pdf-render.mjs +52 -11
  29. package/engine/run-rows.mjs +45 -3
  30. package/engine/script-direction.mjs +67 -0
  31. package/engine/script-emphasis-terms.mjs +11 -3
  32. package/engine/script-font-delivery.mjs +42 -9
  33. package/engine/script-fonts.mjs +7 -0
  34. package/engine/site-build.mjs +8 -1
  35. package/engine/site-reach.mjs +213 -0
  36. package/engine/site-render.mjs +120 -45
  37. package/engine/site-root.mjs +68 -26
  38. package/engine/site-serve.mjs +2 -1
  39. package/engine/site-theme.mjs +19 -6
  40. package/engine/sql-tables.mjs +9 -6
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +1 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/engine/content-scripts.d.mts +31 -6
  46. package/types/engine/content-scroll.d.mts +179 -0
  47. package/types/engine/font-outlines.d.mts +67 -0
  48. package/types/engine/language-data.d.mts +13 -0
  49. package/types/engine/language-rows.d.mts +4 -3
  50. package/types/engine/language-terms.d.mts +7 -0
  51. package/types/engine/map-lexicon.d.mts +4 -3
  52. package/types/engine/pdf-fonts.d.mts +10 -0
  53. package/types/engine/run-rows.d.mts +28 -6
  54. package/types/engine/script-direction.d.mts +10 -0
  55. package/types/engine/script-emphasis-terms.d.mts +10 -3
  56. package/types/engine/script-font-delivery.d.mts +5 -3
  57. package/types/engine/script-fonts.d.mts +9 -0
  58. package/types/engine/site-reach.d.mts +76 -0
  59. package/types/engine/site-render.d.mts +4 -2
  60. package/types/engine/site-root.d.mts +2 -0
  61. 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=` gets the
17
- * fonts on the site, in the Foundry module and in its books; without both,
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 with `font=`, read from the
70
- * notes themselves: every Markdown file under the content tree that writes
71
- * `font=` is scanned for its runs.
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
- /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,
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)
@@ -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
  }
@@ -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
- const resolvedBody = expanded.markdown;
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(/&amp;/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(/&amp;/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(/&amp;/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
+ }
@@ -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[], findings: object[]}} Every file
302
- * written, the address of every page, and the layouts' findings.
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 theme = createSiteTheme({
315
- site: {
316
- ...site,
317
- feed:
318
- site.feed ?
319
- {
320
- title: site.feed.title ?? site.title,
321
- url: feedUrl,
322
- section: text(site.feed.section),
323
- }
324
- : undefined,
325
- pages: tree
326
- .filter((page) => page.kind === "page")
327
- .map((page) => ({
328
- url: page.url,
329
- title: page.title,
330
- section: text(page.section),
331
- type: page.params?.type,
332
- subType: page.params?.subType,
333
- date: page.date ?? page.params?.date,
334
- content: page.content,
335
- })),
336
- pageExists: (url) => published.has(url) || published.has(`${url.replace(/\/?$/, "/")}`),
337
- },
338
- report,
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 origin = (url) => new URL(url, site.baseURL).href;
348
- const draw = (page) =>
349
- write(
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 { files, pages: tree.map((page) => page.url), findings: theme.findings };
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.map((page) => ({
377
- loc: origin(page.url),
378
- lastmod: page.params?.lastmod ?? page.date ?? page.params?.date,
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.content).split(" ").filter(Boolean);
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.content,
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 { files, pages: tree.map((page) => page.url), findings: theme.findings };
498
+ return result();
424
499
  }
425
500
 
426
501
  /**