@heroiclands/package-build 25.5.4 → 25.6.0

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 (108) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +7 -0
  3. package/assets/styles/content-base.css +1 -1
  4. package/bin/content-commands.mjs +32 -8
  5. package/docs/api.md +81 -32
  6. package/docs/authoring/frontmatter.md +1 -1
  7. package/docs/authoring/links-and-markup.md +59 -10
  8. package/docs/commands.md +60 -17
  9. package/docs/configuration.md +3 -2
  10. package/docs/content-format.md +50 -1
  11. package/docs/diagnostics.md +17 -0
  12. package/docs/guides/module-project.md +11 -3
  13. package/docs/guides/reference-pages.md +4 -1
  14. package/docs/guides/site.md +13 -3
  15. package/docs/guides/writing-in-invented-scripts.md +5 -2
  16. package/docs/reference/format-details.md +233 -138
  17. package/docs/reference/note-types.md +165 -152
  18. package/docs/reference/theme-pages.md +1 -0
  19. package/e2e.mjs +1 -1
  20. package/engine/base-compiler.mjs +2 -0
  21. package/engine/compile-corpus.mjs +6 -2
  22. package/engine/content-charset.mjs +1 -1
  23. package/engine/content-format.yaml +9 -7
  24. package/engine/content-html.mjs +3 -2
  25. package/engine/content-index.mjs +23 -0
  26. package/engine/content-links.mjs +8 -0
  27. package/engine/content-render.mjs +14 -2
  28. package/engine/content-scripts.mjs +44 -6
  29. package/engine/content-session.mjs +11 -7
  30. package/engine/content-spans.mjs +3 -4
  31. package/engine/content-tables.mjs +12 -5
  32. package/engine/content-verse.mjs +52 -10
  33. package/engine/core-report.mjs +27 -18
  34. package/engine/culture-reach.mjs +3 -0
  35. package/engine/declared-sections.mjs +100 -0
  36. package/engine/derived-sections.mjs +36 -2
  37. package/engine/dictionary-rows.mjs +2 -1
  38. package/engine/generated-sections.mjs +2 -0
  39. package/engine/holdings.mjs +107 -3
  40. package/engine/index-records.mjs +3 -1
  41. package/engine/index.mjs +1 -1
  42. package/engine/language-data.mjs +0 -1
  43. package/engine/language-names.mjs +53 -23
  44. package/engine/language-rows.mjs +1 -3
  45. package/engine/language-terms.mjs +7 -10
  46. package/engine/map-dot.mjs +22 -0
  47. package/engine/markdown-blocks.mjs +23 -8
  48. package/engine/markdownlint-run.mjs +1 -1
  49. package/engine/markup-validation.mjs +2 -0
  50. package/engine/metadata-index.mjs +1 -0
  51. package/engine/note-addresses.mjs +16 -0
  52. package/engine/note-vocabulary.mjs +29 -13
  53. package/engine/npc-generate.mjs +52 -7
  54. package/engine/office-holders.mjs +76 -0
  55. package/engine/page-breaks.mjs +143 -0
  56. package/engine/pdf-build.mjs +12 -4
  57. package/engine/pdf-render.mjs +12 -2
  58. package/engine/place-kinds.mjs +106 -0
  59. package/engine/preview-index.mjs +4 -2
  60. package/engine/settlement-tables.mjs +410 -675
  61. package/engine/settlement-terms.mjs +118 -0
  62. package/engine/site-build.mjs +14 -7
  63. package/engine/site-preview.mjs +3 -0
  64. package/engine/site-render.mjs +18 -4
  65. package/engine/site-root.mjs +10 -1
  66. package/engine/site-serve-http.mjs +151 -0
  67. package/engine/site-serve.mjs +196 -126
  68. package/engine/sql-tables.mjs +101 -7
  69. package/engine/standing-terms.mjs +9 -5
  70. package/engine/standings.mjs +83 -31
  71. package/engine/unique-items.mjs +85 -0
  72. package/engine/verse-metre.mjs +50 -5
  73. package/engine/verse-regions.mjs +16 -0
  74. package/package.json +9 -4
  75. package/site-theme/data/banner-kinds.yaml +2 -0
  76. package/sohl/actors.mjs +4 -3
  77. package/types/e2e.d.mts +1 -1
  78. package/types/engine/content-html.d.mts +2 -1
  79. package/types/engine/content-scripts.d.mts +9 -1
  80. package/types/engine/content-verse.d.mts +11 -2
  81. package/types/engine/core-report.d.mts +6 -2
  82. package/types/engine/culture-reach.d.mts +3 -0
  83. package/types/engine/declared-sections.d.mts +30 -0
  84. package/types/engine/derived-sections.d.mts +5 -1
  85. package/types/engine/holdings.d.mts +34 -2
  86. package/types/engine/index-records.d.mts +2 -1
  87. package/types/engine/language-names.d.mts +18 -6
  88. package/types/engine/language-terms.d.mts +5 -1
  89. package/types/engine/markdown-blocks.d.mts +5 -1
  90. package/types/engine/npc-generate.d.mts +3 -0
  91. package/types/engine/office-holders.d.mts +33 -0
  92. package/types/engine/page-breaks.d.mts +38 -0
  93. package/types/engine/pdf-render.d.mts +2 -0
  94. package/types/engine/place-kinds.d.mts +18 -0
  95. package/types/engine/settlement-tables.d.mts +25 -158
  96. package/types/engine/settlement-terms.d.mts +53 -0
  97. package/types/engine/site-build.d.mts +3 -1
  98. package/types/engine/site-render.d.mts +6 -2
  99. package/types/engine/site-root.d.mts +3 -1
  100. package/types/engine/site-serve-http.d.mts +1 -0
  101. package/types/engine/site-serve.d.mts +12 -6
  102. package/types/engine/sql-tables.d.mts +35 -3
  103. package/types/engine/standing-terms.d.mts +3 -2
  104. package/types/engine/standings.d.mts +14 -2
  105. package/types/engine/unique-items.d.mts +12 -0
  106. package/types/engine/verse-metre.d.mts +2 -1
  107. package/types/engine/verse-regions.d.mts +8 -0
  108. package/vendor/markdownlint.mjs +855 -569
