From 7152439659dada8c0241a2c09be73bed0b8a537b Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Fri, 11 Sep 2026 15:06:04 +0800 Subject: [PATCH 1/2] fix: support inline ES modules with executeScript Execute inline module scripts through a new script element so the browser can handle static imports, exports, and top-level await. Preserve classic script execution and first-script selection behavior. Keep external modules handled by the external-script plugin. Add browser regression tests and a manual demo. Document asynchronous execution and relative import resolution. --- docs/configuration.md | 14 +- src/core/render/index.js | 18 ++- test/e2e/execute-script.test.js | 246 ++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 test/e2e/execute-script.test.js diff --git a/docs/configuration.md b/docs/configuration.md index 16d1fded4..7d48d40a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -227,7 +227,19 @@ 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. Relative imports resolve against the HTML document URL, rather than the Markdown file URL. Only the first non-template script is considered for execution, whether it is a classic script or a module. + +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'); + }); +}); From 6a9ebecda6ba884f4c927b3896a12ffc61a07ebc Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Wed, 28 Oct 2026 16:29:18 +0800 Subject: [PATCH 2/2] feat(module): implement relative module imports for Markdown content --- docs/configuration.md | 10 +- package-lock.json | 7 + package.json | 1 + rollup.config.js | 8 + src/core/render/index.js | 29 +- src/core/render/module-script.js | 38 +++ src/core/util/ajax.js | 4 +- test/e2e/module-paths.test.js | 291 ++++++++++++++++++ test/manual/execute-script/README.md | 21 ++ test/manual/execute-script/content/README.md | 5 + .../content/guide/demo-module.js | 6 + .../execute-script/content/guide/page.md | 12 + test/manual/execute-script/content/shared.js | 1 + test/manual/execute-script/index.html | 29 ++ test/manual/execute-script/server.js | 26 ++ test/unit/module-script.test.js | 77 +++++ tsconfig.json | 5 + 17 files changed, 563 insertions(+), 7 deletions(-) create mode 100644 src/core/render/module-script.js create mode 100644 test/e2e/module-paths.test.js create mode 100644 test/manual/execute-script/README.md create mode 100644 test/manual/execute-script/content/README.md create mode 100644 test/manual/execute-script/content/guide/demo-module.js create mode 100644 test/manual/execute-script/content/guide/page.md create mode 100644 test/manual/execute-script/content/shared.js create mode 100644 test/manual/execute-script/index.html create mode 100644 test/manual/execute-script/server.js create mode 100644 test/unit/module-script.test.js diff --git a/docs/configuration.md b/docs/configuration.md index 7d48d40a5..b76c9db1a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -237,7 +237,15 @@ Inline ES modules are supported with `type="module"`: ``` -Modules execute asynchronously, and Docsify does not wait for them before running its lifecycle hooks. Relative imports resolve against the HTML document URL, rather than the Markdown file URL. Only the first non-template script is considered for execution, whether it is a classic script or a module. +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 ` +`; + +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 @@ + + + + + +