diff --git a/src/core/fetch/index.js b/src/core/fetch/index.js index 3dcc0caf9d..a276391bed 100644 --- a/src/core/fetch/index.js +++ b/src/core/fetch/index.js @@ -186,7 +186,7 @@ export function Fetch(Base) { } } - _fetchCover() { + _fetchCover(cb = noop) { const { coverpage, requestHeaders } = this.config; const query = this.route.query; const root = getParentPath(this.route.path); @@ -206,17 +206,26 @@ export function Fetch(Base) { } const coverOnly = Boolean(path) && this.config.onlyCover; + const next = () => cb(coverOnly); if (path) { path = this.router.getFile(root + path); this.coverIsHTML = /\.html$/g.test(path); get(path + stringifyQuery(query, ['id']), false, requestHeaders).then( - text => this._renderCover(text, coverOnly), + text => this._renderCover(text, coverOnly, next), + (event, response) => { + this.coverIsHTML = false; + this._renderCover( + `# ${response.status} - ${response.statusText}`, + coverOnly, + next, + ); + }, ); } else { - this._renderCover(null, coverOnly); + this._renderCover(null, coverOnly, next); } - - return coverOnly; + } else { + cb(false); } } @@ -226,16 +235,16 @@ export function Fetch(Base) { cb(); }; - const onlyCover = this._fetchCover(); - - if (onlyCover) { - done(); - } else { - this._fetch(() => { - onNavigate(); + this._fetchCover(onlyCover => { + if (onlyCover) { done(); - }); - } + } else { + this._fetch(() => { + onNavigate(); + done(); + }); + } + }); } /** diff --git a/src/core/render/compiler.js b/src/core/render/compiler.js index ef15709108..fbb542aed7 100644 --- a/src/core/render/compiler.js +++ b/src/core/render/compiler.js @@ -1,6 +1,7 @@ import { marked } from 'marked'; +/** @import {TokensList, Marked} from 'marked' */ import { isAbsolutePath, getPath, getParentPath } from '../router/util.js'; -import { isFn, cached, isPrimitive } from '../util/core.js'; +import { isFn, cached } from '../util/core.js'; import { tree as treeTpl } from './tpl.js'; import { genTree } from './gen-tree.js'; import { slugify } from './slugify.js'; @@ -32,6 +33,7 @@ export class Compiler { this.contentBase = router.getBasePath(); this.renderer = this._initRenderer(); + /** @type {typeof marked & Marked} */ let compile; const mdConf = config.markdown || {}; @@ -43,10 +45,14 @@ export class Compiler { renderer: Object.assign(this.renderer, mdConf.renderer), }), ); + // @ts-expect-error FIXME temporary ugly Marked types compile = marked; } + /** @type {typeof marked & Marked} */ this._marked = compile; + + /** @param {string | TokensList} text */ this.compile = text => { let isCached = true; @@ -59,8 +65,8 @@ export class Compiler { return text; } - if (isPrimitive(text)) { - html = compile(text); + if (typeof text === 'string') { + html = /** @type {string} */ (compile(text)); } else { html = compile.parser(text); } @@ -113,7 +119,8 @@ export class Compiler { } let media; - if (config.type && (media = compileMedia[config.type])) { + const configType = /** @type {string | undefined} */ (config.type); + if (configType && (media = compileMedia[configType])) { embed = media.call(this, href, title); embed.type = config.type; } else { @@ -273,8 +280,8 @@ export class Compiler { /** * Compile cover page - * @param {Text} text Text content - * @returns {String} Cover page + * @param {TokensList} text Text content + * @returns {string} Cover page */ cover(text) { const cacheToc = this.toc.slice(); diff --git a/src/core/render/embed.js b/src/core/render/embed.js index 8a55005341..5e38e35c74 100644 --- a/src/core/render/embed.js +++ b/src/core/render/embed.js @@ -1,5 +1,7 @@ import { stripIndent } from 'common-tags'; import { get } from '../util/ajax.js'; +/** @import { Compiler } from '../Docsify.js' */ +/** @import {TokensList} from 'marked' */ const cached = {}; @@ -32,7 +34,7 @@ function extractFragmentContent(text, fragment, fullLine) { return stripIndent((match || [])[1] || '').trim(); } -function walkFetchEmbed({ embedTokens, compile, fetch }, cb) { +function walkFetchEmbed({ embedTokens, compile }, cb) { let token; let step = 0; let count = 0; @@ -132,7 +134,13 @@ function walkFetchEmbed({ embedTokens, compile, fetch }, cb) { } } -export function prerenderEmbed({ compiler, raw = '', fetch }, done) { +/** + * @param {Object} options + * @param {Compiler} options.compiler + * @param {string} [options.raw] + * @param {Function} done + */ +export function prerenderEmbed({ compiler, raw = '' }, done) { const hit = cached[raw]; if (hit) { const copy = hit.slice(); @@ -193,7 +201,7 @@ export function prerenderEmbed({ compiler, raw = '', fetch }, done) { // are returned const moves = []; walkFetchEmbed( - { compile, embedTokens, fetch }, + { compile, embedTokens }, ({ embedToken, token, rowIndex, cellIndex, tokenRef }) => { if (token) { if (typeof rowIndex === 'number' && typeof cellIndex === 'number') { @@ -212,9 +220,14 @@ export function prerenderEmbed({ compiler, raw = '', fetch }, done) { Object.assign(links, embedToken.links); + // FIXME This is an actual code error caught by TypeScript, but + // apparently we've not been effected by deleting the `.links` property + // yet. + // @ts-expect-error tokens = tokens - .slice(0, index) + .slice(0, index) // This deletes the original .links by returning a new array, so now we have Tokens[] instead of TokensList .concat(embedToken, tokens.slice(index + 1)); + moves.push({ start: index, length: embedToken.length - 1 }); } } else { diff --git a/src/core/render/index.js b/src/core/render/index.js index 9e546a3d01..cc2f92ceb6 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -433,7 +433,6 @@ export function Render(Base) { { compiler: /** @type {Compiler} */ (this.compiler), raw: result, - fetch: undefined, }, tokens => { html = /** @type {Compiler} */ (this.compiler).compile(tokens); @@ -444,108 +443,71 @@ export function Render(Base) { }); } - _renderCover(text, coverOnly) { - const el = dom.getNode('.cover'); + _renderCover(text, coverOnly, next) { + const el = /** @type {HTMLElement} */ (dom.getNode('.cover')); const rootElm = document.documentElement; + // TODO this is now unused. What did we break? + // eslint-disable-next-line no-unused-vars const coverBg = getComputedStyle(rootElm).getPropertyValue('--cover-bg'); dom.getNode('main').classList[coverOnly ? 'add' : 'remove']('hidden'); if (!text) { el.classList.remove('show'); + next(); return; } el.classList.add('show'); - let html = this.coverIsHTML - ? text - : /** @type {Compiler} */ (this.compiler).cover(text); - - if (!coverBg) { - const mdBgMatch = html + const callback = html => { + const m = html .trim() - .match( - '

]*?>([^<]*?)

