lowkey-doc 0.0.0-stage → 1.0.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.
@@ -0,0 +1,63 @@
1
+ // sections.mjs
2
+ // ------------------------------------------------------------------
3
+ // Every heading takes ownership of the blocks that follow it, so the page can
4
+ // fold a whole section away and space two of them apart with one rule instead
5
+ // of a stack of heading margins. Numbers come from the nesting, not the level:
6
+ // a document whose top level is ## still starts at 1.
7
+ // ------------------------------------------------------------------
8
+
9
+ const FOLD_SVG = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" ' +
10
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6L8 10.5 12.5 6"/></svg>';
11
+
12
+ const HEAD_RE = /^<h([1-6]) id="([^"]+)">([\s\S]*)<\/h[1-6]>$/;
13
+ const ANCHOR_RE = /^<a class="h-anchor"[^>]*>#<\/a>/;
14
+ export const SECNUM_RE = /^(\d+(?:\.\d+)*)[.)]?\s+(?=\S)/;
15
+
16
+ export function nestSections(blocks, ctx) {
17
+ // A --- written just above a heading is the author drawing the section break
18
+ // by hand. The section itself draws that rule now, so drop the duplicate.
19
+ blocks = blocks.filter((html, n) =>
20
+ html !== '<hr>' || !(blocks[n + 1] || '').match(HEAD_RE));
21
+
22
+ const root = { level: 0, kids: [] };
23
+ const stack = [root];
24
+ for (const html of blocks) {
25
+ const m = html.match(HEAD_RE);
26
+ if (!m) { stack[stack.length - 1].kids.push(html); continue; }
27
+ const level = +m[1];
28
+ while (stack.length > 1 && stack[stack.length - 1].level >= level) stack.pop();
29
+ const node = { level, id: m[2], inner: m[3], kids: [] };
30
+ stack[stack.length - 1].kids.push(node);
31
+ stack.push(node);
32
+ }
33
+ return emitSections(root, [], ctx);
34
+ }
35
+
36
+ function emitSections(node, prefix, ctx) {
37
+ let n = 0;
38
+ return node.kids.map((kid) => {
39
+ if (typeof kid === 'string') return kid;
40
+ n++;
41
+ const anchor = (kid.inner.match(ANCHOR_RE) || [''])[0];
42
+ let text = kid.inner.slice(anchor.length);
43
+
44
+ // A heading that numbers itself keeps its own number: the prose around it
45
+ // says things like "the diagram in 1.2", and a second number invented here
46
+ // would both duplicate it and contradict the cross-reference.
47
+ const own = text.match(SECNUM_RE);
48
+ const num = own ? own[1].split('.') : prefix.concat(n);
49
+ const label = num.join('.');
50
+ if (own) text = text.slice(own[0].length);
51
+ ctx.numbers.set(kid.id, label);
52
+
53
+ const head = '<h' + kid.level + ' id="' + kid.id + '" class="sec-head">' +
54
+ '<span class="sec-gut">' +
55
+ '<button class="sec-tog" type="button" tabindex="-1" aria-label="Fold section">' + FOLD_SVG + '</button>' +
56
+ '<span class="sec-num">' + label + '</span>' +
57
+ '</span>' +
58
+ '<span class="sec-txt">' + text + '</span>' + anchor +
59
+ '</h' + kid.level + '>';
60
+ return '<section class="sec" data-sec="' + kid.id + '">' + head +
61
+ '<div class="sec-body">' + emitSections(kid, num, ctx) + '</div></section>';
62
+ }).join('\n');
63
+ }
package/src/vendor.mjs ADDED
@@ -0,0 +1,151 @@
1
+ // vendor.mjs
2
+ // ------------------------------------------------------------------
3
+ // The page is a single file:// document with no server and no npm install,
4
+ // so everything it needs travels with it. Each asset is fetched once and
5
+ // cached under <out>/vendor; --refresh re-fetches.
6
+ //
7
+ // Every asset is pinned twice: by version, and by the SHA-384 of its bytes.
8
+ // A download that does not match is refused, and so is a cached copy that
9
+ // does not - which is also how a cache left by another version is replaced.
10
+ // Whatever happens to the CDN, the page runs the bytes that were checked
11
+ // here. Bumping a version means bumping its hash: take it from the file in
12
+ // the npm tarball (npm pack <name>@<version>), never from the CDN.
13
+ //
14
+ // If a fetch fails the build still succeeds: mermaid falls back to its CDN
15
+ // (the page then needs a network connection, and the browser checks the same
16
+ // hash), ELK simply is not offered (flowcharts render with mermaid), and the
17
+ // font falls back to the system.
18
+ //
19
+ // The licenses of all three ship in licenses/ and are written beside them as
20
+ // vendor/THIRD-PARTY-NOTICES.txt. The font's goes inside the page as well,
21
+ // since the font itself does.
22
+ // ------------------------------------------------------------------
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import crypto from 'node:crypto';
27
+
28
+ // Pinned on purpose: a diagram that renders today should render the same way
29
+ // in a year, and a surprise major version should not land in someone's docs.
30
+ export const MERMAID_VERSION = '11.17.2';
31
+ export const ELK_VERSION = '0.12.0';
32
+ // @fontsource-variable/inter, which carries Inter 4.001
33
+ export const INTER_VERSION = '5.3.0';
34
+
35
+ export const NOTICES_FILE = 'THIRD-PARTY-NOTICES.txt';
36
+
37
+ const CDN = 'https://cdn.jsdelivr.net/npm/';
38
+ const LICENSES = new URL('../licenses/', import.meta.url);
39
+
40
+ const MERMAID = {
41
+ url: CDN + 'mermaid@' + MERMAID_VERSION + '/dist/mermaid.min.js',
42
+ file: 'mermaid.min.js',
43
+ sha384: 'sha384-EOXBFmc3gx5mb+vn0vPvvGqACToJD24hhacX5Yx+8NUUQrHIle/Qi5Bg9o3zKwW2',
44
+ };
45
+ const ELK = {
46
+ url: CDN + 'elkjs@' + ELK_VERSION + '/lib/elk.bundled.js',
47
+ file: 'elk.bundled.js',
48
+ sha384: 'sha384-ww57TDqx4cGknIavPm0QKO+aygLUR1BLSn2Vhbnt1XdYKWcwLyWTFKX7aZMaKIi2',
49
+ };
50
+ // The variable font with both of its axes, optical size and weight. Latin
51
+ // only - the other subsets triple the page for glyphs no doc uses.
52
+ const INTER = [
53
+ { style: 'normal', sha384: 'sha384-J6qsAXAqf6o0318WxA4xhF/vxuL4NhwP9fFcjHZXsSqdyKns1va+jGgjyn5XiIJL' },
54
+ { style: 'italic', sha384: 'sha384-wxEgdtMgsytE2z0IplCB84KY2MIh0AXdBHiaX7U1ghRtSnew3nsMP2GBUCzhRCsP' },
55
+ ].map((face) => ({
56
+ ...face,
57
+ url: CDN + '@fontsource-variable/inter@' + INTER_VERSION + '/files/inter-latin-opsz-' + face.style + '.woff2',
58
+ file: 'inter-latin-' + face.style + '.woff2',
59
+ }));
60
+
61
+ const NOTICES = [
62
+ {
63
+ name: 'Mermaid ' + MERMAID_VERSION, where: 'vendor/' + MERMAID.file, license: 'mermaid.txt',
64
+ source: 'https://github.com/mermaid-js/mermaid',
65
+ more: 'mermaid.min.js also contains Mermaid\'s own dependencies, each under its own license:\n' +
66
+ 'they are listed in Mermaid\'s package.json, at the source above.',
67
+ },
68
+ {
69
+ name: 'ELK (elkjs) ' + ELK_VERSION, where: 'vendor/' + ELK.file, license: 'elkjs.txt',
70
+ source: 'https://github.com/kieler/elkjs',
71
+ more: 'The source code of elkjs is available under the license below, at the address above.',
72
+ },
73
+ {
74
+ name: 'Inter 4.001 (@fontsource-variable/inter ' + INTER_VERSION + ')', where: 'inlined in index.html', license: 'inter.txt',
75
+ source: 'https://github.com/rsms/inter',
76
+ },
77
+ ];
78
+
79
+ const sri = (bytes) => 'sha384-' + crypto.createHash('sha384').update(bytes).digest('base64');
80
+ const license = (file) => fs.readFileSync(new URL(file, LICENSES), 'utf8');
81
+
82
+ // The asset's bytes: the cached copy while it is still the pinned one, else a
83
+ // fresh download, which is kept only if it is.
84
+ async function pinned(asset, outDir, refresh) {
85
+ const dest = path.join(outDir, 'vendor', asset.file);
86
+ if (!refresh) {
87
+ try {
88
+ const cached = fs.readFileSync(dest);
89
+ if (sri(cached) === asset.sha384) return { bytes: cached, fresh: false };
90
+ } catch { /* not cached yet */ }
91
+ }
92
+ const res = await fetch(asset.url);
93
+ if (!res.ok) throw new Error('HTTP ' + res.status);
94
+ const bytes = Buffer.from(await res.arrayBuffer());
95
+ if (sri(bytes) !== asset.sha384) throw new Error('checksum mismatch, download refused');
96
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
97
+ fs.writeFileSync(dest, bytes);
98
+ return { bytes, fresh: true };
99
+ }
100
+
101
+ async function vendorScript(asset, outDir, refresh, optional) {
102
+ try {
103
+ const { bytes, fresh } = await pinned(asset, outDir, refresh);
104
+ return {
105
+ tag: { src: 'vendor/' + asset.file },
106
+ note: fresh ? 'downloaded (' + (bytes.length / 1024 / 1024).toFixed(1) + ' MB)' : 'cached',
107
+ };
108
+ } catch (err) {
109
+ if (optional) return { tag: null, note: 'unavailable (' + err.message + ')' };
110
+ return { tag: { src: asset.url, integrity: asset.sha384 }, note: 'CDN fallback (' + err.message + ')' };
111
+ }
112
+ }
113
+
114
+ export const vendorMermaid = (outDir, refresh) => vendorScript(MERMAID, outDir, refresh, false);
115
+
116
+ export const vendorElk = (outDir, refresh) => vendorScript(ELK, outDir, refresh, true);
117
+
118
+ // Inter, as a variable font inlined into the page. A file:// origin will not
119
+ // fetch a font over CORS even from the folder next to it, so the woff2 has to
120
+ // travel as a data: URI, with its license in a comment above it.
121
+ export async function vendorFont(outDir, refresh) {
122
+ // the stylesheet versions before the font was pinned cached in its place
123
+ fs.rmSync(path.join(outDir, 'vendor', 'inter.css'), { force: true });
124
+ try {
125
+ const faces = [];
126
+ let fetched = false;
127
+ for (const face of INTER) {
128
+ const { bytes, fresh } = await pinned(face, outDir, refresh);
129
+ fetched = fetched || fresh;
130
+ faces.push('@font-face{font-family:Inter;font-weight:100 900;font-display:swap;font-style:' + face.style +
131
+ ';src:url(data:font/woff2;base64,' + bytes.toString('base64') + ') format("woff2")}');
132
+ }
133
+ const css = '/*! The Inter typeface, inlined below\n\n' + license('inter.txt') + '*/\n' + faces.join('\n');
134
+ return { css, note: fetched ? 'downloaded (' + (Buffer.byteLength(css) / 1024).toFixed(0) + ' KB inlined)' : 'cached' };
135
+ } catch (err) {
136
+ return { css: '', note: 'system fallback (' + err.message + ')' };
137
+ }
138
+ }
139
+
140
+ // The licenses of everything above, beside the files they cover. Written on
141
+ // every build, so it always matches the versions pinned here.
142
+ export function writeNotices(outDir) {
143
+ const rule = '-'.repeat(72);
144
+ const text = 'Third-party software used by index.html\n\n' +
145
+ 'Each component below keeps its own license, reproduced in full.\n\n' +
146
+ NOTICES.map((n) => rule + '\n' + n.name + ' - ' + n.where + '\nSource: ' + n.source + '\n' +
147
+ (n.more ? n.more + '\n' : '') + rule + '\n\n' + license(n.license).trimEnd() + '\n').join('\n');
148
+ const dest = path.join(outDir, 'vendor', NOTICES_FILE);
149
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
150
+ fs.writeFileSync(dest, text);
151
+ }
package/src/watch.mjs ADDED
@@ -0,0 +1,374 @@
1
+ // watch.mjs - lowkey-doc --watch: serve the page, render it again on every save.
2
+ // ------------------------------------------------------------------
3
+ // The built page stays a file:// document that needs nothing; this is the
4
+ // writing-time half. The same page is served from localhost with a small live
5
+ // client added (client/live.mjs), the documents folder is watched, and a save
6
+ // renders again only the files whose bytes changed. The open page is told,
7
+ // pulls the new data and patches itself in place: the document being read
8
+ // keeps its scroll position, its folds and its comments, and a diagram whose
9
+ // source did not change is not laid out again. index.html on disk is kept
10
+ // current as well, so the file:// page never falls behind.
11
+ //
12
+ // Changes are detected by content, not by timestamp, so a sync client or an
13
+ // editor touching a file without changing it costs nothing and shows nothing.
14
+ // Editing docs.config.json reloads the configuration and the page with it.
15
+ // ------------------------------------------------------------------
16
+
17
+ import fs from 'node:fs';
18
+ import http from 'node:http';
19
+ import path from 'node:path';
20
+ import crypto from 'node:crypto';
21
+ import { loadConfig, jsonLike, CONFIG_NAMES, MARGIN_MIN, MARGIN_MAX, VERSION } from './config.mjs';
22
+ import { collectDocs, vendorAssets, pageFor, openInBrowser } from './build.mjs';
23
+ import { LIVE_CSS, LIVE_JS } from './client/live.mjs';
24
+
25
+ export const DEFAULT_PORT = 4747;
26
+ // one save is often several writes (truncate, write, rename); act on the last
27
+ const SETTLE_MS = 120;
28
+ // Any website can point a hostname of its own at 127.0.0.1 (DNS rebinding) and
29
+ // would then read the documents through this server as if it were this page.
30
+ // The Host header still names that site, so only a local name gets an answer.
31
+ const LOCAL_HOST = /^(127\.0\.0\.1|localhost)(:\d+)?$/i;
32
+
33
+ const TYPES = {
34
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
35
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
36
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
37
+ '.gif': 'image/gif', '.webp': 'image/webp', '.woff2': 'font/woff2', '.pdf': 'application/pdf',
38
+ };
39
+
40
+ const clock = () => new Date().toTimeString().slice(0, 8) + ' ';
41
+ const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 12);
42
+
43
+ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refresh = false, quiet = false } = {}) {
44
+ let cfg = loadConfig(configArgs);
45
+ let assets = await vendorAssets(cfg, refresh);
46
+ let configRaw = readOr(cfg.configFile);
47
+ let shell = shellOf(cfg, assets);
48
+
49
+ const boot = crypto.randomBytes(3).toString('hex');
50
+ let memo = null, docs = [], html = '', seq = 0, error = null;
51
+ let watchers = [], settle = null, retry = null;
52
+ const clients = new Set();
53
+ const log = quiet ? () => {} : (msg) => console.log(clock() + msg);
54
+
55
+ const state = () => ({ version: boot + ':' + seq, shell, error });
56
+ const send = (res, s) => res.write('event: state\ndata: ' + JSON.stringify(s) + '\n\n');
57
+ const broadcast = () => { const s = state(); for (const res of clients) send(res, s); };
58
+
59
+ /* ------------------------------------------------------------ rebuilding */
60
+
61
+ function rebuild() {
62
+ const t0 = Date.now();
63
+ let r;
64
+ try {
65
+ r = collectDocs(cfg, memo);
66
+ } catch (err) {
67
+ // the page keeps showing the last good build, with the reason beside it
68
+ const msg = String(err && err.message || err);
69
+ // before the first good build there is no page to keep: the caller throws, and prints it
70
+ if (msg !== error) { error = msg; broadcast(); if (seq) log('failed ' + msg.split('\n')[0]); }
71
+ if (!fs.existsSync(cfg.docsDir)) waitForFolder();
72
+ return;
73
+ }
74
+ const ids = (list) => list.map((d) => d.id).join('\n');
75
+ const reordered = ids(docs) !== ids(r.docs);
76
+ memo = r.memo;
77
+ if (seq && !r.changed.length && !reordered && !error) return; // touched, not changed
78
+
79
+ const gone = docs.filter((d) => !r.docs.some((n) => n.id === d.id));
80
+ docs = r.docs;
81
+ error = null;
82
+ html = pageFor(cfg, docs, assets);
83
+ seq++;
84
+ try {
85
+ fs.writeFileSync(path.join(cfg.outDir, 'index.html'), html);
86
+ } catch (err) {
87
+ log('could not write index.html: ' + err.message);
88
+ }
89
+ broadcast();
90
+
91
+ if (seq === 1) return;
92
+ const what = r.changed.length > 3 ? r.changed.length + ' documents'
93
+ : r.changed.map((id) => docs.find((d) => d.id === id).file)
94
+ .concat(gone.map((d) => d.file + ' (removed)')).join(', ') || 'document order';
95
+ log('updated ' + what + ' (' + (Date.now() - t0) + ' ms)');
96
+ }
97
+
98
+ const soon = () => { clearTimeout(settle); settle = setTimeout(rebuild, SETTLE_MS); };
99
+
100
+ async function reconfigure() {
101
+ const raw = readOr(cfg.configFile);
102
+ if (raw === configRaw) return;
103
+ configRaw = raw;
104
+ try {
105
+ const next = loadConfig(configArgs);
106
+ if (next.outDir !== cfg.outDir) assets = await vendorAssets(next);
107
+ cfg = next;
108
+ shell = shellOf(cfg, assets); // a new shell tells open pages to reload
109
+ memo = null;
110
+ log('config ' + (cfg.configFile ? path.basename(cfg.configFile) : 'defaults') + ' reloaded');
111
+ arm();
112
+ rebuild();
113
+ } catch (err) {
114
+ error = String(err && err.message || err);
115
+ broadcast();
116
+ log('failed ' + error.split('\n')[0]);
117
+ }
118
+ }
119
+
120
+ /* -------------------------------------------------------------- watching */
121
+
122
+ function arm() {
123
+ disarm();
124
+ const outside = (name) => {
125
+ if (!name) return false;
126
+ const abs = path.resolve(cfg.docsDir, name);
127
+ return name.split(/[\\/]/).some((seg) => seg.startsWith('.')) ||
128
+ abs === cfg.outDir || abs.startsWith(cfg.outDir + path.sep);
129
+ };
130
+ try {
131
+ watchers = watchTree(cfg.docsDir, (name) => { if (!outside(name)) soon(); }, waitForFolder);
132
+ } catch {
133
+ return waitForFolder();
134
+ }
135
+
136
+ // The folder's own parent: renaming or deleting the watched folder is not
137
+ // reported from inside it (on Windows its watcher quietly follows it to the
138
+ // new name), and the config file usually lives there too.
139
+ const docsName = path.basename(cfg.docsDir);
140
+ const configName = cfg.configFile && path.basename(cfg.configFile);
141
+ const parents = new Set([path.dirname(cfg.docsDir)]);
142
+ if (cfg.configFile) parents.add(path.dirname(cfg.configFile));
143
+ for (const dir of parents) {
144
+ try {
145
+ const w = fs.watch(dir, (_, name) => {
146
+ name = name && String(name);
147
+ if (dir === path.dirname(cfg.docsDir) && (!name || name === docsName)) soon();
148
+ if (configName && dir === path.dirname(cfg.configFile) && (!name || name === configName)) reconfigure();
149
+ });
150
+ w.on('error', () => {});
151
+ watchers.push(w);
152
+ } catch { /* the documents still update; only a rename or a config edit needs a restart */ }
153
+ }
154
+ }
155
+
156
+ function disarm() {
157
+ for (const w of watchers) { try { w.close(); } catch {} }
158
+ watchers = [];
159
+ }
160
+
161
+ // The documents folder went away (renamed, moved, a sync client replacing
162
+ // it): the page already says so. Let go of it - a watcher left on it would
163
+ // follow it to its new name - and pick it up again once it is back.
164
+ function waitForFolder() {
165
+ if (retry) return;
166
+ disarm();
167
+ retry = setInterval(() => {
168
+ if (!fs.existsSync(cfg.docsDir)) return;
169
+ clearInterval(retry);
170
+ retry = null;
171
+ arm();
172
+ rebuild();
173
+ }, 1000);
174
+ }
175
+
176
+ /* --------------------------------------------------------------- serving */
177
+
178
+ const withLive = (page) => {
179
+ const tail = '<style>' + LIVE_CSS + '</style>\n' +
180
+ '<script>window.__LOWKEY_DOC_LIVE__=' + JSON.stringify(state()).replace(/</g, '\\u003c') + ';<\/script>\n' +
181
+ '<script>' + LIVE_JS + '<\/script>\n';
182
+ const at = page.lastIndexOf('</body>');
183
+ return at < 0 ? page + tail : page.slice(0, at) + tail + page.slice(at);
184
+ };
185
+
186
+ /* -------------------------------------------------------------- settings */
187
+
188
+ // The page's settings panel saves here. They are the project's settings, so
189
+ // they go into its config file - created next to the documents' project if
190
+ // there is none - and the page is rendered again with them. The page that
191
+ // saved already shows them and takes the new shell from the reply; any
192
+ // other open page reloads to pick them up.
193
+ function saveSettings(req, res) {
194
+ const json = (status, body) => reply(res, status, TYPES['.json'], JSON.stringify(body));
195
+ if (req.method !== 'POST') return json(405, { error: 'POST only' });
196
+ // only this page may change the project, not any site the browser has open
197
+ const origin = req.headers.origin;
198
+ if (origin && origin !== 'http://' + req.headers.host) return json(403, { error: 'Not from this page' });
199
+ if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return json(415, { error: 'JSON only' });
200
+
201
+ let body = '';
202
+ req.setEncoding('utf8');
203
+ req.on('data', (chunk) => { body += chunk; if (body.length > 4096) req.destroy(); });
204
+ req.on('end', () => {
205
+ let next;
206
+ try { next = JSON.parse(body); } catch { return json(400, { error: 'Not JSON' }); }
207
+ const margin = next && next.margin;
208
+ if (margin !== null && !(Number.isFinite(margin) && margin >= MARGIN_MIN && margin <= MARGIN_MAX)) {
209
+ return json(400, { error: 'margin must be null or ' + MARGIN_MIN + '–' + MARGIN_MAX });
210
+ }
211
+
212
+ const file = cfg.configFile || path.join(cfg.root, CONFIG_NAMES[0]);
213
+ const before = readOr(file);
214
+ try {
215
+ const obj = before == null ? {} : JSON.parse(before);
216
+ const layout = { ...(obj.layout || {}) };
217
+ if (margin === null) delete layout.margin; else layout.margin = Math.round(margin);
218
+ if (Object.keys(layout).length) obj.layout = layout; else delete obj.layout;
219
+ const text = jsonLike(before, obj);
220
+ if (text !== before) fs.writeFileSync(file, text);
221
+ // the watcher will see the write: it is already applied, so let it pass
222
+ configRaw = text;
223
+ if (!cfg.configFile) configArgs = { ...configArgs, configPath: file };
224
+ cfg = loadConfig(configArgs);
225
+ } catch (err) {
226
+ return json(500, { error: 'Could not update ' + path.basename(file) + ': ' + err.message });
227
+ }
228
+
229
+ shell = shellOf(cfg, assets);
230
+ html = pageFor(cfg, docs, assets);
231
+ try { fs.writeFileSync(path.join(cfg.outDir, 'index.html'), html); }
232
+ catch (err) { log('could not write index.html: ' + err.message); }
233
+ log('settings saved to ' + path.basename(file) + ' (margin ' + (margin === null ? 'automatic' : Math.round(margin) + ' px') + ')');
234
+ json(200, { shell });
235
+ broadcast();
236
+ });
237
+ }
238
+
239
+ const server = http.createServer((req, res) => {
240
+ if (!LOCAL_HOST.test(req.headers.host || '')) return reply(res, 403, 'text/plain; charset=utf-8', 'Forbidden');
241
+ let p;
242
+ try { p = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); }
243
+ catch { return reply(res, 400, 'text/plain; charset=utf-8', 'Bad request'); }
244
+
245
+ if (p === '/__lowkey-doc/events') {
246
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
247
+ res.write('retry: 1000\n\n');
248
+ send(res, state());
249
+ clients.add(res);
250
+ req.on('close', () => clients.delete(res));
251
+ return;
252
+ }
253
+ if (p === '/__lowkey-doc/data.json') {
254
+ return reply(res, 200, TYPES['.json'],
255
+ JSON.stringify({ version: state().version, docs }));
256
+ }
257
+ if (p === '/__lowkey-doc/settings') return saveSettings(req, res);
258
+ if (p === '/' || p === '/index.html') {
259
+ const page = html || '<!doctype html><meta charset="utf-8"><title>' + escHtml(cfg.title) +
260
+ '</title><body><pre>' + escHtml(error || 'Building…') + '</pre></body>';
261
+ return reply(res, 200, TYPES['.html'], withLive(page));
262
+ }
263
+ // the vendored scripts, then anything a document points at (images)
264
+ const file = inside(cfg.outDir, p) || inside(cfg.docsDir, p);
265
+ if (!file) return reply(res, 404, 'text/plain; charset=utf-8', 'Not found');
266
+ reply(res, 200, TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream', fs.readFileSync(file));
267
+ });
268
+
269
+ rebuild();
270
+ if (!seq) throw new Error(error);
271
+ arm();
272
+
273
+ const url = await listen(server, port);
274
+ const beat = setInterval(() => { for (const res of clients) res.write(': ping\n\n'); }, 25000);
275
+ beat.unref();
276
+
277
+ if (!quiet) {
278
+ console.log(cfg.title + ' (lowkey-doc ' + VERSION + ')');
279
+ console.log(' ' + docs.length + (docs.length === 1 ? ' document' : ' documents') + ' from ' + cfg.docsDir);
280
+ console.log(' mermaid: ' + assets.mermaid.note + ' · elk: ' + assets.elk.note + ' · font: ' + assets.font.note);
281
+ console.log(' -> ' + path.join(cfg.outDir, 'index.html'));
282
+ console.log('\n live at ' + url + ' (Ctrl+C to stop)\n');
283
+ }
284
+ if (open) openInBrowser(url);
285
+
286
+ const close = () => new Promise((resolve) => {
287
+ clearInterval(beat);
288
+ clearTimeout(settle);
289
+ clearInterval(retry);
290
+ disarm();
291
+ for (const res of clients) res.end();
292
+ clients.clear();
293
+ server.close(() => resolve());
294
+ if (server.closeAllConnections) server.closeAllConnections();
295
+ });
296
+ return { url, close };
297
+ }
298
+
299
+ /* ------------------------------------------------------------------ helpers */
300
+
301
+ // Recursive watching is native on Windows and macOS, and on Linux from Node 20.
302
+ // Elsewhere: the folder, plus each folder directly inside it - as deep as
303
+ // listDocs ever looks - following sub-folders as they come and go.
304
+ function watchTree(dir, onEvent, onError) {
305
+ try {
306
+ const w = fs.watch(dir, { recursive: true }, (_, name) => onEvent(name && String(name)));
307
+ w.on('error', onError);
308
+ return [w];
309
+ } catch (err) {
310
+ if (err.code !== 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') throw err;
311
+ }
312
+ const subs = new Map();
313
+ const follow = () => {
314
+ const now = new Set(fs.readdirSync(dir, { withFileTypes: true })
315
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name));
316
+ for (const [name, w] of subs) if (!now.has(name)) { w.close(); subs.delete(name); }
317
+ for (const name of now) {
318
+ if (subs.has(name)) continue;
319
+ try {
320
+ const w = fs.watch(path.join(dir, name), (_, f) => onEvent(name + '/' + (f || '')));
321
+ w.on('error', () => {});
322
+ subs.set(name, w);
323
+ } catch { /* gone again already */ }
324
+ }
325
+ };
326
+ const top = fs.watch(dir, (_, name) => {
327
+ try { follow(); } catch { /* the folder itself is going; its watcher reports that */ }
328
+ onEvent(name && String(name));
329
+ });
330
+ top.on('error', onError);
331
+ follow();
332
+ return [top, { close: () => { for (const w of subs.values()) w.close(); subs.clear(); } }];
333
+ }
334
+
335
+ // The page's shell is everything but the documents. When it changes - the
336
+ // configuration, or Lowkey Doc itself after a restart - an open page reloads
337
+ // rather than patching.
338
+ function shellOf(cfg, assets) {
339
+ return sha(pageFor(cfg, [], assets) + LIVE_CSS + LIVE_JS);
340
+ }
341
+
342
+ function listen(server, port, tries = 20) {
343
+ return new Promise((resolve, reject) => {
344
+ const onError = (err) => {
345
+ server.off('listening', onListening);
346
+ if (err.code === 'EADDRINUSE' && port && tries > 1) resolve(listen(server, port + 1, tries - 1));
347
+ else reject(err);
348
+ };
349
+ const onListening = () => {
350
+ server.off('error', onError);
351
+ resolve('http://127.0.0.1:' + server.address().port + '/');
352
+ };
353
+ server.once('error', onError);
354
+ server.once('listening', onListening);
355
+ server.listen(port, '127.0.0.1');
356
+ });
357
+ }
358
+
359
+ function inside(base, p) {
360
+ const file = path.resolve(base, p.replace(/^[\\/]+/, ''));
361
+ if (file !== base && !file.startsWith(base + path.sep)) return null;
362
+ try { return fs.statSync(file).isFile() ? file : null; } catch { return null; }
363
+ }
364
+
365
+ function reply(res, status, type, body) {
366
+ res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
367
+ res.end(body);
368
+ }
369
+
370
+ function readOr(file) {
371
+ try { return file ? fs.readFileSync(file, 'utf8') : null; } catch { return null; }
372
+ }
373
+
374
+ const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');