Changeset 0.37.9 (#221)
This commit is contained in:
157
src/js/sw/components/notes-pane/markdown.js
Normal file
157
src/js/sw/components/notes-pane/markdown.js
Normal file
@@ -0,0 +1,157 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — Markdown with Wikilinks
|
||||
// ==========================================
|
||||
// Renders markdown content via marked + DOMPurify,
|
||||
// then post-processes wikilinks into clickable chips
|
||||
// and transclusion blocks.
|
||||
// Independently importable.
|
||||
|
||||
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.data || 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, '>');
|
||||
}
|
||||
Reference in New Issue
Block a user