lowkey-doc 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lowkey-doc",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
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",
@@ -34,6 +34,7 @@ export const APP_JS = String.raw`
34
34
  }
35
35
 
36
36
  var CARET = '<svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M6 4l4 4-4 4"/></svg>';
37
+ var CHECK = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.3"/><path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></svg>';
37
38
 
38
39
  function buildNav(){
39
40
  var group = null;
@@ -47,6 +48,7 @@ export const APP_JS = String.raw`
47
48
  item.title = d.file;
48
49
  item.appendChild(el('span', 'num', d.badge));
49
50
  item.appendChild(el('span', 'item-title', d.title));
51
+ if (RV_ON) item.insertAdjacentHTML('beforeend', '<span class="rv-mark" title="Reviewed">' + CHECK + '</span>');
50
52
  if (d.sections.length) item.insertAdjacentHTML('beforeend', CARET);
51
53
  item.addEventListener('click', function(e){
52
54
  if (e.target.closest('.caret') && current === d.id){ item.classList.toggle('open'); return; }
@@ -62,6 +64,8 @@ export const APP_JS = String.raw`
62
64
  a.title = (s.num ? s.num + ' ' : '') + s.text;
63
65
  if (s.num) a.appendChild(el('span', 'sub-n', s.num));
64
66
  a.appendChild(el('span', 'sub-t', s.text));
67
+ if (RV_ON) a.insertAdjacentHTML('beforeend', '<span class="rv-mark" title="Reviewed">' + CHECK + '</span>');
68
+ a.dataset.sec = s.id;
65
69
  subs.appendChild(a);
66
70
  });
67
71
  nav.appendChild(subs);
@@ -106,6 +110,7 @@ export const APP_JS = String.raw`
106
110
  restoreFolds(main);
107
111
  if (window.__diagrams) window.__diagrams.drawAll(main);
108
112
  renderComments();
113
+ paintReviews();
109
114
  watch();
110
115
  }
111
116
  if (anchor && reveal(anchor, false)) return;
@@ -130,9 +135,15 @@ export const APP_JS = String.raw`
130
135
  var head = el('header', 'head');
131
136
  head.appendChild(el('h1', null, d.title));
132
137
  if (d.subtitle){
133
- var p = el('p');
134
- p.innerHTML = d.subtitle;
135
- head.appendChild(p);
138
+ var lede = el('div', 'lede');
139
+ lede.innerHTML = d.subtitle;
140
+ head.appendChild(lede);
141
+ }
142
+ if (RV_ON){
143
+ var rvBar = el('div', 'rv-bar');
144
+ rvBar.appendChild(el('span', 'rv-count'));
145
+ rvBar.insertAdjacentHTML('beforeend', '<button class="rv rv-doc" type="button" aria-pressed="false" aria-label="Reviewed">' + CHECK + '</button>');
146
+ head.appendChild(rvBar);
136
147
  }
137
148
  wrap.appendChild(head);
138
149
 
@@ -201,6 +212,98 @@ export const APP_JS = String.raw`
201
212
  return true;
202
213
  }
203
214
 
