UNPKG

zero-md

Version:

Ridiculously simple zero-config markdown displayer

454 lines (453 loc) 14.3 kB
class ZeroMdBase extends HTMLElement { get src() { return this.getAttribute("src"); } set src(val) { val ? this.setAttribute("src", val) : this.removeAttribute("src"); } get auto() { return !this.hasAttribute("no-auto"); } get bodyClass() { const classes = this.getAttribute("body-class"); return `markdown-body${classes ? " " + classes : ""}`; } constructor() { super(); try { this.version = "3.1.7"; } catch { } this.template = ""; const handler = (e) => { var _a; if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.defaultPrevented) return; const a = (_a = e.target) == null ? void 0 : _a.closest("a"); if (a && a.hash && a.host === location.host && a.pathname === location.pathname) this.goto(a.hash); }; this._clicked = handler.bind(this); this._observer = new MutationObserver(() => { this._observe(); if (this.auto) this.render(); }); this._loaded = false; this.root = this; } static get observedAttributes() { return ["src", "body-class"]; } /** * @param {string} name * @param {string} old * @param {string} val */ attributeChangedCallback(name, old, val) { var _a; if (this.ready && old !== val) { switch (name) { case "body-class": (_a = this.root.querySelector(".markdown-body")) == null ? void 0 : _a.setAttribute("class", this.bodyClass); break; case "src": if (this.auto) this.render(); } } } async connectedCallback() { var _a; if (!this._loaded) { await this.load(); if (!this.hasAttribute("no-shadow")) this.root = this.attachShadow({ mode: "open" }); this.root.prepend( this.frag(`<div class="markdown-styles"></div><div class="${this.bodyClass}"></div>`) ); this._loaded = true; } (_a = this.shadowRoot) == null ? void 0 : _a.addEventListener("click", this._clicked); this._observer.observe(this, { childList: true }); this._observe(); this.ready = true; this.fire("zero-md-ready"); if (this.auto) this.render(); } disconnectedCallback() { var _a; (_a = this.shadowRoot) == null ? void 0 : _a.removeEventListener("click", this._clicked); this._observer.disconnect(); this.ready = false; } _observe() { this.querySelectorAll('template,script[type="text/markdown"]').forEach( (node) => this._observer.observe(node.content || node, { childList: true, subtree: true, attributes: true, characterData: true }) ); } /** * Async load function that runs after constructor. Like constructor, only runs once. * @returns {Promise<*>} */ async load() { } /** * Async parse function that takes in markdown and returns the html-formatted string. * Can use any md parser you prefer, like marked.js * @param {ZeroMdRenderObject} obj * @returns {Promise<string>} */ async parse({ text = "" }) { return text; } /** * Scroll to heading id * @param {string} id */ goto(id) { var _a; const ctx = this.shadowRoot || document; id && ((_a = ctx.getElementById(decodeURIComponent(id[0] === "#" ? id.slice(1) : id))) == null ? void 0 : _a.scrollIntoView()); } /** * Convert html string to document fragment * @param {string} html * @returns {DocumentFragment} */ frag(html) { const tpl = document.createElement("template"); tpl.innerHTML = html; return tpl.content; } /** * Compute 32-bit DJB2a hash in base36 * @param {string} str * @returns {string} */ hash(str) { let hash = 5381; for (let index = 0; index < str.length; index++) { hash = (hash << 5) + hash ^ str.charCodeAt(index); } return (hash >>> 0).toString(36); } /** * Await the next tick * @returns {Promise<*>} */ tick() { return new Promise((resolve) => requestAnimationFrame(resolve)); } /** * Fire custom event * @param {string} name * @param {*} [detail] */ fire(name, detail = {}) { this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); } /** * Retrieve raw style templates and markdown strings * @param {ZeroMdRenderObject} obj * @returns {Promise<ZeroMdRenderObject>} */ async read(obj) { const { target } = obj; const results = (text = "", baseUrl = "") => { var _a; const hash = this.hash(text); const changed = ((_a = this.root.querySelector(`.markdown-${target}`)) == null ? void 0 : _a.getAttribute("data-hash")) !== hash; return { ...obj, text, hash, changed, baseUrl }; }; switch (target) { case "styles": { const get = (query = "") => { var _a; return (_a = this.querySelector(query)) == null ? void 0 : _a.innerHTML; }; return results( (get("template[data-prepend]") ?? "") + (get("template:not([data-prepend],[data-append])") ?? this.template) + (get("template[data-append]") ?? "") ); } case "body": { if (this.src) { const response = await fetch(this.src); if (response.ok) { const getBaseUrl = () => { const a = document.createElement("a"); a.href = this.src || ""; return a.href.substring(0, a.href.lastIndexOf("/") + 1); }; return results(await response.text(), getBaseUrl()); } else { console.warn("[zero-md] error reading src", this.src); } } const script = this.querySelector('script[type="text/markdown"]'); return results((script == null ? void 0 : script.text) || ""); } default: return results(); } } /** * Stamp parsed html strings into dom * @param {ZeroMdRenderObject} obj * @returns {Promise<ZeroMdRenderObject>} */ async stamp(obj) { const { target, text = "", hash = "" } = obj; const node = this.root.querySelector(`.markdown-${target}`); if (!node) return obj; node.setAttribute("data-hash", hash); const frag = this.frag(text); const links = Array.from(frag.querySelectorAll('link[rel="stylesheet"]') || []); const whenLoaded = Promise.all( links.map( (link2) => new Promise((resolve) => { link2.onload = resolve; link2.onerror = (err) => { console.warn("[zero-md] error loading stylesheet", link2.href); resolve(err); }; }) ) ); node.innerHTML = ""; node.append(frag); await whenLoaded; return { ...obj, stamped: true }; } /** * Start rendering * @param {{ fire?: boolean, goto?: string|false }} obj * @returns {Promise<*>} */ async render({ fire = true, goto = location.hash } = {}) { const styles = await this.read({ target: "styles" }); const pending = styles.changed && this.stamp(styles); const md = await this.read({ target: "body" }); if (md.changed) { const parsed = this.parse(md); await pending; await this.tick(); await this.stamp({ ...md, text: await parsed }); } else await pending; await this.tick(); const detail = { styles: styles.changed, body: md.changed }; if (fire) this.fire("zero-md-rendered", detail); if (this.auto && goto) this.goto(goto); return detail; } } const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1(?=[\s?!\.,:?!。,:]|$)/; const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1/; const blockRule = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/; function katexExtension(options = {}) { return { extensions: [ inlineKatex(options, createRenderer()), blockKatex(options, createRenderer()) ] }; } function createRenderer() { return (token) => token.text; } function inlineKatex(options, renderer) { const nonStandard = options && options.nonStandard; const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule; return { name: "inlineKatex", level: "inline", start(src) { let index; let indexSrc = src; while (indexSrc) { index = indexSrc.indexOf("$"); if (index === -1) { return; } const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === " "; if (f) { const possibleKatex = indexSrc.substring(index); if (possibleKatex.match(ruleReg)) { return index; } } indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ""); } }, tokenizer(src, tokens) { const match = src.match(ruleReg); if (match) { return { type: "inlineKatex", raw: match[0], text: match[2].trim(), displayMode: match[1].length === 2 }; } }, renderer }; } function blockKatex(options, renderer) { return { name: "blockKatex", level: "block", tokenizer(src, tokens) { const match = src.match(blockRule); if (match) { return { type: "blockKatex", raw: match[0], text: match[2].trim(), displayMode: match[1].length === 2 }; } }, renderer }; } const jsdelivr = (repo) => `https://cdn.jsdelivr.net/npm/${repo}`; const link = (href, attrs) => `<link rel="stylesheet" href="${href}"${attrs ? ` ${attrs}` : ""}>`; const load = async (url, name = "default") => (await import( /* @vite-ignore */ url ))[name]; const STYLES = { HOST: "<style>:host{display:block;position:relative;contain:content;}:host([hidden]){display:none;}</style>", MARKDOWN: link(jsdelivr("github-markdown-css@5/github-markdown.min.css")), MARKDOWN_LIGHT: link(jsdelivr("github-markdown-css@5/github-markdown-light.min.css")), MARKDOWN_DARK: link(jsdelivr("github-markdown-css@5/github-markdown-dark.min.css")), HIGHLIGHT_LIGHT: link(jsdelivr("@highlightjs/cdn-assets@11/styles/github.min.css")), HIGHLIGHT_DARK: link(jsdelivr("@highlightjs/cdn-assets@11/styles/github-dark.min.css")), HIGHLIGHT_PREFERS_DARK: link( jsdelivr("@highlightjs/cdn-assets@11/styles/github-dark.min.css"), `media="(prefers-color-scheme:dark)"` ), KATEX: link(jsdelivr("katex@0/dist/katex.min.css")), preset(theme = "") { const { HOST, MARKDOWN, MARKDOWN_LIGHT, MARKDOWN_DARK, HIGHLIGHT_LIGHT, HIGHLIGHT_DARK, HIGHLIGHT_PREFERS_DARK, KATEX } = this; const get = (sheets) => `${HOST}${sheets}${KATEX}`; switch (theme) { case "light": return get(MARKDOWN_LIGHT + HIGHLIGHT_LIGHT); case "dark": return get(MARKDOWN_DARK + HIGHLIGHT_DARK); default: return get(MARKDOWN + HIGHLIGHT_LIGHT + HIGHLIGHT_PREFERS_DARK); } } }; const LOADERS = { marked: async () => { const Marked = await load(jsdelivr("marked@15/lib/marked.esm.js"), "Marked"); return new Marked({ async: true }); }, markedBaseUrl: () => load(jsdelivr("marked-base-url@1/+esm"), "baseUrl"), markedHighlight: () => load(jsdelivr("marked-highlight@2/+esm"), "markedHighlight"), markedGfmHeadingId: () => load(jsdelivr("marked-gfm-heading-id@4/+esm"), "gfmHeadingId"), markedAlert: () => load(jsdelivr("marked-alert@2/+esm")), hljs: () => load(jsdelivr("@highlightjs/cdn-assets@11/es/highlight.min.js")), mermaid: () => load(jsdelivr("mermaid@11/dist/mermaid.esm.min.mjs")), katex: () => load(jsdelivr("katex@0/dist/katex.mjs")) }; let hljsHoisted; let mermaidHoisted; let katexHoisted; let uid = 0; class ZeroMd extends ZeroMdBase { async load(loaders = {}) { const { marked, markedBaseUrl, markedHighlight, markedGfmHeadingId, markedAlert, hljs, mermaid, katex, katexOptions = { nonStandard: true, throwOnError: false } } = { ...LOADERS, ...loaders }; this.template = STYLES.preset(); const modules = await Promise.all([ marked(), markedBaseUrl(), markedGfmHeadingId(), markedAlert(), markedHighlight() ]); this.marked = modules[0]; this.setBaseUrl = modules[1]; const parseKatex = async (text, displayMode) => { if (!katexHoisted) katexHoisted = await katex(); return katexHoisted.renderToString(text, { displayMode, ...katexOptions }); }; this.marked.use( modules[2](), modules[3](), { ...modules[4]({ async: true, highlight: async (code, lang) => { if (lang === "mermaid") { if (!mermaidHoisted) { mermaidHoisted = await mermaid(); mermaidHoisted.initialize({ startOnLoad: false }); } const { svg } = await mermaidHoisted.render(`mermaid-svg-${uid++}`, code); return svg; } if (lang === "math") return `<pre class="math">${await parseKatex(code, true)}</pre>`; if (!hljsHoisted) hljsHoisted = await hljs(); return hljsHoisted.getLanguage(lang) ? hljsHoisted.highlight(code, { language: lang }).value : hljsHoisted.highlightAuto(code).value; } }), renderer: { code: ({ text, lang }) => { if (lang === "mermaid") return `<div class="mermaid">${text}</div>`; if (lang === "math") return text; return `<pre><code class="hljs${lang ? ` language-${lang}` : ""}">${text} </code></pre>`; } } }, { ...katexExtension(katexOptions), walkTokens: async (token) => { const types = ["inlineKatex", "blockKatex"]; if (types.includes(token.type)) { token.text = await parseKatex(token.text, token.displayMode) + (token.type === types[1] ? "\n" : ""); } } } ); } /** @param {import('./zero-md-base.js').ZeroMdRenderObject} _obj */ async parse({ text, baseUrl }) { this.marked.use(this.setBaseUrl(baseUrl || "")); return this.marked.parse(text); } } if (new URL(import.meta.url).searchParams.has("register")) { customElements.define("zero-md", ZeroMd); } export { LOADERS, STYLES, ZeroMdBase, ZeroMd as default };