Skip to content

Commit d1db06b

Browse files
committed
Add client-side text-to-speech option for articles
1 parent 59a85fd commit d1db06b

2 files changed

Lines changed: 96 additions & 50 deletions

File tree

theme/base.html

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -363,34 +363,49 @@ <h2 class="accordion-header" id="h-{{ pid }}">
363363
<!-- ========================= /NAV ========================= -->
364364

365365

366-
<main>
367-
<div class="container-xxl">
368-
<section class="content-inner">
369-
{% block content %}
370-
<section>
371-
<div class="row">
372-
<div class="col-12 col-xl-10 mx-auto">
373-
{% block content_inner %}
374-
{{ page.content }}
375-
{% endblock content_inner %}
366+
<main>
367+
<div class="container-xxl">
368+
<section class="content-inner">
369+
{% block content %}
370+
<section>
371+
<div class="row">
372+
<div class="col-12 col-xl-10 mx-auto">
373+
{% block content_inner %}
374+
375+
<!-- Listen to article button -->
376+
<div class="mb-3">
377+
<button
378+
id="tts-btn"
379+
type="button"
380+
class="btn btn-sm btn-outline-secondary"
381+
aria-label="Listen to article"
382+
>
383+
Listen to article
384+
</button>
376385
</div>
377-
</div>
378-
</section>
379-
{% endblock content %}
380386

381-
<div class="mt-4">
382-
<small class="badge-updated">
383-
{% if page.meta.git_revision_date_localized %}
384-
Last update: {{ page.meta.git_revision_date_localized }}
385-
{% endif %}
386-
{% if page.meta.git_created_date_localized %}
387-
&nbsp;•&nbsp;Created: {{ page.meta.git_created_date_localized }}
388-
{% endif %}
389-
</small>
387+
{{ page.content }}
388+
389+
{% endblock content_inner %}
390390
</div>
391-
</section>
391+
</div>
392+
</section>
393+
{% endblock content %}
394+
395+
<div class="mt-4">
396+
<small class="badge-updated">
397+
{% if page.meta.git_revision_date_localized %}
398+
Last update: {{ page.meta.git_revision_date_localized }}
399+
{% endif %}
400+
{% if page.meta.git_created_date_localized %}
401+
&nbsp;•&nbsp;Created: {{ page.meta.git_created_date_localized }}
402+
{% endif %}
403+
</small>
392404
</div>
393-
</main>
405+
</section>
406+
</div>
407+
</main>
408+
394409

395410
<svg width="0" height="0" class="hidden">
396411
<symbol viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" id="facebook">

theme/js/theme.js

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,35 +55,71 @@
5555
if (offcanvasEl) {
5656
offcanvasEl.addEventListener('click', function (e) {
5757
const a = e.target.closest('a');
58-
if (!a) return; // ignore clicks on accordion buttons etc.
58+
if (!a) return;
5959
const offcanvas = bootstrap.Offcanvas.getInstance(offcanvasEl) ||
6060
new bootstrap.Offcanvas(offcanvasEl);
61-
// Close for internal links (no target="_blank")
6261
if (a.getAttribute('href') && !a.getAttribute('target')) {
6362
offcanvas.hide();
6463
}
6564
});
6665
}
6766

68-
/* 5) Mega dropdown accordions: ensure one-open-at-a-time via data-bs-parent */
67+
/* 5) Mega dropdown accordions */
6968
document.querySelectorAll('.dropdown-menu.mega .accordion').forEach((acc, i) => {
70-
// Make sure the accordion has an id
7169
if (!acc.id) acc.id = `mega-acc-${i}`;
7270
const parentSel = `#${acc.id}`;
73-
74-
// For each collapse pane, set data-bs-parent if missing
7571
acc.querySelectorAll('.accordion-collapse').forEach(col => {
7672
if (!col.getAttribute('data-bs-parent')) {
7773
col.setAttribute('data-bs-parent', parentSel);
7874
}
7975
});
8076
});
8177

82-
/* 6) Keep clicks inside mega from bubbling to the dropdown toggle (belt & suspenders).
83-
With data-bs-auto-close="outside" this isn’t strictly needed, but it’s harmless. */
78+
/* 6) Stop mega menu click bubbling */
8479
document.querySelectorAll('.dropdown-menu.mega').forEach(menu => {
8580
menu.addEventListener('click', (e) => e.stopPropagation());
8681
});
82+
83+
/* =========================================================
84+
7) Client-side Text-to-Speech (Listen to Article)
85+
========================================================= */
86+
if ('speechSynthesis' in window) {
87+
const ttsBtn = document.getElementById('tts-btn');
88+
let speaking = false;
89+
let utterance = null;
90+
91+
function getArticleText() {
92+
const main = document.querySelector('main');
93+
return main ? main.innerText : '';
94+
}
95+
96+
if (ttsBtn) {
97+
ttsBtn.addEventListener('click', function () {
98+
if (speaking) {
99+
window.speechSynthesis.cancel();
100+
speaking = false;
101+
ttsBtn.textContent = 'Listen to article';
102+
return;
103+
}
104+
105+
const text = getArticleText();
106+
if (!text) return;
107+
108+
utterance = new SpeechSynthesisUtterance(text);
109+
utterance.lang = 'en-US';
110+
utterance.rate = 1;
111+
112+
utterance.onend = function () {
113+
speaking = false;
114+
ttsBtn.textContent = 'Listen to article';
115+
};
116+
117+
window.speechSynthesis.speak(utterance);
118+
speaking = true;
119+
ttsBtn.textContent = 'Stop listening';
120+
});
121+
}
122+
}
87123
});
88124