215
+ /* ---------------- reviews ----------------
216
+ A reader marks what they have read and agree with. The mark is kept on the
217
+ smallest sections only: a section with sections under it is reviewed when
218
+ every one of them is, and the document when all of its sections are. So
219
+ marking a document or a chapter marks everything in it, reviewing the last
220
+ open section of a chapter completes the chapter, and a section written
221
+ later opens its chapter and its document again until it is read too.
222
+ Like comments, the marks live in this browser and nowhere else. */
223
+
224
+ var RV_ON = CFG.reviews !== false;
225
+ var reviewed = {};
226
+ try {
227
+ var rvSaved = JSON.parse(recall('reviewed') || '{}');
228
+ if (rvSaved && rvSaved.docs && typeof rvSaved.docs === 'object') reviewed = rvSaved.docs;
229
+ } catch (e) { reviewed = {}; }
230
+
231
+ function findSec(list, id){
232
+ for (var i = 0; i < list.length; i++){
233
+ if (list[i].id === id) return list[i];
234
+ var hit = findSec(list[i].kids, id);
235
+ if (hit) return hit;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ // what a review of the document (id null) or of one section stands for: the
241
+ // smallest sections under it, or the document itself if it has no sections
242
+ function rvKeys(d, id){
243
+ var outline = d.outline || [];
244
+ var node = id ? findSec(outline, id) : { kids: outline };
245
+ if (!node) return [];
246
+ if (!node.kids.length) return [id || ''];
247
+ var out = [];
248
+ (function walk(list){
249
+ list.forEach(function(k){ if (k.kids.length) walk(k.kids); else out.push(k.id); });
250
+ })(node.kids);
251
+ return out;
252
+ }
253
+
254
+ function rvCount(d, id){
255
+ var mine = reviewed[d.id] || {}, keys = rvKeys(d, id);
256
+ return { done: keys.filter(function(k){ return mine[k]; }).length, of: keys.length };
257
+ }
258
+ function isReviewed(d, id){ var c = rvCount(d, id); return c.of > 0 && c.done === c.of; }
259
+
260
+ function setReviewed(d, id, on){
261
+ var mine = reviewed[d.id] || {}, now = Date.now();
262
+ rvKeys(d, id).forEach(function(k){ if (on) mine[k] = mine[k] || now; else delete mine[k]; });
263
+ if (Object.keys(mine).length) reviewed[d.id] = mine; else delete reviewed[d.id];
264
+ keep('reviewed', JSON.stringify({ version: 1, docs: reviewed }));
265
+ paintReviews();
266
+ }
267
+
268
+ function paintBtn(btn, on, what){
269
+ btn.classList.toggle('is-on', on);
270
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
271
+ btn.title = on ? 'Reviewed. Click to open ' + what + ' again' : 'Mark ' + what + ' as reviewed';
272
+ }
273
+
274
+ function paintReviews(){
275
+ if (!RV_ON) return;
276
+ nav.querySelectorAll('.item').forEach(function(item){
277
+ var d = byId[item.dataset.doc];
278
+ if (!d) return;
279
+ item.classList.toggle('is-reviewed', isReviewed(d, null));
280
+ var subs = nav.querySelector('[data-subs="' + d.id + '"]');
281
+ if (subs) subs.querySelectorAll('.sub').forEach(function(a){
282
+ a.classList.toggle('is-reviewed', isReviewed(d, a.dataset.sec));
283
+ });
284
+ });
285
+ var d = byId[current];
286
+ if (!d) return;
287
+ main.querySelectorAll('.sec').forEach(function(sec){
288
+ var btn = sec.querySelector(':scope > .sec-head > .rv');
289
+ if (btn) paintBtn(btn, isReviewed(d, sec.dataset.sec), 'this section');
290
+ });
291
+ var whole = main.querySelector('.rv-doc'), count = main.querySelector('.rv-count');
292
+ if (whole) paintBtn(whole, isReviewed(d, null), 'the whole document');
293
+ if (count){
294
+ var c = rvCount(d, null);
295
+ count.textContent = c.done && c.done < c.of ? c.done + ' of ' + c.of + ' sections' : '';
296
+ }
297
+ }
298
+
299
+ function toggleReview(btn){
300
+ var d = byId[current];
301
+ if (!d) return;
302
+ var sec = btn.classList.contains('rv-doc') ? null : btn.closest('.sec');
303
+ var id = sec ? sec.dataset.sec : null;
304
+ setReviewed(d, id, !isReviewed(d, id));
305
+ }
306
+
204
307
  /* ---------------- lightbox ---------------- */
205
308
 
206
309
  var box = document.getElementById('lightbox');
@@ -293,6 +396,8 @@ export const APP_JS = String.raw`
293
396
  if (reveal(href.slice(1), true)) history.replaceState(null, '', '#/' + current + '/' + href.slice(1));
294
397
  return;
295
398
  }
399
+ var rv = e.target.closest('.rv');
400
+ if (rv){ toggleReview(rv); return; }
296
401
  // anywhere on a heading folds its section - unless the click was really the
297
402
  // end of a drag that selected some of it
298
403
  var head = e.target.closest('.sec-head');
@@ -901,6 +1006,7 @@ export const APP_JS = String.raw`
901
1006
  n.classList.toggle('open', !!open[n.dataset.doc] || n.dataset.doc === current);
902
1007
  });
