lowkey-doc 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
3
+ "version": "1.1.2",
4
4
  "description": "Render a folder of Markdown into one self-contained documentation dashboard, with flowcharts and ER diagrams that stay readable when they get dense.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
@@ -450,7 +476,11 @@ export const APP_JS = String.raw`
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) return;
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').addEventListener('click', function(){
471
- var on = layout.classList.toggle('collapsed');
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 === '[') document.getElementById('toggle').click();
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 < 70 || r.bottom > innerHeight - 20) m.scrollIntoView({ block: 'center' });
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 = 64; // below the sticky top bar
1054
+ var TOP_EDGE = 78; // below the sticky top bar
1017
1055
  var hold = null;
1018
1056
 
1019
1057
  function applyUpdate(next, marks){
@@ -1347,7 +1385,7 @@ export const APP_JS = String.raw`
1347
1385
  doc: function(id){ return byId[id]; }
1348
1386
  };
1349
1387
 
1350
- if (recall('collapsed')) layout.classList.add('collapsed');
1388
+ if (recall('collapsed')) setSide(true);
1351
1389
  buildNav();
1352
1390
  route();
1353
1391
  })();
@@ -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:270px;
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:62px}
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 shares a row with the title, so its 28px box - and the background
102
- that appears under it on hover - is centred on the title's own line rather
103
- than on the title-plus-subtitle block */
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:88px}
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:98px}
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:46px;
547
- background:rgba(8,9,11,.78);backdrop-filter:saturate(160%) blur(14px);
591
+ display:flex;height:60px;background:var(--bg);
548
592
  border-bottom:1px solid var(--line);transform:translateY(-101%);transition:transform .22s ease}
593
+ .anim .topbar{transition:transform .22s ease,left .28s ease}
549
594
  .topbar.show{transform:none}
550
595
  .topbar-in{display:flex;align-items:center;gap:10px;min-width:0;width:100%;
551
596
  max-width:var(--column);margin-inline:auto;padding:0 var(--gutter)}
package/src/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
+ }