65 lines
1.9 KiB
TypeScript
65 lines
1.9 KiB
TypeScript
import { marked, Renderer } from 'marked';
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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' : '');
|
|
}
|