diff --git a/docs/configuration.md b/docs/configuration.md
index 5ad0b8958..9c678e3d2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,11 +83,30 @@ window.$docsify = {
};
```
+## absoluteBasePath
+
+- Type: `String|null`
+- Default: `null` (domain root `/`)
+
+Overrides [`basePath`](#basepath) when resolving paths that begin with `/`.
+By default, absolute links and resources are resolved from the domain root,
+including when Docsify is served from a nested directory.
+
+```js
+window.$docsify = {
+ absoluteBasePath: '/docs/',
+};
+```
+
## basePath
-- Type: `String`
+- Type: `String|null`
+- Default: `null`
-Base path of the website. You can set it to another directory or another domain name.
+Sets the source path for the website and the common base for relative and
+absolute resources. [`relativeBasePath`](#relativebasepath) and
+[`absoluteBasePath`](#absolutebasepath) take precedence when set. You can set
+it to another directory or another domain name.
```js
window.$docsify = {
@@ -102,6 +121,18 @@ window.$docsify = {
};
```
+Individual markdown links, images, and embedded resources can override the
+configured base with the `:basepath` attribute:
+
+```markdown
+[Guide](guide.md ':basepath=/shared/')
+
+[Example](example.js ':include :basepath=/examples/')
+```
+
+An invalid base-path value is ignored and the resource falls back to its
+default path, allowing the rest of the page to continue rendering.
+
## catchPluginErrors
- Type: `Boolean`
@@ -719,12 +750,29 @@ window.$docsify = {
See [Plugins](./plugins.md).
+## relativeBasePath
+
+- Type: `String|null`
+- Default: `null` (directory of the current page)
+
+Overrides [`basePath`](#basepath) when resolving relative links, images, and
+embedded resources. Without this option, relative paths follow standard web
+behavior and are resolved from the page where they are declared.
+
+```js
+window.$docsify = {
+ relativeBasePath: '/shared/',
+};
+```
+
## relativePath
- Type: `Boolean`
-- Default: `false`
+- Default: `true`
-If **true**, links are relative to the current context.
+If **true**, links are relative to the page where they are declared. Set this
+to `false` only to preserve the legacy behavior that resolves relative links
+from the Docsify index route.
For example, the directory structure is as follows:
@@ -751,10 +799,10 @@ config/example.md => http://domain.com/zh-cn/config/example
```js
window.$docsify = {
- // Relative path enabled
+ // Standard relative path behavior (default)
relativePath: true,
- // Relative path disabled (default value)
+ // Legacy index-relative behavior
relativePath: false,
};
```
diff --git a/src/core/config.js b/src/core/config.js
index 4df2e29f3..a7a46b4a0 100644
--- a/src/core/config.js
+++ b/src/core/config.js
@@ -9,7 +9,8 @@ const defaultDocsifyConfig = () => ({
alias: /** @type {Record} */ ({}),
auto2top: false,
autoHeader: false,
- basePath: '',
+ absoluteBasePath: /** @type {null | string} */ (null),
+ basePath: /** @type {null | string} */ (null),
catchPluginErrors: true,
collapseSidebarGroups: false,
cornerExternalLinkTarget:
@@ -51,7 +52,8 @@ const defaultDocsifyConfig = () => ({
),
onlyCover: false,
plugins: /** @type {Plugin[]} */ ([]),
- relativePath: false,
+ relativeBasePath: /** @type {null | string} */ (null),
+ relativePath: true,
repo: /** @type {string} */ (''),
requestHeaders: /** @type {Record} */ ({}),
routerMode: /** @type {'hash' | 'history'} */ 'hash',
diff --git a/src/core/render/compiler.js b/src/core/render/compiler.js
index e81aaa81f..e0afc4477 100644
--- a/src/core/render/compiler.js
+++ b/src/core/render/compiler.js
@@ -1,5 +1,4 @@
import { marked } from 'marked';
-import { isAbsolutePath, getPath, getParentPath } from '../router/util.js';
import { isFn, cached, isPrimitive } from '../util/core.js';
import { tree as treeTpl } from './tpl.js';
import { genTree } from './gen-tree.js';
@@ -16,6 +15,7 @@ import { taskListItemCompiler } from './compiler/taskListItem.js';
import { linkCompiler } from './compiler/link.js';
import { compileMedia } from './compiler/media.js';
import { tableCellCompiler } from './compiler/tableCell.js';
+import { resolveResourcePath } from './path.js';
const cachedLinks = {};
@@ -105,13 +105,12 @@ export class Compiler {
title = str;
if (config.include) {
- if (!isAbsolutePath(href)) {
- href = getPath(
- this.contentBase,
- getParentPath(this.router.getCurrentPath()),
- href,
- );
- }
+ href = resolveResourcePath(href, {
+ config: this.config,
+ contentBase: this.contentBase,
+ currentPath: this.router.getCurrentPath(),
+ elementBasePath: config.basepath,
+ });
let media;
const mediaType = Array.isArray(config.type)
@@ -183,7 +182,12 @@ export class Compiler {
compiler: this,
});
origin.paragraph = paragraphCompiler({ renderer });
- origin.image = imageCompiler({ renderer, contentBase, router });
+ origin.image = imageCompiler({
+ renderer,
+ contentBase,
+ router,
+ compiler: this,
+ });
origin.list = taskListCompiler({ renderer });
origin.listitem = taskListItemCompiler({ renderer });
origin.tablecell = tableCellCompiler({ renderer });
diff --git a/src/core/render/compiler/image.js b/src/core/render/compiler/image.js
index 68bb478b4..8734a9bc3 100644
--- a/src/core/render/compiler/image.js
+++ b/src/core/render/compiler/image.js
@@ -1,7 +1,7 @@
import { escapeHtml, getAndRemoveConfig } from '../utils.js';
-import { isAbsolutePath, getPath, getParentPath } from '../../router/util.js';
+import { resolveResourcePath } from '../path.js';
-export const imageCompiler = ({ renderer, contentBase, router }) =>
+export const imageCompiler = ({ renderer, contentBase, router, compiler }) =>
(renderer.image = ({ href, title, text }) => {
let url = href;
const attrs = [];
@@ -38,9 +38,12 @@ export const imageCompiler = ({ renderer, contentBase, router }) =>
attrs.push(`id="${config.id}"`);
}
- if (!isAbsolutePath(href)) {
- url = getPath(contentBase, getParentPath(router.getCurrentPath()), href);
- }
+ url = resolveResourcePath(href, {
+ config: compiler.config,
+ contentBase,
+ currentPath: router.getCurrentPath(),
+ elementBasePath: config.basepath,
+ });
return /* html */ `
} options.config
+ * @param {string} options.contentBase
+ * @param {string} options.currentPath
+ * @param {string | string[] | undefined} options.elementBasePath
+ * @returns {string}
+ */
+export function resolveResourcePath(
+ href,
+ { config, contentBase, currentPath, elementBasePath },
+) {
+ if (isAbsolutePath(href)) {
+ return href;
+ }
+
+ const configuredBasePath = getConfiguredBasePath(
+ href,
+ config,
+ elementBasePath,
+ );
+ const configuredPath =
+ configuredBasePath && resolvePathFromBase(href, configuredBasePath);
+
+ if (configuredPath) {
+ return configuredPath;
+ }
+
+ if (href.startsWith('/')) {
+ return href;
+ }
+
+ const pageBase = getPath(contentBase, getParentPath(currentPath));
+
+ return resolvePathFromBase(href, pageBase) || href;
+}
+
+/**
+ * Resolve a document link. The boolean indicates that the link is rooted at
+ * the domain hierarchy rather than the current Docsify index route.
+ *
+ * @param {string} href
+ * @param {object} options
+ * @param {Record} options.config
+ * @param {string | string[] | undefined} options.elementBasePath
+ * @returns {{path: string, rooted: boolean}}
+ */
+export function resolveDocumentPath(href, { config, elementBasePath }) {
+ const configuredBasePath = getConfiguredBasePath(
+ href,
+ config,
+ elementBasePath,
+ true,
+ );
+ const configuredPath =
+ configuredBasePath && resolvePathFromBase(href, configuredBasePath);
+
+ if (configuredPath) {
+ return { path: configuredPath, rooted: true };
+ }
+
+ return { path: href, rooted: href.startsWith('/') };
+}
diff --git a/src/core/render/utils.js b/src/core/render/utils.js
index 2fd98e276..f84bd56d5 100644
--- a/src/core/render/utils.js
+++ b/src/core/render/utils.js
@@ -26,7 +26,7 @@ export function getAndRemoveConfig(str = '') {
str = str
.replace(/^('|")/, '')
.replace(/('|")$/, '')
- .replace(/(?:^|\s):([\w-]+:?)=?([\w-%]+)?/g, (m, key, value) => {
+ .replace(/(?:^|\s):([\w-]+:?)=?([^\s'"]+)?/g, (m, key, value) => {
if (key.indexOf(':') !== -1) {
return m;
}
diff --git a/src/core/router/history/base.js b/src/core/router/history/base.js
index 22f2276f4..f7f15cca9 100644
--- a/src/core/router/history/base.js
+++ b/src/core/router/history/base.js
@@ -48,7 +48,7 @@ export class History {
}
getBasePath() {
- return this.config.basePath;
+ return this.config.basePath || '';
}
/**
@@ -63,7 +63,15 @@ export class History {
path = config.alias ? this.#getAlias(path, config.alias) : path;
path = this.#getFileName(path, ext);
path = path === `/README${ext}` ? config.homepage || path : path;
- path = isAbsolutePath(path) ? path : getPath(base, path);
+
+ const normalizedBase = base && cleanPath(`/${base.replace(/^\/+/, '')}/`);
+ const isWithinLocalBase =
+ normalizedBase &&
+ !isAbsolutePath(base) &&
+ cleanPath(`/${path.replace(/^\/+/, '')}`).startsWith(normalizedBase);
+
+ path =
+ isAbsolutePath(path) || isWithinLocalBase ? path : getPath(base, path);
if (isRelative) {
path = path.replace(new RegExp(`^${base}`), '');
diff --git a/src/core/router/history/hash.js b/src/core/router/history/hash.js
index 8bd091d53..aae80b5f3 100644
--- a/src/core/router/history/hash.js
+++ b/src/core/router/history/hash.js
@@ -13,7 +13,7 @@ export class HashHistory extends History {
getBasePath() {
const path = window.location.pathname || '';
- const base = this.config.basePath;
+ const base = this.config.basePath || '';
// This handles the case where Docsify is served off an
// explicit file path, i.e.`/base/index.html#/blah`. This
diff --git a/test/integration/__snapshots__/docs.test.js.snap b/test/integration/__snapshots__/docs.test.js.snap
index 85027adb9..3794218d1 100644
--- a/test/integration/__snapshots__/docs.test.js.snap
+++ b/test/integration/__snapshots__/docs.test.js.snap
@@ -10,7 +10,7 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = `
"
`;
-exports[`Docs Site navbar renders and is unchanged 1`] = `""`;
+exports[`Docs Site navbar renders and is unchanged 1`] = `""`;
exports[`Docs Site sidebar renders and is unchanged 1`] = `
"
"
const output = window.marked("[alt text](/url ':target=_blank')");
expect(output).toMatchInlineSnapshot(
- '"alt text
"',
+ '"alt text
"',
);
});
diff --git a/test/unit/render-path.test.js b/test/unit/render-path.test.js
new file mode 100644
index 000000000..517bb92f3
--- /dev/null
+++ b/test/unit/render-path.test.js
@@ -0,0 +1,124 @@
+import {
+ resolveDocumentPath,
+ resolvePathFromBase,
+ resolveResourcePath,
+} from '../../src/core/render/path.js';
+
+describe('render/path', () => {
+ describe('resolvePathFromBase', () => {
+ test.each([
+ ['/dir/image.png', '/', '/dir/image.png'],
+ ['/dir/image.png', '/site/', '/site/dir/image.png'],
+ ['../image.png', '/site/dir/', '/site/image.png'],
+ [
+ 'image.png',
+ 'https://cdn.example.com/assets/',
+ 'https://cdn.example.com/assets/image.png',
+ ],
+ ])('resolves %s from %s', (path, basePath, expected) => {
+ expect(resolvePathFromBase(path, basePath)).toBe(expected);
+ });
+
+ test('returns null for an invalid base', () => {
+ expect(resolvePathFromBase('image.png', 'https://[')).toBeNull();
+ });
+ });
+
+ describe('resolveResourcePath', () => {
+ const defaults = {
+ config: {},
+ contentBase: '/site/',
+ currentPath: '/dir/page',
+ };
+
+ test.each([
+ ['/image.png', '/image.png'],
+ ['/dir/image.png', '/dir/image.png'],
+ ['image.png', '/site/dir/image.png'],
+ ['./image.png', '/site/dir/image.png'],
+ ['../image.png', '/site/image.png'],
+ ['../dir/image.png', '/site/dir/image.png'],
+ ])('resolves %s using standard web behavior', (href, expected) => {
+ expect(resolveResourcePath(href, defaults)).toBe(expected);
+ });
+
+ test('uses resource-specific base paths before basePath', () => {
+ expect(
+ resolveResourcePath('/image.png', {
+ ...defaults,
+ config: {
+ absoluteBasePath: '/assets/',
+ basePath: '/ignored/',
+ },
+ }),
+ ).toBe('/assets/image.png');
+
+ expect(
+ resolveResourcePath('image.png', {
+ ...defaults,
+ config: {
+ relativeBasePath: '/assets/',
+ basePath: '/ignored/',
+ },
+ }),
+ ).toBe('/assets/image.png');
+ });
+
+ test('uses the element base path and falls back when it is invalid', () => {
+ expect(
+ resolveResourcePath('image.png', {
+ ...defaults,
+ elementBasePath: '/assets/',
+ }),
+ ).toBe('/assets/image.png');
+
+ expect(
+ resolveResourcePath('image.png', {
+ ...defaults,
+ elementBasePath: true,
+ }),
+ ).toBe('/site/dir/image.png');
+ });
+ });
+
+ describe('resolveDocumentPath', () => {
+ test('preserves default relative and absolute paths', () => {
+ expect(resolveDocumentPath('guide.md', { config: {} })).toEqual({
+ path: 'guide.md',
+ rooted: false,
+ });
+ expect(resolveDocumentPath('/guide.md', { config: {} })).toEqual({
+ path: '/guide.md',
+ rooted: true,
+ });
+ });
+
+ test('supports a per-element base path', () => {
+ expect(
+ resolveDocumentPath('guide.md', {
+ config: {},
+ elementBasePath: '/shared/',
+ }),
+ ).toEqual({ path: '/shared/guide.md', rooted: true });
+ });
+
+ test('keeps external basePath links as SPA routes', () => {
+ expect(
+ resolveDocumentPath('guide.md', {
+ config: { basePath: 'https://cdn.example.com/docs/' },
+ }),
+ ).toEqual({ path: 'guide.md', rooted: false });
+ });
+
+ test('allows resource-specific external bases', () => {
+ expect(
+ resolveDocumentPath('guide.md', {
+ config: { relativeBasePath: 'https://docs.example.com/' },
+ }),
+ ).toEqual({
+ path: 'https://docs.example.com/guide.md',
+ rooted: true,
+ });
+ });
+ });
+});
diff --git a/test/unit/render-util.test.js b/test/unit/render-util.test.js
index 77cb0c0fd..d7e1967b8 100644
--- a/test/unit/render-util.test.js
+++ b/test/unit/render-util.test.js
@@ -62,6 +62,20 @@ describe('core/render/utils', () => {
// getAndRemoveConfig()
// ---------------------------------------------------------------------------
describe('getAndRemoveConfig()', () => {
+ test('parses URL paths in config values', () => {
+ const result = getAndRemoveConfig(
+ 'image title :basepath=/assets/images/ :class=thumbnail',
+ );
+
+ expect(result).toEqual({
+ str: 'image title',
+ config: {
+ basepath: '/assets/images/',
+ class: 'thumbnail',
+ },
+ });
+ });
+
test('parse simple config', () => {
const result = getAndRemoveConfig(
"[filename](_media/example.md ':include')",
diff --git a/test/unit/router-history-base.test.js b/test/unit/router-history-base.test.js
index 58ff08246..653169f99 100644
--- a/test/unit/router-history-base.test.js
+++ b/test/unit/router-history-base.test.js
@@ -1,6 +1,8 @@
import { History } from '../../src/core/router/history/base.js';
class MockHistory extends History {
+ mode = 'hash';
+
parse(path) {
return { path };
}
@@ -67,6 +69,21 @@ describe('router/history/base', () => {
});
});
+ describe('default path behavior', () => {
+ beforeEach(() => {
+ history = new MockHistory({ relativePath: true });
+ });
+
+ test('resolves relative links from the current page', () => {
+ expect(history.toURL('guide.md', {}, '/dir/page')).toBe('/dir/guide');
+ expect(history.toURL('../guide.md', {}, '/dir/page')).toBe('/guide');
+ });
+
+ test('keeps absolute links rooted', () => {
+ expect(history.toURL('/guide.md', {}, '/dir/page')).toBe('/guide');
+ });
+ });
+
// getFile test
// ---------------------------------------------------------------------------
describe('getFile', () => {
@@ -96,5 +113,11 @@ describe('router/history/base', () => {
expect(file).toBe('https://some/raw/url/README.md.ext');
});
+
+ test('does not duplicate a configured local base path', () => {
+ history = new MockHistory({ basePath: '/docs/' });
+
+ expect(history.getFile('/docs/guide')).toBe('/docs/guide.md');
+ });
});
});