fix(router): standardize link and resource path resolution

- resolve relative paths from the current Markdown page
- resolve absolute paths from the domain root
- add configurable and per-element base path overrides
- prevent duplicate base paths and preserve sidebar activation
- add regression tests and configuration documentation

Fixes #1891
This commit is contained in:
Luffy
2026-08-25 12:51:42 +08:00
parent 50992940f3
commit f852d61613
15 changed files with 451 additions and 31 deletions
+54 -6
View File
@@ -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/')
![Logo](logo.png ':basepath=/assets/')
[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,
};
```
+4 -2
View File
@@ -9,7 +9,8 @@ const defaultDocsifyConfig = () => ({
alias: /** @type {Record<string, string>} */ ({}),
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<string, string>} */ ({}),
routerMode: /** @type {'hash' | 'history'} */ 'hash',
+13 -9
View File
@@ -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 });
+8 -5
View File
@@ -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 */ `<img src="${escapeHtml(url)}" data-origin="${escapeHtml(href)}" alt="${escapeHtml(text)}" ${attrs.join(
' ',
+20 -2
View File
@@ -1,5 +1,6 @@
import { escapeHtml, getAndRemoveConfig } from '../utils.js';
import { isAbsolutePath } from '../../router/util.js';
import { resolveDocumentPath } from '../path.js';
export const linkCompiler = ({
renderer,
@@ -27,9 +28,26 @@ export const linkCompiler = ({
if (href === compiler.config.homepage) {
href = 'README';
}
href = router.toURL(href, null, router.getCurrentPath());
const resolved = resolveDocumentPath(href, {
config: compiler.config,
elementBasePath: config.basepath,
});
if (config.target && !isMailto) {
if (isAbsolutePath(resolved.path)) {
href = resolved.path;
attrs.push(`target="${linkTarget}"`);
if (linkRel !== '') {
attrs.push(`rel="${linkRel}"`);
}
} else {
href = router.toURL(resolved.path, null, router.getCurrentPath());
if (resolved.rooted && router.mode === 'hash') {
href = `/${href}`;
}
}
if (config.target && !isMailto && !attrs.length) {
attrs.push(`target="${linkTarget}"`);
}
} else {
+3 -1
View File
@@ -336,7 +336,9 @@ export function Render(Base) {
this.router.toURL(this.route.path),
);
const activeEl = /** @type {HTMLElement | null} */ (
dom.find(`.sidebar-nav a[href="${activeElmHref}"]`)
dom.find(`.sidebar-nav a[href="${activeElmHref}"]`) ||
(this.router.mode === 'hash' &&
dom.find(`.sidebar-nav a[href="/${activeElmHref}"]`))
);
this.#addTextAsTitleAttribute('.sidebar-nav a');
+126
View File
@@ -0,0 +1,126 @@
import { getParentPath, getPath, isAbsolutePath } from '../router/util.js';
const placeholderOrigin = 'https://docsify.invalid';
function getString(value) {
return typeof value === 'string' && value ? value : null;
}
function getConfiguredBasePath(
href,
config,
elementBasePath,
ignoreExternalBasePath = false,
) {
const elementPath = getString(elementBasePath);
const resourcePath = getString(
href.startsWith('/') ? config.absoluteBasePath : config.relativeBasePath,
);
const sharedPath = getString(config.basePath);
const configuredBasePath = elementPath || resourcePath || sharedPath;
// An external basePath identifies where Docsify fetches markdown files. A
// document link must remain an SPA route so the router can perform that
// fetch, while images and includes can point at the source URL directly.
return ignoreExternalBasePath &&
!elementPath &&
!resourcePath &&
isAbsolutePath(sharedPath)
? null
: configuredBasePath;
}
/**
* Resolve a path against an explicit base while preserving URL origins.
* Invalid bases return null so callers can safely use the default behavior.
*
* @param {string} path
* @param {string} basePath
* @returns {string | null}
*/
export function resolvePathFromBase(path, basePath) {
try {
const protocolRelative = basePath.startsWith('//');
const absolute = isAbsolutePath(basePath);
const base = new URL(
basePath.endsWith('/') ? basePath : `${basePath}/`,
placeholderOrigin,
);
const url = new URL(path.replace(/^\/+/, ''), base);
if (protocolRelative) {
return url.href.replace(url.protocol, '');
}
return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
/**
* Resolve image and embedded-resource paths using standard web path rules.
*
* @param {string} href
* @param {object} options
* @param {Record<string, any>} 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<string, any>} 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('/') };
}
+1 -1
View File
@@ -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;
}
+10 -2
View File
@@ -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}`), '');
+1 -1
View File
@@ -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
@@ -10,7 +10,7 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = `
</section>"
`;
exports[`Docs Site navbar renders and is unchanged 1`] = `"<nav class="app-nav" aria-label="secondary"><!-- markdownlint-disable first-line-h1 --><ul><li><p>Translations</p><ul><li><a href="#/">English</a></li><li><a href="#/zh-cn/">简体中文</a></li></ul></li></ul></nav>"`;
exports[`Docs Site navbar renders and is unchanged 1`] = `"<nav class="app-nav" aria-label="secondary"><!-- markdownlint-disable first-line-h1 --><ul><li><p>Translations</p><ul><li><a href="/#/">English</a></li><li><a href="/#/zh-cn/">简体中文</a></li></ul></li></ul></nav>"`;
exports[`Docs Site sidebar renders and is unchanged 1`] = `
"<aside id="__sidebar" class="sidebar show" tabindex="-1" role="none">
+49 -1
View File
@@ -5,6 +5,54 @@ import { waitForText } from '../helpers/wait-for.js';
// Suite
// -----------------------------------------------------------------------------
describe('render', function () {
describe('path handling', function () {
beforeEach(async () => {
await docsifyInit({
config: {
basePath: null,
routes: {
'/': '# Home',
},
},
testURL: `${process.env.TEST_HOST}/site/`,
});
window.history.replaceState(
{},
'',
`${process.env.TEST_HOST}/site/#/dir/page`,
);
});
test('uses standard relative and absolute paths for all resources', () => {
const output = window.marked(stripIndent`
[relative link](guide.md)
[absolute link](/guide.md)
![relative image](image.png)
![absolute image](/image.png)
`);
expect(output).toContain('href="#/dir/guide"');
expect(output).toContain('href="/#/guide"');
expect(output).toContain('src="/site/dir/image.png"');
expect(output).toContain('src="/image.png"');
});
test('supports per-element base paths', () => {
const output = window.marked(stripIndent`
[based link](guide.md ':basepath=/shared/')
![based image](image.png ':basepath=/assets/')
`);
expect(output).toContain('href="/#/shared/guide"');
expect(output).toContain('src="/assets/image.png"');
});
});
// Helpers
// ---------------------------------------------------------------------------
describe('callouts', () => {
@@ -356,7 +404,7 @@ Text</p></div>"
const output = window.marked("[alt text](/url ':target=_blank')");
expect(output).toMatchInlineSnapshot(
'"<p><a href="#/url" target="_blank">alt text</a></p>"',
'"<p><a href="/#/url" target="_blank">alt text</a></p>"',
);
});
+124
View File
@@ -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,
});
});
});
});
+14
View File
@@ -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')",
+23
View File
@@ -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');
});
});
});