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/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
- html = pageFor(cfg, docs, assets);
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
- /* -------------------------------------------------------------- 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) {
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 > 4096) req.destroy(); });
220
+ req.on('data', (chunk) => { body += chunk; if (body.length > limit) req.destroy(); });
204
221
  req.on('end', () => {
205
- let next;
206
- try { next = JSON.parse(body); } catch { return json(400, { error: 'Not JSON' }); }
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);