diff --git a/backend/src/server.js b/backend/src/server.js index 5395ff7..4ac3fa7 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -421,16 +421,22 @@ const EMBED_INJECT = ` `; -function _renderEmbed(filePath, slug) { +// Always injected (plain + embed): deep-link helper so /textbook/#sec-pN +// actually opens § N. Without it the page ignores the hash and shows §1. +const DEEPLINK_INJECT = `\n\n`; + +function _renderTextbook(filePath, slug, embed) { let stat; try { stat = fs.statSync(filePath); } catch { return null; } - const cached = _embedCache.get(filePath); + const cacheKey = `${filePath}|${embed ? 'e' : 'p'}`; + const cached = _embedCache.get(cacheKey); if (cached && cached.mtime === stat.mtimeMs && cached.slug === slug) return cached.html; let html; try { html = fs.readFileSync(filePath, 'utf8'); } catch { return null; } - const inject = EMBED_INJECT.replace('__LS_SLUG__', JSON.stringify(slug)); + let inject = DEEPLINK_INJECT; + if (embed) inject += EMBED_INJECT.replace('__LS_SLUG__', JSON.stringify(slug)); if (html.includes('')) html = html.replace('', inject + ''); else html = inject + html; - _embedCache.set(filePath, { mtime: stat.mtimeMs, slug, html }); + _embedCache.set(cacheKey, { mtime: stat.mtimeMs, slug, html }); return html; } @@ -443,13 +449,10 @@ app.get('/textbook/:slug', (req, res, next) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - if (req.query.embed === '1') { - const html = _renderEmbed(filePath, req.params.slug); - if (html == null) return next(); - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - return res.send(html); - } - res.sendFile(filePath, err => { if (err) next(); }); + const html = _renderTextbook(filePath, req.params.slug, req.query.embed === '1'); + if (html == null) return next(); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.send(html); }); // Catalog: /textbooks → frontend/textbooks.html (explicit to avoid conflict with /textbooks/ directory) diff --git a/frontend/js/textbook-deeplink.js b/frontend/js/textbook-deeplink.js new file mode 100644 index 0000000..d702290 --- /dev/null +++ b/frontend/js/textbook-deeplink.js @@ -0,0 +1,50 @@ +/* textbook-deeplink.js + Injected by the server into every /textbook/ page. + + Makes deep links like /textbook/#sec-p13 (and #p13) actually open § 13. + The static algebra/geometry pages ignore location.hash in their own init() + (they always show §1), and textbook-tracker.js only matches the bare "#pN" + form via .para-pill — so exam-prep's "#sec-pN" links never navigated. + + This helper is page-agnostic: it activates the target section by clicking the + matching paragraph card/pill, which works on both the static pages + (.psel-card[data-id]) and the math6-engine pages (.psel-card / .para-pill), + falling back to goTo()/scrollIntoView. Idempotent; safe alongside + textbook-tracker.js (a repeated activation of the same § is a no-op). */ +(function () { + 'use strict'; + + // "#sec-p13" | "#p13" -> "p13" (null if not a paragraph anchor) + function targetId() { + var h = (location.hash || '').replace(/^#/, ''); + if (!h) return null; + var m = h.match(/^sec-(p\d+)$/) || h.match(/^(p\d+)$/); + return m ? m[1] : null; + } + + function go() { + var id = targetId(); + if (!id) return; + var card = document.querySelector('.psel-card[data-id="' + id + '"]'); + if (card) { card.click(); return; } + var pill = document.querySelector('.para-pill[data-para="' + id + '"]'); + if (pill) { pill.click(); return; } + if (typeof window.goTo === 'function') { try { window.goTo(id); return; } catch (e) {} } + var el = document.getElementById('sec-' + id) || document.getElementById(id); + if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + // The page's own init builds the cards/sections on DOMContentLoaded; run after + // it (microtask + a slower pass for engine-rendered math5/6 pages). + function boot() { + setTimeout(go, 60); + setTimeout(go, 350); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } + window.addEventListener('hashchange', go); +})();