markserv 1.17.4 → 1.20.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/lib/server.js CHANGED
@@ -10,8 +10,8 @@ const Promise = require('bluebird')
10
10
  const connect = require('connect')
11
11
  const less = require('less')
12
12
  const send = require('send')
13
- const liveReload = require('livereload')
14
- const connectLiveReload = require('connect-livereload')
13
+ const WebSocket = require('ws')
14
+ const getPort = require('get-port')
15
15
  const implant = require('implant')
16
16
  const deepmerge = require('deepmerge')
17
17
  const handlebars = require('handlebars')
@@ -61,6 +61,20 @@ const md = new MarkdownIt({
61
61
  slugify
62
62
  })
63
63
 
64
+ // Mermaid fences are rendered client-side by the lazy loader in
65
+ // templates/markdown.html. Pass the source through escaped (the loader
66
+ // reads it back via innerHTML) and keep highlight.js away from it, which
67
+ // would otherwise log "Could not find the language 'mermaid'".
68
+ // markdown-it passes strings starting with <pre straight through.
69
+ const {highlight} = md.options
70
+ md.options.highlight = (code, lang) => {
71
+ if (lang === 'mermaid') {
72
+ return `<pre class="mermaid">${md.utils.escapeHtml(code)}</pre>`
73
+ }
74
+
75
+ return highlight(code, lang)
76
+ }
77
+
64
78
  // Markdown Extension Types
