@heroiclands/package-build 24.0.0 → 24.0.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 24.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ **Sites**
8
+
9
+ - The sitemap and site search list only the pages a reader can reach from the homepage, so a page can be published and read at its address before anything links it.
10
+ - Related links, previous and next, and the other generated link sections point only to pages a reader can reach.
11
+ - `site build` finishes writing the search index before it reports success.
12
+
13
+ **Queries**
14
+
15
+ - A query on a note's `data` reads the same however other notes are shaped; adding a field to one note no longer breaks unrelated queries.
16
+
3
17
  ## 24.0.0
4
18
 
5
19
  ### Major Changes
package/docs/commands.md CHANGED
@@ -657,12 +657,14 @@ The rules are scoped to those hostnames, so a site deployed under a domain of
657
657
  its own stays indexable.
658
658
 
659
659
  With `_headers` written, the rendered site is indexed for search. Pagefind
660
- reads every page under `<out>/<contentPackage>/` and writes the index into
660
+ reads the pages under `<out>/<contentPackage>/` that are reachable from the
661
+ homepage (see [the pages a reader finds](guides/site.md#decide-which-pages-a-reader-finds))
662
+ and writes the index into
661
663
  `<out>/<contentPackage>/pagefind/`, so it is deployed with the pages and served
662
664
  at `/<contentPackage>/pagefind/`; the theme loads `pagefind.js` from there,
663
665
  relative to the site's base URL, and the browser bundle derives the prefix from
664
666
  its own address, so nothing about the prefix is configured on either side. What
665
- is indexed is the body of every page and the homepage; the theme marks its own
667
+ is indexed is the body of every reachable page and the homepage; the theme marks its own
666
668
  chrome — header, footer, breadcrumbs, infobox, the related-pages card —
667
669
  `data-pagefind-ignore`, and carries `data-pagefind-filter` attributes for a
668
670
  page's `type` and `package`, so a search can be narrowed to one kind of page.
@@ -2577,7 +2579,7 @@ emptied first:
2577
2579
  `site.sections` names at its section, every other note at
2578
2580
  `<type>-<shortcode>/` — each minified;
2579
2581
  - `404.html`, the page-not-found page `site.notfound` describes;
2580
- - `sitemap.xml`, every page by its full address;
2582
+ - `sitemap.xml`, the full address of every page reachable from the homepage;
2581
2583
  - the theme's stylesheets, scripts, icon font and images, and `css/code.css`;
2582
2584
  - the script fonts the notes set text in, under
2583
2585
  `fonts/heroiclands/<version>/`;
@@ -2595,8 +2597,8 @@ page-not-found page and reported as a warning located at that entry of
2595
2597
  the content tree is an error located at its entry, and writes nothing.
2596
2598
 
2597
2599
  Then, as `package-build site-root` does, `_headers` is written at `<out>/` and
2598
- the pages are indexed for search into `pagefind/` inside the site's directory,
2599
- unless `site.search` is `false`. With `site.mergeIndexes`, the search reads
2600
+ the pages reachable from the homepage are indexed for search into `pagefind/`
2601
+ inside the site's directory, unless `site.search` is `false`. With `site.mergeIndexes`, the search reads
2600
2602
  each package site's index beside its own. What a page tells the search is set
2601
2603
  out in the [site guide](guides/site.md#search).
2602
2604
 
@@ -223,6 +223,49 @@ The build writes these keys; [what the theme draws](../reference/theme-pages.md)
223
223
  describes the panel each one feeds — the infobox, the hero band, the related
224
224
  card, and search — and the generated sections the theme draws as any other.
225
225
 
226
+ ## Decide which pages a reader finds
227
+
228
+ Every note is written as a page, linked or not, and a page is served at its
229
+ address. What a reader can find is what the homepage leads to: the sitemap and
230
+ the site search cover the pages reachable from the homepage, directly or
231
+ through the pages it links, and no others.
232
+
233
+ Reachability is read from the links the rendered pages carry, never from the
234
+ notes' sources. A page is reachable when an authored link leads to it:
235
+
236
+ - a link in the homepage or in the body of a reachable note, including the
237
+ links a `pagelist` or a `sql` table writes into the body;
238
+ - a link in the menu or the rest of the site chrome;
239
+ - a link in a listing: the pages of a section page, a tag page, and the posts
240
+ an Atom feed lists, once a reachable page links the section, the tag or the
241
+ feed. A tag chip on a reachable page links its tag page when `site.tags` is
242
+ on, so every page carrying the tag is then reachable.
243
+
244
+ The links the build generates do not make a page reachable. These are the
245
+ Related card, the previous and next links, the infobox links, and the sections
246
+ generated after a note's text (Within, Governed by, Governed places,
247
+ Chronology, Events, Accounts, What followed, In song and story). Each is drawn
248
+ without a link to a page outside the reachable set, and a section left with
249
+ nothing under it is not drawn. An infobox value that names an unreachable page
250
+ is set as plain text. A link written in prose or produced by a query is kept
251
+ wherever it points, and it is what makes its target reachable.
252
+
253
+ A page that is not reachable is the way to preview a page in place. Write the
254
+ note, build the site, and read the page at its address on the live site; link
255
+ it from the homepage or from a linked page when it is ready to publish. For a
256
+ note `doc-charlie` in the package `setting`:
257
+
258
+ 1. Add `doc-charlie` to the content tree. It is written at
259
+ `/setting/doc-charlie/` and appears in neither the sitemap nor the search.
260
+ 2. Read it at `/setting/doc-charlie/`.
261
+ 3. Link it, for example `[[doc-charlie|Charlie]]` in a linked note, to publish
262
+ it. The next build lists it in the sitemap and the search.
263
+
264
+ An address is a short code and the site serves no directory listing, so an
265
+ unlinked page is found only by someone who knows its address. The content
266
+ index that `package-build content-index` writes is a release asset and is not
267
+ part of the site.
268
+
226
269
  ## Show marked text in its font
227
270
 
228
271
  A site shows text marked with `font=` and one of the faces of
@@ -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
  /**
@@ -51,7 +51,8 @@
51
51
  import fs from "node:fs";
52
52
  import path from "node:path";
53
53
  import * as pagefind from "pagefind";
54
- import { gunzipSync } from "node:zlib";
54
+
55
+ import { feedPaths, reachablePages } from "./site-reach.mjs";
55
56
 
56
57
  import { formatDiagnostic } from "./diagnostics.mjs";
57
58
 
@@ -149,11 +150,40 @@ export function headers({ pkg, base, fonts = false } = {}) {
149
150
  * binary.
150
151
  *
151
152
  * @callback SiteIndexer
152
- * @param {{site: string, output: string}} where - The rendered site, and the
153
- * directory inside it the index is written to.
153
+ * @param {{site: string, output: string, base?: string}} where - The rendered
154
+ * site, the directory inside it the index is written to, and the path the
155
+ * site is served at. With a `base`, only the pages reachable from the
156
+ * homepage are indexed.
154
157
  * @returns {Promise<{pages: number}>} How many pages were indexed.
155
158
  */
156
159
 
160
+ /**
161
+ * The addresses of the pages reachable from a rendered site's homepage.
162
+ *
163
+ * @param {string} site - The rendered site's directory, served at `base`.
164
+ * @param {string} base - The path the site is served at.
165
+ * @returns {Set<string>} Page addresses, each beginning with `base`.
166
+ */
167
+ function reachableFromDisk(site, base) {
168
+ const file = (url) => path.join(site, ...url.slice(base.length).split("/").filter(Boolean));
169
+ const read = (target) => {
170
+ try {
171
+ return fs.readFileSync(target, "utf8");
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ };
176
+ return reachablePages({
177
+ base,
178
+ html: (url) => read(path.join(file(url), "index.html")),
179
+ expand: (target) => {
180
+ if (!target.endsWith(".xml")) return [];
181
+ const xml = read(file(target));
182
+ return xml === undefined ? [] : feedPaths(xml);
183
+ },
184
+ });
185
+ }
186
+
157
187
  /**
158
188
  * Build the search index of a rendered site with Pagefind.
159
189
  *
@@ -169,12 +199,17 @@ export function headers({ pkg, base, fonts = false } = {}) {
169
199
  * - The binary missing is reported by the package as an install failure of
170
200
  * its own; it is rethrown naming the platform package to install.
171
201
  *
202
+ * With a `base`, the index covers the pages reachable from the homepage by
203
+ * the links the rendered HTML carries, and no others: an unlinked page is
204
+ * served but not searchable. Each reachable page is handed to Pagefind as its
205
+ * own file, so a page outside the set is never read.
206
+ *
172
207
  * The output directory is emptied first, so an index of an earlier build's
173
208
  * pages never survives beside the current one.
174
209
  *
175
210
  * @type {SiteIndexer}
176
211
  */
177
- export async function indexSite({ site, output }) {
212
+ export async function indexSite({ site, output, base }) {
178
213
  if (!fs.statSync(site, { throwIfNoEntry: false })?.isDirectory()) {
179
214
  throw new Error(`${site} is not a directory, so there is nothing to index`);
180
215
  }
@@ -201,33 +236,40 @@ export async function indexSite({ site, output }) {
201
236
  }
202
237
  let pages;
203
238
  try {
204
- const added = await index.addDirectory({ path: site });
205
- if (added.errors.length) throw new Error(added.errors.join("; "));
206
- pages = added.page_count;
239
+ if (base) {
240
+ pages = 0;
241
+ for (const url of reachableFromDisk(site, base)) {
242
+ const sourcePath = path.posix.join(
243
+ ...url.slice(base.length).split("/"),
244
+ "index.html",
245
+ );
246
+ const content = fs.readFileSync(path.join(site, sourcePath), "utf8");
247
+ const added = await index.addHTMLFile({ sourcePath, content });
248
+ if (added.errors.length) throw new Error(added.errors.join("; "));
249
+ pages += 1;
250
+ }
251
+ } else {
252
+ const added = await index.addDirectory({ path: site });
253
+ if (added.errors.length) throw new Error(added.errors.join("; "));
254
+ pages = added.page_count;
255
+ }
207
256
  if (!pages) {
208
257
  throw new Error(`no page under ${site} was indexed — the site holds no HTML`);
209
258
  }
210
- const written = await index.writeFiles({ outputPath: output });
211
- if (written.errors.length) throw new Error(written.errors.join("; "));
259
+ // The files are taken from the index in memory and written here,
260
+ // because `writeFiles` can answer while the binary is still flushing
261
+ // them, which leaves an index that a reader loads half-written.
262
+ const { files, errors } = await index.getFiles();
263
+ if (errors.length) throw new Error(errors.join("; "));
264
+ if (!files.length) throw new Error("the search index holds no files");
265
+ for (const file of files) {
266
+ const target = path.join(output, file.path);
267
+ fs.mkdirSync(path.dirname(target), { recursive: true });
268
+ fs.writeFileSync(target, file.content);
269
+ }
212
270
  } finally {
213
271
  await pagefind.close();
214
272
  }
215
- // Pagefind may finish its API call while fragment writes are still visible
216
- // as incomplete gzip streams under heavy parallel load. A successful site
217
- // build must hand deployment complete fragments.
218
- const fragmentDir = path.join(output, "fragment");
219
- const deadline = Date.now() + 5000;
220
- for (;;) {
221
- try {
222
- const fragments = fs.readdirSync(fragmentDir);
223
- if (!fragments.length) throw new Error("no search fragments were written");
224
- for (const name of fragments) gunzipSync(fs.readFileSync(path.join(fragmentDir, name)));
225
- break;
226
- } catch (error) {
227
- if (Date.now() >= deadline) throw error;
228
- await new Promise((resolve) => setTimeout(resolve, 25));
229
- }
230
- }
231
273
  return { pages };
232
274
  }
233
275
 
@@ -289,7 +331,7 @@ export async function writeSiteRoot({ pkg, base, out, search = true, indexer = i
289
331
  }
290
332
  let pages;
291
333
  try {
292
- ({ pages } = await indexer({ site: rendered, output: dir }));
334
+ ({ pages } = await indexer({ site: rendered, output: dir, base: prefix }));
293
335
  } catch (err) {
294
336
  const message = err instanceof Error ? err.message : String(err);
295
337
  // A directory, so the locator is the file field alone: there is no
@@ -45,6 +45,7 @@ import path from "node:path";
45
45
  import { siteBuildFailed } from "./site-build.mjs";
46
46
  import { SITE_THEME_DIR } from "./site-theme.mjs";
47
47
  import { SITE_STATIC_DIR, buildPackageSite, writeSite, writeThemeFiles } from "./site-render.mjs";
48
+ import { siteBase } from "../content-config.mjs";
48
49
  import { SEARCH_DIR, indexSite } from "./site-root.mjs";
49
50
 
50
51
  /** The address a served page listens on for the server's reloads. */
@@ -165,7 +166,7 @@ export async function serveSite({
165
166
 
166
167
  const indexForSearch = async () => {
167
168
  if (!search || config.site.search === false || !state) return;
168
- await indexSite({ site: out, output: path.join(out, SEARCH_DIR) });
169
+ await indexSite({ site: out, output: path.join(out, SEARCH_DIR), base: siteBase(config) });
169
170
  };
170
171
 
171
172
  await buildAll();
@@ -37,7 +37,9 @@
37
37
  * `draftNotice`, `scriptFonts`), its `menu`, and `pages`, every regular page
38
38
  * as `{url, title, section, type, subType, date, content}`. `tagPages` says the site
39
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,
40
+ * the site is published. `visible`, where the site sets it, answers whether a
41
+ * generated link may point at a URL; the Related card, the previous and next
42
+ * links and the infobox links leave out, or set as plain text, what it refuses. `feed` is the site's Atom feed as `{title, url,
41
43
  * section}`, `section` being the one its posts are listed in, and
42
44
  * `mergeIndexes` the search bundles of other sites its search reads beside
43
45
  * its own.
@@ -453,11 +455,14 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
453
455
  return { bannerUrl: url, title, tagline };
454
456
  };
455
457
 
456
- /** One infobox entry. */
458
+ /** Whether a generated link may point at a URL: any, where the site sets no `visible`. */
459
+ const visible = (url) => (typeof site.visible === "function" ? site.visible(url) : true);
460
+
461
+ /** One infobox entry; one that leads outside the reachable pages is its text alone. */
457
462
  const entryOf = (entry) => ({
458
463
  lead: text(entry?.lead),
459
464
  text: text(entry?.text),
460
- url: entry?.url ? safeUrl(entry.url) : "",
465
+ url: entry?.url && visible(entry.url) ? safeUrl(entry.url) : "",
461
466
  });
462
467
 
463
468
  /** The value of one row, by its kind. */
@@ -552,7 +557,7 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
552
557
  const deliberate = [];
553
558
  const merged = [];
554
559
  for (const ref of related.seeAlso ?? []) {
555
- if (seen.has(ref.url)) continue;
560
+ if (seen.has(ref.url) || !visible(ref.url)) continue;
556
561
  seen.add(ref.url);
557
562
  deliberate.push({
558
563
  url: safeUrl(ref.url),
@@ -561,7 +566,7 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
561
566
  });
562
567
  }
563
568
  for (const ref of [...(related.mentions ?? []), ...(related.backlinks ?? [])]) {
564
- if (seen.has(ref.url)) continue;
569
+ if (seen.has(ref.url) || !visible(ref.url)) continue;
565
570
  seen.add(ref.url);
566
571
  merged.push(ref);
567
572
  }
@@ -595,7 +600,15 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
595
600
 
596
601
  /** The prev/next links of a page. */
597
602
  const navOf = (page, narrow) => {
598
- const { prev, next } = neighbours(page, narrow);
603
+ const { prev, next } = neighbours(
604
+ {
605
+ ...page,
606
+ siblings: (page.siblings ?? []).filter(
607
+ (item) => item.url === page.url || visible(item.url),
608
+ ),
609
+ },
610
+ narrow,
611
+ );
599
612
  if (!prev && !next) return undefined;
600
613
  const link = (item) => item && { url: safeUrl(item.url), title: text(item.title) };
601
614
  return { prev: link(prev), next: link(next) };
@@ -603,11 +603,7 @@ async function createEventRelation(connection, file, records, prefix, audience)
603
603
  fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
604
604
  // `whenIn` and `untilIn` are keyed by calendar Address, and read as structs
605
605
  // whatever their number of keys, so `whenIn['<calendar>']` always binds.
606
- await createTableFromFile(
607
- connection,
608
- `${prefix}events`,
609
- readJsonAuto(file).replace(/\)$/, ", map_inference_threshold=-1)"),
610
- );
606
+ await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
611
607
  }
612
608
 
613
609
  /**
@@ -941,13 +937,20 @@ async function createRowRelation(connection, file, rows, view, columns, types) {
941
937
  * type's fields with `NULL` where a record lacks one. A dependency's index has
942
938
  * exactly the same shape and needs exactly the same reading.
943
939
  *
940
+ * `map_inference_threshold=-1` keeps every object a struct. Left at its default,
941
+ * DuckDB reads an object whose keys are sparsely spread across rows (the mean
942
+ * share of rows carrying a key falls under `field_appearance_threshold`) as a
943
+ * map, so `data.<key>` changes meaning with the corpus: one more field on one
944
+ * note can turn a plain comparison into a JSON conversion error.
945
+ *
944
946
  * @param {string} file - The JSONL to read.
945
947
  * @returns {string} The `SELECT … FROM read_json_auto(…)` clause.
946
948
  */
947
949
  function readJsonAuto(file) {
948
950
  return (
949
951
  `SELECT * FROM read_json_auto('${file.replace(/'/g, "''")}', ` +
950
- `format='newline_delimited', union_by_name=true, maximum_object_size=20000000)`
952
+ `format='newline_delimited', union_by_name=true, maximum_object_size=20000000, ` +
953
+ `map_inference_threshold=-1)`
951
954
  );
952
955
  }
953
956
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.0.0",
3
+ "version": "24.0.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The internal targets a page links.
3
+ *
4
+ * @param {string} html - A rendered page.
5
+ * @param {string} from - The page's own address, beginning with `/`.
6
+ * @param {string} base - The site's base path, beginning and ending with `/`.
7
+ * @returns {string[]} Absolute paths inside the base, without fragment or query.
8
+ */
9
+ export function internalLinks(html: string, from: string, base: string): string[];
10
+ /**
11
+ * The address a link to a file names a page by: `…/index.html` is its
12
+ * directory, and a bare directory ends in a slash.
13
+ *
14
+ * @param {string} pathname - An absolute path.
15
+ * @returns {string} The page address it would be keyed by.
16
+ */
17
+ export function pageAddress(pathname: string): string;
18
+ /**
19
+ * The pages reachable from the homepage.
20
+ *
21
+ * @param {object} options - The inputs.
22
+ * @param {string} options.base - The site's base path, beginning and ending
23
+ * with `/`; the homepage is at the base.
24
+ * @param {(url: string) => string|undefined} options.html - A page's rendered
25
+ * HTML by its address, or `undefined` where the site has no such page.
26
+ * @param {(pathname: string) => readonly string[]} [options.expand] - The page
27
+ * addresses a linked non-page file, such as a feed, lists.
28
+ * @returns {Set<string>} The address of every reachable page, the homepage
29
+ * included.
30
+ */
31
+ export function reachablePages({ base, html, expand }: {
32
+ base: string;
33
+ html: (url: string) => string | undefined;
34
+ expand?: ((pathname: string) => readonly string[]) | undefined;
35
+ }): Set<string>;
36
+ /**
37
+ * The page addresses an Atom feed lists, as paths.
38
+ *
39
+ * @param {string} xml - The feed.
40
+ * @returns {string[]} The `href` of every `<link>` that is an absolute URL,
41
+ * as its path.
42
+ */
43
+ export function feedPaths(xml: string): string[];
44
+ /**
45
+ * A page body split at {@link GENERATED_MARK}.
46
+ *
47
+ * @param {string} html - A page body.
48
+ * @returns {{authored: string, generated: string}} The author's text, and the
49
+ * generated sections after it, `""` where there are none.
50
+ */
51
+ export function splitContent(html: string): {
52
+ authored: string;
53
+ generated: string;
54
+ };
55
+ /**
56
+ * Generated sections with the entries that lead outside a set of pages taken
57
+ * out.
58
+ *
59
+ * An entry is a list item, a table row, or a link in a paragraph's comma
60
+ * separated run; a paragraph, list or table left with no entry goes, and so
61
+ * does a section left with nothing under its heading. A link to anything that
62
+ * is not a page of the site is kept, and so is text that was never a link.
63
+ *
64
+ * @param {string} html - The generated sections, rendered.
65
+ * @param {(href: string) => boolean} outside - Whether an `href` names a page
66
+ * of the site that is not reachable.
67
+ * @returns {string} The sections, or `""` when nothing is left.
68
+ */
69
+ export function pruneGenerated(html: string, outside: (href: string) => boolean): string;
70
+ /**
71
+ * The comment that separates a page's authored text from the sections the
72
+ * build generated after it, in the HTML the renderer writes.
73
+ *
74
+ * @type {string}
75
+ */
76
+ export const GENERATED_MARK: string;
@@ -50,8 +50,9 @@ export function writeThemeFiles(out: string, staticDir?: string): string[];
50
50
  * @param {string} [options.staticDir] - The theme's own files, copied first.
51
51
  * @param {ReadonlySet<string>} [options.only] - The addresses of the pages to
52
52
  * draw again into a site already written. Nothing else is touched.
53
- * @returns {{files: string[], pages: string[], findings: object[]}} Every file
54
- * written, the address of every page, and the layouts' findings.
53
+ * @returns {{files: string[], pages: string[], reachable: string[],
54
+ * findings: object[]}} Every file written, the address of every page, the
55
+ * address of each page reachable from the homepage, and the layouts' findings.
55
56
  * @throws {Error} When a page's address is outside the site, or a site served
56
57
  * under a path asks for a sitemap index.
57
58
  */
@@ -65,6 +66,7 @@ export function writeSite({ site, pages, out, report, staticDir, only }: {
65
66
  }): {
66
67
  files: string[];
67
68
  pages: string[];
69
+ reachable: string[];
68
70
  findings: object[];
69
71
  };
70
72
  /**
@@ -42,6 +42,7 @@ export function headers({ pkg, base, fonts }?: {
42
42
  export function indexSite(where: {
43
43
  site: string;
44
44
  output: string;
45
+ base?: string;
45
46
  }): Promise<{
46
47
  pages: number;
47
48
  }>;
@@ -125,6 +126,7 @@ export const ORIGIN_SUFFIX: string;
125
126
  export type SiteIndexer = (where: {
126
127
  site: string;
127
128
  output: string;
129
+ base?: string;
128
130
  }) => Promise<{
129
131
  pages: number;
130
132
  }>;