Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,27 @@ window.$docsify = {
</script>
```

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
<script type="module">
import { message } from './example.js';

console.log(message);
</script>
```

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 `<script type="module" src="...">`.

## ext

Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"dependencies": {
"common-tags": "^1.8.0",
"dexie": "^4.0.11",
"es-module-lexer": "^3.0.4",
"marked": "^18.0.3",
"medium-zoom": "^1.1.0",
"opencollective-postinstall": "^2.0.2",
Expand Down
8 changes: 8 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ const baseConfig = {
resolve(),
commonjs(),
css(),
// Run the synchronous lexer as plain JS. asm.js is only an optimization
// hint and produces a deprecation warning in Firefox, even before use.
replace({
include: '**/es-module-lexer/dist/lexer.asm.js',
preventAssignment: true,
delimiters: ['', ''],
values: { '"use asm";': '' },
}),
replace({
preventAssignment: true,
values: {
Expand Down
45 changes: 40 additions & 5 deletions src/core/render/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isExternal, isPrimitive } from '../util/core.js';
import { Compiler } from './compiler.js';
import * as tpl from './tpl.js';
import { prerenderEmbed } from './embed.js';
import { resolveModuleSpecifiers } from './module-script.js';

/** @typedef {import('../Docsify.js').Constructor} Constructor */

Expand Down Expand Up @@ -66,7 +67,8 @@ export function Render(Base) {
});
}

#executeScript() {
/** @param {string} moduleBaseURL */
#executeScript(moduleBaseURL) {
const script = dom
.findAll('.markdown-section>script')
.filter(
Expand All @@ -81,7 +83,34 @@ export function Render(Base) {
return false;
}

new Function(code)();
if (script.getAttribute('type') === 'module') {
// External scripts are handled by the external-script plugin.
if (script.hasAttribute('src')) {
return false;
}

// Scripts inserted via innerHTML are inert. Create a fresh element
// so the browser can execute imports and exports as an ES module.
const moduleScript = document.createElement('script');
for (const { name, value } of script.attributes) {
moduleScript.setAttribute(name, value);
}
try {
moduleScript.textContent = resolveModuleSpecifiers(
code,
moduleBaseURL,
);
} catch (error) {
// Report invalid modules without interrupting document rendering.
queueMicrotask(() => {
throw error;
});
return false;
}
script.replaceWith(moduleScript);
} else {
new Function(code)();
}
}

#formatUpdated(html, updated, fn) {
Expand All @@ -95,7 +124,7 @@ export function Render(Base) {
return html.replace(/{docsify-updated}/g, updated);
}

#renderMain(html) {
#renderMain(html, moduleBaseURL) {
const docsifyConfig = this.config;
const markdownElm = dom.find('.markdown-section');
const vueVersion =
Expand Down Expand Up @@ -135,7 +164,7 @@ export function Render(Base) {
docsifyConfig.executeScript ||
('Vue' in window && docsifyConfig.executeScript !== false)
) {
this.#executeScript();
this.#executeScript(moduleBaseURL);
}

// Handle Vue content not mounted by markdown <script>
Expand Down Expand Up @@ -556,6 +585,12 @@ export function Render(Base) {

_renderMain(text, opt = {}, next) {
const { response } = this.route;
// Fetch metadata identifies redirects, fallback pages, and 404 pages.
// Virtual routes use their mapped file URL instead. Capture this before
// asynchronous hooks run, while the route still belongs to this content.
const moduleBaseURL =
opt.url ||
new URL(this.router.getFile(this.route.path), document.baseURI).href;

// Note: It is possible for the response to be undefined in environments
// where XMLHttpRequest has been modified or mocked
Expand All @@ -575,7 +610,7 @@ export function Render(Base) {
}

this.callHook('afterEach', html, hookData => {
this.#renderMain(hookData);
this.#renderMain(hookData, moduleBaseURL);
next();
});
};
Expand Down
38 changes: 38 additions & 0 deletions src/core/render/module-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { parse } from 'es-module-lexer/js';

/**
* Resolve relative module specifiers without changing strings, comments,
* import.meta, or computed dynamic imports. The JS lexer runs synchronously
* and does not require WebAssembly initialization or eval permissions.
*
* @param {string} code
* @param {string} baseURL URL of the Markdown content being rendered
* @returns {string}
*/
export function resolveModuleSpecifiers(code, baseURL) {
const [imports] = parse(code, baseURL);

// Work backwards so replacements do not invalidate the lexer's offsets.
for (let i = imports.length - 1; i >= 0; i--) {
const entry = imports[i];
const specifier = entry.specifier;
const dynamic = entry.type === 'dynamic';

if (
!specifier ||
!/^(?:\.\/|\.\.\/)/.test(specifier) ||
(dynamic && (entry.glob || !['"', "'"].includes(code[entry.start])))
) {
continue;
}

// Static offsets omit quotes; dynamic offsets include the whole literal.
// Replace both quotes too, to safely encode URLs containing apostrophes.
const start = dynamic ? entry.start : entry.start - 1;
const end = dynamic ? entry.end : entry.end + 1;
const url = new URL(specifier, baseURL).href;
code = code.slice(0, start) + JSON.stringify(url) + code.slice(end);
}

return code;
}
4 changes: 3 additions & 1 deletion src/core/util/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import progressbar from '../render/progressbar.js';
import { noop } from './core.js';

/** @typedef {{updatedAt: string}} CacheOpt */
/** @typedef {{updatedAt: string, url: string}} CacheOpt */
/** @typedef {{content: string, opt: CacheOpt, response: ResponseStatus}} CacheItem */
/** @typedef {{ok: boolean, status: number, statusText: string}} ResponseStatus */
/** @type {Record<string, CacheItem>} */
Expand Down Expand Up @@ -89,6 +89,8 @@ export function get(url, hasBar = false, headers = {}) {
content: target.response,
opt: {
updatedAt: xhr.getResponseHeader('last-modified') ?? '',
// Preserve the content location across redirects and cache hits.
url: xhr.responseURL || new URL(url, document.baseURI).href,
},
response: getResponseStatus(event),
});
Expand Down
Loading