lowkey-doc 1.1.0 → 1.1.2

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.
@@ -24,6 +24,7 @@ import { loadConfig, CLI, VERSION } from '../src/config.mjs';
24
24
  import { build } from '../src/build.mjs';
25
25
  import { init } from '../src/init.mjs';
26
26
  import { watch, DEFAULT_PORT } from '../src/watch.mjs';
27
+ import { checkForUpdate } from '../src/update.mjs';
27
28
 
28
29
  // Every option each mode accepts, and whether it takes a value. Anything else
29
30
  // is an error: a typo that silently does something else is worse than a stop.
@@ -59,6 +60,10 @@ init
59
60
  --title <name> the dashboard title
60
61
  --force replace the Claude command and skills with this version's.
61
62
  An existing config is never replaced: --docs and --title edit it.
63
+
64
+ Update check
65
+ A maximum of one time each day, lowkey-doc asks npm for the latest version.
66
+ To stop the update check, set NO_UPDATE_NOTIFIER=1.
62
67
  `;
63
68
 
64
69
  const usage = (msg) => { throw new Error(msg + '\nSee: ' + CLI + ' --help'); };
@@ -120,6 +125,14 @@ async function main() {
120
125
  if (opts['-h'] || opts['--help']) { console.log(HELP); return; }
121
126
  if (opts['-v'] || opts['--version']) { console.log(VERSION); return; }
122
127
 
128
+ // asked while the command runs, told once it is done
129
+ const update = checkForUpdate();
130
+ await run(mode, folder, opts);
131
+ const notice = await update();
132
+ if (notice) console.error(notice);
133
+ }
134
+
135
+ async function run(mode, folder, opts) {
123
136
  if (mode === 'init') {
124
137
  const r = init(folder, { docs: opts['--docs'], title: opts['--title'], force: !!opts['--force'] });
125
138
  console.log('lowkey-doc init ' + r.root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lowkey-doc",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Render a folder of Markdown into one self-contained documentation dashboard, with flowcharts and ER diagrams that stay readable when they get dense.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: write-doc
3
- description: Write a new high-level product document for this project's documentation folder. Use when asked to document a feature, flow, or area for a non-technical reader — "write a doc about X", "document the checkout flow", "add a docs page for onboarding".
3
+ description: Write a new high-level product document for this project's documentation folder, or change an existing one. Use when asked to document a feature, flow, or area for a non-technical reader — "write a doc about X", "document the checkout flow", "add a docs page for onboarding", "update the onboarding doc".
4
4
  ---
5
5
 
6
6
  # Writing a document
@@ -86,6 +86,28 @@ For an `erDiagram`, the entity is drawn as a table with the keys pinned to its r
86
86
  ## Appendix — implementation direction (only if a decision must be recorded)
87
87
  ```
88
88
 
89
+ ## Changing what someone has reviewed
90
+
91
+ Readers mark sections **Reviewed** on the dashboard, and the marks are kept in `docs.reviews.json`
92
+ beside `docs.config.json`. A mark means a person read those words and agreed with them, so it must
93
+ not outlive them. Before editing a document, look for its file name in `docs.reviews.json`:
94
+
95
+ - **Every section whose words you change loses its mark: delete its line.** A section is keyed by
96
+ its heading's GitHub-style anchor (`## 2. Inviting somebody in` → `"2-inviting-somebody-in"`),
97
+ and `""` is the document's own words: the blockquote under the title and anything before the
98
+ first section.
99
+ - **A mark covers only the words directly under its heading**, up to the next heading of any level.
100
+ Changing a sub-section removes the sub-section's line, not its chapter's; the chapter and the
101
+ document show as open on their own until the changed part is reviewed again.
102
+ - **Renaming a heading or moving a section** changes its anchor: delete its old line as well.
103
+ - Re-wrapping lines or changing only formatting leaves the words as they were: keep the mark.
104
+ - **Never add or change a mark yourself.** Only a person reviews.
105
+ - **Say which reviewed sections you changed** at the end of your reply, so the reviewer knows what
106
+ to read again.
107
+
108
+ The dashboard would show a changed section as open anyway, since each mark carries a fingerprint
109
+ of its words; deleting the line keeps the file honest in git as well.
110
+
89
111
  ## Filing
90
112
 
91
113
  - Name: `<n>. TITLE-IN-CAPS.md` in the documents folder, `<n>` being the next free number.
package/src/build.mjs CHANGED
@@ -6,13 +6,15 @@ import { execFile } from 'node:child_process';
6
6
  import { listDocs, assignIds, buildDoc } from './collect.mjs';
7
7
  import { vendorMermaid, vendorElk, vendorFont, writeNotices } from './vendor.mjs';
8
8
  import { renderPage } from './page.mjs';
9
+ import { readReviews } from './reviews.mjs';
9
10
  import { DEFAULTS, CLI, VERSION } from './config.mjs';
10
11
 