$', - ); - - let mdCoverBg; + .match('

([^<]*?)

$'); - if (mdBgMatch) { - const [bgMatch, bgValue, bgType] = mdBgMatch; + if (m) { + if (m[2] === 'color') { + el.style.background = m[1] + (m[3] || ''); + } else { + let path = m[1]; - // Color - if (bgType === 'color') { - mdCoverBg = bgValue; - } - // Image - else { - const path = !isAbsolutePath(bgValue) - ? getPath(this.router.getBasePath(), bgValue) - : bgValue; + el.classList.add('has-mask'); + if (!isAbsolutePath(m[1])) { + path = getPath(this.router.getBasePath(), m[1]); + } - mdCoverBg = `center center / cover url(${path})`; + el.style.backgroundImage = `url(${path})`; + el.style.backgroundSize = 'cover'; + el.style.backgroundPosition = 'center center'; } - html = html.replace(bgMatch, ''); + html = html.replace(m[0], ''); } - // 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; - } + dom.setHTML('.cover-main', html); + next(); + }; - // 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, ' '); - } + // TODO: Call the 'beforeEach' and 'afterEach' hooks. + // However, when the cover and the home page are on the same page, + // the 'beforeEach' and 'afterEach' hooks are called multiple times. + // It is difficult to determine the target of the hook within the + // hook functions. We might need to make some changes. + if (this.coverIsHTML) { + callback(text); + } else { + const compiler = this.compiler; + if (!compiler) { + throw new Error('Compiler is not initialized'); } - - rootElm.style.setProperty('--cover-bg', mdCoverBg); + prerenderEmbed( + { + compiler, + raw: text, + }, + tokens => callback(compiler.cover(tokens)), + ); } - - 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() { diff --git a/src/core/util/core.js b/src/core/util/core.js index 04f3fbdd12..3291c67dd1 100644 --- a/src/core/util/core.js +++ b/src/core/util/core.js @@ -33,9 +33,10 @@ export function isPrimitive(value) { /** * Performs no operation. - * @void + * @param {...any} args Any arguments ignored. + * @returns {void} */ -export function noop() {} +export function noop(...args) {} /** * Check if value is function diff --git a/test/e2e/embed-files.test.js b/test/e2e/embed-files.test.js new file mode 100644 index 0000000000..46ae1d1c3a --- /dev/null +++ b/test/e2e/embed-files.test.js @@ -0,0 +1,33 @@ +import docsifyInit from '../helpers/docsify-init.js'; +import { test, expect } from './fixtures/docsify-init-fixture.js'; + +test.describe('Embed files', () => { + const routes = { + 'fragment.md': '## Fragment', + }; + + test('embed into homepage', async ({ page }) => { + await docsifyInit({ + routes, + markdown: { + homepage: "# Hello World\n\n[fragment](fragment.md ':include')", + }, + // _logHTML: {}, + }); + + await expect(page.locator('#main')).toContainText('Fragment'); + }); + + test('embed into cover', async ({ page }) => { + await docsifyInit({ + routes, + markdown: { + homepage: '# Hello World', + coverpage: "# Cover\n\n[fragment](fragment.md ':include')", + }, + // _logHTML: {}, + }); + + await expect(page.locator('.cover-main')).toContainText('Fragment'); + }); +}); diff --git a/test/e2e/plugins.test.js b/test/e2e/plugins.test.js index b1e53d729a..7be946aa0b 100644 --- a/test/e2e/plugins.test.js +++ b/test/e2e/plugins.test.js @@ -164,6 +164,74 @@ test.describe('Plugins', () => { }); }); + test.describe('doneEach()', () => { + test('callback after cover loads', async ({ page }) => { + const consoleMessages = []; + + page.on('console', msg => consoleMessages.push(msg.text())); + + await docsifyInit({ + config: { + plugins: [ + function (hook) { + hook.doneEach(() => { + const homepageTitle = document.querySelector('#homepage-title'); + const coverTitle = document.querySelector('#cover-title'); + console.log(homepageTitle?.textContent); + console.log(coverTitle?.textContent); + }); + }, + ], + }, + markdown: { + homepage: '# Hello World :id=homepage-title', + coverpage: () => { + return new Promise(resolve => { + setTimeout(() => resolve('# Cover Page :id=cover-title'), 500); + }); + }, + }, + // _logHTML: {}, + }); + + await expect(consoleMessages).toEqual(['Hello World', 'Cover Page']); + }); + + test('only cover', async ({ page }) => { + const consoleMessages = []; + + page.on('console', msg => consoleMessages.push(msg.text())); + + await docsifyInit({ + config: { + onlyCover: true, + plugins: [ + function (hook) { + hook.doneEach(() => { + const homepageTitle = document.querySelector('#homepage-title'); + const coverTitle = document.querySelector('#cover-title'); + console.log(homepageTitle?.textContent); + console.log(coverTitle?.textContent); + }); + }, + ], + }, + markdown: { + homepage: '# Hello World :id=homepage-title', + coverpage: () => { + return new Promise(resolve => { + setTimeout(() => resolve('# Cover Page :id=cover-title'), 500); + }); + }, + }, + waitForSelector: '.cover-main > *:first-child', + // _logHTML: {}, + }); + + await expect(consoleMessages).toEqual(['undefined', 'Cover Page']); + }); + }); + test.describe('route data accessible to plugins', () => { let routeData = null; diff --git a/test/helpers/docsify-init.js b/test/helpers/docsify-init.js index 2c7f23933d..59730a2d44 100644 --- a/test/helpers/docsify-init.js +++ b/test/helpers/docsify-init.js @@ -17,11 +17,11 @@ const docsifyURL = '/dist/docsify.js'; // Playwright * @param {Function|Object} [options.config] docsify configuration (merged with default) * @param {String} [options.html] HTML content to use for docsify `index.html` page * @param {Object} [options.markdown] Docsify markdown content - * @param {String} [options.markdown.coverpage] coverpage markdown - * @param {String} [options.markdown.homepage] homepage markdown - * @param {String} [options.markdown.navbar] navbar markdown - * @param {String} [options.markdown.sidebar] sidebar markdown - * @param {Object} [options.routes] custom routes defined as `{ pathOrGlob: responseText }` + * @param {String|(()=>Promise|String)} [options.markdown.coverpage] coverpage markdown + * @param {String|(()=>Promise|String)} [options.markdown.homepage] homepage markdown + * @param {String|(()=>Promise|String)} [options.markdown.navbar] navbar markdown + * @param {String|(()=>Promise|String)} [options.markdown.sidebar] sidebar markdown + * @param {RecordPromise|String)>} [options.routes] custom routes defined as `{ pathOrGlob: response }` * @param {String} [options.script] JS to inject via