@heroiclands/package-build 25.5.4 → 25.5.5
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 +22 -0
- package/README.md +7 -0
- package/bin/content-commands.mjs +32 -8
- package/docs/api.md +57 -16
- package/docs/authoring/links-and-markup.md +51 -3
- package/docs/commands.md +60 -17
- package/docs/configuration.md +3 -2
- package/docs/content-format.md +10 -0
- package/docs/diagnostics.md +17 -0
- package/docs/guides/module-project.md +11 -3
- package/docs/guides/site.md +12 -2
- package/docs/guides/writing-in-invented-scripts.md +5 -2
- package/docs/reference/format-details.md +61 -11
- package/docs/reference/note-types.md +32 -32
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +2 -0
- package/engine/compile-corpus.mjs +6 -2
- package/engine/content-charset.mjs +1 -1
- package/engine/content-render.mjs +14 -2
- package/engine/content-scripts.mjs +44 -6
- package/engine/content-session.mjs +11 -7
- package/engine/content-spans.mjs +3 -4
- package/engine/content-verse.mjs +52 -10
- package/engine/core-report.mjs +27 -18
- package/engine/language-names.mjs +53 -23
- package/engine/markdownlint-run.mjs +1 -1
- package/engine/markup-validation.mjs +2 -0
- package/engine/note-vocabulary.mjs +10 -5
- package/engine/npc-generate.mjs +52 -7
- package/engine/page-breaks.mjs +115 -0
- package/engine/pdf-build.mjs +12 -4
- package/engine/pdf-render.mjs +10 -1
- package/engine/preview-index.mjs +4 -2
- package/engine/site-build.mjs +12 -3
- package/engine/site-preview.mjs +3 -0
- package/engine/site-render.mjs +18 -4
- package/engine/site-root.mjs +10 -1
- package/engine/site-serve-http.mjs +151 -0
- package/engine/site-serve.mjs +196 -126
- package/engine/verse-metre.mjs +50 -5
- package/engine/verse-regions.mjs +16 -0
- package/package.json +3 -3
- package/types/e2e.d.mts +1 -1
- package/types/engine/content-scripts.d.mts +9 -1
- package/types/engine/content-verse.d.mts +11 -2
- package/types/engine/core-report.d.mts +6 -2
- package/types/engine/language-names.d.mts +18 -6
- package/types/engine/npc-generate.d.mts +3 -0
- package/types/engine/page-breaks.d.mts +30 -0
- package/types/engine/pdf-render.d.mts +2 -0
- package/types/engine/site-build.d.mts +3 -1
- package/types/engine/site-render.d.mts +6 -2
- package/types/engine/site-root.d.mts +3 -1
- package/types/engine/site-serve-http.d.mts +1 -0
- package/types/engine/site-serve.d.mts +12 -6
- package/types/engine/verse-metre.d.mts +2 -1
- package/types/engine/verse-regions.d.mts +8 -0
package/engine/site-serve.mjs
CHANGED
|
@@ -25,21 +25,20 @@
|
|
|
25
25
|
*
|
|
26
26
|
* A page that changes because another note changed — a table that lists the
|
|
27
27
|
* note, a link that carries its name — is written again by the next whole
|
|
28
|
-
* build. A
|
|
29
|
-
*
|
|
30
|
-
* site in place.
|
|
28
|
+
* build. A failed build is reported and leaves the last successful site in
|
|
29
|
+
* place, including its search index.
|
|
31
30
|
*
|
|
32
31
|
* The site is served from the deployed root, so a page is at the address it is
|
|
33
32
|
* deployed at, its base path included. Every HTML page is sent with a script
|
|
34
33
|
* that listens on {@link RELOAD_PATH} for the server's `reload` event; the
|
|
35
34
|
* script is added as the page is sent and is never written to the site. The
|
|
36
|
-
* search index is built
|
|
35
|
+
* search index is built with each whole build before it replaces the last site.
|
|
37
36
|
*
|
|
38
37
|
* @module
|
|
39
38
|
*/
|
|
40
39
|
|
|
41
40
|
import fs from "node:fs";
|
|
42
|
-
import
|
|
41
|
+
import { Worker } from "node:worker_threads";
|
|
43
42
|
import path from "node:path";
|
|
44
43
|
|
|
45
44
|
import { siteBuildFailed } from "./site-build.mjs";
|
|
@@ -47,6 +46,7 @@ import { SITE_THEME_DIR } from "./site-theme.mjs";
|
|
|
47
46
|
import { SITE_STATIC_DIR, buildPackageSite, writeSite, writeThemeFiles } from "./site-render.mjs";
|
|
48
47
|
import { siteBase } from "../content-config.mjs";
|
|
49
48
|
import { SEARCH_DIR, indexSite } from "./site-root.mjs";
|
|
49
|
+
import { readCachedNavigation, siteSettings } from "./site-config.mjs";
|
|
50
50
|
|
|
51
51
|
/** The address a served page listens on for the server's reloads. */
|
|
52
52
|
export const RELOAD_PATH = "/__package-build/reload";
|
|
@@ -54,29 +54,6 @@ export const RELOAD_PATH = "/__package-build/reload";
|
|
|
54
54
|
/** How long a burst of changes is gathered before it is written, in milliseconds. */
|
|
55
55
|
const SETTLE_MS = 120;
|
|
56
56
|
|
|
57
|
-
/** What a file is sent as, by its extension. */
|
|
58
|
-
const CONTENT_TYPES = Object.freeze({
|
|
59
|
-
".html": "text/html; charset=utf-8",
|
|
60
|
-
".css": "text/css; charset=utf-8",
|
|
61
|
-
".js": "text/javascript; charset=utf-8",
|
|
62
|
-
".mjs": "text/javascript; charset=utf-8",
|
|
63
|
-
".json": "application/json",
|
|
64
|
-
".xml": "application/xml",
|
|
65
|
-
".txt": "text/plain; charset=utf-8",
|
|
66
|
-
".svg": "image/svg+xml",
|
|
67
|
-
".png": "image/png",
|
|
68
|
-
".jpg": "image/jpeg",
|
|
69
|
-
".jpeg": "image/jpeg",
|
|
70
|
-
".webp": "image/webp",
|
|
71
|
-
".gif": "image/gif",
|
|
72
|
-
".woff2": "font/woff2",
|
|
73
|
-
".woff": "font/woff",
|
|
74
|
-
".wasm": "application/wasm",
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
/** The script a served page is sent with. */
|
|
78
|
-
const RELOAD_SCRIPT = `<script>new EventSource(${JSON.stringify(RELOAD_PATH)}).addEventListener("reload", () => location.reload());</script>`;
|
|
79
|
-
|
|
80
57
|
/** Whether a note's text holds a query its build prepares ahead of the render. */
|
|
81
58
|
const holdsQuery = (text) => /```\s*sql\b|\{\{[^}]*\bsql\b/.test(text);
|
|
82
59
|
|
|
@@ -91,16 +68,19 @@ const holdsQuery = (text) => /```\s*sql\b|\{\{[^}]*\bsql\b/.test(text);
|
|
|
91
68
|
* directory above `out` by default; `out` itself for a site served at `/`.
|
|
92
69
|
* @param {string} [options.host] - The address to listen on.
|
|
93
70
|
* @param {number} [options.port] - The port; `0` takes any free one.
|
|
94
|
-
* @param {boolean} [options.search] - Build the search index
|
|
71
|
+
* @param {boolean} [options.search] - Build the search index with each whole
|
|
95
72
|
* build, where the site has one.
|
|
96
73
|
* @param {number} [options.year] - The year the footer states.
|
|
97
|
-
* @param {(event: {kind: string, built?: object, files?: string[], findings?: object[], error?: unknown}) => void} [options.report] -
|
|
98
|
-
* Told of each build: `built` for a whole build, `page` for one note's page,
|
|
74
|
+
* @param {(event: {kind: string, built?: object, files?: string[], findings?: object[], error?: unknown, message?: string, fatal?: boolean}) => void} [options.report] -
|
|
75
|
+
* Told of progress (`message`) and each build: `built` for a whole build, `page` for one note's page,
|
|
99
76
|
* `theme` for the theme, `failed` with what the build found, `error` with
|
|
100
77
|
* what it threw.
|
|
101
|
-
* @returns {Promise<{url: string, close: () => Promise<void>, settled: () => Promise<void>}>}
|
|
102
|
-
*
|
|
103
|
-
*
|
|
78
|
+
* @returns {Promise<{url: string, close: () => Promise<void>, settled: () => Promise<void>, done: Promise<void>}>}
|
|
79
|
+
* Resolves as soon as HTTP listens, before the first build. The address of
|
|
80
|
+
* the site's home page, how to stop serving, and a promise for the initial
|
|
81
|
+
* build and every change seen so far to finish. Failed builds are reported
|
|
82
|
+
* through `report` and leave the last successful site in service. `done`
|
|
83
|
+
* resolves on close and rejects if HTTP stops unexpectedly.
|
|
104
84
|
*/
|
|
105
85
|
export async function serveSite({
|
|
106
86
|
config,
|
|
@@ -112,27 +92,147 @@ export async function serveSite({
|
|
|
112
92
|
year,
|
|
113
93
|
report = () => {},
|
|
114
94
|
}) {
|
|
115
|
-
|
|
116
|
-
|
|
95
|
+
// Refuse a site that cannot be described before starting its server.
|
|
96
|
+
siteSettings({ config });
|
|
97
|
+
readCachedNavigation(config);
|
|
98
|
+
root = path.resolve(root);
|
|
99
|
+
out = path.resolve(out);
|
|
100
|
+
let timer = null;
|
|
101
|
+
const watchers = [];
|
|
102
|
+
let closing = false;
|
|
103
|
+
let complete;
|
|
104
|
+
let abort;
|
|
105
|
+
const done = new Promise((resolve, reject) => {
|
|
106
|
+
complete = resolve;
|
|
107
|
+
abort = reject;
|
|
108
|
+
});
|
|
109
|
+
// The caller may attach its lifecycle handler after awaiting startup.
|
|
110
|
+
done.catch(() => {});
|
|
111
|
+
const stopWatching = () => {
|
|
112
|
+
if (timer) clearTimeout(timer);
|
|
113
|
+
timer = null;
|
|
114
|
+
for (const watcher of watchers) watcher.close();
|
|
115
|
+
};
|
|
116
|
+
const worker = new Worker(new URL("./site-serve-http.mjs", import.meta.url), {
|
|
117
|
+
workerData: { root, out, host, port, reloadPath: RELOAD_PATH },
|
|
118
|
+
// The HTTP helper uses only Node builtins. A caller's --input-type
|
|
119
|
+
// or loader flags describe its own entry point, not this file.
|
|
120
|
+
execArgv: [],
|
|
121
|
+
});
|
|
122
|
+
let workerFailure;
|
|
123
|
+
let started = false;
|
|
124
|
+
const requests = new Map();
|
|
125
|
+
worker.on("message", (event) => {
|
|
126
|
+
const waiting = requests.get(event.kind);
|
|
127
|
+
if (!waiting) return;
|
|
128
|
+
requests.delete(event.kind);
|
|
129
|
+
if (event.error) waiting.reject(event.error);
|
|
130
|
+
else waiting.resolve(event);
|
|
131
|
+
});
|
|
132
|
+
const failed = (error) => {
|
|
133
|
+
if (workerFailure) return;
|
|
134
|
+
workerFailure = error;
|
|
135
|
+
stopWatching();
|
|
136
|
+
abort(error);
|
|
137
|
+
for (const waiting of requests.values()) waiting.reject(error);
|
|
138
|
+
requests.clear();
|
|
139
|
+
if (started) report({ kind: "error", error, fatal: true });
|
|
140
|
+
};
|
|
141
|
+
worker.on("error", failed);
|
|
142
|
+
const exited = new Promise((resolve) => {
|
|
143
|
+
worker.once("exit", (code) => {
|
|
144
|
+
if ((started || requests.size) && !workerFailure)
|
|
145
|
+
failed(new Error(`the preview HTTP server stopped (exit ${code})`));
|
|
146
|
+
resolve();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
const receive = (kind) =>
|
|
150
|
+
new Promise((resolve, reject) => {
|
|
151
|
+
if (workerFailure) reject(workerFailure);
|
|
152
|
+
else requests.set(kind, { resolve, reject });
|
|
153
|
+
});
|
|
154
|
+
const listening = await receive("listening");
|
|
155
|
+
started = true;
|
|
117
156
|
let state = null;
|
|
157
|
+
const progress = (message) => report({ kind: "progress", message });
|
|
158
|
+
const reload = () => worker.postMessage({ kind: "reload" });
|
|
118
159
|
|
|
119
|
-
|
|
120
|
-
|
|
160
|
+
// Whole builds and incremental updates publish through the same boundary.
|
|
161
|
+
// HTTP owns the renames, between requests; writers only touch private trees.
|
|
162
|
+
const publish = async (write) => {
|
|
163
|
+
if (workerFailure || closing) return null;
|
|
164
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
165
|
+
const staging = fs.mkdtempSync(path.join(path.dirname(out), ".site-preview-"));
|
|
166
|
+
const staged = path.join(staging, "site");
|
|
167
|
+
const previous = path.join(staging, "previous");
|
|
168
|
+
let installed = false;
|
|
169
|
+
try {
|
|
170
|
+
const result = await write(staged);
|
|
171
|
+
if (!result || closing) return null;
|
|
172
|
+
if (workerFailure) throw workerFailure;
|
|
173
|
+
const ready = receive("installed");
|
|
174
|
+
worker.postMessage({ kind: "install", staged, previous });
|
|
175
|
+
await ready;
|
|
176
|
+
installed = true;
|
|
177
|
+
return result;
|
|
178
|
+
} finally {
|
|
179
|
+
// A failed rollback must retain the only remaining previous tree.
|
|
180
|
+
if (installed || !fs.existsSync(previous))
|
|
181
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
182
|
+
}
|
|
121
183
|
};
|
|
122
184
|
|
|
123
185
|
const buildAll = async () => {
|
|
124
|
-
const result = await
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
186
|
+
const result = await publish(async (staged) => {
|
|
187
|
+
const built = await buildPackageSite({ config, out: staged, year, progress });
|
|
188
|
+
const findings = [...(built.written?.findings ?? []), ...(built.fonts?.findings ?? [])];
|
|
189
|
+
if (
|
|
190
|
+
!built.written ||
|
|
191
|
+
siteBuildFailed(built.built) ||
|
|
192
|
+
built.fonts?.findings.length ||
|
|
193
|
+
findings.some((finding) => finding.severity === "error")
|
|
194
|
+
) {
|
|
195
|
+
report({ kind: "failed", built: built.built, findings });
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
if (search && config.site.search !== false) {
|
|
199
|
+
progress("building the search index");
|
|
200
|
+
const indexed = await indexSite({
|
|
201
|
+
site: staged,
|
|
202
|
+
output: path.join(staged, SEARCH_DIR),
|
|
203
|
+
base: siteBase(config),
|
|
204
|
+
});
|
|
205
|
+
progress(`indexed ${indexed.pages} page(s) for search`);
|
|
206
|
+
}
|
|
207
|
+
return { ...built, findings };
|
|
208
|
+
});
|
|
209
|
+
if (!result) return false;
|
|
128
210
|
state = result;
|
|
129
|
-
report({ kind: "built", built: result.built, findings: result.
|
|
211
|
+
report({ kind: "built", built: result.built, findings: result.findings });
|
|
130
212
|
return true;
|
|
131
213
|
};
|
|
132
214
|
|
|
133
|
-
const
|
|
215
|
+
const redraw = async ({ pages, only, theme = false }) => {
|
|
216
|
+
return publish((staged) => {
|
|
217
|
+
// Keep unchanged pages, static files and the current search index.
|
|
218
|
+
fs.cpSync(out, staged, { recursive: true, mode: fs.constants.COPYFILE_FICLONE });
|
|
219
|
+
if (theme) writeThemeFiles(staged);
|
|
220
|
+
const written = writeSite({ site: state.site, pages, out: staged, only });
|
|
221
|
+
if (written.findings.some((finding) => finding.severity === "error")) {
|
|
222
|
+
report({ kind: "failed", findings: written.findings });
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
...written,
|
|
227
|
+
files: written.files.map((file) => path.join(out, path.relative(staged, file))),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const redrawNotes = async (files) => {
|
|
134
233
|
if (!state) return false;
|
|
135
234
|
const changed = new Set();
|
|
235
|
+
let pages = state.pages;
|
|
136
236
|
for (const file of files) {
|
|
137
237
|
const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
138
238
|
if (!file.endsWith(".md") || !text || holdsQuery(text)) return false;
|
|
@@ -140,41 +240,33 @@ export async function serveSite({
|
|
|
140
240
|
if (!entry) return false;
|
|
141
241
|
if (entry.findings.length) {
|
|
142
242
|
report({ kind: "failed", findings: entry.findings });
|
|
143
|
-
return
|
|
243
|
+
return null;
|
|
144
244
|
}
|
|
145
245
|
const page = state.pageOf(entry);
|
|
146
|
-
|
|
246
|
+
pages = pages.map((old) => (old.url === page.url ? page : old));
|
|
147
247
|
changed.add(page.url);
|
|
148
248
|
}
|
|
149
|
-
const written =
|
|
249
|
+
const written = await redraw({ pages, only: changed });
|
|
250
|
+
if (!written) return null;
|
|
251
|
+
state.pages = pages;
|
|
150
252
|
report({ kind: "page", files: written.files, findings: written.findings });
|
|
151
253
|
return true;
|
|
152
254
|
};
|
|
153
255
|
|
|
154
|
-
const redrawTheme = () => {
|
|
256
|
+
const redrawTheme = async () => {
|
|
155
257
|
if (!state) return false;
|
|
156
|
-
|
|
157
|
-
const written = writeSite({
|
|
158
|
-
site: state.site,
|
|
258
|
+
const written = await redraw({
|
|
159
259
|
pages: state.pages,
|
|
160
|
-
out,
|
|
161
260
|
only: new Set(state.pages.map((page) => page.url)),
|
|
261
|
+
theme: true,
|
|
162
262
|
});
|
|
263
|
+
if (!written) return false;
|
|
163
264
|
report({ kind: "theme", files: written.files, findings: written.findings });
|
|
164
265
|
return true;
|
|
165
266
|
};
|
|
166
267
|
|
|
167
|
-
const indexForSearch = async () => {
|
|
168
|
-
if (!search || config.site.search === false || !state) return;
|
|
169
|
-
await indexSite({ site: out, output: path.join(out, SEARCH_DIR), base: siteBase(config) });
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
await buildAll();
|
|
173
|
-
await indexForSearch();
|
|
174
|
-
|
|
175
268
|
/** @type {{content: Set<string>, theme: boolean}} */
|
|
176
269
|
let pending = { content: new Set(), theme: false };
|
|
177
|
-
let timer = null;
|
|
178
270
|
let work = Promise.resolve();
|
|
179
271
|
const flush = () => {
|
|
180
272
|
timer = null;
|
|
@@ -182,93 +274,71 @@ export async function serveSite({
|
|
|
182
274
|
pending = { content: new Set(), theme: false };
|
|
183
275
|
work = work.then(async () => {
|
|
184
276
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (done) reload();
|
|
277
|
+
if (workerFailure || closing) return;
|
|
278
|
+
let changed = batch.content.size ? await redrawNotes([...batch.content]) : true;
|
|
279
|
+
if (changed === false) changed = await buildAll();
|
|
280
|
+
else if (changed && batch.theme) changed = await redrawTheme();
|
|
281
|
+
if (changed) reload();
|
|
191
282
|
} catch (error) {
|
|
192
|
-
report({ kind: "error", error });
|
|
283
|
+
if (error !== workerFailure) report({ kind: "error", error });
|
|
193
284
|
}
|
|
194
285
|
});
|
|
195
286
|
};
|
|
196
287
|
const queue = (kind, file) => {
|
|
288
|
+
if (workerFailure || closing) return;
|
|
197
289
|
if (kind === "theme") pending.theme = true;
|
|
198
290
|
else pending.content.add(file);
|
|
199
291
|
if (!timer) timer = setTimeout(flush, SETTLE_MS);
|
|
200
292
|
};
|
|
201
293
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
.filter((dir) => fs.existsSync(dir))
|
|
208
|
-
.map((dir) => fs.watch(dir, { recursive: true }, () => queue("theme"))),
|
|
209
|
-
];
|
|
210
|
-
|
|
211
|
-
const server = http.createServer((req, res) => {
|
|
212
|
-
const address = decodeURIComponent(
|
|
213
|
-
new URL(req.url ?? "/", "http://serve.invalid").pathname,
|
|
294
|
+
try {
|
|
295
|
+
watchers.push(
|
|
296
|
+
fs.watch(config.paths.content, { recursive: true }, (_event, name) => {
|
|
297
|
+
if (name) queue("content", path.join(config.paths.content, String(name)));
|
|
298
|
+
}),
|
|
214
299
|
);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"cache-control": "no-cache",
|
|
219
|
-
connection: "keep-alive",
|
|
220
|
-
});
|
|
221
|
-
res.write(": connected\n\n");
|
|
222
|
-
listeners.add(res);
|
|
223
|
-
req.on("close", () => listeners.delete(res));
|
|
224
|
-
return;
|
|
300
|
+
for (const dir of [SITE_THEME_DIR, SITE_STATIC_DIR]) {
|
|
301
|
+
if (fs.existsSync(dir))
|
|
302
|
+
watchers.push(fs.watch(dir, { recursive: true }, () => queue("theme")));
|
|
225
303
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
243
|
-
let body = fs.readFileSync(file);
|
|
244
|
-
if (type.startsWith("text/html")) {
|
|
245
|
-
const html = body.toString("utf8");
|
|
246
|
-
const at = html.lastIndexOf("</body>");
|
|
247
|
-
body = Buffer.from(
|
|
248
|
-
at < 0 ? html + RELOAD_SCRIPT : html.slice(0, at) + RELOAD_SCRIPT + html.slice(at),
|
|
249
|
-
);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
stopWatching();
|
|
306
|
+
started = false;
|
|
307
|
+
worker.postMessage({ kind: "close" });
|
|
308
|
+
await exited;
|
|
309
|
+
abort(error);
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Return the listening address before beginning the first long build.
|
|
314
|
+
work = new Promise((resolve) => setImmediate(resolve)).then(async () => {
|
|
315
|
+
try {
|
|
316
|
+
if (await buildAll()) reload();
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error !== workerFailure) report({ kind: "error", error });
|
|
250
319
|
}
|
|
251
|
-
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
252
|
-
res.end(body);
|
|
253
320
|
});
|
|
254
|
-
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
255
|
-
const listening = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
256
|
-
const base = new URL(config.homepage).pathname;
|
|
257
321
|
|
|
258
322
|
return {
|
|
259
|
-
url: `http://${host}:${listening.port}${
|
|
323
|
+
url: `http://${host}:${listening.port}${new URL(config.homepage).pathname}`,
|
|
324
|
+
done,
|
|
260
325
|
settled: async () => {
|
|
261
|
-
|
|
326
|
+
for (;;) {
|
|
327
|
+
const current = work;
|
|
328
|
+
await current;
|
|
329
|
+
if (workerFailure) throw workerFailure;
|
|
330
|
+
if (!timer && !pending.content.size && !pending.theme && current === work) return;
|
|
262
331
|
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
263
332
|
}
|
|
264
|
-
await work;
|
|
265
333
|
},
|
|
266
334
|
close: async () => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
for (const res of listeners) res.end();
|
|
335
|
+
closing = true;
|
|
336
|
+
stopWatching();
|
|
270
337
|
await work;
|
|
271
|
-
|
|
338
|
+
started = false;
|
|
339
|
+
worker.postMessage({ kind: "close" });
|
|
340
|
+
await exited;
|
|
341
|
+
complete();
|
|
272
342
|
},
|
|
273
343
|
};
|
|
274
344
|
}
|
package/engine/verse-metre.mjs
CHANGED
|
@@ -62,8 +62,48 @@ import MarkdownIt from "markdown-it";
|
|
|
62
62
|
import { spanMarkdownPlugin } from "./content-spans.mjs";
|
|
63
63
|
import { parseVerse } from "./content-verse.mjs";
|
|
64
64
|
import { inlineText } from "./name-markup.mjs";
|
|
65
|
+
import { WIKILINK, authoredLabel, parseWikilink } from "./wikilink-syntax.mjs";
|
|
65
66
|
|
|
66
67
|
const verseParser = new MarkdownIt().use(spanMarkdownPlugin);
|
|
68
|
+
const wikilinkAt = new RegExp(WIKILINK.source, "y");
|
|
69
|
+
|
|
70
|
+
// Parse link labels in place so neighbouring spans keep their source offsets.
|
|
71
|
+
verseParser.inline.ruler.before("link", "metre_wikilink", (state, silent) => {
|
|
72
|
+
const start = state.pos;
|
|
73
|
+
wikilinkAt.lastIndex = start;
|
|
74
|
+
const match = wikilinkAt.exec(state.src);
|
|
75
|
+
if (!match || start + match[0].length > state.posMax) return false;
|
|
76
|
+
const finish = start + match[0].length;
|
|
77
|
+
if (silent) {
|
|
78
|
+
state.pos = finish;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const parsed = parseWikilink(match[1], match[0]);
|
|
82
|
+
const label = authoredLabel(parsed) ?? state.env.referenceName?.(parsed.target);
|
|
83
|
+
const token = state.push("span_open", "span", 1);
|
|
84
|
+
token.meta = {
|
|
85
|
+
span: true,
|
|
86
|
+
start,
|
|
87
|
+
end: finish,
|
|
88
|
+
unresolvedReference: !parsed.labelled || label === undefined || label === "",
|
|
89
|
+
};
|
|
90
|
+
if (parsed.attributes?.classes.length)
|
|
91
|
+
token.attrSet("class", parsed.attributes.classes.join(" "));
|
|
92
|
+
for (const [key, value] of Object.entries(parsed.attributes?.values ?? {}))
|
|
93
|
+
token.attrSet(key, value);
|
|
94
|
+
const nested = [];
|
|
95
|
+
state.md.inline.parse(label ?? "", state.md, state.env, nested);
|
|
96
|
+
const labelStart = start + match[0].indexOf("|") + 1;
|
|
97
|
+
for (const child of nested)
|
|
98
|
+
if (child.meta?.span) {
|
|
99
|
+
child.meta.start += labelStart;
|
|
100
|
+
child.meta.end += labelStart;
|
|
101
|
+
}
|
|
102
|
+
state.tokens.push(...nested);
|
|
103
|
+
state.push("span_close", "span", -1);
|
|
104
|
+
state.pos = finish;
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
67
107
|
|
|
68
108
|
export { METRE_KINDS, METRE_NAME, checkMetresField } from "./metre-declaration.mjs";
|
|
69
109
|
|
|
@@ -240,11 +280,12 @@ function wordBeats(word, book) {
|
|
|
240
280
|
const folded = foldWord(word);
|
|
241
281
|
const count = nucleiOf(folded, book.pairs).length;
|
|
242
282
|
if (count === 0) return { min: 0, max: 0 };
|
|
283
|
+
const bases = book.classes.get(folded);
|
|
243
284
|
if (count > 1) {
|
|
285
|
+
const functionWord = bases && [...bases].every((base) => !BEAT_BASES.includes(base));
|
|
244
286
|
const first = stressIndex(folded, book.rule, book.pairs) === 0;
|
|
245
|
-
return { min: 1, max: count >= 4 && !first ? 2 : 1 };
|
|
287
|
+
return { min: functionWord ? 0 : 1, max: count >= 4 && !first ? 2 : 1 };
|
|
246
288
|
}
|
|
247
|
-
const bases = book.classes.get(folded);
|
|
248
289
|
if (!bases) return { min: 1, max: 1 };
|
|
249
290
|
const carrying = [...bases].filter((base) => BEAT_BASES.includes(base)).length;
|
|
250
291
|
if (carrying === bases.size) return { min: 1, max: 1 };
|
|
@@ -292,7 +333,7 @@ export function metreMessage({ min, max }, metre) {
|
|
|
292
333
|
* @param {ReadonlyArray<{start: number, end: number, indent: string, attributes: Record<string, string>, classes: string[]}>} blocks -
|
|
293
334
|
* The divs, from {@link module:engine/content-verse.scanVerse}.
|
|
294
335
|
* @param {string[]} lines - The source's lines.
|
|
295
|
-
* @param {{languages?: Readonly<Record<string, {tag: string}>>, metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
|
|
336
|
+
* @param {{languages?: Readonly<Record<string, {tag: string}>>, metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, referenceName?: (target: string) => string|undefined, attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
|
|
296
337
|
* The metres and books, and how the div says its columns and where its
|
|
297
338
|
* keys sit.
|
|
298
339
|
* @returns {{errors: object[], warnings: object[]}} The findings at 1-based
|
|
@@ -358,9 +399,12 @@ export function metreFindings(blocks, lines, ctx) {
|
|
|
358
399
|
({ offset }) =>
|
|
359
400
|
!model.layout.some(([from, to]) => offset >= from && offset < to),
|
|
360
401
|
);
|
|
402
|
+
const inlineContext = { referenceName: ctx.referenceName };
|
|
361
403
|
const parsed =
|
|
362
|
-
verseParser.parseInline(
|
|
363
|
-
|
|
404
|
+
verseParser.parseInline(
|
|
405
|
+
units.map((unit) => unit.text).join(""),
|
|
406
|
+
inlineContext,
|
|
407
|
+
)[0]?.children ?? [];
|
|
364
408
|
let presentationDepth = 0;
|
|
365
409
|
const tokens = parsed.filter((token) => {
|
|
366
410
|
if (presentationDepth) {
|
|
@@ -395,6 +439,7 @@ export function metreFindings(blocks, lines, ctx) {
|
|
|
395
439
|
);
|
|
396
440
|
continue;
|
|
397
441
|
}
|
|
442
|
+
if (tokens.some((token) => token.meta?.unresolvedReference)) continue;
|
|
398
443
|
const beats = lineBeats(text, book);
|
|
399
444
|
if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
|
|
400
445
|
warnings.push({
|
package/engine/verse-regions.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { parseExtensionAttributes } from "./extension-attributes.mjs";
|
|
|
26
26
|
* @property {number} bodyStart - First body offset.
|
|
27
27
|
* @property {number} bodyEnd - Offset past body text, excluding its final newline.
|
|
28
28
|
* @property {string} body - Authored body bytes.
|
|
29
|
+
* @property {string} logicalBody - Body without Markdown container prefixes.
|
|
30
|
+
* @property {number[]} bodyOffsets - Logical UTF-16 offsets mapped to authored body offsets, including the end.
|
|
29
31
|
* @property {Record<string,string>} attributes - Authored values.
|
|
30
32
|
* @property {string} id - Anchor or empty string.
|
|
31
33
|
* @property {string[]} classes - Authored classes including verse.
|
|
@@ -95,6 +97,18 @@ export function verseDivPlugin(md) {
|
|
|
95
97
|
if (original[bodyEnd - 1] === "\n") bodyEnd--;
|
|
96
98
|
if (original[bodyEnd - 1] === "\r") bodyEnd--;
|
|
97
99
|
}
|
|
100
|
+
const bodyOffsets = [];
|
|
101
|
+
let logicalBody = "";
|
|
102
|
+
for (let number = first + 1; number < (closed ? close : past); number++) {
|
|
103
|
+
const lineEnd = Math.min(starts[number] + rawLines[number].length, bodyEnd);
|
|
104
|
+
const contentStart = Math.min(contentStarts[number - first], lineEnd);
|
|
105
|
+
const pastLine = Math.min(starts[number + 1] ?? bodyEnd, bodyEnd);
|
|
106
|
+
for (let offset = contentStart; offset < pastLine; offset++) {
|
|
107
|
+
logicalBody += original[offset];
|
|
108
|
+
bodyOffsets.push(offset - bodyStart);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
bodyOffsets.push(bodyEnd - bodyStart);
|
|
98
112
|
const region = {
|
|
99
113
|
start: first,
|
|
100
114
|
end: closed ? close : past - 1,
|
|
@@ -104,6 +118,8 @@ export function verseDivPlugin(md) {
|
|
|
104
118
|
bodyStart,
|
|
105
119
|
bodyEnd,
|
|
106
120
|
body: original.slice(bodyStart, bodyEnd),
|
|
121
|
+
logicalBody,
|
|
122
|
+
bodyOffsets,
|
|
107
123
|
attributes: parsed.values,
|
|
108
124
|
id: parsed.id,
|
|
109
125
|
classes: parsed.classes,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "25.5.
|
|
3
|
+
"version": "25.5.5",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
"eslint-plugin-yml": "^3.8.1",
|
|
158
158
|
"fflate": "^0.8.3",
|
|
159
159
|
"flesch": "^2.0.1",
|
|
160
|
-
"glob": "^
|
|
160
|
+
"glob": "^14.0.0",
|
|
161
161
|
"handlebars": "^4.7.10",
|
|
162
162
|
"highlight.js": "^11.12.0",
|
|
163
163
|
"ignore": "^7.0.12",
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
"markdown-it": "^15.0.0",
|
|
169
169
|
"markdown-it-deflist": "^4.0.0",
|
|
170
170
|
"markdown-it-footnote": "^4.0.0",
|
|
171
|
-
"minimatch": "^
|
|
171
|
+
"minimatch": "^11.0.0",
|
|
172
172
|
"pagefind": "^1.5.2",
|
|
173
173
|
"prettier": "^3.9.6",
|
|
174
174
|
"remark-frontmatter": "^5.0.0",
|
package/types/e2e.d.mts
CHANGED
|
@@ -494,7 +494,7 @@ export const E2E_SCENE_ID: "heroiclandsScene";
|
|
|
494
494
|
/** How the suite may be run. */
|
|
495
495
|
export const E2E_MODES: readonly string[];
|
|
496
496
|
/** The Chrome for Testing build shared by local and CI Cypress runs. */
|
|
497
|
-
export const CHROME_BUILD_ID: "
|
|
497
|
+
export const CHROME_BUILD_ID: "159.0.8136.0";
|
|
498
498
|
/**
|
|
499
499
|
* The disposable world's identity and credentials.
|
|
500
500
|
*/
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* resolved configuration.
|
|
6
6
|
* @param {Iterable<Record<string, any>>} [records] - The content index's
|
|
7
7
|
* records, whose language notes supply lexical writings and script choices.
|
|
8
|
+
* @param {{referenceName?: (target: string) => string|undefined}} [options] -
|
|
9
|
+
* The rendering or validation index's own target-name lookup.
|
|
8
10
|
* @returns {RunContext} Its languages, the faces installed where it sits, and
|
|
9
11
|
* the encodings. A fonts package that cannot be read gives no faces; a run naming
|
|
10
12
|
* one is reported.
|
|
@@ -16,7 +18,9 @@ export function runContext(config: {
|
|
|
16
18
|
paths?: {
|
|
17
19
|
content?: string;
|
|
18
20
|
};
|
|
19
|
-
} | undefined, records?: Iterable<Record<string, any
|
|
21
|
+
} | undefined, records?: Iterable<Record<string, any>>, { referenceName: indexedName }?: {
|
|
22
|
+
referenceName?: (target: string) => string | undefined;
|
|
23
|
+
}): RunContext;
|
|
20
24
|
/**
|
|
21
25
|
* Find the spans, divs and verse divs marked with `lang=`, `font=` or
|
|
22
26
|
* `enc=`, and every fault in their markup and their text.
|
|
@@ -326,6 +330,10 @@ export type RunContext = {
|
|
|
326
330
|
* {@link module :engine/verse-metre.metreBooksFromRecords}.
|
|
327
331
|
*/
|
|
328
332
|
metreBooks?: ReadonlyMap<string, import("./verse-metre.mjs").MetreBook> | undefined;
|
|
333
|
+
/**
|
|
334
|
+
* - An indexed wikilink target's visible name.
|
|
335
|
+
*/
|
|
336
|
+
referenceName?: ((target: string) => string | undefined) | undefined;
|
|
329
337
|
/**
|
|
330
338
|
* -
|
|
331
339
|
* Exact Roman source inventories by configured alias and BCP 47 tag.
|