334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
'use strict';
|
||
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const vm = require('node:vm');
|
||
const test = require('node:test');
|
||
const { MiniDocument } = require('../helpers/mini-dom.cjs');
|
||
|
||
const root = path.resolve(__dirname, '..', '..');
|
||
const source = fs.readFileSync(path.join(root, 'assets/js/section-navs.js'), 'utf8');
|
||
|
||
function createRuntime({ readyState = 'complete', reducedMotion = false, hash = '', navFixed = false, pageYOffset = 0 } = {}) {
|
||
const document = new MiniDocument();
|
||
document.readyState = readyState;
|
||
if (navFixed) document.body.classList.add('nav-fixed');
|
||
const listeners = {};
|
||
const timers = [];
|
||
const frames = [];
|
||
const scrollCalls = [];
|
||
const collapseCalls = [];
|
||
const autoFun = [];
|
||
const window = {
|
||
location: { hash },
|
||
pageYOffset,
|
||
scrollY: pageYOffset,
|
||
matchMedia: () => ({ matches: reducedMotion }),
|
||
addEventListener(type, callback) { (listeners[type] ||= []).push(callback); },
|
||
dispatch(type) { (listeners[type] || []).forEach((callback) => callback({ type })); },
|
||
requestAnimationFrame(callback) { frames.push(callback); },
|
||
setTimeout(callback, delay) { timers.push({ callback, delay }); },
|
||
scrollTo(options) { scrollCalls.push(options); },
|
||
};
|
||
|
||
function jQuery(target) {
|
||
if (target === document) {
|
||
return {
|
||
off(event) {
|
||
for (const key of Object.keys(autoFun)) delete autoFun[key];
|
||
target._removedEvent = event;
|
||
return this;
|
||
},
|
||
on(event, callback) { autoFun.push({ event, callback }); return this; },
|
||
trigger(event) {
|
||
autoFun.filter((entry) => entry.event.split('.')[0] === event).forEach((entry) => entry.callback());
|
||
return this;
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
parents(selector) {
|
||
const matches = [];
|
||
for (let node = target.parentElement; node; node = node.parentElement) {
|
||
if (node.matches(selector)) matches.push(node);
|
||
}
|
||
return {
|
||
find(childSelector) {
|
||
const children = matches.flatMap((node) => node.querySelectorAll(childSelector));
|
||
return { collapse(action) { collapseCalls.push({ children, action }); } };
|
||
},
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
vm.runInNewContext(source, { document, window, jQuery, WeakMap, JSON, Math, encodeURIComponent, decodeURIComponent });
|
||
return {
|
||
document,
|
||
window,
|
||
autoFun: () => jQuery(document).trigger('auto_fun'),
|
||
dispatchReady: () => document.dispatch('DOMContentLoaded'),
|
||
flushTimers() { while (timers.length) timers.shift().callback(); },
|
||
timerDelays: () => timers.map((timer) => timer.delay),
|
||
flushFrames() { while (frames.length) frames.shift()(); },
|
||
scrollCalls,
|
||
collapseCalls,
|
||
};
|
||
}
|
||
|
||
function addBox(document, title = '文章目录') {
|
||
const box = document.createElement('div');
|
||
box.className = 'posts-nav-box';
|
||
box.setAttribute('data-title', title);
|
||
document.body.appendChild(box);
|
||
return box;
|
||
}
|
||
|
||
function addHeading(document, rootNode, tag, text, { id = '', className = '', top = 200, offsetTop = 9999 } = {}) {
|
||
const heading = document.createElement(tag);
|
||
heading.textContent = text;
|
||
heading.rectTop = top;
|
||
heading.offsetTop = offsetTop;
|
||
if (id) heading.id = id;
|
||
if (className) heading.className = className;
|
||
rootNode.appendChild(heading);
|
||
return heading;
|
||
}
|
||
|
||
function articleRoot(document, { noNav = false } = {}) {
|
||
const rootNode = document.createElement('div');
|
||
rootNode.setAttribute('data-nav', 'posts');
|
||
if (noNav) rootNode.className = 'no-nav';
|
||
document.body.appendChild(rootNode);
|
||
return rootNode;
|
||
}
|
||
|
||
test('initializes both before and after DOM ready and recovers when auto_fun first sees no article content', () => {
|
||
const early = createRuntime({ readyState: 'complete' });
|
||
const earlyBox = addBox(early.document);
|
||
const earlyRoot = articleRoot(early.document);
|
||
['One', 'Two', 'Three'].forEach((text) => addHeading(early.document, earlyRoot, 'h2', text));
|
||
early.autoFun();
|
||
assert.equal(earlyBox.querySelectorAll('ul.bl.relative.nav').length, 1);
|
||
|
||
const late = createRuntime({ readyState: 'loading' });
|
||
const lateBox = addBox(late.document);
|
||
const lateRoot = articleRoot(late.document);
|
||
['One', 'Two', 'Three'].forEach((text) => addHeading(late.document, lateRoot, 'h2', text));
|
||
assert.equal(lateBox.querySelector('.posts-nav-lists'), null);
|
||
late.dispatchReady();
|
||
assert.equal(lateBox.querySelectorAll('ul.bl.relative.nav').length, 1);
|
||
|
||
const delayed = createRuntime({ readyState: 'complete' });
|
||
const delayedBox = addBox(delayed.document);
|
||
delayed.autoFun();
|
||
assert.equal(delayedBox.querySelector('.posts-nav-lists'), null);
|
||
const delayedRoot = articleRoot(delayed.document);
|
||
['One', 'Two', 'Three'].forEach((text) => addHeading(delayed.document, delayedRoot, 'h2', text));
|
||
delayed.autoFun();
|
||
assert.equal(delayedBox.querySelectorAll('ul.bl.relative.nav').length, 1);
|
||
});
|
||
|
||
test('short or missing headings preserve pre-existing box content but clear this script’s stale TOC', () => {
|
||
const runtime = createRuntime();
|
||
const box = addBox(runtime.document);
|
||
const external = runtime.document.createElement('div');
|
||
external.className = 'other-widget-content';
|
||
external.textContent = 'keep me';
|
||
box.appendChild(external);
|
||
const rootNode = articleRoot(runtime.document);
|
||
const first = addHeading(runtime.document, rootNode, 'h2', 'One');
|
||
const second = addHeading(runtime.document, rootNode, 'h2', 'Two');
|
||
|
||
runtime.autoFun();
|
||
assert.equal(box.firstChild, external);
|
||
assert.equal(box.querySelector('.other-widget-content').textContent, 'keep me');
|
||
assert.equal(box.querySelector('.posts-nav-lists'), null);
|
||
assert.equal(box.dataset.aksrtSectionNav, undefined);
|
||
|
||
const third = addHeading(runtime.document, rootNode, 'h2', 'Three');
|
||
runtime.autoFun();
|
||
assert.equal(box.dataset.aksrtSectionNav, '1');
|
||
assert.ok(box.querySelector('.posts-nav-lists'));
|
||
|
||
third.remove();
|
||
runtime.autoFun();
|
||
assert.equal(box.querySelector('.posts-nav-lists'), null);
|
||
assert.equal(box.children.length, 0);
|
||
assert.equal(box.dataset.aksrtSectionNav, undefined);
|
||
assert.ok(first.parentNode && second.parentNode);
|
||
});
|
||
|
||
test('renders reference list shape for three real headings and excludes no-nav and item-heading', () => {
|
||
const runtime = createRuntime();
|
||
const box = addBox(runtime.document, '目录');
|
||
const rootNode = articleRoot(runtime.document);
|
||
addHeading(runtime.document, rootNode, 'h2', 'Post card title', { className: 'item-heading' });
|
||
const excluded = runtime.document.createElement('div');
|
||
excluded.className = 'no-nav';
|
||
rootNode.appendChild(excluded);
|
||
addHeading(runtime.document, excluded, 'h2', 'Excluded section');
|
||
const headings = [
|
||
addHeading(runtime.document, rootNode, 'h1', 'Main section'),
|
||
addHeading(runtime.document, rootNode, 'h2', 'Nested section'),
|
||
addHeading(runtime.document, rootNode, 'h3', 'Final section'),
|
||
];
|
||
runtime.autoFun();
|
||
|
||
const list = box.querySelector('ul.bl.relative.nav');
|
||
assert.ok(list);
|
||
assert.equal(list.parentElement.className, 'posts-nav-lists scroll-y mini-scrollbar list-unstyled');
|
||
assert.equal(list.querySelectorAll('li').length, 3);
|
||
assert.deepEqual(list.querySelectorAll('a').map((link) => link.textContent), ['Main section', 'Nested section', 'Final section']);
|
||
assert.equal(box.querySelector('.title-theme').textContent, '目录');
|
||
assert.ok(box.querySelector('.zib-widget'));
|
||
headings.forEach((heading) => assert.match(heading.id, /^wznav_/));
|
||
});
|
||
|
||
test('multiple directory copies synchronize current location using viewport position', () => {
|
||
const runtime = createRuntime();
|
||
const firstBox = addBox(runtime.document, '目录一');
|
||
const secondBox = addBox(runtime.document, '目录二');
|
||
const rootNode = articleRoot(runtime.document);
|
||
const headings = [
|
||
addHeading(runtime.document, rootNode, 'h2', 'First', { top: 140, offsetTop: 0 }),
|
||
addHeading(runtime.document, rootNode, 'h2', 'Second', { top: 100, offsetTop: 5000 }),
|
||
addHeading(runtime.document, rootNode, 'h2', 'Third', { top: 900, offsetTop: 10 }),
|
||
];
|
||
runtime.autoFun();
|
||
runtime.window.dispatch('scroll');
|
||
runtime.flushFrames();
|
||
|
||
for (const box of [firstBox, secondBox]) {
|
||
const active = box.querySelector('.posts-nav-lists li.active a');
|
||
assert.equal(active.textContent, 'Second');
|
||
assert.equal(active.getAttribute('aria-current'), 'location');
|
||
assert.equal(box.querySelectorAll('.posts-nav-lists a[aria-current="location"]').length, 1);
|
||
}
|
||
assert.equal(headings[2].offsetTop, 10, 'offsetTop intentionally disagrees with viewport position');
|
||
});
|
||
|
||
test('a hidden accordion heading does not take the current highlight from visible content', () => {
|
||
const runtime = createRuntime();
|
||
const box = addBox(runtime.document);
|
||
const rootNode = articleRoot(runtime.document);
|
||
addHeading(runtime.document, rootNode, 'h2', 'Visible current', { top: 80 });
|
||
const hidden = addHeading(runtime.document, rootNode, 'h2', 'Hidden panel heading', { top: 10 });
|
||
hidden.visible = false;
|
||
addHeading(runtime.document, rootNode, 'h2', 'Visible later', { top: 240 });
|
||
runtime.autoFun();
|
||
|
||
assert.equal(box.querySelector('.posts-nav-lists li.active a').textContent, 'Visible current');
|
||
const links = box.querySelectorAll('.posts-nav-lists a');
|
||
assert.equal(links[1].parentElement.classList.contains('active'), false);
|
||
assert.equal(links[1].getAttribute('aria-current'), null);
|
||
});
|
||
|
||
test('encodes unusual heading IDs and treats heading text as text instead of executable markup', () => {
|
||
const runtime = createRuntime();
|
||
const box = addBox(runtime.document);
|
||
const rootNode = articleRoot(runtime.document);
|
||
const label = '<img src=x onerror=alert(1)> & 说明';
|
||
const special = 'part id / ?#雪';
|
||
const [one, two, three] = [
|
||
addHeading(runtime.document, rootNode, 'h2', label, { id: special }),
|
||
addHeading(runtime.document, rootNode, 'h2', 'Second', { id: 'duplicate-id' }),
|
||
addHeading(runtime.document, rootNode, 'h2', 'Third', { id: 'duplicate-id' }),
|
||
];
|
||
runtime.autoFun();
|
||
const link = box.querySelector('a[href^="#"]');
|
||
assert.equal(link.getAttribute('href'), '#' + encodeURIComponent(special));
|
||
assert.equal(link.textContent, label);
|
||
assert.equal(link.querySelector('img'), null);
|
||
assert.equal(one.id, special);
|
||
assert.equal(two.id, 'duplicate-id');
|
||
assert.notEqual(three.id, two.id);
|
||
assert.equal(runtime.document.getElementById(three.id), three);
|
||
});
|
||
|
||
test('long directories add accessible group toggles and hide then reveal the group rows', () => {
|
||
const runtime = createRuntime();
|
||
const originalCreate = runtime.document.createElement.bind(runtime.document);
|
||
runtime.document.createElement = (tag) => {
|
||
const element = originalCreate(tag);
|
||
if (String(tag).toLowerCase() === 'ul') element.scrollHeight = 500;
|
||
return element;
|
||
};
|
||
const box = addBox(runtime.document);
|
||
const rootNode = articleRoot(runtime.document);
|
||
addHeading(runtime.document, rootNode, 'h1', 'Chapter one');
|
||
addHeading(runtime.document, rootNode, 'h2', 'Section one');
|
||
addHeading(runtime.document, rootNode, 'h3', 'Detail one');
|
||
addHeading(runtime.document, rootNode, 'h1', 'Chapter two');
|
||
addHeading(runtime.document, rootNode, 'h2', 'Section two');
|
||
runtime.autoFun();
|
||
|
||
const firstItem = box.querySelector('.n-H2');
|
||
assert.equal(box.querySelector('ul.bl').scrollHeight, 500);
|
||
const toggle = firstItem.querySelector('.nav-toggle-collapse');
|
||
assert.ok(toggle);
|
||
assert.equal(toggle.tagName, 'BUTTON');
|
||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||
assert.equal(firstItem.nextElementSibling.classList.contains('yc'), true);
|
||
toggle.click();
|
||
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
|
||
assert.equal(firstItem.nextElementSibling.classList.contains('yc'), false);
|
||
assert.equal(toggle.classList.contains('fa-rotate-90'), true);
|
||
toggle.click();
|
||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||
assert.equal(firstItem.nextElementSibling.classList.contains('yc'), true);
|
||
});
|
||
|
||
test('TOC click reveals collapsed content, exits mobile menu, and subtracts fixed header plus 20px', () => {
|
||
const runtime = createRuntime({ navFixed: true, pageYOffset: 400 });
|
||
addBox(runtime.document);
|
||
const rootNode = articleRoot(runtime.document);
|
||
const panel = runtime.document.createElement('div');
|
||
panel.className = 'panel';
|
||
rootNode.appendChild(panel);
|
||
const collapse = runtime.document.createElement('div');
|
||
collapse.className = 'collapse';
|
||
panel.appendChild(collapse);
|
||
const heading = addHeading(runtime.document, panel, 'h2', 'Open this section', { top: 300 });
|
||
addHeading(runtime.document, rootNode, 'h2', 'Second');
|
||
addHeading(runtime.document, rootNode, 'h2', 'Third');
|
||
const header = runtime.document.createElement('header');
|
||
header.className = 'header';
|
||
header.offsetHeight = 60;
|
||
runtime.document.body.appendChild(header);
|
||
runtime.document.body.classList.add('mobile-navbar-show');
|
||
const readMore = runtime.document.createElement('button');
|
||
readMore.className = 'read-more-open';
|
||
let readMoreClicks = 0;
|
||
readMore.addEventListener('click', () => { readMoreClicks++; });
|
||
runtime.document.body.appendChild(readMore);
|
||
runtime.autoFun();
|
||
|
||
const link = runtime.document.querySelector('.posts-nav-lists a');
|
||
const event = link.click();
|
||
assert.equal(event.defaultPrevented, true);
|
||
assert.deepEqual(runtime.collapseCalls.map((call) => call.action), ['show']);
|
||
assert.equal(runtime.collapseCalls[0].children[0], collapse);
|
||
assert.equal(readMoreClicks, 1);
|
||
assert.equal(runtime.document.body.classList.contains('mobile-navbar-show'), false);
|
||
assert.equal(runtime.scrollCalls[0].top, 620);
|
||
assert.equal(runtime.scrollCalls[0].behavior, 'smooth');
|
||
assert.equal(heading.rectTop, 300);
|
||
});
|
||
|
||
test('hash navigation decodes special IDs and reduced motion disables smooth scrolling', () => {
|
||
const special = 'section / id?#';
|
||
const runtime = createRuntime({ hash: '#' + encodeURIComponent(special), reducedMotion: true, pageYOffset: 50 });
|
||
addBox(runtime.document);
|
||
const rootNode = articleRoot(runtime.document);
|
||
const target = addHeading(runtime.document, rootNode, 'h2', 'Target', { id: special, top: 100 });
|
||
addHeading(runtime.document, rootNode, 'h2', 'Second');
|
||
addHeading(runtime.document, rootNode, 'h2', 'Third');
|
||
runtime.autoFun();
|
||
runtime.flushTimers();
|
||
assert.equal(runtime.scrollCalls[0].top, 150);
|
||
assert.equal(runtime.scrollCalls[0].behavior, 'auto');
|
||
assert.equal(target.id, special);
|
||
});
|