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.
- package/bin/lowkey-doc.mjs +13 -0
- package/package.json +1 -1
- package/skills/write-doc/SKILL.md +23 -1
- package/src/build.mjs +9 -3
- package/src/client/app.mjs +158 -57
- package/src/client/css.mjs +71 -26
- package/src/client/live.mjs +15 -1
- package/src/collect.mjs +8 -2
- package/src/config.mjs +3 -0
- package/src/page.mjs +10 -10
- package/src/reviews.mjs +82 -0
- package/src/sections.mjs +11 -4
- package/src/update.mjs +89 -0
- package/src/watch.mjs +82 -21
package/bin/lowkey-doc.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
@@ -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.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
255
|
-
var
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
358
|
+
if (whole) paintBtn(whole, d, null, 'the whole document');
|
|
293
359
|
if (count){
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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)
|
|
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')
|
|
412
|
-
|
|
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 === '[')
|
|
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 <
|
|
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 =
|
|
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'))
|
|
1388
|
+
if (recall('collapsed')) setSide(true);
|
|
1288
1389
|
buildNav();
|
|
1289
1390
|
route();
|
|
1290
1391
|
})();
|
package/src/client/css.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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)}
|
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}>
|
|
@@ -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">
|
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/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
|
-
|
|
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);
|