lowkey-doc 1.1.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lowkey-doc",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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
 
@@ -213,62 +213,102 @@ export const APP_JS = String.raw`
213
213
  }
214
214
 
215
215
  /* ---------------- 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. */
216
+ A reader marks what they have read and agree with. Every section owns the
217
+ words directly under its heading, and the document owns its standfirst
218
+ and whatever comes before its first section; each of those is marked on
219
+ its own, with a fingerprint of the words it was given for. A section is
220
+ reviewed when its own words are and every section under it is. So marking
221
+ a chapter or the document marks everything in it, reviewing the last open
222
+ section of a chapter completes the chapter, and clearing one opens what is
223
+ above it again. Words edited after their review, and a section written
224
+ later, leave their chapter and document open until they are read too.
225
+
226
+ The marks are the project's, in docs.reviews.json: the page shows the ones
227
+ its build read, and only lowkey-doc --watch can save a change (app.saveReviews). */
223
228
 
224
229
  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;
230
+ var reviews = DATA.reviews || {};
231
+ var rvSaving = 0;
232
+
233
+ // the document as the root of its own outline; its own words are the "" mark
234
+ function rvRoot(d){ return { id: '', sig: d.sig, kids: d.outline || [] }; }
235
+
236
+ // the nodes from the document down to one of its sections (id null: the document)
237
+ function rvTrail(d, id){
238
+ var trail = [rvRoot(d)];
239
+ if (!id) return trail;
240
+ var seek = function(list){
241
+ for (var i = 0; i < list.length; i++){
242
+ trail.push(list[i]);
243
+ if (list[i].id === id || seek(list[i].kids)) return true;
244
+ trail.pop();
245
+ }
246
+ return false;
247
+ };
248
+ return seek(trail[0].kids) ? trail : null;
249
+ }
250
+
251
+ function ownDone(marks, n){ var m = marks[n.id]; return n.sig == null || !!(m && m.sig === n.sig); }
252
+ function allDone(marks, n){ return ownDone(marks, n) && n.kids.every(function(k){ return allDone(marks, k); }); }
253
+
254
+ function isReviewed(d, id){
255
+ var trail = rvTrail(d, id);
256
+ return !!trail && allDone(reviews[d.file] || {}, trail[trail.length - 1]);
257
+ }
258
+
259
+ // A document's marks once one part of it is marked (on) or cleared: the
260
+ // part and everything in it, then upwards - marking completes each chapter
261
+ // whose sections are now all reviewed, clearing opens every one above.
262
+ function rvNext(d, id, on){
263
+ var trail = rvTrail(d, id);
264
+ if (!trail) return null;
265
+ var was = reviews[d.file] || {}, mine = {}, all = [];
266
+ var t = new Date(), today = t.getFullYear() + '-' + ('0' + (t.getMonth() + 1)).slice(-2) + '-' + ('0' + t.getDate()).slice(-2);
267
+ (function walk(n){ all.push(n); n.kids.forEach(walk); })(trail[0]);
268
+ all.forEach(function(n){ if (was[n.id] && n.sig != null) mine[n.id] = was[n.id]; });
269
+ var set = function(n){
270
+ if (n.sig == null) return;
271
+ var m = was[n.id];
272
+ mine[n.id] = m && m.sig === n.sig ? m : { sig: n.sig, at: today };
273
+ };
274
+ (function each(n){ if (on) set(n); else delete mine[n.id]; n.kids.forEach(each); })(trail[trail.length - 1]);
275
+ for (var i = trail.length - 2; i >= 0; i--){
276
+ if (!on){ delete mine[trail[i].id]; continue; }
277
+ if (!trail[i].kids.every(function(k){ return allDone(mine, k); })) break;
278
+ set(trail[i]);
236
279
  }
237
- return null;
280
+ var out = {};
281
+ all.forEach(function(n){ if (mine[n.id]) out[n.id] = mine[n.id]; });
282
+ return out;
238
283
  }
239
284
 
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;
285
+ function putMarks(file, marks){
286
+ if (marks && Object.keys(marks).length) reviews[file] = marks; else delete reviews[file];
252
287
  }
253
288
 
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 };
289
+ function day(at){
290
+ var p = String(at).split('-');
291
+ var t = new Date(+p[0], +p[1] - 1, +p[2]);
292
+ return isNaN(t) ? at : t.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
257
293
  }
258
- function isReviewed(d, id){ var c = rvCount(d, id); return c.of > 0 && c.done === c.of; }
259
294
 
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();
295
+ // the latest day anything in this part was reviewed, and whether its own
296
+ // words have changed since
297
+ function rvAbout(d, id){
298
+ var trail = rvTrail(d, id), marks = reviews[d.file] || {}, last = '';
299
+ if (!trail) return {};
300
+ var node = trail[trail.length - 1], m = marks[node.id];
301
+ (function walk(n){ if (marks[n.id] && marks[n.id].at > last) last = marks[n.id].at; n.kids.forEach(walk); })(node);
302
+ return { last: last, changed: !!(m && node.sig != null && m.sig !== node.sig) ? m.at : '' };
266
303
  }
267
304
 
268
- function paintBtn(btn, on, what){
305
+ function paintBtn(btn, d, id, what){
306
+ var on = isReviewed(d, id), about = rvAbout(d, id);
269
307
  btn.classList.toggle('is-on', on);
270
308
  btn.setAttribute('aria-pressed', on ? 'true' : 'false');
271
- btn.title = on ? 'Reviewed. Click to open ' + what + ' again' : 'Mark ' + what + ' as reviewed';
309
+ btn.title = on ? 'Reviewed' + (about.last ? ' on ' + day(about.last) : '') + '. Click to open ' + what + ' again'
310
+ : about.changed ? 'Changed since it was reviewed on ' + day(about.changed) + '. Click to review it again'
311
+ : 'Mark ' + what + ' as reviewed';
272
312
  }
273
313
 
274
314
  function paintReviews(){
@@ -286,22 +326,41 @@ export const APP_JS = String.raw`
286
326
  if (!d) return;
