pdf-tei-editor / app /src /modules /markdown-utils.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
10.1 kB
/**
* Utilities for rendering and processing markdown content
*/
/**
* @import MarkdownIt from 'markdown-it'
*/
import markdownit from 'markdown-it'
import markdownItContainer from 'markdown-it-container'
// highlight.js is loaded separately via highlight.js bundle
// Access it from window.hljs (set by app/web/highlight.js)
const hljs = typeof window !== 'undefined' ? window.hljs : null
/**
* Admonition type configuration
* Maps both container types (:::info) and GFM alert types ([!NOTE]) to styling
*/
const admonitionConfig = {
info: { icon: 'info-circle', colorVar: 'accent', gfmType: 'NOTE' },
tip: { icon: 'lightbulb', colorVar: 'accent', gfmType: 'TIP' },
warning: { icon: 'exclamation-triangle', colorVar: 'attention', gfmType: 'WARNING' },
important: { icon: 'exclamation-circle', colorVar: 'danger', gfmType: 'IMPORTANT' },
caution: { icon: 'exclamation-octagon', colorVar: 'danger', gfmType: 'CAUTION' },
success: { icon: 'check-circle', colorVar: 'success', gfmType: null },
danger: { icon: 'x-octagon', colorVar: 'danger', gfmType: null },
note: { icon: 'info-circle', colorVar: 'accent', gfmType: 'NOTE' }
}
/**
* Renders opening tag for an admonition block
* @param {string} type - The admonition type (info, warning, etc.)
* @returns {string} Opening HTML
*/
function renderAdmonitionOpen(type) {
const config = admonitionConfig[type] || admonitionConfig.info
return `<div class="admonition admonition-${type}" style="
border-left: 4px solid var(--borderColor-${config.colorVar}-emphasis);
background-color: var(--bgColor-muted);
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
display: flex;
gap: 0.75rem;
">
<div style="flex-shrink: 0; color: var(--fgColor-${config.colorVar}); font-size: 1.25rem; line-height: 1.5;">
<sl-icon name="${config.icon}"></sl-icon>
</div>
<div class="admonition-content" style="flex: 1;">
`
}
/**
* Renders closing tag for an admonition block
* @returns {string} Closing HTML
*/
function renderAdmonitionClose() {
return '</div></div>\n'
}
/**
* Creates a configured markdown-it instance with admonitions, TOC support, and syntax highlighting
* @returns {MarkdownIt} Configured markdown renderer
*/
export function createMarkdownRenderer() {
const options = {
html: true,
linkify: true,
typographer: true,
highlight: (str, lang) => {
if (hljs && lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang }).value +
'</code></pre>'
} catch (error) {
console.warn(`Failed to highlight ${lang}:`, error)
}
}
// Fallback to plain code block without highlighting
return '<pre class="hljs"><code>' + markdownit().utils.escapeHtml(str) + '</code></pre>'
}
}
const md = markdownit(options)
// Add support for container-style admonitions (:::info, :::warning, etc.)
Object.keys(admonitionConfig).forEach(type => {
md.use(markdownItContainer, type, {
render: (tokens, idx) => {
if (tokens[idx].nesting === 1) {
// Opening tag
return renderAdmonitionOpen(type)
} else {
// Closing tag
return renderAdmonitionClose()
}
}
})
})
// Add support for GitHub-style alerts (> [!NOTE])
const defaultBlockquoteRender = md.renderer.rules.blockquote_open || function(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.blockquote_open = (tokens, idx, options, _env, self) => {
// Check if next token is a paragraph containing a GFM alert marker
const nextToken = tokens[idx + 1]
if (nextToken && nextToken.type === 'paragraph_open') {
const contentToken = tokens[idx + 2]
if (contentToken && contentToken.type === 'inline') {
const alertMatch = contentToken.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/)
if (alertMatch) {
const gfmType = alertMatch[1]
// Find matching admonition type
const type = Object.keys(admonitionConfig).find(
key => admonitionConfig[key].gfmType === gfmType
) || 'info'
// Remove the alert marker from content
contentToken.content = contentToken.content.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/, '')
// Mark this blockquote as an alert for closing
tokens[idx].attrSet('data-alert-type', type)
return renderAdmonitionOpen(type)
}
}
}
return defaultBlockquoteRender(tokens, idx, options, _env, self)
}
const defaultBlockquoteCloseRender = md.renderer.rules.blockquote_close || function(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.blockquote_close = (tokens, idx, options, _env, self) => {
// Check if this is closing an alert blockquote
const openToken = tokens.slice(0, idx).reverse().find(t => t.type === 'blockquote_open')
if (openToken && openToken.attrGet('data-alert-type')) {
return renderAdmonitionClose()
}
return defaultBlockquoteCloseRender(tokens, idx, options, _env, self)
}
// Add automatic heading IDs for TOC links
const defaultHeadingRender = md.renderer.rules.heading_open || function(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.heading_open = (tokens, idx, options, _env, self) => {
const token = tokens[idx]
const contentToken = tokens[idx + 1]
if (contentToken && contentToken.type === 'inline') {
const text = contentToken.content
// Generate anchor from heading text
const anchor = text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
token.attrSet('id', anchor)
}
return defaultHeadingRender(tokens, idx, options, _env, self)
}
return md
}
/**
* Fetches markdown content from a URL
* @param {string} url - URL to fetch markdown from
* @param {boolean} enableCache - Whether to enable browser caching
* @returns {Promise<string>} The markdown content
* @throws {Error} If fetch fails
*/
export async function fetchMarkdown(url, enableCache = true) {
const fetchOptions = enableCache ? {} : { cache: 'no-cache' }
const response = await fetch(url, fetchOptions)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
}
return await response.text()
}
/**
* Processes rendered HTML to handle links and images
* @param {string} html - Rendered HTML from markdown
* @param {object} options - Processing options
* @param {string} options.localLinkHandler - JavaScript function name to call for local links
* @param {string} [options.imagePrefix] - Prefix to add to relative image paths
* @param {boolean} [options.openExternalInNewTab=true] - Whether to open external links in new tabs
* @returns {string} Processed HTML
*/
export function processMarkdownHtml(html, options) {
const {
localLinkHandler,
imagePrefix,
openExternalInNewTab = true
} = options
let processed = html
// Replace local links with JavaScript handler calls
if (localLinkHandler) {
processed = processed.replaceAll(
/(<a\s+.*?)href=(["'])((?!https?:\/\/|\/\/|#).*?)\2(.*?>)/g,
`$1href="#" onclick="${localLinkHandler}('$3'); return false"$4`
)
}
// Add prefix to relative image paths
if (imagePrefix) {
processed = processed.replaceAll(
/src="(\.\/)?images\//g,
`src="${imagePrefix}/images/`
)
}
// Open external links in new tabs
if (openExternalInNewTab) {
processed = processed.replaceAll(
/(href="http)/g,
`target="_blank" $1`
)
}
// Remove comment tags that mask Shoelace components
processed = processed.replaceAll(/<!--|-->/gs, '')
return processed
}
/**
* Generates a table of contents from markdown headings
* @param {string} markdown - Markdown content to analyze
* @returns {string} HTML list of linked headings
*/
export function generateTOC(markdown) {
const headings = []
const lines = markdown.split('\n')
lines.forEach(line => {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
const level = match[1].length
const text = match[2].trim()
// Create anchor from heading text (lowercase, replace spaces with hyphens)
const anchor = text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
headings.push({ level, text, anchor })
}
})
if (headings.length === 0) {
return '<p><em>No headings found</em></p>'
}
// Build nested list
let html = '<nav class="toc" style="background-color: var(--bgColor-muted); padding: 1rem; border-radius: 4px; margin: 1rem 0;">\n'
html += '<div style="font-weight: 600; margin-bottom: 0.5rem;">Table of Contents</div>\n'
html += '<ul style="list-style: none; padding-left: 0; margin: 0;">\n'
headings.forEach(({ level, text, anchor }) => {
const indent = (level - 1) * 1.5
html += `<li style="margin-left: ${indent}rem; margin-top: 0.25rem;">
<a href="#${anchor}" style="color: var(--fgColor-accent); text-decoration: none;">
${text}
</a>
</li>\n`
})
html += '</ul>\n</nav>'
return html
}
/**
* Renders markdown to HTML with standard processing
* @param {MarkdownIt} md - Markdown renderer instance
* @param {string} markdown - Markdown content to render
* @param {object} options - Processing options (see processMarkdownHtml)
* @returns {string} Processed HTML
*/
export function renderMarkdown(md, markdown, options) {
// Replace TOC placeholder with generated table of contents (case-insensitive)
const TOC_REGEX = /^(\[TOC\]|\[toc])$/m
let processedMarkdown = markdown
if (TOC_REGEX.test(markdown)) {
const toc = generateTOC(markdown)
processedMarkdown = markdown.replace(TOC_REGEX, toc)
}
const html = md.render(processedMarkdown)
return processMarkdownHtml(html, options)
}