markdown import

This commit is contained in:
Salihu
2026-08-09 12:16:48 +01:00
parent add1d7bf61
commit ea012f9430
6 changed files with 240 additions and 20 deletions
@@ -2,6 +2,7 @@ import { marked } from "marked";
import { calloutExtension } from "./callout.marked";
import { mathBlockExtension } from "./math-block.marked";
import { mathInlineExtension } from "./math-inline.marked";
import { tabsExtension } from "./tabs.marked";
marked.use({
renderer: {
@@ -34,7 +35,12 @@ marked.use({
});
marked.use({
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
extensions: [
calloutExtension,
mathBlockExtension,
mathInlineExtension,
tabsExtension,
],
});
marked.setOptions({ breaks: true });
@@ -0,0 +1,122 @@
import { marked, type Token } from 'marked';
interface MarkdownTab {
label: string;
text: string;
forceActive: boolean;
}
interface TabbedToken {
type: 'tabbed';
raw: string;
tabs: MarkdownTab[];
activeTabIndex: number;
}
const HEADER_RE = /^===([!+])?\s*["'“”‘’]([^"'“”‘’\n]+)["'“”‘’]\s*$/gm;
export const tabsExtension = {
name: 'tabbed',
level: 'block',
start(src: string) {
return src.search(/^===(?:[!+])?\s*["'“”‘’]/m);
},
tokenizer(src: string): TabbedToken | undefined {
if (src.indexOf('===') === -1) return;
const headers: Array<{
index: number;
raw: string;
marker: string;
label: string;
}> = [];
HEADER_RE.lastIndex = 0;
let m: RegExpExecArray | null = null;
while ((m = HEADER_RE.exec(src)) !== null) {
headers.push({
index: m.index,
raw: m[0],
marker: m[1] ?? '',
label: m[2].trim(),
});
}
if (headers.length < 1 || headers[0].index !== 0) return;
const tabs: MarkdownTab[] = [];
let consumed = 0;
for (let i = 0; i < headers.length; i++) {
const h = headers[i];
if (i > 0 && h.marker === '!') break;
const headerEnd = h.index + h.raw.length;
const bodyStart =
src.charCodeAt(headerEnd) === 10 ? headerEnd + 1 : headerEnd;
const next = headers[i + 1];
const bodyEnd = next ? next.index : src.length;
let body = src.slice(bodyStart, bodyEnd).replace(/\n+$/, '');
if (body.length > 0) {
body = body
.split('\n')
.map((line) => line.replace(/^\s{0,4}/, ''))
.join('\n');
}
tabs.push({
label: h.label,
text: body,
forceActive: h.marker === '+',
});
consumed = bodyEnd;
}
if (tabs.length < 1) return;
const forcedActiveIndex = tabs.findIndex((t) => t.forceActive);
return {
type: 'tabbed',
raw: src.slice(0, consumed),
tabs,
activeTabIndex: Math.max(forcedActiveIndex, 0),
};
},
renderer(token: Token) {
const tabbedToken = token as TabbedToken;
const activeTabIndex = Math.max(
0,
Math.min(tabbedToken.activeTabIndex ?? 0, tabbedToken.tabs.length - 1),
);
const sections = tabbedToken.tabs.map((tab, index) => {
const label = escapeHtml(tab.label);
const panel = marked.parse(tab.text || '').toString();
const isActive = index === activeTabIndex;
const activeAttrs = isActive
? 'data-tab-active="true"'
: 'data-tab-active="false"';
return `<div data-type="tab" aria-hidden="true" ${activeAttrs}><div data-type="tabLabel">${label}</div><div data-type="tabPanel" >${panel}</div></div>`;
});
return `<div data-type="tabs" data-active-tab="${activeTabIndex}">${sections.join('')}</div>`;
},
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@@ -24,6 +24,7 @@ export function htmlToMarkdown(html: string): string {
TurndownPluginGfm.tables,
TurndownPluginGfm.strikethrough,
TurndownPluginGfm.highlightedCodeBlock,
tabs,
taskList,
callout,
preserveDetail,
@@ -38,6 +39,37 @@ export function htmlToMarkdown(html: string): string {
return turndownService.turndown(html).replaceAll('<br>', ' ');
}
function tabs(turndownService: _TurndownService) {
turndownService.addRule('tabs', {
filter: (node: HTMLInputElement) =>
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'tabs',
replacement: (content: string, node: HTMLInputElement) => {
const tabNodes = Array.from(
node.querySelectorAll(':scope > div[data-type="tab"]'),
);
if (tabNodes.length === 0) return content;
const tabBlocks = tabNodes.map((tabNode) => {
const labelNode = tabNode.querySelector(
':scope > div[data-type="tabLabel"]',
);
const panelNode = tabNode.querySelector(
':scope > div[data-type="tabPanel"]',
);
const label = sanitizeTabLabel(labelNode?.textContent || 'Tab');
const panelMarkdown = panelNode
? turndownService.turndown(panelNode.innerHTML).trim()
: '';
return `=== "${label}"\n${indentMarkdownBlock(panelMarkdown)}`;
});
return `\n\n${tabBlocks.join('\n\n')}\n\n`;
},
});
}
function listParagraph(turndownService: _TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
@@ -218,3 +250,19 @@ function video(turndownService: _TurndownService) {
},
});
}
function sanitizeTabLabel(value: string): string {
return value
.replace(/[\r\n]+/g, ' ')
.replace(/"/g, '\\"')
.trim();
}
function indentMarkdownBlock(content: string): string {
if (!content) return ' ';
return content
.split('\n')
.map((line) => (line.trim() ? ` ${line}` : ''))
.join('\n');
}