UNPKG

docsify

Version:

A magical documentation generator.

634 lines (526 loc) 18.9 kB
import tinydate from 'tinydate'; import * as dom from '../util/dom.js'; import { getPath, isAbsolutePath } from '../router/util.js'; import { isMobile } from '../util/env.js'; import { isPrimitive } from '../util/core.js'; import { Compiler } from './compiler.js'; import * as tpl from './tpl.js'; import { prerenderEmbed } from './embed.js'; /** @typedef {import('../Docsify.js').Constructor} Constructor */ // TODO replace with Vue types if available /** @typedef {{ _isVue?: boolean, $destroy?: () => void }} Vue2Instance */ /** @typedef {{ __vue__?: Vue2Instance }} WithVue2 */ /** @typedef {{ __v_skip?: boolean }} VNode3 */ /** @typedef {{ _vnode?: VNode3, __vue_app__?: { unmount: () => void } }} WithVue3 */ /** @typedef {Element & WithVue2 & WithVue3} VueMountElement */ /** * @template {!Constructor} T * @param {T} Base - The class to extend */ export function Render(Base) { return class Render extends Base { /** @type {Compiler | undefined} */ compiler; #vueGlobalData; #addTextAsTitleAttribute(cssSelector) { dom.findAll(cssSelector).forEach(elm => { const e = /** @type {HTMLElement} */ (elm); if (!e.title && e.innerText) { e.title = e.innerText; } }); } #executeScript() { const script = dom .findAll('.markdown-section>script') .filter( s => !/template/.test(/** @type {HTMLScriptElement} */ (s).type), )[0]; if (!script) { return false; } const code = /** @type {HTMLElement} */ (script).innerText.trim(); if (!code) { return false; } new Function(code)(); } #formatUpdated(html, updated, fn) { updated = typeof fn === 'function' ? fn(updated) : typeof fn === 'string' ? tinydate(fn)(new Date(updated)) : updated; return html.replace(/{docsify-updated}/g, updated); } #renderMain(html) { const docsifyConfig = this.config; const markdownElm = dom.find('.markdown-section'); const vueVersion = 'Vue' in window && window.Vue.version && Number(window.Vue.version.charAt(0)); /** * @param {VueMountElement} elm */ const isMountedVue = elm => { const isVue2 = Boolean(elm.__vue__ && elm.__vue__._isVue); const isVue3 = Boolean(elm._vnode && elm._vnode.__v_skip); return isVue2 || isVue3; }; if ('Vue' in window) { const mountedElms = dom .findAll('.markdown-section > *') .filter(elm => isMountedVue(elm)); // Destroy/unmount existing Vue instances for (const mountedElm of mountedElms) { if (vueVersion === 2) { /** @type {VueMountElement} */ (mountedElm).__vue__?.$destroy?.(); } else if (vueVersion === 3) { /** @type {VueMountElement} */ (mountedElm).__vue_app__?.unmount(); } } } dom.setHTML(markdownElm, html); // Execute markdown <script> if ( docsifyConfig.executeScript || ('Vue' in window && docsifyConfig.executeScript !== false) ) { this.#executeScript(); } // Handle Vue content not mounted by markdown <script> if ('Vue' in window) { const vueGlobalOptions = docsifyConfig.vueGlobalOptions || {}; const vueMountData = []; const vueComponentNames = Object.keys( docsifyConfig.vueComponents || {}, ); // Register global vueComponents if (vueVersion === 2 && vueComponentNames.length) { vueComponentNames.forEach(name => { const isNotRegistered = !window.Vue.options.components[name]; if (isNotRegistered) { window.Vue.component(name, docsifyConfig.vueComponents[name]); } }); } // Store global data() return value as shared data object if ( !this.#vueGlobalData && vueGlobalOptions.data && typeof vueGlobalOptions.data === 'function' ) { this.#vueGlobalData = vueGlobalOptions.data(); } // vueMounts vueMountData.push( ...Object.keys(docsifyConfig.vueMounts || {}) .map(cssSelector => [ dom.find(markdownElm, cssSelector), docsifyConfig.vueMounts[cssSelector], ]) .filter(([elm, vueConfig]) => elm), ); // Template syntax, vueComponents, vueGlobalOptions ... const reHasBraces = /{{2}[^{}]*}{2}/; // Matches Vue full and shorthand syntax as attributes in HTML tags. // // Full syntax examples: // v-foo, v-foo[bar], v-foo-bar, v-foo:bar-baz.prop // // Shorthand syntax examples: // @foo, @foo.bar, @foo.bar.baz, @[foo], :foo, :[foo] // // Markup examples: // <div v-html>{{ html }}</div> // <div v-text="msg"></div> // <div v-bind:text-content.prop="text"> // <button v-on:click="doThis"></button> // <button v-on:click.once="doThis"></button> // <button v-on:[event]="doThis"></button> // <button @click.stop.prevent="doThis"> // <a :href="url"> // <a :[key]="url"> const reHasDirective = /<[^>/]+\s([@:]|v-)[\w-:.[\]]+[=>\s]/; vueMountData.push( ...dom .findAll('.markdown-section > *') // Remove duplicates .filter(elm => !vueMountData.some(([e, c]) => e === elm)) // Detect Vue content .filter(elm => { const selector = vueComponentNames.join(','); const hasComponents = selector ? Boolean(elm.querySelector(selector)) : false; const isVueMount = // is a component elm.tagName.toLowerCase() in (docsifyConfig.vueComponents || {}) || // has a component(s) hasComponents || // has curly braces reHasBraces.test(elm.outerHTML) || // has content directive reHasDirective.test(elm.outerHTML); return isVueMount; }) .map(elm => { // Clone global configuration const vueConfig = { ...vueGlobalOptions, }; // Replace vueGlobalOptions data() return value with shared data object. // This provides a global store for all Vue instances that receive // vueGlobalOptions as their configuration. if (this.#vueGlobalData) { vueConfig.data = () => this.#vueGlobalData; } return [elm, vueConfig]; }), ); // Not found mounts but import Vue resource if (vueMountData.length === 0) { return; } // Mount for (const [mountElm, vueConfig] of vueMountData) { const isVueAttr = 'data-isvue'; const isSkipElm = // Is an invalid tag mountElm.matches('pre, :not([v-template]):has(pre), script') || // Is a mounted instance isMountedVue(mountElm) || // Has mounted instance(s) mountElm.querySelector(`[${isVueAttr}]`); if (!isSkipElm) { mountElm.setAttribute(isVueAttr, ''); if (vueVersion === 2) { vueConfig.el = undefined; new window.Vue(vueConfig).$mount(mountElm); } else if (vueVersion === 3) { const app = window.Vue.createApp(vueConfig); // Register global vueComponents vueComponentNames.forEach(name => { const config = docsifyConfig.vueComponents[name]; app.component(name, config); }); app.mount(mountElm); } } } } } #renderNameLink(vm) { const el = dom.getNode('.app-name-link'); const nameLink = vm.config.nameLink; const path = vm.route.path; if (!el) { return; } if (isPrimitive(vm.config.nameLink)) { el.setAttribute('href', nameLink); } else if (typeof nameLink === 'object') { const match = Object.keys(nameLink).filter( key => path.indexOf(key) > -1, )[0]; el.setAttribute('href', nameLink[match]); } } #renderSkipLink(vm) { const { skipLink } = vm.config; if (skipLink !== false) { const el = dom.getNode('#skip-to-content'); let skipLinkText = typeof skipLink === 'string' ? skipLink : 'Skip to main content'; if (skipLink?.constructor === Object) { const matchingPath = Object.keys(skipLink).find(path => vm.route.path.startsWith(path.startsWith('/') ? path : `/${path}`), ); const matchingText = matchingPath && skipLink[matchingPath]; skipLinkText = matchingText || skipLinkText; } if (el) { el.innerHTML = skipLinkText; } else { const html = `<button type="button" id="skip-to-content" class="primary">${skipLinkText}</button>`; dom.body.insertAdjacentHTML('afterbegin', html); } } } _renderSidebar(text) { const { maxLevel, subMaxLevel, loadSidebar, hideSidebar } = this.config; const sidebarEl = dom.getNode('aside.sidebar'); const sidebarNavEl = dom.getNode('.sidebar-nav'); const sidebarToggleEl = dom.getNode('button.sidebar-toggle'); if (hideSidebar) { sidebarEl?.remove(); sidebarToggleEl?.remove(); return null; } if (!this.compiler) { throw new Error('Compiler is not initialized'); } dom.setHTML('.sidebar-nav', this.compiler.sidebar(text, maxLevel)); sidebarToggleEl.setAttribute('aria-expanded', String(!isMobile())); const activeElmHref = decodeURIComponent( this.router.toURL(this.route.path), ); const activeEl = /** @type {HTMLElement | null} */ ( dom.find(`.sidebar-nav a[href="${activeElmHref}"]`) ); this.#addTextAsTitleAttribute('.sidebar-nav a'); if (loadSidebar && activeEl) { const parent = /** @type {HTMLElement} */ (activeEl.parentElement); parent.innerHTML += this.compiler.subSidebar(subMaxLevel) || ''; } else { this.compiler.resetToc(); } // Bind event this._bindEventOnRendered(activeEl); // Mark page links and groups const pageLinks = dom.findAll( sidebarNavEl, 'a:is(li > a, li > p > a):not(.section-link, [target="_blank"])', ); const pageLinkGroups = dom // NOTE: Using filter() method as a replacement for :has() selector. It // would be preferable to use only 'li:not(:has(> a, > p > a))' selector // but the :has() selector is not supported by our Jest test environment // See: https://github.com/jsdom/jsdom/issues/3506#issuecomment-1769782333 .findAll(sidebarEl, 'li') .filter( elm => elm.querySelector(':scope > ul') && !elm.querySelectorAll(':scope > a, :scope > p > a').length, ); pageLinks.forEach(elm => { elm.classList.add('page-link'); }); pageLinkGroups.forEach(elm => { elm.classList.add('group'); elm .querySelector(':scope > p:not(:has(> *))') ?.classList.add('group-title'); }); } /** * @param {HTMLElement | null} activeEl */ _bindEventOnRendered(activeEl) { const { autoHeader } = this.config; this.onRender(); if (autoHeader && activeEl) { const main = dom.getNode('#main'); const hasH1 = main.querySelector('h1'); if (!hasH1) { const h1HTML = /** @type {Compiler} */ (this.compiler).header( activeEl.innerText, 1, ); const h1Node = dom.create('div', h1HTML).firstElementChild; if (h1Node) { dom.before(main, h1Node); } } } } _renderNav(text) { if (!text) { return; } const html = /** @type {Compiler} */ (this.compiler).compile(text); ['.app-nav', '.app-nav-merged'].forEach(selector => { dom.setHTML(selector, html); this.#addTextAsTitleAttribute(`${selector} a`); }); } _renderMain(text, opt = {}, next) { const { response } = this.route; // Note: It is possible for the response to be undefined in environments // where XMLHttpRequest has been modified or mocked if (response && !response.ok && (!text || response.status !== 404)) { text = `# ${response.status} - ${response.statusText}`; } this.callHook('beforeEach', text, result => { let html; const callback = () => { if (opt.updatedAt) { html = this.#formatUpdated( html, opt.updatedAt, this.config.formatUpdated, ); } this.callHook('afterEach', html, hookData => { this.#renderMain(hookData); next(); }); }; if (this.isHTML) { html = this.result = text; callback(); } else { prerenderEmbed( { compiler: /** @type {Compiler} */ (this.compiler), raw: result, fetch: undefined, }, tokens => { html = /** @type {Compiler} */ (this.compiler).compile(tokens); callback(); }, ); } }); } _renderCover(text, coverOnly) { const el = dom.getNode('.cover'); const rootElm = document.documentElement; const coverBg = getComputedStyle(rootElm).getPropertyValue('--cover-bg'); dom.getNode('main').classList[coverOnly ? 'add' : 'remove']('hidden'); if (!text) { el.classList.remove('show'); return; } el.classList.add('show'); let html = this.coverIsHTML ? text : /** @type {Compiler} */ (this.compiler).cover(text); if (!coverBg) { const mdBgMatch = html .trim() .match( '<p><img.*?data-origin="(.*?)".*?alt="(.*?)"[^>]*?>([^<]*?)</p>$', ); let mdCoverBg; if (mdBgMatch) { const [bgMatch, bgValue, bgType] = mdBgMatch; // Color if (bgType === 'color') { mdCoverBg = bgValue; } // Image else { const path = !isAbsolutePath(bgValue) ? getPath(this.router.getBasePath(), bgValue) : bgValue; mdCoverBg = `center center / cover url(${path})`; } html = html.replace(bgMatch, ''); } // Gradient background else { const degrees = Math.round((Math.random() * 120) / 2); let hue1 = Math.round(Math.random() * 360); let hue2 = Math.round(Math.random() * 360); // Ensure hue1 and hue2 are at least 50 degrees apart if (Math.abs(hue1 - hue2) < 50) { const hueShift = Math.round(Math.random() * 25) + 25; hue1 = Math.max(hue1, hue2) + hueShift; hue2 = Math.min(hue1, hue2) - hueShift; } // OKLCH color if (window?.CSS?.supports('color', 'oklch(0 0 0 / 1%)')) { const l = 90; // Lightness const c = 20; // Chroma // prettier-ignore mdCoverBg = `linear-gradient( ${degrees}deg, oklch(${l}% ${c}% ${hue1}) 0%, oklch(${l}% ${c}% ${hue2}) 100% )`.replace(/\s+/g, ' '); } // HSL color (Legacy) else { const s = 100; // Saturation const l = 85; // Lightness const o = 100; // Opacity // prettier-ignore mdCoverBg = `linear-gradient( ${degrees}deg, hsl(${hue1} ${s}% ${l}% / ${o}%) 0%, hsl(${hue2} ${s}% ${l}% / ${o}%) 100% )`.replace(/\s+/g, ' '); } } rootElm.style.setProperty('--cover-bg', mdCoverBg); } dom.setHTML('.cover-main', html); // Button styles dom .findAll('.cover-main > p:last-of-type > a:not([class])') .forEach(elm => { const buttonType = elm.matches(':first-child') ? 'primary' : 'secondary'; elm.classList.add('button', buttonType); }); } _updateRender() { // Render name link this.#renderNameLink(this); // Render skip link this.#renderSkipLink(this); } initRender() { const config = this.config; // Init markdown compiler this.compiler = new Compiler(config, this.router); window.__current_docsify_compiler__ = this.compiler; const id = config.el || '#app'; const el = dom.find(id); if (el) { let html = ''; if (config.repo) { html += tpl.corner(config.repo, config.cornerExternalLinkTarget); } if (config.coverpage) { html += tpl.cover(); } if (config.logo) { const isBase64 = /^data:image/.test(config.logo); const isExternal = /(?:http[s]?:)?\/\//.test(config.logo); const isRelative = /^\./.test(config.logo); if (!isBase64 && !isExternal && !isRelative) { config.logo = getPath(this.router.getBasePath(), config.logo); } } html += tpl.main(config); // Render main app dom.setHTML(el, html, true); } else { this.rendered = true; } // Add nav if (config.loadNavbar) { const navEl = dom.find('nav') || dom.create('nav'); const isMergedSidebar = config.mergeNavbar; navEl.classList.add('app-nav'); navEl.setAttribute('aria-label', 'secondary'); dom.body.prepend(navEl); if (isMergedSidebar) { const mergedNavEl = dom.create('div'); const sidebarEl = dom.find('.sidebar'); const sidebarNavEl = dom.find('.sidebar-nav'); mergedNavEl?.classList.add('app-nav-merged'); sidebarEl?.insertBefore(mergedNavEl, sidebarNavEl); } } if (config.themeColor) { const themeNode = dom.create( 'div', tpl.theme(config.themeColor), ).firstElementChild; if (themeNode) { dom.$.head.appendChild(themeNode); } } this._updateRender(); dom.body.classList.add('ready'); } }; }