lowkey-doc 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/write-doc/SKILL.md +23 -1
- package/src/build.mjs +9 -3
- package/src/client/app.mjs +175 -5
- package/src/client/css.mjs +53 -17
- package/src/client/diagrams.mjs +121 -26
- package/src/client/live.mjs +15 -1
- package/src/collect.mjs +15 -12
- package/src/config.mjs +5 -1
- package/src/markdown.mjs +25 -10
- package/src/page.mjs +6 -5
- package/src/reviews.mjs +82 -0
- package/src/sections.mjs +19 -1
- package/src/watch.mjs +82 -21
package/src/client/diagrams.mjs
CHANGED
|
@@ -800,36 +800,101 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
800
800
|
return out;
|
|
801
801
|
}
|
|
802
802
|
|
|
803
|
-
|
|
803
|
+
// the corners of the shapes that are drawn as polygons, null for the rest
|
|
804
|
+
function polyPoints(x, y, w, h, shape){
|
|
804
805
|
var s;
|
|
805
806
|
switch (shape){
|
|
806
807
|
case 'diamond': case 'question': case 'rhombus':
|
|
807
|
-
return
|
|
808
|
-
case 'stadium': case 'pill':
|
|
809
|
-
return mk('rect', { x:x, y:y, width:w, height:h, rx:h/2, ry:h/2 });
|
|
810
|
-
case 'circle': case 'doublecircle': case 'state':
|
|
811
|
-
return mk('ellipse', { cx:x+w/2, cy:y+h/2, rx:w/2, ry:h/2 });
|
|
812
|
-
case 'cylinder': case 'database':
|
|
813
|
-
var e = Math.min(11, h/5);
|
|
814
|
-
return mk('path', { d:'M'+x+','+(y+e)+' A'+(w/2)+','+e+' 0 0 1 '+(x+w)+','+(y+e)+
|
|
815
|
-
' L'+(x+w)+','+(y+h-e)+' A'+(w/2)+','+e+' 0 0 1 '+x+','+(y+h-e)+' Z'+
|
|
816
|
-
' M'+x+','+(y+e)+' A'+(w/2)+','+e+' 0 0 0 '+(x+w)+','+(y+e) });
|
|
808
|
+
return [[x+w/2,y], [x+w,y+h/2], [x+w/2,y+h], [x,y+h/2]];
|
|
817
809
|
case 'hexagon':
|
|
818
810
|
s = Math.min(18, w/5);
|
|
819
|
-
return
|
|
820
|
-
(x+w-s)+','+(y+h)+' '+(x+s)+','+(y+h)+' '+x+','+(y+h/2) });
|
|
811
|
+
return [[x+s,y], [x+w-s,y], [x+w,y+h/2], [x+w-s,y+h], [x+s,y+h], [x,y+h/2]];
|
|
821
812
|
case 'lean_right': case 'lean_r':
|
|
822
813
|
s = Math.min(22, w/6);
|
|
823
|
-
return
|
|
814
|
+
return [[x+s,y], [x+w,y], [x+w-s,y+h], [x,y+h]];
|
|
824
815
|
case 'lean_left': case 'lean_l':
|
|
825
816
|
s = Math.min(22, w/6);
|
|
826
|
-
return
|
|
817
|
+
return [[x,y], [x+w-s,y], [x+w,y+h], [x+s,y+h]];
|
|
827
818
|
case 'trapezoid':
|
|
828
819
|
s = Math.min(22, w/6);
|
|
829
|
-
return
|
|
820
|
+
return [[x+s,y], [x+w-s,y], [x+w,y+h], [x,y+h]];
|
|
830
821
|
case 'inv_trapezoid':
|
|
831
822
|
s = Math.min(22, w/6);
|
|
832
|
-
return
|
|
823
|
+
return [[x,y], [x+w,y], [x+w-s,y+h], [x+s,y+h]];
|
|
824
|
+
}
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// The outline shapeEl draws, as a closed ring of points, its curves walked
|
|
829
|
+
// in short steps.
|
|
830
|
+
function outline(x, y, w, h, shape){
|
|
831
|
+
var poly = polyPoints(x, y, w, h, shape);
|
|
832
|
+
if (poly) return poly.map(function(p){ return { x:p[0], y:p[1] }; });
|
|
833
|
+
var ring = [];
|
|
834
|
+
var arc = function(cx, cy, rx, ry, a0, a1){
|
|
835
|
+
var n = Math.max(2, Math.ceil(Math.abs(a1 - a0) / (Math.PI/24)));
|
|
836
|
+
for (var i=0;i<=n;i++){
|
|
837
|
+
var t = a0 + (a1 - a0) * i/n;
|
|
838
|
+
ring.push({ x: cx + rx*Math.cos(t), y: cy + ry*Math.sin(t) });
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
var rounded = function(r){
|
|
842
|
+
r = Math.min(r, w/2, h/2);
|
|
843
|
+
arc(x+w-r, y+r, r, r, -Math.PI/2, 0);
|
|
844
|
+
arc(x+w-r, y+h-r, r, r, 0, Math.PI/2);
|
|
845
|
+
arc(x+r, y+h-r, r, r, Math.PI/2, Math.PI);
|
|
846
|
+
arc(x+r, y+r, r, r, Math.PI, Math.PI*1.5);
|
|
847
|
+
};
|
|
848
|
+
switch (shape){
|
|
849
|
+
case 'stadium': case 'pill': rounded(h/2); break;
|
|
850
|
+
case 'circle': case 'doublecircle': case 'state': arc(x+w/2, y+h/2, w/2, h/2, 0, Math.PI*2); break;
|
|
851
|
+
case 'cylinder': case 'database':
|
|
852
|
+
var e = Math.min(11, h/5);
|
|
853
|
+
arc(x+w/2, y+e, w/2, e, Math.PI, Math.PI*2);
|
|
854
|
+
arc(x+w/2, y+h-e, w/2, e, 0, Math.PI);
|
|
855
|
+
break;
|
|
856
|
+
case 'squareRect': case 'rect': case 'square': rounded(3); break;
|
|
857
|
+
default: rounded(8);
|
|
858
|
+
}
|
|
859
|
+
return ring;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ELK knows a node only as its box, and ends every edge on the box. A shape
|
|
863
|
+
// that does not fill its box - a diamond most of all - would leave arrows
|
|
864
|
+
// stopping short in the air beside it, so each end is carried on, straight
|
|
865
|
+
// in across the side of the box it arrived at, until it meets the outline.
|
|
866
|
+
// The route is orthogonal, so that is the direction its last run already had.
|
|
867
|
+
function onOutline(p, a){
|
|
868
|
+
var ring = outline(a.x, a.y, a.w, a.h, a.n.shape);
|
|
869
|
+
var gap = [Math.abs(p.y - a.y), Math.abs(a.y + a.h - p.y), Math.abs(p.x - a.x), Math.abs(a.x + a.w - p.x)];
|
|
870
|
+
var side = gap.indexOf(Math.min.apply(null, gap));
|
|
871
|
+
var ux = [0, 0, 1, -1][side], uy = [1, -1, 0, 0][side];
|
|
872
|
+
// from just outside the box, the first crossing of the outline
|
|
873
|
+
var ox = p.x - ux*2, oy = p.y - uy*2, best = Infinity;
|
|
874
|
+
for (var i=0;i<ring.length;i++){
|
|
875
|
+
var q = ring[i], r = ring[(i+1) % ring.length];
|
|
876
|
+
var ex = r.x - q.x, ey = r.y - q.y, den = ux*ey - uy*ex;
|
|
877
|
+
if (Math.abs(den) < 1e-9) continue;
|
|
878
|
+
var qx = q.x - ox, qy = q.y - oy;
|
|
879
|
+
var t = (qx*ey - qy*ex) / den, s = (qx*uy - qy*ux) / den;
|
|
880
|
+
if (s >= -1e-6 && s <= 1 + 1e-6 && t >= 0 && t < best) best = t;
|
|
881
|
+
}
|
|
882
|
+
return best < Infinity ? { x: ox + ux*best, y: oy + uy*best } : p;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function shapeEl(x, y, w, h, shape){
|
|
886
|
+
var poly = polyPoints(x, y, w, h, shape);
|
|
887
|
+
if (poly) return mk('polygon', { points: poly.map(function(p){ return p[0]+','+p[1]; }).join(' ') });
|
|
888
|
+
switch (shape){
|
|
889
|
+
case 'stadium': case 'pill':
|
|
890
|
+
return mk('rect', { x:x, y:y, width:w, height:h, rx:h/2, ry:h/2 });
|
|
891
|
+
case 'circle': case 'doublecircle': case 'state':
|
|
892
|
+
return mk('ellipse', { cx:x+w/2, cy:y+h/2, rx:w/2, ry:h/2 });
|
|
893
|
+
case 'cylinder': case 'database':
|
|
894
|
+
var e = Math.min(11, h/5);
|
|
895
|
+
return mk('path', { d:'M'+x+','+(y+e)+' A'+(w/2)+','+e+' 0 0 1 '+(x+w)+','+(y+e)+
|
|
896
|
+
' L'+(x+w)+','+(y+h-e)+' A'+(w/2)+','+e+' 0 0 1 '+x+','+(y+h-e)+' Z'+
|
|
897
|
+
' M'+x+','+(y+e)+' A'+(w/2)+','+e+' 0 0 0 '+(x+w)+','+(y+e) });
|
|
833
898
|
case 'squareRect': case 'rect': case 'square':
|
|
834
899
|
return mk('rect', { x:x, y:y, width:w, height:h, rx:3, ry:3 });
|
|
835
900
|
default:
|
|
@@ -1020,22 +1085,46 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
1020
1085
|
|
|
1021
1086
|
// Right-angle bends look mechanical when they are exactly square; a small
|
|
1022
1087
|
// radius reads as a route rather than as a staircase.
|
|
1088
|
+
var BEND_R = 7;
|
|
1089
|
+
|
|
1090
|
+
// where the curve that rounds the bend at b starts and ends, or null if
|
|
1091
|
+
// there is no room for one
|
|
1092
|
+
function bendAt(a, b, c, r){
|
|
1093
|
+
var l1=Math.hypot(b.x-a.x,b.y-a.y), l2=Math.hypot(c.x-b.x,c.y-b.y);
|
|
1094
|
+
if (!l1 || !l2) return null;
|
|
1095
|
+
var rr = Math.min(r, l1/2, l2/2);
|
|
1096
|
+
return { from: { x: b.x+(a.x-b.x)/l1*rr, y: b.y+(a.y-b.y)/l1*rr },
|
|
1097
|
+
to: { x: b.x+(c.x-b.x)/l2*rr, y: b.y+(c.y-b.y)/l2*rr } };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1023
1100
|
function roundedPath(pts, r){
|
|
1024
1101
|
if (pts.length < 2) return '';
|
|
1025
|
-
if (pts.length === 2) return 'M'+pts[0].x+','+pts[0].y+' L'+pts[1].x+','+pts[1].y;
|
|
1026
1102
|
var d = 'M'+pts[0].x+','+pts[0].y;
|
|
1027
1103
|
for (var i=1;i<pts.length-1;i++){
|
|
1028
|
-
var
|
|
1029
|
-
|
|
1030
|
-
if (!l1 || !l2){ d += ' L'+b.x+','+b.y; continue; }
|
|
1031
|
-
var rr = Math.min(r, l1/2, l2/2);
|
|
1032
|
-
d += ' L'+(b.x+(a.x-b.x)/l1*rr)+','+(b.y+(a.y-b.y)/l1*rr) +
|
|
1033
|
-
' Q'+b.x+','+b.y+' '+(b.x+(c.x-b.x)/l2*rr)+','+(b.y+(c.y-b.y)/l2*rr);
|
|
1104
|
+
var b = pts[i], k = bendAt(pts[i-1], b, pts[i+1], r);
|
|
1105
|
+
d += k ? ' L'+k.from.x+','+k.from.y+' Q'+b.x+','+b.y+' '+k.to.x+','+k.to.y : ' L'+b.x+','+b.y;
|
|
1034
1106
|
}
|
|
1035
1107
|
var e = pts[pts.length-1];
|
|
1036
1108
|
return d + ' L'+e.x+','+e.y;
|
|
1037
1109
|
}
|
|
1038
1110
|
|
|
1111
|
+
// The route as roundedPath draws it, its curves walked in short steps. A
|
|
1112
|
+
// point found on this is on the line the reader sees; one found on the
|
|
1113
|
+
// square corner it rounds off sits beside the line, in the air.
|
|
1114
|
+
function roundedPts(pts, r){
|
|
1115
|
+
var out = [pts[0]];
|
|
1116
|
+
for (var i=1;i<pts.length-1;i++){
|
|
1117
|
+
var b = pts[i], k = bendAt(pts[i-1], b, pts[i+1], r);
|
|
1118
|
+
if (!k){ out.push(b); continue; }
|
|
1119
|
+
for (var j=0;j<=8;j++){
|
|
1120
|
+
var t = j/8, u = 1-t;
|
|
1121
|
+
out.push({ x: u*u*k.from.x + 2*u*t*b.x + t*t*k.to.x, y: u*u*k.from.y + 2*u*t*b.y + t*t*k.to.y });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
out.push(pts[pts.length-1]);
|
|
1125
|
+
return out;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1039
1128
|
function nearestOnPath(pts, px, py){
|
|
1040
1129
|
var best = null, bd = Infinity;
|
|
1041
1130
|
for (var i=0;i<pts.length-1;i++){
|
|
@@ -1187,9 +1276,15 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
1187
1276
|
runs.push([sec.startPoint].concat(sec.bendPoints || [], [sec.endPoint]).map(shift));
|
|
1188
1277
|
});
|
|
1189
1278
|
if (!runs.length) return;
|
|
1279
|
+
// both ends on the shape itself, not on the box ELK knows it by
|
|
1280
|
+
if (!isEr){
|
|
1281
|
+
var first = runs[0], last = runs[runs.length-1];
|
|
1282
|
+
if (abs[src] && !abs[src].n.isGroup) first[0] = onOutline(first[0], abs[src]);
|
|
1283
|
+
if (abs[dst] && !abs[dst].n.isGroup) last[last.length-1] = onOutline(last[last.length-1], abs[dst]);
|
|
1284
|
+
}
|
|
1190
1285
|
|
|
1191
1286
|
runs.forEach(function(pts, i){
|
|
1192
|
-
var d = roundedPath(pts,
|
|
1287
|
+
var d = roundedPath(pts, BEND_R);
|
|
1193
1288
|
g.appendChild(mk('path', { d:d, fill:'none' }, 'dg-edge-hit'));
|
|
1194
1289
|
var line = mk('path', { d:d, fill:'none' }, 'dg-edge-line');
|
|
1195
1290
|
if (info.pattern === 'dashed') line.style.strokeDasharray = '7 4';
|
|
@@ -1232,7 +1327,7 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
1232
1327
|
var cx = lb.x + o.x + lb.width/2, cy = lb.y + o.y + lb.height/2;
|
|
1233
1328
|
var pw = info._lw || lb.width, ph = info._lh || lb.height;
|
|
1234
1329
|
var lx = cx - pw/2, ly = cy - ph/2;
|
|
1235
|
-
var anchor = pts.length > 1 ? nearestOnPath(pts, cx, cy) : null;
|
|
1330
|
+
var anchor = pts.length > 1 ? nearestOnPath(roundedPts(pts, BEND_R), cx, cy) : null;
|
|
1236
1331
|
if (anchor){
|
|
1237
1332
|
var dx = anchor.x - cx, dy = anchor.y - cy;
|
|
1238
1333
|
var hw = pw/2 + 3, hh = ph/2 + 3, ax = cx, ay = cy;
|
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
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { esc, plain, slugify,
|
|
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(),
|
|
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);
|
|
@@ -114,31 +117,31 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
|
|
|
114
117
|
lines.splice(h1, 1);
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
// the
|
|
120
|
+
// the blockquote under it - all of it, every paragraph - becomes the subtitle
|
|
118
121
|
let j = 0;
|
|
119
122
|
while (j < lines.length && !lines[j].trim()) j++;
|
|
120
123
|
if (j < lines.length && /^ {0,3}>/.test(lines[j])) {
|
|
121
|
-
const lede =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
j++;
|
|
125
|
-
}
|
|
126
|
-
subtitle = inline(lede.join(' '), ctx);
|
|
127
|
-
lines.splice(0, j);
|
|
124
|
+
const lede = takeQuote(lines, j);
|
|
125
|
+
subtitle = renderBlocks(lede.buf, ctx);
|
|
126
|
+
lines.splice(0, lede.end);
|
|
128
127
|
}
|
|
129
128
|
|
|
130
129
|
html = nestSections(renderBlocks(lines, ctx, true), ctx);
|
|
130
|
+
own = (words(subtitle) + ' ' + ctx.intro).trim();
|
|
131
131
|
} else {
|
|
132
132
|
const kind = extOf(entry.file).slice(1);
|
|
133
|
-
subtitle = 'Kept verbatim as <code>' + esc(kind) + '</code
|
|
133
|
+
subtitle = '<p>Kept verbatim as <code>' + esc(kind) + '</code>.</p>';
|
|
134
134
|
html = '<div class="code-wrap"><span class="code-lang">' + esc(kind) +
|
|
135
135
|
'</span><pre class="code"><code>' + esc(raw) + '</code></pre></div>';
|
|
136
|
+
own = raw.replace(/\s+/g, ' ').trim();
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
const badge = (name.match(/^(\d+)/) || [])[1] || (name.trim()[0] || '?').toUpperCase();
|
|
139
140
|
|
|
140
141
|
return {
|
|
141
142
|
id, title, subtitle, html, badge,
|
|
143
|
+
outline: ctx.outline,
|
|
144
|
+
sig: own || !ctx.outline.length ? fingerprint(own) : null,
|
|
142
145
|
group: entry.group,
|
|
143
146
|
file: entry.rel || entry.file,
|
|
144
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
|
|
|
@@ -24,7 +26,7 @@ export const CLI = process.env.npm_command === 'exec' ? 'npx lowkey-doc' : 'lowk
|
|
|
24
26
|
|
|
25
27
|
const DEFAULTS = {
|
|
26
28
|
// Shown in the sidebar, the browser tab, and used to namespace the
|
|
27
|
-
// per-reader state (which document was open, folded sections, comments).
|
|
29
|
+
// per-reader state (which document was open, folded sections, comments, reviews).
|
|
28
30
|
title: null, // null -> derived from the project folder
|
|
29
31
|
// Where the documents are, relative to the project root.
|
|
30
32
|
docs: 'docs',
|
|
@@ -68,6 +70,7 @@ const DEFAULTS = {
|
|
|
68
70
|
},
|
|
69
71
|
// Reader features. Turn off what a given project does not want.
|
|
70
72
|
comments: true,
|
|
73
|
+
reviews: true,
|
|
71
74
|
search: true,
|
|
72
75
|
};
|
|
73
76
|
|
|
@@ -138,6 +141,7 @@ export function loadConfig({ root, docs, folder, out, title, configPath, engine
|
|
|
138
141
|
cfg.docsDir = path.resolve(projectRoot, cfg.docs);
|
|
139
142
|
cfg.outDir = path.resolve(projectRoot, cfg.out);
|
|
140
143
|
cfg.configFile = file;
|
|
144
|
+
cfg.reviewsFile = cfg.reviews === false ? null : path.join(projectRoot, REVIEWS_FILE);
|
|
141
145
|
if (!cfg.title) cfg.title = titleCase(path.basename(projectRoot)) + ' Docs';
|
|
142
146
|
|
|
143
147
|
// Namespace for everything the reader's browser remembers. Two dashboards
|
package/src/markdown.mjs
CHANGED
|
@@ -303,8 +303,9 @@ export function inline(src, ctx) {
|
|
|
303
303
|
s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>');
|
|
304
304
|
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>');
|
|
305
305
|
s = s.replace(/(^|[^\w])__(\S[\s\S]*?\S|\S)__(?!\w)/g, '$1<strong>$2</strong>');
|
|
306
|
-
|
|
307
|
-
s = s.replace(/(^|[
|
|
306
|
+
// like strong, emphasis may run across a line break, never across a blank line
|
|
307
|
+
s = s.replace(/(^|[^*])\*(?!\s)((?:[^*\n]|\n(?!\n))+?)(?<!\s)\*(?!\*)/g, '$1<em>$2</em>');
|
|
308
|
+
s = s.replace(/(^|[^\w\\])_(?!\s)((?:[^_\n]|\n(?!\n))+?)(?<!\s)_(?!\w)/g, '$1<em>$2</em>');
|
|
308
309
|
|
|
309
310
|
// 6. bare urls left in plain text
|
|
310
311
|
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,;:])/g,
|
|
@@ -359,6 +360,23 @@ const isDelim = (line) => {
|
|
|
359
360
|
};
|
|
360
361
|
const listMark = (line) => line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
|
361
362
|
const indentOf = (line) => (line.match(/^\s*/) || [''])[0].length;
|
|
363
|
+
const isRule = (line) => /^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(line);
|
|
364
|
+
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
365
|
+
|
|
366
|
+
// The blockquote that starts at lines[i]: its lines without their markers, and
|
|
367
|
+
// the index just past it. A line with no marker still belongs to it when it
|
|
368
|
+
// carries on the quote's last paragraph, unless it starts a block of its own.
|
|
369
|
+
export function takeQuote(lines, i) {
|
|
370
|
+
const buf = [];
|
|
371
|
+
for (; i < lines.length; i++) {
|
|
372
|
+
const line = lines[i];
|
|
373
|
+
if (/^ {0,3}>/.test(line)) buf.push(line.replace(/^ {0,3}> ?/, ''));
|
|
374
|
+
else if (buf.length && buf[buf.length - 1].trim() && line.trim() &&
|
|
375
|
+
!listMark(line) && !isRule(line) && !HEADING_RE.test(line) && !BLOCK_RE.test(line)) buf.push(line);
|
|
376
|
+
else break;
|
|
377
|
+
}
|
|
378
|
+
return { buf, end: i };
|
|
379
|
+
}
|
|
362
380
|
|
|
363
381
|
export function renderBlocks(lines, ctx, asArray) {
|
|
364
382
|
const out = [];
|
|
@@ -380,7 +398,7 @@ export function renderBlocks(lines, ctx, asArray) {
|
|
|
380
398
|
const park = line.match(BLOCK_RE);
|
|
381
399
|
if (park) { flush(); out.push(ctx.blocks[+park[1]]); i++; continue; }
|
|
382
400
|
|
|
383
|
-
const head = line.match(
|
|
401
|
+
const head = line.match(HEADING_RE);
|
|
384
402
|
if (head) {
|
|
385
403
|
flush();
|
|
386
404
|
const level = head[1].length;
|
|
@@ -393,16 +411,13 @@ export function renderBlocks(lines, ctx, asArray) {
|
|
|
393
411
|
i++; continue;
|
|
394
412
|
}
|
|
395
413
|
|
|
396
|
-
if (
|
|
414
|
+
if (isRule(line)) { flush(); out.push('<hr>'); i++; continue; }
|
|
397
415
|
|
|
398
416
|
if (/^ {0,3}>/.test(line)) {
|
|
399
417
|
flush();
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
i++;
|
|
404
|
-
}
|
|
405
|
-
out.push('<blockquote>' + renderBlocks(buf, ctx) + '</blockquote>');
|
|
418
|
+
const quote = takeQuote(lines, i);
|
|
419
|
+
out.push('<blockquote>' + renderBlocks(quote.buf, ctx) + '</blockquote>');
|
|
420
|
+
i = quote.end;
|
|
406
421
|
continue;
|
|
407
422
|
}
|
|
408
423
|
|
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,
|
|
@@ -17,13 +17,14 @@ export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
|
|
|
17
17
|
nodeTextWidth: cfg.diagrams.nodeTextWidth,
|
|
18
18
|
edgeTextWidth: cfg.diagrams.edgeTextWidth,
|
|
19
19
|
comments: cfg.comments !== false,
|
|
20
|
+
reviews: cfg.reviews !== false,
|
|
20
21
|
margin: cfg.layout.margin,
|
|
21
22
|
};
|
|
22
23
|
// fixed margins are set on the root, so the page is laid out right from its first paint
|
|
23
24
|
const rootAttrs = cfg.layout.margin == null ? ''
|
|
24
25
|
: ' class="fixed-margin" style="--margin:' + Number(cfg.layout.margin) + 'px"';
|
|
25
26
|
|
|
26
|
-
const data = JSON.stringify({ docs }).replace(/</g, '\\u003c');
|
|
27
|
+
const data = JSON.stringify({ docs, reviews: cfg.reviews === false ? {} : reviews }).replace(/</g, '\\u003c');
|
|
27
28
|
|
|
28
29
|
// a script from the CDN carries its pinned hash: the browser refuses any other bytes
|
|
29
30
|
const script = (tag) => !tag ? '' : '<script src="' + escAttr(tag.src) + '"' +
|
|
@@ -48,8 +49,7 @@ export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
|
|
|
48
49
|
|
|
49
50
|
const commentBits = cfg.comments === false ? '' : `
|
|
50
51
|
<button class="cmt-add" id="cmt-add" type="button" hidden>Comment</button>
|
|
51
|
-
<div class="cmt-pop" id="cmt-pop" hidden></div
|
|
52
|
-
<div class="cmt-toast" id="cmt-toast" hidden></div>`;
|
|
52
|
+
<div class="cmt-pop" id="cmt-pop" hidden></div>`;
|
|
53
53
|
|
|
54
54
|
return `<!doctype html>
|
|
55
55
|
<html lang="en"${rootAttrs}>
|
|
@@ -89,7 +89,8 @@ export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
|
|
|
89
89
|
<div id="main"></div>
|
|
90
90
|
</div>
|
|
91
91
|
</div>
|
|
92
|
-
${commentBits}
|
|
92
|
+
${commentBits}${cfg.comments === false && cfg.reviews === false ? '' : `
|
|
93
|
+
<div class="cmt-toast" id="cmt-toast" hidden></div>`}
|
|
93
94
|
<div class="settings-veil" id="settings-veil" hidden></div>
|
|
94
95
|
<div class="settings" id="settings" role="dialog" aria-modal="true" aria-label="Settings" hidden>
|
|
95
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,8 +6,16 @@
|
|
|
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>';
|
|
13
|
+
const CHECK_SVG = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" ' +
|
|
14
|
+
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.3"/>' +
|
|
15
|
+
'<path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></svg>';
|
|
16
|
+
// The word on the button is drawn by CSS, so it is never part of the heading's
|
|
17
|
+
// text: not copied with it, not matched by search, not quoted by a comment.
|
|
18
|
+
const REVIEW_BTN = '<button class="rv" type="button" aria-pressed="false" aria-label="Reviewed">' + CHECK_SVG + '</button>';
|
|
11
19
|
|
|
12
20
|
const HEAD_RE = /^<h([1-6]) id="([^"]+)">([\s\S]*)<\/h[1-6]>$/;
|
|
13
21
|
const ANCHOR_RE = /^<a class="h-anchor"[^>]*>#<\/a>/;
|
|
@@ -30,6 +38,16 @@ export function nestSections(blocks, ctx) {
|
|
|
30
38
|
stack[stack.length - 1].kids.push(node);
|
|
31
39
|
stack.push(node);
|
|
32
40
|
}
|
|
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
|
+
});
|
|
49
|
+
ctx.outline = outline(root);
|
|
50
|
+
ctx.intro = own(root);
|
|
33
51
|
return emitSections(root, [], ctx);
|
|
34
52
|
}
|
|
35
53
|
|
|
@@ -55,7 +73,7 @@ function emitSections(node, prefix, ctx) {
|
|
|
55
73
|
'<button class="sec-tog" type="button" tabindex="-1" aria-label="Fold section">' + FOLD_SVG + '</button>' +
|
|
56
74
|
'<span class="sec-num">' + label + '</span>' +
|
|
57
75
|
'</span>' +
|
|
58
|
-
'<span class="sec-txt">' + text + '</span>' + anchor +
|
|
76
|
+
'<span class="sec-txt">' + text + '</span>' + anchor + (ctx.reviews ? REVIEW_BTN : '') +
|
|
59
77
|
'</h' + kid.level + '>';
|
|
60
78
|
return '<section class="sec" data-sec="' + kid.id + '">' + head +
|
|
61
79
|
'<div class="sec-body">' + emitSections(kid, num, ctx) + '</div></section>';
|