903
1008
  if (search && search.value) search.dispatchEvent(new Event('input'));
1009
+ paintReviews();
904
1010
  nav.scrollTop = top;
905
1011
  }
906
1012
 
@@ -920,6 +1026,7 @@ export const APP_JS = String.raw`
920
1026
  restoreFolds(main);
921
1027
  if (window.__diagrams) window.__diagrams.drawAll(main);
922
1028
  renderComments();
1029
+ paintReviews();
923
1030
  watch();
924
1031
  keepPlace(at);
925
1032
 
@@ -938,7 +1045,7 @@ export const APP_JS = String.raw`
938
1045
  var out = [];
939
1046
  var head = holder.querySelector('.head');
940
1047
  if (was.title !== d.title) out.push(head.querySelector('h1'));
941
- if (was.subtitle !== d.subtitle && head.querySelector('p')) out.push(head.querySelector('p'));
1048
+ if (was.subtitle !== d.subtitle && head.querySelector('.lede')) out.push(head.querySelector('.lede'));
942
1049
 
943
1050
  var body = holder.querySelector('.doc');
944
1051
  var t = document.createElement('template');
@@ -9,6 +9,8 @@ html{-webkit-text-size-adjust:100%}
9
9
  --line-2:rgba(255,255,255,.16); --surface:#13161b; --surface-2:#1a1e24; --hover:rgba(255,255,255,.05);
10
10
  /* the one accent: an electric blue, used for what is lit, current or alive */
11
11
  --accent:#4da3ff; --accent-soft:rgba(77,163,255,.12); --accent-ring:rgba(77,163,255,.36);
12
+ /* reviewed: a green as light and as cool as the blue, so the two sit together */
13
+ --ok:#45cf98; --ok-soft:rgba(69,207,152,.12); --ok-ring:rgba(69,207,152,.36);
12
14
  /* a filled button is white with dark text */
13
15
  --on-ink:#0b0d10; --ink-hover:#dde2e8;
14
16
  --serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
@@ -153,9 +155,14 @@ a{color:inherit;text-decoration:none}
153
155
  .sub-n{flex:none;font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums}
154
156
  .sub.current .sub-n{color:var(--muted)}
