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