} */ ({}),
diff --git a/src/core/event/index.js b/src/core/event/index.js
index 8395761430..d61aa3210f 100644
--- a/src/core/event/index.js
+++ b/src/core/event/index.js
@@ -1,7 +1,11 @@
import { isMobile, mobileBreakpoint } from '../util/env.js';
import { noop } from '../util/core.js';
import * as dom from '../util/dom.js';
-import { stripUrlExceptId } from '../router/util.js';
+import {
+ findLinkByHref,
+ resolveHref,
+ stripUrlExceptId,
+} from '../router/util.js';
/** @typedef {import('../Docsify.js').Constructor} Constructor */
@@ -252,6 +256,17 @@ export function Events(Base) {
// Collapse toggle
dom.on(sidebarElm, 'click', (/** @type {MouseEvent} */ { target }) => {
+ const groupToggle = /** @type {HTMLElement | null} */ (
+ /** @type {HTMLElement} */ (target).closest(
+ '.group-toggle[role="button"]',
+ )
+ );
+
+ if (groupToggle) {
+ this.#toggleSidebarGroup(groupToggle);
+ return;
+ }
+
const linkElm = /** @type {HTMLElement} */ (target).closest('a');
const linkParent = /** @type {HTMLLIElement} */ (
linkElm?.closest('li')
@@ -259,11 +274,44 @@ export function Events(Base) {
const hasSubSidebar = linkParent?.querySelector('.app-sub-sidebar');
if (hasSubSidebar) {
- linkParent.classList.toggle('collapse');
+ const isCollapsed = linkParent.classList.toggle('collapse');
+ linkElm?.setAttribute('aria-expanded', String(!isCollapsed));
+ }
+ });
+
+ dom.on(sidebarElm, 'keydown', (/** @type {KeyboardEvent} */ event) => {
+ const groupToggle = /** @type {HTMLElement | null} */ (
+ /** @type {HTMLElement} */ (event.target).closest(
+ '.group-toggle[role="button"]',
+ )
+ );
+
+ if (groupToggle && (event.key === 'Enter' || event.key === ' ')) {
+ event.preventDefault();
+ this.#toggleSidebarGroup(groupToggle);
}
});
}
+ /**
+ * Toggle a root sidebar group and keep its accessible state in sync.
+ *
+ * @param {HTMLElement} groupToggle
+ * @void
+ */
+ #toggleSidebarGroup(groupToggle) {
+ const group = /** @type {HTMLLIElement | null} */ (
+ groupToggle.closest('li')
+ );
+
+ if (!group) {
+ return;
+ }
+
+ const isCollapsed = group.classList.toggle('collapse');
+ groupToggle.setAttribute('aria-expanded', String(!isCollapsed));
+ }
+
/**
* Initialize sidebar show/hide toggle behavior
*
@@ -360,9 +408,11 @@ export function Events(Base) {
* @param {undefined|"history"|"navigate"} source Type of navigation where
* undefined is initial load, "history" is forward/back, and "navigate" is
* user click/tap
+ * @param {import('../router/util.js').SidebarNavigationTarget} [focusTarget]
+ * Sidebar link to restore after rendering
* @void
*/
- onNavigate(source) {
+ onNavigate(source, focusTarget) {
const { auto2top, topMargin } = this.config;
const { path, query } = this.route;
const activeSidebarElm = this.#markSidebarActiveElm();
@@ -403,7 +453,12 @@ export function Events(Base) {
// Clicked anchor link or page load with anchor ID
if (hasId || isNavigate) {
- this.#focusContent();
+ const sidebarFocused =
+ isNavigate && this.#focusSidebarNavigation(focusTarget);
+
+ if (!sidebarFocused) {
+ this.#focusContent();
+ }
}
}
@@ -451,11 +506,47 @@ export function Events(Base) {
return focusEl;
}
+ /**
+ * Restore focus to the rendered sidebar link that initiated navigation.
+ *
+ * @param {import('../router/util.js').SidebarNavigationTarget} [target]
+ * Sidebar navigation target
+ * @returns {boolean} True when focus was restored
+ */
+ #focusSidebarNavigation(target) {
+ if (!target || isMobile()) {
+ return false;
+ }
+
+ const sidebarElm = dom.find('.sidebar');
+
+ if (!sidebarElm) {
+ return false;
+ }
+
+ const focusElm = /** @type {HTMLElement|undefined} */ (
+ dom
+ .findAll(sidebarElm, 'a')
+ .find(
+ linkElm =>
+ linkElm.classList.contains(target.className) &&
+ /** @type {HTMLAnchorElement} */ (linkElm).href === target.href,
+ )
+ );
+
+ if (!focusElm) {
+ return false;
+ }
+
+ focusElm.focus({ preventScroll: true });
+ return true;
+ }
+
/**
* Marks the active app nav item
*/
#markAppNavActiveElm() {
- const href = decodeURIComponent(this.router.toURL(this.route.path));
+ const href = resolveHref(this.router.toURL(this.route.path));
['.app-nav', '.app-nav-merged'].forEach(selector => {
const navElm = dom.find(selector);
@@ -468,13 +559,7 @@ export function Events(Base) {
dom.findAll(navElm, 'a')
)
.sort((a, b) => b.href.length - a.href.length)
- .find(
- a =>
- href.includes(/** @type {string} */ (a.getAttribute('href'))) ||
- href.includes(
- decodeURI(/** @type {string} */ (a.getAttribute('href'))),
- ),
- )
+ .find(a => href.includes(a.href))
?.closest('li');
const oldActive = dom.find(navElm, 'li.active');
@@ -501,15 +586,14 @@ export function Events(Base) {
return;
}
- href = stripUrlExceptId(href);
+ const matchingHref = stripUrlExceptId(/** @type {string} */ (href));
const oldActive = dom.find(sidebar, 'li.active');
- const newActive = dom
- .find(
- sidebar,
- `a[href="${href}"], a[href="${decodeURIComponent(/** @type {string} */ (href))}"]`,
- )
- ?.closest('li');
+ const newActive = findLinkByHref(
+ sidebar,
+ matchingHref,
+ '.sidebar-nav a',
+ )?.closest('li');
if (newActive && newActive !== oldActive) {
oldActive?.classList.remove('active');
@@ -537,12 +621,9 @@ export function Events(Base) {
const path = href?.split('?')[0];
const oldPage = dom.find(sidebar, 'li[aria-current]');
- const newPage = dom
- .find(
- sidebar,
- `a[href="${path}"], a[href="${decodeURIComponent(/** @type {string} */ (path))}"]`,
- )
- ?.closest('li');
+ const newPage = path
+ ? findLinkByHref(sidebar, path, '.sidebar-nav a')?.closest('li')
+ : undefined;
if (newPage && newPage !== oldPage) {
oldPage?.removeAttribute('aria-current');
diff --git a/src/core/fetch/index.js b/src/core/fetch/index.js
index 3dcc0caf9d..fbd5b3d1b6 100644
--- a/src/core/fetch/index.js
+++ b/src/core/fetch/index.js
@@ -23,6 +23,7 @@ export function Fetch(Base) {
path = getParentPath(path);
if (!path) {
+ next('');
return;
}
diff --git a/src/core/render/compiler.js b/src/core/render/compiler.js
index e81aaa81f8..a0b5bbf8f5 100644
--- a/src/core/render/compiler.js
+++ b/src/core/render/compiler.js
@@ -160,7 +160,7 @@ export class Compiler {
_initRenderer() {
const renderer = new marked.Renderer();
- const { linkTarget, linkRel, router, contentBase } = this;
+ const { linkTarget, router, contentBase } = this;
// Supports mermaid
const origin = {};
@@ -179,7 +179,6 @@ export class Compiler {
renderer,
router,
linkTarget,
- linkRel,
compiler: this,
});
origin.paragraph = paragraphCompiler({ renderer });
@@ -206,7 +205,7 @@ export class Compiler {
let html = '';
// compile sidebar from _sidebar.md
- if (text) {
+ if (typeof text === 'string') {
return this.compile(text);
}
// compile sidebar from content's headings toc
diff --git a/src/core/render/compiler/link.js b/src/core/render/compiler/link.js
index 6c017adcf3..93e042bfb7 100644
--- a/src/core/render/compiler/link.js
+++ b/src/core/render/compiler/link.js
@@ -4,8 +4,7 @@ import { isAbsolutePath } from '../../router/util.js';
export const linkCompiler = ({
renderer,
router,
- linkTarget,
- linkRel,
+ linkTarget: defaultLinkTarget,
compiler,
}) =>
(renderer.link = function ({ href, title = '', tokens }) {
@@ -16,8 +15,8 @@ export const linkCompiler = ({
const isNotCompilable = compiler._matchNotCompileLink(href);
const isMailto = href.startsWith('mailto:');
- linkTarget = config.target || linkTarget;
- linkRel =
+ const linkTarget = config.target || defaultLinkTarget;
+ const linkRel =
linkTarget === '_blank'
? compiler.config.externalLinkRel || 'noopener'
: '';
diff --git a/src/core/render/index.js b/src/core/render/index.js
index f4db193754..1d55c66a3d 100644
--- a/src/core/render/index.js
+++ b/src/core/render/index.js
@@ -1,8 +1,13 @@
import tinydate from 'tinydate';
import * as dom from '../util/dom.js';
-import { getPath, isAbsolutePath } from '../router/util.js';
+import {
+ cleanPath,
+ findLinkByHref,
+ 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';
@@ -35,6 +40,32 @@ export function Render(Base) {
});
}
+ /**
+ * Normalize links in loose Markdown lists from `` to
+ * `` so sidebar behavior and styling do not depend on list
+ * tightness.
+ *
+ * @param {Element} sidebarNavEl
+ */
+ #normalizeSidebarPageLinks(sidebarNavEl) {
+ dom.findAll(sidebarNavEl, 'li > p').forEach(paragraph => {
+ const link = paragraph.firstElementChild;
+ const onlyContainsLink = [...paragraph.childNodes].every(
+ node =>
+ node === link || (node.nodeType === 3 && !node.textContent?.trim()),
+ );
+
+ if (
+ !paragraph.attributes.length &&
+ paragraph.children.length === 1 &&
+ link?.tagName === 'A' &&
+ onlyContainsLink
+ ) {
+ paragraph.replaceWith(link);
+ }
+ });
+ }
+
#executeScript() {
const script = dom
.findAll('.markdown-section>script')
@@ -294,7 +325,14 @@ export function Render(Base) {
}
_renderSidebar(text) {
- const { maxLevel, subMaxLevel, loadSidebar, hideSidebar } = this.config;
+ const {
+ collapseSidebarGroups,
+ collapsibleSidebarGroups,
+ maxLevel,
+ subMaxLevel,
+ loadSidebar,
+ hideSidebar,
+ } = this.config;
const sidebarEl = dom.getNode('aside.sidebar');
const sidebarNavEl = dom.getNode('.sidebar-nav');
const sidebarToggleEl = dom.getNode('button.sidebar-toggle');
@@ -310,22 +348,40 @@ export function Render(Base) {
throw new Error('Compiler is not initialized');
}
+ const sidebarGroupStates = new Map(
+ dom
+ .findAll(
+ sidebarNavEl,
+ 'li.group > .group-toggle[role="button"][data-group-id]',
+ )
+ .map(elm => [
+ elm.getAttribute('data-group-id'),
+ elm.closest('li')?.classList.contains('collapse'),
+ ]),
+ );
+
dom.setHTML('.sidebar-nav', this.compiler.sidebar(text, maxLevel));
+ this.#normalizeSidebarPageLinks(sidebarNavEl);
sidebarToggleEl.setAttribute('aria-expanded', String(!isMobile()));
- const activeElmHref = decodeURIComponent(
- this.router.toURL(this.route.path),
- );
const activeEl = /** @type {HTMLElement | null} */ (
- dom.find(`.sidebar-nav a[href="${activeElmHref}"]`)
+ findLinkByHref(sidebarNavEl, this.router.toURL(this.route.path), 'a')
);
this.#addTextAsTitleAttribute('.sidebar-nav a');
if (loadSidebar && activeEl) {
- const parent = /** @type {HTMLElement} */ (activeEl.parentElement);
- parent.innerHTML += this.compiler.subSidebar(subMaxLevel) || '';
+ const activeItem = activeEl.closest('li');
+
+ activeItem?.insertAdjacentHTML(
+ 'beforeend',
+ this.compiler.subSidebar(subMaxLevel) || '',
+ );
+
+ if (activeItem?.querySelector(':scope > .app-sub-sidebar')) {
+ activeEl.setAttribute('aria-expanded', 'true');
+ }
} else {
this.compiler.resetToc();
}
@@ -336,18 +392,18 @@ export function Render(Base) {
// Mark page links and groups
const pageLinks = dom.findAll(
sidebarNavEl,
- 'a:is(li > a, li > p > a):not(.section-link, [target="_blank"])',
+ 'li > a:not(.section-link, [target="_blank"])',
);
const pageLinkGroups = dom
// NOTE: Using filter() method as a replacement for :has() selector. It
- // would be preferable to use only 'li:not(:has(> a, > p > a))' selector
+ // would be preferable to use only 'li:not(:has(> a))' selector
// but the :has() selector is not supported by our Jest test environment
// See: https://github.com/jsdom/jsdom/issues/3506#issuecomment-1769782333
.findAll(sidebarEl, 'li')
.filter(
elm =>
elm.querySelector(':scope > ul') &&
- !elm.querySelectorAll(':scope > a, :scope > p > a').length,
+ !elm.querySelector(':scope > a'),
);
pageLinks.forEach(elm => {
@@ -356,9 +412,57 @@ export function Render(Base) {
pageLinkGroups.forEach(elm => {
elm.classList.add('group');
- elm
- .querySelector(':scope > p:not(:has(> *))')
- ?.classList.add('group-title');
+
+ let groupTitle = [...elm.children].find(
+ child => child.tagName === 'P' && !child.querySelector('a'),
+ );
+ // Preserve the original styling behavior: only text-only paragraphs
+ // produced by Markdown receive the group-title class.
+ const styledGroupTitle =
+ groupTitle && !groupTitle.children.length ? groupTitle : null;
+
+ if (!groupTitle) {
+ const sublist = [...elm.children].find(
+ child => child.tagName === 'UL',
+ );
+ const titleNodes = [];
+
+ for (const child of elm.childNodes) {
+ if (child === sublist) {
+ break;
+ }
+
+ titleNodes.push(child);
+ }
+
+ if (sublist && titleNodes.some(node => node.textContent?.trim())) {
+ const newGroupTitle = document.createElement('p');
+ titleNodes.forEach(node => newGroupTitle.append(node));
+ groupTitle = newGroupTitle;
+ elm.insertBefore(newGroupTitle, sublist);
+ }
+ }
+
+ styledGroupTitle?.classList.add('group-title');
+
+ const rootList = elm.parentElement;
+
+ if (
+ collapsibleSidebarGroups &&
+ groupTitle &&
+ rootList?.parentElement === sidebarNavEl
+ ) {
+ const groupId = `${[...sidebarNavEl.children].indexOf(rootList)}:${[...rootList.children].indexOf(elm)}`;
+ const isCollapsed =
+ sidebarGroupStates.get(groupId) ?? collapseSidebarGroups;
+
+ elm.classList.toggle('collapse', isCollapsed);
+ groupTitle.classList.add('group-toggle');
+ groupTitle.setAttribute('data-group-id', groupId);
+ groupTitle.setAttribute('role', 'button');
+ groupTitle.setAttribute('tabindex', '0');
+ groupTitle.setAttribute('aria-expanded', String(!isCollapsed));
+ }
});
}
@@ -397,10 +501,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/src/core/render/slugify.js b/src/core/render/slugify.js
index 6f1f6a7ee2..825011779b 100644
--- a/src/core/render/slugify.js
+++ b/src/core/render/slugify.js
@@ -23,7 +23,7 @@ export function slugify(str) {
.replace(/^(\d)/, '_$1');
let count = cache[slug];
- count = Object.keys(cache).includes(slug) ? count + 1 : 0;
+ count = Object.prototype.hasOwnProperty.call(cache, slug) ? count + 1 : 0;
cache[slug] = count;
if (count) {
diff --git a/src/core/render/tpl.js b/src/core/render/tpl.js
index 88b732a78f..b4c6de3b5c 100644
--- a/src/core/render/tpl.js
+++ b/src/core/render/tpl.js
@@ -36,18 +36,20 @@ export function corner(data, cornerExternalLinkTarget) {
* @returns {String} HTML of the main content
*/
export function main(config) {
- const { hideSidebar, name } = config;
+ const { hideSidebar, name, sidebarPosition } = config;
+ const sidebarPositionClass =
+ sidebarPosition === 'right' ? ' sidebar-right' : '';
// const name = config.name ? config.name : '';
const aside = /* html */ hideSidebar
? ''
: `
-
"
);
});
+ test.each([
+ 'https://example.org/first',
+ '/first',
+ 'mailto:first@example.org',
+ ])('does not leak a target override from %s into later links', href => {
+ const container = document.createElement('div');
+ container.innerHTML = window.marked(
+ `[First](${href} ':target=_self')\n\n[Second](https://example.org/second)`,
+ );
+
+ const secondLink = container.querySelectorAll('a')[1];
+ expect(secondLink.target).toBe('_blank');
+ expect(secondLink.rel).toBe('noopener');
+ });
+
test('target for relative path', async function () {
const output = window.marked("[alt text](/url ':target=_blank')");
diff --git a/test/unit/ajax.test.js b/test/unit/ajax.test.js
new file mode 100644
index 0000000000..42e8280385
--- /dev/null
+++ b/test/unit/ajax.test.js
@@ -0,0 +1,30 @@
+import _mock from 'xhr-mock';
+import { get } from '../../src/core/util/ajax.js';
+
+const mock = _mock.default;
+
+describe('ajax/get', () => {
+ beforeEach(() => mock.setup());
+ afterEach(() => mock.teardown());
+
+ test('preserves response status when serving cached content', async () => {
+ const url = '/cached-response-status.md';
+ mock.get(url, { status: 200, body: '# Cached page' });
+
+ const first = await new Promise(resolve => {
+ get(url).then((content, opt, response) => {
+ resolve({ content, opt, response });
+ });
+ });
+ const cached = await new Promise(resolve => {
+ get(url).then((content, opt, response) => {
+ resolve({ content, opt, response });
+ });
+ });
+
+ expect(first.response).toEqual(
+ expect.objectContaining({ ok: true, status: 200 }),
+ );
+ expect(cached).toEqual(first);
+ });
+});
diff --git a/test/unit/render-util.test.js b/test/unit/render-util.test.js
index 9636c01e6b..77cb0c0fd2 100644
--- a/test/unit/render-util.test.js
+++ b/test/unit/render-util.test.js
@@ -167,6 +167,10 @@ describe('core/render/tpl', () => {
});
describe('core/render/slugify', () => {
+ beforeEach(() => {
+ slugify.clear();
+ });
+
test('slugify()', () => {
const htmlStrippedSlug = slugify(
'Bla bla bla ',
@@ -218,4 +222,18 @@ describe('core/render/slugify', () => {
);
expect(markdownLinkSlug).toBe('_500-rc4-2026-03-11');
});
+
+ test('slugify.clear() resets duplicate tracking', () => {
+ expect(slugify('duplicate')).toBe('duplicate');
+ expect(slugify('duplicate')).toBe('duplicate-1');
+
+ slugify.clear();
+
+ expect(slugify('duplicate')).toBe('duplicate');
+ });
+
+ test('slugify() handles inherited property names as new slugs', () => {
+ expect(slugify('constructor')).toBe('constructor');
+ expect(slugify('constructor')).toBe('constructor-1');
+ });
});
diff --git a/test/unit/router-util.test.js b/test/unit/router-util.test.js
index 82973fa3a5..e724f165c7 100644
--- a/test/unit/router-util.test.js
+++ b/test/unit/router-util.test.js
@@ -1,8 +1,94 @@
import { resolvePath } from '../../src/core/util/index.js';
+import {
+ findLinkByHref,
+ getClickedLink,
+ getSidebarNavigationTarget,
+ isCurrentContextNavigation,
+} from '../../src/core/router/util.js';
// Suite
// -----------------------------------------------------------------------------
describe('router/util', () => {
+ describe('navigation click helpers', () => {
+ test('finds a link from a nested click target', () => {
+ document.body.innerHTML = 'Guide';
+ const spanElm = /** @type {HTMLElement} */ (
+ document.querySelector('span')
+ );
+ const event = new MouseEvent('click', { bubbles: true });
+
+ spanElm.dispatchEvent(event);
+
+ expect(getClickedLink(event)).toBe(document.querySelector('a'));
+ });
+
+ test.each([
+ ['alternate button', { button: 1 }],
+ ['Alt modifier', { altKey: true }],
+ ['Control modifier', { ctrlKey: true }],
+ ['Meta modifier', { metaKey: true }],
+ ['Shift modifier', { shiftKey: true }],
+ ])('ignores %s clicks', (name, eventInit) => {
+ const linkElm = document.createElement('a');
+ const event = new MouseEvent('click', {
+ button: 0,
+ ...eventInit,
+ });
+
+ expect(isCurrentContextNavigation(event, linkElm)).toBe(false);
+ });
+
+ test('ignores cancelled, download, and new-context navigation', () => {
+ const linkElm = document.createElement('a');
+ const cancelledEvent = new MouseEvent('click', {
+ button: 0,
+ cancelable: true,
+ });
+
+ cancelledEvent.preventDefault();
+ expect(isCurrentContextNavigation(cancelledEvent, linkElm)).toBe(false);
+
+ linkElm.setAttribute('download', '');
+ expect(isCurrentContextNavigation(new MouseEvent('click'), linkElm)).toBe(
+ false,
+ );
+
+ linkElm.removeAttribute('download');
+ linkElm.target = '_blank';
+ expect(isCurrentContextNavigation(new MouseEvent('click'), linkElm)).toBe(
+ false,
+ );
+ });
+
+ test('describes a sidebar link by class and URL', () => {
+ document.body.innerHTML = `
+
+ `;
+ const linkElm = /** @type {HTMLAnchorElement} */ (
+ document.querySelector('.page-link')
+ );
+
+ expect(getSidebarNavigationTarget(linkElm)).toEqual({
+ className: 'page-link',
+ href: linkElm.href,
+ });
+ });
+
+ test('finds encoded URLs without using them as selectors', () => {
+ const href = '#/say%22hi';
+
+ document.body.innerHTML = ``;
+ const navElm = /** @type {HTMLElement} */ (document.querySelector('nav'));
+
+ expect(findLinkByHref(navElm, href)).toBe(document.querySelector('a'));
+ });
+ });
+
// resolvePath()
// ---------------------------------------------------------------------------
describe('resolvePath()', () => {
diff --git a/test/unit/search-markdown-to-txt.test.js b/test/unit/search-markdown-to-txt.test.js
new file mode 100644
index 0000000000..449b729cae
--- /dev/null
+++ b/test/unit/search-markdown-to-txt.test.js
@@ -0,0 +1,12 @@
+import { markdownToTxt } from '../../src/plugins/search/markdown-to-txt.js';
+
+describe('search markdown to text', () => {
+ test.each([
+ ['**a *formatted* word**', 'a formatted word'],
+ ['**a `code` word**', 'a code word'],
+ ['**[link](https://example.org)**', 'link https://example.org'],
+ ['**plain bold text**', 'plain bold text'],
+ ])('extracts nested inline content from %s', (markdown, expected) => {
+ expect(markdownToTxt(markdown)).toBe(expected);
+ });
+});