89125
// Optional public API
@@ -99,17 +135,15 @@
99135
document.querySelectorAll('a[href^="http"]').forEach(a => {
100136
try {
101137
const url = new URL(a.href);
102-
// skip same-origin
103138
if (url.origin === window.location.origin) return;
104139
if (!a.hasAttribute('target')) a.setAttribute('target', '_blank');
105140
if (!a.hasAttribute('rel')) a.setAttribute('rel', 'noopener');
106141
} catch (_) {}
107142
});
108143

109-
// B) small “copy code buttons for pygments blocks (MkDocs default markup)
144+
// B) copy code buttons
110145
document.querySelectorAll('div.highlight > pre').forEach((pre, i) => {
111-
// container for the button
112-
const wrap = pre.parentElement; // .highlight
146+
const wrap = pre.parentElement;
113147
wrap.style.position = 'relative';
114148

115149
const btn = document.createElement('button');
@@ -126,7 +160,6 @@
126160
btn.textContent = 'Copied!';
127161
setTimeout(() => (btn.textContent = old), 1200);
128162
} catch (e) {
129-
// fallback
130163
const ta = document.createElement('textarea');
131164
ta.value = code;
132165
ta.style.position = 'fixed';
@@ -144,18 +177,17 @@
144177
wrap.appendChild(btn);
145178
});
146179

147-
// C) heading anchors (h2–h4) inside main content
180+
// C) heading anchors
148181
const contentRoot = document.querySelector('main .content-inner') || document.querySelector('main');
149182
if (contentRoot) {
150183
contentRoot.querySelectorAll('h2[id], h3[id], h4[id]').forEach(h => {
151-
if (h.querySelector('a.anchor-link')) return; // idempotent
184+
if (h.querySelector('a.anchor-link')) return;
152185
const a = document.createElement('a');
153186
a.href = `#${h.id}`;
154187
a.className = 'anchor-link ms-2';
155188
a.setAttribute('aria-label', 'Copy link to this section');
156-
a.innerHTML = '¶'; // simple mark; you can swap for an SVG if you prefer
157-
a.addEventListener('click', (e) => {
158-
// let it navigate, then copy
189+
a.innerHTML = '¶';
190+
a.addEventListener('click', () => {
159191
setTimeout(() => navigator.clipboard.writeText(window.location.href), 0);
160192
});
161193
h.appendChild(a);
@@ -167,7 +199,6 @@
167199

168200
// --- Search results link absolutizer ---
169201
(function () {
170-
// Which containers might hold search result links?
171202
const candidates = [
172203
'.mk-search-results',
173204
'.search-results',
@@ -176,31 +207,31 @@
176207

177208
function absolutize(href) {
178209
if (!href) return href;
179-
if (/^([a-z]+:)?\/\//i.test(href)) return href; // already absolute URL
180-
if (href.startsWith('/')) return href; // already site-absolute
181-
return '/' + href.replace(/^\/+/, ''); // make it site-absolute
210+
if (/^([a-z]+:)?\/\//i.test(href)) return href;
211+
if (href.startsWith('/')) return href;
212+
return '/' + href.replace(/^\/+/, '');
182213
}
183214

184215
function fixLinks(root = document) {
185216
candidates.forEach(sel => {
186217
root.querySelectorAll(`${sel} a[href]`).forEach(a => {
187218
const fixed = absolutize(a.getAttribute('href'));
188-
if (fixed && fixed !== a.getAttribute('href')) a.setAttribute('href', fixed);
219+
if (fixed && fixed !== a.getAttribute('href')) {
220+
a.setAttribute('href', fixed);
221+
}
189222
});
190223
});
191224
}
192225

193-
// 1) Run once after DOM ready (in case results render immediately)
194226
if (document.readyState === 'loading') {
195227
document.addEventListener('DOMContentLoaded', () => fixLinks());
196228
} else {
197229
fixLinks();
198230
}
199231

200-
// 2) Watch for results being (re)rendered
201232
const obs = new MutationObserver(muts => {
202233
for (const m of muts) {
203-
if (m.type === 'childList' && (m.addedNodes && m.addedNodes.length)) {
234+
if (m.type === 'childList' && m.addedNodes.length) {
204235
fixLinks(document);
205236
}
206237
}

0 commit comments

Comments
 (0)