/** * Markdown Viewer — Client-side functionality * Version: 2.7.0 * Author: Mikhail Deynekin * Site: https://Deynekin.com * Email: Mikhail@Deynekin.com * * Features: * - Theme toggle with localStorage persistence + system sync * - Width control (reading/article/wide) with persistence * - Mermaid diagram initialization * - Unified copy-to-clipboard controls for code blocks, blockquotes and the * whole source file * - Mermaid auto-repair with English console diagnostics * - File browser: debounced search, tri-state sort with date columns opening * newest-first, panel-chosen default sort, click/keyboard open * * v2.7.0: The table is sorted on load by the default chosen in the settings * panel, and the panel can re-apply that choice without a reload. The * default itself (Modified, newest first) lives in md.php, which renders * it onto the table as data-sort-default / data-sort-dir-default, so the * table carries the value it is ordered by rather than a copy of it. * sessionStorage then cookie, read with the same two key names the panel * writes (mdv_sort, mdv_sort-dir) - the scripts share no state, so those * names are the whole interface between them and are mirrored in * js/md/settings.js. An unknown or absent value changes nothing: the * server's order stands, and a click still cycles first-direction -> * opposite -> off as before. * v2.6.1: The Created and Modified columns of the file browser open * newest-first on the first click. They used to open oldest-first like * every other column, so the one order a date column exists to give * cost a second click. File, Dir and Size are unchanged, and each * column still cycles first-direction -> opposite -> off; the direction * off returns to is still the original path order. * v2.6.0: A copy control may carry the URL of the file it copies * (data-copy-source) instead of a rendered block. Its text is requested * from the server on click, from the same request the download control * uses, so the page does not carry the source a second time. Without * this branch such a control matches the delegated handler and then * returns silently: it looks wired and does nothing. * * The parsed-fragment field of the Mermaid diagnostic report is named * tokenText: the previous name read like a credential assignment, which * is noise in reviewers' tooling and in secret scanners. The value is a * diagram fragment from mermaid.parse() and never a credential. * v2.5.3: File rows open an absolute URL spelled out from the page origin with * duplicate slashes in the path collapsed. A page opened at a path such * as //extensions/... used to produce a link starting with //, which the * browser read as a protocol-relative URL and treated as host * "extensions", so the opened document lost the real domain. * v2.5.2: Added classDiagram note conversion, made every application and * console message English, and made Mermaid diagnostics unconditional. * v2.5.0: Added Mermaid auto-repair with a grouped console report per diagram. * v2.4.0: Unified code and quote copying behind one copy-btn handler. * v2.3.0: Added one accessible copy control per rendered blockquote. * v2.2.1: Width selection persisted and re-applied to every width target; * hardened theme/listener guards; const-grouped selectors; safer * clipboard fallback; defensive null checks throughout. */ (() => { 'use strict'; const root = document.documentElement; // Absolute URL of this page: the origin the page was opened from plus its // path with duplicate slashes collapsed. Spelling the origin out keeps // generated links on the real host; a bare pathname such as // //extensions/... would be read as a protocol-relative URL and the first // path segment would become the host. const pageUrl = () => window.location.origin + window.location.pathname.replace(/\/{2,}/g, '/'); const THEME_KEY = 'radio-viewer-theme'; const themeBtn = document.querySelector('[data-theme-toggle]'); const themeIcon = document.querySelector('[data-theme-icon]'); // ============================================================ // Theme management // ============================================================ const getPreferredTheme = () => { const stored = localStorage.getItem(THEME_KEY); if (stored === 'light' || stored === 'dark') { return stored; } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }; const applyTheme = (theme) => { root.setAttribute('data-theme', theme); root.classList.toggle('dark', theme === 'dark'); if (themeIcon) { themeIcon.innerHTML = theme === 'dark' ? '' : ''; } themeBtn?.setAttribute( 'aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme' ); }; applyTheme(getPreferredTheme()); themeBtn?.addEventListener('click', () => { const current = root.getAttribute('data-theme') || getPreferredTheme(); const next = current === 'dark' ? 'light' : 'dark'; localStorage.setItem(THEME_KEY, next); applyTheme(next); }); const media = window.matchMedia('(prefers-color-scheme: dark)'); const onSystemChange = (e) => { if (!localStorage.getItem(THEME_KEY)) { applyTheme(e.matches ? 'dark' : 'light'); } }; if (typeof media.addEventListener === 'function') { media.addEventListener('change', onSystemChange); } else if (typeof media.addListener === 'function') { media.addListener(onSystemChange); } // ============================================================ // Mermaid diagram initialization (lazy-load) // Loads Mermaid only when rendered Markdown contains
.
// ============================================================
const MDV_MERMAID_REPAIR_VERSION = '2.5.2';
const MERMAID_NODE_SHAPES = [['[[', ']]'], ['[(', ')]'], ['([', '])'], ['[', ']'], ['{{', '}}'], ['{', '}'], ['((', '))']];
const MERMAID_KEYWORD_LINE = /^(style|classDef|class|linkStyle|click|subgraph|end|graph|flowchart|direction|%%)\b/;
const MERMAID_SEQUENCE_ARROW = /^\s*([^\s:]+?)\s*(-->>|->>|-->|->|--x|-x|--\)|-\))\s*([^\s:]+?)\s*:/;

