zero-md
Version:
Ridiculously simple zero-config markdown displayer
454 lines (453 loc) • 14.3 kB
JavaScript
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
};