lowkey-doc 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lowkey-doc.mjs +13 -0
- package/package.json +1 -1
- package/src/build.mjs +2 -2
- package/src/client/app.mjs +49 -12
- package/src/client/css.mjs +85 -34
- package/src/client/diagrams.mjs +18 -23
- package/src/markdown.mjs +7 -3
- package/src/page.mjs +5 -5
- package/src/update.mjs +89 -0
package/bin/lowkey-doc.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { loadConfig, CLI, VERSION } from '../src/config.mjs';
|
|
|
24
24
|
import { build } from '../src/build.mjs';
|
|
25
25
|
import { init } from '../src/init.mjs';
|
|
26
26
|
import { watch, DEFAULT_PORT } from '../src/watch.mjs';
|
|
27
|
+
import { checkForUpdate } from '../src/update.mjs';
|
|
27
28
|
|
|
28
29
|
// Every option each mode accepts, and whether it takes a value. Anything else
|
|
29
30
|
// is an error: a typo that silently does something else is worse than a stop.
|
|
@@ -59,6 +60,10 @@ init
|
|
|
59
60
|
--title <name> the dashboard title
|
|
60
61
|
--force replace the Claude command and skills with this version's.
|
|
61
62
|
An existing config is never replaced: --docs and --title edit it.
|
|
63
|
+
|
|
64
|
+
Update check
|
|
65
|
+
A maximum of one time each day, lowkey-doc asks npm for the latest version.
|
|
66
|
+
To stop the update check, set NO_UPDATE_NOTIFIER=1.
|
|
62
67
|
`;
|
|
63
68
|
|
|
64
69
|
const usage = (msg) => { throw new Error(msg + '\nSee: ' + CLI + ' --help'); };
|
|
@@ -120,6 +125,14 @@ async function main() {
|
|
|
120
125
|
if (opts['-h'] || opts['--help']) { console.log(HELP); return; }
|
|
121
126
|
if (opts['-v'] || opts['--version']) { console.log(VERSION); return; }
|
|
122
127
|
|
|
128
|
+
// asked while the command runs, told once it is done
|
|
129
|
+
const update = checkForUpdate();
|
|
130
|
+
await run(mode, folder, opts);
|
|
131
|
+
const notice = await update();
|
|
132
|
+
if (notice) console.error(notice);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function run(mode, folder, opts) {
|
|
123
136
|
if (mode === 'init') {
|
|
124
137
|
const r = init(folder, { docs: opts['--docs'], title: opts['--title'], force: !!opts['--force'] });
|
|
125
138
|
console.log('lowkey-doc init ' + r.root);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lowkey-doc",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
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",
|
package/src/build.mjs
CHANGED
|
@@ -81,8 +81,8 @@ export function collectDocs(cfg, memo = null) {
|
|
|
81
81
|
return { docs, changed, memo: { layout, files } };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
// ELK travels even when the default engine is mermaid
|
|
85
|
-
//
|
|
84
|
+
// ELK travels even when the default engine is mermaid, so a config edit under
|
|
85
|
+
// --watch can turn the clean layout on without fetching anything.
|
|
86
86
|
export async function vendorAssets(cfg, refresh = false) {
|
|
87
87
|
fs.mkdirSync(cfg.outDir, { recursive: true });
|
|
88
88
|
// The default output folder keeps itself out of git, so no project has to
|
package/src/client/app.mjs
CHANGED
|
@@ -96,6 +96,7 @@ export const APP_JS = String.raw`
|
|
|
96
96
|
current = id;
|
|
97
97
|
keep('last', id);
|
|
98
98
|
|
|
99
|
+
light(null); // a document is kept once built: left lit, it would light again on its return
|
|
99
100
|
main.textContent = '';
|
|
100
101
|
main.appendChild(bodyFor(d));
|
|
101
102
|
|
|
@@ -208,10 +209,35 @@ export const APP_JS = String.raw`
|
|
|
208
209
|
revealed = id;
|
|
209
210
|
unfoldTo(t);
|
|
210
211
|
t.scrollIntoView(smooth ? { behavior: 'smooth' } : undefined);
|
|
212
|
+
var head = t.closest('.sec-head'); // the heading, or an <a id> the author put in it
|
|
213
|
+
if (head) light(head.parentNode);
|
|
211
214
|
requestAnimationFrame(sync); // at the foot of the page nothing scrolls, so no scroll event would
|
|
212
215
|
return true;
|
|
213
216
|
}
|
|
214
217
|
|
|
218
|
+
// The section a link led to lights up (.is-lit) and fades. The ring is drawn
|
|
219
|
+
// round everything in it that is on view, inside the reading area.
|
|
220
|
+
var lit = null;
|
|
221
|
+
function light(sec){
|
|
222
|
+
if (lit) lit.classList.remove('is-lit');
|
|
223
|
+
lit = sec;
|
|
224
|
+
if (!sec) return;
|
|
225
|
+
var box = sec.getBoundingClientRect(), area = main.getBoundingClientRect();
|
|
226
|
+
var l = box.left, r = box.right;
|
|
227
|
+
sec.querySelectorAll('.sec-gut,.diagram,.table-wrap').forEach(function(n){
|
|
228
|
+
if (!n.offsetParent) return; // inside a folded section
|
|
229
|
+
var b = n.getBoundingClientRect();
|
|
230
|
+
l = Math.min(l, b.left); r = Math.max(r, b.right);
|
|
231
|
+
});
|
|
232
|
+
sec.style.setProperty('--lit-l', Math.round(box.left - Math.max(area.left + 4, l - 16)) + 'px');
|
|
233
|
+
sec.style.setProperty('--lit-r', Math.round(Math.min(area.right - 4, r + 16) - box.right) + 'px');
|
|
234
|
+
void sec.offsetWidth; // lit again: the fade starts over
|
|
235
|
+
sec.classList.add('is-lit');
|
|
236
|
+
}
|
|
237
|
+
main.addEventListener('animationend', function(e){
|
|
238
|
+
if (e.animationName === 'sec-lit') e.target.classList.remove('is-lit');
|
|
239
|
+
});
|
|
240
|
+
|
|
215
241
|
/* ---------------- reviews ----------------
|
|
216
242
|
A reader marks what they have read and agree with. Every section owns the
|
|
217
243
|
words directly under its heading, and the document owns its standfirst
|
|
@@ -443,14 +469,18 @@ export const APP_JS = String.raw`
|
|
|
443
469
|
// left alone here.
|
|
444
470
|
var fig = e.target.closest('.diagram');
|
|
445
471
|
if (fig && !e.target.closest('svg.dg') && !e.target.closest('.diagram-head')){
|
|
446
|
-
var svg = fig.querySelector('svg');
|
|
472
|
+
var svg = fig.querySelector('.diagram-out > svg');
|
|
447
473
|
if (svg) openBox(svg);
|
|
448
474
|
return;
|
|
449
475
|
}
|
|
450
476
|
var a = e.target.closest('a[href^="#"]');
|
|
451
477
|
if (a){
|
|
452
478
|
var href = a.getAttribute('href');
|
|
453
|
-
if (href.indexOf('#/') === 0)
|
|
479
|
+
if (href.indexOf('#/') === 0){
|
|
480
|
+
// a link to where the page already is changes no hash, so would do nothing
|
|
481
|
+
if (a.hash === location.hash){ e.preventDefault(); route(); }
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
454
484
|
e.preventDefault();
|
|
455
485
|
if (reveal(href.slice(1), true)) history.replaceState(null, '', '#/' + current + '/' + href.slice(1));
|
|
456
486
|
return;
|
|
@@ -467,11 +497,19 @@ export const APP_JS = String.raw`
|
|
|
467
497
|
fold(sec, !sec.classList.contains('shut'), true);
|
|
468
498
|
});
|
|
469
499
|
|
|
470
|
-
document.getElementById('toggle')
|
|
471
|
-
|
|
500
|
+
var sideBtn = document.getElementById('toggle');
|
|
501
|
+
function setSide(on){
|
|
502
|
+
layout.classList.toggle('collapsed', on);
|
|
503
|
+
sideBtn.title = (on ? 'Show' : 'Hide') + ' sidebar [';
|
|
504
|
+
}
|
|
505
|
+
function toggleSide(){
|
|
506
|
+
layout.classList.add('anim'); // slid when the reader asks, never on load
|
|
507
|
+
var on = !layout.classList.contains('collapsed');
|
|
508
|
+
setSide(on);
|
|
472
509
|
keep('collapsed', on ? '1' : '');
|
|
473
510
|
schedulePlace();
|
|
474
|
-
}
|
|
511
|
+
}
|
|
512
|
+
sideBtn.addEventListener('click', toggleSide);
|
|
475
513
|
|
|
476
514
|
if (search) search.addEventListener('input', function(){
|
|
477
515
|
var q = search.value.trim().toLowerCase();
|
|
@@ -507,7 +545,7 @@ export const APP_JS = String.raw`
|
|
|
507
545
|
}
|
|
508
546
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
509
547
|
if (e.key === '/' && search){ e.preventDefault(); search.focus(); }
|
|
510
|
-
if (e.key === '[')
|
|
548
|
+
if (e.key === '[') toggleSide();
|
|
511
549
|
});
|
|
512
550
|
|
|
513
551
|
function sync(){
|
|
@@ -824,7 +862,7 @@ export const APP_JS = String.raw`
|
|
|
824
862
|
if (!m) return;
|
|
825
863
|
unfoldTo(m);
|
|
826
864
|
var r = m.getBoundingClientRect();
|
|
827
|
-
if (r.top <
|
|
865
|
+
if (r.top < 84 || r.bottom > innerHeight - 20) m.scrollIntoView({ block: 'center' });
|
|
828
866
|
}
|
|
829
867
|
|
|
830
868
|
// a highlight and its card are one comment seen twice: hovering either lights
|
|
@@ -1013,7 +1051,7 @@ export const APP_JS = String.raw`
|
|
|
1013
1051
|
|
|
1014
1052
|
var BLOCKS = 'p,li,h1,h2,h3,h4,h5,h6,pre,table,blockquote,figure,img,hr';
|
|
1015
1053
|
var MARK_SHARE = .5; // past half the page changed, marking all of it says nothing
|
|
1016
|
-
var TOP_EDGE =
|
|
1054
|
+
var TOP_EDGE = 78; // below the sticky top bar
|
|
1017
1055
|
var hold = null;
|
|
1018
1056
|
|
|
1019
1057
|
function applyUpdate(next, marks){
|
|
@@ -1151,13 +1189,12 @@ export const APP_JS = String.raw`
|
|
|
1151
1189
|
|
|
1152
1190
|
// Edited diagrams, when they line up one for one with the ones they
|
|
1153
1191
|
// replace: the old drawing stays up, faded, until the new one is ready -
|
|
1154
|
-
// no collapse and regrow under the reader - and
|
|
1155
|
-
//
|
|
1192
|
+
// no collapse and regrow under the reader - and an entity opened from the
|
|
1193
|
+
// page stays open.
|
|
1156
1194
|
var stale = olds.filter(function(f){ return !used.has(f); });
|
|
1157
1195
|
if (stale.length !== fresh.length) return;
|
|
1158
1196
|
fresh.forEach(function(f, i){
|
|
1159
1197
|
var o = stale[i];
|
|
1160
|
-
if (o.dataset.engine) f.dataset.engine = o.dataset.engine;
|
|
1161
1198
|
if (o._open) f._open = o._open;
|
|
1162
1199
|
var out = f.querySelector('.diagram-out'), prev = o.querySelector('.diagram-out');
|
|
1163
1200
|
if (!out || !prev || !prev.firstChild) return;
|
|
@@ -1347,7 +1384,7 @@ export const APP_JS = String.raw`
|
|
|
1347
1384
|
doc: function(id){ return byId[id]; }
|
|
1348
1385
|
};
|
|
1349
1386
|
|
|
1350
|
-
if (recall('collapsed'))
|
|
1387
|
+
if (recall('collapsed')) setSide(true);
|
|
1351
1388
|
buildNav();
|
|
1352
1389
|
route();
|
|
1353
1390
|
})();
|
package/src/client/css.mjs
CHANGED
|
@@ -25,7 +25,8 @@ html{-webkit-text-size-adjust:100%}
|
|
|
25
25
|
inside the sentence, not as a heavier typeface dropped into it. */
|
|
26
26
|
--semi:560; --bold:620;
|
|
27
27
|
--scroll:rgba(255,255,255,.12); --scroll-on:rgba(255,255,255,.22);
|
|
28
|
-
--rail
|
|
28
|
+
/* --side is the sidebar's own width, --rail the room the page gives it */
|
|
29
|
+
--side:270px; --rail:var(--side);
|
|
29
30
|
/* content column: --measure is the text width, --gutter the breathing room
|
|
30
31
|
either side of it. The column is centred in the main area. */
|
|
31
32
|
--measure:92ch; --gutter:46px;
|
|
@@ -78,7 +79,12 @@ body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;
|
|
|
78
79
|
radial-gradient(circle,rgba(77,163,255,.16) 0 .6px,transparent 1.1px) 137px 71px/311px 283px}
|
|
79
80
|
a{color:inherit;text-decoration:none}
|
|
80
81
|
.layout{display:grid;grid-template-columns:var(--rail) 1fr;min-height:100vh}
|
|
81
|
-
.layout.collapsed{--rail:
|
|
82
|
+
.layout.collapsed{--rail:0px}
|
|
83
|
+
/* Hiding the sidebar slides it out whole: its column closes while the
|
|
84
|
+
sidebar moves left by as much, so nothing in it squeezes on the way out.
|
|
85
|
+
Only a toggle the reader makes is animated (.anim), never the state a page
|
|
86
|
+
opens in. */
|
|
87
|
+
.layout.anim{transition:grid-template-columns .28s ease}
|
|
82
88
|
|
|
83
89
|
/* ---- scrollbars ----
|
|
84
90
|
Chrome and Safari take the ::-webkit rules. Firefox only understands the two
|
|
@@ -94,14 +100,16 @@ a{color:inherit;text-decoration:none}
|
|
|
94
100
|
@supports (-moz-appearance:none){*{scrollbar-width:thin;scrollbar-color:var(--scroll) transparent}}
|
|
95
101
|
|
|
96
102
|
/* ---- sidebar ---- */
|
|
97
|
-
.side{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;
|
|
103
|
+
.side{position:sticky;top:0;width:var(--side);height:100vh;display:flex;flex-direction:column;
|
|
98
104
|
border-right:1px solid var(--line);overflow:hidden}
|
|
105
|
+
.collapsed .side{transform:translateX(-100%);visibility:hidden}
|
|
106
|
+
.anim .side{transition:transform .28s ease,visibility 0s}
|
|
107
|
+
.anim.collapsed .side{transition:transform .28s ease,visibility 0s .28s}
|
|
99
108
|
.side-top{padding:16px 14px 12px}
|
|
100
109
|
.brand{min-width:0}
|
|
101
|
-
/* the toggle
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
.brand-row{display:flex;align-items:center;gap:8px;min-width:0}
|
|
110
|
+
/* the toggle sits at the end of the title's row (it lives outside the sidebar,
|
|
111
|
+
see .side-btn), so the row keeps its 28px and the gap before it free */
|
|
112
|
+
.brand-row{display:flex;align-items:center;min-width:0;padding-right:36px}
|
|
105
113
|
.brand-name{flex:1;min-width:0;height:28px;line-height:28px;font-size:14px;font-weight:var(--bold);
|
|
106
114
|
letter-spacing:-.012em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
107
115
|
.brand-name::before{content:"";display:inline-block;width:8px;height:8px;margin:0 9px 1px 2px;
|
|
@@ -111,6 +119,34 @@ a{color:inherit;text-decoration:none}
|
|
|
111
119
|
transition:background .15s,color .15s,border-color .15s}
|
|
112
120
|
.icon-btn:hover{background:var(--hover);color:var(--ink);border-color:var(--line)}
|
|
113
121
|
.icon-btn svg{width:15px;height:15px}
|
|
122
|
+
/* One button hides the sidebar and brings it back. It can't live in the
|
|
123
|
+
sidebar - the sidebar's slide would carry it off - so it is fixed over it,
|
|
124
|
+
on the title's row (.side-top's 16px down, 14px in from the edge). As the
|
|
125
|
+
sidebar slides out the button rides along until it reaches the corner,
|
|
126
|
+
12px in, and stops there; the sidebar coming back picks it up on its way.
|
|
127
|
+
That is the sidebar's own ease over 270px, cut off where the button's 215
|
|
128
|
+
run out (--ride-out), or starting only once the sidebar reaches it
|
|
129
|
+
(--ride-in) - both worked out for a --side of 270px.
|
|
130
|
+
Away, it gets a ring of its own, which comes in as it stops. */
|
|
131
|
+
.side-btn{position:fixed;top:16px;left:12px;z-index:8;isolation:isolate;
|
|
132
|
+
transform:translateX(calc(var(--side) - 55px));
|
|
133
|
+
--ride-out:linear(0,.041 4.9%,.117 9.9%,.228 14.8%,.364 19.8%,.505 24.7%,.636 29.6%,.751 34.6%,.848 39.5%,.931 44.5%,1 49.4%,1);
|
|
134
|
+
--ride-in:linear(0,0 15.9%,.238 24.3%,.453 32.7%,.621 41.1%,.746 49.5%,.838 58%,.904 66.4%,.949 74.8%,.979 83.2%,.995 91.6%,1)}
|
|
135
|
+
.side-btn::before{content:"";position:absolute;inset:-1px;z-index:-1;border:1px solid var(--line-2);
|
|
136
|
+
border-radius:inherit;background:var(--surface);box-shadow:0 6px 18px -8px rgba(0,0,0,.8);
|
|
137
|
+
opacity:0;transition:background .15s}
|
|
138
|
+
.collapsed .side-btn{transform:none}
|
|
139
|
+
.collapsed .side-btn::before{opacity:1}
|
|
140
|
+
.collapsed .side-btn:hover::before{background:var(--surface-2)}
|
|
141
|
+
.anim .side-btn{transition:transform .28s var(--ride-in),background .15s,color .15s,border-color .15s}
|
|
142
|
+
.anim.collapsed .side-btn{transition:transform .28s var(--ride-out),background .15s,color .15s,border-color .15s}
|
|
143
|
+
.anim .side-btn::before{transition:opacity .1s,background .15s}
|
|
144
|
+
.anim.collapsed .side-btn::before{transition:opacity .14s ease .1s,background .15s}
|
|
145
|
+
/* ...and the page keeps clear of it: the top bar's title, and the title card
|
|
146
|
+
when the column comes close enough to the corner (a step: 28px, or 59px) */
|
|
147
|
+
.collapsed .topbar-in{padding-left:max(var(--gutter),calc(52px - max(0px,(100vw - var(--column)) / 2)))}
|
|
148
|
+
.collapsed .head{margin-top:clamp(28px,calc((52px - (100vw - var(--column)) / 2) * 100),59px)}
|
|
149
|
+
.anim .head{transition:margin-top .28s ease}
|
|
114
150
|
.search{margin:8px 14px 6px;position:relative}
|
|
115
151
|
.search input{width:100%;padding:7px 12px 7px 31px;border:1px solid var(--line);border-radius:999px;
|
|
116
152
|
background:var(--surface);color:var(--ink);font:inherit;font-size:13px;outline:none;
|
|
@@ -161,18 +197,6 @@ a{color:inherit;text-decoration:none}
|
|
|
161
197
|
.is-reviewed>.rv-mark{display:block}
|
|
162
198
|
.sub .rv-mark{align-self:center;width:12px;height:12px}
|
|
163
199
|
|
|
164
|
-
.collapsed .brand-name,.collapsed .search,.collapsed .nav-group,
|
|
165
|
-
.collapsed .item-title,.collapsed .caret,.collapsed .foot-lbl,.collapsed .rv-mark{display:none}
|
|
166
|
-
.collapsed .subs,.collapsed .item.open+.subs{display:none}
|
|
167
|
-
.collapsed .side-top{padding:16px 0 12px}
|
|
168
|
-
.collapsed .brand-row{justify-content:center}
|
|
169
|
-
.collapsed .nav{padding:2px 8px;scrollbar-width:none}
|
|
170
|
-
.collapsed .nav::-webkit-scrollbar{display:none}
|
|
171
|
-
.collapsed .item{justify-content:center;padding:8px 0;margin-bottom:2px}
|
|
172
|
-
.collapsed .num{min-width:0;font-size:12px}
|
|
173
|
-
.collapsed .side-foot{padding:10px 8px}
|
|
174
|
-
.collapsed .foot-btn{justify-content:center;padding:7px 0}
|
|
175
|
-
|
|
176
200
|
/* the foot of the sidebar: the settings, an icon with its name beside it */
|
|
177
201
|
.side-foot{display:flex;padding:10px 12px;border-top:1px solid var(--line)}
|
|
178
202
|
.foot-btn{flex:1;display:flex;align-items:center;gap:9px;padding:7px 12px;border:1px solid transparent;
|
|
@@ -281,7 +305,7 @@ export const CSS_MAIN = `
|
|
|
281
305
|
Every heading owns the blocks under it, so a section can be folded away and
|
|
282
306
|
the space between two of them can be set as one rule instead of guessed at
|
|
283
307
|
from heading margins. */
|
|
284
|
-
.doc [id]{scroll-margin-top:
|
|
308
|
+
.doc [id]{scroll-margin-top:102px}
|
|
285
309
|
.sec{margin-top:30px}
|
|
286
310
|
.doc>.sec{margin-top:54px;padding-top:32px;border-top:1px solid var(--line)}
|
|
287
311
|
.doc>.sec:first-child{margin-top:2px;padding-top:0;border-top:0}
|
|
@@ -291,7 +315,7 @@ export const CSS_MAIN = `
|
|
|
291
315
|
.sec.shut>.sec-body{display:none}
|
|
292
316
|
|
|
293
317
|
.sec-head{position:relative;display:flex;align-items:baseline;gap:11px;cursor:pointer;
|
|
294
|
-
line-height:1.32;letter-spacing:-.018em;scroll-margin-top:
|
|
318
|
+
line-height:1.32;letter-spacing:-.018em;scroll-margin-top:112px}
|
|
295
319
|
h1.sec-head{margin:0 0 15px;font-size:22px;font-weight:var(--bold)}
|
|
296
320
|
h2.sec-head{margin:0 0 13px;font-size:18.5px;font-weight:var(--bold)}
|
|
297
321
|
h3.sec-head{margin:0 0 10px;font-size:15.5px;font-weight:var(--semi)}
|
|
@@ -320,6 +344,22 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
|
|
|
320
344
|
opacity:0;transition:opacity .15s,color .15s}
|
|
321
345
|
.sec-head:hover .h-anchor{opacity:1}
|
|
322
346
|
.h-anchor:hover{color:var(--ink)}
|
|
347
|
+
/* The section a link led to: a ring lights up round it and fades away, so
|
|
348
|
+
which part of the page the link meant is never a guess. It stands off the
|
|
349
|
+
text on every side - on the left past the numbers hanging in the margin, on
|
|
350
|
+
the right past a diagram spilling out of the column - by --lit-l and
|
|
351
|
+
--lit-r, measured when it is lit. It sits behind the section, so the text,
|
|
352
|
+
and every box with a face of its own, stays on top of it. */
|
|
353
|
+
.sec.is-lit{position:relative;isolation:isolate}
|
|
354
|
+
.sec.is-lit::after{content:"";position:absolute;z-index:-1;pointer-events:none;
|
|
355
|
+
top:-14px;bottom:-14px;left:calc(0px - var(--lit-l,16px));right:calc(0px - var(--lit-r,16px));
|
|
356
|
+
border:1px solid var(--accent-ring);border-radius:16px;
|
|
357
|
+
background:linear-gradient(var(--accent-soft),rgba(77,163,255,.025) 260px);
|
|
358
|
+
box-shadow:0 0 24px -6px var(--accent-ring);
|
|
359
|
+
animation:sec-lit 2.4s ease-in-out both}
|
|
360
|
+
/* a chapter's box starts at the rule above it; the ring starts at its heading */
|
|
361
|
+
.doc>.sec.is-lit:not(:first-child)::after{top:18px}
|
|
362
|
+
@keyframes sec-lit{0%{opacity:0}16%,48%{opacity:1}100%{opacity:0}}
|
|
323
363
|
|
|
324
364
|
/* ---- reviews ----
|
|
325
365
|
Every section, and the document in its header card, has a Reviewed button:
|
|
@@ -518,7 +558,6 @@ tbody tr:last-child td{border-bottom:0}
|
|
|
518
558
|
.empty{padding:60px var(--gutter);width:100%;max-width:var(--column);margin-inline:auto;color:var(--muted)}
|
|
519
559
|
mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:0 2px}
|
|
520
560
|
@media (max-width:820px){
|
|
521
|
-
.layout{grid-template-columns:var(--rail)}
|
|
522
561
|
.layout:not(.collapsed) .main{display:none}
|
|
523
562
|
:root{--gutter:20px}
|
|
524
563
|
:root.fixed-margin{--column:100%}
|
|
@@ -535,17 +574,23 @@ mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:
|
|
|
535
574
|
.sec-tog{opacity:1}
|
|
536
575
|
}
|
|
537
576
|
@media print{
|
|
538
|
-
.side{display:none}.layout{grid-template-columns:1fr}
|
|
577
|
+
.side,.side-btn{display:none}.layout{grid-template-columns:1fr}
|
|
539
578
|
.head{border:0;border-radius:0;padding-block:20px;background:none}.head::before,.head::after{display:none}
|
|
540
|
-
.sec.shut>.sec-body{display:block}.sec-tog,.h-anchor,.rv,.rv-bar{display:none}
|
|
579
|
+
.sec.shut>.sec-body{display:block}.sec-tog,.h-anchor,.rv,.rv-bar,.sec.is-lit::after{display:none}
|
|
541
580
|
}
|
|
542
581
|
`;
|
|
543
582
|
|
|
583
|
+
/* The bar is solid, not frosted. A backdrop blur on a fixed bar has to be
|
|
584
|
+
redrawn under it on every frame of a scroll, and when the browser misses one
|
|
585
|
+
the bar flickers between its glass and the bare page beneath - and without
|
|
586
|
+
the blur, anything short of opaque lets the text scrolling under it show.
|
|
587
|
+
It is 60px tall so the title centred in it sits on the sidebar title's line
|
|
588
|
+
(16px down, 28px high), and so does the corner button beside it. */
|
|
544
589
|
export const CSS_BAR = `
|
|
545
590
|
.topbar{position:fixed;top:0;left:var(--rail);right:0;z-index:6;pointer-events:none;
|
|
546
|
-
display:flex;height:
|
|
547
|
-
background:rgba(8,9,11,.78);backdrop-filter:saturate(160%) blur(14px);
|
|
591
|
+
display:flex;height:60px;background:var(--bg);
|
|
548
592
|
border-bottom:1px solid var(--line);transform:translateY(-101%);transition:transform .22s ease}
|
|
593
|
+
.anim .topbar{transition:transform .22s ease,left .28s ease}
|
|
549
594
|
.topbar.show{transform:none}
|
|
550
595
|
.topbar-in{display:flex;align-items:center;gap:10px;min-width:0;width:100%;
|
|
551
596
|
max-width:var(--column);margin-inline:auto;padding:0 var(--gutter)}
|
|
@@ -569,18 +614,24 @@ export const CSS_DIAGRAM = `
|
|
|
569
614
|
.diagram-name{flex:1;min-width:0;display:flex;align-items:baseline;gap:9px;overflow:hidden;white-space:nowrap}
|
|
570
615
|
.diagram-kind{flex:none;font-size:10px;font-weight:var(--bold);letter-spacing:.09em;text-transform:uppercase;color:var(--faint)}
|
|
571
616
|
.diagram-title{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12.5px;font-weight:var(--semi);color:var(--ink)}
|
|
572
|
-
.diagram-
|
|
573
|
-
.diagram-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
.
|
|
578
|
-
.diagram
|
|
617
|
+
.diagram-head{cursor:pointer;transition:background .12s}
|
|
618
|
+
.diagram-head:hover{background:var(--surface-2)}
|
|
619
|
+
.diagram-bar{flex:none;display:flex;gap:2px}
|
|
620
|
+
.diagram-btn{width:26px;height:26px;padding:0;display:grid;place-items:center;
|
|
621
|
+
background:none;border:1px solid transparent;border-radius:999px;color:var(--faint);cursor:pointer;
|
|
622
|
+
transition:background .15s,color .15s,border-color .15s}
|
|
623
|
+
.diagram-btn svg{width:14px;height:14px}
|
|
624
|
+
.diagram-btn[data-act=fold] svg{transition:transform .18s ease}
|
|
625
|
+
.diagram-head:hover .diagram-btn[data-act=fold],.diagram-btn:hover{color:var(--ink)}
|
|
626
|
+
.diagram-btn:hover{background:var(--hover);border-color:var(--line)}
|
|
627
|
+
.diagram:has(.diagram-out > svg):hover{border-color:var(--line-2)}
|
|
579
628
|
.diagram .fail{font-family:var(--mono);font-size:12px;color:var(--muted);text-align:left;white-space:pre-wrap}
|
|
580
629
|
.diagram-note{margin-top:10px;font-size:11px;color:var(--faint);text-align:center}
|
|
581
630
|
/* A collapsed diagram is its header and nothing else: a plain white box the
|
|
582
|
-
height of a line, which a click opens again.
|
|
631
|
+
height of a line, which a click opens again. Its chevron turns to point at
|
|
632
|
+
the header, as a closed dropdown's does. */
|
|
583
633
|
.diagram.is-folded{padding-bottom:0;overflow:hidden;cursor:pointer}
|
|
634
|
+
.diagram.is-folded .diagram-btn[data-act=fold] svg{transform:rotate(-90deg)}
|
|
584
635
|
.diagram.is-folded .diagram-head{margin-bottom:0;border-bottom-color:transparent}
|
|
585
636
|
.diagram.is-folded:hover{border-color:var(--line-2)}
|
|
586
637
|
.diagram.is-folded .diagram-out,.diagram.is-folded .diagram-note,
|
package/src/client/diagrams.mjs
CHANGED
|
@@ -2180,19 +2180,11 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
2180
2180
|
});
|
|
2181
2181
|
}
|
|
2182
2182
|
|
|
2183
|
-
// The
|
|
2184
|
-
//
|
|
2185
|
-
|
|
2186
|
-
function engineFor(fig, code){
|
|
2183
|
+
// The engine is the project's choice, made in the config: the page does not
|
|
2184
|
+
// offer the reader another one.
|
|
2185
|
+
function engineFor(code){
|
|
2187
2186
|
if (!cleanKind(code)) return 'mermaid';
|
|
2188
|
-
return
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
|
-
function paintButton(fig, code, used){
|
|
2192
|
-
var btn = fig.querySelector('[data-act="engine"]');
|
|
2193
|
-
if (!btn) return;
|
|
2194
|
-
btn.textContent = (cleanKind(code) && canClean(cleanKind(code)))
|
|
2195
|
-
? (used === 'clean' ? 'Mermaid layout' : 'Clean layout') : '';
|
|
2187
|
+
return CFG.engine || 'clean';
|
|
2196
2188
|
}
|
|
2197
2189
|
|
|
2198
2190
|
/* ------------------------------------------------------------- folding
|
|
@@ -2224,8 +2216,9 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
2224
2216
|
fig.classList.toggle('is-folded', on);
|
|
2225
2217
|
var btn = fig.querySelector('[data-act="fold"]');
|
|
2226
2218
|
if (btn){
|
|
2227
|
-
btn.
|
|
2228
|
-
btn.
|
|
2219
|
+
btn.title = on ? 'Show' : 'Collapse';
|
|
2220
|
+
btn.setAttribute('aria-label', btn.title);
|
|
2221
|
+
btn.setAttribute('aria-expanded', on ? 'false' : 'true');
|
|
2229
2222
|
}
|
|
2230
2223
|
}
|
|
2231
2224
|
|
|
@@ -2233,7 +2226,7 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
2233
2226
|
var code = fig.querySelector('.diagram-src').textContent;
|
|
2234
2227
|
if (!fig._foldSeen){ fig._foldSeen = true; paintFold(fig, folded(fig)); }
|
|
2235
2228
|
if (fig.classList.contains('is-folded')) return Promise.resolve();
|
|
2236
|
-
var want = force || engineFor(
|
|
2229
|
+
var want = force || engineFor(code);
|
|
2237
2230
|
if (fig.dataset.drawn === want) return Promise.resolve();
|
|
2238
2231
|
fig.dataset.drawn = want;
|
|
2239
2232
|
var kind = cleanKind(code);
|
|
@@ -2251,7 +2244,7 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
2251
2244
|
if (note) note.remove();
|
|
2252
2245
|
done = renderMermaid(fig, code);
|
|
2253
2246
|
}
|
|
2254
|
-
return done
|
|
2247
|
+
return done;
|
|
2255
2248
|
}
|
|
2256
2249
|
|
|
2257
2250
|
function drawAll(scope){
|
|
@@ -2545,6 +2538,14 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
2545
2538
|
var shut = e.target.closest && e.target.closest('.diagram.is-folded');
|
|
2546
2539
|
// anywhere on a collapsed diagram opens it
|
|
2547
2540
|
if (shut && (!btn || btn.dataset.act === 'fold')){ e.stopPropagation(); setFold(shut, false); return; }
|
|
2541
|
+
// the header bar folds the diagram as its chevron does - unless the click
|
|
2542
|
+
// was really the end of a drag that selected some of the title
|
|
2543
|
+
var head = !btn && e.target.closest && e.target.closest('.diagram-head');
|
|
2544
|
+
if (head){
|
|
2545
|
+
var s = window.getSelection();
|
|
2546
|
+
if (s && !s.isCollapsed && String(s).trim()) return;
|
|
2547
|
+
btn = head.querySelector('[data-act="fold"]');
|
|
2548
|
+
}
|
|
2548
2549
|
if (!btn) return;
|
|
2549
2550
|
var fig = btn.closest('.diagram');
|
|
2550
2551
|
if (!fig) return;
|
|
@@ -2555,14 +2556,8 @@ export const DIAGRAMS_JS = String.raw`
|
|
|
2555
2556
|
setFold(fig, true);
|
|
2556
2557
|
return;
|
|
2557
2558
|
}
|
|
2558
|
-
if (btn.dataset.act === 'engine'){
|
|
2559
|
-
var next = fig.dataset.drawn === 'clean' ? 'mermaid' : 'clean';
|
|
2560
|
-
fig.dataset.engine = next;
|
|
2561
|
-
draw(fig, next);
|
|
2562
|
-
return;
|
|
2563
|
-
}
|
|
2564
2559
|
if (btn.dataset.act === 'zoom'){
|
|
2565
|
-
var svg = fig.querySelector('svg');
|
|
2560
|
+
var svg = fig.querySelector('.diagram-out > svg');
|
|
2566
2561
|
if (svg && window.__lightbox) window.__lightbox(svg);
|
|
2567
2562
|
}
|
|
2568
2563
|
}, true);
|
package/src/markdown.mjs
CHANGED
|
@@ -25,6 +25,11 @@ const DIAGRAM_KINDS = {
|
|
|
25
25
|
gantt: 'Gantt chart', pie: 'Pie chart', journey: 'User journey', mindmap: 'Mind map',
|
|
26
26
|
timeline: 'Timeline', gitgraph: 'Git graph', quadrantchart: 'Quadrant chart',
|
|
27
27
|
};
|
|
28
|
+
const SVG_OPEN = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
|
|
29
|
+
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">';
|
|
30
|
+
const FULL_SVG = SVG_OPEN + '<path d="M3 6.5V3h3.5M9.5 3H13v3.5M13 9.5V13H9.5M6.5 13H3V9.5"/></svg>';
|
|
31
|
+
const FOLD_SVG = SVG_OPEN + '<path d="M3.5 6L8 10.5 12.5 6"/></svg>';
|
|
32
|
+
|
|
28
33
|
function diagramName(code) {
|
|
29
34
|
let title = '';
|
|
30
35
|
const body = String(code).replace(/^\s*---\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/, (_, fm) => {
|
|
@@ -521,9 +526,8 @@ export function parkFences(md, ctx) {
|
|
|
521
526
|
? '<figure class="diagram" data-diagram><pre class="diagram-src">' + esc(code) + '</pre>' +
|
|
522
527
|
'<div class="diagram-head">' + diagramName(code) +
|
|
523
528
|
'<div class="diagram-bar">' +
|
|
524
|
-
'<button class="diagram-btn" data-act="
|
|
525
|
-
'<button class="diagram-btn" data-act="
|
|
526
|
-
'<button class="diagram-btn" data-act="engine" type="button" title="Switch layout engine"></button>' +
|
|
529
|
+
'<button class="diagram-btn" data-act="zoom" type="button" title="Open full size" aria-label="Open full size">' + FULL_SVG + '</button>' +
|
|
530
|
+
'<button class="diagram-btn" data-act="fold" type="button" title="Collapse" aria-label="Collapse" aria-expanded="true">' + FOLD_SVG + '</button>' +
|
|
527
531
|
'</div>' +
|
|
528
532
|
'</div>' +
|
|
529
533
|
'<div class="diagram-out"></div></figure>'
|
package/src/page.mjs
CHANGED
|
@@ -61,16 +61,16 @@ export function renderPage({ cfg, docs, reviews = {}, fontCss, mermaidTag, elkTa
|
|
|
61
61
|
</head>
|
|
62
62
|
<body>
|
|
63
63
|
<div class="layout" id="layout">
|
|
64
|
+
<button class="icon-btn side-btn" id="toggle" type="button" title="Hide sidebar [">
|
|
65
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
|
66
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="2.5"/><path d="M6 2.5v11"/>
|
|
67
|
+
</svg>
|
|
68
|
+
</button>
|
|
64
69
|
<aside class="side">
|
|
65
70
|
<div class="side-top">
|
|
66
71
|
<div class="brand">
|
|
67
72
|
<div class="brand-row">
|
|
68
73
|
<span class="brand-name">${escAttr(cfg.title)}</span>
|
|
69
|
-
<button class="icon-btn" id="toggle" type="button" title="Toggle sidebar [">
|
|
70
|
-
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
|
71
|
-
<rect x="1.5" y="2.5" width="13" height="11" rx="2.5"/><path d="M6 2.5v11"/>
|
|
72
|
-
</svg>
|
|
73
|
-
</button>
|
|
74
74
|
</div>
|
|
75
75
|
</div>
|
|
76
76
|
</div>${searchBox}
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// update.mjs - say so when npm has a newer lowkey-doc than the one running.
|
|
2
|
+
// ------------------------------------------------------------------
|
|
3
|
+
// The registry is asked at most once a day, while the command does its own
|
|
4
|
+
// work, and never for longer than WAIT: a slow or absent network costs the
|
|
5
|
+
// notice and nothing else. The answer is kept in the user's cache folder, so
|
|
6
|
+
// the runs in between only read a file.
|
|
7
|
+
//
|
|
8
|
+
// Off when stderr is not a terminal, on CI, and with NO_UPDATE_NOTIFIER set.
|
|
9
|
+
// ------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import https from 'node:https';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { VERSION } from './config.mjs';
|
|
16
|
+
|
|
17
|
+
const REGISTRY = 'https://registry.npmjs.org/lowkey-doc/latest';
|
|
18
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
19
|
+
const WAIT = 1500;
|
|
20
|
+
|
|
21
|
+
export const UPDATE_FILE = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'),
|
|
22
|
+
'lowkey-doc', 'update-check.json');
|
|
23
|
+
|
|
24
|
+
const enabled = () => process.stderr.isTTY && !process.env.CI && !process.env.NO_UPDATE_NOTIFIER;
|
|
25
|
+
|
|
26
|
+
// 1.10.0 is newer than 1.9.3; a pre-release is never offered
|
|
27
|
+
export function newer(latest, current) {
|
|
28
|
+
if (!/^\d+\.\d+\.\d+$/.test(latest)) return false;
|
|
29
|
+
const a = latest.split('.').map(Number);
|
|
30
|
+
const b = current.split('-')[0].split('.').map(Number);
|
|
31
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
32
|
+
return current.includes('-');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// What to type depends on how it was run: npx keeps its own copy, and npm
|
|
36
|
+
// run uses the project's.
|
|
37
|
+
function howTo(env = process.env) {
|
|
38
|
+
if (env.npm_command === 'exec') return 'npx lowkey-doc@latest';
|
|
39
|
+
if (env.npm_command === 'run-script') return 'npm install lowkey-doc@latest';
|
|
40
|
+
return 'npm install -g lowkey-doc@latest';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const notice = (latest, current = VERSION, env = process.env) =>
|
|
44
|
+
'\n Update available: lowkey-doc ' + current + ' → ' + latest + '\n Run: ' + howTo(env) + '\n';
|
|
45
|
+
|
|
46
|
+
function readCache() {
|
|
47
|
+
try { return JSON.parse(fs.readFileSync(UPDATE_FILE, 'utf8')); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeCache(data) {
|
|
51
|
+
try {
|
|
52
|
+
fs.mkdirSync(path.dirname(UPDATE_FILE), { recursive: true });
|
|
53
|
+
fs.writeFileSync(UPDATE_FILE, JSON.stringify(data) + '\n');
|
|
54
|
+
} catch { /* a cache we cannot write only means asking again next time */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// node:https rather than fetch: an aborted fetch still holds the process open
|
|
58
|
+
// until its connection attempt times out, ten seconds on a network that drops
|
|
59
|
+
// packets. A destroyed request lets go of its socket at once.
|
|
60
|
+
function latestVersion() {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const done = (version) => { clearTimeout(timer); resolve(version); };
|
|
63
|
+
const req = https.get(REGISTRY, { headers: { accept: 'application/json' } }, (res) => {
|
|
64
|
+
if (res.statusCode !== 200) { res.resume(); return done(null); }
|
|
65
|
+
let body = '';
|
|
66
|
+
res.setEncoding('utf8');
|
|
67
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
68
|
+
res.on('end', () => { try { done(JSON.parse(body).version || null); } catch { done(null); } });
|
|
69
|
+
});
|
|
70
|
+
req.on('error', () => done(null));
|
|
71
|
+
const timer = setTimeout(() => req.destroy(), WAIT);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Starts the check, if one is due, and returns what to call once the command
|
|
76
|
+
// is done: it resolves to the notice to print, or to null.
|
|
77
|
+
export function checkForUpdate() {
|
|
78
|
+
if (!enabled()) return async () => null;
|
|
79
|
+
const cached = readCache();
|
|
80
|
+
// a failed check counts as one too, so being offline is not paid for on every run
|
|
81
|
+
const pending = cached && Date.now() - cached.checkedAt < DAY ? null : latestVersion().then((latest) => {
|
|
82
|
+
writeCache({ checkedAt: Date.now(), latest: latest || (cached && cached.latest) || null });
|
|
83
|
+
return latest;
|
|
84
|
+
});
|
|
85
|
+
return async () => {
|
|
86
|
+
const latest = (pending && await pending) || (cached && cached.latest);
|
|
87
|
+
return latest && newer(latest, VERSION) ? notice(latest) : null;
|
|
88
|
+
};
|
|
89
|
+
}
|