262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
// ── Markdown rendering ──
|
|
|
|
import { esc } from './util.js';
|
|
import { state } from './state.js';
|
|
|
|
marked.use({ breaks: true, gfm: true, renderer: {
|
|
link({ href, text }) {
|
|
const isExternal = /^https?:\/\//.test(href);
|
|
if (isExternal) return `<a href="${href}" target="_blank" rel="noopener">${text}</a>`;
|
|
return `<a href="${href}">${text}</a>`;
|
|
}
|
|
}});
|
|
|
|
// ── Built-in fenced block handlers ──
|
|
// Registered through the same plugin API so plugins and core use one path.
|
|
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
|
const md = window.hyprcrd.markdown;
|
|
|
|
// ```gallery — image grid
|
|
md.registerFencedBlock('gallery', (content, { esc: e }) => {
|
|
const imgs = [];
|
|
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
let m;
|
|
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
|
if (!imgs.length) return '';
|
|
return '<div class="md-gallery">' + imgs.map(i =>
|
|
`<div class="md-gallery-item"><img src="${i.src}" alt="${e(i.alt)}" loading="lazy"></div>`
|
|
).join('') + '</div>';
|
|
});
|
|
|
|
// ```tree / ```bullets — custom list rendering (handled via post-processor)
|
|
const listHandler = (kind) => (content, { normalizeListBlock: norm }) => {
|
|
const normalized = norm(content);
|
|
return `\n<!-- list:${kind} -->\n${normalized}\n`;
|
|
};
|
|
md.registerFencedBlock('tree', listHandler('tree'));
|
|
md.registerFencedBlock('bullets', listHandler('bullets'));
|
|
}
|
|
|
|
const purifyConfig = {
|
|
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
|
|
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
|
|
ALLOW_DATA_ATTR: true,
|
|
};
|
|
|
|
function normalizeListBlock(block) {
|
|
const lines = String(block || '').replace(/\r\n?/g, '\n').split('\n');
|
|
while (lines.length && !lines[0].trim()) lines.shift();
|
|
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
if (!lines.length) return '';
|
|
|
|
const minIndent = lines
|
|
.filter(l => l.trim())
|
|
.map(l => (l.match(/^[ \t]*/) || [''])[0].replace(/\t/g, ' ').length)
|
|
.reduce((min, n) => Math.min(min, n), Infinity);
|
|
|
|
return lines
|
|
.map(l => {
|
|
const expanded = l.replace(/\t/g, ' ');
|
|
if (!expanded.trim()) return '';
|
|
return expanded.slice(Math.min(minIndent, expanded.length));
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
export function renderMd(src) {
|
|
let processed = String(src || '').replace(/\r\n?/g, '\n');
|
|
|
|
// Convert Unicode box-drawing tables to HTML
|
|
processed = processed.replace(/([ \t]*[┌╔][\s\S]*?[┘╝][ \t]*)/g, (block) => {
|
|
const lines = block.split('\n').filter(l => l.trim());
|
|
const rows = [];
|
|
let currentRow = null;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
// Skip border lines (┌─┬─┐, ├─┼─┤, └─┴─┘, ╔═╦═╗, etc.)
|
|
if (/^[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║\s]+$/.test(trimmed)) {
|
|
if (currentRow) { rows.push(currentRow); currentRow = null; }
|
|
continue;
|
|
}
|
|
// Data row: │ cell │ cell │ or ║ cell ║
|
|
if (/^[│║]/.test(trimmed)) {
|
|
const cells = trimmed.split(/[│║]/).slice(1, -1).map(c => c.trim());
|
|
if (currentRow && currentRow.length === cells.length) {
|
|
// Continuation line — append to previous cells
|
|
for (let i = 0; i < cells.length; i++) {
|
|
if (cells[i]) currentRow[i] = ((currentRow[i] || '') + ' ' + cells[i]).trim();
|
|
}
|
|
} else {
|
|
if (currentRow) rows.push(currentRow);
|
|
currentRow = cells;
|
|
}
|
|
}
|
|
}
|
|
if (currentRow) rows.push(currentRow);
|
|
if (rows.length < 2) return block;
|
|
|
|
const header = rows[0];
|
|
const body = rows.slice(1);
|
|
return '\n<table><thead><tr>' +
|
|
header.map(c => `<th>${esc(c)}</th>`).join('') +
|
|
'</tr></thead><tbody>' +
|
|
body.map(r => '<tr>' + r.map(c => `<td>${esc(c)}</td>`).join('') + '</tr>').join('') +
|
|
'</tbody></table>\n';
|
|
});
|
|
|
|
// ── Fenced block registry ─────────────────────────────────────
|
|
// Apply all registered ```<lang> handlers (built-in + plugins).
|
|
// Plugins register via window.hyprcrd.markdown.registerFencedBlock().
|
|
const fencedBlocks = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getFencedBlocks)
|
|
? window.hyprcrd.markdown.getFencedBlocks()
|
|
: [];
|
|
for (const { lang, handler } of fencedBlocks) {
|
|
// Escape regex metachars in lang (allow simple identifiers)
|
|
const safeLang = String(lang).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp('```' + safeLang + '\\s*\\n([\\s\\S]*?)(?:```|$)', 'g');
|
|
processed = processed.replace(re, (_, content) => {
|
|
try { return handler(content, { esc, normalizeListBlock }); }
|
|
catch (err) { console.error(`[markdown fenced ${lang}]`, err); return ''; }
|
|
});
|
|
}
|
|
|
|
// ── Plugin pre-processors ─────────────────────────────────────
|
|
const preProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPreProcessors)
|
|
? window.hyprcrd.markdown.getPreProcessors()
|
|
: [];
|
|
for (const fn of preProcessors) {
|
|
try { processed = fn(processed) ?? processed; }
|
|
catch (err) { console.error('[markdown pre]', err); }
|
|
}
|
|
|
|
processed = processed
|
|
// Help parser: ensure a list can start after text without requiring manual blank line
|
|
.replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
|
|
// Preserve extra blank lines as <br> spacers (3+ newlines = 1+ extra blank lines)
|
|
// Insert <br> BETWEEN paragraphs but not before block-level elements
|
|
.replace(/\n{3,}/g, m => {
|
|
const extra = m.length - 2;
|
|
return '\n\n' + '<br class="spacer">\n\n'.repeat(extra > 3 ? 3 : extra);
|
|
})
|
|
// Ensure hr rules have clean blank lines
|
|
.replace(/<br class="spacer">\n\n(---+|___+|\*\*\*+)/gm, '\n\n$1')
|
|
.replace(/(---+|___+|\*\*\*+)\n\n<br class="spacer">/gm, '$1\n\n');
|
|
|
|
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
|
|
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
|
|
if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
|
|
return `<span class="card-link broken" title="card not found">${esc(title)}</span>`;
|
|
});
|
|
|
|
let html = marked.parse(linked);
|
|
|
|
// Convert list hints before sanitize (DOMPurify strips comments)
|
|
html = html.replace(/<!--\s*list:tree\s*-->\s*<ul>/g, '<ul class="tree-list">');
|
|
html = html.replace(/<!--\s*list:bullets\s*-->\s*<ul>/g, '<ul class="bullet-list">');
|
|
|
|
// ── Plugin post-processors (run on HTML, before sanitize) ──
|
|
const postProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPostProcessors)
|
|
? window.hyprcrd.markdown.getPostProcessors()
|
|
: [];
|
|
for (const fn of postProcessors) {
|
|
try { html = fn(html) ?? html; }
|
|
catch (err) { console.error('[markdown post]', err); }
|
|
}
|
|
|
|
html = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html, purifyConfig) : html;
|
|
return html;
|
|
}
|
|
|
|
// Expose to plugins via the hyprcrd API
|
|
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
|
window.hyprcrd.markdown.render = renderMd;
|
|
}
|
|
|
|
export function addCodeCopyButtons(container) {
|
|
if (!container) return;
|
|
container.querySelectorAll('pre > code').forEach(code => {
|
|
const pre = code.parentElement;
|
|
if (pre.querySelector('.code-copy')) return;
|
|
const btn = document.createElement('button');
|
|
btn.className = 'code-copy';
|
|
btn.type = 'button';
|
|
btn.innerHTML = '<i class="ph ph-copy"></i>';
|
|
btn.addEventListener('click', async () => {
|
|
await navigator.clipboard.writeText(code.textContent);
|
|
btn.classList.add('copied');
|
|
btn.innerHTML = '<i class="ph ph-check"></i>';
|
|
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = '<i class="ph ph-copy"></i>'; }, 1500);
|
|
});
|
|
pre.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
export function formatTodoItems(container) {
|
|
if (!container) return;
|
|
container.querySelectorAll('li').forEach(li => {
|
|
const cb = li.querySelector('input[type="checkbox"]');
|
|
if (!cb) return;
|
|
if (li.classList.contains('todo-item')) return;
|
|
|
|
const checked = cb.checked;
|
|
li.classList.add('todo-item');
|
|
if (checked) li.classList.add('todo-done');
|
|
|
|
// Replace <input> with a styled button
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'todo-cb' + (checked ? ' checked' : '');
|
|
btn.innerHTML = '<i class="ph ph-check"></i>';
|
|
btn.dataset.idx = cb.dataset.idx || '';
|
|
cb.replaceWith(btn);
|
|
|
|
// Wrap remaining content in a span
|
|
const span = document.createElement('span');
|
|
span.className = 'todo-text';
|
|
while (btn.nextSibling) span.appendChild(btn.nextSibling);
|
|
li.appendChild(span);
|
|
|
|
});
|
|
}
|
|
|
|
export function stripMd(src) {
|
|
return (src || '')
|
|
.replace(/<img[^>]*>/gi, '')
|
|
.replace(/<[^>]+>/g, '')
|
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
|
.replace(/^#{1,6}\s+/gm, '')
|
|
.replace(/(\*{1,3}|_{1,3}|~~)(.*?)\1/g, '$2')
|
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
.replace(/`{1,3}[^`]*`{1,3}/g, '')
|
|
.replace(/^[-*>]\s+/gm, '')
|
|
.replace(/^\d+\.\s+/gm, '')
|
|
.replace(/\n{2,}/g, '\n')
|
|
.trim();
|
|
}
|
|
|
|
export function getTodos(src) {
|
|
const s = src || '';
|
|
const total = (s.match(/- \[([ x])\]/g) || []).length;
|
|
if (!total) return null;
|
|
return { total, done: (s.match(/- \[x\]/g) || []).length };
|
|
}
|
|
|
|
export function firstImage(src) {
|
|
const s = src || '';
|
|
const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/);
|
|
if (md) return md[1];
|
|
const html = s.match(/<img[^>]+src="([^"]+)"/i);
|
|
return html ? html[1] : null;
|
|
}
|
|
|
|
export function leadingImage(src) {
|
|
const s = (src || '').trimStart();
|
|
if (!s) return null;
|
|
const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/);
|
|
if (md) return md[1].split(/\s+/)[0].replace(/^<(.+)>$/, '$1') || null;
|
|
const html = s.match(/^<img[^>]+src=(['"])(.*?)\1/i);
|
|
return html ? html[2] : null;
|
|
}
|