refactor: event modernization and optimization (#2404)

- Refactor methods names and functionality
- Replace scroll listeners with observers
- Replace Tweezer-based scrolling with native scroll methods
- Remove tweezer.js dependency
- Remove redundant method calls
- Rename $resetEvents to onNavigate
- Rename __scrollActiveSidebar to onRender
- Remove __getAndActive
- Remove __sticky
- Add IntersectionObserver mock to Jest environment

Also included:

- Add e2e test “ui” and “chromium” scripts
- Rename "jest" script to "test:jest"
- Remove unused SSR code

---------

Co-authored-by: Koy Zhuang <koy@ko8e24.top>
This commit is contained in:
John Hildenbiddle 2024-04-21 07:44:14 -05:00 committed by GitHub
parent 2d986feb34
commit bb902f8997
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 475 additions and 383 deletions

8
package-lock.json generated
View File

@ -15,8 +15,7 @@
"opencollective-postinstall": "^2.0.2",
"prismjs": "^1.29.0",
"strip-indent": "^3.0.0",
"tinydate": "^1.3.0",
"tweezer.js": "^1.4.0"
"tinydate": "^1.3.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",
@ -14739,11 +14738,6 @@
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
}
},
"node_modules/tweezer.js": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/tweezer.js/-/tweezer.js-1.5.0.tgz",
"integrity": "sha512-aSiJz7rGWNAQq7hjMK9ZYDuEawXupcCWgl3woQQSoDP2Oh8O4srWb/uO1PzzHIsrPEOqrjJ2sUb9FERfzuBabQ=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -28,19 +28,18 @@
"build:css": "mkdirp lib/themes && node build/css -o lib/themes",
"build:emoji": "node ./build/emoji.js",
"build:js": "cross-env NODE_ENV=production node build/build.js",
"build:test": "npm run build && npm test",
"build": "rimraf lib themes && run-s build:js build:css build:css:min build:cover build:emoji",
"build": "run-s clean build:js build:css build:css:min build:cover build:emoji",
"clean": "rimraf lib themes _playwright*",
"dev": "run-p serve:dev watch:*",
"docker:build:test": "npm run docker:cli -- build:test",
"docker:build": "docker build -f Dockerfile -t docsify-test:local .",
"docker:clean": "docker rmi docsify-test:local",
"docker:cli": "docker run --rm -it --ipc=host --mount type=bind,source=$(pwd)/test,target=/app/test docsify-test:local",
"docker:rebuild": "npm run docker:clean && npm run docker:build",
"docker:rebuild": "run-s docker:clean docker:build",
"docker:test:e2e": "npm run docker:cli -- test:e2e",
"docker:test:integration": "npm run docker:cli -- test:integration",
"docker:test:unit": "npm run docker:cli -- test:unit",
"docker:test": "npm run docker:cli -- test",
"jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
"lint:fix": "eslint . --fix",
"lint": "prettier . --check && eslint .",
"postinstall": "opencollective-postinstall && npx husky install",
@ -51,9 +50,12 @@
"serve:dev": "npm run serve -- --dev",
"serve": "node server",
"test:e2e": "playwright test",
"test:integration": "npm run jest -- --selectProjects integration",
"test:unit": "npm run jest -- --selectProjects unit",
"test": "npm run jest && run-s test:e2e",
"test:e2e:chromium": "playwright test --project='chromium'",
"test:e2e:ui": "playwright test --ui",
"test:integration": "npm run test:jest -- --selectProjects integration",
"test:jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
"test:unit": "npm run test:jest -- --selectProjects unit",
"test": "run-s test:jest test:e2e",
"watch:css": "npm run build:css -- -w",
"watch:js": "node build/build.js"
},
@ -66,8 +68,7 @@
"opencollective-postinstall": "^2.0.2",
"prismjs": "^1.29.0",
"strip-indent": "^3.0.0",
"tinydate": "^1.3.0",
"tweezer.js": "^1.4.0"
"tinydate": "^1.3.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",

View File