65
79
  const fileTypes = {
66
80
  markdown: [
@@ -166,12 +180,12 @@ const getFile = path => new Promise((resolve, reject) => {
166
180
 
167
181
  // Get Custom Less CSS to use in all Markdown files
168
182
  const buildLessStyleSheet = cssPath =>
169
- new Promise(resolve =>
183
+ new Promise((resolve, reject) =>
170
184
  getFile(cssPath).then(data =>
171
185
  less.render(data).then(data =>
172
186
  resolve(data.css)
173
187
  )
174
- )
188
+ ).catch(reject)
175
189
  )
176
190
 
177
191
  const baseTemplate = (templateUrl, handlebarData) => new Promise((resolve, reject) => {
@@ -296,6 +310,22 @@ const createBreadcrumbs = path => {
296
310
  }
297
311
 
298
312
  // Http_request_handler: handles all the browser requests
313
+ const resolveTheme = flags => {
314
+ if (flags.light) {
315
+ return 'light'
316
+ }
317
+
318
+ if (flags.synthwave) {
319
+ return 'synthwave'
320
+ }
321
+
322
+ if (flags.theme && flags.theme !== 'dark') {
323
+ return flags.theme
324
+ }
325
+
326
+ return 'dark'
327
+ }
328
+
299
329
  const createRequestHandler = flags => {
300
330
  let {dir} = flags
301
331
  const isDir = fs.statSync(dir).isDirectory()
@@ -304,6 +334,13 @@ const createRequestHandler = flags => {
304
334
  }
305
335
 
306
336
  flags.$openLocation = path.relative(dir, flags.dir)
337
+ const theme = resolveTheme(flags)
338
+ const themeFlags = {
339
+ themeDark: theme === 'dark',
340
+ themeLight: theme === 'light',
341
+ themeSynthwave: theme === 'synthwave',
342
+ themeSolarized: theme === 'solarized'
343
+ }
307
344
 
308
345
  const implantOpts = {
309
346
  maxDepth: 10
@@ -320,6 +357,10 @@ const createRequestHandler = flags => {
320
357
  }),
321
358
 
322
359
  file: (url, opts) => new Promise(resolve => {
360
+ if (typeof url !== 'string' || typeof (opts && opts.baseDir) !== 'string') {
361
+ return resolve(false)
362
+ }
363
+
323
364
  const absUrl = path.join(opts.baseDir, url)
324
365
  getFile(absUrl)
325
366
  .then(data => {
@@ -333,6 +374,10 @@ const createRequestHandler = flags => {
333
374
  }),
334
375
 
335
376
  less: (url, opts) => new Promise(resolve => {
377
+ if (typeof url !== 'string' || typeof (opts && opts.baseDir) !== 'string') {
378
+ return resolve(false)
379
+ }
380
+
336
381
  const absUrl = path.join(opts.baseDir, url)
337
382
  buildLessStyleSheet(absUrl)
338
383
  .then(data => {
@@ -346,6 +391,10 @@ const createRequestHandler = flags => {
346
391
  }),
347
392
 
348
393
  markdown: (url, opts) => new Promise(resolve => {
394
+ if (typeof url !== 'string' || typeof (opts && opts.baseDir) !== 'string') {
395
+ return resolve(false)
396
+ }
397
+
349
398
  const absUrl = path.join(opts.baseDir, url)
350
399
  getFile(absUrl).then(markdownToHTML)
351
400
  .then(data => {
@@ -359,6 +408,10 @@ const createRequestHandler = flags => {
359
408
  }),
360
409
 
361
410
  html: (url, opts) => new Promise(resolve => {
411
+ if (typeof url !== 'string' || typeof (opts && opts.baseDir) !== 'string') {
412
+ return resolve(false)
413
+ }
414
+
362
415
  const absUrl = path.join(opts.baseDir, url)
363
416
  getFile(absUrl)
364
417
  .then(data => {
@@ -396,7 +449,12 @@ const createRequestHandler = flags => {
396
449
  filePath,
397
450
  errorMsg,
398
451
  errorStack,
399
- referer
452
+ referer,
453
+ theme,
454
+ ...themeFlags,
455
+ hotreload: flags.$hotreload,
456
+ wsPort: flags.$wsPort,
457
+ rootDir: flags.dir
400
458
  }
401
459
 
402
460
  return baseTemplate(templateUrl, handlebarData).then(final => {
@@ -455,26 +513,42 @@ const createRequestHandler = flags => {
455
513
  if (isMarkdown) {
456
514
  msg('markdown', style.link(prettyPath), flags)
457
515
  getFile(filePath).then(markdownToHTML).then(filePath).then(html => {
458
- return implant(html, implantHandlers, implantOpts).then(output => {
516
+ const contentPromise = flags.templates ?
517
+ implant(html, implantHandlers, implantOpts) :
518
+ Promise.resolve(html)
519
+
520
+ return contentPromise.then(output => {
459
521
  const templateUrl = path.join(__dirname, 'templates/markdown.html')
460
522
 
461
523
  const handlebarData = {
462
524
  title: path.parse(filePath).base,
463
525
  content: output,
464
- pid: process.pid | 'N/A'
526
+ pid: process.pid | 'N/A',
527
+ theme,
528
+ ...themeFlags,
529
+ hotreload: flags.$hotreload,
530
+ wsPort: flags.$wsPort,
531
+ rootDir: flags.dir
465
532
  }
466
533
 
467
534
  return baseTemplate(templateUrl, handlebarData).then(final => {
468
- const lvl2Dir = path.parse(templateUrl).dir
469
- const lvl2Opts = deepmerge(implantOpts, {baseDir: lvl2Dir})
470
-
471
- return implant(final, implantHandlers, lvl2Opts)
472
- .then(output => {
473
- res.writeHead(200, {
474
- 'content-type': 'text/html'
535
+ if (flags.templates) {
536
+ const lvl2Dir = path.parse(templateUrl).dir
537
+ const lvl2Opts = deepmerge(implantOpts, {baseDir: lvl2Dir})
538
+
539
+ return implant(final, implantHandlers, lvl2Opts)
540
+ .then(output => {
541
+ res.writeHead(200, {
542
+ 'content-type': 'text/html'
543
+ })
544
+ res.end(output)
475
545
  })
476
- res.end(output)
477
- })
546
+ }
547
+
548
+ res.writeHead(200, {
549
+ 'content-type': 'text/html'
550
+ })
551
+ res.end(final)
478
552
  })
479
553
  })
480
554
  }).catch(error => {
@@ -483,7 +557,11 @@ const createRequestHandler = flags => {
483
557
  } else if (isHtml) {
484
558
  msg('html', style.link(prettyPath), flags)
485
559
  getFile(filePath).then(html => {
486
- return implant(html, implantHandlers, implantOpts).then(output => {
560
+ const contentPromise = flags.templates ?
561
+ implant(html, implantHandlers, implantOpts) :
562
+ Promise.resolve(html)
563
+
564
+ return contentPromise.then(output => {
487
565
  res.writeHead(200, {
488
566
  'content-type': 'text/html'
489
567
  })
@@ -504,20 +582,32 @@ const createRequestHandler = flags => {
504
582
  content: dirToHtml(filePath),
505
583
  title: path.parse(filePath).base,
506
584
  pid: process.pid | 'N/A',
507
- breadcrumbs: createBreadcrumbs(path.relative(dir, filePath))
585
+ breadcrumbs: createBreadcrumbs(path.relative(dir, filePath)),
586
+ theme,
587
+ ...themeFlags,
588
+ hotreload: flags.$hotreload,
589
+ wsPort: flags.$wsPort,
590
+ rootDir: flags.dir
508
591
  }
509
592
 
510
593
  return baseTemplate(templateUrl, handlebarData).then(final => {
511
- const lvl2Dir = path.parse(templateUrl).dir
512
- const lvl2Opts = deepmerge(implantOpts, {baseDir: lvl2Dir})
513
- return implant(final, implantHandlers, lvl2Opts).then(output => {
514
- res.writeHead(200, {
515
- 'content-type': 'text/html'
594
+ if (flags.templates) {
595
+ const lvl2Dir = path.parse(templateUrl).dir
596
+ const lvl2Opts = deepmerge(implantOpts, {baseDir: lvl2Dir})
597
+ return implant(final, implantHandlers, lvl2Opts).then(output => {
598
+ res.writeHead(200, {
599
+ 'content-type': 'text/html'
600
+ })
601
+ res.end(output)
602
+ }).catch(error => {
603
+ console.error(error)
516
604
  })
517
- res.end(output)
518
- }).catch(error => {
519
- console.error(error)
605
+ }
606
+
607
+ res.writeHead(200, {
608
+ 'content-type': 'text/html'
520
609
  })
610
+ res.end(final)
521
611
  })
522
612
  } catch (error) {
523
613
  errorPage(500, filePath, error)
@@ -530,11 +620,8 @@ const createRequestHandler = flags => {
530
620
  }
531
621
  }
532
622
 
533
- const startConnectApp = (liveReloadPort, httpRequestHandler) => {
623
+ const startConnectApp = httpRequestHandler => {
534
624
  return connect()
535
- .use(connectLiveReload({
536
- port: liveReloadPort
537
- }))
538
625
  .use('/', httpRequestHandler)
539
626
  }
540
627
 
@@ -551,33 +638,178 @@ const startHTTPServer = (connectApp, port, flags) => {
551
638
  return httpServer
552
639
  }
553
640
 
554
- const startLiveReloadServer = (liveReloadPort, flags) => {
641
+ const startHotReload = (wsPort, flags) => {
555
642
  let {dir} = flags
556
643
  const isDir = fs.statSync(dir).isDirectory()
557
644
  if (!isDir) {
558
645
  dir = path.parse(flags.dir).dir
559
646
  }
560
647
 
561
- const exts = fileTypes.watch.map(type => type.slice(1))
562
- const exclusions = fileTypes.exclusions.map(exPath => {
563
- return path.join(dir, exPath)
648
+ const wss = new WebSocket.Server({port: wsPort})
649
+ const clients = new Map()
650
+
651
+ wss.on('connection', ws => {
652
+ ws.on('message', data => {
653
+ try {
654
+ const msg_ = JSON.parse(data)
655
+ if (msg_.path) {
656
+ clients.set(ws, msg_.path)
657
+ }
658
+ } catch (_) {}
659
+ })
660
+
661
+ ws.on('close', () => {
662
+ clients.delete(ws)
663
+ })
564
664
  })
565
665
 
566
- return liveReload.createServer({
567
- exts,
568
- exclusions,
569
- port: liveReloadPort
570
- }).watch(path.resolve(dir))
666
+ const sendToClients = (sockets, content) => {
667
+ for (const ws of sockets) {
668
+ if (ws.readyState === WebSocket.OPEN) {
669
+ ws.send(content)
670
+ }
671
+ }
672
+ }
673
+
674
+ // Debounced file watcher
675
+ let debounceTimer
676
+ const watchDir = path.resolve(dir)
677
+
678
+ const handleChange = () => {
679
+ // Group clients by path
680
+ const pathClients = new Map()
681
+ for (const [ws, clientPath] of clients) {
682
+ if (ws.readyState !== WebSocket.OPEN) {
683
+ continue
684
+ }
685
+
686
+ if (!pathClients.has(clientPath)) {
687
+ pathClients.set(clientPath, [])
688
+ }
689
+
690
+ pathClients.get(clientPath).push(ws)
691
+ }
692
+
693
+ const implantOpts = {maxDepth: 10}
694
+
695
+ const implantHandlers = {
696
+ markserv: () => new Promise(resolve => {
697
+ const value = path.relative(dir, __dirname)
698
+ resolve(value)
699
+ }),
700
+
701
+ file: (url, opts) => new Promise(resolve => {
702
+ const absUrl = path.join(opts.baseDir, url)
703
+ getFile(absUrl).then(data => resolve(data)).catch(() => resolve(false))
704
+ }),
705
+
706
+ less: (url, opts) => new Promise(resolve => {
707
+ const absUrl = path.join(opts.baseDir, url)
708
+ buildLessStyleSheet(absUrl).then(data => resolve(data)).catch(() => resolve(false))
709
+ }),
710
+
711
+ markdown: (url, opts) => new Promise(resolve => {
712
+ const absUrl = path.join(opts.baseDir, url)
713
+ getFile(absUrl).then(markdownToHTML).then(data => resolve(data)).catch(() => resolve(false))
714
+ }),
715
+
716
+ html: (url, opts) => new Promise(resolve => {
717
+ const absUrl = path.join(opts.baseDir, url)
718
+ getFile(absUrl).then(data => resolve(data)).catch(() => resolve(false))
719
+ })
720
+ }
721
+
722
+ for (const [clientPath, sockets] of pathClients) {
723
+ const decodedUrl = getPathFromUrl(decodeURIComponent(clientPath))
724
+ const filePath = path.normalize(unescape(dir) + unescape(decodedUrl))
725
+ const baseDir = path.parse(filePath).dir
726
+ implantOpts.baseDir = baseDir
727
+
728
+ let stat
729
+ let isDir_
730
+ let isMarkdown
731
+
732
+ try {
733
+ stat = fs.statSync(filePath)
734
+ isDir_ = stat.isDirectory()
735
+ if (!isDir_) {
736
+ isMarkdown = isType(fileTypes.markdown, filePath)
737
+ }
738
+ } catch (_) {
739
+ continue
740
+ }
741
+
742
+ if (isMarkdown) {
743
+ getFile(filePath)
744
+ .then(markdownToHTML)
745
+ .then(html => implant(html, implantHandlers, Object.assign({}, implantOpts, {baseDir})))
746
+ .then(output => {
747
+ sendToClients(sockets, output)
748
+ })
749
+ .catch(error => {
750
+ errormsg('hotreload', filePath, flags, error)
751
+ })
752
+ } else if (isDir_) {
753
+ try {
754
+ const content = dirToHtml(filePath)
755
+ const breadcrumbHtml = createBreadcrumbs(path.relative(dir, filePath))
756
+ let headerHtml = '<h1 class="icon folder isfolder">'
757
+ for (const crumb of breadcrumbHtml) {
758
+ headerHtml += `<a href="${crumb.href}">${crumb.text}</a>`
759
+ }
760
+
761
+ headerHtml += '</h1>\n'
762
+ const fullContent = headerHtml + content +
763
+ '<footer><sup><hr> Served by <a href="https://www.npmjs.com/package/markserv">MarkServ</a> | PID: ' + (process.pid || 'N/A') + '</sup></footer>'
764
+
765
+ sendToClients(sockets, fullContent)
766
+ } catch (error) {
767
+ errormsg('hotreload', filePath, flags, error)
768
+ }
769
+ }
770
+ }
771
+ }
772
+
773
+ try {
774
+ fs.watch(watchDir, {recursive: true}, (eventType, filename) => {
775
+ if (!filename) {
776
+ return
777
+ }
778
+
779
+ // Check exclusions
780
+ for (const exclusion of fileTypes.exclusions) {
781
+ if (filename.includes(exclusion.replace(/\/$/, ''))) {
782
+ return
783
+ }
784
+ }
785
+
786
+ // Check if file extension is watched
787
+ const ext = path.extname(filename)
788
+ if (ext && !fileTypes.watch.includes(ext)) {
789
+ return
790
+ }
791
+
792
+ clearTimeout(debounceTimer)
793
+ debounceTimer = setTimeout(handleChange, 150)
794
+ })
795
+ } catch (error) {
796
+ errormsg('watch', watchDir, flags, error)
797
+ }
798
+
799
+ return wss
571
800
  }
572
801
 
573
- const logActiveServerInfo = async (serveURL, httpPort, liveReloadPort, flags) => {
802
+ const logActiveServerInfo = async (serveURL, httpPort, wsPort, flags) => {
574
803
  const dir = path.resolve(flags.dir)
575
804
 
576
805
  const githubLink = 'github.com/markserv'
577
806
 
578
807
  msg('address', style.address(serveURL), flags)
579
808
  msg('path', chalk`{grey ${style.address(dir)}}`, flags)
580
- msg('livereload', chalk`{grey communicating on port: ${style.port(liveReloadPort)}}`, flags)
809
+
810
+ if (wsPort) {
811
+ msg('hotreload', chalk`{grey ws://localhost:${style.port(wsPort)}}`, flags)
812
+ }
581
813
 
582
814
  if (process.pid) {
583
815
  msg('process', chalk`{grey your pid is: ${style.pid(process.pid)}}`, flags)
@@ -666,22 +898,31 @@ const optionalUpgrade = async flags => {
666
898
  }
667
899
 
668
900
  const init = async flags => {
669
- const liveReloadPort = flags.livereloadport
670
- const httpPort = flags.port
901
+ const preferredPort = Number(flags.port) || 8642
902
+ const httpPort = flags.port ? preferredPort : await getPort({port: preferredPort})
903
+
904
+ let wsPort = null
905
+ const hotreloadEnabled = flags.hotreload !== false && flags.hotreload !== 'false'
906
+ if (hotreloadEnabled) {
907
+ wsPort = await getPort({port: httpPort + 1})
908
+ }
909
+
910
+ flags.$wsPort = wsPort
911
+ flags.$hotreload = hotreloadEnabled
671
912
 
672
913
  const httpRequestHandler = createRequestHandler(flags)
673
- const connectApp = startConnectApp(liveReloadPort, httpRequestHandler)
914
+ const connectApp = startConnectApp(httpRequestHandler)
674
915
  const httpServer = await startHTTPServer(connectApp, httpPort, flags)
675
916
 
676
- let liveReloadServer
677
- if (liveReloadPort && liveReloadPort !== 'false') {
678
- liveReloadServer = await startLiveReloadServer(liveReloadPort, flags)
917
+ let hotReloadServer
918
+ if (hotreloadEnabled) {
919
+ hotReloadServer = startHotReload(wsPort, flags)
679
920
  }
680
921
 
681
922
  const serveURL = 'http://' + flags.address + ':' + httpPort
682
923
 
683
924
  // Log server info to CLI
684
- logActiveServerInfo(serveURL, httpPort, liveReloadPort, flags)
925
+ logActiveServerInfo(serveURL, httpPort, wsPort, flags)
685
926
 
686
927
  let launchUrl = false
687
928
  if (flags.$openLocation || flags.$pathProvided) {
@@ -691,7 +932,7 @@ const init = async flags => {
691
932
  const service = {
692
933
  pid: process.pid,
693
934
  httpServer,
694
- liveReloadServer,
935
+ hotReloadServer,
695
936
  connectApp,
696
937
  launchUrl
697
938
  }
@@ -1,11 +1,15 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html data-theme="{{theme}}">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
7
7
  <title>{{title}}</title>
8
8
  <meta charset="utf-8">
9
+ <link rel="stylesheet" id="theme-dark" href="{markserv}templates/github-markdown-dark.css"{{#unless themeDark}} disabled{{/unless}}>
10
+ <link rel="stylesheet" id="theme-light" href="{markserv}templates/github-markdown-light.css"{{#unless themeLight}} disabled{{/unless}}>
11
+ <link rel="stylesheet" id="theme-synthwave" href="{markserv}templates/github-markdown-synthwave.css"{{#unless themeSynthwave}} disabled{{/unless}}>
12
+ <link rel="stylesheet" id="theme-solarized" href="{markserv}templates/github-markdown-solarized.css"{{#unless themeSolarized}} disabled{{/unless}}>
9
13
  <link rel="stylesheet" href="{markserv}templates/markserv.css">
10
14
  <link rel="stylesheet" href="{markserv}templates/highlight-js-github-gist.css">
11
15
  <link rel="stylesheet" href="{markserv}icons/icons.css">
@@ -16,5 +20,157 @@
16
20
  {{{content}}}
17
21
  <footer><sup><hr> Served by <a href="https://www.npmjs.com/package/markserv">MarkServ</a> | PID: {{pid}}</sup></footer>
18
22
  </article>
23
+ <div class="width-control" id="width-control">
24
+ <span id="width-tooltip"></span>
25
+ <input type="range" id="width-slider" min="0" max="1920" value="978" step="1">
26
+ </div>
27
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme"></button>
28
+ <script>
29
+ (function() {
30
+ var themes = ['dark', 'light', 'synthwave', 'solarized'];
31
+ var icons = {dark: '\u{1F319}', light: '\u{2600}\u{FE0F}', synthwave: '\u{1F9D1}\u{200D}\u{1F3A4}', solarized: '\u{262F}\u{FE0F}'};
32
+ var serverTheme = '{{theme}}' || 'dark';
33
+ var current = localStorage.getItem('markserv-theme') || serverTheme;
34
+
35
+ function applyTheme(t) {
36
+ document.documentElement.setAttribute('data-theme', t);
37
+ themes.forEach(function(name) {
38
+ var el = document.getElementById('theme-' + name);
39
+ if (el) el.disabled = (name !== t);
40
+ });
41
+ var btn = document.getElementById('theme-toggle');
42
+ if (btn) btn.textContent = icons[t] || icons.dark;
43
+ current = t;
44
+ }
45
+
46
+ applyTheme(current);
47
+
48
+ document.getElementById('theme-toggle').addEventListener('click', function() {
49
+ var idx = themes.indexOf(current);
50
+ var next = themes[(idx + 1) % themes.length];
51
+ localStorage.setItem('markserv-theme', next);
52
+ applyTheme(next);
53
+ });
54
+ })();
55
+ </script>
56
+ <script>
57
+ (function() {
58
+ var slider = document.getElementById('width-slider');
59
+ var tooltip = document.getElementById('width-tooltip');
60
+ if (!slider) return;
61
+
62
+ var CSS_DEFAULT = 978; // must match body { width } in markserv.css
63
+ var storageKey = 'markserv-width:' + '{{rootDir}}';
64
+ var stored = localStorage.getItem(storageKey);
65
+
66
+ function updateSliderMax() {
67
+ // Slider max = current viewport width so user can slide up to full-bleed
68
+ slider.max = window.innerWidth;
69
+ }
70
+
71
+ function applyWidth(px) {
72
+ if (px === CSS_DEFAULT) {
73
+ // Matches stylesheet default — remove inline style so CSS rules apply normally
74
+ document.body.style.width = '';
75
+ } else {
76
+ document.body.style.width = px + 'px';
77
+ }
78
+ slider.value = Math.min(px, parseInt(slider.max, 10));
79
+ }
80
+
81
+ // Initialize
82
+ updateSliderMax();
83
+ if (stored) {
84
+ applyWidth(parseInt(stored, 10));
85
+ } else {
86
+ // No stored value — keep the 978px stylesheet default; slider reflects it
87
+ slider.value = Math.min(CSS_DEFAULT, parseInt(slider.max, 10));
88
+ }
89
+
90
+ function positionTooltip() {
91
+ if (!tooltip) return;
92
+ var val = parseInt(slider.value, 10);
93
+ var min = parseInt(slider.min, 10);
94
+ var max = parseInt(slider.max, 10);
95
+ var pct = (val - min) / (max - min);
96
+ var sliderW = slider.offsetWidth;
97
+ var thumbW = 14;
98
+ var offset = thumbW / 2 + pct * (sliderW - thumbW);
99
+ tooltip.textContent = val + 'px';
100
+ tooltip.style.left = offset + 'px';
101
+ tooltip.style.transform = 'translateX(-50%)';
102
+ }
103
+
104
+ // Slider input → live update
105
+ slider.addEventListener('input', function() {
106
+ var val = parseInt(slider.value, 10);
107
+ applyWidth(val);
108
+ localStorage.setItem(storageKey, String(val));
109
+ if (tooltip) tooltip.classList.add('visible');
110
+ positionTooltip();
111
+ });
112
+
113
+ slider.addEventListener('mousedown', function() {
114
+ if (tooltip) tooltip.classList.add('visible');
115
+ positionTooltip();
116
+ });
117
+
118
+ window.addEventListener('mouseup', function() {
119
+ if (tooltip) tooltip.classList.remove('visible');
120
+ });
121
+
122
+ slider.addEventListener('focus', function() {
123
+ if (tooltip) tooltip.classList.add('visible');
124
+ positionTooltip();
125
+ });
126
+
127
+ slider.addEventListener('blur', function() {
128
+ if (tooltip) tooltip.classList.remove('visible');
129
+ });
130
+
131
+ // Double-click to reset to default width
132
+ slider.addEventListener('dblclick', function() {
133
+ applyWidth(CSS_DEFAULT);
134
+ localStorage.removeItem(storageKey);
135
+ });
136
+
137
+ // On window resize, update slider max to reflect new viewport
138
+ window.addEventListener('resize', function() {
139
+ updateSliderMax();
140
+ });
141
+ })();
142
+ </script>
143
+ {{#if hotreload}}
144
+ <script>
145
+ (function() {
146
+ var ws;
147
+ var delay = 1000;
148
+ var maxDelay = 30000;
149
+ var maxRetries = 20;
150
+ var retries = 0;
151
+
152
+ function connect() {
153
+ ws = new WebSocket('ws://localhost:{{wsPort}}');
154
+ ws.onopen = function() {
155
+ delay = 1000;
156
+ retries = 0;
157
+ ws.send(JSON.stringify({path: location.pathname}));
158
+ };
159
+ ws.onmessage = function(e) {
160
+ var el = document.querySelector('.markdown-body');
161
+ if (el) el.innerHTML = e.data;
162
+ };
163
+ ws.onclose = function() {
164
+ if (retries < maxRetries) {
165
+ retries++;
166
+ setTimeout(connect, delay);
167
+ delay = Math.min(delay * 2, maxDelay);
168
+ }
169
+ };
170
+ }
171
+ connect();
172
+ })();
173
+ </script>
174
+ {{/if}}
19
175
  </body>
20
- </html>
176
+ </html>