// ========================================== // NotesPane Kit — Markdown with Wikilinks // ========================================== // Renders markdown content via marked + DOMPurify, // then post-processes wikilinks into clickable chips // and transclusion blocks. // Independently importable. // // NOTE (CR P3-17): Uses raw DOM (innerHTML, querySelector, addEventListener) // for post-render wikilink processing. This is intentional — Preact renders // the initial HTML, then this module post-processes specific elements for // interactive behavior. The lifecycle is managed by the parent component. const WIKILINK_RE = /(!?)\[\[([^\[\]|]+?)(?:\|([^\]]+?))?\]\]/g; /** * Render markdown with wikilink support. * * @param {string} content — raw markdown * @param {{ onLinkClick?: (title: string) => void }} opts * @returns {{ html: string, wikilinks: Array<{title: string, display: string, isTransclusion: boolean}> }} */ export function renderNoteMarkdown(content, opts = {}) { if (!content) return { html: '', wikilinks: [] }; // 1. Render markdown let rendered = ''; if (window.marked) { try { rendered = window.marked.parse(content, { breaks: true, gfm: true }); } catch { rendered = _escapeHtml(content).replace(/\n/g, '
'); } } else { rendered = _escapeHtml(content).replace(/\n/g, '
'); } // 2. Sanitize if (window.DOMPurify) { rendered = window.DOMPurify.sanitize(rendered, { ADD_ATTR: ['target', 'rel'], }); } // 3. Extract and replace wikilinks const wikilinks = []; rendered = rendered.replace(WIKILINK_RE, (_match, bang, title, display) => { const titleTrimmed = title.trim(); const displayText = display ? display.trim() : titleTrimmed; const isTransclusion = bang === '!'; wikilinks.push({ title: titleTrimmed, display: displayText, isTransclusion }); if (isTransclusion) { return `
` + `
` + `` + `\u2197 ${_escapeHtml(displayText)}
` + `
Loading\u2026
`; } return `${_escapeHtml(displayText)}`; }); return { html: rendered, wikilinks }; } /** * Attach click handlers to wikilink elements inside a container. * @param {HTMLElement} container * @param {(title: string) => void} onLinkClick */ export function attachWikilinkHandlers(container, onLinkClick) { if (!container || !onLinkClick) return; container.querySelectorAll('[data-link-title]').forEach(el => { el.style.cursor = 'pointer'; el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onLinkClick(el.dataset.linkTitle); }); }); } /** * Resolve transclusion blocks — load content for ![[Title]] embeds. * @param {HTMLElement} container * @param {(title: string) => void} onLinkClick */ export async function resolveTransclusions(container, onLinkClick) { const embeds = container?.querySelectorAll('.sw-notes-pane__transclusion'); if (!embeds?.length) return; const api = window.sw?.api?.notes; if (!api) return; const cache = {}; for (const el of embeds) { const title = el.dataset.title; const contentEl = el.querySelector('.sw-notes-pane__transclusion-content'); if (!title || !contentEl) continue; try { let note = cache[title.toLowerCase()]; if (!note) { const resp = await api.searchTitles(title, 1); const match = (resp || []).find( n => n.title.toLowerCase() === title.toLowerCase() ); if (!match) { contentEl.innerHTML = 'Note not found'; continue; } note = await api.get(match.id); cache[title.toLowerCase()] = note; } // Render content (strip nested transclusions to prevent recursion) const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => { const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || ''; return `[Embedded: ${inner}]`; }); const { html } = renderNoteMarkdown(safeContent); contentEl.innerHTML = html; // Attach link handlers inside transclusion if (onLinkClick) attachWikilinkHandlers(contentEl, onLinkClick); } catch { contentEl.innerHTML = 'Failed to load'; } } } /** * Extract headings from markdown content for outline/TOC. * @param {string} content — raw markdown * @returns {Array<{level: number, text: string, id: string}>} */ export function extractHeadings(content) { if (!content) return []; const headings = []; const lines = content.split('\n'); for (const line of lines) { const match = line.match(/^(#{1,3})\s+(.+)/); if (match) { const text = match[2].trim(); const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, ''); headings.push({ level: match[1].length, text, id }); } } return headings; } function _escapeHtml(str) { const el = document.createElement('div'); el.textContent = str; return el.innerHTML; } function _escapeAttr(str) { return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); }