@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.
- package/CHANGELOG.md +40 -0
- package/README.md +7 -0
- package/assets/styles/content-base.css +1 -1
- package/bin/content-commands.mjs +32 -8
- package/docs/api.md +81 -32
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +59 -10
- package/docs/commands.md +60 -17
- package/docs/configuration.md +3 -2
- package/docs/content-format.md +50 -1
- package/docs/diagnostics.md +17 -0
- package/docs/guides/module-project.md +11 -3
- package/docs/guides/reference-pages.md +4 -1
- package/docs/guides/site.md +13 -3
- package/docs/guides/writing-in-invented-scripts.md +5 -2
- package/docs/reference/format-details.md +233 -138
- package/docs/reference/note-types.md +165 -152
- package/docs/reference/theme-pages.md +1 -0
- 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-format.yaml +9 -7
- package/engine/content-html.mjs +3 -2
- package/engine/content-index.mjs +23 -0
- package/engine/content-links.mjs +8 -0
- 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-tables.mjs +12 -5
- package/engine/content-verse.mjs +52 -10
- package/engine/core-report.mjs +27 -18
- package/engine/culture-reach.mjs +3 -0
- package/engine/declared-sections.mjs +100 -0
- package/engine/derived-sections.mjs +36 -2
- package/engine/dictionary-rows.mjs +2 -1
- package/engine/generated-sections.mjs +2 -0
- package/engine/holdings.mjs +107 -3
- package/engine/index-records.mjs +3 -1
- package/engine/index.mjs +1 -1
- package/engine/language-data.mjs +0 -1
- package/engine/language-names.mjs +53 -23
- package/engine/language-rows.mjs +1 -3
- package/engine/language-terms.mjs +7 -10
- package/engine/map-dot.mjs +22 -0
- package/engine/markdown-blocks.mjs +23 -8
- package/engine/markdownlint-run.mjs +1 -1
- package/engine/markup-validation.mjs +2 -0
- package/engine/metadata-index.mjs +1 -0
- package/engine/note-addresses.mjs +16 -0
- package/engine/note-vocabulary.mjs +29 -13
- package/engine/npc-generate.mjs +52 -7
- package/engine/office-holders.mjs +76 -0
- package/engine/page-breaks.mjs +143 -0
- package/engine/pdf-build.mjs +12 -4
- package/engine/pdf-render.mjs +12 -2
- package/engine/place-kinds.mjs +106 -0
- package/engine/preview-index.mjs +4 -2
- package/engine/settlement-tables.mjs +410 -675
- package/engine/settlement-terms.mjs +118 -0
- package/engine/site-build.mjs +14 -7
- 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/sql-tables.mjs +101 -7
- package/engine/standing-terms.mjs +9 -5
- package/engine/standings.mjs +83 -31
- package/engine/unique-items.mjs +85 -0
- package/engine/verse-metre.mjs +50 -5
- package/engine/verse-regions.mjs +16 -0
- package/package.json +9 -4
- package/site-theme/data/banner-kinds.yaml +2 -0
- package/sohl/actors.mjs +4 -3
- package/types/e2e.d.mts +1 -1
- package/types/engine/content-html.d.mts +2 -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/culture-reach.d.mts +3 -0
- package/types/engine/declared-sections.d.mts +30 -0
- package/types/engine/derived-sections.d.mts +5 -1
- package/types/engine/holdings.d.mts +34 -2
- package/types/engine/index-records.d.mts +2 -1
- package/types/engine/language-names.d.mts +18 -6
- package/types/engine/language-terms.d.mts +5 -1
- package/types/engine/markdown-blocks.d.mts +5 -1
- package/types/engine/npc-generate.d.mts +3 -0
- package/types/engine/office-holders.d.mts +33 -0
- package/types/engine/page-breaks.d.mts +38 -0
- package/types/engine/pdf-render.d.mts +2 -0
- package/types/engine/place-kinds.d.mts +18 -0
- package/types/engine/settlement-tables.d.mts +25 -158
- package/types/engine/settlement-terms.d.mts +53 -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/sql-tables.d.mts +35 -3
- package/types/engine/standing-terms.d.mts +3 -2
- package/types/engine/standings.d.mts +14 -2
- package/types/engine/unique-items.d.mts +12 -0
- package/types/engine/verse-metre.d.mts +2 -1
- package/types/engine/verse-regions.d.mts +8 -0
- package/vendor/markdownlint.mjs +855 -569
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/sql-tables.mjs
CHANGED
|
@@ -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[]
|
|
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
|
-
|
|
1590
|
-
|
|
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[]
|
|
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 (
|
|
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,
|
|
39
|
-
*
|
|
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
|
-
|
|
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",
|