From 0e4ec44c5a032225947368927045122d9545eec6 Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Wed, 5 Aug 2026 17:57:28 +0800 Subject: [PATCH] feat(navbar): add navbarPreservePath option to maintain current document path in language links --- docs/configuration.md | 15 +++++++++ src/core/config.js | 1 + src/core/render/index.js | 53 +++++++++++++++++++++++++++-- test/integration/docs.test.js | 63 +++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 23ca300b93..45433bd32f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -486,6 +486,21 @@ window.$docsify = { }; ``` +## navbarPreservePath + +- Type: `Boolean` +- Default: `false` + +If **true**, appends the current document path to navbar links that point to a language root. This makes it possible to switch languages while staying on the corresponding document. + +For example, when the current path is `/quickstart`, a navbar link to `/zh-cn/` becomes `/zh-cn/quickstart`. + +```js +window.$docsify = { + navbarPreservePath: true, +}; +``` + ## name - Type: `Boolean|String` diff --git a/src/core/config.js b/src/core/config.js index 2648a9824e..bafc3ea254 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -40,6 +40,7 @@ const defaultDocsifyConfig = () => ({ markdown: null, maxLevel: 6, mergeNavbar: false, + navbarPreservePath: false, name: /** @type {boolean | string} */ (''), nameLink: window.location.pathname, nativeEmoji: false, diff --git a/src/core/render/index.js b/src/core/render/index.js index f4db193754..e8fc3390f6 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -1,8 +1,8 @@ import tinydate from 'tinydate'; import * as dom from '../util/dom.js'; -import { getPath, isAbsolutePath } from '../router/util.js'; +import { cleanPath, getPath, isAbsolutePath } from '../router/util.js'; import { isMobile } from '../util/env.js'; -import { isPrimitive } from '../util/core.js'; +import { isExternal, isPrimitive } from '../util/core.js'; import { Compiler } from './compiler.js'; import * as tpl from './tpl.js'; import { prerenderEmbed } from './embed.js'; @@ -397,10 +397,59 @@ export function Render(Base) { ['.app-nav', '.app-nav-merged'].forEach(selector => { dom.setHTML(selector, html); + if (this.config.navbarPreservePath) { + this.#appendNavbarPath(selector); + } this.#addTextAsTitleAttribute(`${selector} a`); }); } + #appendNavbarPath(selector) { + const nav = dom.find(selector); + + if (!nav) { + return; + } + + const links = dom.findAll(nav, 'a').reduce((links, link) => { + const anchor = /** @type {HTMLAnchorElement} */ (link); + const href = anchor.getAttribute('href'); + + if ( + !href || + isExternal(anchor.href) || + (href.startsWith('#') && !href.startsWith('#/')) + ) { + return links; + } + + const route = this.router.parse(href); + const path = cleanPath(`/${route.path}`); + + if (route.query.id || (path !== '/' && !path.endsWith('/'))) { + return links; + } + + links.push({ link: anchor, path, query: route.query }); + return links; + }, /** @type {{link: HTMLAnchorElement, path: string, query: Record}[]} */ ([])); + + const currentPath = cleanPath(`/${this.route.path}`); + const currentRoot = links + .filter(({ path }) => currentPath.startsWith(path)) + .sort((a, b) => b.path.length - a.path.length)[0]; + + if (!currentRoot) { + return; + } + + const suffix = currentPath.slice(currentRoot.path.length); + + links.forEach(({ link, path, query }) => { + link.setAttribute('href', this.router.toURL(`${path}${suffix}`, query)); + }); + } + _renderMain(text, opt = {}, next) { const { response } = this.route; diff --git a/test/integration/docs.test.js b/test/integration/docs.test.js index c4528addc5..af69c33244 100644 --- a/test/integration/docs.test.js +++ b/test/integration/docs.test.js @@ -64,4 +64,67 @@ describe('Docs Site', function () { expect(navbarElm).not.toBeNull(); expect(navbarElm.outerHTML).toMatchSnapshot(); }); + + test('navbar appends the current path to language links when enabled', async () => { + await docsifyInit({ + config: { + loadNavbar: '_navbar.md', + navbarPreservePath: true, + }, + markdown: { + homepage: '# Hello World', + navbar: ` + - [English](/en-us/) + - [简体中文](/zh-cn/) + - [Guide](/guide) + - [Anchor](#section) + - [External](https://example.com/) + `, + }, + testURL: `${process.env.TEST_HOST}/docsify-init.html#/en-us/guide/start`, + waitForSelector: '.app-nav > ul', + }); + + const links = Object.fromEntries( + [...document.querySelectorAll('.app-nav a')].map(link => [ + link.textContent, + link.getAttribute('href'), + ]), + ); + + expect(links).toEqual({ + English: '#/en-us/guide/start', + 简体中文: '#/zh-cn/guide/start', + Guide: '#/guide', + Anchor: '#/en-us/guide/start?id=section', + External: 'https://example.com/', + }); + }); + + test('navbar appends the current path in history mode and merged navbars', async () => { + await docsifyInit({ + config: { + loadNavbar: '_navbar.md', + mergeNavbar: true, + navbarPreservePath: true, + routerMode: 'history', + }, + markdown: { + homepage: '# Hello World', + navbar: ` + - [English](/en-us/) + - [简体中文](/zh-cn/) + `, + }, + testURL: `${process.env.TEST_HOST}/en-us/guide/start`, + waitForSelector: '.app-nav-merged > ul', + }); + + document.querySelectorAll('.app-nav, .app-nav-merged').forEach(nav => { + expect([...nav.querySelectorAll('a')].map(link => link.href)).toEqual([ + `${process.env.TEST_HOST}/en-us/guide/start`, + `${process.env.TEST_HOST}/zh-cn/guide/start`, + ]); + }); + }); });