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.
@@ -800,36 +800,101 @@ export const DIAGRAMS_JS = String.raw`
800
800
  return out;
801
801
  }
802
802
 
803
- function shapeEl(x, y, w, h, shape){
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 mk('polygon', { points: (x+w/2)+','+y+' '+(x+w)+','+(y+h/2)+' '+(x+w/2)+','+(y+h)+' '+x+','+(y+h/2) });
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 mk('polygon', { points:(x+s)+','+y+' '+(x+w-s)+','+y+' '+(x+w)+','+(y+h/2)+' '+
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 mk('polygon', { points:(x+s)+','+y+' '+(x+w)+','+y+' '+(x+w-s)+','+(y+h)+' '+x+','+(y+h) });
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 mk('polygon', { points:x+','+y+' '+(x+w-s)+','+y+' '+(x+w)+','+(y+h)+' '+(x+s)+','+(y+h) });
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 mk('polygon', { points:(x+s)+','+y+' '+(x+w-s)+','+y+' '+(x+w)+','+(y+h)+' '+x+','+(y+h) });
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 mk('polygon', { points:x+','+y+' '+(x+w)+','+y+' '+(x+w-s)+','+(y+h)+' '+(x+s)+','+(y+h) });
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 a=pts[i-1], b=pts[i], c=pts[i+1];
1029
- var l1=Math.hypot(b.x-a.x,b.y-a.y), l2=Math.hypot(c.x-b.x,c.y-b.y);
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, 7);
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;
@@ -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, inline, parkFences, renderBlocks } from './markdown.mjs';
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
- let title = name, subtitle = '', html;
106
+ // own: the document's own words, for reviews - its standfirst and whatever
107
+ // comes before its first section; a verbatim file is all its own words
108
+ let title = name, subtitle = '', html, own;
106
109
 
107
110
  if (cfg.extensions.includes(extOf(entry.file))) {
108
111
  const lines = parkFences(raw, ctx);
@@ -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 lede paragraph of the blockquote under it becomes the subtitle
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
- while (j < lines.length && /^ {0,3}>/.test(lines[j]) && lines[j].replace(/^ {0,3}> ?/, '').trim()) {
123
- lede.push(lines[j].replace(/^ {0,3}> ?/, '').trim());
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
- s = s.replace(/(^|[^*])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)/g, '$1<em>$2</em>');
307
- s = s.replace(/(^|[^\w\\])_(?!\s)([^_\n]+?)(?<!\s)_(?!\w)/g, '$1<em>$2</em>');
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(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
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 (/^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) { flush(); out.push('<hr>'); i++; continue; }
414
+ if (isRule(line)) { flush(); out.push('<hr>'); i++; continue; }
397
415
 
398
416
  if (/^ {0,3}>/.test(line)) {
399
417
  flush();
400
- const buf = [];
401
- while (i < lines.length && (/^ {0,3}>/.test(lines[i]) || (buf.length && lines[i].trim() && !listMark(lines[i])))) {
402
- buf.push(lines[i].replace(/^ {0,3}> ?/, ''));
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
11
 
12
- export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
12
+ export function renderPage({ cfg, docs, reviews = {}, fontCss, mermaidTag, elkTag }) {
13
13
  const runtime = {
14
14
  title: cfg.title,
15
15
  ns: cfg.ns,
@@ -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">
@@ -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>';