lowkey-doc 1.0.1 → 1.1.1
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/package.json +1 -1
- package/skills/write-doc/SKILL.md +23 -1
- package/src/build.mjs +9 -3
- package/src/client/app.mjs +175 -5
- package/src/client/css.mjs +53 -17
- package/src/client/diagrams.mjs +121 -26
- package/src/client/live.mjs +15 -1
- package/src/collect.mjs +15 -12
- package/src/config.mjs +5 -1
- package/src/markdown.mjs +25 -10
- package/src/page.mjs +6 -5
- package/src/reviews.mjs +82 -0
- package/src/sections.mjs +19 -1
- package/src/watch.mjs +82 -21
package/src/watch.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// Changes are detected by content, not by timestamp, so a sync client or an
|
|
13
13
|
// editor touching a file without changing it costs nothing and shows nothing.
|
|
14
14
|
// Editing docs.config.json reloads the configuration and the page with it.
|
|
15
|
+
// The page's Reviewed marks are saved here, to docs.reviews.json, and reach
|
|
16
|
+
// every open page; an edit to that file from outside (a git pull) does too.
|
|
15
17
|
// ------------------------------------------------------------------
|
|
16
18
|
|
|
17
19
|
import fs from 'node:fs';
|
|
@@ -20,6 +22,7 @@ import path from 'node:path';
|
|
|
20
22
|
import crypto from 'node:crypto';
|
|
21
23
|
import { loadConfig, jsonLike, CONFIG_NAMES, MARGIN_MIN, MARGIN_MAX, VERSION } from './config.mjs';
|
|
22
24
|
import { collectDocs, vendorAssets, pageFor, openInBrowser } from './build.mjs';
|
|
25
|
+
import { readReviews, saveReviews } from './reviews.mjs';
|
|
23
26
|
import { LIVE_CSS, LIVE_JS } from './client/live.mjs';
|
|
24
27
|
|
|
25
28
|
export const DEFAULT_PORT = 4747;
|
|
@@ -45,6 +48,7 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
45
48
|
let assets = await vendorAssets(cfg, refresh);
|
|
46
49
|
let configRaw = readOr(cfg.configFile);
|
|
47
50
|
let shell = shellOf(cfg, assets);
|
|
51
|
+
let marks = readReviews(cfg);
|
|
48
52
|
|
|
49
53
|
const boot = crypto.randomBytes(3).toString('hex');
|
|
50
54
|
let memo = null, docs = [], html = '', seq = 0, error = null;
|
|
@@ -79,7 +83,18 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
79
83
|
const gone = docs.filter((d) => !r.docs.some((n) => n.id === d.id));
|
|
80
84
|
docs = r.docs;
|
|
81
85
|
error = null;
|
|
82
|
-
|
|
86
|
+
publish();
|
|
87
|
+
|
|
88
|
+
if (seq === 1) return;
|
|
89
|
+
const what = r.changed.length > 3 ? r.changed.length + ' documents'
|
|
90
|
+
: r.changed.map((id) => docs.find((d) => d.id === id).file)
|
|
91
|
+
.concat(gone.map((d) => d.file + ' (removed)')).join(', ') || 'document order';
|
|
92
|
+
log('updated ' + what + ' (' + (Date.now() - t0) + ' ms)');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// a new version of the page: on disk, and to every open copy
|
|
96
|
+
function publish() {
|
|
97
|
+
html = pageFor(cfg, docs, assets, marks.reviews);
|
|
83
98
|
seq++;
|
|
84
99
|
try {
|
|
85
100
|
fs.writeFileSync(path.join(cfg.outDir, 'index.html'), html);
|
|
@@ -87,16 +102,19 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
87
102
|
log('could not write index.html: ' + err.message);
|
|
88
103
|
}
|
|
89
104
|
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
105
|
}
|
|
97
106
|
|
|
98
107
|
const soon = () => { clearTimeout(settle); settle = setTimeout(rebuild, SETTLE_MS); };
|
|
99
108
|
|
|
109
|
+
// docs.reviews.json changed from outside: a pull, a merge, a hand edit
|
|
110
|
+
function rereadReviews() {
|
|
111
|
+
if (!cfg.reviewsFile || readOr(cfg.reviewsFile) === marks.raw) return;
|
|
112
|
+
marks = readReviews(cfg);
|
|
113
|
+
if (marks.error) log('reviews ' + marks.error);
|
|
114
|
+
else log('reviews ' + path.basename(cfg.reviewsFile) + ' reloaded');
|
|
115
|
+
if (seq) publish();
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
async function reconfigure() {
|
|
101
119
|
const raw = readOr(cfg.configFile);
|
|
102
120
|
if (raw === configRaw) return;
|
|
@@ -106,6 +124,7 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
106
124
|
if (next.outDir !== cfg.outDir) assets = await vendorAssets(next);
|
|
107
125
|
cfg = next;
|
|
108
126
|
shell = shellOf(cfg, assets); // a new shell tells open pages to reload
|
|
127
|
+
marks = readReviews(cfg);
|
|
109
128
|
memo = null;
|
|
110
129
|
log('config ' + (cfg.configFile ? path.basename(cfg.configFile) : 'defaults') + ' reloaded');
|
|
111
130
|
arm();
|
|
@@ -138,14 +157,17 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
138
157
|
// new name), and the config file usually lives there too.
|
|
139
158
|
const docsName = path.basename(cfg.docsDir);
|
|
140
159
|
const configName = cfg.configFile && path.basename(cfg.configFile);
|
|
160
|
+
const reviewsName = cfg.reviewsFile && path.basename(cfg.reviewsFile);
|
|
141
161
|
const parents = new Set([path.dirname(cfg.docsDir)]);
|
|
142
162
|
if (cfg.configFile) parents.add(path.dirname(cfg.configFile));
|
|
163
|
+
if (cfg.reviewsFile) parents.add(path.dirname(cfg.reviewsFile));
|
|
143
164
|
for (const dir of parents) {
|
|
144
165
|
try {
|
|
145
166
|
const w = fs.watch(dir, (_, name) => {
|
|
146
167
|
name = name && String(name);
|
|
147
168
|
if (dir === path.dirname(cfg.docsDir) && (!name || name === docsName)) soon();
|
|
148
169
|
if (configName && dir === path.dirname(cfg.configFile) && (!name || name === configName)) reconfigure();
|
|
170
|
+
if (reviewsName && dir === path.dirname(cfg.reviewsFile) && (!name || name === reviewsName)) rereadReviews();
|
|
149
171
|
});
|
|
150
172
|
w.on('error', () => {});
|
|
151
173
|
watchers.push(w);
|
|
@@ -183,27 +205,35 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
183
205
|
return at < 0 ? page + tail : page.slice(0, at) + tail + page.slice(at);
|
|
184
206
|
};
|
|
185
207
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
|
|
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) {
|
|
208
|
+
// A JSON body POSTed by this page, handed to then(value, json) - or an
|
|
209
|
+
// answer saying why not. Only this page may change the project, not any
|
|
210
|
+
// site the browser has open.
|
|
211
|
+
function posted(req, res, limit, then) {
|
|
194
212
|
const json = (status, body) => reply(res, status, TYPES['.json'], JSON.stringify(body));
|
|
195
213
|
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
214
|
const origin = req.headers.origin;
|
|
198
215
|
if (origin && origin !== 'http://' + req.headers.host) return json(403, { error: 'Not from this page' });
|
|
199
216
|
if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return json(415, { error: 'JSON only' });
|
|
200
217
|
|
|
201
218
|
let body = '';
|
|
202
219
|
req.setEncoding('utf8');
|
|
203
|
-
req.on('data', (chunk) => { body += chunk; if (body.length >
|
|
220
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > limit) req.destroy(); });
|
|
204
221
|
req.on('end', () => {
|
|
205
|
-
let
|
|
206
|
-
try {
|
|
222
|
+
let value;
|
|
223
|
+
try { value = JSON.parse(body); } catch { return json(400, { error: 'Not JSON' }); }
|
|
224
|
+
then(value, json);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* -------------------------------------------------------------- settings */
|
|
229
|
+
|
|
230
|
+
// The page's settings panel saves here. They are the project's settings, so
|
|
231
|
+
// they go into its config file - created next to the documents' project if
|
|
232
|
+
// there is none - and the page is rendered again with them. The page that
|
|
233
|
+
// saved already shows them and takes the new shell from the reply; any
|
|
234
|
+
// other open page reloads to pick them up.
|
|
235
|
+
function saveSettings(req, res) {
|
|
236
|
+
posted(req, res, 4096, (next, json) => {
|
|
207
237
|
const margin = next && next.margin;
|
|
208
238
|
if (margin !== null && !(Number.isFinite(margin) && margin >= MARGIN_MIN && margin <= MARGIN_MAX)) {
|
|
209
239
|
return json(400, { error: 'margin must be null or ' + MARGIN_MIN + '–' + MARGIN_MAX });
|
|
@@ -227,7 +257,7 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
227
257
|
}
|
|
228
258
|
|
|
229
259
|
shell = shellOf(cfg, assets);
|
|
230
|
-
html = pageFor(cfg, docs, assets);
|
|
260
|
+
html = pageFor(cfg, docs, assets, marks.reviews);
|
|
231
261
|
try { fs.writeFileSync(path.join(cfg.outDir, 'index.html'), html); }
|
|
232
262
|
catch (err) { log('could not write index.html: ' + err.message); }
|
|
233
263
|
log('settings saved to ' + path.basename(file) + ' (margin ' + (margin === null ? 'automatic' : Math.round(margin) + ' px') + ')');
|
|
@@ -236,6 +266,35 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
236
266
|
});
|
|
237
267
|
}
|
|
238
268
|
|
|
269
|
+
/* --------------------------------------------------------------- reviews */
|
|
270
|
+
|
|
271
|
+
// The page sends one document's marks, all of them, each time one changes:
|
|
272
|
+
// { file, marks: { "<section id>" | "": { sig, at } } }. Only the sections
|
|
273
|
+
// that document has now are taken, which is also how the marks of a section
|
|
274
|
+
// that has since been removed leave the file.
|
|
275
|
+
function saveReview(req, res) {
|
|
276
|
+
posted(req, res, 1 << 20, (body, json) => {
|
|
277
|
+
if (!cfg.reviewsFile) return json(404, { error: 'Reviews are turned off for this project' });
|
|
278
|
+
const doc = body && docs.find((d) => d.file === body.file);
|
|
279
|
+
if (!doc) return json(400, { error: 'No such document' });
|
|
280
|
+
const ids = new Set(['']);
|
|
281
|
+
(function walk(list) { for (const s of list) { ids.add(s.id); walk(s.kids); } })(doc.outline);
|
|
282
|
+
const given = body.marks && typeof body.marks === 'object' ? body.marks : {};
|
|
283
|
+
const kept = {};
|
|
284
|
+
for (const [key, m] of Object.entries(given)) if (ids.has(key)) kept[key] = m;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
marks = { ...saveReviews(cfg, doc.file, kept), error: null };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return json(500, { error: err.message });
|
|
290
|
+
}
|
|
291
|
+
const n = Object.keys(marks.reviews[doc.file] || {}).length;
|
|
292
|
+
log('reviews ' + doc.file + ' (' + n + (n === 1 ? ' mark' : ' marks') + ')');
|
|
293
|
+
publish();
|
|
294
|
+
json(200, { version: state().version });
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
239
298
|
const server = http.createServer((req, res) => {
|
|
240
299
|
if (!LOCAL_HOST.test(req.headers.host || '')) return reply(res, 403, 'text/plain; charset=utf-8', 'Forbidden');
|
|
241
300
|
let p;
|
|
@@ -252,9 +311,10 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
252
311
|
}
|
|
253
312
|
if (p === '/__lowkey-doc/data.json') {
|
|
254
313
|
return reply(res, 200, TYPES['.json'],
|
|
255
|
-
JSON.stringify({ version: state().version, docs }));
|
|
314
|
+
JSON.stringify({ version: state().version, docs, reviews: marks.reviews }));
|
|
256
315
|
}
|
|
257
316
|
if (p === '/__lowkey-doc/settings') return saveSettings(req, res);
|
|
317
|
+
if (p === '/__lowkey-doc/reviews') return saveReview(req, res);
|
|
258
318
|
if (p === '/' || p === '/index.html') {
|
|
259
319
|
const page = html || '<!doctype html><meta charset="utf-8"><title>' + escHtml(cfg.title) +
|
|
260
320
|
'</title><body><pre>' + escHtml(error || 'Building…') + '</pre></body>';
|
|
@@ -279,6 +339,7 @@ export async function watch(configArgs, { port = DEFAULT_PORT, open = true, refr
|
|
|
279
339
|
console.log(' ' + docs.length + (docs.length === 1 ? ' document' : ' documents') + ' from ' + cfg.docsDir);
|
|
280
340
|
console.log(' mermaid: ' + assets.mermaid.note + ' · elk: ' + assets.elk.note + ' · font: ' + assets.font.note);
|
|
281
341
|
console.log(' -> ' + path.join(cfg.outDir, 'index.html'));
|
|
342
|
+
if (marks.error) console.log(' reviews: not shown - ' + marks.error);
|
|
282
343
|
console.log('\n live at ' + url + ' (Ctrl+C to stop)\n');
|
|
283
344
|
}
|
|
284
345
|
if (open) openInBrowser(url);
|