@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 +14 -0
- package/docs/commands.md +7 -5
- package/docs/guides/site.md +43 -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/package.json +1 -1
- 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/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
|
|
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
|
|
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/`
|
|
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
|
|
package/docs/guides/site.md
CHANGED
|
@@ -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
|
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
|
/**
|
package/engine/site-root.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
package/engine/site-serve.mjs
CHANGED
|
@@ -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();
|
package/engine/site-theme.mjs
CHANGED
|
@@ -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. `
|
|
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
|
-
/**
|
|
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(
|
|
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) };
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -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.
|
|
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[],
|
|
54
|
-
* written, the address of every page,
|
|
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
|
}>;
|