@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -0
  3. package/bin/content-commands.mjs +32 -8
  4. package/docs/api.md +57 -16
  5. package/docs/authoring/links-and-markup.md +51 -3
  6. package/docs/commands.md +60 -17
  7. package/docs/configuration.md +3 -2
  8. package/docs/content-format.md +10 -0
  9. package/docs/diagnostics.md +17 -0
  10. package/docs/guides/module-project.md +11 -3
  11. package/docs/guides/site.md +12 -2
  12. package/docs/guides/writing-in-invented-scripts.md +5 -2
  13. package/docs/reference/format-details.md +61 -11
  14. package/docs/reference/note-types.md +32 -32
  15. package/e2e.mjs +1 -1
  16. package/engine/base-compiler.mjs +2 -0
  17. package/engine/compile-corpus.mjs +6 -2
  18. package/engine/content-charset.mjs +1 -1
  19. package/engine/content-render.mjs +14 -2
  20. package/engine/content-scripts.mjs +44 -6
  21. package/engine/content-session.mjs +11 -7
  22. package/engine/content-spans.mjs +3 -4
  23. package/engine/content-verse.mjs +52 -10
  24. package/engine/core-report.mjs +27 -18
  25. package/engine/language-names.mjs +53 -23
  26. package/engine/markdownlint-run.mjs +1 -1
  27. package/engine/markup-validation.mjs +2 -0
  28. package/engine/note-vocabulary.mjs +10 -5
  29. package/engine/npc-generate.mjs +52 -7
  30. package/engine/page-breaks.mjs +115 -0
  31. package/engine/pdf-build.mjs +12 -4
  32. package/engine/pdf-render.mjs +10 -1
  33. package/engine/preview-index.mjs +4 -2
  34. package/engine/site-build.mjs +12 -3
  35. package/engine/site-preview.mjs +3 -0
  36. package/engine/site-render.mjs +18 -4
  37. package/engine/site-root.mjs +10 -1
  38. package/engine/site-serve-http.mjs +151 -0
  39. package/engine/site-serve.mjs +196 -126
  40. package/engine/verse-metre.mjs +50 -5
  41. package/engine/verse-regions.mjs +16 -0
  42. package/package.json +3 -3
  43. package/types/e2e.d.mts +1 -1
  44. package/types/engine/content-scripts.d.mts +9 -1
  45. package/types/engine/content-verse.d.mts +11 -2
  46. package/types/engine/core-report.d.mts +6 -2
  47. package/types/engine/language-names.d.mts +18 -6
  48. package/types/engine/npc-generate.d.mts +3 -0
  49. package/types/engine/page-breaks.d.mts +30 -0
  50. package/types/engine/pdf-render.d.mts +2 -0
  51. package/types/engine/site-build.d.mts +3 -1
  52. package/types/engine/site-render.d.mts +6 -2
  53. package/types/engine/site-root.d.mts +3 -1
  54. package/types/engine/site-serve-http.d.mts +1 -0
  55. package/types/engine/site-serve.d.mts +12 -6
  56. package/types/engine/verse-metre.d.mts +2 -1
  57. package/types/engine/verse-regions.d.mts +8 -0
@@ -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 note in error is written as its page shows it, and the error is
29
- * reported; a build whose gates fire writes nothing and leaves the last good
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 once, after the first build.
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 http from "node:http";
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 after the first
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
- * The address of the site's home page, how to stop serving, and a promise
103
- * for the moment every change seen so far has been written.
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
- /** @type {Set<http.ServerResponse>} */
116
- const listeners = new Set();
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
- const reload = () => {
120
- for (const res of listeners) res.write("event: reload\ndata: {}\n\n");
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 buildPackageSite({ config, out, year });
125
- if (!result.written || siteBuildFailed(result.built))
126
- report({ kind: "failed", built: result.built });
127
- if (!result.written) return false;
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.written.findings });
211
+ report({ kind: "built", built: result.built, findings: result.findings });
130
212
  return true;
131
213
  };
132
214
 
133
- const redrawNotes = (files) => {
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 true;
243
+ return null;
144
244
  }
145
245
  const page = state.pageOf(entry);
146
- state.pages = state.pages.map((old) => (old.url === page.url ? page : old));
246
+ pages = pages.map((old) => (old.url === page.url ? page : old));
147
247
  changed.add(page.url);
148
248
  }
149
- const written = writeSite({ site: state.site, pages: state.pages, out, only: changed });
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
- writeThemeFiles(out);
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
- let done = true;
186
- if (batch.content.size && !redrawNotes([...batch.content])) {
187
- done = await buildAll();
188
- if (done) await indexForSearch();
189
- } else if (batch.theme) done = redrawTheme();
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
- const watchers = [
203
- fs.watch(config.paths.content, { recursive: true }, (_event, name) => {
204
- if (name) queue("content", path.join(config.paths.content, String(name)));
205
- }),
206
- ...[SITE_THEME_DIR, SITE_STATIC_DIR]
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
- if (address === RELOAD_PATH) {
216
- res.writeHead(200, {
217
- "content-type": "text/event-stream",
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
- let file = path.join(root, address);
227
- if (!file.startsWith(root)) {
228
- res.writeHead(403).end();
229
- return;
230
- }
231
- if (fs.statSync(file, { throwIfNoEntry: false })?.isDirectory())
232
- file = path.join(file, "index.html");
233
- let status = 200;
234
- if (!fs.existsSync(file)) {
235
- status = 404;
236
- file = path.join(out, "404.html");
237
- if (!fs.existsSync(file)) {
238
- res.writeHead(404).end();
239
- return;
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}${base}`,
323
+ url: `http://${host}:${listening.port}${new URL(config.homepage).pathname}`,
324
+ done,
260
325
  settled: async () => {
261
- while (timer || pending.content.size || pending.theme) {
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
- if (timer) clearTimeout(timer);
268
- for (const watcher of watchers) watcher.close();
269
- for (const res of listeners) res.end();
335
+ closing = true;
336
+ stopWatching();
270
337
  await work;
271
- await new Promise((resolve) => server.close(() => resolve()));
338
+ started = false;
339
+ worker.postMessage({ kind: "close" });
340
+ await exited;
341
+ complete();
272
342
  },
273
343
  };
274
344
  }
@@ -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(units.map((unit) => unit.text).join(""), {})[0]
363
- ?.children ?? [];
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({
@@ -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.4",
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": "^13.0.6",
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": "^10.2.6",
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: "158.0.8122.0";
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>>): RunContext;
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.