287
327
  main.querySelectorAll('.sec').forEach(function(sec){
288
328
  var btn = sec.querySelector(':scope > .sec-head > .rv');
289
- if (btn) paintBtn(btn, isReviewed(d, sec.dataset.sec), 'this section');
329
+ if (btn) paintBtn(btn, d, sec.dataset.sec, 'this section');
290
330
  });
291
331
  var whole = main.querySelector('.rv-doc'), count = main.querySelector('.rv-count');
292
- if (whole) paintBtn(whole, isReviewed(d, null), 'the whole document');
332
+ if (whole) paintBtn(whole, d, null, 'the whole document');
293
333
  if (count){
294
- var c = rvCount(d, null);
295
- count.textContent = c.done && c.done < c.of ? c.done + ' of ' + c.of + ' sections' : '';
334
+ // the sections with words of their own to review
335
+ var marks = reviews[d.file] || {}, done = 0, of = 0;
336
+ (function walk(list){
337
+ list.forEach(function(n){ if (n.sig != null){ of++; if (ownDone(marks, n)) done++; } walk(n.kids); });
338
+ })(d.outline || []);
339
+ count.textContent = done && !isReviewed(d, null) ? done + ' of ' + of + ' sections' : '';
296
340
  }
297
341
  }
298
342
 
343
+ // Shown at once, saved behind; a save that fails puts the marks back.
299
344
  function toggleReview(btn){
300
345
  var d = byId[current];
301
346
  if (!d) return;
347
+ if (!app.saveReviews){
348
+ toast('Reviews are saved to the project by lowkey-doc --watch. This page was opened from its file, so it can only show them.');
349
+ return;
350
+ }
302
351
  var sec = btn.classList.contains('rv-doc') ? null : btn.closest('.sec');
303
352
  var id = sec ? sec.dataset.sec : null;
304
- setReviewed(d, id, !isReviewed(d, id));
353
+ var before = reviews[d.file], next = rvNext(d, id, !isReviewed(d, id));
354
+ if (!next) return;
355
+ putMarks(d.file, next);
356
+ paintReviews();
357
+ rvSaving++;
358
+ app.saveReviews(d.file, next).then(function(){ rvSaving--; }, function(err){
359
+ rvSaving--;
360
+ putMarks(d.file, before);
361
+ paintReviews();
362
+ toast('Review not saved: ' + (err && err.message || err));
363
+ });
305
364
  }
306
365
 
307
366
  /* ---------------- lightbox ---------------- */
@@ -957,8 +1016,10 @@ export const APP_JS = String.raw`
957
1016
  var TOP_EDGE = 64; // below the sticky top bar
958
1017
  var hold = null;
959
1018
 
960
- function applyUpdate(next){
1019
+ function applyUpdate(next, marks){
961
1020
  var old = byId, before = docs;
1021
+ // while this page is saving a review, its own marks are the newest there are
1022
+ if (marks && !rvSaving) reviews = marks;
962
1023
  var navOf = function(list){
963
1024
  return JSON.stringify(list.map(function(d){ return [d.id, d.title, d.badge, d.group, d.file, d.sections]; }));
964
1025
  };
@@ -993,6 +1054,7 @@ export const APP_JS = String.raw`
993
1054
  return r;
994
1055
  }
995
1056
  if (navMoved || changed.indexOf(current) >= 0) patch(old[current], byId[current], r);
1057
+ else paintReviews();
996
1058
  return r;
997
1059
  }
998
1060
 
@@ -1279,6 +1341,7 @@ export const APP_JS = String.raw`
1279
1341
 
1280
1342
  var app = window.__lowkey_doc = {
1281
1343
  save: null,
1344
+ saveReviews: null,
1282
1345
  apply: applyUpdate,
1283
1346
  bring: bring,
1284
1347
  doc: function(id){ return byId[id]; }
@@ -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}>
@@ -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/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);