tfrere HF Staff commited on
Commit
35f4ea0
·
1 Parent(s): d2072ce

feat: add Table of Contents to published article

Browse files

Adapted from the research-article-template TOC system:
- Desktop (>1100px): sticky sidebar on the left in a 3-column grid
- Mobile (<1100px): hamburger button + sliding panel from the left
- Active section tracking on scroll (h2/h3/h4)
- Auto-generated IDs on headings (slugified, deduplicated)
- Theme toggle now persists to localStorage
- Print styles hide TOC and UI elements

Made-with: Cursor

Files changed (1) hide show
  1. backend/src/publisher/html-renderer.ts +310 -57
backend/src/publisher/html-renderer.ts CHANGED
@@ -88,10 +88,16 @@ body {
88
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
89
  }
90
 
91
- .article-container {
92
- max-width: 780px;
 
93
  margin: 0 auto;
94
- padding: 2rem 32px 4rem;
 
 
 
 
 
95
  }
96
 
97
  .article-header {
@@ -131,13 +137,8 @@ body {
131
  margin-left: 0.25rem;
132
  }
133
 
134
- .article-header .author-affiliation::before {
135
- content: "(";
136
- }
137
-
138
- .article-header .author-affiliation::after {
139
- content: ")";
140
- }
141
 
142
  .article-header .date {
143
  color: var(--text-tertiary);
@@ -159,6 +160,118 @@ body {
159
  z-index: 100;
160
  }
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  /* Accordion as details/summary */
163
  details[data-component="accordion"] {
164
  border: 1px solid var(--border);
@@ -176,60 +289,70 @@ details[data-component="accordion"] > summary {
176
  user-select: none;
177
  }
178
 
179
- details[data-component="accordion"] > summary::-webkit-details-marker {
180
- display: none;
181
- }
182
 
183
  details[data-component="accordion"] > summary::before {
184
- content: "▸";
185
  display: inline-block;
186
  margin-right: 0.5rem;
187
  transition: transform 0.2s;
188
  }
189
 
190
- details[data-component="accordion"][open] > summary::before {
191
- transform: rotate(90deg);
192
- }
193
-
194
- details[data-component="accordion"] > .accordion-content {
195
- padding: 0 1rem 1rem;
196
- }
197
 
198
  /* Image lightbox dialog */
199
- dialog.lightbox {
200
- border: none;
201
- background: transparent;
202
- padding: 0;
203
- max-width: 95vw;
204
- max-height: 95vh;
205
- }
206
-
207
- dialog.lightbox::backdrop {
208
- background: rgba(0, 0, 0, 0.85);
209
- }
210
-
211
- dialog.lightbox img {
212
- max-width: 95vw;
213
- max-height: 90vh;
214
- object-fit: contain;
215
- border-radius: 4px;
216
  }
217
  </style>
218
  </head>
219
  <body>
220
- <button class="theme-toggle" aria-label="Toggle theme" onclick="toggleTheme()">🌓</button>
221
-
222
- <article class="article-container">
223
- <header class="article-header">
224
- <h1>${safeTitle}</h1>
225
- ${meta.authors.length > 0 ? `<div class="authors">${renderAuthors(meta.authors)}</div>` : ""}
226
- ${meta.date ? `<p class="date">${escapeHtml(formatDate(meta.date))}</p>` : ""}
227
- </header>
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- <div class="tiptap">
230
- ${enrichedBody}
 
 
231
  </div>
232
- </article>
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
  <!-- Image lightbox -->
235
  <dialog class="lightbox" id="lightbox">
@@ -237,30 +360,160 @@ dialog.lightbox img {
237
  </dialog>
238
 
239
  <script>
 
240
  // Theme toggle
241
- function toggleTheme() {
242
- const html = document.documentElement;
243
- const current = html.getAttribute('data-theme');
244
- html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
245
- }
 
 
 
246
 
247
  // Image lightbox
248
  document.querySelectorAll('.tiptap img').forEach(function(img) {
249
  img.style.cursor = 'zoom-in';
250
  img.addEventListener('click', function() {
251
- var d = document.getElementById('lightbox');
252
  document.getElementById('lightbox-img').src = img.src;
253
- d.showModal();
254
  });
255
  });
256
  document.getElementById('lightbox').addEventListener('click', function(e) {
257
  if (e.target === this) this.close();
258
  });
259
 
260
- // Glossary tooltips
261
  document.querySelectorAll('[data-type="glossary"]').forEach(function(el) {
262
  el.setAttribute('tabindex', '0');
263
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  </script>
265
  </body>
266
  </html>`;
 
88
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
89
  }
90
 
91
+ /* Grid: TOC | Article | (empty) */
92
+ .content-grid {
93
+ max-width: 1280px;
94
  margin: 0 auto;
95
+ padding: 0 24px;
96
+ margin-top: 40px;
97
+ display: grid;
98
+ grid-template-columns: 240px minmax(0, 720px) 1fr;
99
+ gap: 32px;
100
+ align-items: start;
101
  }
102
 
103
  .article-header {
 
137
  margin-left: 0.25rem;
138
  }
139
 
140
+ .article-header .author-affiliation::before { content: "("; }
141
+ .article-header .author-affiliation::after { content: ")"; }
 
 
 
 
 
142
 
143
  .article-header .date {
144
  color: var(--text-tertiary);
 
160
  z-index: 100;
161
  }
162
 
163
+ /* ---------- Table of Contents ---------- */
164
+ .toc {
165
+ position: sticky;
166
+ top: 32px;
167
+ max-height: calc(100vh - 64px);
168
+ overflow-y: auto;
169
+ }
170
+
171
+ .toc .toc-title {
172
+ font-weight: 600;
173
+ font-size: 14px;
174
+ margin-bottom: 8px;
175
+ color: var(--text-primary);
176
+ }
177
+
178
+ .toc nav { border-left: 1px solid var(--border); padding-left: 16px; font-size: 13px; }
179
+ .toc nav ul { margin: 0 0 4px; padding-left: 1em; }
180
+ .toc nav li { list-style: none; margin: 0.25em 0; }
181
+ .toc nav a { color: var(--text-secondary); text-decoration: none; transition: color 0.15s; }
182
+ .toc nav > ul > li > a { font-weight: 600; color: var(--text-primary); }
183
+ .toc nav a:hover { color: var(--accent-light); }
184
+ .toc nav a.active { color: var(--accent-light); text-decoration: underline; text-underline-offset: 2px; }
185
+
186
+ /* Mobile TOC toggle */
187
+ .toc-mobile-toggle {
188
+ display: none;
189
+ position: fixed;
190
+ top: 1rem;
191
+ left: 1rem;
192
+ z-index: 200;
193
+ width: 40px;
194
+ height: 40px;
195
+ border-radius: 50%;
196
+ border: 1px solid var(--border);
197
+ background: var(--bg-primary);
198
+ color: var(--text-primary);
199
+ cursor: pointer;
200
+ align-items: center;
201
+ justify-content: center;
202
+ box-shadow: var(--shadow-sm);
203
+ }
204
+
205
+ /* Mobile backdrop */
206
+ .toc-mobile-backdrop {
207
+ display: none;
208
+ position: fixed;
209
+ inset: 0;
210
+ z-index: 201;
211
+ background: rgba(0,0,0,.4);
212
+ opacity: 0;
213
+ pointer-events: none;
214
+ transition: opacity 250ms ease;
215
+ }
216
+ .toc-mobile-backdrop.open { opacity: 1; pointer-events: auto; }
217
+
218
+ /* Mobile sidebar */
219
+ .toc-mobile-sidebar {
220
+ display: none;
221
+ position: fixed;
222
+ top: 0; left: 0; bottom: 0;
223
+ z-index: 202;
224
+ width: min(320px, 85vw);
225
+ background: var(--bg-primary);
226
+ border-right: 1px solid var(--border);
227
+ transform: translateX(-100%);
228
+ transition: transform 300ms cubic-bezier(.4,0,.2,1);
229
+ flex-direction: column;
230
+ }
231
+ .toc-mobile-sidebar.open { transform: translateX(0); }
232
+
233
+ .toc-mobile-sidebar__header {
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: space-between;
237
+ padding: 0.75rem 1rem;
238
+ border-bottom: 1px solid var(--border);
239
+ flex-shrink: 0;
240
+ }
241
+ .toc-mobile-sidebar__title { font-weight: 700; font-size: 15px; }
242
+ .toc-mobile-sidebar__close {
243
+ background: none; border: none; color: var(--text-tertiary);
244
+ cursor: pointer; padding: 6px; border-radius: 6px;
245
+ display: flex; align-items: center; justify-content: center;
246
+ }
247
+ .toc-mobile-sidebar__close:hover { color: var(--text-primary); background: var(--bg-secondary); }
248
+
249
+ .toc-mobile-sidebar__body {
250
+ flex: 1;
251
+ overflow-y: auto;
252
+ -webkit-overflow-scrolling: touch;
253
+ padding: 0.75rem 1rem 2rem;
254
+ }
255
+ .toc-mobile-sidebar__body nav ul { margin: 0 0 4px; padding-left: 1em; }
256
+ .toc-mobile-sidebar__body nav li { list-style: none; margin: 0.35em 0; }
257
+ .toc-mobile-sidebar__body nav a { color: var(--text-secondary); text-decoration: none; font-size: 14px; line-height: 1.5; }
258
+ .toc-mobile-sidebar__body nav > ul > li > a { font-weight: 600; color: var(--text-primary); }
259
+ .toc-mobile-sidebar__body nav a:hover { color: var(--accent-light); }
260
+ .toc-mobile-sidebar__body nav a.active { color: var(--accent-light); text-decoration: underline; }
261
+
262
+ /* Responsive: collapse grid below 1100px */
263
+ @media (max-width: 1100px) {
264
+ .content-grid {
265
+ display: block;
266
+ padding: 0 16px;
267
+ margin-top: 1rem;
268
+ }
269
+ .toc { display: none; }
270
+ .toc-mobile-toggle { display: flex; }
271
+ .toc-mobile-backdrop { display: block; }
272
+ .toc-mobile-sidebar { display: flex; }
273
+ }
274
+
275
  /* Accordion as details/summary */
276
  details[data-component="accordion"] {
277
  border: 1px solid var(--border);
 
289
  user-select: none;
290
  }
291
 
292
+ details[data-component="accordion"] > summary::-webkit-details-marker { display: none; }
 
 
293
 
294
  details[data-component="accordion"] > summary::before {
295
+ content: "\\25B8";
296
  display: inline-block;
297
  margin-right: 0.5rem;
298
  transition: transform 0.2s;
299
  }
300
 
301
+ details[data-component="accordion"][open] > summary::before { transform: rotate(90deg); }
302
+ details[data-component="accordion"] > .accordion-content { padding: 0 1rem 1rem; }
 
 
 
 
 
303
 
304
  /* Image lightbox dialog */
305
+ dialog.lightbox { border: none; background: transparent; padding: 0; max-width: 95vw; max-height: 95vh; }
306
+ dialog.lightbox::backdrop { background: rgba(0, 0, 0, 0.85); }
307
+ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; border-radius: 4px; }
308
+
309
+ /* Print */
310
+ @media print {
311
+ .toc, .toc-mobile-toggle, .toc-mobile-backdrop, .toc-mobile-sidebar, .theme-toggle { display: none !important; }
312
+ .content-grid { display: block !important; }
 
 
 
 
 
 
 
 
 
313
  }
314
  </style>
315
  </head>
316
  <body>
317
+ <button class="theme-toggle" aria-label="Toggle theme" onclick="toggleTheme()">&#127763;</button>
318
+
319
+ <!-- Mobile TOC toggle -->
320
+ <button class="toc-mobile-toggle" aria-label="Open table of contents" aria-expanded="false">
321
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
322
+ <line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
323
+ </svg>
324
+ </button>
325
+ <div class="toc-mobile-backdrop" aria-hidden="true"></div>
326
+ <aside class="toc-mobile-sidebar" aria-label="Table of Contents">
327
+ <div class="toc-mobile-sidebar__header">
328
+ <span class="toc-mobile-sidebar__title">Table of Contents</span>
329
+ <button class="toc-mobile-sidebar__close" aria-label="Close">
330
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
331
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
332
+ </svg>
333
+ </button>
334
+ </div>
335
+ <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
336
+ </aside>
337
 
338
+ <section class="content-grid">
339
+ <div class="toc" aria-label="Table of Contents">
340
+ <div class="toc-title">Table of Contents</div>
341
+ <div id="toc-placeholder"></div>
342
  </div>
343
+
344
+ <article>
345
+ <header class="article-header">
346
+ <h1>${safeTitle}</h1>
347
+ ${meta.authors.length > 0 ? `<div class="authors">${renderAuthors(meta.authors)}</div>` : ""}
348
+ ${meta.date ? `<p class="date">${escapeHtml(formatDate(meta.date))}</p>` : ""}
349
+ </header>
350
+
351
+ <div class="tiptap">
352
+ ${enrichedBody}
353
+ </div>
354
+ </article>
355
+ </section>
356
 
357
  <!-- Image lightbox -->
358
  <dialog class="lightbox" id="lightbox">
 
360
  </dialog>
361
 
362
  <script>
363
+ (function() {
364
  // Theme toggle
365
+ window.toggleTheme = function() {
366
+ var html = document.documentElement;
367
+ var next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
368
+ html.setAttribute('data-theme', next);
369
+ localStorage.setItem('theme', next);
370
+ };
371
+ var saved = localStorage.getItem('theme');
372
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
373
 
374
  // Image lightbox
375
  document.querySelectorAll('.tiptap img').forEach(function(img) {
376
  img.style.cursor = 'zoom-in';
377
  img.addEventListener('click', function() {
 
378
  document.getElementById('lightbox-img').src = img.src;
379
+ document.getElementById('lightbox').showModal();
380
  });
381
  });
382
  document.getElementById('lightbox').addEventListener('click', function(e) {
383
  if (e.target === this) this.close();
384
  });
385
 
386
+ // Glossary tabindex
387
  document.querySelectorAll('[data-type="glossary"]').forEach(function(el) {
388
  el.setAttribute('tabindex', '0');
389
  });
390
+
391
+ // ---- Table of Contents ----
392
+ var holder = document.getElementById('toc-placeholder');
393
+ var holderMobile = document.getElementById('toc-mobile-placeholder');
394
+ var articleRoot = document.querySelector('.content-grid article');
395
+ if (!articleRoot) return;
396
+
397
+ var headings = articleRoot.querySelectorAll('h2, h3, h4');
398
+ if (!headings.length) return;
399
+ var headingsArr = Array.from(headings);
400
+
401
+ // Ensure unique IDs
402
+ var usedIds = {};
403
+ var slugify = function(s) {
404
+ return String(s || '').toLowerCase().trim().replace(/\\s+/g, '_').replace(/[^a-z0-9_-]/g, '');
405
+ };
406
+ headingsArr.forEach(function(h) {
407
+ var id = (h.id || '').trim() || slugify(h.textContent) || 'section';
408
+ var candidate = id;
409
+ var n = 2;
410
+ while (usedIds[candidate]) { candidate = id + '-' + (n++); }
411
+ if (h.id !== candidate) h.id = candidate;
412
+ usedIds[candidate] = true;
413
+ });
414
+
415
+ // Build nested nav
416
+ var nav = document.createElement('nav');
417
+ var ulStack = [document.createElement('ul')];
418
+ nav.appendChild(ulStack[0]);
419
+ var levelOf = function(tag) { return tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4; };
420
+ var prev = 2;
421
+ headingsArr.forEach(function(h) {
422
+ var lvl = levelOf(h.tagName);
423
+ while (lvl > prev) {
424
+ var ul = document.createElement('ul');
425
+ var last = ulStack[ulStack.length - 1].lastElementChild;
426
+ if (last) last.appendChild(ul);
427
+ ulStack.push(ul);
428
+ prev++;
429
+ }
430
+ while (lvl < prev) { ulStack.pop(); prev--; }
431
+ var li = document.createElement('li');
432
+ var a = document.createElement('a');
433
+ a.href = '#' + h.id;
434
+ a.textContent = h.textContent;
435
+ li.appendChild(a);
436
+ ulStack[ulStack.length - 1].appendChild(li);
437
+ });
438
+
439
+ if (holder) holder.appendChild(nav);
440
+ var navClone = nav.cloneNode(true);
441
+ if (holderMobile) holderMobile.appendChild(navClone);
442
+
443
+ // Active section tracking
444
+ var allLinks = [].concat(
445
+ holder ? Array.from(holder.querySelectorAll('a')) : [],
446
+ holderMobile ? Array.from(holderMobile.querySelectorAll('a')) : []
447
+ );
448
+ var OFFSET = 80;
449
+ var lastScrollTime = 0;
450
+
451
+ var onScroll = function() {
452
+ var now = performance.now();
453
+ if (now - lastScrollTime < 50) return;
454
+ lastScrollTime = now;
455
+ requestAnimationFrame(function() {
456
+ var activeId = null;
457
+ for (var i = headingsArr.length - 1; i >= 0; i--) {
458
+ if (headingsArr[i].getBoundingClientRect().top - OFFSET <= 0) {
459
+ activeId = headingsArr[i].id;
460
+ break;
461
+ }
462
+ }
463
+ allLinks.forEach(function(l) {
464
+ if (activeId && l.getAttribute('href') === '#' + activeId) {
465
+ l.classList.add('active');
466
+ } else {
467
+ l.classList.remove('active');
468
+ }
469
+ });
470
+ });
471
+ };
472
+ window.addEventListener('scroll', onScroll, { passive: true });
473
+ onScroll();
474
+
475
+ // Mobile sidebar
476
+ var sidebar = document.querySelector('.toc-mobile-sidebar');
477
+ var backdrop = document.querySelector('.toc-mobile-backdrop');
478
+ var toggleBtn = document.querySelector('.toc-mobile-toggle');
479
+ var closeBtn = document.querySelector('.toc-mobile-sidebar__close');
480
+
481
+ var openSidebar = function() {
482
+ sidebar.classList.add('open');
483
+ backdrop.classList.add('open');
484
+ toggleBtn.setAttribute('aria-expanded', 'true');
485
+ document.body.style.overflow = 'hidden';
486
+ requestAnimationFrame(function() {
487
+ var active = sidebar.querySelector('a.active');
488
+ if (active) {
489
+ var body = sidebar.querySelector('.toc-mobile-sidebar__body');
490
+ if (body) body.scrollTop = Math.max(0, active.offsetTop - body.offsetTop - body.clientHeight / 3);
491
+ }
492
+ });
493
+ };
494
+ var closeSidebar = function() {
495
+ sidebar.classList.remove('open');
496
+ backdrop.classList.remove('open');
497
+ toggleBtn.setAttribute('aria-expanded', 'false');
498
+ document.body.style.overflow = '';
499
+ };
500
+
501
+ if (toggleBtn) toggleBtn.addEventListener('click', openSidebar);
502
+ if (closeBtn) closeBtn.addEventListener('click', closeSidebar);
503
+ if (backdrop) backdrop.addEventListener('click', closeSidebar);
504
+ if (holderMobile) holderMobile.addEventListener('click', function(e) {
505
+ if (e.target.closest && e.target.closest('a')) closeSidebar();
506
+ });
507
+ document.addEventListener('keydown', function(e) {
508
+ if (e.key === 'Escape' && sidebar.classList.contains('open')) closeSidebar();
509
+ });
510
+
511
+ // Handle initial hash navigation
512
+ if (window.location.hash) {
513
+ var target = document.querySelector(window.location.hash);
514
+ if (target) setTimeout(function() { target.scrollIntoView({ block: 'start' }); }, 100);
515
+ }
516
+ })();
517
  </script>
518
  </body>
519
  </html>`;