diff --git a/docs/configuration.md b/docs/configuration.md index 16d1fded4..b76c9db1a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -227,7 +227,27 @@ window.$docsify = { ``` -Note that if you are running an external script, e.g. an embedded jsfiddle demo, make sure to include the [external-script](plugins.md?id=external-script) plugin. +Inline ES modules are supported with `type="module"`: + +```html + +``` + +Modules execute asynchronously, and Docsify does not wait for them before running its lifecycle hooks. Only the first non-template script is considered for execution, whether it is a classic script or a module. + +Relative specifiers (`./` and `../`) in inline modules resolve against the URL of the Markdown content being rendered, in both `hash` and `history` router modes. This applies to static imports, re-exports, and dynamic imports with quoted string literals, such as `import('./example.js')`. Docsify uses the content URL after applying `basePath`, `alias`, and `homepage`, including redirects, language fallback pages, and custom 404 pages. Virtual routes use their mapped Markdown file URL. + +For example, if the Markdown file is `/content/guide/page.md`, `./example.js` resolves to `/content/guide/example.js` and `../shared.js` resolves to `/content/shared.js`. A hash route at `/app/index.html#/guide/page` with the default `basePath` loads `/app/guide/page.md`; `index.html` is not treated as a directory. + +Absolute URLs, root-relative paths (`/example.js`), and bare specifiers (`vue`) retain their native browser behavior, including import maps. `import.meta.url` and computed dynamic imports such as `import(path)`, `import('./' + name)`, and template literals are not rewritten; they retain the HTML document as their base. Imports inside external modules resolve relative to those modules, as usual. + +If an inline module cannot be parsed, Docsify reports the error without executing it, and continues rendering the document. + +Note that if you are running an external script with a `src` attribute, e.g. an embedded jsfiddle demo, make sure to include the [external-script](plugins.md?id=external-script) plugin. This also applies to ` + + + `; + + await docsifyInit({ + config: { executeScript: true }, + markdown: { homepage: markdown }, + routes: { + 'next.md': markdown.replace('# Module example', '# Next page'), + 'module-value.js': ` + window.dependencyRuns = (window.dependencyRuns || 0) + 1; + export const value = 'Module loaded'; + `, + }, + styleURLs: ['/dist/themes/core.css'], + }); + + await expect(page.locator('#result')).toHaveText('Module loaded'); + await expect(page.locator('#result')).toBeInViewport(); + await expect(page.locator('script[data-example]')).toHaveAttribute( + 'type', + 'module', + ); + await page.getByRole('link', { name: 'Next page', exact: true }).click(); + await expect(page.locator('h1')).toHaveText('Next page'); + await expect(page.locator('#result')).toHaveText('Module loaded'); + await page.goBack(); + await expect(page.locator('h1')).toHaveText('Module example'); + await expect(page.locator('#result')).toHaveText('Module loaded'); + expect( + await page.evaluate(() => ({ + moduleRuns: window.moduleRuns, + dependencyRuns: window.dependencyRuns, + moduleValue: window.moduleValue, + })), + ).toEqual({ moduleRuns: 3, dependencyRuns: 1, moduleValue: undefined }); + expect(errors).toEqual([]); + }); + } + + test('keeps classic scripts in function scope and skips templates', async ({ + page, + }) => { + await docsifyInit({ + config: { executeScript: true }, + markdown: { + homepage: ` +
Waiting
+ + + + + + + `, + }, + }); + + await expect(page.locator('#result')).toHaveText('Classic script'); + expect(await page.evaluate(() => window.localValue)).toBeUndefined(); + }); + + for (const type of ['', 'module']) { + for (const executeScript of [false, null]) { + test(`does not execute type="${type}" with executeScript=${executeScript}`, async ({ + page, + }) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await docsifyInit({ + config: { executeScript }, + markdown: { + homepage: ` + # Disabled script + + + `, + }, + }); + + await expect(page.locator('h1')).toHaveText('Disabled script'); + expect(errors).toEqual([]); + }); + } + } + + for (const script of [ + '', + '', + '', + '', + ]) { + test(`handles an empty page script: ${JSON.stringify(script)}`, async ({ + page, + }) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await docsifyInit({ + config: { executeScript: true }, + markdown: { homepage: `# Empty script\n\n${script}` }, + }); + + await expect(page.locator('h1')).toHaveText('Empty script'); + expect(errors).toEqual([]); + }); + } + + for (const [scriptURLs, expectedText] of [ + [[], 'Waiting'], + [['/dist/plugins/external-script.js'], 'External module'], + ]) { + test(`leaves external modules to the plugin (${expectedText})`, async ({ + page, + }) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await docsifyInit({ + config: { executeScript: true }, + scriptURLs, + markdown: { + homepage: ` +Waiting
+ + + `, + }, + routes: { + 'external-module.js': ` + document.querySelector('#result').textContent = 'External module'; + `, + }, + }); + + await expect(page.locator('#result')).toHaveText(expectedText); + expect(errors).toEqual([]); + }); + } + + test('renders the document while a module is loading', async ({ page }) => { + let releaseModule; + const moduleReady = new Promise(resolve => { + releaseModule = resolve; + }); + await page.route('**/slow-module.js', async route => { + await moduleReady; + await route.fulfill({ + contentType: 'application/javascript', + body: 'export const value = "Loaded";', + }); + }); + const request = page.waitForRequest('**/slow-module.js'); + + try { + await docsifyInit({ + config: { executeScript: true }, + markdown: { + homepage: ` + # Loading example + +Loading
+ + + `, + }, + }); + + await request; + await expect(page.locator('h1')).toHaveText('Loading example'); + await expect(page.locator('#result')).toHaveText('Loading'); + } finally { + releaseModule(); + } + + await expect(page.locator('#result')).toHaveText('Loaded'); + }); + + test('keeps navigation working after a module fails to load', async ({ + page, + }) => { + await page.route('**/missing-module.js', route => route.abort()); + const failedRequest = page.waitForEvent('requestfailed', request => + request.url().endsWith('/missing-module.js'), + ); + + await docsifyInit({ + config: { executeScript: true }, + markdown: { + homepage: ` + # Failed module + + [Next page](next.md) + + + `, + }, + routes: { 'next.md': '# Next page' }, + }); + + expect((await failedRequest).failure()).toBeTruthy(); + await expect(page.locator('h1')).toHaveText('Failed module'); + await page.getByRole('link', { name: 'Next page', exact: true }).click(); + await expect(page.locator('h1')).toHaveText('Next page'); + }); +}); diff --git a/test/e2e/module-paths.test.js b/test/e2e/module-paths.test.js new file mode 100644 index 000000000..ee462a7fb --- /dev/null +++ b/test/e2e/module-paths.test.js @@ -0,0 +1,291 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import docsifyInit from '../helpers/docsify-init.js'; +import { test, expect } from './fixtures/docsify-init-fixture.js'; + +const home = '# Home\n\n[Guide](/guide/page)'; +const guide = ` + # Guide + +Loading
+ + +`; + +async function initSite(options) { + await docsifyInit({ + ...options, + config: { + ...options.config, + alias: { '/module-host.html': '/', ...options.config.alias }, + }, + }); +} + +// docsifyInit accepts text fixtures; HTTP statuses and headers need native routes. +async function mockFiles(page, files) { + for (const [path, response] of Object.entries(files)) { + await page.route( + new URL(path, `${process.env.TEST_HOST}/content/`).href, + route => + route.fulfill({ + contentType: path.endsWith('.js') + ? 'application/javascript' + : 'text/markdown', + ...(typeof response === 'string' ? { body: response } : response), + }), + ); + } +} + +async function startRedirectServer() { + const docsify = await readFile( + new URL('../../dist/docsify.js', import.meta.url), + ); + const server = createServer((request, response) => { + response.setHeader('Access-Control-Allow-Origin', '*'); + if (request.url === '/dist/docsify.js') { + response.setHeader('Content-Type', 'application/javascript'); + response.end(docsify); + } else if (request.url === '/redirect.md') { + response.writeHead(302, { Location: '/actual/page.md' }).end(); + } else if (request.url === '/actual/page.md') { + response.setHeader('Content-Type', 'text/markdown'); + response.end(guide); + } else if (request.url === '/actual/dep.js') { + response.setHeader('Content-Type', 'application/javascript'); + response.end('export const value = "Redirected";'); + } else { + response.writeHead(404).end(); + } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return server; +} + +for (const routerMode of ['hash', 'history']) { + const testURL = `${process.env.TEST_HOST}/module-host.html`; + test.describe(`Markdown module paths (${routerMode})`, () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(mode => { + if (mode === 'hash') { + history.replaceState(null, '', location.pathname + '#/'); + } + }, routerMode); + }); + for (const width of [1280, 375]) { + test(`resolves imports on navigation at ${width}px`, async ({ page }) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.setViewportSize({ width, height: 800 }); + const markdown = ` + # Guide + + [Other](/guide/other) + +Loading
+ + + `; + + await initSite({ + testURL, + config: { executeScript: true, routerMode, basePath: '/content/' }, + markdown: { homepage: home }, + routes: { + 'guide/page.md': markdown, + 'guide/other.md': markdown.replace('# Guide', '# Other'), + 'guide/dep.js': "export { value } from './nested.js';", + 'guide/nested.js': 'export const value = "nested";', + 'guide/side-effect.js': 'window.sideEffect = "side";', + 'guide/more.js': 'export const more = true;', + 'shared.js': 'export const shared = true;', + 'dynamic.js': 'export const dynamic = "dynamic";', + '/root.js': 'export const root = "root";', + }, + styleURLs: ['/dist/themes/core.css'], + }); + + const result = page.locator('#result'); + const expected = "nested|side|root|dynamic|import './untouched.js'|"; + await page.getByRole('link', { name: 'Guide', exact: true }).click(); + await expect(result).toHaveText(`${expected}1`); + await expect(result).toBeInViewport(); + await page.getByRole('link', { name: 'Other', exact: true }).click(); + await expect(result).toHaveText(`${expected}2`); + await page.goBack(); + await expect(result).toHaveText(`${expected}3`); + expect(errors).toEqual([]); + }); + } + + for (const { name, config, files } of [ + { + name: 'alias and custom extension', + config: { ext: '.txt', alias: { '/guide/page': '/actual/page' } }, + files: { + 'actual/page.txt': guide, + 'actual/dep.js': 'export const value = "OK";', + }, + }, + { + name: 'remote alias', + config: { + alias: { '/guide/page': 'https://modules.example/actual/page.md' }, + }, + files: { + 'https://modules.example/actual/page.md': { + body: guide, + headers: { 'Access-Control-Allow-Origin': '*' }, + }, + 'https://modules.example/actual/dep.js': { + body: 'export const value = "OK";', + headers: { 'Access-Control-Allow-Origin': '*' }, + }, + }, + }, + { + name: 'virtual route', + config: { routes: { '/guide/page': guide } }, + files: { 'guide/dep.js': 'export const value = "OK";' }, + }, + { + name: 'language fallback', + config: { + fallbackLanguages: ['guide'], + fallbackDefaultLanguage: '/en', + }, + files: { + 'guide/page.md': { status: 404, body: 'Missing' }, + 'en/page.md': guide, + 'en/dep.js': 'export const value = "OK";', + }, + }, + { + name: 'custom 404 page', + config: { notFoundPage: 'errors/not-found.md' }, + files: { + 'guide/page.md': { status: 404, body: 'Missing' }, + 'errors/not-found.md': guide, + 'errors/dep.js': 'export const value = "OK";', + }, + }, + ]) { + test(`uses the content URL for ${name}`, async ({ page }) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await mockFiles(page, files); + await initSite({ + testURL, + config: { + executeScript: true, + routerMode, + basePath: '/content/', + ...config, + }, + markdown: { homepage: home }, + }); + + await page.getByRole('link', { name: 'Guide', exact: true }).click(); + await expect(page.locator('#result')).toHaveText('OK'); + expect(errors).toEqual([]); + }); + } + + test('uses a nested homepage URL', async ({ page }) => { + await initSite({ + testURL, + config: { + executeScript: true, + routerMode, + basePath: '/content/', + homepage: 'start/home.md', + }, + routes: { + 'start/home.md': guide, + 'start/dep.js': 'export const value = "Homepage";', + }, + }); + await expect(page.locator('#result')).toHaveText('Homepage'); + }); + + test('uses the response URL after a redirect, including cached navigation', async ({ + page, + }) => { + // A real redirect verifies XMLHttpRequest.responseURL end to end, + // including WebKit, which cannot fulfill a mocked redirect response. + const server = await startRedirectServer(); + try { + const origin = `http://127.0.0.1:${server.address().port}`; + await initSite({ + testURL: `${origin}/module-host.html`, + config: { + executeScript: true, + routerMode, + basePath: `${origin}/content/`, + alias: { '/guide/page': `${origin}/redirect.md` }, + }, + markdown: { homepage: home }, + }); + await page.getByRole('link', { name: 'Guide', exact: true }).click(); + await expect(page.locator('#result')).toHaveText('Redirected'); + await page.goBack(); + await page.getByRole('link', { name: 'Guide', exact: true }).click(); + await expect(page.locator('#result')).toHaveText('Redirected'); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } + }); + + test('keeps navigation working after a module parse error', async ({ + page, + }) => { + const error = page.waitForEvent('pageerror'); + await initSite({ + testURL, + config: { executeScript: true, routerMode }, + markdown: { + homepage: `${home}\n\n`, + }, + routes: { 'guide/page.md': '# Next page' }, + }); + expect((await error).message).toBeTruthy(); + await page.getByRole('link', { name: 'Guide', exact: true }).click(); + await expect(page.locator('h1')).toHaveText('Next page'); + }); + }); +} + +test('resolves hash routes hosted at an explicit HTML file', async ({ + page, +}) => { + await page.addInitScript(() => { + history.replaceState(null, '', location.pathname + '#/'); + }); + await docsifyInit({ + testURL: `${process.env.TEST_HOST}/app/index.html`, + config: { executeScript: true, basePath: '' }, + routes: { + '/app/README.md': home, + '/app/guide/page.md': guide, + '/app/guide/dep.js': 'export const value = "Index file";', + }, + }); + await page.getByRole('link', { name: 'Guide', exact: true }).click(); + await expect(page.locator('#result')).toHaveText('Index file'); +}); diff --git a/test/manual/execute-script/README.md b/test/manual/execute-script/README.md new file mode 100644 index 000000000..cc0432b87 --- /dev/null +++ b/test/manual/execute-script/README.md @@ -0,0 +1,21 @@ +# Manual module import test + +Run these commands from the repository root: + +```sh +npm run build +node test/manual/execute-script/server.js +``` + +Open either mode: + +- Hash:Loading moduleā¦
+ + + +[Return home](../README.md) diff --git a/test/manual/execute-script/content/shared.js b/test/manual/execute-script/content/shared.js new file mode 100644 index 000000000..c3304af3b --- /dev/null +++ b/test/manual/execute-script/content/shared.js @@ -0,0 +1 @@ +export const message = 'Static and dynamic imports succeeded'; diff --git a/test/manual/execute-script/index.html b/test/manual/execute-script/index.html new file mode 100644 index 000000000..c72c9455f --- /dev/null +++ b/test/manual/execute-script/index.html @@ -0,0 +1,29 @@ + + + + + +