@ -1,9 +1,5 @@
import Tweezer from 'tweezer.js';
import { isMobile } from '../util/env.js';
import { body, on } from '../util/dom.js';
import * as dom from '../util/dom.js';
import { removeParams } from '../router/util.js';
import config from '../config.js';
/** @typedef {import('../Docsify.js').Constructor} Constructor */
@ -13,50 +9,128 @@ import config from '../config.js';
*/
export function Events(Base) {
return class Events extends Base {
$resetEvents(source) {
const { auto2top, loadNavbar } = this.config;
const { path, query } = this.route;
#intersectionObserver;
#isScrolling;
#title = dom.$.title;
// Note: Scroll position set by browser on forward/back (i.e. "history")
if (source !== 'history') {
// Scroll to ID if specified
if (query.id) {
this.#scrollIntoView(path, query.id, true);
}
// Scroll to top if a link was clicked and auto2top is enabled
else if (source === 'navigate') {
auto2top && this.#scroll2Top(auto2top);
}
// Initialization
// =========================================================================
/**
* Initialize Docsify events
* One-time setup of listeners, observers, and tasks.
* @void
*/
initEvent() {
const { topMargin } = this.config;
// Apply topMargin to scrolled content
if (topMargin) {
document.documentElement.style.setProperty(
'scroll-padding-top',
`${topMargin}px`
);
}
// Move focus to content
if (query.id || source === 'navigate') {
this.#focusContent();
}
if (loadNavbar) {
this.__getAndActive(this.router, 'nav');
}
this.#initCover();
this.#initSkipToContent('#skip-to-content');
this.#initSidebarCollapse('.sidebar');
this.#initSidebarToggle('button.sidebar-toggle');
this.#initKeyBindings();
}
initEvent() {
const { coverpage, keyBindings } = this.config;
// Sub-Initializations
// =========================================================================
/**
* Initialize cover observer
* Toggles sticky behavior when when cover is not in view
* @void
*/
#initCover() {
const coverElm = dom.find('section.cover');
if (!coverElm) {
dom.toggleClass(dom.body, 'add', 'sticky');
return;
}
const observer = new IntersectionObserver(entries => {
const isIntersecting = entries[0].isIntersecting;
const op = isIntersecting ? 'remove' : 'add';
dom.toggleClass(dom.body, op, 'sticky');
});
observer.observe(coverElm);
}
/**
* Initialize heading observer
* Toggles sidebar active item based on top viewport edge intersection
* @void
*/
#initHeadings() {
const headingElms = dom.findAll('#main :where(h1, h2, h3, h4, h5)');
const headingsInView = new Set();
// Mark sidebar active item on heading intersection
this.#intersectionObserver?.disconnect();
this.#intersectionObserver = new IntersectionObserver(
entries => {
if (this.#isScrolling) {
return;
}
for (const entry of entries) {
const op = entry.isIntersecting ? 'add' : 'delete';
headingsInView[op](entry.target);
}
const activeHeading =
headingsInView.size > 1
? // Sort headings by proximity to viewport top and select first
Array.from(headingsInView).sort((a, b) =>
a.compareDocumentPosition(b) &
Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1
)[0]
: // Get first and only item in set.
// May be undefined if no headings are in view.
headingsInView.values().next().value;
if (activeHeading) {
const id = activeHeading.getAttribute('id');
const href = this.router.toURL(this.router.getCurrentPath(), {
id,
});
const newSidebarActiveElm = this.#markSidebarActiveElm(href);
newSidebarActiveElm?.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
});
}
},
{
rootMargin: '0% 0% -50% 0%', // Top half of viewport
}
);
headingElms.forEach(elm => {
this.#intersectionObserver.observe(elm);
});
}
/**
* Initialize keyboard bindings
* @void
*/
#initKeyBindings() {
const { keyBindings } = this.config;
const modifierKeys = ['alt', 'ctrl', 'meta', 'shift'];
// Bind skip link
this.#skipLink('#skip-to-content');
// Bind toggle button
this.#btn('button.sidebar-toggle', this.router);
this.#collapse('.sidebar', this.router);
// Bind sticky effect
if (coverpage) {
!isMobile && on('scroll', this.__sticky);
} else {
body.classList.add('sticky');
}
// Bind keyboard shortcuts
if (keyBindings && keyBindings.constructor === Object) {
// Prepare key binding configurations
Object.values(keyBindings || []).forEach(bindingConfig => {
@ -96,7 +170,7 @@ export function Events(Base) {
});
// Handle keyboard events
on('keydown', e => {
dom.on('keydown', e => {
const isTextEntry = document.activeElement.matches(
'input, select, textarea'
);
@ -131,243 +205,20 @@ export function Events(Base) {
}
}
/** @readonly */
#nav = {};
#hoverOver = false;
#scroller = null;
#enableScrollEvent = true;
#coverHeight = 0;
#skipLink(elm) {
elm = typeof elm === 'string' ? dom.find(elm) : elm;
elm?.addEventListener('click', evt => {
evt.preventDefault();
dom.getNode('main')?.scrollIntoView({
behavior: 'smooth',
});
this.#focusContent({ preventScroll: true });
});
}
#scrollTo(el, offset = 0) {
if (this.#scroller) {
this.#scroller.stop();
}
this.#enableScrollEvent = false;
this.#scroller = new Tweezer({
start: window.pageYOffset,
end:
Math.round(el.getBoundingClientRect().top) +
window.pageYOffset -
offset,
duration: 500,
})
.on('tick', v => window.scrollTo(0, v))
.on('done', () => {
this.#enableScrollEvent = true;
this.#scroller = null;
})
.begin();
}
#focusContent(options = {}) {
const { query } = this.route;
const focusEl = query.id
? // Heading ID
dom.find(`#${query.id}`)
: // First heading
dom.find('#main :where(h1, h2, h3, h4, h5, h6)') ||
// Content container
dom.find('#main');
// Move focus to content area
focusEl && focusEl.focus(options);
}
#highlight(path) {
if (!this.#enableScrollEvent) {
return;
}
const sidebar = dom.getNode('.sidebar');
const anchors = dom.findAll('.anchor');
const wrap = dom.find(sidebar, '.sidebar-nav');
let active = dom.find(sidebar, 'li.active');
const doc = document.documentElement;
const top =
((doc && doc.scrollTop) || document.body.scrollTop) - this.#coverHeight;
let last;
for (const node of anchors) {
if (node.offsetTop > top) {
if (!last) {
last = node;
}
break;
} else {
last = node;
}
}
if (!last) {
return;
}
const li = this.#nav[this.#getNavKey(path, last.getAttribute('data-id'))];
if (!li || li === active) {
return;
}
active && active.classList.remove('active');
li.classList.add('active');
active = li;
// Scroll into view
// https://github.com/vuejs/vuejs.org/blob/master/themes/vue/source/js/common.js#L282-L297
if (!this.#hoverOver && dom.body.classList.contains('sticky')) {
const height = sidebar.clientHeight;
const curOffset = 0;
const cur = active.offsetTop + active.clientHeight + 40;
const isInView =
active.offsetTop >= wrap.scrollTop && cur <= wrap.scrollTop + height;
const notThan = cur - curOffset < height;
sidebar.scrollTop = isInView
? wrap.scrollTop
: notThan
? curOffset
: cur - height;
}
}
#getNavKey(path, id) {
return `${decodeURIComponent(path)}?id=${decodeURIComponent(id)}`;
}
__scrollActiveSidebar(router) {
const cover = dom.find('.cover.show');
this.#coverHeight = cover ? cover.offsetHeight : 0;
const sidebar = dom.getNode('.sidebar');
let lis = [];
if (sidebar !== null && sidebar !== undefined) {
lis = dom.findAll(sidebar, 'li');
}
for (const li of lis) {
const a = li.querySelector('a');
if (!a) {
continue;
}
let href = a.getAttribute('href');
if (href !== '/') {
const {
query: { id },
path,
} = router.parse(href);
if (id) {
href = this.#getNavKey(path, id);
}
}
if (href) {
this.#nav[decodeURIComponent(href)] = li;
}
}
if (isMobile) {
return;
}
const path = removeParams(router.getCurrentPath());
dom.off('scroll', () => this.#highlight(path));
dom.on('scroll', () => this.#highlight(path));
dom.on(sidebar, 'mouseover', () => {
this.#hoverOver = true;
});
dom.on(sidebar, 'mouseleave', () => {
this.#hoverOver = false;
});
}
#scrollIntoView(path, id) {
if (!id) {
return;
}
const topMargin = config().topMargin;
// Use [id='1234'] instead of #id to handle special cases such as reserved characters and pure number id
// https://stackoverflow.com/questions/37270787/uncaught-syntaxerror-failed-to-execute-queryselector-on-document
const section = dom.find(`[id="${id}"]`);
section && this.#scrollTo(section, topMargin);
const sidebar = dom.getNode('.sidebar');
const oldActive = dom.find(sidebar, 'li.active');
const oldPage = dom.find(sidebar, `[aria-current]`);
const newActive = this.#nav[this.#getNavKey(path, id)];
const newPage = dom.find(sidebar, `[href$="${path}"]`)?.parentNode;
oldActive?.classList.remove('active');
oldPage?.removeAttribute('aria-current');
newActive?.classList.add('active');
newPage?.setAttribute('aria-current', 'page');
}
#scrollEl = dom.$.scrollingElement || dom.$.documentElement;
#scroll2Top(offset = 0) {
this.#scrollEl.scrollTop = offset === true ? 0 : Number(offset);
}
/** @readonly */
#title = dom.$.title;
/**
* Toggle button
* @param {Element} el Button to be toggled
* Initialize sidebar content expand/collapse toggle behavior
*
* @param {Element|string} elm Sidebar Element or CSS selector
* @void
*/
#btn(el) {
const toggle = _ => {
dom.body.classList.toggle('close');
#initSidebarCollapse(elm) {
elm = typeof elm === 'string' ? document.querySelector(elm) : elm;
const isClosed = isMobile
? dom.body.classList.contains('close')
: !dom.body.classList.contains('close');
el.setAttribute('aria-expanded', isClosed);
};
el = dom.getNode(el);
if (el === null || el === undefined) {
if (!elm) {
return;
}
dom.on(el, 'click', e => {
e.stopPropagation();
toggle();
});
isMobile &&
dom.on(
dom.body,
'click',
_ => dom.body.classList.contains('close') && toggle()
);
}
#collapse(el) {
el = dom.getNode(el);
if (el === null || el === undefined) {
return;
}
dom.on(el, 'click', ({ target }) => {
dom.on(elm, 'click', ({ target }) => {
if (
target.nodeName === 'A' &&
target.nextSibling &&
@ -379,67 +230,293 @@ export function Events(Base) {
});
}
__sticky = () => {
const cover = dom.getNode('section.cover');
if (!cover) {
/**
* Initialize sidebar show/hide toggle behavior
*
* @param {Element|string} elm Toggle Element or CSS selector
* @void
*/
#initSidebarToggle(elm) {
elm = typeof elm === 'string' ? document.querySelector(elm) : elm;
if (!elm) {
return;
}
const coverHeight = cover.getBoundingClientRect().height;
const toggle = () => {
dom.body.classList.toggle('close');
if (
window.pageYOffset >= coverHeight ||
cover.classList.contains('hidden')
) {
dom.toggleClass(dom.body, 'add', 'sticky');
} else {
dom.toggleClass(dom.body, 'remove', 'sticky');
}
};
const isClosed = isMobile
? dom.body.classList.contains('close')
: !dom.body.classList.contains('close');
elm.setAttribute('aria-expanded', isClosed);
};
dom.on(elm, 'click', e => {
e.stopPropagation();
toggle();
});
isMobile &&
dom.on(
dom.body,
'click',
() => dom.body.classList.contains('close') && toggle()
);
}
/**
* Get and active link
* @param {Object} router Router
* @param {String|Element} el Target element
* @param {Boolean} isParent Active parent
* @param {Boolean} autoTitle Automatically set title
* @return {Element} Active element
* Initialize skip to content behavior
*
* @param {Element|string} elm Skip link Element or CSS selector
* @void
*/
__getAndActive(router, el, isParent, autoTitle) {
el = dom.getNode(el);
let links = [];
if (el !== null && el !== undefined) {
links = dom.findAll(el, 'a');
#initSkipToContent(elm) {
elm = typeof elm === 'string' ? document.querySelector(elm) : elm;
if (!elm) {
return;
}
const hash = decodeURI(router.toURL(router.getCurrentPath()));
let target;
links
.sort((a, b) => b.href.length - a.href.length)
.forEach(a => {
const href = decodeURI(a.getAttribute('href'));
const node = isParent ? a.parentNode : a;
a.title = a.title || a.innerText;
if (hash.indexOf(href) === 0 && !target) {
target = a;
dom.toggleClass(node, 'add', 'active');
node.setAttribute('aria-current', 'page');
} else {
dom.toggleClass(node, 'remove', 'active');
node.removeAttribute('aria-current');
}
elm.addEventListener('click', evt => {
evt.preventDefault();
dom.find('main')?.scrollIntoView({
behavior: 'smooth',
});
this.#focusContent();
});
}
if (autoTitle) {
dom.$.title = target
? target.title || `${target.innerText} - ${this.#title}`
: this.#title;
// Callbacks
// =========================================================================
/**
* Handle rendering UI element updates and new content
* @void
*/
onRender() {
const currentPath = this.router.toURL(this.router.getCurrentPath());
const currentTitle = dom.find(
`.sidebar a[href='${currentPath}']`
)?.innerText;
// Update page title
dom.$.title = currentTitle || this.#title;
this.#markAppNavActiveElm();
this.#markSidebarCurrentPage();
this.#initHeadings();
}
/**
* Handle navigation events
*
* @param {undefined|"history"|"navigate"} source Type of navigation where
* undefined is initial load, "history" is forward/back, and "navigate" is
* user click/tap
* @void
*/
onNavigate(source) {
const { auto2top, topMargin } = this.config;
const { query } = this.route;
this.#markSidebarActiveElm();
// Note: Scroll position set by browser on forward/back (i.e. "history")
if (source !== 'history') {
// Anchor link
if (query.id) {
const headingElm = dom.find(
`.markdown-section :where(h1, h2, h3, h4, h5)[id="${query.id}"]`
);
if (headingElm) {
this.#watchNextScroll();
headingElm.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}
// User click/tap
else if (source === 'navigate') {
// Scroll to top
if (auto2top) {
document.scrollingElement.scrollTop = topMargin ?? 0;
}
}
}
return target;
// Move focus to content
if (query.id || source === 'navigate') {
this.#focusContent();
}
}
// Functions
// =========================================================================
/**
* Set focus on the main content area: current route ID, first heading, or
* the main content container
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
* @param {Object} options HTMLElement focus() method options
* @void
*/
#focusContent(options = {}) {
const settings = {
preventScroll: true,
...options,
};
const { query } = this.route;
const focusEl = query.id
? // Heading ID
dom.find(`#${query.id}`)
: // First heading
dom.find('#main :where(h1, h2, h3, h4, h5, h6)') ||
// Content container
dom.find('#main');
// Move focus to content area
focusEl?.focus(settings);
}
/**
* Marks the active app nav item
*
* @param {string} [href] Matching element HREF value. If unspecified,
* defaults to the current path (without query params)
* @returns Element|undefined
*/
#markAppNavActiveElm() {
const href = decodeURIComponent(this.router.toURL(this.route.path));
const navElm = dom.find('nav.app-nav');
if (!navElm) {
return;
}
const newActive = dom
.findAll(navElm, 'a')
.sort((a, b) => b.href.length - a.href.length)
.find(
a =>
href.includes(a.getAttribute('href')) ||
href.includes(decodeURI(a.getAttribute('href')))
);
const oldActive = dom.find(navElm, 'li.active');
if (newActive && newActive !== oldActive) {
oldActive?.classList.remove('active');
newActive.classList.add('active');
}
return newActive;
}
/**
* Marks the active sidebar item
*
* @param {string} [href] Matching element HREF value. If unspecified,
* defaults to the current path (with query params)
* @returns Element|undefined
*/
#markSidebarActiveElm(href) {
href ??= this.router.toURL(this.router.getCurrentPath());
const sidebar = dom.find('.sidebar');
if (!sidebar) {
return;
}
const oldActive = dom.find(sidebar, 'li.active');
const newActive = dom
.find(
sidebar,
`a[href="${href}"], a[href="${decodeURIComponent(href)}"]`
)
?.closest('li');
if (newActive && newActive !== oldActive) {
oldActive?.classList.remove('active');
newActive.classList.add('active');
}
return newActive;
}
/**
* Marks the current page in the sidebar
*
* @param {string} [href] Matching sidebar element HREF value. If
* unspecified, defaults to the current path (without query params)
* @returns Element|undefined
*/
#markSidebarCurrentPage(href) {
href ??= this.router.toURL(this.route.path);
const sidebar = dom.find('.sidebar');
if (!sidebar) {
return;
}
const path = href?.split('?')[0];
const oldPage = dom.find(sidebar, 'li[aria-current]');
const newPage = dom
.find(
sidebar,
`a[href="${path}"], a[href="${decodeURIComponent(path)}"]`
)
?.closest('li');
if (newPage && newPage !== oldPage) {
oldPage?.removeAttribute('aria-current');
newPage.setAttribute('aria-current', 'page');
}
return newPage;
}
/**
* Monitor next scroll start/end and set #isScrolling to true/false
* accordingly. Listeners are removed after the start/end events are fired.
* @void
*/
#watchNextScroll() {
// Scroll start
document.addEventListener(
'scroll',
() => {
this.#isScrolling = true;
// Scroll end
if ('onscrollend' in window) {
document.addEventListener(
'scrollend',
() => (this.#isScrolling = false),
{ once: true }
);
}
// Browsers w/o native scrollend event support (Safari)
else {
const callback = () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
document.removeEventListener('scroll', callback);
this.#isScrolling = false;
}, 100);
};
let scrollTimer;
document.addEventListener('scroll', callback, false);
}
},
{ once: true }
);
}
};
}