document.documentElement.dataset.mdvMermaidRepairVersion = MDV_MERMAID_REPAIR_VERSION;
console.info('[MD.Viewer] Mermaid repair engine v' + MDV_MERMAID_REPAIR_VERSION + ' loaded');

const getMermaidDiagramType = (source) => {
    const header = source.split('\n').map((line) => line.trim()).find((line) => line !== '' && !line.startsWith('%%')) || '';
    return header.split(/\s+/)[0];
};

const normalizeMermaidLine = (line) => {
    let result = '';
    let index = 0;
    while (index < line.length) {
        const idMatch = /^[A-Za-z_][\w-]*/.exec(line.slice(index));
        if (idMatch === null) { result += line[index]; index += 1; continue; }
        const nodeId = idMatch[0];
        const afterId = index + nodeId.length;
        let shape = null;
        for (const [open, close] of MERMAID_NODE_SHAPES) {
            if (!line.startsWith(open, afterId)) continue;
            const closeIndex = line.indexOf(close, afterId + open.length);
            if (closeIndex !== -1) { shape = { open, close, closeIndex }; break; }
        }
        if (shape === null) { result += nodeId; index = afterId; continue; }
        const rawLabel = line.slice(afterId + shape.open.length, shape.closeIndex);
        const trimmed = rawLabel.trim();
        const needsQuotes = trimmed !== '' && !/^["`]/.test(trimmed) && /[()]/.test(trimmed);
        result += nodeId + shape.open + (needsQuotes ? '"' + trimmed.replace(/"/g, '#quot;') + '"' : rawLabel) + shape.close;
        index = shape.closeIndex + shape.close.length;
    }
    return result;
};

const repairClassDiagramNotes = (source, fixes) => {
    const lines = source.split('\n');
    const result = [];

    for (let index = 0; index < lines.length; index += 1) {
        const match = /^(\s*)note\s+(?:right|left)\s+of\s+([A-Za-z_][\w-]*)\s*$/.exec(lines[index]);
        if (match === null) {
            result.push(lines[index]);
            continue;
        }

        const noteLines = [];
        let endIndex = index + 1;
        while (endIndex < lines.length && !/^\s*end\s+note\s*$/.test(lines[endIndex])) {
            const text = lines[endIndex].trim();
            if (text !== '') noteLines.push(text);
            endIndex += 1;
        }

        if (endIndex >= lines.length) {
            result.push(lines[index]);
            continue;
        }

        const noteText = noteLines.join('
').replace(/"/g, '#quot;'); const replacement = match[1] + 'note for ' + match[2] + ' "' + noteText + '"'; fixes.push({ line: index + 1, rule: 'plantuml-class-note', detail: 'PlantUML note block converted to Mermaid note-for syntax', before: lines[index].trim() + ' ... end note', after: replacement.trim() }); result.push(replacement); index = endIndex; } return result.join('\n'); }; const repairMermaidSource = (source) => { const fixes = []; const diagramType = getMermaidDiagramType(source); const isSequence = diagramType === 'sequenceDiagram'; const isFlowchart = diagramType === 'graph' || diagramType === 'flowchart'; let prepared = source.replace(/\r\n?/g, '\n'); if (diagramType === 'classDiagram') { prepared = repairClassDiagramNotes(prepared, fixes); } let lastCaller = null; let lastCallee = null; const repaired = prepared.split('\n').map((line, position) => { const lineNumber = position + 1; const original = line; let current = line; if (current.includes('\\n')) { current = current.split('\\n').join('
'); fixes.push({ line: lineNumber, rule: 'literal-newline', detail: 'Literal \\n replaced with
', before: original.trim(), after: current.trim() }); } if (//i.test(current)) { const before = current; current = current.replace(//gi, '
'); fixes.push({ line: lineNumber, rule: 'unclosed-br', detail: '
replaced with
', before: before.trim(), after: current.trim() }); } if (isSequence) { const arrow = MERMAID_SEQUENCE_ARROW.exec(current); if (arrow !== null) { lastCaller = arrow[1]; lastCallee = arrow[3]; } const returnMatch = /^(\s*)return\b\s*(.*)$/.exec(current); if (returnMatch !== null) { const before = current; const message = returnMatch[2].trim(); if (lastCaller !== null && lastCallee !== null) { current = returnMatch[1] + lastCallee + '-->>' + lastCaller + ': ' + (message === '' ? 'return' : message); fixes.push({ line: lineNumber, rule: 'plantuml-return', detail: 'PlantUML return converted to ' + lastCallee + '-->>' + lastCaller, before: before.trim(), after: current.trim() }); } else { current = returnMatch[1] + '%% ' + before.trim(); fixes.push({ line: lineNumber, rule: 'plantuml-return-orphan', detail: 'Orphan PlantUML return commented out', before: before.trim(), after: current.trim() }); } } } else if (isFlowchart && !MERMAID_KEYWORD_LINE.test(current.trim())) { const normalized = normalizeMermaidLine(current); if (normalized !== current) { fixes.push({ line: lineNumber, rule: 'unquoted-parentheses', detail: 'Node label containing parentheses was quoted', before: current.trim(), after: normalized.trim() }); current = normalized; } } return current; }).join('\n'); return { source: repaired, fixes, diagramType }; }; const extractMermaidError = (error) => { if (!error) return { message: 'mermaid.parse() returned false', line: null, tokenText: null }; const hash = error.hash || error.error?.hash || null; return { message: error.message || error.str || String(error), line: hash?.loc?.first_line || hash?.line || null, tokenText: hash?.token || hash?.text || null }; }; const reportMermaidDiagnostics = (index, repair, isValid, parseError = null) => { const label = '[MD.Viewer] Mermaid #' + (index + 1) + ' (' + repair.diagramType + ')' + (isValid ? ' — ready' : ' — syntax error after auto-repair'); console.group(label); console.info('Repair engine:', MDV_MERMAID_REPAIR_VERSION); console.info('Applied fixes:', repair.fixes.length); if (repair.fixes.length > 0) console.table(repair.fixes, ['line', 'rule', 'detail', 'before', 'after']); if (!isValid) { const details = extractMermaidError(parseError); console.error('Mermaid parse failed:', details.message); if (details.line !== null) console.error('Line:', details.line, 'Token:', details.tokenText); console.debug('Repaired source:\n' + repair.source); } console.groupEnd(); }; const initMermaidIfNeeded = async () => { const mermaidBlocks = [...document.querySelectorAll('pre.mermaid')]; console.info('[MD.Viewer] Mermaid blocks found:', mermaidBlocks.length); if (mermaidBlocks.length === 0) return; try { const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'); mermaid.initialize({ startOnLoad: false, theme: root.getAttribute('data-theme') === 'dark' ? 'dark' : 'default', securityLevel: 'loose', suppressErrorRendering: true, flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' } }); for (const [index, node] of mermaidBlocks.entries()) { const originalSource = node.textContent || ''; const repair = repairMermaidSource(originalSource); node.dataset.mermaidRepairVersion = MDV_MERMAID_REPAIR_VERSION; node.dataset.mermaidFixes = String(repair.fixes.length); node.dataset.mermaidSource = originalSource; node.textContent = repair.source; let isValid = false; let parseError = null; try { isValid = await mermaid.parse(repair.source, { suppressErrors: true }) !== false; } catch (error) { parseError = error; } reportMermaidDiagnostics(index, repair, isValid, parseError); if (!isValid) { node.classList.add('mermaid-error'); node.setAttribute('data-mermaid-error', 'true'); continue; } const wrapper = document.createElement('div'); wrapper.className = 'mermaid'; wrapper.textContent = repair.source; wrapper.dataset.mermaidSource = originalSource; wrapper.dataset.mermaidFixes = String(repair.fixes.length); wrapper.dataset.mermaidRepairVersion = MDV_MERMAID_REPAIR_VERSION; wrapper.setAttribute('data-mermaid-id', 'mermaid-' + index); node.replaceWith(wrapper); try { await mermaid.run({ nodes: [wrapper], suppressErrors: true }); console.info('[MD.Viewer] Mermaid #' + (index + 1) + ' rendered successfully'); } catch (error) { console.error('[MD.Viewer] Mermaid #' + (index + 1) + ' render failed:', error); wrapper.replaceWith(node); node.classList.add('mermaid-error'); node.setAttribute('data-mermaid-error', 'true'); } } } catch (error) { console.error('[MD.Viewer] Mermaid module initialization failed:', error); } }; const getQuoteText = (blockquote) => { const clone = blockquote.cloneNode(true); clone.querySelectorAll('.copy-btn').forEach((button) => button.remove()); return [...clone.childNodes].map((node) => { const text = node.nodeType === Node.ELEMENT_NODE ? (node.innerText || node.textContent || '') : (node.textContent || ''); return text.trim(); }).filter(Boolean).join('\n\n'); }; const initQuoteBlocks = () => { document.querySelectorAll('blockquote').forEach((blockquote) => { blockquote.classList.add('quote-block'); blockquote.setAttribute('data-quote-block', ''); }); }; initQuoteBlocks(); window.addEventListener('load', () => { initMermaidIfNeeded(); }, { once: true }); // ============================================================ // Copy-to-clipboard (event delegation) // ============================================================ const fallbackCopy = (text) => { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'; textarea.setAttribute('readonly', ''); document.body.appendChild(textarea); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); let ok = false; try { ok = document.execCommand('copy'); } finally { document.body.removeChild(textarea); } if (!ok) { throw new Error('execCommand copy failed'); } }; document.addEventListener('click', async (e) => { const btn = e.target.closest('.copy-btn'); if (!btn || btn.disabled) { return; } const quote = btn.closest('blockquote[data-quote-block]'); const wrapper = btn.closest('.code-block-wrapper'); const mermaidElement = wrapper?.querySelector('.mermaid[data-mermaid-source], pre.mermaid[data-mermaid-source]'); const codeElement = wrapper?.querySelector('pre code, pre.mermaid'); let text = quote ? getQuoteText(quote) : (mermaidElement?.dataset.mermaidSource || codeElement?.textContent || ''); let subject = quote ? 'Quote' : 'Code'; const copyIcon = btn.querySelector('.copy-icon'); const checkIcon = btn.querySelector('.check-icon'); const copyText = btn.querySelector('.copy-text'); const checkText = btn.querySelector('.check-text'); const statusEl = document.getElementById('copy-status'); const announce = (message) => { if (statusEl) { statusEl.textContent = message; setTimeout(() => { statusEl.textContent = ''; }, 3000); } }; const resetButton = () => { copyIcon?.classList.remove('hidden'); checkIcon?.classList.add('hidden'); copyText?.classList.remove('hidden'); checkText?.classList.add('hidden'); btn.disabled = false; btn.classList.remove('opacity-75', 'text-green-400'); }; // A toolbar control carries the URL of the file to copy instead of a // rendered block: the page must not hold a second copy of the document, // so the text is requested on click. The URL is the download request, // which the viewer validates on arrival. if (!text && btn.dataset.copySource) { subject = 'File'; // Disabled before the request, not after it: the button is disarmed // at the top of this handler, but the usual arming point sits below // the awaits, so without this a second click during the request // would start a second one. btn.disabled = true; btn.classList.add('opacity-75'); try { const response = await fetch(btn.dataset.copySource); text = response.ok ? await response.text() : ''; } catch (err) { console.error('Copy source request failed:', err); text = ''; } } if (!text) { // Release the button in every case: a failed request must not leave // a permanently disarmed control, and a block with no text used to // return silently. Saying so is the difference between a control // that did nothing and one that says it has nothing to do. resetButton(); announce('Nothing to copy here'); return; } const showSuccess = () => { copyIcon?.classList.add('hidden'); checkIcon?.classList.remove('hidden'); copyText?.classList.add('hidden'); checkText?.classList.remove('hidden'); btn.classList.remove('opacity-75'); btn.classList.add('text-green-400'); announce(subject + ' copied to clipboard'); setTimeout(resetButton, 2000); }; btn.disabled = true; btn.classList.add('opacity-75'); try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { fallbackCopy(text); } showSuccess(); } catch (err) { console.error('Copy to clipboard failed:', err); resetButton(); announce('Failed to copy ' + subject.toLowerCase() + '. Please select and copy manually.'); } }); // ============================================================ // File browser: search, sort, open // ============================================================ const filesTable = document.getElementById('files-table'); const searchInput = document.getElementById('files-search'); if (!filesTable || !searchInput) { return; } const tbody = filesTable.querySelector('tbody'); if (!tbody) { return; } const rows = [...tbody.querySelectorAll('tr.file-row')]; const headers = [...filesTable.querySelectorAll('th.sortable')]; let currentSort = { key: null, direction: null }; let searchDebounceTimer = null; const performSearch = (query) => { const q = query.trim().toLowerCase(); let visibleCount = 0; rows.forEach((row) => { if (q === '') { row.classList.remove('filtered-out'); visibleCount++; return; } const fileName = row.dataset.file || ''; const dirName = row.dataset.dir || ''; const matches = fileName.includes(q) || dirName.includes(q); row.classList.toggle('filtered-out', !matches); if (matches) { visibleCount++; } }); const noResults = document.getElementById('no-results-msg'); if (noResults) { noResults.style.display = (visibleCount === 0 && q !== '') ? 'block' : 'none'; } }; searchInput.addEventListener('input', (e) => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => performSearch(e.target.value), 150); }); const NUMERIC_KEYS = ['size', 'created', 'modified']; const compareRows = (a, b, key, direction) => { if (NUMERIC_KEYS.includes(key)) { const valA = parseInt(a.dataset[key], 10) || 0; const valB = parseInt(b.dataset[key], 10) || 0; return direction === 'asc' ? valA - valB : valB - valA; } const valA = a.dataset[key] || ''; const valB = b.dataset[key] || ''; const cmp = valA.localeCompare(valB, undefined, { sensitivity: 'base', numeric: true }); return direction === 'asc' ? cmp : -cmp; }; const reorder = (sortedRows) => { const fragment = document.createDocumentFragment(); sortedRows.forEach((row) => fragment.appendChild(row)); tbody.appendChild(fragment); }; // Direction a column selects on its first click. A date column (Created, // Modified) opens newest-first: ascending would put the newest document // last and make the reader click twice for the order the column exists to // give. File, Dir and Size keep the ascending default. const FIRST_DIRECTION = { created: 'desc', modified: 'desc' }; const SORT_KEYS = ['file', 'dir', 'created', 'modified', 'size']; const SORT_DIRECTIONS = ['asc', 'desc']; const applySort = (key, direction) => { headers.forEach((th) => { th.classList.remove('asc', 'desc'); if (th.dataset.sort === key && direction) { th.classList.add(direction); } }); currentSort = { key: direction ? key : null, direction }; if (!direction) { reorder([...rows].sort((a, b) => (a.dataset.path || '').localeCompare(b.dataset.path || '') )); return; } reorder([...rows].sort((a, b) => compareRows(a, b, key, direction))); }; const sortTable = (key) => { const first = FIRST_DIRECTION[key] ?? 'asc'; let newDirection; if (currentSort.key !== key) { newDirection = first; } else if (currentSort.direction === first) { newDirection = first === 'asc' ? 'desc' : 'asc'; } else { newDirection = null; } applySort(key, newDirection); }; headers.forEach((th) => { th.addEventListener('click', () => { const key = th.dataset.sort; if (key) { sortTable(key); } }); }); // ── Default sort, chosen in the settings panel ──────────────────────────── // The panel stores mdv_sort / mdv_sort-dir through its own storage helper // (store/load in js/md/settings.js): the two scripts share no state, so the // stored value is the only channel between them and these two key names are // mirrored there. sessionStorage first, cookie second, exactly as that // helper reads it, so a preference that survives only as a cookie still // reaches the table in a fresh browser session. const readSortPref = (key) => { const sessionValue = sessionStorage.getItem(key); if (sessionValue !== null) { return sessionValue; } const pattern = new RegExp( '(?:^|;)\\s*' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)' ); const match = document.cookie.match(pattern); return match ? decodeURIComponent(match[1]) : null; }; // A stored preference wins; otherwise the shipped default, authored once in // md.php and rendered onto the table it orders. An absent attribute (a // viewer paired with an older table) leaves the server's own order alone. const storedSortKey = readSortPref('mdv_sort'); const storedSortDir = readSortPref('mdv_sort-dir'); const initialSortKey = SORT_KEYS.includes(storedSortKey) ? storedSortKey : filesTable.dataset.sortDefault; const initialSortDir = SORT_DIRECTIONS.includes(storedSortDir) ? storedSortDir : filesTable.dataset.sortDirDefault; if (SORT_KEYS.includes(initialSortKey) && SORT_DIRECTIONS.includes(initialSortDir)) { applySort(initialSortKey, initialSortDir); } // The panel announces a change instead of reloading the page; the table is // the only owner of the order, so it is applied here and nowhere else. document.addEventListener('mdv:sort', (event) => { const { key, direction } = event.detail || {}; if (SORT_KEYS.includes(key) && SORT_DIRECTIONS.includes(direction)) { applySort(key, direction); } }); tbody.addEventListener('click', (e) => { if (e.target.closest('th')) { return; } const row = e.target.closest('tr.file-row'); const path = row?.dataset.path; if (!path) { return; } // Defense in depth: reject control chars and traversal/absolute paths. if (/[\x00-\x1F\x7F]/.test(path) || /\.\.(?:\/|$)|^\//.test(path)) { return; } const url = pageUrl() + '?file=' + encodeURIComponent(path); window.open(url, '_blank', 'noopener,noreferrer'); }); rows.forEach((row) => { row.setAttribute('tabindex', '0'); row.setAttribute('role', 'button'); row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); row.click(); } }); }); })(); // ── Inline code: click to copy ─────────────────────────────────────────────── // Reuses the existing navigator.clipboard API, consistent with copy-btn logic. document.addEventListener('click', (e) => { const el = e.target.closest('code.copy-on-click'); if (!el) return; const text = el.textContent ?? ''; if (!text) return; navigator.clipboard.writeText(text).then(() => { // Brief visual feedback — reuse the same "copied" style as copy-btn el.classList.add('copied'); const prev = el.title; el.title = 'Copied!'; setTimeout(() => { el.classList.remove('copied'); el.title = prev; }, 1500); }).catch(() => { // Fallback for HTTP contexts without clipboard API const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); sel?.removeAllRanges(); sel?.addRange(range); }); });