Files
HolodoriCalc/src/lib/release-notes/markdown.ts
T

65 lines
1.9 KiB
TypeScript

import { marked, Renderer } from 'marked';
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function safeUrl(value: string): string | null {
const trimmed = value.trim();
if (trimmed.startsWith('#') || trimmed.startsWith('/')) return trimmed;
try {
const url = new URL(trimmed);
return ['http:', 'https:'].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
const renderer = new Renderer();
renderer.html = ({ text }) => escapeHtml(text);
renderer.link = function ({ href, title, tokens }) {
const label = this.parser.parseInline(tokens);
const url = safeUrl(href);
if (!url) return label;
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(url)}"${titleAttribute} target="_blank" rel="noreferrer">${label}</a>`;
};
renderer.image = ({ href, title, text }) => {
const url = safeUrl(href);
if (!url) return escapeHtml(text);
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(url)}" alt="${escapeHtml(text)}"${titleAttribute} loading="lazy">`;
};
export function renderReleaseMarkdown(markdown: string): string {
const options = {
async: false,
breaks: true,
gfm: true,
renderer
} as const;
const tokens = marked.lexer(markdown, options);
let html = '';
let entryOpen = false;
for (const token of tokens) {
const boundary = token.type === 'hr' || (token.type === 'heading' && token.depth <= 3);
if (boundary && entryOpen) {
html += '</section>\n';
entryOpen = false;
}
if (token.type === 'heading' && token.depth === 3) {
html += '<section class="update-entry">\n';
entryOpen = true;
}
html += marked.parser([token], options);
}
return html + (entryOpen ? '</section>\n' : '');
}