Skip to content

feat: support inline ES modules with executeScript - #2808

Open
sy-records wants to merge 1 commit into
docsifyjs:developfrom
sy-records:fix/2010
Open

sy-records wants to merge 1 commit into
docsifyjs:developfrom
sy-records:fix/2010

Conversation

@sy-records

Copy link
Copy Markdown
Member

Summary

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.

Related issue, if any:

Close #2010

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • Documentation content changes
  • Other (please describe):

For any code change,

  • Related documentation has been updated, if needed
  • Related tests have been added or updated, if needed

Does this PR introduce a breaking change?

  • Yes
  • No

Tested in the following browsers:

  • Chrome
  • Firefox
  • Safari
  • Edge

@vercel

vercel Bot commented Sep 25, 2026

Copy link
Copy Markdown

@sy-records is attempting to deploy a commit to the Docsify Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docsify-preview Ready Ready Preview Oct 28, 2026 2:27am UTC

@trusktr

trusktr commented Oct 28, 2026

Copy link
Copy Markdown
Member

@sy-records Copilot can't commit to the branch (because it's in your account) so here are the patches it would have applied to get module imports with relative paths to be relative to the markdown file so that it works the same in both hash and history router modes:

Below is the full change, ready to apply to the branch, and I haven't run any of it. I can also hand it to the Copilot coding agent on docsifyjs/docsify, but that would open a separate PR rather than update this one.

Behavior

  • History mode: the base URL is router.getFile(route.path), which already includes basePath, alias, ext and homepage.
  • Hash mode: the base URL is the native pathname joined with the hash route file, so ../ can climb past the hash into the native URL.
    • At /app/index.html#/guide/page the base is /app/index.html/guide/page.md.
    • ./a.js resolves to /app/index.html/guide/a.js.
    • ../../a.js resolves to /app/index.html/a.js.
    • ../../../a.js resolves to /app/a.js.
  • Untouched specifiers: absolute (/x.js, https:), bare (vue) and data: specifiers are not rewritten. They stay origin-relative or go through import maps.

src/core/render/module-script.js (new)

/**
 * Rewrites relative module specifiers (`./`, `../`) to absolute URLs resolved
 * against `baseURL`. Absolute, bare, and data: specifiers are left untouched.
 * Handles `from '…'`, `import '…'`, and `import('…')` with string literals.
 *
 * @param {string} code
 * @param {string} baseURL URL of the markdown file being rendered
 */
export function resolveModuleSpecifiers(code, baseURL) {
  return code.replace(
    /\b(from\s*|import\s*\(\s*|import\s*)(['"])(\.{1,2}\/[^'"\n]*)\2/g,
    (_, prefix, quote, specifier) =>
      `${prefix}${quote}${new URL(specifier, baseURL).href}${quote}`,
  );
}

/**
 * URL of the markdown file for the current route. In hash mode the hash path
 * is appended to the native path, so enough `../` segments climb past the
 * hash into the native URL.
 *
 * @param {any} router
 * @param {string} routePath
 */
export function getModuleBaseURL(router, routePath) {
  if (router.mode === 'hash') {
    const file = router.getFile(routePath, true); // e.g. /guide/page.md
    const native = location.pathname.replace(/\/+$/, '');
    return new URL(`${native}/${file}`.replace(/\/{2,}/g, '/'), location.origin)
      .href;
  }

  return new URL(router.getFile(routePath), location.href).href;
}

src/core/render/index.js

Add the import:

import { getModuleBaseURL, resolveModuleSpecifiers } from './module-script.js';

In #executeScript(), change the module branch to:

moduleScript.textContent = resolveModuleSpecifiers(
  code,
  getModuleBaseURL(this.router, this.route.path),
);

docs/configuration.md

Replace the "Relative imports resolve against the HTML document URL…" sentence with:

Relative imports (./ and ../) resolve against the URL of the Markdown file being rendered, in both hash and history router modes. In hash mode the hash path is appended to the page's native path, so enough ../ segments climb out of the hash route into the native URL. Absolute paths (/foo.js), URLs and bare specifiers are not rewritten. import.meta.url and non-literal import(expr) are not rewritten.

test/e2e/execute-script.test.js

Add this test inside the describe. It covers both modes plus the backtrack in hash mode.

for (const routerMode of ['hash', 'history']) {
  test(`resolves relative module imports against the markdown file (${routerMode})`, async ({
    page,
  }) => {
    const errors = [];
    page.on('pageerror', error => errors.push(error.message));

    await docsifyInit({
      config: { executeScript: true, routerMode },
      markdown: { homepage: '# Home' },
      routes: {
        'guide/page.md': `
          # Guide

          <p id="result">Loading</p>

          <script type="module">
            import { a } from './dep.js';
            import { b } from '../${routerMode === 'hash' ? '../' : ''}shared.js';
            import { c } from '/abs.js';
            document.querySelector('#result').textContent = [a, b, c].join(',');
          </script>
        `,
        // history: /guide/dep.js, /shared.js
        // hash: native /docsify-init.html/guide/dep.js, and ../../../ climbs past the hash page to /shared.js
        [routerMode === 'hash' ? 'docsify-init.html/guide/dep.js' : 'guide/dep.js']:
          'export const a = "a";',
        'shared.js': 'export const b = "b";',
        'abs.js': 'export const c = "c";',
      },
    });

    await page.evaluate(m => {
      location.hash = m === 'hash' ? '#/guide/page' : location.hash;
    }, routerMode);
    if (routerMode === 'history') {
      await page.evaluate(() => {
        history.pushState({}, '', '/guide/page');
        dispatchEvent(new PopStateEvent('popstate'));
      });
    }

    await expect(page.locator('#result')).toHaveText('a,b,c');
    expect(errors).toEqual([]);
  });
}

Notes on the test:

  • In hash mode, ../../shared.js from the /docsify-init.html/guide/page.md base resolves to /docsify-init.html/shared.js. The extra ../ in the template, giving ../../../shared.js, climbs past docsify-init.html to /shared.js.
  • In history mode, ../shared.js from /guide/page.md resolves to /shared.js.
  • I wrote the navigation setup and the route keys without running the helper, so they may need adjusting. Check the history-mode route and the pushState navigation against docsify-init.js, since it serves testURL at /docsify-init.html.

The existing test's ./module-value.js at the homepage still resolves to /module-value.js in both modes, so it should pass unchanged.

I'd also like to confirm that router.mode and router.getFile(path, true) give the file path you expect for alias and homepage in hash mode. I only checked that from reading the code.

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.
@sy-records

Copy link
Copy Markdown
Member Author

The regular expression you provided may convert to a plain string, and path concatenation using the hash pattern may also cause the basePath to be lost.

I'll come take a look at it.

This branch was successfully deployed

1 active (outdated) deployment
Preview — f32e64b1 Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

executeScript does not support ES modules

2 participants