View File

@ -184,7 +184,7 @@ export function Fetch(Base) {
}
}
$fetch(cb = noop, $resetEvents = this.$resetEvents.bind(this)) {
$fetch(cb = noop, onNavigate = this.onNavigate.bind(this)) {
const done = () => {
this.callHook('doneEach');
cb();
@ -196,7 +196,7 @@ export function Fetch(Base) {
done();
} else {
this._fetch(() => {
$resetEvents();
onNavigate();
done();
});
}
@ -262,25 +262,7 @@ export function Fetch(Base) {
initFetch() {
const { loadSidebar } = this.config;
// Server-Side Rendering
if (this.rendered) {
const activeEl = this.__getAndActive(
this.router,
'.sidebar-nav',
true,
true
);
if (loadSidebar && activeEl) {
activeEl.parentNode.innerHTML += window.__SUB_SIDEBAR__;
}
this._bindEventOnRendered(activeEl);
this.$resetEvents();
this.callHook('doneEach');
this.callHook('ready');
} else {
this.$fetch(_ => this.callHook('ready'));
}
this.$fetch(_ => this.callHook('ready'));
}
};
}

View File

@ -18,6 +18,14 @@ export function Render(Base) {
return class Render extends Base {
#vueGlobalData;
#addTextAsTitleAttribute(cssSelector) {
dom.findAll(cssSelector).forEach(elm => {
if (!elm.title && elm.innerText) {
elm.title = elm.innerText;
}
});
}
#executeScript() {
const script = dom
.findAll('.markdown-section>script')
@ -293,12 +301,10 @@ export function Render(Base) {
this._renderTo('.sidebar-nav', this.compiler.sidebar(text, maxLevel));
sidebarToggleEl.setAttribute('aria-expanded', !isMobile);
const activeEl = this.__getAndActive(
this.router,
'.sidebar-nav',
true,
true
);
const activeElmHref = this.router.toURL(this.route.path);
const activeEl = dom.find(`.sidebar-nav a[href="${activeElmHref}"]`);
this.#addTextAsTitleAttribute('.sidebar-nav a');
if (loadSidebar && activeEl) {
activeEl.parentNode.innerHTML +=
@ -315,7 +321,7 @@ export function Render(Base) {
_bindEventOnRendered(activeEl) {
const { autoHeader } = this.config;
this.__scrollActiveSidebar(this.router);
this.onRender();
if (autoHeader && activeEl) {
const main = dom.getNode('#main');
@ -330,9 +336,7 @@ export function Render(Base) {
_renderNav(text) {
text && this._renderTo('nav', this.compiler.compile(text));
if (this.config.loadNavbar) {
this.__getAndActive(this.router, 'nav');
}
this.#addTextAsTitleAttribute('nav a');
}
_renderMain(text, opt = {}, next) {
@ -420,7 +424,6 @@ export function Render(Base) {
}
this._renderTo('.cover-main', html);
this.__sticky();
}
_updateRender() {

View File

@ -49,11 +49,11 @@ export function Router(Base) {
this._updateRender();
if (lastRoute.path === this.route.path) {
this.$resetEvents(params.source);
this.onNavigate(params.source);
return;
}
this.$fetch(noop, this.$resetEvents.bind(this, params.source));
this.$fetch(noop, this.onNavigate.bind(this, params.source));
lastRoute = this.route;
});
}

View File

@ -273,6 +273,9 @@ main.hidden
line-height 2em
padding-bottom 40px
li
scroll-margin-bottom 40px
li.collapse
.app-sub-sidebar
display none

View File

@ -21,8 +21,32 @@ const sideEffects = {
},
};
class IntersectionObserver {
constructor() {}
root = null;
rootMargin = '';
thresholds = [];
disconnect() {
return null;
}
observe() {
return null;
}
takeRecords() {
return [];
}
unobserve() {
return null;
}
}
// Lifecycle Hooks
// -----------------------------------------------------------------------------
// =============================================================================
beforeAll(async () => {
// Spy addEventListener
['document', 'window'].forEach(obj => {
@ -44,13 +68,16 @@ beforeAll(async () => {
});
});
// Reset JSDOM. This attempts to remove side effects from tests, however it does
// not reset all changes made to globals like the window and document
// objects. Tests requiring a full JSDOM reset should be stored in separate
// files, which is only way to do a complete JSDOM reset with Jest.
beforeEach(async () => {
const rootElm = document.documentElement;
// Reset JSDOM
// -----------------------------------------------------------------------------
// This attempts to remove side effects from tests, however it does not reset
// all changes made to globals like the window and document objects. Tests
// requiring a full JSDOM reset should be stored in separate files, which is
// only way to do a complete JSDOM reset with Jest.
// Remove attributes on root element
[...rootElm.attributes].forEach(attr => rootElm.removeAttribute(attr.name));
@ -79,6 +106,12 @@ beforeEach(async () => {
// Restore base elements
rootElm.innerHTML = '<head></head><body></body>';
// Mock IntersectionObserver
// -----------------------------------------------------------------------------
[global, window].forEach(
obj => (obj.IntersectionObserver = IntersectionObserver)
);
});
afterEach(async () => {

View File

@ -44,6 +44,10 @@ test.describe('Sidebar Tests', () => {
await docsifyInit(docsifyInitConfig);
await page.click('a[href="#/test"]');
await expect(activeLinkElm).toHaveText('Test');
expect(page.url()).toMatch(/\/test$/);
await page.click('a[href="#/test%20space"]');
await expect(activeLinkElm).toHaveText('Test Space');
expect(page.url()).toMatch(/\/test%20space$/);
@ -57,15 +61,11 @@ test.describe('Sidebar Tests', () => {
expect(page.url()).toMatch(/\/test-foo$/);
await page.click('a[href="#/test.foo"]');
expect(page.url()).toMatch(/\/test.foo$/);
await expect(activeLinkElm).toHaveText('Test .');
expect(page.url()).toMatch(/\/test.foo$/);
await page.click('a[href="#/test>foo"]');
await expect(activeLinkElm).toHaveText('Test >');
expect(page.url()).toMatch(/\/test%3Efoo$/);
await page.click('a[href="#/test"]');
await expect(activeLinkElm).toHaveText('Test');
expect(page.url()).toMatch(/\/test$/);
});
});

View File

@ -16,11 +16,11 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = `
</section>"
`;
exports[`Docs Site navbar renders and is unchanged 1`] = `"<nav aria-label=\\"secondary\\" class=\\"app-nav no-badge\\"><ul><li>Translations<ul><li><a href=\\"#/\\" title=\\"undefined\\" class=\\"active\\" aria-current=\\"page\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ec-1f1e7.png?v8.png\\" alt=\\"uk\\" class=\\"emoji\\" loading=\\"lazy\\"> English</a></li><li><a href=\\"#/zh-cn/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e8-1f1f3.png?v8.png\\" alt=\\"cn\\" class=\\"emoji\\" loading=\\"lazy\\"> 简体中文</a></li><li><a href=\\"#/de-de/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e9-1f1ea.png?v8.png\\" alt=\\"de\\" class=\\"emoji\\" loading=\\"lazy\\"> Deutsch</a></li><li><a href=\\"#/es/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ea-1f1f8.png?v8.png\\" alt=\\"es\\" class=\\"emoji\\" loading=\\"lazy\\"> Español</a></li><li><a href=\\"#/ru-ru/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1f7-1f1fa.png?v8.png\\" alt=\\"ru\\" class=\\"emoji\\" loading=\\"lazy\\"> Русский</a></li></ul></li></ul></nav>"`;
exports[`Docs Site navbar renders and is unchanged 1`] = `"<nav aria-label=\\"secondary\\" class=\\"app-nav no-badge\\"><ul><li>Translations<ul><li><a href=\\"#/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ec-1f1e7.png?v8.png\\" alt=\\"uk\\" class=\\"emoji\\" loading=\\"lazy\\"> English</a></li><li><a href=\\"#/zh-cn/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e8-1f1f3.png?v8.png\\" alt=\\"cn\\" class=\\"emoji\\" loading=\\"lazy\\"> 简体中文</a></li><li><a href=\\"#/de-de/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e9-1f1ea.png?v8.png\\" alt=\\"de\\" class=\\"emoji\\" loading=\\"lazy\\"> Deutsch</a></li><li><a href=\\"#/es/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ea-1f1f8.png?v8.png\\" alt=\\"es\\" class=\\"emoji\\" loading=\\"lazy\\"> Español</a></li><li><a href=\\"#/ru-ru/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1f7-1f1fa.png?v8.png\\" alt=\\"ru\\" class=\\"emoji\\" loading=\\"lazy\\"> Русский</a></li></ul></li></ul></nav>"`;
exports[`Docs Site sidebar renders and is unchanged 1`] = `
"<aside id=\\"__sidebar\\" class=\\"sidebar\\" role=\\"none\\">
<div class=\\"sidebar-nav\\" role=\\"navigation\\" aria-label=\\"primary\\"><ul><li><p>Getting started</p><ul><li><a href=\\"#/quickstart\\" title=\\"undefined\\">Quick start</a></li><li><a href=\\"#/more-pages\\" title=\\"undefined\\">Writing more pages</a></li><li><a href=\\"#/custom-navbar\\" title=\\"undefined\\">Custom navbar</a></li><li><a href=\\"#/cover\\" title=\\"undefined\\">Cover page</a></li></ul></li><li><p>Customization</p><ul><li><a href=\\"#/configuration\\" title=\\"undefined\\">Configuration</a></li><li><a href=\\"#/themes\\" title=\\"undefined\\">Themes</a></li><li><a href=\\"#/plugins\\" title=\\"undefined\\">List of Plugins</a></li><li><a href=\\"#/write-a-plugin\\" title=\\"undefined\\">Write a Plugin</a></li><li><a href=\\"#/markdown\\" title=\\"undefined\\">Markdown configuration</a></li><li><a href=\\"#/language-highlight\\" title=\\"undefined\\">Language highlighting</a></li><li><a href=\\"#/emoji\\" title=\\"undefined\\">Emoji</a></li></ul></li><li><p>Guide</p><ul><li><a href=\\"#/deploy\\" title=\\"undefined\\">Deploy</a></li><li><a href=\\"#/helpers\\" title=\\"undefined\\">Helpers</a></li><li><a href=\\"#/vue\\" title=\\"undefined\\">Vue compatibility</a></li><li><a href=\\"#/cdn\\" title=\\"undefined\\">CDN</a></li><li><a href=\\"#/pwa\\" title=\\"undefined\\">Offline Mode (PWA)</a></li><li><a href=\\"#/embed-files\\" title=\\"undefined\\">Embed Files</a></li></ul></li><li><p><a href=\\"#/awesome\\" title=\\"undefined\\">Awesome docsify</a></p></li><li><p><a href=\\"#/changelog\\" title=\\"undefined\\">Changelog</a></p></li></ul></div>
<div class=\\"sidebar-nav\\" role=\\"navigation\\" aria-label=\\"primary\\"><ul><li><p>Getting started</p><ul><li><a href=\\"#/quickstart\\">Quick start</a></li><li><a href=\\"#/more-pages\\">Writing more pages</a></li><li><a href=\\"#/custom-navbar\\">Custom navbar</a></li><li><a href=\\"#/cover\\">Cover page</a></li></ul></li><li><p>Customization</p><ul><li><a href=\\"#/configuration\\">Configuration</a></li><li><a href=\\"#/themes\\">Themes</a></li><li><a href=\\"#/plugins\\">List of Plugins</a></li><li><a href=\\"#/write-a-plugin\\">Write a Plugin</a></li><li><a href=\\"#/markdown\\">Markdown configuration</a></li><li><a href=\\"#/language-highlight\\">Language highlighting</a></li><li><a href=\\"#/emoji\\">Emoji</a></li></ul></li><li><p>Guide</p><ul><li><a href=\\"#/deploy\\">Deploy</a></li><li><a href=\\"#/helpers\\">Helpers</a></li><li><a href=\\"#/vue\\">Vue compatibility</a></li><li><a href=\\"#/cdn\\">CDN</a></li><li><a href=\\"#/pwa\\">Offline Mode (PWA)</a></li><li><a href=\\"#/embed-files\\">Embed Files</a></li></ul></li><li><p><a href=\\"#/awesome\\">Awesome docsify</a></p></li><li><p><a href=\\"#/changelog\\">Changelog</a></p></li></ul></div>
</aside>"
`;

View File

@ -11,7 +11,6 @@ describe('Docsify', function () {
expect(vm).toBeInstanceOf(Object);
expect(vm.constructor.name).toBe('Docsify');
expect(vm.$fetch).toBeInstanceOf(Function);
expect(vm.$resetEvents).toBeInstanceOf(Function);
expect(vm.route).toBeInstanceOf(Object);
});