From 24a991b016ecb63aa094b7f9a97f3e678c3febdb Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Fri, 11 Sep 2026 15:06:04 +0800 Subject: [PATCH] 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'); + }); +});