Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
163 lines
5.9 KiB
JavaScript
163 lines
5.9 KiB
JavaScript
// ==========================================
|
|
// 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, '<br>');
|
|
}
|
|
} else {
|
|
rendered = _escapeHtml(content).replace(/\n/g, '<br>');
|
|
}
|
|
|
|
// 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 `<div class="sw-notes-pane__transclusion" data-title="${_escapeAttr(titleTrimmed)}">` +
|
|
`<div class="sw-notes-pane__transclusion-header">` +
|
|
`<span class="sw-notes-pane__wikilink sw-notes-pane__wikilink--transclusion" ` +
|
|
`data-link-title="${_escapeAttr(titleTrimmed)}">` +
|
|
`\u2197 ${_escapeHtml(displayText)}</span></div>` +
|
|
`<div class="sw-notes-pane__transclusion-content">Loading\u2026</div></div>`;
|
|
}
|
|
|
|
return `<span class="sw-notes-pane__wikilink" ` +
|
|
`data-link-title="${_escapeAttr(titleTrimmed)}">${_escapeHtml(displayText)}</span>`;
|
|
});
|
|
|
|
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 = '<span style="color:var(--text-3)">Note not found</span>';
|
|
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 = '<span style="color:var(--text-3)">Failed to load</span>';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, '<').replace(/>/g, '>');
|
|
}
|