JaBa/dashboard/public/bower_components/zero-md/src/index.js

290 lines
9 KiB
JavaScript
Raw Normal View History

2022-01-04 02:18:28 +05:00
export class ZeroMd extends HTMLElement {
get src () { return this.getAttribute('src') }
set src (val) { this.reflect('src', val) }
get manualRender () { return this.hasAttribute('manual-render') }
set manualRender (val) { this.reflect('manual-render', val) }
reflect (name, val) {
if (val === false) {
this.removeAttribute(name)
} else {
this.setAttribute(name, val === true ? '' : val)
}
}
static get observedAttributes () {
return ['src']
}
attributeChangedCallback (name, old, val) {
if (name === 'src' && this.connected && !this.manualRender && val !== old) {
this.render()
}
}
constructor (defaults) {
super()
this.version = '$VERSION'
this.config = {
markedUrl: 'https://cdn.jsdelivr.net/gh/markedjs/marked@2/marked.min.js',
prismUrl: [
['https://cdn.jsdelivr.net/gh/PrismJS/prism@1/prism.min.js', 'data-manual'],
'https://cdn.jsdelivr.net/gh/PrismJS/prism@1/plugins/autoloader/prism-autoloader.min.js'
],
cssUrls: [
'https://cdn.jsdelivr.net/gh/sindresorhus/github-markdown-css@4/github-markdown.min.css',
'https://cdn.jsdelivr.net/gh/PrismJS/prism@1/themes/prism.min.css'
],
hostCss: ':host{display:block;position:relative;contain:content;}:host([hidden]){display:none;}',
...defaults,
...window.ZeroMdConfig
}
this.cache = {}
this.root = this.hasAttribute('no-shadow') ? this : this.attachShadow({ mode: 'open' })
if (!this.constructor.ready) {
this.constructor.ready = Promise.all([
!!window.marked || this.loadScript(this.config.markedUrl),
!!window.Prism || this.loadScript(this.config.prismUrl)
])
}
this.clicked = this.clicked.bind(this)
if (!this.manualRender) {
// Scroll to hash id after first render. However, `history.scrollRestoration` inteferes with this on refresh.
// It's much better to use a `setTimeout` rather than to alter the browser's behaviour.
this.render().then(() => setTimeout(() => this.goto(location.hash), 250))
}
this.observer = new MutationObserver(async () => {
this.observeChanges()
if (!this.manualRender) {
await this.render()
}
})
this.observeChanges()
}
connectedCallback () {
this.connected = true
this.fire('zero-md-connected', {}, { bubbles: false, composed: false })
this.waitForReady().then(() => {
this.fire('zero-md-ready')
})
if (this.shadowRoot) {
this.shadowRoot.addEventListener('click', this.clicked)
}
}
disconnectedCallback () {
this.connected = false
if (this.shadowRoot) {
this.shadowRoot.removeEventListener('click', this.clicked)
}
}
waitForReady () {
const ready = this.connected || new Promise(resolve => {
this.addEventListener('zero-md-connected', function handler () {
this.removeEventListener('zero-md-connected', handler)
resolve()
})
})
return Promise.all([this.constructor.ready, ready])
}
fire (name, detail = {}, opts = { bubbles: true, composed: true }) {
if (detail.msg) {
console.warn(detail.msg)
}
this.dispatchEvent(new CustomEvent(name, {
detail: { node: this, ...detail },
...opts
}))
}
tick () {
return new Promise(resolve => requestAnimationFrame(resolve))
}
// Coerce anything into an array
arrify (any) {
return any ? (Array.isArray(any) ? any : [any]) : []
}
// Promisify an element's onload callback
onload (node) {
return new Promise((resolve, reject) => {
node.onload = resolve
node.onerror = err => reject(err.path ? err.path[0] : err.composedPath()[0])
})
}
// Load a url or load (in order) an array of urls via <script> tags
loadScript (urls) {
return Promise.all(this.arrify(urls).map(item => {
const [url, ...attrs] = this.arrify(item)
const el = document.createElement('script')
el.src = url
el.async = false
attrs.forEach(attr => el.setAttribute(attr, ''))
return this.onload(document.head.appendChild(el))
}))
}
// Scroll to selected element
goto (id) {
if (id) {
const el = this.root.getElementById(id.substring(1))
if (el) {
el.scrollIntoView()
}
}
}
// Hijack same-doc anchor hash links
clicked (ev) {
if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.shiftKey || ev.defaultPrevented) {
return
}
const a = ev.target.closest('a')
if (a && a.hash && a.host === location.host && a.pathname === location.pathname) {
this.goto(a.hash)
}
}
dedent (str) {
str = str.replace(/^\n/, '')
const match = str.match(/^\s+/)
return match ? str.replace(new RegExp(`^${match[0]}`, 'gm'), '') : str
}
getBaseUrl (src) {
const a = document.createElement('a')
a.href = src
return a.href.substring(0, a.href.lastIndexOf('/') + 1)
}
// Runs Prism highlight async; falls back to sync if Web Workers throw
highlight (container) {
return new Promise(resolve => {
const unhinted = container.querySelectorAll('pre>code:not([class*="language-"])')
unhinted.forEach(n => {
// Dead simple language detection :)
const lang = n.innerText.match(/^\s*</) ? 'markup' : n.innerText.match(/^\s*(\$|#)/) ? 'bash' : 'js'
n.classList.add(`language-${lang}`)
})
try {
window.Prism.highlightAllUnder(container, true, resolve())
} catch {
window.Prism.highlightAllUnder(container)
resolve()
}
})
}
// Converts HTML string into node
makeNode (html) {
const tpl = document.createElement('template')
tpl.innerHTML = html
return tpl.content.firstElementChild
}
// Construct styles dom and return HTML string
buildStyles () {
const get = query => {
const node = this.querySelector(query)
return node ? node.innerHTML || ' ' : ''
}
const urls = this.arrify(this.config.cssUrls)
const html = `<div class="markdown-styles"><style>${
this.config.hostCss}</style>${
get('template[data-merge="prepend"]')}${
get('template:not([data-merge])') || urls.reduce((a, c) => `${a}<link rel="stylesheet" href="${c}">`, '')}${
get('template[data-merge="append"]')}</div>`
return html
}
// Construct md nodes and return HTML string
async buildMd (opts = {}) {
const src = async () => {
if (!this.src) {
return ''
}
const resp = await fetch(this.src)
if (resp.ok) {
const md = await resp.text()
return window.marked(md, { baseUrl: this.getBaseUrl(this.src), ...opts })
} else {
this.fire('zero-md-error', { msg: `[zero-md] HTTP error ${resp.status} while fetching src`, status: resp.status, src: this.src })
return ''
}
}
const script = () => {
const el = this.querySelector('script[type="text/markdown"]')
if (!el) { return '' }
const md = el.hasAttribute('data-dedent') ? this.dedent(el.text) : el.text
return window.marked(md, opts)
}
const html = `<div class="markdown-body${
opts.classes ? this.arrify(opts.classes).reduce((a, c) => `${a} ${c}`, ' ') : ''}">${
await src() || script()}</div>`
return html
}
// Insert or replace HTML styles string into DOM and wait for links to load
async stampStyles (html) {
const node = this.makeNode(html)
const links = [...node.querySelectorAll('link[rel="stylesheet"]')]
const target = [...this.root.children].find(n => n.classList.contains('markdown-styles'))
if (target) {
target.replaceWith(node)
} else {
this.root.prepend(node)
}
await Promise.all(links.map(l => this.onload(l))).catch(err => {
this.fire('zero-md-error', { msg: '[zero-md] An external stylesheet failed to load', status: undefined, src: err.href })
})
}
// Insert or replace HTML body string into DOM and returns the node
stampBody (html) {
const node = this.makeNode(html)
const target = [...this.root.children].find(n => n.classList.contains('markdown-body'))
if (target) {
target.replaceWith(node)
} else {
this.root.append(node)
}
return node
}
// Start observing for changes in root, templates and scripts
observeChanges () {
this.observer.observe(this, { childList: true })
this.querySelectorAll('template,script[type="text/markdown"]').forEach(n => {
this.observer.observe(n.content || n, { childList: true, subtree: true, attributes: true, characterData: true })
})
}
async render (opts = {}) {
await this.waitForReady()
const stamped = {}
const pending = this.buildMd(opts)
const css = this.buildStyles()
if (css !== this.cache.styles) {
this.cache.styles = css
await this.stampStyles(css)
stamped.styles = true
await this.tick()
}
const md = await pending
if (md !== this.cache.body) {
this.cache.body = md
const node = this.stampBody(md)
stamped.body = true
await this.highlight(node)
}
this.fire('zero-md-rendered', { stamped })
}
}
customElements.define('zero-md', ZeroMd)