@@ -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
  }
@@ -217,6 +217,59 @@ export function findSqlBlocks(markdown) {
217
217
  return blocks;
218
218
  }
219
219
 
220
+ /**
221
+ * Overwrite string literals and comments with spaces, keeping every offset and
222
+ * newline, so a search of what remains reads only identifiers and operators.
223
+ *
224
+ * @param {string} sql - A query.
225
+ * @returns {string} The query, the same length.
226
+ */
227
+ function maskLiterals(sql) {
228
+ const blank = (text) => text.replace(/[^\n]/g, " ");
229
+ return sql.replace(/'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g, blank);
230
+ }
231
+
232
+ /**
233
+ * Where a query reads the `file` block of the index record: `file.path`,
234
+ * `file.folder` or `file.name`, bare or qualified by a relation or its alias.
235
+ * A note is selected by property, tag or address, so a query that reaches for
236
+ * the file it was written in is refused. A field of a note's own data that is
237
+ * named `file` is not the block and is left alone.
238
+ *
239
+ * @param {string} sql - One fence's query.
240
+ * @returns {{line: number, column: number, text: string}|undefined} The first
241
+ * reference, its 0-based line within the query and 1-based column, or
242
+ * nothing where the query makes none.
243
+ */
244
+ export function findFileReference(sql) {
245
+ const masked = maskLiterals(String(sql ?? ""));
246
+ const qualifiers = new Set();
247
+ for (const [, relation, alias] of masked.matchAll(
248
+ /\b(?:from|join)\s+([\w."]+)(?:\s+(?:as\s+)?([A-Za-z_]\w*))?/gi,
249
+ )) {
250
+ qualifiers.add(relation.split(".").at(-1).replaceAll('"', ""));
251
+ if (
252
+ alias &&
253
+ !/^(where|group|order|limit|join|inner|left|right|full|cross|on|union|having|window|qualify|using|natural)$/i.test(
254
+ alias,
255
+ )
256
+ )
257
+ qualifiers.add(alias);
258
+ }
259
+ const reference = /(?<![\w$"])(?:(\w+|"\w+")\s*\.\s*)?"?file"?\s*\.\s*"?(path|folder|name)\b/gi;
260
+ for (const match of masked.matchAll(reference)) {
261
+ const qualifier = match[1]?.replaceAll('"', "");
262
+ if (qualifier && !qualifiers.has(qualifier)) continue;
263
+ const before = masked.slice(0, match.index);
264
+ return {
265
+ line: before.split("\n").length - 1,
266
+ column: match.index - (before.lastIndexOf("\n") + 1) + 1,
267
+ text: match[0].replace(/\s+/g, ""),
268
+ };
269
+ }
270
+ return undefined;
271
+ }
272
+
220
273
  /** Omit the Foundry export from SQL; the canonical index stays intact.
221
274
  * Keep encoding source keys as case-sensitive values in the SQL projection.
222
275
  * The canonical index retains its authored mapping object; DuckDB structs
@@ -1371,6 +1424,16 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
1371
1424
  for (const [ordinal, block] of blocks.entries()) {
1372
1425
  try {
1373
1426
  if (block.problems.length) throw new Error(block.problems.join("; "));
1427
+ const reference = findFileReference(block.query);
1428
+ if (reference) {
1429
+ forNote.push({
1430
+ reason: `\`${reference.text}\` is not available to a query; a query selects notes by property, tag or address`,
1431
+ line: block.line + 1 + reference.line,
1432
+ column: reference.column,
1433
+ allowEmpty: block.allowEmpty,
1434
+ });
1435
+ continue;
1436
+ }
1374
1437
  const result = await runSqlQuery(reader, block.query);
1375
1438
  const options = {
1376
1439
  linkable,
@@ -1558,11 +1621,13 @@ export async function prepareInlineSqlExpressions(db, sources) {
1558
1621
  * @param {Map<string, Map<string, string>>} [opts.derived] - The derived
1559
1622
  * lists, from an earlier call over the same records, which are not derived
1560
1623
  * again.
1624
+ * @param {Map<string,{raw?:string}>} [opts.parsed] - Captured source locators.
1561
1625
  * @param {object[]} [opts.all=records] - Complete records for lexical lineage
1562
1626
  * traversal through providers outside the surface's audience.
1563
1627
  * @returns {Promise<{sections: Map<string, string>, views: Set<string>,
1564
1628
  * derived: Map<string, Map<string, string>>,
1565
- * lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}>} Note file to its sections,
1629
+ * lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]>,
1630
+ * declared: Map<string, Array<{slug:string,heading:string,markdown:string}>>}>} Note file to its sections,
1566
1631
  * joined in order; the files whose sections hold an event view's fence; and
1567
1632
  * the derived lists alone.
1568
1633
  */
@@ -1570,7 +1635,7 @@ export async function generatedSectionsFor(
1570
1635
  db,
1571
1636
  records,
1572
1637
  contentBase,
1573
- { only, foreignIndex, config, derived: given, all = records } = {},
1638
+ { only, foreignIndex, config, derived: given, all = records, parsed } = {},
1574
1639
  ) {
1575
1640
  const notes = records.filter((record) => isNoteRecord(record));
1576
1641
  const fileOf = (record) => noteFile(contentBase, record);
@@ -1583,11 +1648,27 @@ export async function generatedSectionsFor(
1583
1648
  foreignIndex,
1584
1649
  config,
1585
1650
  only,
1651
+ all,
1652
+ rawOf: (file) => parsed?.get(file)?.raw,
1586
1653
  });
1587
1654
  const views = db ? await eventViewSections(db, notes, fileOf, only) : new Map();
1588
1655
  const sections = new Map();
1589
- for (const file of new Set([...derived.keys(), ...views.keys()]))
1590
- sections.set(file, joinSections([derived.get(file), views.get(file)]));
1656
+ const declared = new Map();
1657
+ for (const file of new Set([...derived.keys(), ...views.keys()])) {
1658
+ const lists = new Map(derived.get(file));
1659
+ const settlements = lists.get("settlements");
1660
+ if (settlements)
1661
+ declared.set(file, [
1662
+ {
1663
+ slug: "settlements",
1664
+ heading: "Settlements",
1665
+ markdown: settlements.replace(/^# Settlements \{#settlements\}\s*\n/, ""),
1666
+ },
1667
+ ]);
1668
+ lists.delete("settlements");
1669
+ const text = joinSections([lists, views.get(file)]);
1670
+ if (text) sections.set(file, text);
1671
+ }
1591
1672
  // Automatic sections follow their source's audience even in a Foundry
1592
1673
  // corpus containing both public and private packs. Explicit SQL keeps
1593
1674
  // the database audience the caller requested.
@@ -1607,7 +1688,7 @@ export async function generatedSectionsFor(
1607
1688
  if (owners.has(key) && (only === undefined || fileOf(note) === only))
1608
1689
  lexical.set(fileOf(note), owners.get(key));
1609
1690
  }
1610
- return { sections, views: new Set(views.keys()), derived, lexical };
1691
+ return { sections, views: new Set(views.keys()), derived, lexical, declared };
1611
1692
  }
1612
1693
 
1613
1694
  /**
@@ -1654,11 +1735,16 @@ export function joinGeneratedSections(generated, sources, parsed) {
1654
1735
  * empty set carrying them.
1655
1736
  *
1656
1737
  * @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
1657
- * @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}} generated - From
1738
+ * @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>, declared?: Map<string, Array<{slug:string,heading:string,markdown:string}>>}} generated - From
1658
1739
  * {@link generatedSectionsFor}.
1659
1740
  * @returns {void}
1660
1741
  */
1661
1742
  export function attachGeneratedSections(prepared, generated) {
1743
+ for (const [absPath, sections] of generated.declared ?? []) {
1744
+ let results = prepared.get(absPath);
1745
+ if (!results) prepared.set(absPath, (results = []));
1746
+ results.declared = sections;
1747
+ }
1662
1748
  for (const [absPath, text] of generated.sections) {
1663
1749
  let results = prepared.get(absPath);
1664
1750
  if (!results) prepared.set(absPath, (results = []));
@@ -1881,11 +1967,18 @@ export async function prepareTreeSqlTables(
1881
1967
  }
1882
1968
  }
1883
1969
  const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
1970
+ parsed,
1884
1971
  foreignIndex: foreign,
1885
1972
  config,
1886
1973
  all: allRecords,
1887
1974
  });
1888
- if (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
1975
+ if (
1976
+ !sources.length &&
1977
+ !hasEvents &&
1978
+ !derivedOnly.sections.size &&
1979
+ !derivedOnly.lexical.size &&
1980
+ !derivedOnly.declared.size
1981
+ )
1889
1982
  return undefined;
1890
1983
 
1891
1984
  // A page list is a filter over the records, so it needs no database. A tree
@@ -1940,6 +2033,7 @@ export async function prepareTreeSqlTables(
1940
2033
  }));
1941
2034
  try {
1942
2035
  const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
2036
+ parsed,
1943
2037
  derived: derivedOnly.derived,
1944
2038
  all: allRecords,
1945
2039
  });
@@ -35,8 +35,9 @@ export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
35
35
  * The keys one standing carries, as inner-key declarations.
36
36
  *
37
37
  * `rank` is the level on the body's own `governance.ranks` ladder, and every
38
- * membership states one. `office` is a key of its `governance.offices` map, and
39
- * is stated only where the being holds a post. `societies` names the societies
38
+ * membership states one. `office` is a key of its `governance.offices` map, or
39
+ * a list of them where the being holds several posts in that body, and is
40
+ * stated only where the being holds a post. `societies` names the societies
40
41
  * the membership is held in, as a list because a membership can span several.
41
42
  *
42
43
  * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
@@ -53,9 +54,12 @@ export const STANDING_FIELDS = Object.freeze([
53
54
  }),
54
55
  Object.freeze({
55
56
  name: "office",
56
- kind: "string",
57
- shape: "a post from that body's `governance.offices`",
58
- describe: "A post the body names in its `governance.offices`, where one is held.",
57
+ kind: "string-or-list",
58
+ shape: "a post from that body's `governance.offices`, or a list of them",
59
+ entries: Object.freeze({ kind: "string", shape: "a post" }),
60
+ describe:
61
+ "A post the body names in its `governance.offices`, where one is held, or a list of " +
62
+ "the posts one being holds in that body.",
59
63
  }),
60
64
  Object.freeze({
61
65
  name: "societies",