11
12
  export async function build(cfg, { open = true, refresh = false, quiet = false } = {}) {
12
13
  const { docs } = collectDocs(cfg);
13
14
 
14
15
  const assets = await vendorAssets(cfg, refresh);
15
- const html = pageFor(cfg, docs, assets);
16
+ const marks = readReviews(cfg);
17
+ const html = pageFor(cfg, docs, assets, marks.reviews);
16
18
 
17
19
  const outFile = path.join(cfg.outDir, 'index.html');
18
20
  fs.writeFileSync(outFile, html);
@@ -22,6 +24,7 @@ export async function build(cfg, { open = true, refresh = false, quiet = false }
22
24
  docs,
23
25
  bytes: fs.statSync(outFile).size,
24
26
  notes: { mermaid: assets.mermaid.note, elk: assets.elk.note, font: assets.font.note },
27
+ reviews: marks.error,
25
28
  };
26
29
 
27
30
  if (!quiet) print(cfg, report);
@@ -97,9 +100,11 @@ export async function vendorAssets(cfg, refresh = false) {
97
100
  return assets;
98
101
  }
99
102
 
100
- export function pageFor(cfg, docs, assets) {
103
+ // reviews: the marks read from docs.reviews.json. The watcher leaves them out
104
+ // of the page it fingerprints as its shell, so saving one never reloads a page.
105
+ export function pageFor(cfg, docs, assets, reviews = {}) {
101
106
  return renderPage({
102
- cfg, docs,
107
+ cfg, docs, reviews,
103
108
  fontCss: assets.font.css,
104
109
  mermaidTag: assets.mermaid.tag,
105
110
  elkTag: assets.elk.tag,
@@ -117,6 +122,7 @@ function print(cfg, r) {
117
122
  console.log(' mermaid: ' + r.notes.mermaid);
118
123
  console.log(' elk: ' + r.notes.elk);
119
124
  console.log(' font: ' + r.notes.font);
125
+ if (r.reviews) console.log(' reviews: not shown - ' + r.reviews);
120
126
  console.log(' -> ' + r.outFile + ' (' + (r.bytes / 1024).toFixed(0) + ' KB)');
121
127
  }
122
128
 
@@ -96,6 +96,7 @@ export const APP_JS = String.raw`
96
96
  current = id;
97
97
  keep('last', id);
98
98
 
99
+ light(null); // a document is kept once built: left lit, it would light again on its return
99
100
  main.textContent = '';
100
101
  main.appendChild(bodyFor(d));
101
102
 
@@ -208,67 +209,132 @@ export const APP_JS = String.raw`
208
209
  revealed = id;
209
210
  unfoldTo(t);
210
211
  t.scrollIntoView(smooth ? { behavior: 'smooth' } : undefined);
212
+ var head = t.closest('.sec-head'); // the heading, or an <a id> the author put in it
213
+ if (head) light(head.parentNode);
211
214
  requestAnimationFrame(sync); // at the foot of the page nothing scrolls, so no scroll event would
212
215
  return true;
213
216
  }
214
217
 
218
+ // The section a link led to lights up (.is-lit) and fades. The ring is drawn
219
+ // round everything in it that is on view, inside the reading area.
220
+ var lit = null;
221
+ function light(sec){
222
+ if (lit) lit.classList.remove('is-lit');
223
+ lit = sec;
224
+ if (!sec) return;
225
+ var box = sec.getBoundingClientRect(), area = main.getBoundingClientRect();
226
+ var l = box.left, r = box.right;
227
+ sec.querySelectorAll('.sec-gut,.diagram,.table-wrap').forEach(function(n){
228
+ if (!n.offsetParent) return; // inside a folded section
229
+ var b = n.getBoundingClientRect();
230
+ l = Math.min(l, b.left); r = Math.max(r, b.right);
231
+ });
232
+ sec.style.setProperty('--lit-l', Math.round(box.left - Math.max(area.left + 4, l - 16)) + 'px');
233
+ sec.style.setProperty('--lit-r', Math.round(Math.min(area.right - 4, r + 16) - box.right) + 'px');
234
+ void sec.offsetWidth; // lit again: the fade starts over
235
+ sec.classList.add('is-lit');
236
+ }
237
+ main.addEventListener('animationend', function(e){
238
+ if (e.animationName === 'sec-lit') e.target.classList.remove('is-lit');
239
+ });
240
+
215
241
  /* ---------------- reviews ----------------
216
- A reader marks what they have read and agree with. The mark is kept on the
217
- smallest sections only: a section with sections under it is reviewed when
218
- every one of them is, and the document when all of its sections are. So
219
- marking a document or a chapter marks everything in it, reviewing the last
220
- open section of a chapter completes the chapter, and a section written
221
- later opens its chapter and its document again until it is read too.
222
- Like comments, the marks live in this browser and nowhere else. */
242
+ A reader marks what they have read and agree with. Every section owns the
243
+ words directly under its heading, and the document owns its standfirst
244
+ and whatever comes before its first section; each of those is marked on
245
+ its own, with a fingerprint of the words it was given for. A section is
246
+ reviewed when its own words are and every section under it is. So marking
247
+ a chapter or the document marks everything in it, reviewing the last open
248
+ section of a chapter completes the chapter, and clearing one opens what is
249
+ above it again. Words edited after their review, and a section written
250
+ later, leave their chapter and document open until they are read too.
251
+
252
+ The marks are the project's, in docs.reviews.json: the page shows the ones
253
+ its build read, and only lowkey-doc --watch can save a change (app.saveReviews). */
223
254
 
224
255
  var RV_ON = CFG.reviews !== false;
225
- var reviewed = {};
226
- try {
227
- var rvSaved = JSON.parse(recall('reviewed') || '{}');
228
- if (rvSaved && rvSaved.docs && typeof rvSaved.docs === 'object') reviewed = rvSaved.docs;
229
- } catch (e) { reviewed = {}; }
230
-
231
- function findSec(list, id){
232
- for (var i = 0; i < list.length; i++){
233
- if (list[i].id === id) return list[i];
234
- var hit = findSec(list[i].kids, id);
235
- if (hit) return hit;
256
+ var reviews = DATA.reviews || {};
257
+ var rvSaving = 0;
258
+
259
+ // the document as the root of its own outline; its own words are the "" mark
260
+ function rvRoot(d){ return { id: '', sig: d.sig, kids: d.outline || [] }; }
261
+
262
+ // the nodes from the document down to one of its sections (id null: the document)
263
+ function rvTrail(d, id){
264
+ var trail = [rvRoot(d)];
265
+ if (!id) return trail;
266
+ var seek = function(list){
267
+ for (var i = 0; i < list.length; i++){
268
+ trail.push(list[i]);
269
+ if (list[i].id === id || seek(list[i].kids)) return true;
270
+ trail.pop();
271
+ }
272
+ return false;
273
+ };
274
+ return seek(trail[0].kids) ? trail : null;
275
+ }
276
+
277
+ function ownDone(marks, n){ var m = marks[n.id]; return n.sig == null || !!(m && m.sig === n.sig); }
278
+ function allDone(marks, n){ return ownDone(marks, n) && n.kids.every(function(k){ return allDone(marks, k); }); }
279
+
280
+ function isReviewed(d, id){
281
+ var trail = rvTrail(d, id);
282
+ return !!trail && allDone(reviews[d.file] || {}, trail[trail.length - 1]);
283
+ }
284
+
285
+ // A document's marks once one part of it is marked (on) or cleared: the
286
+ // part and everything in it, then upwards - marking completes each chapter
287
+ // whose sections are now all reviewed, clearing opens every one above.
288
+ function rvNext(d, id, on){
289
+ var trail = rvTrail(d, id);
290
+ if (!trail) return null;
291
+ var was = reviews[d.file] || {}, mine = {}, all = [];
292
+ var t = new Date(), today = t.getFullYear() + '-' + ('0' + (t.getMonth() + 1)).slice(-2) + '-' + ('0' + t.getDate()).slice(-2);
293
+ (function walk(n){ all.push(n); n.kids.forEach(walk); })(trail[0]);
294
+ all.forEach(function(n){ if (was[n.id] && n.sig != null) mine[n.id] = was[n.id]; });
295
+ var set = function(n){
296
+ if (n.sig == null) return;
297
+ var m = was[n.id];
298
+ mine[n.id] = m && m.sig === n.sig ? m : { sig: n.sig, at: today };
299
+ };
300
+ (function each(n){ if (on) set(n); else delete mine[n.id]; n.kids.forEach(each); })(trail[trail.length - 1]);
301
+ for (var i = trail.length - 2; i >= 0; i--){
302
+ if (!on){ delete mine[trail[i].id]; continue; }
303
+ if (!trail[i].kids.every(function(k){ return allDone(mine, k); })) break;
304
+ set(trail[i]);
236
305
  }
237
- return null;
306
+ var out = {};
307
+ all.forEach(function(n){ if (mine[n.id]) out[n.id] = mine[n.id]; });
308
+ return out;
238
309
  }
239
310
 
240
- // what a review of the document (id null) or of one section stands for: the
241
- // smallest sections under it, or the document itself if it has no sections
242
- function rvKeys(d, id){
243
- var outline = d.outline || [];
244
- var node = id ? findSec(outline, id) : { kids: outline };
245
- if (!node) return [];
246
- if (!node.kids.length) return [id || ''];
247
- var out = [];
248
- (function walk(list){
249
- list.forEach(function(k){ if (k.kids.length) walk(k.kids); else out.push(k.id); });
250
- })(node.kids);
251
- return out;
311
+ function putMarks(file, marks){
312
+ if (marks && Object.keys(marks).length) reviews[file] = marks; else delete reviews[file];
252
313
  }
253
314
 
254
- function rvCount(d, id){
255
- var mine = reviewed[d.id] || {}, keys = rvKeys(d, id);
256
- return { done: keys.filter(function(k){ return mine[k]; }).length, of: keys.length };
315
+ function day(at){
316
+ var p = String(at).split('-');
317
+ var t = new Date(+p[0], +p[1] - 1, +p[2]);
318
+ return isNaN(t) ? at : t.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
257
319
  }
258
- function isReviewed(d, id){ var c = rvCount(d, id); return c.of > 0 && c.done === c.of; }
259
320
 
260
- function setReviewed(d, id, on){
261
- var mine = reviewed[d.id] || {}, now = Date.now();
262
- rvKeys(d, id).forEach(function(k){ if (on) mine[k] = mine[k] || now; else delete mine[k]; });
263
- if (Object.keys(mine).length) reviewed[d.id] = mine; else delete reviewed[d.id];
264
- keep('reviewed', JSON.stringify({ version: 1, docs: reviewed }));
265
- paintReviews();
321
+ // the latest day anything in this part was reviewed, and whether its own
322
+ // words have changed since
323
+ function rvAbout(d, id){
324
+ var trail = rvTrail(d, id), marks = reviews[d.file] || {}, last = '';
325
+ if (!trail) return {};
326
+ var node = trail[trail.length - 1], m = marks[node.id];
327
+ (function walk(n){ if (marks[n.id] && marks[n.id].at > last) last = marks[n.id].at; n.kids.forEach(walk); })(node);
328
+ return { last: last, changed: !!(m && node.sig != null && m.sig !== node.sig) ? m.at : '' };
266
329
  }
267
330
 
268
- function paintBtn(btn, on, what){
331
+ function paintBtn(btn, d, id, what){
332
+ var on = isReviewed(d, id), about = rvAbout(d, id);
269
333
  btn.classList.toggle('is-on', on);
270
334
  btn.setAttribute('aria-pressed', on ? 'true' : 'false');
271
- btn.title = on ? 'Reviewed. Click to open ' + what + ' again' : 'Mark ' + what + ' as reviewed';
335
+ btn.title = on ? 'Reviewed' + (about.last ? ' on ' + day(about.last) : '') + '. Click to open ' + what + ' again'
336
+ : about.changed ? 'Changed since it was reviewed on ' + day(about.changed) + '. Click to review it again'
337
+ : 'Mark ' + what + ' as reviewed';
272
338
  }
273
339
 
274
340
  function paintReviews(){
@@ -286,22 +352,41 @@ export const APP_JS = String.raw`
286
352
  if (!d) return;
287
353
  main.querySelectorAll('.sec').forEach(function(sec){
288
354
  var btn = sec.querySelector(':scope > .sec-head > .rv');
289
- if (btn) paintBtn(btn, isReviewed(d, sec.dataset.sec), 'this section');
355
+ if (btn) paintBtn(btn, d, sec.dataset.sec, 'this section');
290
356
  });
291
357
  var whole = main.querySelector('.rv-doc'), count = main.querySelector('.rv-count');
292
- if (whole) paintBtn(whole, isReviewed(d, null), 'the whole document');
358
+ if (whole) paintBtn(whole, d, null, 'the whole document');
293
359
  if (count){
294
- var c = rvCount(d, null);
295
- count.textContent = c.done && c.done < c.of ? c.done + ' of ' + c.of + ' sections' : '';
360
+ // the sections with words of their own to review
361
+ var marks = reviews[d.file] || {}, done = 0, of = 0;
362
+ (function walk(list){
363
+ list.forEach(function(n){ if (n.sig != null){ of++; if (ownDone(marks, n)) done++; } walk(n.kids); });
364
+ })(d.outline || []);
365
+ count.textContent = done && !isReviewed(d, null) ? done + ' of ' + of + ' sections' : '';
296
366
  }
297
367
  }
298
368
 
369
+ // Shown at once, saved behind; a save that fails puts the marks back.
299
370
  function toggleReview(btn){
300
371
  var d = byId[current];
301
372
  if (!d) return;
373
+ if (!app.saveReviews){
374
+ toast('Reviews are saved to the project by lowkey-doc --watch. This page was opened from its file, so it can only show them.');
375
+ return;
376
+ }
302
377
  var sec = btn.classList.contains('rv-doc') ? null : btn.closest('.sec');
303
378
  var id = sec ? sec.dataset.sec : null;
304
- setReviewed(d, id, !isReviewed(d, id));
379
+ var before = reviews[d.file], next = rvNext(d, id, !isReviewed(d, id));
380
+ if (!next) return;
381
+ putMarks(d.file, next);
382
+ paintReviews();
383
+ rvSaving++;
384
+ app.saveReviews(d.file, next).then(function(){ rvSaving--; }, function(err){
385
+ rvSaving--;
386
+ putMarks(d.file, before);
387
+ paintReviews();
388
+ toast('Review not saved: ' + (err && err.message || err));
389
+ });
305
390
  }
306
391
 
307
392
  /* ---------------- lightbox ---------------- */
@@ -391,7 +476,11 @@ export const APP_JS = String.raw`
391
476
  var a = e.target.closest('a[href^="#"]');
392
477
  if (a){
393
478
  var href = a.getAttribute('href');
394
- if (href.indexOf('#/') === 0) return;
479
+ if (href.indexOf('#/') === 0){
480
+ // a link to where the page already is changes no hash, so would do nothing
481
+ if (a.hash === location.hash){ e.preventDefault(); route(); }
482
+ return;
483
+ }
395
484
  e.preventDefault();
396
485
  if (reveal(href.slice(1), true)) history.replaceState(null, '', '#/' + current + '/' + href.slice(1));
397
486
  return;
@@ -408,11 +497,19 @@ export const APP_JS = String.raw`
408
497
  fold(sec, !sec.classList.contains('shut'), true);
409
498
  });
410
499
 
411
- document.getElementById('toggle').addEventListener('click', function(){
412
- var on = layout.classList.toggle('collapsed');
500
+ var sideBtn = document.getElementById('toggle');
501
+ function setSide(on){
502
+ layout.classList.toggle('collapsed', on);
503
+ sideBtn.title = (on ? 'Show' : 'Hide') + ' sidebar [';
504
+ }
505
+ function toggleSide(){
506
+ layout.classList.add('anim'); // slid when the reader asks, never on load
507
+ var on = !layout.classList.contains('collapsed');
508
+ setSide(on);
413
509
  keep('collapsed', on ? '1' : '');
414
510
  schedulePlace();
415
- });
511
+ }
512
+ sideBtn.addEventListener('click', toggleSide);
416
513
 
417
514
  if (search) search.addEventListener('input', function(){
418
515
  var q = search.value.trim().toLowerCase();
@@ -448,7 +545,7 @@ export const APP_JS = String.raw`
448
545
  }
449
546
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
450
547
  if (e.key === '/' && search){ e.preventDefault(); search.focus(); }
451
- if (e.key === '[') document.getElementById('toggle').click();
548
+ if (e.key === '[') toggleSide();
452
549
  });
453
550
 
454
551
  function sync(){
@@ -765,7 +862,7 @@ export const APP_JS = String.raw`
765
862
  if (!m) return;
766
863
  unfoldTo(m);
767
864
  var r = m.getBoundingClientRect();
768
- if (r.top < 70 || r.bottom > innerHeight - 20) m.scrollIntoView({ block: 'center' });
865
+ if (r.top < 84 || r.bottom > innerHeight - 20) m.scrollIntoView({ block: 'center' });
769
866
  }
770
867
 
771
868
  // a highlight and its card are one comment seen twice: hovering either lights
@@ -954,11 +1051,13 @@ export const APP_JS = String.raw`
954
1051
 
955
1052
  var BLOCKS = 'p,li,h1,h2,h3,h4,h5,h6,pre,table,blockquote,figure,img,hr';
956
1053
  var MARK_SHARE = .5; // past half the page changed, marking all of it says nothing
957
- var TOP_EDGE = 64; // below the sticky top bar
1054
+ var TOP_EDGE = 78; // below the sticky top bar
958
1055
  var hold = null;
959
1056
 
960
- function applyUpdate(next){
1057
+ function applyUpdate(next, marks){
961
1058
  var old = byId, before = docs;
1059
+ // while this page is saving a review, its own marks are the newest there are
1060
+ if (marks && !rvSaving) reviews = marks;
962
1061
  var navOf = function(list){
963
1062
  return JSON.stringify(list.map(function(d){ return [d.id, d.title, d.badge, d.group, d.file, d.sections]; }));
964
1063
  };
@@ -993,6 +1092,7 @@ export const APP_JS = String.raw`
993
1092
  return r;
994
1093
  }
995
1094
  if (navMoved || changed.indexOf(current) >= 0) patch(old[current], byId[current], r);
1095
+ else paintReviews();
996
1096
  return r;
997
1097
  }
998
1098
 
@@ -1279,12 +1379,13 @@ export const APP_JS = String.raw`
1279
1379
 
1280
1380
  var app = window.__lowkey_doc = {
1281
1381
  save: null,
1382
+ saveReviews: null,
1282
1383
  apply: applyUpdate,
1283
1384
  bring: bring,
1284
1385
  doc: function(id){ return byId[id]; }
1285
1386
  };
1286
1387
 
1287
- if (recall('collapsed')) layout.classList.add('collapsed');
1388
+ if (recall('collapsed')) setSide(true);
1288
1389
  buildNav();
1289
1390
  route();
1290
1391
  })();
@@ -25,7 +25,8 @@ html{-webkit-text-size-adjust:100%}
25
25
  inside the sentence, not as a heavier typeface dropped into it. */
26
26
  --semi:560; --bold:620;
27
27
  --scroll:rgba(255,255,255,.12); --scroll-on:rgba(255,255,255,.22);
28
- --rail:270px;
28
+ /* --side is the sidebar's own width, --rail the room the page gives it */
29
+ --side:270px; --rail:var(--side);
29
30
  /* content column: --measure is the text width, --gutter the breathing room
30
31
  either side of it. The column is centred in the main area. */
31
32
  --measure:92ch; --gutter:46px;
@@ -78,7 +79,12 @@ body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;
78
79
  radial-gradient(circle,rgba(77,163,255,.16) 0 .6px,transparent 1.1px) 137px 71px/311px 283px}
79
80
  a{color:inherit;text-decoration:none}
80
81
  .layout{display:grid;grid-template-columns:var(--rail) 1fr;min-height:100vh}
81
- .layout.collapsed{--rail:62px}
82
+ .layout.collapsed{--rail:0px}
83
+ /* Hiding the sidebar slides it out whole: its column closes while the
84
+ sidebar moves left by as much, so nothing in it squeezes on the way out.
85
+ Only a toggle the reader makes is animated (.anim), never the state a page
86
+ opens in. */
87
+ .layout.anim{transition:grid-template-columns .28s ease}
82
88
 
83
89
  /* ---- scrollbars ----
84
90
  Chrome and Safari take the ::-webkit rules. Firefox only understands the two
@@ -94,14 +100,16 @@ a{color:inherit;text-decoration:none}
94
100
  @supports (-moz-appearance:none){*{scrollbar-width:thin;scrollbar-color:var(--scroll) transparent}}
95
101
 
96
102
  /* ---- sidebar ---- */
97
- .side{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;
103
+ .side{position:sticky;top:0;width:var(--side);height:100vh;display:flex;flex-direction:column;
98
104
  border-right:1px solid var(--line);overflow:hidden}
105
+ .collapsed .side{transform:translateX(-100%);visibility:hidden}
106
+ .anim .side{transition:transform .28s ease,visibility 0s}
107
+ .anim.collapsed .side{transition:transform .28s ease,visibility 0s .28s}
99
108
  .side-top{padding:16px 14px 12px}
100
109
  .brand{min-width:0}
101
- /* the toggle shares a row with the title, so its 28px box - and the background
102
- that appears under it on hover - is centred on the title's own line rather
103
- than on the title-plus-subtitle block */
104
- .brand-row{display:flex;align-items:center;gap:8px;min-width:0}
110
+ /* the toggle sits at the end of the title's row (it lives outside the sidebar,
111
+ see .side-btn), so the row keeps its 28px and the gap before it free */
112
+ .brand-row{display:flex;align-items:center;min-width:0;padding-right:36px}
105
113
  .brand-name{flex:1;min-width:0;height:28px;line-height:28px;font-size:14px;font-weight:var(--bold);
106
114
  letter-spacing:-.012em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
107
115
  .brand-name::before{content:"";display:inline-block;width:8px;height:8px;margin:0 9px 1px 2px;
@@ -111,6 +119,34 @@ a{color:inherit;text-decoration:none}
111
119
  transition:background .15s,color .15s,border-color .15s}
112
120
  .icon-btn:hover{background:var(--hover);color:var(--ink);border-color:var(--line)}
113
121
  .icon-btn svg{width:15px;height:15px}
122
+ /* One button hides the sidebar and brings it back. It can't live in the
123
+ sidebar - the sidebar's slide would carry it off - so it is fixed over it,
124
+ on the title's row (.side-top's 16px down, 14px in from the edge). As the
125
+ sidebar slides out the button rides along until it reaches the corner,
126
+ 12px in, and stops there; the sidebar coming back picks it up on its way.
127
+ That is the sidebar's own ease over 270px, cut off where the button's 215
128
+ run out (--ride-out), or starting only once the sidebar reaches it
129
+ (--ride-in) - both worked out for a --side of 270px.
130
+ Away, it gets a ring of its own, which comes in as it stops. */
131
+ .side-btn{position:fixed;top:16px;left:12px;z-index:8;isolation:isolate;
132
+ transform:translateX(calc(var(--side) - 55px));
133
+ --ride-out:linear(0,.041 4.9%,.117 9.9%,.228 14.8%,.364 19.8%,.505 24.7%,.636 29.6%,.751 34.6%,.848 39.5%,.931 44.5%,1 49.4%,1);
134
+ --ride-in:linear(0,0 15.9%,.238 24.3%,.453 32.7%,.621 41.1%,.746 49.5%,.838 58%,.904 66.4%,.949 74.8%,.979 83.2%,.995 91.6%,1)}
135
+ .side-btn::before{content:"";position:absolute;inset:-1px;z-index:-1;border:1px solid var(--line-2);
136
+ border-radius:inherit;background:var(--surface);box-shadow:0 6px 18px -8px rgba(0,0,0,.8);
137
+ opacity:0;transition:background .15s}
138
+ .collapsed .side-btn{transform:none}
139
+ .collapsed .side-btn::before{opacity:1}
140
+ .collapsed .side-btn:hover::before{background:var(--surface-2)}
141
+ .anim .side-btn{transition:transform .28s var(--ride-in),background .15s,color .15s,border-color .15s}
142
+ .anim.collapsed .side-btn{transition:transform .28s var(--ride-out),background .15s,color .15s,border-color .15s}
143
+ .anim .side-btn::before{transition:opacity .1s,background .15s}
144
+ .anim.collapsed .side-btn::before{transition:opacity .14s ease .1s,background .15s}
145
+ /* ...and the page keeps clear of it: the top bar's title, and the title card
146
+ when the column comes close enough to the corner (a step: 28px, or 59px) */
147
+ .collapsed .topbar-in{padding-left:max(var(--gutter),calc(52px - max(0px,(100vw - var(--column)) / 2)))}
148
+ .collapsed .head{margin-top:clamp(28px,calc((52px - (100vw - var(--column)) / 2) * 100),59px)}
149
+ .anim .head{transition:margin-top .28s ease}
114
150
  .search{margin:8px 14px 6px;position:relative}
115
151
  .search input{width:100%;padding:7px 12px 7px 31px;border:1px solid var(--line);border-radius:999px;
116
152
  background:var(--surface);color:var(--ink);font:inherit;font-size:13px;outline:none;
@@ -161,18 +197,6 @@ a{color:inherit;text-decoration:none}
161
197
  .is-reviewed>.rv-mark{display:block}
162
198
  .sub .rv-mark{align-self:center;width:12px;height:12px}
163
199
 
164
- .collapsed .brand-name,.collapsed .search,.collapsed .nav-group,
165
- .collapsed .item-title,.collapsed .caret,.collapsed .foot-lbl,.collapsed .rv-mark{display:none}
166
- .collapsed .subs,.collapsed .item.open+.subs{display:none}
167
- .collapsed .side-top{padding:16px 0 12px}
168
- .collapsed .brand-row{justify-content:center}
169
- .collapsed .nav{padding:2px 8px;scrollbar-width:none}
170
- .collapsed .nav::-webkit-scrollbar{display:none}
171
- .collapsed .item{justify-content:center;padding:8px 0;margin-bottom:2px}
172
- .collapsed .num{min-width:0;font-size:12px}
173
- .collapsed .side-foot{padding:10px 8px}
174
- .collapsed .foot-btn{justify-content:center;padding:7px 0}
175
-
176
200
  /* the foot of the sidebar: the settings, an icon with its name beside it */
177
201
  .side-foot{display:flex;padding:10px 12px;border-top:1px solid var(--line)}
178
202
  .foot-btn{flex:1;display:flex;align-items:center;gap:9px;padding:7px 12px;border:1px solid transparent;
@@ -281,7 +305,7 @@ export const CSS_MAIN = `
281
305
  Every heading owns the blocks under it, so a section can be folded away and
282
306
  the space between two of them can be set as one rule instead of guessed at
283
307
  from heading margins. */
284
- .doc [id]{scroll-margin-top:88px}
308
+ .doc [id]{scroll-margin-top:102px}
285
309
  .sec{margin-top:30px}
286
310
  .doc>.sec{margin-top:54px;padding-top:32px;border-top:1px solid var(--line)}
287
311
  .doc>.sec:first-child{margin-top:2px;padding-top:0;border-top:0}
@@ -291,7 +315,7 @@ export const CSS_MAIN = `
291
315
  .sec.shut>.sec-body{display:none}
292
316
 
293
317
  .sec-head{position:relative;display:flex;align-items:baseline;gap:11px;cursor:pointer;
294
- line-height:1.32;letter-spacing:-.018em;scroll-margin-top:98px}
318
+ line-height:1.32;letter-spacing:-.018em;scroll-margin-top:112px}
295
319
  h1.sec-head{margin:0 0 15px;font-size:22px;font-weight:var(--bold)}
296
320
  h2.sec-head{margin:0 0 13px;font-size:18.5px;font-weight:var(--bold)}
297
321
  h3.sec-head{margin:0 0 10px;font-size:15.5px;font-weight:var(--semi)}
@@ -320,6 +344,22 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
320
344
  opacity:0;transition:opacity .15s,color .15s}
321
345
  .sec-head:hover .h-anchor{opacity:1}
322
346
  .h-anchor:hover{color:var(--ink)}
347
+ /* The section a link led to: a ring lights up round it and fades away, so
348
+ which part of the page the link meant is never a guess. It stands off the
349
+ text on every side - on the left past the numbers hanging in the margin, on
350
+ the right past a diagram spilling out of the column - by --lit-l and
351
+ --lit-r, measured when it is lit. It sits behind the section, so the text,
352
+ and every box with a face of its own, stays on top of it. */
353
+ .sec.is-lit{position:relative;isolation:isolate}
354
+ .sec.is-lit::after{content:"";position:absolute;z-index:-1;pointer-events:none;
355
+ top:-14px;bottom:-14px;left:calc(0px - var(--lit-l,16px));right:calc(0px - var(--lit-r,16px));
356
+ border:1px solid var(--accent-ring);border-radius:16px;
357
+ background:linear-gradient(var(--accent-soft),rgba(77,163,255,.025) 260px);
358
+ box-shadow:0 0 24px -6px var(--accent-ring);
359
+ animation:sec-lit 2.4s ease-in-out both}
360
+ /* a chapter's box starts at the rule above it; the ring starts at its heading */
361
+ .doc>.sec.is-lit:not(:first-child)::after{top:18px}
362
+ @keyframes sec-lit{0%{opacity:0}16%,48%{opacity:1}100%{opacity:0}}
323
363
 
324
364
  /* ---- reviews ----
325
365
  Every section, and the document in its header card, has a Reviewed button:
@@ -518,7 +558,6 @@ tbody tr:last-child td{border-bottom:0}
518
558
  .empty{padding:60px var(--gutter);width:100%;max-width:var(--column);margin-inline:auto;color:var(--muted)}
519
559
  mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:0 2px}
520
560
  @media (max-width:820px){
521
- .layout{grid-template-columns:var(--rail)}
522
561
  .layout:not(.collapsed) .main{display:none}
523
562
  :root{--gutter:20px}
524
563
  :root.fixed-margin{--column:100%}
@@ -535,17 +574,23 @@ mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:
535
574
  .sec-tog{opacity:1}
536
575
  }
537
576
  @media print{
538
- .side{display:none}.layout{grid-template-columns:1fr}
577
+ .side,.side-btn{display:none}.layout{grid-template-columns:1fr}
539
578
  .head{border:0;border-radius:0;padding-block:20px;background:none}.head::before,.head::after{display:none}
540
- .sec.shut>.sec-body{display:block}.sec-tog,.h-anchor,.rv,.rv-bar{display:none}
579
+ .sec.shut>.sec-body{display:block}.sec-tog,.h-anchor,.rv,.rv-bar,.sec.is-lit::after{display:none}
541
580
  }
542
581
  `;
543
582
 
583
+ /* The bar is solid, not frosted. A backdrop blur on a fixed bar has to be
584
+ redrawn under it on every frame of a scroll, and when the browser misses one
585
+ the bar flickers between its glass and the bare page beneath - and without
586
+ the blur, anything short of opaque lets the text scrolling under it show.
587
+ It is 60px tall so the title centred in it sits on the sidebar title's line
588
+ (16px down, 28px high), and so does the corner button beside it. */
544
589
  export const CSS_BAR = `
545
590
  .topbar{position:fixed;top:0;left:var(--rail);right:0;z-index:6;pointer-events:none;
546
- display:flex;height:46px;
547
- background:rgba(8,9,11,.78);backdrop-filter:saturate(160%) blur(14px);
591
+ display:flex;height:60px;background:var(--bg);
548
592
  border-bottom:1px solid var(--line);transform:translateY(-101%);transition:transform .22s ease}
593
+ .anim .topbar{transition:transform .22s ease,left .28s ease}
549
594
  .topbar.show{transform:none}
550
595
  .topbar-in{display:flex;align-items:center;gap:10px;min-width:0;width:100%;
551
596
  max-width:var(--column);margin-inline:auto;padding:0 var(--gutter)}
@@ -79,7 +79,7 @@ export const LIVE_JS = String.raw`
79
79
  }).then(function(data){
80
80
  if (!app) return location.reload();
81
81
  version = data.version;
82
- tell(app.apply(data.docs));
82
+ tell(app.apply(data.docs, data.reviews));
83
83
  }).catch(function(err){
84
84
  say('Could not load the update: ' + (err && err.message || err), 'bad', null, 0);
85
85
  }).then(function(){
@@ -128,6 +128,20 @@ export const LIVE_JS = String.raw`
128
128
  }).then(function(){ saving--; }, function(err){ saving--; throw err; });
129
129
  };
130
130
 
131
+ /* ---------------- saving reviews ---------------- */
132
+
133
+ // One document's marks, all of them, to docs.reviews.json. The save comes
134
+ // back to every open page - this one too - as a new version of the data.
135
+ if (app) app.saveReviews = function(file, marks){
136
+ return fetch('/__lowkey-doc/reviews', {
137
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: file, marks: marks })
138
+ }).then(function(r){
139
+ return r.json().catch(function(){ return {}; }).then(function(body){
140
+ if (!r.ok) throw new Error(body.error || 'HTTP ' + r.status);
141
+ });
142
+ });
143
+ };
144
+
131
145
  /* ---------------- listening ---------------- */
132
146
 
133
147
  if (!window.EventSource) return say('Live updates need a newer browser', 'off', null, 0);
package/src/collect.mjs CHANGED
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { esc, plain, slugify, parkFences, renderBlocks, takeQuote } from './markdown.mjs';
16
16
  import { nestSections, SECNUM_RE } from './sections.mjs';
17
+ import { words, fingerprint } from './reviews.mjs';
17
18
  import { titleCase } from './config.mjs';
18
19
 
19
20
  function orderKey(name) {
@@ -93,7 +94,7 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
93
94
  const id = byFile.get(entry.file);
94
95
  const seen = new Map();
95
96
  const ctx = {
96
- blocks: [], headings: [], byFile, numbers: new Map(), outline: [], reviews: cfg.reviews !== false,
97
+ blocks: [], headings: [], byFile, numbers: new Map(), outline: [], intro: '', reviews: cfg.reviews !== false,
97
98
  uniqueSlug(base) {
98
99
  const key = base || 'section';
99
100
  const n = seen.get(key) || 0;
@@ -102,7 +103,9 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
102
103
  },
103
104
  };
104
105
 
105
- let title = name, subtitle = '', html;
106
+ // own: the document's own words, for reviews - its standfirst and whatever
107
+ // comes before its first section; a verbatim file is all its own words
108
+ let title = name, subtitle = '', html, own;
106
109
 
107
110
  if (cfg.extensions.includes(extOf(entry.file))) {
108
111
  const lines = parkFences(raw, ctx);
@@ -124,11 +127,13 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
124
127
  }
125
128
 
126
129
  html = nestSections(renderBlocks(lines, ctx, true), ctx);
130
+ own = (words(subtitle) + ' ' + ctx.intro).trim();
127
131
  } else {
128
132
  const kind = extOf(entry.file).slice(1);
129
133
  subtitle = '<p>Kept verbatim as <code>' + esc(kind) + '</code>.</p>';
130
134
  html = '<div class="code-wrap"><span class="code-lang">' + esc(kind) +
131
135
  '</span><pre class="code"><code>' + esc(raw) + '</code></pre></div>';
136
+ own = raw.replace(/\s+/g, ' ').trim();
132
137
  }
133
138
 
134
139
  const badge = (name.match(/^(\d+)/) || [])[1] || (name.trim()[0] || '?').toUpperCase();
@@ -136,6 +141,7 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
136
141
  return {
137
142
  id, title, subtitle, html, badge,
138
143
  outline: ctx.outline,
144
+ sig: own || !ctx.outline.length ? fingerprint(own) : null,
139
145
  group: entry.group,
140
146
  file: entry.rel || entry.file,
141
147
  words: raw.split(/\s+/).filter(Boolean).length,
package/src/config.mjs CHANGED
@@ -15,6 +15,8 @@ import path from 'node:path';
15
15
  export const MARGIN_MIN = 20, MARGIN_MAX = 480;
16
16
 
17
17
  export const CONFIG_NAMES = ['docs.config.json', '.lowkey-doc.json'];
18
+ // where the Reviewed marks are kept, at the project root beside the config
19
+ export const REVIEWS_FILE = 'docs.reviews.json';
18
20
 
19
21
  export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
20
22
 
@@ -139,6 +141,7 @@ export function loadConfig({ root, docs, folder, out, title, configPath, engine
139
141
  cfg.docsDir = path.resolve(projectRoot, cfg.docs);
140
142
  cfg.outDir = path.resolve(projectRoot, cfg.out);
141
143
  cfg.configFile = file;
144
+ cfg.reviewsFile = cfg.reviews === false ? null : path.join(projectRoot, REVIEWS_FILE);
142
145
  if (!cfg.title) cfg.title = titleCase(path.basename(projectRoot)) + ' Docs';
143
146
 
144
147
  // Namespace for everything the reader's browser remembers. Two dashboards
package/src/page.mjs CHANGED
@@ -9,7 +9,7 @@ import { MARGIN_MIN, MARGIN_MAX } from './config.mjs';
9
9
  const escAttr = (s) => String(s)
10
10
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
11
 
12
- export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
12
+ export function renderPage({ cfg, docs, reviews = {}, fontCss, mermaidTag, elkTag }) {
13
13
  const runtime = {
14
14
  title: cfg.title,
15
15
  ns: cfg.ns,
@@ -24,7 +24,7 @@ export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
24
24
  const rootAttrs = cfg.layout.margin == null ? ''
25
25
  : ' class="fixed-margin" style="--margin:' + Number(cfg.layout.margin) + 'px"';
26
26
 
27
- const data = JSON.stringify({ docs }).replace(/</g, '\\u003c');
27
+ const data = JSON.stringify({ docs, reviews: cfg.reviews === false ? {} : reviews }).replace(/</g, '\\u003c');
28
28
 
29
29
  // a script from the CDN carries its pinned hash: the browser refuses any other bytes
30
30
  const script = (tag) => !tag ? '' : '<script src="' + escAttr(tag.src) + '"' +
@@ -49,8 +49,7 @@ export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
49
49
 
50
50
  const commentBits = cfg.comments === false ? '' : `
51
51
  <button class="cmt-add" id="cmt-add" type="button" hidden>Comment</button>
52
- <div class="cmt-pop" id="cmt-pop" hidden></div>
53
- <div class="cmt-toast" id="cmt-toast" hidden></div>`;
52
+ <div class="cmt-pop" id="cmt-pop" hidden></div>`;
54
53
 
55
54
  return `<!doctype html>
56
55
  <html lang="en"${rootAttrs}>
@@ -62,16 +61,16 @@ export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
62
61
  </head>
63
62
  <body>
64
63
  <div class="layout" id="layout">
64
+ <button class="icon-btn side-btn" id="toggle" type="button" title="Hide sidebar [">
65
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
66
+ <rect x="1.5" y="2.5" width="13" height="11" rx="2.5"/><path d="M6 2.5v11"/>
67
+ </svg>
68
+ </button>
65
69
  <aside class="side">
66
70
  <div class="side-top">
67
71
  <div class="brand">
68
72
  <div class="brand-row">
69
73
  <span class="brand-name">${escAttr(cfg.title)}</span>
70
- <button class="icon-btn" id="toggle" type="button" title="Toggle sidebar [">
71
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
72
- <rect x="1.5" y="2.5" width="13" height="11" rx="2.5"/><path d="M6 2.5v11"/>
73
- </svg>
74
- </button>
75
74
  </div>
76
75
  </div>
77
76
  </div>${searchBox}
@@ -90,7 +89,8 @@ export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
90
89
  <div id="main"></div>
91
90
  </div>
92
91
  </div>
93
- ${commentBits}
92
+ ${commentBits}${cfg.comments === false && cfg.reviews === false ? '' : `
93
+ <div class="cmt-toast" id="cmt-toast" hidden></div>`}
94
94
  <div class="settings-veil" id="settings-veil" hidden></div>
95
95
  <div class="settings" id="settings" role="dialog" aria-modal="true" aria-label="Settings" hidden>
96
96
  <div class="settings-head">
@@ -0,0 +1,82 @@
1
+ // reviews.mjs
2
+ // ------------------------------------------------------------------
3
+ // The reader's Reviewed marks belong to the project, not to one browser: they
4
+ // are kept in docs.reviews.json beside docs.config.json, committed like any
5
+ // other file, so a team shares one set of marks and git says who changed them.
6
+ // Every build reads the file into the page. Only lowkey-doc --watch writes it:
7
+ // a page opened from its file cannot write anything.
8
+ //
9
+ // A mark carries a fingerprint of the words it was given for. Edit those words
10
+ // and the mark stops matching, so the section reads as open again - and so do
11
+ // the chapter and the document above it - until someone reviews it again.
12
+ // ------------------------------------------------------------------
13
+
14
+ import fs from 'node:fs';
15
+ import crypto from 'node:crypto';
16
+
17
+ const SIG_RE = /^[0-9a-f]{12}$/;
18
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
19
+
20
+ // The words of some rendered HTML: what a reader agrees with. Markup and the
21
+ // page's own buttons are left out, so a change in how Lowkey Doc draws a page
22
+ // does not reopen every review.
23
+ export const words = (html) => html
24
+ .replace(/<button\b[\s\S]*?<\/button>/g, ' ')
25
+ .replace(/<[^>]+>/g, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+
29
+ export const fingerprint = (text) => crypto.createHash('sha1').update(text).digest('hex').slice(0, 12);
30
+
31
+ // Only well-formed marks survive: { "<file>": { "<section id>": { sig, at } } },
32
+ // with "" standing for the document's own words.
33
+ function clean(docs) {
34
+ const out = {};
35
+ if (!docs || typeof docs !== 'object' || Array.isArray(docs)) return out;
36
+ for (const [file, marks] of Object.entries(docs)) {
37
+ if (!marks || typeof marks !== 'object' || Array.isArray(marks)) continue;
38
+ const kept = {};
39
+ for (const [key, m] of Object.entries(marks)) {
40
+ if (m && SIG_RE.test(m.sig) && DAY_RE.test(m.at)) kept[key] = { sig: m.sig, at: m.at };
41
+ }
42
+ if (Object.keys(kept).length) out[file] = kept;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ // The marks in the project, and why they could not be read if they could not.
48
+ export function readReviews(cfg) {
49
+ if (!cfg.reviewsFile) return { reviews: {}, raw: null, error: null };
50
+ let raw;
51
+ try { raw = fs.readFileSync(cfg.reviewsFile, 'utf8'); } catch { return { reviews: {}, raw: null, error: null }; }
52
+ try {
53
+ return { reviews: clean(JSON.parse(raw).docs), raw, error: null };
54
+ } catch (err) {
55
+ return { reviews: {}, raw, error: cfg.reviewsFile + ' could not be read: ' + err.message };
56
+ }
57
+ }
58
+
59
+ // One line per mark, documents in name order: a diff shows exactly which
60
+ // sections were reviewed, and two people reviewing different documents do not
61
+ // touch the same lines.
62
+ export function formatReviews(reviews) {
63
+ const q = JSON.stringify;
64
+ const files = Object.keys(reviews).sort((a, b) => a.localeCompare(b));
65
+ return '{\n "version": 1,\n "docs": {' + (files.length ? '\n' + files.map((f) =>
66
+ ' ' + q(f) + ': {\n' + Object.entries(reviews[f]).map(([key, m]) =>
67
+ ' ' + q(key) + ': { "sig": ' + q(m.sig) + ', "at": ' + q(m.at) + ' }').join(',\n') +
68
+ '\n }').join(',\n') + '\n ' : '') + '}\n}\n';
69
+ }
70
+
71
+ // Replace one document's marks and write the file. A file that cannot be read
72
+ // is never written over: it may be one merge away from holding everyone's work.
73
+ export function saveReviews(cfg, file, marks) {
74
+ const now = readReviews(cfg);
75
+ if (now.error) throw new Error(now.error + '. Fix it, and nothing in it is lost.');
76
+ const reviews = { ...now.reviews };
77
+ const kept = clean({ [file]: marks })[file];
78
+ if (kept) reviews[file] = kept; else delete reviews[file];
79
+ const text = formatReviews(reviews);
80
+ if (text !== now.raw) fs.writeFileSync(cfg.reviewsFile, text);
81
+ return { reviews, raw: text };
82
+ }
package/src/sections.mjs CHANGED
@@ -6,6 +6,8 @@
6
6
  // a document whose top level is ## still starts at 1.
7
7
  // ------------------------------------------------------------------
8
8
 
9
+ import { words, fingerprint } from './reviews.mjs';
10
+
9
11
  const FOLD_SVG = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" ' +
10
12
  'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6L8 10.5 12.5 6"/></svg>';
11
13
  const CHECK_SVG = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" ' +
@@ -36,11 +38,16 @@ export function nestSections(blocks, ctx) {
36
38
  stack[stack.length - 1].kids.push(node);
37
39
  stack.push(node);
38
40
  }
39
- // the shape of the sections alone, which the page needs for reviews without
40
- // having to read it back out of the HTML
41
- const outline = (node) => node.kids.filter((k) => typeof k !== 'string')
42
- .map((k) => ({ id: k.id, kids: outline(k) }));
41
+ // The shape of the sections, for reviews, with a fingerprint of the words
42
+ // each one holds directly under its heading. A chapter with nothing of its
43
+ // own before its first section has nothing of its own to review (sig null).
44
+ const own = (node) => words(node.kids.filter((k) => typeof k === 'string').join('\n'));
45
+ const outline = (node) => node.kids.filter((k) => typeof k !== 'string').map((k) => {
46
+ const kids = outline(k), text = own(k);
47
+ return { id: k.id, sig: text || !kids.length ? fingerprint(text) : null, kids };
48
+ });
43
49
  ctx.outline = outline(root);
50
+ ctx.intro = own(root);
44
51
  return emitSections(root, [], ctx);
45
52
  }
46
53
 
package/src/update.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // update.mjs - say so when npm has a newer lowkey-doc than the one running.
2
+ // ------------------------------------------------------------------
3
+ // The registry is asked at most once a day, while the command does its own
4
+ // work, and never for longer than WAIT: a slow or absent network costs the
5
+ // notice and nothing else. The answer is kept in the user's cache folder, so
6
+ // the runs in between only read a file.
7
+ //
8
+ // Off when stderr is not a terminal, on CI, and with NO_UPDATE_NOTIFIER set.
9
+ // ------------------------------------------------------------------
10
+
11
+ import fs from 'node:fs';
12
+ import https from 'node:https';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { VERSION } from './config.mjs';
16
+
17
+ const REGISTRY = 'https://registry.npmjs.org/lowkey-doc/latest';
18
+ const DAY = 24 * 60 * 60 * 1000;
19
+ const WAIT = 1500;
20
+
21
+ export const UPDATE_FILE = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'),
22
+ 'lowkey-doc', 'update-check.json');
23
+
24
+ const enabled = () => process.stderr.isTTY && !process.env.CI && !process.env.NO_UPDATE_NOTIFIER;
25
+
26
+ // 1.10.0 is newer than 1.9.3; a pre-release is never offered
27
+ export function newer(latest, current) {
28
+ if (!/^\d+\.\d+\.\d+$/.test(latest)) return false;
29
+ const a = latest.split('.').map(Number);
30
+ const b = current.split('-')[0].split('.').map(Number);
31
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
32
+ return current.includes('-');
33
+ }
34
+
35
+ // What to type depends on how it was run: npx keeps its own copy, and npm
36
+ // run uses the project's.
37
+ function howTo(env = process.env) {
38
+ if (env.npm_command === 'exec') return 'npx lowkey-doc@latest';
39
+ if (env.npm_command === 'run-script') return 'npm install lowkey-doc@latest';
40
+ return 'npm install -g lowkey-doc@latest';
41
+ }
42
+
43
+ export const notice = (latest, current = VERSION, env = process.env) =>
44
+ '\n Update available: lowkey-doc ' + current + ' → ' + latest + '\n Run: ' + howTo(env) + '\n';
45
+
46
+ function readCache() {
47
+ try { return JSON.parse(fs.readFileSync(UPDATE_FILE, 'utf8')); } catch { return null; }
48
+ }
49
+
50
+ function writeCache(data) {
51
+ try {
52
+ fs.mkdirSync(path.dirname(UPDATE_FILE), { recursive: true });
53
+ fs.writeFileSync(UPDATE_FILE, JSON.stringify(data) + '\n');
54
+ } catch { /* a cache we cannot write only means asking again next time */ }
55
+ }
56
+
57
+ // node:https rather than fetch: an aborted fetch still holds the process open
58
+ // until its connection attempt times out, ten seconds on a network that drops
59
+ // packets. A destroyed request lets go of its socket at once.
60
+ function latestVersion() {
61
+ return new Promise((resolve) => {
62
+ const done = (version) => { clearTimeout(timer); resolve(version); };
63
+ const req = https.get(REGISTRY, { headers: { accept: 'application/json' } }, (res) => {
64
+ if (res.statusCode !== 200) { res.resume(); return done(null); }
65
+ let body = '';
66
+ res.setEncoding('utf8');
67
+ res.on('data', (chunk) => { body += chunk; });
68
+ res.on('end', () => { try { done(JSON.parse(body).version || null); } catch { done(null); } });
69
+ });
70
+ req.on('error', () => done(null));
71
+ const timer = setTimeout(() => req.destroy(), WAIT);
72
+ });
73
+ }
74
+
75
+ // Starts the check, if one is due, and returns what to call once the command
76
+ // is done: it resolves to the notice to print, or to null.
77
+ export function checkForUpdate() {
78
+ if (!enabled()) return async () => null;
79
+ const cached = readCache();
80
+ // a failed check counts as one too, so being offline is not paid for on every run
81
+ const pending = cached && Date.now() - cached.checkedAt < DAY ? null : latestVersion().then((latest) => {
82
+ writeCache({ checkedAt: Date.now(), latest: latest || (cached && cached.latest) || null });
83
+ return latest;
84
+ });
85
+ return async () => {
86
+ const latest = (pending && await pending) || (cached && cached.latest);
87
+ return latest && newer(latest, VERSION) ? notice(latest) : null;
88
+ };
89
+ }
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);