fix(textbook): рабочий deep-link к § (/textbook/<slug>#sec-pN открывает нужный §)

Раньше статические страницы алгебры/геометрии игнорировали location.hash (init всегда
goTo('p10')), а textbook-tracker матчил только #pN через .para-pill — поэтому ссылки
exam-prep вида #sec-pN вели на главу, но не на §.

- server.js: /textbook/:slug всегда инжектит хелпер (и в обычном режиме, и в embed),
  _renderEmbed → _renderTextbook (кэш по filePath|mode, заголовки no-store сохранены).
- frontend/js/textbook-deeplink.js: по #sec-pN / #pN кликает .psel-card[data-id]
  (фолбэк .para-pill[data-para] → goTo → scrollIntoView). Универсально для статических
  и движковых страниц, идемпотентно, не конфликтует с textbook-tracker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 16:32:57 +03:00
parent c9f3eed8ed
commit 49f01fd23c
2 changed files with 64 additions and 11 deletions
+14 -11
View File
@@ -421,16 +421,22 @@ const EMBED_INJECT = `
</script>
`;
function _renderEmbed(filePath, slug) {
// Always injected (plain + embed): deep-link helper so /textbook/<slug>#sec-pN
// actually opens § N. Without it the page ignores the hash and shows §1.
const DEEPLINK_INJECT = `\n<script defer src="/js/textbook-deeplink.js"></script>\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('</head>')) html = html.replace('</head>', inject + '</head>');
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)