fix(widgets): restore article table of contents behavior and styles
This commit is contained in:
1 parent
fc04bf65e6
commit
ea80cd96cc
15 files changed
+965
-407
No files matched your search
+34
-1
@@ -4804,6 +4804,21 @@ body:has(.modal.in) .float-btn,
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.posts-nav-box {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.42857143;
|
||||
}
|
||||
|
||||
.posts-nav-box .zib-widget {
|
||||
clear: both;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border-radius: var(--main-radius);
|
||||
background-color: var(--main-bg-color);
|
||||
box-shadow: 0 0 10px var(--main-shadow);
|
||||
}
|
||||
|
||||
.posts-nav-lists {
|
||||
max-height: 400px;
|
||||
padding-left: 10px;
|
||||
@@ -4838,6 +4853,24 @@ body:has(.modal.in) .float-btn,
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.posts-nav-lists .nav-toggle-collapse {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.posts-nav-box .posts-nav-lists :where(a, .nav-toggle-collapse):focus-visible {
|
||||
outline: 3px solid var(--focus-color) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.posts-nav-lists .nav-toggle-collapse {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.posts-nav-lists li a:hover,
|
||||
.posts-nav-lists li.active a,
|
||||
.nav .active a,
|
||||
@@ -11177,7 +11210,7 @@ body.error404 {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.mobile-navbar .posts-nav-box .aksrt-panel {
|
||||
.mobile-navbar .posts-nav-box .zib-widget {
|
||||
background: var(--muted-border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
body{--mian-max-width: 1200px;--posts-card-scale: 70%;--posts-list-scale: 70%;--theme-color: #f04494;--focus-color: var(--theme-color);--focus-shadow-color: rgba(253, 83, 161, .4);--key-color: #333;--main-color: #4e5358;--main-shadow: rgba(116, 116, 116, .08);--muted-color: #777;--muted-2-color: #999;--muted-3-color: #b1b1b1;--muted-4-color: #d2d2d2;--body-bg-color: #f5f6f7;--main-bg-color: #fff;--muted-bg-color: #eee;--muted-bg-2-color: #f6f7f8;--main-border-color: rgba(50, 50, 50, .06);--muted-border-color: rgba(0, 0, 0, .03);--main-radius: 8px;--mini-radius: calc(var(--main-radius) / 1.4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}.dark-theme{--key-color: #f8fafc;--main-color: #e5eef7;--main-shadow: rgba(24, 24, 24, .1);--muted-color: #b4b6bb;--muted-2-color: #888a8f;--muted-3-color: #636469;--muted-4-color: #43454a;--body-bg-color: #292a2d;--main-bg-color: #323335;--muted-bg-color: #2d2e31;--muted-bg-2-color: #37383a;--main-border-color: rgba(114, 114, 114, .1);--muted-border-color: rgba(184, 184, 184, .02);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root){z-index:1}.dark::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){body{--blur-bg: #ffffff}body.dark-theme{--blur-bg: #37383a}}body:has(.modal.in),body.modal-open{--blur-bg: #ffffff}body.dark-theme:has(.modal.in),body.dark-theme.modal-open{--blur-bg: #37383a}body.fps-accelerat{--blur-bg: #ffffff}body.fps-accelerat.dark-theme{--blur-bg: #37383a}body.fps-accelerat *{-webkit-backdrop-filter:unset!important;backdrop-filter:unset!important}a[href],.relates-thumb li a>.post-info,.more-posts a>span{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.main-shadow{transition:box-shadow .3s}.but:focus,.but:hover,.progress,.navbar-nav>li:before{transition:.3s}.navbar-top .sub-menu,.theme-popover,.hover-show .hover-show-con{opacity:0;transform:translateY(6px);transition:.4s}.navbar-top li:hover>.sub-menu,.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{opacity:1;transform:translateY(0)}.hover-show{position:relative;cursor:pointer;z-index:10}.hover-show .hover-show-con{position:absolute;visibility:hidden}.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{visibility:unset}.match-ok,.navbar-top .sub-menu li>a,.ic-close{transition:.3s}.ic-close:hover{transform:rotate(270deg);color:inherit}.social_loginbar a,.item-thumbnail img,.dosc-nav .fa{transition:.2s ease-out}.hover-zoom:hover,.hover-zoom-img:hover img,.social_loginbar .xh-social-item:hover,.social_loginbar .social-login-item:hover{transform:scale(1.05)}.hover-zoom-sm:hover,.hover-zoom-img-sm:hover img,.posts-mini:hover img,.posts-item:hover .item-thumbnail img,.posts-item.mult-thumb .thumb-items>span>img:hover{transform:scale(1.02)}.main-min-height{min-height:calc(100vh - 280px)}.main-shadow{box-shadow:0 0 10px var(--main-shadow)}.key-color{color:var(--key-color)}.focus-color{--this-color: var(--focus-color);color:var(--focus-color)}.muted-color{color:var(--muted-color)}.muted-2-color{color:var(--muted-2-color)}.muted-3-color{color:var(--muted-3-color)}.main-bg{background:var(--main-bg-color)}.placeholder{background:linear-gradient(118deg,#7979791a 25%,#79797933 37%,#7979791a 63%);opacity:1;border-radius:4px;-webkit-animation:placeholder 1.5s ease infinite!important;animation:placeholder 1.5s ease infinite!important;background-size:400% 100%!important}.placeholder.img{height:119px}.placeholder.t1{height:24px;width:80%}.placeholder.k1{height:20px;width:60%}.placeholder.k2{height:20px}.placeholder.k3{height:15px}.placeholder.s1{width:70px;height:15px;display:inline-block}@-webkit-keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@-webkit-keyframes fade{0%{opacity:0}to{opacity:1}}@keyframes fade{0%{opacity:0}to{opacity:1}}.ani.shake{-webkit-animation:shake .3s ease-in!important;animation:shake .3s ease-in!important}@-webkit-keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}@keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}.obs-animate{opacity:0;transition:opacity .6sLine truncated
|
||||
body{--mian-max-width: 1200px;--posts-card-scale: 70%;--posts-list-scale: 70%;--theme-color: #f04494;--focus-color: var(--theme-color);--focus-shadow-color: rgba(253, 83, 161, .4);--key-color: #333;--main-color: #4e5358;--main-shadow: rgba(116, 116, 116, .08);--muted-color: #777;--muted-2-color: #999;--muted-3-color: #b1b1b1;--muted-4-color: #d2d2d2;--body-bg-color: #f5f6f7;--main-bg-color: #fff;--muted-bg-color: #eee;--muted-bg-2-color: #f6f7f8;--main-border-color: rgba(50, 50, 50, .06);--muted-border-color: rgba(0, 0, 0, .03);--main-radius: 8px;--mini-radius: calc(var(--main-radius) / 1.4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}.dark-theme{--key-color: #f8fafc;--main-color: #e5eef7;--main-shadow: rgba(24, 24, 24, .1);--muted-color: #b4b6bb;--muted-2-color: #888a8f;--muted-3-color: #636469;--muted-4-color: #43454a;--body-bg-color: #292a2d;--main-bg-color: #323335;--muted-bg-color: #2d2e31;--muted-bg-2-color: #37383a;--main-border-color: rgba(114, 114, 114, .1);--muted-border-color: rgba(184, 184, 184, .02);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root){z-index:1}.dark::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){body{--blur-bg: #ffffff}body.dark-theme{--blur-bg: #37383a}}body:has(.modal.in),body.modal-open{--blur-bg: #ffffff}body.dark-theme:has(.modal.in),body.dark-theme.modal-open{--blur-bg: #37383a}body.fps-accelerat{--blur-bg: #ffffff}body.fps-accelerat.dark-theme{--blur-bg: #37383a}body.fps-accelerat *{-webkit-backdrop-filter:unset!important;backdrop-filter:unset!important}a[href],.relates-thumb li a>.post-info,.more-posts a>span{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.main-shadow{transition:box-shadow .3s}.but:focus,.but:hover,.progress,.navbar-nav>li:before{transition:.3s}.navbar-top .sub-menu,.theme-popover,.hover-show .hover-show-con{opacity:0;transform:translateY(6px);transition:.4s}.navbar-top li:hover>.sub-menu,.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{opacity:1;transform:translateY(0)}.hover-show{position:relative;cursor:pointer;z-index:10}.hover-show .hover-show-con{position:absolute;visibility:hidden}.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{visibility:unset}.match-ok,.navbar-top .sub-menu li>a,.ic-close{transition:.3s}.ic-close:hover{transform:rotate(270deg);color:inherit}.social_loginbar a,.item-thumbnail img,.dosc-nav .fa{transition:.2s ease-out}.hover-zoom:hover,.hover-zoom-img:hover img,.social_loginbar .xh-social-item:hover,.social_loginbar .social-login-item:hover{transform:scale(1.05)}.hover-zoom-sm:hover,.hover-zoom-img-sm:hover img,.posts-mini:hover img,.posts-item:hover .item-thumbnail img,.posts-item.mult-thumb .thumb-items>span>img:hover{transform:scale(1.02)}.main-min-height{min-height:calc(100vh - 280px)}.main-shadow{box-shadow:0 0 10px var(--main-shadow)}.key-color{color:var(--key-color)}.focus-color{--this-color: var(--focus-color);color:var(--focus-color)}.muted-color{color:var(--muted-color)}.muted-2-color{color:var(--muted-2-color)}.muted-3-color{color:var(--muted-3-color)}.main-bg{background:var(--main-bg-color)}.placeholder{background:linear-gradient(118deg,#7979791a 25%,#79797933 37%,#7979791a 63%);opacity:1;border-radius:4px;-webkit-animation:placeholder 1.5s ease infinite!important;animation:placeholder 1.5s ease infinite!important;background-size:400% 100%!important}.placeholder.img{height:119px}.placeholder.t1{height:24px;width:80%}.placeholder.k1{height:20px;width:60%}.placeholder.k2{height:20px}.placeholder.k3{height:15px}.placeholder.s1{width:70px;height:15px;display:inline-block}@-webkit-keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@-webkit-keyframes fade{0%{opacity:0}to{opacity:1}}@keyframes fade{0%{opacity:0}to{opacity:1}}.ani.shake{-webkit-animation:shake .3s ease-in!important;animation:shake .3s ease-in!important}@-webkit-keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}@keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}.obs-animate{opacity:0;transition:opacity .6sLine truncated
|
||||
@@ -56,55 +56,6 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* TOC sidebar filled by section-navs.js */
|
||||
.posts-nav-box .posts-nav-lists,
|
||||
.posts-nav-box .aksrt-section-nav {
|
||||
max-height: 400px;
|
||||
padding-left: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.posts-nav-box .aksrt-panel {
|
||||
background: var(--main-bg-color, var(--surface-1, #fff));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px var(--main-shadow, rgba(0, 0, 0, 0.06));
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.posts-nav-lists .bl {
|
||||
border-left: 1px solid var(--main-border-color, #eee);
|
||||
padding: 12px 10px 12px 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.posts-nav-lists li {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.posts-nav-lists li a {
|
||||
margin: 0 10px;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-right: 20px;
|
||||
color: var(--muted-2-color, #666);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.posts-nav-lists li a:hover,
|
||||
.posts-nav-lists li.active a {
|
||||
background: var(--main-shadow, rgba(0, 0, 0, 0.04));
|
||||
color: var(--focus-color, var(--color-primary));
|
||||
}
|
||||
|
||||
.posts-nav-lists li.n-H1 a,
|
||||
.posts-nav-lists li.n-H2 a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* settings modal */
|
||||
#post-navs-sz.modal {
|
||||
position: fixed;
|
||||
|
||||
+158
-113
@@ -1,141 +1,186 @@
|
||||
/**
|
||||
* AKSRT Section Navs — legacy section_navs parity (posts-nav-lists + scrollspy).
|
||||
*
|
||||
* Builds TOC from [data-nav] h1–h4 into .posts-nav-box / .posts-nav-lists.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
/** Article directory: one renderer for sidebar, affix and mobile copies. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var HEADING_SEL = 'h1, h2, h3, h4';
|
||||
var MIN_HEADINGS = 3;
|
||||
var NAVBOX = '.posts-nav-box';
|
||||
var NAV_CLASS = 'posts-nav-lists';
|
||||
var entries = [];
|
||||
var rendered = new WeakMap();
|
||||
var framePending = false;
|
||||
var handledHash = '';
|
||||
|
||||
function isExcluded(el) {
|
||||
return el.classList.contains('item-heading') || !!el.closest('.no-nav');
|
||||
function hashId(hash) {
|
||||
try { return decodeURIComponent(hash.slice(1)); }
|
||||
catch (error) { return ''; }
|
||||
}
|
||||
|
||||
function ensureId(el, index) {
|
||||
if (!el.id) {
|
||||
el.id = 'wznav_' + index;
|
||||
}
|
||||
return el.id;
|
||||
}
|
||||
|
||||
function buildLists(root) {
|
||||
var headings = root.querySelectorAll(HEADING_SEL);
|
||||
var html = '';
|
||||
var count = 0;
|
||||
headings.forEach(function (h, i) {
|
||||
if (isExcluded(h)) return;
|
||||
var id = ensureId(h, i);
|
||||
var tag = h.tagName.toUpperCase();
|
||||
var text = (h.textContent || '').trim();
|
||||
if (!text) return;
|
||||
html += '<li class="n-' + tag + '"><a href="#' + id + '">' + text + '</a></li>';
|
||||
count++;
|
||||
});
|
||||
return count >= MIN_HEADINGS ? html : '';
|
||||
}
|
||||
|
||||
function fillBoxes(listsHtml) {
|
||||
document.querySelectorAll(NAVBOX).forEach(function (box) {
|
||||
var existing = box.querySelector('.' + NAV_CLASS);
|
||||
if (existing) {
|
||||
existing.innerHTML = listsHtml;
|
||||
return;
|
||||
function collectHeadings() {
|
||||
var found = [];
|
||||
document.querySelectorAll('[data-nav] h1, [data-nav] h2, [data-nav] h3, [data-nav] h4').forEach(function (heading, index) {
|
||||
var text = (heading.textContent || '').trim();
|
||||
if (!text || heading.classList.contains('item-heading') || heading.closest('.no-nav')) return;
|
||||
if (!heading.id || document.getElementById(heading.id) !== heading) {
|
||||
var id = 'wznav_' + index;
|
||||
var suffix = 0;
|
||||
while (document.getElementById(id)) id = 'wznav_' + index + '_' + (++suffix);
|
||||
heading.id = id;
|
||||
}
|
||||
var title = box.getAttribute('data-title') || '';
|
||||
var titleHtml = title
|
||||
? '<div class="box-body notop"><div class="title-theme">' + title + '</div></div>'
|
||||
: '';
|
||||
box.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
titleHtml +
|
||||
'<div class="zib-widget"><div class="' +
|
||||
NAV_CLASS +
|
||||
' scroll-y mini-scrollbar list-unstyled">' +
|
||||
listsHtml +
|
||||
'</div></div>'
|
||||
);
|
||||
found.push({ element: heading, id: heading.id, text: text, tag: heading.tagName.toUpperCase() });
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function smoothTo(hash) {
|
||||
var el = document.querySelector(hash);
|
||||
if (!el) return;
|
||||
var offset = 0;
|
||||
var header = document.querySelector('.header');
|
||||
if (document.body.classList.contains('nav-fixed') && header) {
|
||||
offset = header.offsetHeight + 12;
|
||||
function groupChildren(item) {
|
||||
var children = [];
|
||||
var next = item.nextElementSibling;
|
||||
while (next && !next.classList.contains('n-H1') && !next.classList.contains('n-H2')) {
|
||||
children.push(next);
|
||||
next = next.nextElementSibling;
|
||||
}
|
||||
var top = el.getBoundingClientRect().top + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: top, behavior: 'smooth' });
|
||||
return children;
|
||||
}
|
||||
|
||||
function bindClicks() {
|
||||
document.addEventListener('click', function (e) {
|
||||
var a = e.target && e.target.closest ? e.target.closest('.' + NAV_CLASS + ' a[href^="#"]') : null;
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href');
|
||||
if (!href || href.length < 2) return;
|
||||
e.preventDefault();
|
||||
history.replaceState(null, '', href);
|
||||
smoothTo(href);
|
||||
function collapseGroups(list) {
|
||||
if (list.scrollHeight <= 380 || list.querySelector('.nav-toggle-collapse')) return;
|
||||
list.querySelectorAll('.n-H1, .n-H2').forEach(function (item) {
|
||||
var children = groupChildren(item);
|
||||
if (!children.length) return;
|
||||
children.forEach(function (child) { child.classList.add('yc'); });
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'fa fas fa-angle-right nav-toggle-collapse';
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.setAttribute('aria-label', item.querySelector('a').textContent);
|
||||
item.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function fillBox(box, signature) {
|
||||
if (entries.length < 3) {
|
||||
if (box.dataset.aksrtSectionNav === '1') {
|
||||
box.replaceChildren();
|
||||
delete box.dataset.aksrtSectionNav;
|
||||
rendered.delete(box);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (rendered.get(box) === signature) {
|
||||
var current = box.querySelector('.bl');
|
||||
if (current) collapseGroups(current);
|
||||
return;
|
||||
}
|
||||
box.replaceChildren();
|
||||
box.dataset.aksrtSectionNav = '1';
|
||||
rendered.set(box, signature);
|
||||
var title = box.getAttribute('data-title') || '';
|
||||
if (title) {
|
||||
var titleBody = document.createElement('div');
|
||||
titleBody.className = 'box-body notop';
|
||||
var titleText = document.createElement('div');
|
||||
titleText.className = 'title-theme';
|
||||
// data-title is escaped by the PHP renderer, including its optional small tag.
|
||||
titleText.innerHTML = title;
|
||||
titleBody.appendChild(titleText);
|
||||
box.appendChild(titleBody);
|
||||
}
|
||||
var widget = document.createElement('div');
|
||||
widget.className = 'zib-widget';
|
||||
var nav = document.createElement('div');
|
||||
nav.className = 'posts-nav-lists scroll-y mini-scrollbar list-unstyled';
|
||||
var list = document.createElement('ul');
|
||||
list.className = 'bl relative nav';
|
||||
entries.forEach(function (entry) {
|
||||
var item = document.createElement('li');
|
||||
item.className = 'n-' + entry.tag;
|
||||
var link = document.createElement('a');
|
||||
link.className = 'text-ellipsis';
|
||||
link.setAttribute('href', '#' + encodeURIComponent(entry.id));
|
||||
link.textContent = entry.text;
|
||||
item.appendChild(link);
|
||||
list.appendChild(item);
|
||||
});
|
||||
nav.appendChild(list);
|
||||
widget.appendChild(nav);
|
||||
box.appendChild(widget);
|
||||
collapseGroups(list);
|
||||
}
|
||||
|
||||
function scrollspy() {
|
||||
var links = document.querySelectorAll('.' + NAV_CLASS + ' a[href^="#"]');
|
||||
if (!links.length) return;
|
||||
var map = [];
|
||||
links.forEach(function (a) {
|
||||
var id = a.getAttribute('href').slice(1);
|
||||
var el = document.getElementById(id);
|
||||
if (el) map.push({ el: el, a: a, li: a.parentElement });
|
||||
if (entries.length < 3) return;
|
||||
var current = '';
|
||||
entries.forEach(function (entry) {
|
||||
if (!entry.element.getClientRects().length) return;
|
||||
if (entry.element.getBoundingClientRect().top <= 105) current = entry.id;
|
||||
});
|
||||
document.querySelectorAll('.posts-nav-lists a[href^="#"]').forEach(function (link) {
|
||||
var active = hashId(link.getAttribute('href')) === current;
|
||||
link.parentElement.classList.toggle('active', active);
|
||||
if (active) link.setAttribute('aria-current', 'location');
|
||||
else link.removeAttribute('aria-current');
|
||||
});
|
||||
function update() {
|
||||
var y = window.scrollY + 110;
|
||||
var current = null;
|
||||
for (var i = 0; i < map.length; i++) {
|
||||
if (map[i].el.offsetTop <= y) current = map[i];
|
||||
}
|
||||
map.forEach(function (m) {
|
||||
if (m.li) m.li.classList.toggle('active', m === current);
|
||||
});
|
||||
}
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
update();
|
||||
}
|
||||
|
||||
function autoHash() {
|
||||
if (window.location.hash) {
|
||||
setTimeout(function () {
|
||||
smoothTo(window.location.hash);
|
||||
}, 40);
|
||||
}
|
||||
function scheduleSpy() {
|
||||
if (framePending) return;
|
||||
framePending = true;
|
||||
window.requestAnimationFrame(function () {
|
||||
framePending = false;
|
||||
scrollspy();
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToHeading(target) {
|
||||
// Reference behavior: reveal content hidden by an accordion or height limit.
|
||||
var panels = jQuery(target).parents('.panel').find('.collapse');
|
||||
if (typeof panels.collapse === 'function') panels.collapse('show');
|
||||
document.querySelectorAll('.read-more-open').forEach(function (button) { button.click(); });
|
||||
document.body.classList.remove('mobile-navbar-show');
|
||||
var header = document.querySelector('.header');
|
||||
var offset = document.body.classList.contains('nav-fixed') && header ? header.offsetHeight + 20 : 0;
|
||||
var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
window.scrollTo({ top: Math.max(0, target.getBoundingClientRect().top + window.pageYOffset - offset), behavior: reduced ? 'auto' : 'smooth' });
|
||||
scheduleSpy();
|
||||
}
|
||||
|
||||
function followHash() {
|
||||
var hash = window.location.hash;
|
||||
if (!hash || hash === handledHash) return;
|
||||
var id = hashId(hash);
|
||||
var entry = entries.find(function (item) { return item.id === id; });
|
||||
if (!entry || entries.length < 3) return;
|
||||
handledHash = hash;
|
||||
window.setTimeout(function () { scrollToHeading(entry.element); }, 20);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (document.documentElement.dataset.aksrtSectionNavInitialized === '1') return;
|
||||
document.documentElement.dataset.aksrtSectionNavInitialized = '1';
|
||||
if (document.querySelector('.posts-nav-box .toc-list, .posts-nav-box.toc-widget .toc-list')) {
|
||||
return;
|
||||
}
|
||||
var root = document.querySelector('[data-nav], .article-content, .entry-content, main.article');
|
||||
if (!root) return;
|
||||
var listsHtml = buildLists(root);
|
||||
if (!listsHtml) return;
|
||||
if (!document.querySelector(NAVBOX) && !document.querySelector('.' + NAV_CLASS)) {
|
||||
return;
|
||||
}
|
||||
fillBoxes(listsHtml);
|
||||
bindClicks();
|
||||
entries = collectHeadings();
|
||||
var signature = JSON.stringify(entries.map(function (entry) { return [entry.id, entry.text, entry.tag]; }));
|
||||
document.querySelectorAll('.posts-nav-box').forEach(function (box) {
|
||||
fillBox(box, signature + (box.getAttribute('data-title') || ''));
|
||||
});
|
||||
scrollspy();
|
||||
autoHash();
|
||||
followHash();
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var button = event.target.closest('.posts-nav-lists .nav-toggle-collapse');
|
||||
if (button) {
|
||||
event.preventDefault();
|
||||
var expanded = button.getAttribute('aria-expanded') !== 'true';
|
||||
button.setAttribute('aria-expanded', String(expanded));
|
||||
button.classList.toggle('fa-rotate-90', expanded);
|
||||
groupChildren(button.parentElement).forEach(function (child) { child.classList.toggle('yc', !expanded); });
|
||||
return;
|
||||
}
|
||||
var link = event.target.closest('.posts-nav-lists a[href^="#"]');
|
||||
if (!link) return;
|
||||
var target = document.getElementById(hashId(link.getAttribute('href')));
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
scrollToHeading(target);
|
||||
});
|
||||
window.addEventListener('scroll', scheduleSpy, { passive: true });
|
||||
window.addEventListener('resize', init, { passive: true });
|
||||
window.addEventListener('hashchange', followHash);
|
||||
jQuery(document).off('auto_fun.aksrt-section-navs').on('auto_fun.aksrt-section-navs', init);
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
else init();
|
||||
})();
|
||||
@@ -546,16 +546,6 @@ trait ScriptsPart1b {
|
||||
(string) filemtime($pn_css)
|
||||
);
|
||||
}
|
||||
$section_navs = get_template_directory() . '/assets/js/section-navs.js';
|
||||
if (file_exists($section_navs)) {
|
||||
wp_enqueue_script(
|
||||
'aksrt-section-navs',
|
||||
$base_uri . '/assets/js/section-navs.js',
|
||||
['jquery'],
|
||||
(string) filemtime($section_navs),
|
||||
true
|
||||
);
|
||||
}
|
||||
$pn_js = get_template_directory() . '/assets/js/posts-navs-page.js';
|
||||
if (file_exists($pn_js)) {
|
||||
wp_enqueue_script(
|
||||
|
||||
@@ -69,7 +69,6 @@ trait ScriptsPart1c {
|
||||
'/(?:<!--\s*wp:(?:code|preformatted|aksrt\/enlighter)\b|<pre\b|data-enlighter-language)/i',
|
||||
$display_content
|
||||
);
|
||||
$heading_count = preg_match_all('/<h[1-4]\b/i', $display_content);
|
||||
$has_hidden_content = $is_paid_content_single || (bool) preg_match(
|
||||
'/(?:<!--\s*wp:aksrt\/paid-fragment\b|\[(?:aksrt_hide|hide|reply|vip|payshow|hidecontent|content)\b|aksrt-(?:hidden-content|paywall))/i',
|
||||
$content
|
||||
@@ -106,10 +105,15 @@ trait ScriptsPart1c {
|
||||
$layout = function_exists('aksrt_get_post_show_layout')
|
||||
? (string) aksrt_get_post_show_layout($post_id)
|
||||
: '';
|
||||
$article_nav_enabled = !array_key_exists('article_nav', $settings) || !empty($settings['article_nav']);
|
||||
if ($article_nav_enabled && function_exists('get_post_meta') && class_exists(DomainModel::class)) {
|
||||
$extension_meta = get_post_meta($post_id, DomainModel::POST_META_KEY, true);
|
||||
$article_nav_enabled = !is_array($extension_meta) || empty($extension_meta['no_article-navs']);
|
||||
}
|
||||
|
||||
$requirements['is_article'] = $is_article;
|
||||
$requirements['has_code'] = $has_code;
|
||||
$requirements['has_toc'] = $is_article && $heading_count >= 2;
|
||||
$requirements['has_toc'] = $is_article && $article_nav_enabled;
|
||||
$requirements['has_video'] = $has_video;
|
||||
$requirements['has_hidden_content'] = $has_hidden_content || $has_yiyan || $has_inserted_content;
|
||||
$requirements['needs_pay_box'] = $is_paid_content_single || (bool) preg_match('/\[(?:payshow|hidecontent)\b|wp:aksrt\/paid-fragment/i', $content);
|
||||
|
||||
@@ -23,6 +23,12 @@ final class FrontendRenderer {
|
||||
}
|
||||
|
||||
AssetManager::enqueue_for($definition);
|
||||
if ($definition->slug() === 'posts_toc') {
|
||||
// The TOC renderer owns the reference widget-container wrapper and
|
||||
// displays its title inside the generated directory, not above it.
|
||||
echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
return;
|
||||
}
|
||||
if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts', 'posts'], true)) {
|
||||
self::render_reference_widget($definition, $instance, $widget, $content);
|
||||
return;
|
||||
@@ -31,7 +37,7 @@ final class FrontendRenderer {
|
||||
'post_pay', 'oneline_posts', 'hot_posts',
|
||||
'slider', 'slider_media_combo', 'icon_cards', 'icon_cover_cards',
|
||||
'buttons', 'notice', 'term_cards', 'term_aggregation', 'media_cards',
|
||||
'combo_info', 'partners', 'recent_comments', 'posts_toc',
|
||||
'combo_info', 'partners', 'recent_comments',
|
||||
], true)) {
|
||||
echo (string) ($args['before_widget'] ?? '');
|
||||
if ($definition->slug() !== 'post_pay') echo self::reference_title($instance);
|
||||
|
||||
@@ -72,7 +72,9 @@ final class WidgetCatalog {
|
||||
'common' => [
|
||||
'title' => !in_array($slug, ['post_pay', 'layout_gap', 'slider', 'slider_media_combo', 'buttons', 'posts_toc'], true),
|
||||
'affix' => ($size === 'sidebar' && $slug !== 'posts_toc') || in_array($slug, ['buttons', 'notice', 'posts'], true),
|
||||
'animation' => !in_array($slug, ['buttons', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'slider_media_combo'], true),
|
||||
'visibility' => $slug !== 'posts_toc',
|
||||
'layout' => $slug !== 'posts_toc',
|
||||
'animation' => !in_array($slug, ['buttons', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'slider_media_combo', 'posts_toc'], true),
|
||||
],
|
||||
'fields' => WidgetSchemas::for($slug),
|
||||
'assets' => self::assets($slug),
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
'use strict';
|
||||
|
||||
class MiniElement {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = String(tagName).toUpperCase();
|
||||
this.nodeType = this.tagName === '#TEXT' ? 3 : 1;
|
||||
this.parentNode = null;
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.dataset = {};
|
||||
this.listeners = {};
|
||||
this.offsetLeft = 0;
|
||||
this.offsetTop = 0;
|
||||
this.offsetHeight = 0;
|
||||
this.offsetWidth = 0;
|
||||
this.scrollHeight = 0;
|
||||
this.scrollWidth = 0;
|
||||
this.clientWidth = 0;
|
||||
this.scrollLeft = 0;
|
||||
this.rectTop = 0;
|
||||
this.rectLeft = 0;
|
||||
this.rectWidth = 0;
|
||||
this.visible = true;
|
||||
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 type() { return this.getAttribute('type') || ''; }
|
||||
set type(value) { this.setAttribute('type', value); }
|
||||
get parentElement() { return this.parentNode && this.parentNode.tagName !== '#DOCUMENT' ? this.parentNode : null; }
|
||||
get firstChild() { return this.children[0] || null; }
|
||||
get firstElementChild() { return this.children.find((child) => child.nodeType === 1) || null; }
|
||||
get nextElementSibling() {
|
||||
if (!this.parentNode) return null;
|
||||
const siblings = this.parentNode.children.filter((child) => child.nodeType === 1);
|
||||
return siblings[siblings.indexOf(this) + 1] || 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._text = '';
|
||||
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); }
|
||||
removeAttribute(name) {
|
||||
delete this.attributes[name];
|
||||
if (name === 'class') this.className = '';
|
||||
if (name.startsWith('data-')) delete this.dataset[toDatasetKey(name.slice(5))];
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
if (child.parentNode) child.remove();
|
||||
child.parentNode = this;
|
||||
child.ownerDocument = this.ownerDocument;
|
||||
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;
|
||||
child.ownerDocument = this.ownerDocument;
|
||||
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, options = {}) {
|
||||
(this.listeners[type] ||= []).push({ callback, once: Boolean(options && options.once) });
|
||||
}
|
||||
|
||||
removeEventListener(type, callback) {
|
||||
this.listeners[type] = (this.listeners[type] || []).filter((item) => item.callback !== 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) {
|
||||
const listeners = (node.listeners[type] || []).slice();
|
||||
for (const listener of listeners) {
|
||||
listener.callback(event);
|
||||
if (listener.once) node.removeEventListener(type, listener.callback);
|
||||
}
|
||||
if (event.propagationStopped) break;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
dispatchEvent(event) { return this.dispatch(event.type, event); }
|
||||
click() { return this.dispatch('click'); }
|
||||
focus() { this.ownerDocument.activeElement = this; }
|
||||
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentNode) {
|
||||
if (matchesSelector(node, selector)) return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
matches(selector) { return matchesSelector(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 { top: this.rectTop, left: this.rectLeft, width: this.rectWidth || this.offsetWidth }; }
|
||||
getClientRects() { return this.visible ? [this.getBoundingClientRect()] : []; }
|
||||
}
|
||||
|
||||
class MiniDocument extends MiniElement {
|
||||
constructor() {
|
||||
super('#document');
|
||||
this.nodeType = 9;
|
||||
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;
|
||||
}
|
||||
|
||||
getElementById(id) { return descendants(this).find((node) => node.id === String(id)) || null; }
|
||||
}
|
||||
|
||||
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, operator, expected] of selector.matchAll(/\[([\w-]+)(\^=|\$=|\*=|=)?(?:\s*["']?([^\]"']*)["']?)?\]/g)) {
|
||||
if (!node.hasAttribute(name)) return false;
|
||||
if (operator === '=') {
|
||||
if (node.getAttribute(name) !== expected) return false;
|
||||
} else if (operator === '^=' && !node.getAttribute(name).startsWith(expected)) return false;
|
||||
else if (operator === '$=' && !node.getAttribute(name).endsWith(expected)) return false;
|
||||
else if (operator === '*=' && !node.getAttribute(name).includes(expected)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesSingleSelector(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 matchesSelector(node, selector) {
|
||||
return String(selector).split(',').some((part) => matchesSingleSelector(node, part.trim()));
|
||||
}
|
||||
|
||||
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, doubleQuoted, singleQuoted, bare] of open[2].matchAll(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
||||
node.setAttribute(name, doubleQuoted ?? singleQuoted ?? bare ?? '');
|
||||
}
|
||||
stack[stack.length - 1].appendChild(node);
|
||||
if (!voidTags.has(node.tagName.toLowerCase()) && !token.endsWith('/>')) stack.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MiniDocument, MiniElement };
|
||||
@@ -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; 标题/);
|
||||
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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user