fix(widgets): restore article table of contents behavior and styles

This commit is contained in:
kate522 committed 2026-10-30 13:18:40 +08:00
1 parent fc04bf65e6
commit ea80cd96cc
15 files changed
+965 -407

No files matched your search

@@ -708,7 +708,7 @@ test('single articles enqueue only content and settings driven assets', () => {
}
assert.match(runtime, /wp:\(\?:code\|preformatted\|aksrt\\\/enlighter\)/);
assert.match(runtime, /preg_match_all\('\/<h\[1-4\]/);
assert.match(runtime, /\$requirements\['has_toc'\] = \$is_article && \$article_nav_enabled/);
assert.match(runtime, /data-aksrt-video/);
assert.match(runtime, /post_password_required\(\$post_id\)/);
assert.match(runtime, /comment_settings\['close_comments'\]/);
@@ -0,0 +1,40 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('article TOC widget uses the shared reference tree styles, not posts-navs page CSS', () => {
const routeCss = read('assets/css/routes/posts-navs.css');
const mainCss = read('assets/css/main.css');
assert.doesNotMatch(routeCss, /\.posts-nav-box|\.posts-nav-lists|\.aksrt-section-nav/);
for (const selector of [
'.posts-nav-lists .bl',
'.posts-nav-lists li.n-H1 a',
'.posts-nav-lists li.n-H3 a',
'.posts-nav-lists li.active::before',
]) assert.ok(mainCss.includes(selector), `main.css retains reference tree rule ${selector}`);
assert.match(mainCss, /\.posts-nav-box\s*\{[^}]*font-family:\s*inherit;[^}]*font-size:\s*14px;[^}]*line-height:\s*1\.42857143;/s);
const panel = mainCss.match(/\.posts-nav-box \.zib-widget\s*\{([^}]*)\}/)?.[1] || '';
for (const declaration of [
/background-color:\s*var\(--main-bg-color\)/,
/padding:\s*15px/,
/border-radius:\s*var\(--main-radius\)/,
/box-shadow:\s*0 0 10px var\(--main-shadow\)/,
/margin-bottom:\s*20px/,
]) assert.match(panel, declaration);
});
test('mobile TOC panel and its collapse control remain styled and accessible', () => {
const mainCss = read('assets/css/main.css');
assert.match(mainCss, /\.mobile-navbar \.posts-nav-box \.zib-widget\s*\{[^}]*background:\s*var\(--muted-border-color\);[^}]*box-shadow:\s*none;/s);
assert.match(mainCss, /\.posts-nav-lists \.nav-toggle-collapse\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;/s);
assert.match(mainCss, /\.posts-nav-box \.posts-nav-lists :where\(a, \.nav-toggle-collapse\):focus-visible/);
assert.match(mainCss, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.posts-nav-lists \.nav-toggle-collapse \{\s*transition:\s*none;/);
assert.doesNotMatch(mainCss, /\.mobile-navbar \.posts-nav-box \.aksrt-panel/);
assert.doesNotMatch(read('assets/widgets/frontend.css'), /\.posts-nav-box|\.posts-nav-lists/);
});
@@ -0,0 +1,56 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const { execFileSync } = require('node:child_process');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('TOC widget renders one escaped title and asset eligibility follows saved article settings', () => {
const fixture = JSON.parse(execFileSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'posts-toc-render-harness.php')], { encoding: 'utf8' }));
assert.equal(fixture.dynamic_headings, true, 'shortcode headings must get the directory script');
assert.equal(fixture.global_disabled, false);
assert.equal(fixture.post_disabled, false);
assert.equal(fixture.other_page, false);
assert.equal(fixture.non_article_widget, '');
assert.deepEqual(fixture.widget_fields, ['title', 'mini_title', 'in_affix'], 'TOC exposes only its three reference settings');
assert.match(fixture.widget, /^<div class="widget-container"><div data-affix="true" class="posts-nav-box"/);
assert.equal((fixture.widget.match(/data-title=/g) || []).length, 1);
assert.match(fixture.widget, /&amp;amp; 标题/);
assert.doesNotMatch(fixture.widget, /<aside>|class="title-theme"|aksrt-widget-shell/);
});
test('article TOC renderer owns its reference container and title without the generic title wrapper', () => {
const frontend = read('src/WidgetSystem/FrontendRenderer.php');
const start = frontend.indexOf("if ($definition->slug() === 'posts_toc')");
const end = frontend.indexOf("if (in_array($definition->slug(), ['user_card'", start);
assert.ok(start >= 0 && end > start, 'TOC needs its dedicated render branch');
const branch = frontend.slice(start, end);
assert.match(branch, /echo \$content/);
assert.doesNotMatch(branch, /reference_title|before_widget|after_widget/);
assert.doesNotMatch(frontend.slice(frontend.indexOf("if (in_array($definition->slug(), ["), frontend.indexOf('$classes = [', frontend.indexOf("if (in_array($definition->slug(), ["))), /posts_toc/);
const renderer = read('src/WidgetSystem/Renderers/GeneralRenderer.php');
assert.match(renderer, /return '<div class="widget-container"><div' \. \$affix \. ' class="posts-nav-box" data-title="'/);
assert.match(renderer, /\$title = esc_html\(\(string\) \(\$i\['title'\] \?\? '文章目录'\)\)/);
});
test('TOC script enqueue follows article visibility settings and per-post override, not static source heading markup', () => {
const scripts = read('src/Core/ScriptsPart1c.php');
assert.match(scripts, /\$article_nav_enabled = !array_key_exists\('article_nav', \$settings\) \|\| !empty\(\$settings\['article_nav'\]\)/);
assert.match(scripts, /get_post_meta\(\$post_id, DomainModel::POST_META_KEY, true\)/);
assert.match(scripts, /empty\(\$extension_meta\['no_article-navs'\]\)/);
assert.match(scripts, /\$requirements\['has_toc'\] = \$is_article && \$article_nav_enabled/);
assert.doesNotMatch(scripts, /preg_match_all\('\/<h\[1-4\]/);
assert.match(scripts, /\$singular_assets\['has_toc'\][\s\S]*?'aksrt-section-navs'/);
const pageScripts = read('src/Core/ScriptsPart1b.php');
const pageBlockStart = pageScripts.indexOf("if (is_page_template('templates/pages/page-posts-navs.php'))");
const pageBlockEnd = pageScripts.indexOf('// 文章归档页', pageBlockStart);
const pageBlock = pageScripts.slice(pageBlockStart, pageBlockEnd);
assert.match(pageBlock, /aksrt-posts-navs-page/);
assert.doesNotMatch(pageBlock, /aksrt-section-navs|section-navs\.js/);
});
@@ -0,0 +1,54 @@
<?php
/** Exercise the production TOC widget and article asset resolver. */
declare(strict_types=1);
namespace AKSRT\Settings {
final class Settings {
public static function get_comment_display_settings(): array { return []; }
}
}
namespace AKSRT\WidgetSystem {
final class AssetManager {
public static function enqueue_for(WidgetDefinition $definition): void {}
}
}
namespace {
require __DIR__ . '/bootstrap.php';
function get_post_meta(int $id, string $key, bool $single = false) { return $GLOBALS['toc_meta'][$key] ?? []; }
function get_post_type(int $id): string { return 'post'; }
function post_password_required(int $id): bool { return false; }
function comments_open(int $id): bool { return false; }
function get_comments_number(int $id): int { return 0; }
function aksrt_resolve_single_post_display_settings(int $id): array { return $GLOBALS['toc_settings']; }
class TocPost extends WP_Post { public string $post_content = '[dynamic_headings]'; }
$GLOBALS['aksrt_test_state']['singular'] = true;
$GLOBALS['aksrt_test_state']['current_id'] = 8;
$GLOBALS['aksrt_test_state']['current_post'] = new TocPost(8);
$GLOBALS['toc_settings'] = [];
$GLOBALS['toc_meta'] = [];
$resolver = new class {
use \AKSRT\Core\ScriptsPart1c { resolve_singular_asset_requirements as public requirements; }
};
$cases = ['dynamic_headings' => $resolver->requirements(true, false, false)['has_toc']];
$GLOBALS['toc_settings'] = ['article_nav' => false];
$cases['global_disabled'] = $resolver->requirements(true, false, false)['has_toc'];
$GLOBALS['toc_settings'] = ['article_nav' => true];
$GLOBALS['toc_meta'][\AKSRT\Core\DomainModel::POST_META_KEY] = ['no_article-navs' => true];
$cases['post_disabled'] = $resolver->requirements(true, false, false)['has_toc'];
$GLOBALS['toc_meta'] = [];
$cases['other_page'] = $resolver->requirements(false, false, false)['has_toc'];
$definition = \AKSRT\WidgetSystem\WidgetRegistry::get('posts_toc');
$cases['widget_fields'] = array_column($definition->fields(), 'id');
$widget = new \AKSRT\WidgetSystem\SchemaWidget($definition);
$widget->_set(2);
ob_start();
$widget->widget(['before_widget' => '<aside>', 'after_widget' => '</aside>'], ['title' => '目录 & 标题', 'mini_title' => '副标题', 'in_affix' => true]);
$cases['widget'] = ob_get_clean();
$GLOBALS['aksrt_test_state']['singular'] = false;
ob_start();
$widget->widget([], []);
$cases['non_article_widget'] = ob_get_clean();
echo json_encode($cases, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
@@ -0,0 +1,333 @@
'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);
});
+1 -228
View File
@@ -8,234 +8,7 @@ const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
class MiniElement {
constructor(tagName = 'div') {
this.tagName = String(tagName).toUpperCase();
this.parentNode = null;
this.children = [];
this.attributes = {};
this.dataset = {};
this.listeners = {};
this.offsetLeft = 0;
this.offsetWidth = 0;
this.scrollWidth = 0;
this.clientWidth = 0;
this.scrollLeft = 0;
this.rectLeft = 0;
this.rectWidth = 0;
this.scrollCalls = [];
this._text = '';
this._classes = new Set();
this.classList = {
add: (...tokens) => tokens.forEach((token) => this._classes.add(token)),
remove: (...tokens) => tokens.forEach((token) => this._classes.delete(token)),
contains: (token) => this._classes.has(token),
toggle: (token, force) => {
const shouldAdd = force === undefined ? !this._classes.has(token) : Boolean(force);
if (shouldAdd) this._classes.add(token);
else this._classes.delete(token);
return shouldAdd;
},
};
}
get className() { return Array.from(this._classes).join(' '); }
set className(value) { this._classes = new Set(String(value || '').split(/\s+/).filter(Boolean)); }
get id() { return this.getAttribute('id') || ''; }
set id(value) { this.setAttribute('id', value); }
get max() { return this.getAttribute('max') || ''; }
set max(value) { this.setAttribute('max', value); }
get value() { return this._value || ''; }
set value(value) { this._value = String(value); }
get parentElement() { return this.parentNode && this.parentNode.tagName !== '#DOCUMENT' ? this.parentNode : null; }
get firstChild() { return this.children[0] || null; }
get textContent() { return this._text + this.children.map((child) => child.textContent).join(''); }
set textContent(value) { this._text = String(value); this.replaceChildren(); }
get innerHTML() { return this.children.map((child) => child.outerHTML || child.textContent).join(''); }
set innerHTML(markup) {
this.replaceChildren();
parseMarkup(String(markup || ''), this);
}
setAttribute(name, value) {
const text = String(value);
this.attributes[name] = text;
if (name === 'class') this.className = text;
if (name.startsWith('data-')) this.dataset[toDatasetKey(name.slice(5))] = text;
}
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
hasAttribute(name) { return Object.hasOwn(this.attributes, name); }
appendChild(child) {
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.push(child);
return child;
}
insertBefore(child, anchor) {
if (!anchor || anchor.parentNode !== this) return this.appendChild(child);
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.splice(this.children.indexOf(anchor), 0, child);
return child;
}
replaceChildren(...nodes) {
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
nodes.forEach((node) => this.appendChild(node));
}
remove() {
if (!this.parentNode) return;
const siblings = this.parentNode.children;
const index = siblings.indexOf(this);
if (index !== -1) siblings.splice(index, 1);
this.parentNode = null;
}
addEventListener(type, callback) {
(this.listeners[type] ||= []).push(callback);
}
dispatch(type, properties = {}) {
const event = {
type,
target: this,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() { this.propagationStopped = true; },
...properties,
};
let node = this;
while (node) {
for (const listener of node.listeners[type] || []) listener(event);
if (event.propagationStopped) break;
node = node.parentNode;
}
return event;
}
click() { return this.dispatch('click'); }
focus() { this.ownerDocument.activeElement = this; }
closest(selector) {
for (let node = this; node; node = node.parentNode) {
if (matchesCompound(node, selector)) return node;
}
return null;
}
matches(selector) { return matchesCompound(this, selector); }
querySelectorAll(selector) {
const directOnly = selector.startsWith(':scope > ');
const query = directOnly ? selector.slice(9) : selector;
const nodes = directOnly ? this.children.slice() : descendants(this);
return nodes.filter((node) => matchesSelector(node, query));
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
insertAdjacentHTML(position, markup) {
const fragment = this.ownerDocument.createElement('div');
fragment.innerHTML = markup;
const nodes = fragment.children.slice();
if (position === 'afterbegin') nodes.reverse().forEach((node) => this.insertBefore(node, this.firstChild));
else if (position === 'beforebegin' && this.parentNode) nodes.forEach((node) => this.parentNode.insertBefore(node, this));
else nodes.forEach((node) => this.appendChild(node));
}
scrollTo(options) { this.scrollCalls.push(options); }
getBoundingClientRect() { return { left: this.rectLeft, width: this.rectWidth || this.offsetWidth }; }
}
class MiniDocument extends MiniElement {
constructor() {
super('#document');
this.ownerDocument = this;
this.readyState = 'complete';
this.body = this.createElement('body');
this.appendChild(this.body);
this.activeElement = null;
}
createElement(tagName) {
const element = new MiniElement(tagName);
element.ownerDocument = this;
return element;
}
}
function toDatasetKey(value) { return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); }
function descendants(node) {
return node.children.flatMap((child) => [child, ...descendants(child)]);
}
function matchesCompound(node, selector) {
if (!selector || selector === '*') return true;
const tag = selector.match(/^[a-z][a-z0-9-]*/i);
if (tag && node.tagName.toLowerCase() !== tag[0].toLowerCase()) return false;
const id = selector.match(/#([a-zA-Z_][\w-]*)/);
if (id && node.getAttribute('id') !== id[1]) return false;
for (const [, className] of selector.matchAll(/\.([a-zA-Z_][\w-]*)/g)) {
if (!node.classList.contains(className)) return false;
}
for (const [, name, expected] of selector.matchAll(/\[([\w-]+)(?:=["']?([^\]"']+)["']?)?\]/g)) {
if (!node.hasAttribute(name)) return false;
if (expected !== undefined && node.getAttribute(name) !== expected) return false;
}
return true;
}
function matchesSelector(node, selector) {
const tokens = selector.replace(/>/g, ' > ').trim().split(/\s+/);
let cursor = node;
if (!matchesCompound(cursor, tokens.pop())) return false;
while (tokens.length) {
const combinator = tokens.pop();
const previous = combinator === '>' ? tokens.pop() : combinator;
if (combinator === '>') {
cursor = cursor.parentNode;
if (!cursor || !matchesCompound(cursor, previous)) return false;
} else {
cursor = cursor.parentNode;
while (cursor && !matchesCompound(cursor, previous)) cursor = cursor.parentNode;
if (!cursor) return false;
}
}
return true;
}
function parseMarkup(markup, parent) {
const tokens = markup.match(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g) || [];
const stack = [parent];
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
for (const token of tokens) {
if (token.startsWith('<!--') || token.trim() === '') continue;
if (token.startsWith('</')) {
if (stack.length > 1) stack.pop();
continue;
}
if (!token.startsWith('<')) {
const textNode = parent.ownerDocument.createElement('#text');
textNode._text = token;
stack[stack.length - 1].appendChild(textNode);
continue;
}
const open = token.match(/^<([a-z][\w-]*)([\s\S]*?)\/?\s*>$/i);
if (!open) continue;
const node = parent.ownerDocument.createElement(open[1]);
for (const [, name, quoted, bare] of open[2].matchAll(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
node.setAttribute(name, quoted ?? bare ?? '');
}
stack[stack.length - 1].appendChild(node);
if (!voidTags.has(node.tagName.toLowerCase()) && !token.endsWith('/>')) stack.push(node);
}
}
const { MiniDocument } = require('../helpers/mini-dom.cjs');
function tabMarkup(document, { firstPage = 1, firstSegmentLoaded = true } = {}) {
const root = document.createElement('div');