Homepage: seamless theme music that keeps playing on /members/
Adds the eom.dev theme (Strudel) as a 16-bar Web Audio loop (Opus, AAC fallback) with a Theme on/off button. Playback starts on the first interaction unless the visitor turned it off. While it plays, links between the homepage and membership pages are fetched and swapped in place, so the music doesn't restart; sign-in, checkout, billing and forms remain real navigations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,22 @@ can use the permanent channel and playlist links. Requests use 10-second timeout
|
|||||||
and a 2 MiB response limit. Titles are rendered as text, and watch/thumbnail URLs
|
and a 2 MiB response limit. Titles are rendered as text, and watch/thumbnail URLs
|
||||||
are restricted to the PeerTube origin. No descriptions, HTML, or players are embedded.
|
are restricted to the PeerTube origin. No descriptions, HTML, or players are embedded.
|
||||||
|
|
||||||
|
### Theme music
|
||||||
|
|
||||||
|
The homepage plays the eom.dev theme (composed in Strudel) as a seamless 16-bar
|
||||||
|
loop: `htdocs/static/theme-player.mjs` with `htdocs/static/audio/eom-theme-loop.ogg`
|
||||||
|
(Opus) and an AAC `.m4a` fallback. Web Audio loops it without a gap. Browsers
|
||||||
|
block sound until the first click or key press, so it starts then unless the
|
||||||
|
visitor switched it off with the "Theme" button (remembered in localStorage).
|
||||||
|
|
||||||
|
While it plays, links between the homepage and `/members/` pages are fetched
|
||||||
|
and swapped into the same document, so the music does not restart on the
|
||||||
|
membership page. Forms, sign-in, checkout, billing and other sites stay normal
|
||||||
|
navigations (`HARD_NAV` in the module), and with the music off every link is a
|
||||||
|
normal navigation. The membership templates do not load the player; a visitor
|
||||||
|
who opens `/members/` directly gets no music. Sources and longer versions are
|
||||||
|
kept outside this repository (`eom-theme.strudel.js`).
|
||||||
|
|
||||||
### Local preview and content checks
|
### Local preview and content checks
|
||||||
|
|
||||||
With Node.js 24 or later, run `node scripts/preview.mjs` from this repository
|
With Node.js 24 or later, run `node scripts/preview.mjs` from this repository
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<script type="module" src="/static/peertube-feeds.mjs"></script>
|
<script type="module" src="/static/peertube-feeds.mjs"></script>
|
||||||
<script type="module" src="/static/gitea-projects.mjs"></script>
|
<script type="module" src="/static/gitea-projects.mjs"></script>
|
||||||
|
<script type="module" src="/static/theme-player.mjs"></script>
|
||||||
<!--#include virtual="/common/footer.html" -->
|
<!--#include virtual="/common/footer.html" -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* eom.dev theme music for the homepage.
|
||||||
|
*
|
||||||
|
* Plays a seamless 16-bar loop of the eom.dev theme (Web Audio, so the loop has no
|
||||||
|
* gap). While it plays, links between the homepage and the membership pages are
|
||||||
|
* loaded in place (fetch + swap) instead of reloading the document, so the music
|
||||||
|
* keeps going on /members/. Anything else (forms, sign-in, checkout, other sites)
|
||||||
|
* is a normal navigation.
|
||||||
|
*
|
||||||
|
* Browsers block sound until the visitor interacts with the page, so playback
|
||||||
|
* starts on the first click or key press unless the visitor has turned it off.
|
||||||
|
* The choice is kept in localStorage ("eom-theme-music": "on" | "off").
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
['/static/audio/eom-theme-loop.ogg', 'audio/ogg; codecs=opus'],
|
||||||
|
['/static/audio/eom-theme-loop.m4a', 'audio/mp4; codecs=mp4a.40.2'],
|
||||||
|
];
|
||||||
|
const VOLUME = 0.3;
|
||||||
|
const FADE_S = 1.5;
|
||||||
|
const PREF_KEY = 'eom-theme-music';
|
||||||
|
const RESUME_KEY = 'eom-theme-music-position';
|
||||||
|
const PLAYER_ID = 'eom-theme-player';
|
||||||
|
// Membership routes that must be real navigations (redirects off-site, POST-only, webhooks).
|
||||||
|
const HARD_NAV = /^\/members\/(login|logout|oidc|checkout|billing|stripe|shkeeper|crypto\/checkout)\b/;
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
get(key, area = localStorage) { try { return area.getItem(key); } catch { return null; } },
|
||||||
|
set(key, value, area = localStorage) { try { area.setItem(key, value); } catch { /* private mode */ } },
|
||||||
|
};
|
||||||
|
|
||||||
|
class ThemeMusic {
|
||||||
|
constructor() {
|
||||||
|
this.ctx = null;
|
||||||
|
this.buffer = null;
|
||||||
|
this.source = null;
|
||||||
|
this.gain = null;
|
||||||
|
this.startedAt = 0; // ctx time when the current source started
|
||||||
|
this.offset = 0; // loop position (s) at startedAt
|
||||||
|
this.playing = false;
|
||||||
|
this.loading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
position() {
|
||||||
|
if (!this.buffer) return 0;
|
||||||
|
const t = this.playing ? this.offset + (this.ctx.currentTime - this.startedAt) : this.offset;
|
||||||
|
return t % this.buffer.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
if (this.buffer) return this.buffer;
|
||||||
|
this.loading ??= (async () => {
|
||||||
|
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.gain = this.ctx.createGain();
|
||||||
|
this.gain.gain.value = 0;
|
||||||
|
this.gain.connect(this.ctx.destination);
|
||||||
|
const probe = document.createElement('audio');
|
||||||
|
const candidates = SOURCES.filter(([, type]) => probe.canPlayType(type));
|
||||||
|
for (const [url] of candidates.length ? candidates : SOURCES) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) continue;
|
||||||
|
this.buffer = await this.ctx.decodeAudioData(await res.arrayBuffer());
|
||||||
|
return this.buffer;
|
||||||
|
} catch { /* try the next format */ }
|
||||||
|
}
|
||||||
|
throw new Error('theme music could not be decoded');
|
||||||
|
})();
|
||||||
|
return this.loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(at = this.offset) {
|
||||||
|
await this.load();
|
||||||
|
if (this.ctx.state === 'suspended') await this.ctx.resume();
|
||||||
|
if (this.ctx.state !== 'running') throw new Error('audio blocked until user interaction');
|
||||||
|
if (this.playing) return;
|
||||||
|
const src = this.ctx.createBufferSource();
|
||||||
|
src.buffer = this.buffer;
|
||||||
|
src.loop = true;
|
||||||
|
src.connect(this.gain);
|
||||||
|
this.offset = at % this.buffer.duration;
|
||||||
|
this.startedAt = this.ctx.currentTime;
|
||||||
|
src.start(0, this.offset);
|
||||||
|
this.source = src;
|
||||||
|
this.playing = true;
|
||||||
|
const g = this.gain.gain;
|
||||||
|
g.cancelScheduledValues(this.ctx.currentTime);
|
||||||
|
g.setValueAtTime(g.value, this.ctx.currentTime);
|
||||||
|
g.linearRampToValueAtTime(VOLUME, this.ctx.currentTime + FADE_S);
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
if (!this.playing) return;
|
||||||
|
this.offset = this.position();
|
||||||
|
this.playing = false;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
const g = this.gain.gain;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(g.value, now);
|
||||||
|
g.linearRampToValueAtTime(0, now + 0.3);
|
||||||
|
this.source.stop(now + 0.32);
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const music = new ThemeMusic();
|
||||||
|
let button;
|
||||||
|
let starting = false; // a start is in flight (audio context resuming)
|
||||||
|
let renderedPath = location.pathname + location.search;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!button) return;
|
||||||
|
button.setAttribute('aria-pressed', String(music.playing));
|
||||||
|
button.title = music.playing ? 'Pause the eom.dev theme' : 'Play the eom.dev theme';
|
||||||
|
button.querySelector('.label').textContent = music.playing ? 'Theme on' : 'Theme off';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
starting = true;
|
||||||
|
try { await music.play(); } catch { /* still blocked; wait for a gesture */ }
|
||||||
|
starting = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (music.playing) {
|
||||||
|
music.pause();
|
||||||
|
storage.set(PREF_KEY, 'off');
|
||||||
|
render();
|
||||||
|
} else {
|
||||||
|
storage.set(PREF_KEY, 'on');
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountButton() {
|
||||||
|
if (document.getElementById(PLAYER_ID)) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = `${PLAYER_ID}-style`;
|
||||||
|
style.textContent = `
|
||||||
|
#${PLAYER_ID}{position:fixed;right:1rem;bottom:1rem;z-index:1000;display:inline-flex;align-items:center;gap:.45rem;
|
||||||
|
padding:.5rem .8rem;border-radius:999px;border:1px solid var(--border,#3c3d56);background:var(--surface-raised,#292940);
|
||||||
|
color:var(--text,#cdd6f4);font:600 .85rem/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 4px 18px rgb(0 0 0 / .25)}
|
||||||
|
#${PLAYER_ID}:hover{border-color:var(--accent,#cba6f7)}
|
||||||
|
#${PLAYER_ID}:focus-visible{outline:2px solid var(--accent,#cba6f7);outline-offset:2px}
|
||||||
|
#${PLAYER_ID} .bars{display:inline-flex;gap:2px;align-items:flex-end;height:.9rem}
|
||||||
|
#${PLAYER_ID} .bars i{width:3px;height:30%;background:var(--accent,#cba6f7);border-radius:1px}
|
||||||
|
#${PLAYER_ID}[aria-pressed="true"] .bars i{animation:eomBars 1.07s ease-in-out infinite}
|
||||||
|
#${PLAYER_ID}[aria-pressed="true"] .bars i:nth-child(2){animation-delay:-.27s}
|
||||||
|
#${PLAYER_ID}[aria-pressed="true"] .bars i:nth-child(3){animation-delay:-.54s}
|
||||||
|
@keyframes eomBars{0%,100%{height:30%}50%{height:100%}}
|
||||||
|
@media (prefers-reduced-motion:reduce){#${PLAYER_ID} .bars i{animation:none!important;height:60%!important}}
|
||||||
|
@media print{#${PLAYER_ID}{display:none}}`;
|
||||||
|
document.head.append(style);
|
||||||
|
button = document.createElement('button');
|
||||||
|
button.id = PLAYER_ID;
|
||||||
|
button.type = 'button';
|
||||||
|
button.innerHTML = '<span class="bars" aria-hidden="true"><i></i><i></i><i></i></span><span class="label"></span>';
|
||||||
|
button.addEventListener('click', toggle);
|
||||||
|
document.body.append(button);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- in-place navigation while the music plays ---------- */
|
||||||
|
|
||||||
|
let navToken = 0;
|
||||||
|
let moduleGeneration = 0;
|
||||||
|
|
||||||
|
function softNavigable(url) {
|
||||||
|
if (url.origin !== location.origin) return false;
|
||||||
|
if (url.pathname === location.pathname && url.search === location.search && url.hash) return false; // same-page anchor
|
||||||
|
if (HARD_NAV.test(url.pathname)) return false;
|
||||||
|
return url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/members/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(event) {
|
||||||
|
if (!(music.playing || starting) || event.defaultPrevented || event.button !== 0) return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
const link = event.target.closest?.('a[href]');
|
||||||
|
if (!link || (link.target && link.target !== '_self') || link.hasAttribute('download')) return;
|
||||||
|
const url = new URL(link.href, location.href);
|
||||||
|
if (!softNavigable(url)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(url, { push: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adoptStylesheets(doc) {
|
||||||
|
const wanted = [...doc.querySelectorAll('link[rel="stylesheet"]')].map(l => new URL(l.getAttribute('href'), location.href).href);
|
||||||
|
const current = [...document.querySelectorAll('link[rel="stylesheet"]')];
|
||||||
|
// Load the new sheets first (no unstyled flash), then put every sheet in the new
|
||||||
|
// page's order; cascade order matters (pure.css must come before home.css).
|
||||||
|
const fresh = new Map();
|
||||||
|
for (const href of wanted) {
|
||||||
|
if (current.some(l => l.href === href)) continue;
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = href;
|
||||||
|
fresh.set(href, new Promise(resolve => { link.onload = link.onerror = () => resolve(link); }));
|
||||||
|
document.head.append(link);
|
||||||
|
}
|
||||||
|
const added = new Map(await Promise.all([...fresh].map(async ([href, p]) => [href, await p])));
|
||||||
|
for (const link of current) if (!wanted.includes(link.href)) link.remove();
|
||||||
|
for (const href of wanted) document.head.append(added.get(href) ?? current.find(l => l.href === href));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScripts(container) {
|
||||||
|
for (const old of container.querySelectorAll('script')) {
|
||||||
|
const type = old.getAttribute('type') || '';
|
||||||
|
if (type && type !== 'module' && type !== 'text/javascript') continue; // e.g. JSON-LD
|
||||||
|
if (old.src && new URL(old.src, location.href).pathname === new URL(import.meta.url).pathname) { old.remove(); continue; }
|
||||||
|
const fresh = document.createElement('script');
|
||||||
|
for (const { name, value } of old.attributes) fresh.setAttribute(name, value);
|
||||||
|
if (old.src && type === 'module') {
|
||||||
|
// A module URL only runs once per document; a new query makes it run again.
|
||||||
|
const src = new URL(old.src, location.href);
|
||||||
|
src.searchParams.set('nav', String(++moduleGeneration));
|
||||||
|
fresh.src = src.href;
|
||||||
|
}
|
||||||
|
fresh.textContent = old.textContent;
|
||||||
|
old.replaceWith(fresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(url, { push }) {
|
||||||
|
const token = ++navToken;
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
|
||||||
|
} catch { location.assign(url.href); return; } // e.g. redirected to another origin
|
||||||
|
if (token !== navToken) return;
|
||||||
|
const finalURL = new URL(res.url || url.href);
|
||||||
|
if (!res.ok || !(res.headers.get('content-type') || '').includes('text/html') || finalURL.origin !== location.origin) {
|
||||||
|
location.assign(url.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
||||||
|
if (token !== navToken) return;
|
||||||
|
finalURL.hash = url.hash;
|
||||||
|
if (push) history.pushState({ eomTheme: true }, '', finalURL.href);
|
||||||
|
renderedPath = finalURL.pathname + finalURL.search;
|
||||||
|
|
||||||
|
await adoptStylesheets(doc);
|
||||||
|
document.title = doc.title;
|
||||||
|
for (const name of ['description', 'robots']) {
|
||||||
|
const next = doc.querySelector(`meta[name="${name}"]`);
|
||||||
|
const cur = document.querySelector(`meta[name="${name}"]`);
|
||||||
|
if (next && cur) cur.content = next.content;
|
||||||
|
else if (next) document.head.append(next.cloneNode());
|
||||||
|
else cur?.remove();
|
||||||
|
}
|
||||||
|
document.documentElement.lang = doc.documentElement.lang || document.documentElement.lang;
|
||||||
|
const keep = document.getElementById(PLAYER_ID);
|
||||||
|
document.body.replaceChildren(...doc.body.childNodes);
|
||||||
|
for (const { name, value } of doc.body.attributes) document.body.setAttribute(name, value);
|
||||||
|
if (keep) document.body.append(keep);
|
||||||
|
runScripts(document.body);
|
||||||
|
|
||||||
|
const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)));
|
||||||
|
if (target) target.scrollIntoView();
|
||||||
|
else window.scrollTo(0, 0);
|
||||||
|
(document.querySelector('main') || document.body).focus?.({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopState() {
|
||||||
|
if (location.pathname + location.search === renderedPath) return; // only the #hash changed
|
||||||
|
if (!music.playing) { location.reload(); return; }
|
||||||
|
navigate(new URL(location.href), { push: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- boot ---------- */
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
if (window.__eomThemeMusic) return; // already running in this document
|
||||||
|
window.__eomThemeMusic = music;
|
||||||
|
mountButton();
|
||||||
|
document.addEventListener('click', onClick);
|
||||||
|
window.addEventListener('popstate', onPopState);
|
||||||
|
if (!history.state) history.replaceState({ eomTheme: true }, '');
|
||||||
|
|
||||||
|
// Keep the loop position across a real reload of the homepage.
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
if (music.playing) storage.set(RESUME_KEY, JSON.stringify({ at: music.position(), t: Date.now() }), sessionStorage);
|
||||||
|
else try { sessionStorage.removeItem(RESUME_KEY); } catch { /* private mode */ }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(storage.get(RESUME_KEY, sessionStorage) || 'null');
|
||||||
|
if (saved && Date.now() - saved.t < 30_000) music.offset = saved.at + (Date.now() - saved.t) / 1000;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (storage.get(PREF_KEY) === 'off') return;
|
||||||
|
music.load().catch(() => {}); // fetch/decode early so the first click starts instantly
|
||||||
|
start(); // succeeds only where autoplay is allowed
|
||||||
|
const onGesture = event => {
|
||||||
|
if (event.target.closest?.(`#${PLAYER_ID}`)) return; // the toggle handles its own click
|
||||||
|
for (const type of ['pointerdown', 'keydown']) removeEventListener(type, onGesture, true);
|
||||||
|
if (!music.playing && storage.get(PREF_KEY) !== 'off') start();
|
||||||
|
};
|
||||||
|
for (const type of ['pointerdown', 'keydown']) addEventListener(type, onGesture, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||||
|
else boot();
|
||||||
+1
-1
@@ -24,7 +24,7 @@ const MIME = new Map([
|
|||||||
['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
||||||
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.woff', 'font/woff'],
|
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.woff', 'font/woff'],
|
||||||
['.woff2', 'font/woff2'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
|
['.woff2', 'font/woff2'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
|
||||||
['.mp3', 'audio/mpeg'], ['.ogg', 'audio/ogg'], ['.wav', 'audio/wav'],
|
['.mp3', 'audio/mpeg'], ['.ogg', 'audio/ogg'], ['.m4a', 'audio/mp4'], ['.wav', 'audio/wav'],
|
||||||
['.mp4', 'video/mp4'], ['.webm', 'video/webm'], ['.pdf', 'application/pdf'],
|
['.mp4', 'video/mp4'], ['.webm', 'video/webm'], ['.pdf', 'application/pdf'],
|
||||||
['.wasm', 'application/wasm'], ['.pub', 'text/plain; charset=utf-8'],
|
['.wasm', 'application/wasm'], ['.pub', 'text/plain; charset=utf-8'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -197,6 +197,26 @@ test('live publication regions and automatic collection browser are wired to the
|
|||||||
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('homepage theme music loads its module and seamless loop assets', async t => {
|
||||||
|
const html = await homepage(t);
|
||||||
|
assert.match(html, /<script type="module" src="\/static\/theme-player\.mjs"><\/script>/u);
|
||||||
|
const server = await preview(t);
|
||||||
|
for (const [path, type] of [
|
||||||
|
['/static/theme-player.mjs', 'text/javascript; charset=utf-8'],
|
||||||
|
['/static/audio/eom-theme-loop.ogg', 'audio/ogg'],
|
||||||
|
['/static/audio/eom-theme-loop.m4a', 'audio/mp4'],
|
||||||
|
['/static/audio/eom-theme.mp3', 'audio/mpeg'],
|
||||||
|
]) {
|
||||||
|
const response = await get(server, path);
|
||||||
|
assert.equal(response.status, 200, path);
|
||||||
|
assert.equal(response.headers['content-type'], type, path);
|
||||||
|
}
|
||||||
|
const player = await readFile(new URL('htdocs/static/theme-player.mjs', repository), 'utf8');
|
||||||
|
// Sign-in, checkout and billing must stay real navigations, never fetched in place.
|
||||||
|
for (const route of ['login', 'logout', 'oidc', 'checkout', 'billing', 'stripe', 'shkeeper'])
|
||||||
|
assert.match(player, new RegExp(`HARD_NAV = .*\\b${route}\\b`, 'u'), route);
|
||||||
|
});
|
||||||
|
|
||||||
test('gaming is separate from engineering and writing links to Discourse', async t => {
|
test('gaming is separate from engineering and writing links to Discourse', async t => {
|
||||||
const html = await homepage(t);
|
const html = await homepage(t);
|
||||||
const sections = [...html.matchAll(/<section\b([^>]*)>([\s\S]*?)<\/section>/giu)]
|
const sections = [...html.matchAll(/<section\b([^>]*)>([\s\S]*?)<\/section>/giu)]
|
||||||
|
|||||||
Reference in New Issue
Block a user