155
157
  .sub-t{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
158
+ /* a reviewed document or section carries a green check in the sidebar */
159
+ .rv-mark{display:none;flex:none;width:13px;height:13px;color:var(--ok)}
160
+ .rv-mark svg{display:block;width:100%;height:100%}
161
+ .is-reviewed>.rv-mark{display:block}
162
+ .sub .rv-mark{align-self:center;width:12px;height:12px}
156
163
 
157
164
  .collapsed .brand-name,.collapsed .search,.collapsed .nav-group,
158
- .collapsed .item-title,.collapsed .caret,.collapsed .foot-lbl{display:none}
165
+ .collapsed .item-title,.collapsed .caret,.collapsed .foot-lbl,.collapsed .rv-mark{display:none}
159
166
  .collapsed .subs,.collapsed .item.open+.subs{display:none}
160
167
  .collapsed .side-top{padding:16px 0 12px}
161
168
  .collapsed .brand-row{justify-content:center}
@@ -249,7 +256,9 @@ export const CSS_MAIN = `
249
256
  @keyframes head-lamp{to{opacity:1}}
250
257
  @media (prefers-reduced-motion:reduce){.head::before,.head::after{animation:none}}
251
258
  .head h1{margin:0;max-width:22ch;font-size:36px;line-height:1.12;font-weight:650;letter-spacing:-.034em;color:var(--ink)}
252
- .head p{margin:14px 0 0;max-width:68ch;font-size:15.5px;line-height:1.62;color:var(--muted)}
259
+ .head .lede{margin:14px 0 0;max-width:68ch;font-size:15.5px;line-height:1.62;color:var(--muted)}
260
+ .lede>*{margin:0}
261
+ .lede>*+*{margin-top:10px}
253
262
  .doc{padding:4px var(--gutter) 30px;width:100%;max-width:var(--column);margin-inline:auto}
254
263
 
255
264
  /* ---- prev / next ----
@@ -312,6 +321,32 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
312
321
  .sec-head:hover .h-anchor{opacity:1}
313
322
  .h-anchor:hover{color:var(--ink)}
314
323
 
324
+ /* ---- reviews ----
325
+ Every section, and the document in its header card, has a Reviewed button:
326
+ a grey check until the reader marks it, then green. On a heading the word
327
+ shows only on hover, so a page of unread headings is not a page of labels. */
328
+ .rv{flex:none;align-self:center;margin-left:auto;display:inline-flex;align-items:center;gap:6px;
329
+ padding:3px 4px 3px 9px;border:1px solid transparent;border-radius:999px;background:none;
330
+ color:var(--faint);font:inherit;font-size:11.5px;font-weight:var(--semi);letter-spacing:0;line-height:1;
331
+ cursor:pointer;transition:color .15s,background .15s,border-color .15s}
332
+ .rv svg{width:16px;height:16px;flex:none}
333
+ .rv svg circle{transition:fill .15s,stroke .15s}
334
+ .rv::before{content:"Reviewed";opacity:0;transition:opacity .15s}
335
+ .sec-head:hover>.rv::before,.rv:focus-visible::before,.rv.is-on::before{opacity:1}
336
+ .rv:hover{color:var(--ink);background:var(--hover)}
337
+ .rv:focus-visible{outline:2px solid var(--ok-ring);outline-offset:1px}
338
+ .rv.is-on{color:var(--ok)}
339
+ .rv.is-on svg circle{fill:var(--ok);stroke:var(--ok)}
340
+ .rv.is-on svg path{stroke:var(--on-ink)}
341
+ .rv.is-on:hover{color:var(--ok);background:var(--ok-soft)}
342
+ /* the document's own, in the corner of the header card: always labelled */
343
+ .rv-bar{position:absolute;top:18px;right:18px;display:flex;align-items:center;gap:10px}
344
+ .rv-count{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}
345
+ .rv-count:empty{display:none}
346
+ .rv-doc{margin:0;border-color:var(--line-2);color:var(--muted)}
347
+ .rv-doc::before{opacity:1}
348
+ .rv-doc.is-on{border-color:var(--ok-ring)}
349
+
315
350
  /* ---- prose ---- */
316
351
  .doc p{margin:0 0 18px}
317
352
  .doc>p:first-child{margin-top:6px}
@@ -323,9 +358,9 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
323
358
  .doc ul>li::marker{color:var(--accent)}
324
359
  .doc strong{font-weight:var(--semi);color:inherit}
325
360
  .doc em{font-style:italic}
326
- .doc a:not(.h-anchor){color:var(--ink);border-bottom:1px solid var(--line-2);padding-bottom:1px;
361
+ .doc a:not(.h-anchor),.lede a{color:var(--ink);border-bottom:1px solid var(--line-2);padding-bottom:1px;
327
362
  transition:border-color .12s,color .12s}
328
- .doc a:not(.h-anchor):hover{border-bottom-color:var(--accent);color:var(--accent)}
363
+ .doc a:not(.h-anchor):hover,.lede a:hover{border-bottom-color:var(--accent);color:var(--accent)}
329
364
  .doc hr{margin:32px 0;border:0;border-top:1px solid var(--line)}
330
365
  .doc blockquote{margin:0 0 18px;padding:2px 0 2px 17px;border-left:2px solid var(--accent-ring);color:var(--muted)}
331
366
  /* Inline code is a plate set into the sentence: a faint white fill that
@@ -334,12 +369,12 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
334
369
  alone would vanish), and an ink a shade cooler than the prose, so a name
335
370
  reads as a name without shouting. Padding is in em, so the plate tracks
336
371
  the text it sits in, headings included. */
337
- .doc code{font-family:var(--mono);font-size:.84em;letter-spacing:-.01em;
372
+ .doc code,.lede code{font-family:var(--mono);font-size:.84em;letter-spacing:-.01em;
338
373
  background:var(--code-bg);box-shadow:inset 0 0 0 1px var(--code-ring);
339
374
  border-radius:5px;padding:.1em .38em .12em;color:var(--code-ink);white-space:nowrap;
340
375
  -webkit-box-decoration-break:clone;box-decoration-break:clone}
341
376
  .api code{background:var(--code-bg-on-white)}
342
- .doc a:not(.h-anchor) code{color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-ring)}
377
+ .doc a:not(.h-anchor) code,.lede a code{color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-ring)}
343
378
  .doc h1 code,.doc h2 code,.doc h3 code,.doc h4 code{font-size:.86em;font-weight:var(--semi)}
344
379
  /* a {placeholder} inside code: the braces fade, the name stays */
345
380
  .c-var{color:var(--code-var);font-style:italic}
@@ -491,6 +526,7 @@ mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:
491
526
  .docnav-btn.next{grid-column:1;text-align:left}
492
527
  .head{width:calc(100% - 20px);margin-top:14px;padding-block:34px 36px}
493
528
  .head h1{font-size:29px}
529
+ .rv-bar{position:static;margin-top:18px}
494
530
  }
495
531
  /* Not enough room beside the column to hang the arrow and the number in:
496
532
  bring them back into the heading row rather than let them fall off-screen. */
@@ -501,7 +537,7 @@ mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:
501
537
  @media print{
502
538
  .side{display:none}.layout{grid-template-columns:1fr}
503
539
  .head{border:0;border-radius:0;padding-block:20px;background:none}.head::before,.head::after{display:none}
504
- .sec.shut>.sec-body{display:block}.sec-tog,.h-anchor{display:none}
540
+ .sec.shut>.sec-body{display:block}.sec-tog,.h-anchor,.rv,.rv-bar{display:none}
505
541
  }
506
542
  `;
507
543
 
@@ -753,19 +789,19 @@ export const CSS_CMT = `
753
789
  A highlight and its card are two views of one comment, so hovering either
754
790
  lights both (.hot); clicking either pins the pair (.on). */
755
791
  .body-wrap{position:relative;display:flex;flex-direction:column;min-height:100vh}
756
- .cmt-hl{background:rgba(250,204,21,.16);box-shadow:inset 0 -1px 0 rgba(250,204,21,.6);border-radius:3px;padding:0 1px;
792
+ .cmt-hl{background:rgba(77,163,255,.16);box-shadow:inset 0 -1px 0 rgba(77,163,255,.6);border-radius:3px;padding:0 1px;
757
793
  color:inherit;cursor:pointer;transition:background .12s,box-shadow .12s}
758
- .cmt-hl.hot{background:rgba(250,204,21,.26);box-shadow:inset 0 -1px 0 rgba(250,204,21,.8)}
759
- .cmt-hl.on{background:rgba(250,204,21,.36);box-shadow:inset 0 -1px 0 #facc15}
794
+ .cmt-hl.hot{background:rgba(77,163,255,.26);box-shadow:inset 0 -1px 0 rgba(77,163,255,.8)}
795
+ .cmt-hl.on{background:rgba(77,163,255,.36);box-shadow:inset 0 -1px 0 var(--accent)}
760
796
  .cmt-layer{position:absolute;inset:0;pointer-events:none;z-index:2}
761
797
  .cmt-card{position:absolute;left:0;top:0;width:var(--cmt-w,264px);pointer-events:auto;padding:10px 12px;
762
798
  background:var(--surface);border:1px solid var(--line);border-radius:14px;cursor:pointer;
763
799
  font-size:12.5px;line-height:1.55;transition:border-color .15s ease,box-shadow .15s ease}
764
- .cmt-card.hot{border-color:rgba(250,204,21,.45);box-shadow:0 4px 16px rgba(0,0,0,.4)}
765
- .cmt-card.on{border-color:rgba(250,204,21,.7);box-shadow:0 6px 20px rgba(0,0,0,.5)}
800
+ .cmt-card.hot{border-color:rgba(77,163,255,.45);box-shadow:0 4px 16px rgba(0,0,0,.4)}
801
+ .cmt-card.on{border-color:rgba(77,163,255,.7);box-shadow:0 6px 20px rgba(0,0,0,.5)}
766
802
  .cmt-card[hidden]{display:none!important}
767
803
  .cmt-face{display:none}
768
- .cmt-quote{margin:0 0 7px;padding-left:9px;border-left:2px solid rgba(250,204,21,.6);color:var(--muted);
804
+ .cmt-quote{margin:0 0 7px;padding-left:9px;border-left:2px solid rgba(77,163,255,.6);color:var(--muted);
769
805
  font-size:11.5px;line-height:1.45;overflow:hidden;
770
806
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
771
807
  .cmt-text{white-space:pre-wrap;overflow-wrap:anywhere}
@@ -774,14 +810,14 @@ export const CSS_CMT = `
774
810
  .cmt-act{padding:0;border:0;background:none;color:var(--muted);font:inherit;font-size:11px;
775
811
  cursor:pointer;border-bottom:1px solid transparent}
776
812
  .cmt-act:hover{color:var(--ink);border-bottom-color:var(--ink)}
777
- .cmt-lost{margin:0 0 5px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:#e0b252}
813
+ .cmt-lost{margin:0 0 5px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)}
778
814
 
779
815
  /* tight gutters: the card shrinks to a numbered dot, opened as a popover */
780
816
  .cmt-layer.dots .cmt-card{width:22px;height:22px;padding:0;border-radius:50%;
781
- background:#3a3215;border-color:#a8862a;display:grid;place-items:center;overflow:hidden}
782
- .cmt-layer.dots .cmt-card.hot,.cmt-layer.dots .cmt-card.on{background:#53461a;border-color:#facc15}
817
+ background:#10284d;border-color:#2f6db3;display:grid;place-items:center;overflow:hidden}
818
+ .cmt-layer.dots .cmt-card.hot,.cmt-layer.dots .cmt-card.on{background:#16386b;border-color:var(--accent)}
783
819
  .cmt-layer.dots .cmt-full{display:none}
784
- .cmt-layer.dots .cmt-face{display:block;font-size:11px;font-weight:var(--bold);color:#f5d76e;
820
+ .cmt-layer.dots .cmt-face{display:block;font-size:11px;font-weight:var(--bold);color:#a9d2ff;
785
821
  font-variant-numeric:tabular-nums}
786
822
 
787
823
  /* ---- comments: floating bits ---- */
@@ -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;
package/src/collect.mjs CHANGED
@@ -12,7 +12,7 @@
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
17
  import { titleCase } from './config.mjs';
18
18
 
@@ -93,7 +93,7 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
93
93
  const id = byFile.get(entry.file);
94
94
  const seen = new Map();
95
95
  const ctx = {
96
- blocks: [], headings: [], byFile, numbers: new Map(),
96
+ blocks: [], headings: [], byFile, numbers: new Map(), outline: [], reviews: cfg.reviews !== false,
97
97
  uniqueSlug(base) {
98
98
  const key = base || 'section';
99
99
  const n = seen.get(key) || 0;
@@ -114,23 +114,19 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
114
114
  lines.splice(h1, 1);
115
115
  }
116
116
 
117
- // the lede paragraph of the blockquote under it becomes the subtitle
117
+ // the blockquote under it - all of it, every paragraph - becomes the subtitle
118
118
  let j = 0;
119
119
  while (j < lines.length && !lines[j].trim()) j++;
120
120
  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);
121
+ const lede = takeQuote(lines, j);
122
+ subtitle = renderBlocks(lede.buf, ctx);
123
+ lines.splice(0, lede.end);
128
124
  }
129
125
 
130
126
  html = nestSections(renderBlocks(lines, ctx, true), ctx);
131
127
  } else {
132
128
  const kind = extOf(entry.file).slice(1);
133
- subtitle = 'Kept verbatim as <code>' + esc(kind) + '</code>.';
129
+ subtitle = '<p>Kept verbatim as <code>' + esc(kind) + '</code>.</p>';
134
130
  html = '<div class="code-wrap"><span class="code-lang">' + esc(kind) +
135
131
  '</span><pre class="code"><code>' + esc(raw) + '</code></pre></div>';
136
132
  }
@@ -139,6 +135,7 @@ export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'u
139
135
 
140
136
  return {
141
137
  id, title, subtitle, html, badge,
138
+ outline: ctx.outline,
142
139
  group: entry.group,
143
140
  file: entry.rel || entry.file,
144
141
  words: raw.split(/\s+/).filter(Boolean).length,
package/src/config.mjs CHANGED
@@ -24,7 +24,7 @@ export const CLI = process.env.npm_command === 'exec' ? 'npx lowkey-doc' : 'lowk
24
24
 
25
25
  const DEFAULTS = {
26
26
  // Shown in the sidebar, the browser tab, and used to namespace the
27
- // per-reader state (which document was open, folded sections, comments).
27
+ // per-reader state (which document was open, folded sections, comments, reviews).
28
28
  title: null, // null -> derived from the project folder
29
29
  // Where the documents are, relative to the project root.
30
30
  docs: 'docs',
@@ -68,6 +68,7 @@ const DEFAULTS = {
68
68
  },
69
69
  // Reader features. Turn off what a given project does not want.
70
70
  comments: true,
71
+ reviews: true,
71
72
  search: true,
72
73
  };
73
74
 
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
@@ -17,6 +17,7 @@ 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
package/src/sections.mjs CHANGED
@@ -8,6 +8,12 @@
8
8
 
9
9
  const FOLD_SVG = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" ' +
10
10
  'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6L8 10.5 12.5 6"/></svg>';
11
+ const CHECK_SVG = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" ' +
12
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.3"/>' +
13
+ '<path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></svg>';
14
+ // The word on the button is drawn by CSS, so it is never part of the heading's
15
+ // text: not copied with it, not matched by search, not quoted by a comment.
16
+ const REVIEW_BTN = '<button class="rv" type="button" aria-pressed="false" aria-label="Reviewed">' + CHECK_SVG + '</button>';
11
17
 
12
18
  const HEAD_RE = /^<h([1-6]) id="([^"]+)">([\s\S]*)<\/h[1-6]>$/;
13
19
  const ANCHOR_RE = /^<a class="h-anchor"[^>]*>#<\/a>/;
@@ -30,6 +36,11 @@ export function nestSections(blocks, ctx) {
30
36
  stack[stack.length - 1].kids.push(node);
31
37
  stack.push(node);
32
38
  }
39
+ // the shape of the sections alone, which the page needs for reviews without
40
+ // having to read it back out of the HTML
41
+ const outline = (node) => node.kids.filter((k) => typeof k !== 'string')
42
+ .map((k) => ({ id: k.id, kids: outline(k) }));
43
+ ctx.outline = outline(root);
33
44
  return emitSections(root, [], ctx);
34
45
  }
35
46
 
@@ -55,7 +66,7 @@ function emitSections(node, prefix, ctx) {
55
66
  '<button class="sec-tog" type="button" tabindex="-1" aria-label="Fold section">' + FOLD_SVG + '</button>' +
56
67
  '<span class="sec-num">' + label + '</span>' +
57
68
  '</span>' +
58
- '<span class="sec-txt">' + text + '</span>' + anchor +
69
+ '<span class="sec-txt">' + text + '</span>' + anchor + (ctx.reviews ? REVIEW_BTN : '') +
59
70
  '</h' + kid.level + '>';
60
71
  return '<section class="sec" data-sec="' + kid.id + '">' + head +
61
72
  '<div class="sec-body">' + emitSections(kid, num, ctx) + '</div></section>';