@henryavila/mdprobe 0.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.
@@ -0,0 +1,97 @@
1
+ import { useEffect, useRef } from 'preact/hooks'
2
+ import { currentHtml, theme } from '../state/store.js'
3
+
4
+ const MERMAID_CDN = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js'
5
+ const KATEX_CSS_CDN = 'https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css'
6
+ const KATEX_JS_CDN = 'https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.js'
7
+
8
+ let mermaidLoaded = false
9
+ let katexLoaded = false
10
+
11
+ function loadScript(src) {
12
+ return new Promise((resolve, reject) => {
13
+ if (document.querySelector(`script[src="${src}"]`)) return resolve()
14
+ const s = document.createElement('script')
15
+ s.src = src
16
+ s.onload = resolve
17
+ s.onerror = reject
18
+ document.head.appendChild(s)
19
+ })
20
+ }
21
+
22
+ function loadCSS(href) {
23
+ if (document.querySelector(`link[href="${href}"]`)) return
24
+ const l = document.createElement('link')
25
+ l.rel = 'stylesheet'
26
+ l.href = href
27
+ document.head.appendChild(l)
28
+ }
29
+
30
+ /**
31
+ * Initialize Mermaid diagrams and KaTeX math after content renders.
32
+ * Libraries are loaded lazily from CDN on first use.
33
+ */
34
+ export function useClientLibs() {
35
+ const lastHtml = useRef('')
36
+
37
+ useEffect(() => {
38
+ if (currentHtml.value === lastHtml.current) return
39
+ lastHtml.current = currentHtml.value
40
+
41
+ // Mermaid: render all <pre class="mermaid"> blocks
42
+ const mermaidEls = document.querySelectorAll('pre.mermaid:not([data-processed])')
43
+ if (mermaidEls.length > 0) {
44
+ initMermaid(mermaidEls)
45
+ }
46
+
47
+ // KaTeX: render all elements with data-math or math class
48
+ const mathEls = document.querySelectorAll('[data-math], .math-inline, .math-display')
49
+ if (mathEls.length > 0) {
50
+ initKaTeX(mathEls)
51
+ }
52
+ }, [currentHtml.value, theme.value])
53
+ }
54
+
55
+ async function initMermaid(elements) {
56
+ try {
57
+ if (!mermaidLoaded) {
58
+ await loadScript(MERMAID_CDN)
59
+ mermaidLoaded = true
60
+ }
61
+ const isDark = !['latte', 'light'].includes(theme.value)
62
+ window.mermaid.initialize({
63
+ startOnLoad: false,
64
+ theme: isDark ? 'dark' : 'default',
65
+ })
66
+ // Mermaid 11+ uses run() with nodes
67
+ for (const el of elements) {
68
+ el.setAttribute('data-processed', 'true')
69
+ }
70
+ await window.mermaid.run({ nodes: [...elements] })
71
+ } catch (err) {
72
+ console.warn('mdprobe: Mermaid rendering failed', err)
73
+ }
74
+ }
75
+
76
+ async function initKaTeX(elements) {
77
+ try {
78
+ if (!katexLoaded) {
79
+ loadCSS(KATEX_CSS_CDN)
80
+ await loadScript(KATEX_JS_CDN)
81
+ katexLoaded = true
82
+ }
83
+ for (const el of elements) {
84
+ if (el.getAttribute('data-katex-rendered')) continue
85
+ const tex = el.textContent
86
+ const displayMode = el.classList.contains('math-display')
87
+ try {
88
+ window.katex.render(tex, el, { displayMode, throwOnError: false })
89
+ el.setAttribute('data-katex-rendered', 'true')
90
+ } catch {
91
+ // Leave raw LaTeX visible
92
+ }
93
+ }
94
+ } catch (err) {
95
+ console.warn('mdprobe: KaTeX rendering failed', err)
96
+ }
97
+ }
@@ -0,0 +1,128 @@
1
+ import { useEffect } from 'preact/hooks'
2
+ import {
3
+ leftPanelOpen,
4
+ rightPanelOpen,
5
+ selectedAnnotationId,
6
+ filteredAnnotations,
7
+ } from '../state/store.js'
8
+
9
+ /**
10
+ * Registers global keyboard shortcuts for the mdprobe UI.
11
+ * Shortcuts are suppressed when focus is inside a text input, textarea,
12
+ * select, or contentEditable element.
13
+ *
14
+ * Bindings:
15
+ * [ – toggle left panel
16
+ * ] – toggle right panel
17
+ * \ – toggle both panels (focus mode)
18
+ * j – select next annotation
19
+ * k – select previous annotation
20
+ * r – reserved (resolve, handled by parent)
21
+ * e – reserved (edit, handled by parent)
22
+ * ? – show help overlay
23
+ *
24
+ * @param {{ onShowHelp?: () => void }} options
25
+ */
26
+ export function useKeyboard({ onShowHelp } = {}) {
27
+ useEffect(() => {
28
+ function handleKey(e) {
29
+ // Don't intercept when typing in form controls
30
+ const tag = document.activeElement?.tagName
31
+ if (tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT') return
32
+ if (document.activeElement?.isContentEditable) return
33
+
34
+ switch (e.key) {
35
+ case '[':
36
+ e.preventDefault()
37
+ leftPanelOpen.value = !leftPanelOpen.value
38
+ break
39
+
40
+ case ']':
41
+ e.preventDefault()
42
+ rightPanelOpen.value = !rightPanelOpen.value
43
+ break
44
+
45
+ case '\\': {
46
+ e.preventDefault()
47
+ const bothOpen = leftPanelOpen.value && rightPanelOpen.value
48
+ leftPanelOpen.value = !bothOpen
49
+ rightPanelOpen.value = !bothOpen
50
+ break
51
+ }
52
+
53
+ case 'j':
54
+ e.preventDefault()
55
+ navigateAnnotation(1)
56
+ break
57
+
58
+ case 'k':
59
+ e.preventDefault()
60
+ navigateAnnotation(-1)
61
+ break
62
+
63
+ case 'r':
64
+ // Resolve – handled by parent component listener
65
+ break
66
+
67
+ case 'e':
68
+ // Edit – handled by parent component listener
69
+ break
70
+
71
+ case '?':
72
+ e.preventDefault()
73
+ onShowHelp?.()
74
+ break
75
+
76
+ default:
77
+ break
78
+ }
79
+ }
80
+
81
+ document.addEventListener('keydown', handleKey)
82
+ return () => document.removeEventListener('keydown', handleKey)
83
+ }, [onShowHelp])
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Internal helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Move selection to the next/previous annotation in the filtered list
92
+ * and scroll both the right-panel card and the content highlight into view.
93
+ *
94
+ * @param {1|-1} direction 1 = next, -1 = previous
95
+ */
96
+ function navigateAnnotation(direction) {
97
+ const list = filteredAnnotations.value
98
+ if (list.length === 0) return
99
+
100
+ const currentId = selectedAnnotationId.value
101
+ const currentIndex = list.findIndex((a) => a.id === currentId)
102
+
103
+ let nextIndex
104
+ if (currentIndex === -1) {
105
+ // Nothing selected yet – jump to first or last depending on direction
106
+ nextIndex = direction > 0 ? 0 : list.length - 1
107
+ } else {
108
+ nextIndex = currentIndex + direction
109
+ // Wrap around
110
+ if (nextIndex < 0) nextIndex = list.length - 1
111
+ if (nextIndex >= list.length) nextIndex = 0
112
+ }
113
+
114
+ const target = list[nextIndex]
115
+ selectedAnnotationId.value = target.id
116
+
117
+ // Scroll the annotation card into view in the right panel
118
+ const card = document.querySelector(
119
+ `[data-annotation-id="${target.id}"]`,
120
+ )
121
+ card?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
122
+
123
+ // Scroll the corresponding content highlight into view
124
+ const highlight = document.querySelector(
125
+ `[data-highlight-id="${target.id}"]`,
126
+ )
127
+ highlight?.scrollIntoView({ behavior: 'smooth', block: 'center' })
128
+ }
@@ -0,0 +1,57 @@
1
+ import { useEffect } from 'preact/hooks'
2
+ import { theme } from '../state/store.js'
3
+
4
+ /** Available themes with display metadata. */
5
+ export const THEMES = [
6
+ { id: 'mocha', label: 'Mocha', color: '#1e1e2e' },
7
+ { id: 'macchiato', label: 'Macchiato', color: '#24273a' },
8
+ { id: 'frappe', label: 'Frapp\u00e9', color: '#303446' },
9
+ { id: 'latte', label: 'Latte', color: '#eff1f5' },
10
+ { id: 'light', label: 'Light', color: '#ffffff' },
11
+ ]
12
+
13
+ const STORAGE_KEY = 'mdprobe-theme'
14
+
15
+ /**
16
+ * Theme management hook.
17
+ *
18
+ * - Applies the active theme as a `data-theme` attribute on `<html>`.
19
+ * - Persists the selection to `localStorage`.
20
+ * - Subscribes to signal changes so the DOM stays in sync even when the
21
+ * signal is mutated outside this hook.
22
+ *
23
+ * @returns {{ theme: import('@preact/signals').Signal<string>, setTheme: (id: string) => void, themes: typeof THEMES }}
24
+ */
25
+ export function useTheme() {
26
+ // Subscribe to the theme signal and keep the DOM + storage in sync.
27
+ useEffect(() => {
28
+ // Apply immediately in case the signal already holds a value
29
+ applyTheme(theme.value)
30
+
31
+ const dispose = theme.subscribe((value) => {
32
+ applyTheme(value)
33
+ })
34
+
35
+ return dispose
36
+ }, [])
37
+
38
+ /** Set the active theme by id. */
39
+ function setTheme(id) {
40
+ theme.value = id
41
+ }
42
+
43
+ return { theme, setTheme, themes: THEMES }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Internal helper
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function applyTheme(id) {
51
+ document.documentElement.setAttribute('data-theme', id)
52
+ try {
53
+ localStorage.setItem(STORAGE_KEY, id)
54
+ } catch {
55
+ // Storage may be unavailable (private browsing, quota exceeded, etc.)
56
+ }
57
+ }
@@ -0,0 +1,126 @@
1
+ import { useEffect, useRef, useCallback } from 'preact/hooks'
2
+ import {
3
+ currentHtml,
4
+ currentToc,
5
+ currentFile,
6
+ files,
7
+ annotations,
8
+ sections,
9
+ driftWarning,
10
+ } from '../state/store.js'
11
+
12
+ const RECONNECT_DELAY_MS = 2000
13
+ const MAX_RECONNECT_DELAY_MS = 30000
14
+
15
+ /**
16
+ * Manages the WebSocket connection to the mdprobe server.
17
+ * Automatically reconnects with exponential back-off on disconnection.
18
+ * Dispatches incoming messages to the appropriate signals in the store.
19
+ *
20
+ * @returns {import('preact/hooks').Ref<WebSocket|null>} ref to the active WebSocket
21
+ */
22
+ export function useWebSocket() {
23
+ const wsRef = useRef(null)
24
+ const reconnectDelay = useRef(RECONNECT_DELAY_MS)
25
+ const reconnectTimer = useRef(null)
26
+ const unmounted = useRef(false)
27
+
28
+ const connect = useCallback(() => {
29
+ if (unmounted.current) return
30
+
31
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
32
+ const ws = new WebSocket(`${protocol}//${location.host}/ws`)
33
+ wsRef.current = ws
34
+
35
+ ws.onopen = () => {
36
+ // Reset back-off on successful connection
37
+ reconnectDelay.current = RECONNECT_DELAY_MS
38
+ }
39
+
40
+ ws.onmessage = (event) => {
41
+ let msg
42
+ try {
43
+ msg = JSON.parse(event.data)
44
+ } catch {
45
+ console.warn('mdprobe: received non-JSON WebSocket message')
46
+ return
47
+ }
48
+
49
+ switch (msg.type) {
50
+ case 'update': {
51
+ // Preserve scroll position across live reload
52
+ const contentEl = document.querySelector('.content-area')
53
+ const savedScroll = contentEl ? contentEl.scrollTop : 0
54
+ currentHtml.value = msg.html
55
+ currentToc.value = msg.toc || []
56
+ // Restore scroll after DOM update
57
+ if (contentEl) {
58
+ requestAnimationFrame(() => { contentEl.scrollTop = savedScroll })
59
+ }
60
+ break
61
+ }
62
+
63
+ case 'file-added':
64
+ // Avoid duplicates
65
+ if (!files.value.some((f) => f.path === msg.file)) {
66
+ files.value = [
67
+ ...files.value,
68
+ { path: msg.file, label: msg.file.replace(/\.md$/, '') },
69
+ ]
70
+ }
71
+ break
72
+
73
+ case 'file-removed':
74
+ files.value = files.value.filter((f) => f.path !== msg.file)
75
+ break
76
+
77
+ case 'annotations':
78
+ // Only apply if the broadcast is for the currently viewed file
79
+ if (!msg.file || msg.file === currentFile.value) {
80
+ annotations.value = msg.annotations || []
81
+ sections.value = msg.sections || []
82
+ }
83
+ break
84
+
85
+ case 'drift':
86
+ driftWarning.value = msg.warning || true
87
+ break
88
+
89
+ case 'error':
90
+ // Keep last valid render; surface the warning in the console
91
+ console.warn('mdprobe:', msg.message)
92
+ break
93
+ }
94
+ }
95
+
96
+ ws.onerror = (err) => {
97
+ console.warn('mdprobe: WebSocket error', err)
98
+ }
99
+
100
+ ws.onclose = () => {
101
+ wsRef.current = null
102
+ if (unmounted.current) return
103
+
104
+ // Exponential back-off with jitter, capped at MAX_RECONNECT_DELAY_MS
105
+ const delay = reconnectDelay.current
106
+ reconnectDelay.current = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS)
107
+ const jitter = Math.random() * delay * 0.3
108
+
109
+ reconnectTimer.current = setTimeout(connect, delay + jitter)
110
+ }
111
+ }, [])
112
+
113
+ useEffect(() => {
114
+ unmounted.current = false
115
+ connect()
116
+
117
+ return () => {
118
+ unmounted.current = true
119
+ clearTimeout(reconnectTimer.current)
120
+ wsRef.current?.close()
121
+ wsRef.current = null
122
+ }
123
+ }, [connect])
124
+
125
+ return wsRef
126
+ }
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>mdprobe</title>
7
+ <script>
8
+ (function() {
9
+ var theme = localStorage.getItem('mdprobe-theme') || 'mocha';
10
+ document.documentElement.setAttribute('data-theme', theme);
11
+ })();
12
+ </script>
13
+ <link rel="stylesheet" href="./styles/themes.css" />
14
+ </head>
15
+ <body>
16
+ <div id="app"></div>
17
+ <script type="module" src="./app.jsx"></script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,76 @@
1
+ import { signal, computed } from '@preact/signals'
2
+
3
+ // Files
4
+ export const files = signal([])
5
+ export const currentFile = signal(null)
6
+
7
+ // Content
8
+ export const currentHtml = signal('')
9
+ export const currentToc = signal([])
10
+ export const frontmatter = signal(null)
11
+
12
+ // Annotations
13
+ export const annotations = signal([])
14
+ export const sections = signal([])
15
+ export const selectedAnnotationId = signal(null)
16
+ export const showResolved = signal(false)
17
+ export const filterTag = signal(null)
18
+ export const filterAuthor = signal(null)
19
+
20
+ // UI state — restore from localStorage
21
+ export const leftPanelOpen = signal(localStorage.getItem('mdprobe-left-panel') !== 'false')
22
+ export const rightPanelOpen = signal(localStorage.getItem('mdprobe-right-panel') !== 'false')
23
+ export const theme = signal(localStorage.getItem('mdprobe-theme') || 'mocha')
24
+ export const driftWarning = signal(false)
25
+
26
+ // Persist panel state on change via effect (subscribed below)
27
+ leftPanelOpen.subscribe(v => localStorage.setItem('mdprobe-left-panel', v))
28
+ rightPanelOpen.subscribe(v => localStorage.setItem('mdprobe-right-panel', v))
29
+
30
+ // Config
31
+ export const author = signal('anonymous')
32
+ export const reviewMode = signal(false)
33
+
34
+ // Computed
35
+ export const openAnnotations = computed(() =>
36
+ annotations.value.filter(a => a.status === 'open')
37
+ )
38
+
39
+ export const resolvedAnnotations = computed(() =>
40
+ annotations.value.filter(a => a.status === 'resolved')
41
+ )
42
+
43
+ export const filteredAnnotations = computed(() => {
44
+ let list = showResolved.value
45
+ ? annotations.value
46
+ : annotations.value.filter(a => a.status === 'open')
47
+
48
+ if (filterTag.value) {
49
+ list = list.filter(a => a.tag === filterTag.value)
50
+ }
51
+ if (filterAuthor.value) {
52
+ list = list.filter(a => a.author === filterAuthor.value)
53
+ }
54
+
55
+ return list
56
+ })
57
+
58
+ export const uniqueTags = computed(() =>
59
+ [...new Set(annotations.value.map(a => a.tag))]
60
+ )
61
+
62
+ export const uniqueAuthors = computed(() =>
63
+ [...new Set(annotations.value.map(a => a.author))]
64
+ )
65
+
66
+ // Adaptive section level (set from API response)
67
+ export const sectionLevel = signal(2)
68
+
69
+ // Section stats — count at the adaptive section level
70
+ export const sectionStats = computed(() => {
71
+ const lvl = sectionLevel.value
72
+ const atLevel = sections.value.filter(s => s.level === lvl)
73
+ const total = atLevel.length
74
+ const reviewed = atLevel.filter(s => s.status !== 'pending').length
75
+ return { total, reviewed }
76
+ })