This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/sw/components/notes-pane/markdown.js
gobha be67feaa8e Changeset 0.37.19 (#232)
Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
2026-03-25 00:26:44 +00:00

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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}