Files
ElyOffice/apps/ely-mark/web/src/render.js
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

111 lines
3.8 KiB
JavaScript

// The whole document as one page or as text (MARK-11).
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import { fromDom } from 'hast-util-from-dom'
import rehypeStringify from 'rehype-stringify'
import { toText } from 'hast-util-to-text'
import { visit } from 'unist-util-visit'
import { VFile } from 'vfile'
import { bundle } from './diagrams.js'
const markdown = () => unified().use(remarkParse).use(remarkGfm).use(remarkMath)
/** Mermaid drawn for paper; a broken one stays code. */
async function draw(tree) {
const blocks = []
visit(tree, 'code', (node) => {
if (node.lang === 'mermaid') blocks.push(node)
})
const failed = []
if (!blocks.length) return failed
const paint = await bundle()
for (const node of blocks) {
try {
const svg = await paint(node.value, 'paper')
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
} catch (error) {
// Lines only: Mermaid's message quotes the document.
console.warn(`ely: a diagram did not draw: ${error.name}`)
failed.push(node.position.start.line)
}
}
return failed
}
/** A `srcset`'s first URL, split as the spec does. */
function first(srcset) {
const url = srcset.replace(/^[\s,]+/, '').split(/\s/)[0]
return url.replace(/,+$/, '')
}
/** One `src` per image, swapped for the app's mark. */
function marked(html) {
const doc = new DOMParser().parseFromString(html, 'text/html')
const token = `ely-image-${crypto.randomUUID()}-`
// Responsive sources go: the page carries one file per image.
for (const source of doc.querySelectorAll('picture > source')) source.remove()
const images = []
for (const image of doc.querySelectorAll('img')) {
const srcset = image.getAttribute('srcset')
image.removeAttribute('srcset')
image.removeAttribute('sizes')
const src = image.getAttribute('src') || (srcset && first(srcset))
if (!src) continue
images.push(src)
image.setAttribute('src', token + (images.length - 1))
}
return { html: doc.body.innerHTML, images, token }
}
/** KaTeX's failures: line and its message, never logged. */
function formulas(file) {
return file.messages
.filter((message) => message.source === 'rehype-katex')
.map((message) => ({
line: message.line,
error: message.cause?.rawMessage ?? message.cause?.name ?? message.reason
}))
}
/** `body` as HTML, images marked; the failed diagrams and formulas. */
export async function html(body) {
const tree = markdown().parse(body)
displayed(tree, body)
const failed = await draw(tree)
const page = unified()
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
.use(rehypeStringify, { allowDangerousHtml: true })
const file = new VFile()
const done = page.stringify(await page.run(tree, file), file)
return { ...marked(done), failed, formulas: formulas(file) }
}
/** `$$…$$` inline displays, as in the editor. */
function displayed(tree, body) {
visit(tree, 'inlineMath', (node) => {
const at = node.position?.start.offset ?? 0
if (body.startsWith('$$', at)) {
node.data.hProperties = { className: ['language-math', 'math-display'] }
}
})
}
/** `body` as plain text: blocks on lines, math as TeX. */
export async function text(body) {
const page = unified().use(remarkRehype, { allowDangerousHtml: true })
const hast = await page.run(markdown().parse(body))
// Raw HTML alone goes through the DOM; blocks keep lines.
visit(hast, 'raw', (node, index, parent) => {
const parsed = new DOMParser().parseFromString(node.value, 'text/html').body
const shown = fromDom(parsed).children
parent.children.splice(index, 1, ...shown)
return index + shown.length
})
return toText(hast)
}