tfrere HF Staff commited on
Commit
0094189
·
1 Parent(s): d4a36db

fix: align published article with template design

Browse files

Theme toggle:
- Replace emoji button with SVG sun/moon icons from ThemeToggle.astro
- Fix id mismatch (class="theme-toggle" -> id="theme-toggle")
- Add spin animation and system theme detection

Hero:
- Add .hero-title class to h1 so clamp font-size styles apply

DOM structure:
- Change <article> to <main> in content-grid to match template
selectors (.content-grid main) used across _base.css, _layout.css,
_table.css for typography, figures, references

Component CSS:
- Add styles for note, quoteBlock, sidenote, stack, reference,
wide, fullWidth components (div[data-component="..."])

Code highlighting:
- Load highlight.js runtime + call hljs.highlightAll() for syntax
coloring in published code blocks

Performance:
- Debounce CommentsSidebar position updates and TOC heading extraction

Made-with: Cursor

backend/src/publisher/html-renderer.ts CHANGED
@@ -29,6 +29,7 @@ export interface PublishMeta {
29
  affiliations: PublishAffiliation[];
30
  date: string;
31
  doi?: string;
 
32
  ogImage?: string;
33
  pdfUrl?: string;
34
  }
@@ -82,8 +83,10 @@ export function renderArticleHTML(
82
  integrity="sha384-zh0CIslj3dQfMxK3pZnPJAb3bprHitCE2vBz9TyOTICAn3fso5GYa90qPNMBclov"
83
  crossorigin="anonymous">
84
 
85
- <!-- highlight.js theme -->
86
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
 
 
87
 
88
  <!-- Mermaid (renders <pre class="mermaid"> blocks) -->
89
  <script type="module">
@@ -109,6 +112,106 @@ ${css.print}
109
 
110
  /* Publisher-only overrides */
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  /* Accordion as details/summary */
113
  details[data-component="accordion"] {
114
  border: 1px solid var(--border-color);
@@ -138,6 +241,122 @@ details[data-component="accordion"] > summary::before {
138
  details[data-component="accordion"][open] > summary::before { transform: rotate(90deg); }
139
  details[data-component="accordion"] > .accordion-content { padding: 0 1rem 1rem; }
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  /* PDF download link */
142
  a.pdf-link {
143
  display: inline-flex;
@@ -157,7 +376,16 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
157
  </style>
158
  </head>
159
  <body>
160
- <button class="theme-toggle" aria-label="Toggle theme" onclick="toggleTheme()">&#127763;</button>
 
 
 
 
 
 
 
 
 
161
 
162
  <!-- Mobile TOC toggle -->
163
  <button class="toc-mobile-toggle" aria-label="Open table of contents" aria-expanded="false">
@@ -180,7 +408,7 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
180
 
181
  <!-- Hero -->
182
  <section class="hero">
183
- <h1>${safeTitle}</h1>
184
  ${meta.subtitle ? `<p class="hero-desc">${escapeHtml(meta.subtitle)}</p>` : ""}
185
  </section>
186
 
@@ -193,13 +421,16 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
193
  <div id="toc-placeholder"></div>
194
  </nav>
195
 
196
- <article>
197
  <div class="tiptap">
198
  ${enrichedBody}
199
  </div>
200
- </article>
201
  </section>
202
 
 
 
 
203
  <!-- Image lightbox -->
204
  <dialog class="lightbox" id="lightbox">
205
  <img id="lightbox-img" src="" alt="">
@@ -207,14 +438,44 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
207
 
208
  <script>
209
  (function() {
210
- // Theme
211
- window.toggleTheme = function() {
212
- var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
213
- document.documentElement.setAttribute('data-theme', next);
214
- localStorage.setItem('theme', next);
215
- };
 
216
  var saved = localStorage.getItem('theme');
217
- if (saved) document.documentElement.setAttribute('data-theme', saved);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  // Lightbox
220
  document.querySelectorAll('.tiptap img').forEach(function(img) {
@@ -232,7 +493,7 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
232
  // ---- Table of Contents ----
233
  var holder = document.getElementById('toc-placeholder');
234
  var holderMobile = document.getElementById('toc-mobile-placeholder');
235
- var articleRoot = document.querySelector('.content-grid article');
236
  if (!articleRoot) return;
237
  var headings = articleRoot.querySelectorAll('h2, h3, h4');
238
  if (!headings.length) return;
@@ -425,6 +686,67 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
425
  if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
426
  document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  // Hash navigation
429
  if (window.location.hash) {
430
  var target = document.querySelector(window.location.hash);
@@ -600,3 +922,89 @@ function formatDate(dateStr: string): string {
600
  return dateStr;
601
  }
602
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  affiliations: PublishAffiliation[];
30
  date: string;
31
  doi?: string;
32
+ licence?: string;
33
  ogImage?: string;
34
  pdfUrl?: string;
35
  }
 
83
  integrity="sha384-zh0CIslj3dQfMxK3pZnPJAb3bprHitCE2vBz9TyOTICAn3fso5GYa90qPNMBclov"
84
  crossorigin="anonymous">
85
 
86
+ <!-- highlight.js for code syntax highlighting -->
87
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
88
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
89
+ <script>hljs.highlightAll();</script>
90
 
91
  <!-- Mermaid (renders <pre class="mermaid"> blocks) -->
92
  <script type="module">
 
112
 
113
  /* Publisher-only overrides */
114
 
115
+ /* Theme toggle icon wrapper (from ThemeToggle.astro) */
116
+ #theme-toggle .icon-wrapper {
117
+ display: grid;
118
+ place-items: center;
119
+ width: 20px;
120
+ height: 20px;
121
+ }
122
+ #theme-toggle .icon-wrapper .icon {
123
+ grid-area: 1 / 1;
124
+ filter: none !important;
125
+ }
126
+ #theme-toggle .icon-wrapper.animated .icon {
127
+ transition: opacity 0.35s ease;
128
+ }
129
+ #theme-toggle .icon-wrapper.spin-cw { animation: spin-cw 0.5s cubic-bezier(0.4,0,0.2,1); }
130
+ #theme-toggle .icon-wrapper.spin-ccw { animation: spin-ccw 0.5s cubic-bezier(0.4,0,0.2,1); }
131
+ @keyframes spin-cw { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
132
+ @keyframes spin-ccw { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
133
+
134
+ /* Note component */
135
+ div[data-component="note"] {
136
+ border: 1px solid var(--border-color);
137
+ border-radius: 8px;
138
+ padding: 1rem 1.25rem;
139
+ margin: 1em 0;
140
+ background: var(--surface-bg, rgba(0,0,0,0.02));
141
+ }
142
+ div[data-component="note"][data-variant="warning"] {
143
+ border-color: #f59e0b;
144
+ background: rgba(245,158,11,0.06);
145
+ }
146
+ div[data-component="note"][data-variant="danger"] {
147
+ border-color: #ef4444;
148
+ background: rgba(239,68,68,0.06);
149
+ }
150
+ div[data-component="note"][data-variant="success"] {
151
+ border-color: #22c55e;
152
+ background: rgba(34,197,94,0.06);
153
+ }
154
+ div[data-component="note"] > *:first-child { margin-top: 0; }
155
+ div[data-component="note"] > *:last-child { margin-bottom: 0; }
156
+
157
+ /* Quote component */
158
+ div[data-component="quoteBlock"] {
159
+ border-left: 3px solid var(--primary-color, var(--border-color));
160
+ padding: 1rem 1.25rem;
161
+ margin: 1.5em 0;
162
+ font-style: italic;
163
+ color: var(--muted-color);
164
+ }
165
+ div[data-component="quoteBlock"] > *:first-child { margin-top: 0; }
166
+ div[data-component="quoteBlock"] > *:last-child { margin-bottom: 0; }
167
+
168
+ /* Sidenote component */
169
+ div[data-component="sidenote"] {
170
+ font-size: 0.85em;
171
+ color: var(--muted-color);
172
+ border-left: 2px solid var(--border-color);
173
+ padding-left: 0.75rem;
174
+ margin: 1em 0;
175
+ }
176
+ div[data-component="sidenote"] > *:first-child { margin-top: 0; }
177
+ div[data-component="sidenote"] > *:last-child { margin-bottom: 0; }
178
+
179
+ /* Reference wrapper */
180
+ div[data-component="reference"] {
181
+ margin: 1.5em 0;
182
+ }
183
+ div[data-component="reference"] > *:first-child { margin-top: 0; }
184
+ div[data-component="reference"] > *:last-child { margin-bottom: 0; }
185
+
186
+ /* Stack / multi-column layout */
187
+ div[data-component="stack"] {
188
+ display: grid;
189
+ gap: 1rem;
190
+ margin: 1.5em 0;
191
+ }
192
+ div[data-component="stack"][data-layout="2-column"] { grid-template-columns: 1fr 1fr; }
193
+ div[data-component="stack"][data-layout="3-column"] { grid-template-columns: 1fr 1fr 1fr; }
194
+ div[data-component="stack"][data-layout="4-column"] { grid-template-columns: 1fr 1fr 1fr 1fr; }
195
+ div[data-component="stack"][data-gap="small"] { gap: 0.5rem; }
196
+ div[data-component="stack"][data-gap="large"] { gap: 2rem; }
197
+ div[data-type="stack-column"] { min-width: 0; }
198
+ div[data-type="stack-column"] > *:first-child { margin-top: 0; }
199
+ div[data-type="stack-column"] > *:last-child { margin-bottom: 0; }
200
+ @media (max-width: 640px) {
201
+ div[data-component="stack"] { grid-template-columns: 1fr !important; }
202
+ }
203
+
204
+ /* Wide / full-width helpers for published content (if not already from _layout.css) */
205
+ div[data-component="wide"] {
206
+ width: min(1100px, 100vw - 64px);
207
+ margin-left: 50%;
208
+ transform: translateX(-50%);
209
+ }
210
+ div[data-component="fullWidth"] {
211
+ width: 100vw;
212
+ margin-left: calc(50% - 50vw);
213
+ }
214
+
215
  /* Accordion as details/summary */
216
  details[data-component="accordion"] {
217
  border: 1px solid var(--border-color);
 
241
  details[data-component="accordion"][open] > summary::before { transform: rotate(90deg); }
242
  details[data-component="accordion"] > .accordion-content { padding: 0 1rem 1rem; }
243
 
244
+ /* Footer */
245
+ .footer {
246
+ contain: layout style;
247
+ font-size: 0.8em;
248
+ line-height: 1.7em;
249
+ margin-top: 60px;
250
+ margin-bottom: 0;
251
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
252
+ color: rgba(0, 0, 0, 0.5);
253
+ }
254
+
255
+ .footer-inner {
256
+ max-width: 1280px;
257
+ margin: 0 auto;
258
+ padding: 60px 16px 48px;
259
+ display: grid;
260
+ grid-template-columns: 220px minmax(0, 680px) 260px;
261
+ gap: 32px;
262
+ align-items: start;
263
+ }
264
+
265
+ .citation-block,
266
+ .references-block,
267
+ .reuse-block,
268
+ .doi-block {
269
+ display: contents;
270
+ }
271
+
272
+ .citation-block > .footer-heading,
273
+ .references-block > .footer-heading,
274
+ .reuse-block > .footer-heading,
275
+ .doi-block > .footer-heading {
276
+ grid-column: 1;
277
+ font-size: 15px;
278
+ font-weight: 600;
279
+ margin: 0;
280
+ text-align: right;
281
+ padding-right: 30px;
282
+ }
283
+
284
+ .citation-block > :not(.footer-heading),
285
+ .references-block > :not(.footer-heading),
286
+ .reuse-block > :not(.footer-heading),
287
+ .doi-block > :not(.footer-heading) {
288
+ grid-column: 2;
289
+ }
290
+
291
+ .citation-block .footer-heading { margin: 0 0 8px; }
292
+
293
+ .citation-block p,
294
+ .reuse-block p,
295
+ .doi-block p,
296
+ .footnotes ol,
297
+ .footnotes ol p,
298
+ .references { margin-top: 0; }
299
+
300
+ .citation {
301
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
302
+ font-size: 11px;
303
+ line-height: 15px;
304
+ border: 1px solid rgba(0, 0, 0, 0.1);
305
+ background: rgba(0, 0, 0, 0.02);
306
+ padding: 10px 18px;
307
+ border-radius: 3px;
308
+ color: rgba(150, 150, 150, 1);
309
+ overflow: hidden;
310
+ margin-top: -12px;
311
+ white-space: pre-wrap;
312
+ word-wrap: break-word;
313
+ }
314
+
315
+ .citation a { color: rgba(0, 0, 0, 0.6); text-decoration: underline; }
316
+ .citation.short { margin-top: -4px; }
317
+
318
+ .references-block .footer-heading { margin: 0; }
319
+ .references-block ol { padding: 0 0 0 15px; }
320
+ .references-block li { margin-bottom: 1em; }
321
+ .references-block a { color: var(--text-color); }
322
+
323
+ .footer a {
324
+ color: var(--primary-color);
325
+ border-bottom: 1px solid var(--link-underline);
326
+ text-decoration: none;
327
+ }
328
+ .footer a:hover {
329
+ color: var(--primary-color-hover);
330
+ border-bottom-color: var(--link-underline-hover);
331
+ }
332
+
333
+ .template-credit { display: contents; }
334
+ .template-credit p {
335
+ grid-column: 2;
336
+ margin: 24px 0 0 0;
337
+ font-size: 0.85em;
338
+ color: rgba(0, 0, 0, 0.5);
339
+ }
340
+ .template-credit a { color: rgba(0, 0, 0, 0.6); border-bottom: 1px solid rgba(0, 0, 0, 0.15); }
341
+ .template-credit a:hover { color: rgba(0, 0, 0, 0.8); border-bottom-color: rgba(0, 0, 0, 0.3); }
342
+
343
+ [data-theme="dark"] .footer { border-top-color: rgba(255, 255, 255, 0.15); color: rgba(200, 200, 200, 0.8); }
344
+ [data-theme="dark"] .citation { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.15); color: rgba(200, 200, 200, 1); }
345
+ [data-theme="dark"] .citation a { color: rgba(255, 255, 255, 0.75); }
346
+ [data-theme="dark"] .footer a { color: var(--primary-color); }
347
+ [data-theme="dark"] .template-credit p { color: rgba(200, 200, 200, 0.6); }
348
+ [data-theme="dark"] .template-credit a { color: rgba(200, 200, 200, 0.7); border-bottom-color: rgba(255, 255, 255, 0.2); }
349
+ [data-theme="dark"] .template-credit a:hover { color: rgba(200, 200, 200, 0.9); border-bottom-color: rgba(255, 255, 255, 0.35); }
350
+
351
+ @media (max-width: 1100px) {
352
+ .footer-inner { grid-template-columns: 1fr; gap: 16px; display: block; padding: 40px 16px; }
353
+ .footer-inner > .footer-heading { grid-column: auto; margin-top: 16px; }
354
+ }
355
+
356
+ @media (min-width: 768px) {
357
+ .references-block ol { padding: 0 0 0 30px; margin-left: -30px; }
358
+ }
359
+
360
  /* PDF download link */
361
  a.pdf-link {
362
  display: inline-flex;
 
376
  </style>
377
  </head>
378
  <body>
379
+ <button id="theme-toggle" aria-label="Toggle color theme">
380
+ <span class="icon-wrapper">
381
+ <svg class="icon icon--sun" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
382
+ <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
383
+ </svg>
384
+ <svg class="icon icon--moon" style="opacity:0" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
385
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
386
+ </svg>
387
+ </span>
388
+ </button>
389
 
390
  <!-- Mobile TOC toggle -->
391
  <button class="toc-mobile-toggle" aria-label="Open table of contents" aria-expanded="false">
 
408
 
409
  <!-- Hero -->
410
  <section class="hero">
411
+ <h1 class="hero-title">${safeTitle}</h1>
412
  ${meta.subtitle ? `<p class="hero-desc">${escapeHtml(meta.subtitle)}</p>` : ""}
413
  </section>
414
 
 
421
  <div id="toc-placeholder"></div>
422
  </nav>
423
 
424
+ <main>
425
  <div class="tiptap">
426
  ${enrichedBody}
427
  </div>
428
+ </main>
429
  </section>
430
 
431
+ <!-- Footer -->
432
+ ${renderFooter(meta)}
433
+
434
  <!-- Image lightbox -->
435
  <dialog class="lightbox" id="lightbox">
436
  <img id="lightbox-img" src="" alt="">
 
438
 
439
  <script>
440
  (function() {
441
+ // Theme toggle (matches template ThemeToggle.astro)
442
+ var btn = document.getElementById('theme-toggle');
443
+ var sunIcon = btn && btn.querySelector('.icon--sun');
444
+ var moonIcon = btn && btn.querySelector('.icon--moon');
445
+ var wrapper = btn && btn.querySelector('.icon-wrapper');
446
+ var media = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
447
+ var prefersDark = media && media.matches;
448
  var saved = localStorage.getItem('theme');
449
+
450
+ function applyTheme(mode) {
451
+ document.documentElement.dataset.theme = mode;
452
+ if (sunIcon && moonIcon) {
453
+ sunIcon.style.opacity = mode === 'dark' ? '0' : '1';
454
+ moonIcon.style.opacity = mode === 'dark' ? '1' : '0';
455
+ }
456
+ }
457
+ applyTheme(saved || (prefersDark ? 'dark' : 'light'));
458
+ requestAnimationFrame(function() { if (wrapper) wrapper.classList.add('animated'); });
459
+
460
+ if (!saved && media) {
461
+ var syncSystem = function(e) { applyTheme(e.matches ? 'dark' : 'light'); };
462
+ if (media.addEventListener) media.addEventListener('change', syncSystem);
463
+ }
464
+
465
+ if (btn) {
466
+ btn.addEventListener('click', function() {
467
+ var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
468
+ localStorage.setItem('theme', next);
469
+ if (wrapper) {
470
+ var cls = next === 'dark' ? 'spin-cw' : 'spin-ccw';
471
+ wrapper.classList.remove('spin-cw', 'spin-ccw');
472
+ void wrapper.offsetWidth;
473
+ wrapper.classList.add(cls);
474
+ wrapper.addEventListener('animationend', function() { wrapper.classList.remove(cls); }, { once: true });
475
+ }
476
+ applyTheme(next);
477
+ });
478
+ }
479
 
480
  // Lightbox
481
  document.querySelectorAll('.tiptap img').forEach(function(img) {
 
493
  // ---- Table of Contents ----
494
  var holder = document.getElementById('toc-placeholder');
495
  var holderMobile = document.getElementById('toc-mobile-placeholder');
496
+ var articleRoot = document.querySelector('.content-grid main');
497
  if (!articleRoot) return;
498
  var headings = articleRoot.querySelectorAll('h2, h3, h4');
499
  if (!headings.length) return;
 
686
  if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
687
  document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
688
 
689
+ // ---- Footer: move references & footnotes ----
690
+ (function() {
691
+ var footer = document.querySelector('footer.footer');
692
+ if (!footer) return;
693
+ var target = footer.querySelector('.references-block');
694
+ if (!target) return;
695
+ var contentRoot = document.querySelector('.content-grid main') || document.body;
696
+
697
+ var ensureHeading = function(text) {
698
+ var exists = Array.from(target.children).some(function(c) {
699
+ return c.classList.contains('footer-heading') && c.textContent.trim().toLowerCase() === text.toLowerCase();
700
+ });
701
+ if (!exists) {
702
+ var h = document.createElement('p');
703
+ h.className = 'footer-heading';
704
+ h.setAttribute('role', 'heading');
705
+ h.setAttribute('aria-level', '2');
706
+ h.textContent = text;
707
+ target.appendChild(h);
708
+ }
709
+ };
710
+
711
+ var moveIntoFooter = function(el, headingText) {
712
+ if (!el) return false;
713
+ var firstH = el.querySelector(':scope > h1, :scope > h2, :scope > h3, :scope > .footer-heading, :scope > .bibliography-title');
714
+ if (firstH) {
715
+ var t = (firstH.textContent || '').trim().toLowerCase();
716
+ if (t === headingText.toLowerCase() || t.includes('reference') || t.includes('bibliograph')) firstH.remove();
717
+ }
718
+ ensureHeading(headingText);
719
+ target.appendChild(el);
720
+ return true;
721
+ };
722
+
723
+ var findAllOutsideFooter = function(selectors) {
724
+ var results = [];
725
+ for (var i = 0; i < selectors.length; i++) {
726
+ var els = contentRoot.querySelectorAll(selectors[i]);
727
+ els.forEach(function(el) { if (!footer.contains(el) && results.indexOf(el) === -1) results.push(el); });
728
+ }
729
+ return results;
730
+ };
731
+
732
+ var findFirst = function(selectors) { var a = findAllOutsideFooter(selectors); return a.length ? a[0] : null; };
733
+
734
+ var run = function() {
735
+ if (footer.dataset.processed === 'true') return;
736
+ var refs = findAllOutsideFooter(['[data-type="bibliography"]', '#bibliography-references-list', '#references', '#refs', '.bibliography']);
737
+ var notes = findFirst(['.footnotes', 'section.footnotes', 'div.footnotes']);
738
+ var moved = false;
739
+ refs.forEach(function(el) { if (moveIntoFooter(el, 'References')) moved = true; });
740
+ if (moveIntoFooter(notes, 'Footnotes')) moved = true;
741
+ if (moved) footer.dataset.processed = 'true';
742
+ };
743
+
744
+ run();
745
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true });
746
+ window.addEventListener('load', function() { setTimeout(run, 100); }, { once: true });
747
+ setTimeout(run, 300);
748
+ })();
749
+
750
  // Hash navigation
751
  if (window.location.hash) {
752
  var target = document.querySelector(window.location.hash);
 
922
  return dateStr;
923
  }
924
  }
925
+
926
+ function extractYear(dateStr?: string): number | undefined {
927
+ if (!dateStr) return undefined;
928
+ const d = new Date(dateStr);
929
+ if (!Number.isNaN(d.getTime())) return d.getFullYear();
930
+ const m = dateStr.match(/(19|20)\d{2}/);
931
+ return m ? Number(m[0]) : undefined;
932
+ }
933
+
934
+ function buildCitationText(meta: PublishMeta): string {
935
+ const authorNames = meta.authors.map((a) => a.name);
936
+ const authorsStr = authorNames.join(", ");
937
+ const year = extractYear(meta.date);
938
+ const title = meta.title.replace(/\s+/g, " ").trim();
939
+ return `${authorsStr}${year ? ` (${year})` : ""}. "${title}".`;
940
+ }
941
+
942
+ function buildBibtex(meta: PublishMeta): string {
943
+ const authorNames = meta.authors.map((a) => a.name);
944
+ const authorsBib = authorNames.join(" and ");
945
+ const title = meta.title.replace(/\s+/g, " ").trim();
946
+ const year = extractYear(meta.date);
947
+ const keyAuthor = (authorNames[0] || "article")
948
+ .split(/\s+/)
949
+ .slice(-1)[0]
950
+ .toLowerCase();
951
+ const keyTitle = title
952
+ .toLowerCase()
953
+ .replace(/[^a-z0-9]+/g, "_")
954
+ .replace(/^_|_$/g, "");
955
+ const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`;
956
+ const parts = [
957
+ ` title={${title}}`,
958
+ ` author={${authorsBib}}`,
959
+ ];
960
+ if (year) parts.push(` year={${year}}`);
961
+ if (meta.doi) parts.push(` doi={${meta.doi}}`);
962
+ return `@misc{${bibKey},\n${parts.join(",\n")}\n}`;
963
+ }
964
+
965
+ function renderFooter(meta: PublishMeta): string {
966
+ const citationText = buildCitationText(meta);
967
+ const bibtex = buildBibtex(meta);
968
+
969
+ let sections = "";
970
+
971
+ // Citation block
972
+ sections += `
973
+ <section class="citation-block">
974
+ <p class="footer-heading" role="heading" aria-level="2">Citation</p>
975
+ <p>For attribution in academic contexts, please cite this work as</p>
976
+ <pre class="citation short">${escapeHtml(citationText)}</pre>
977
+ <p>BibTeX citation</p>
978
+ <pre class="citation long">${escapeHtml(bibtex)}</pre>
979
+ </section>`;
980
+
981
+ // DOI block
982
+ if (meta.doi) {
983
+ sections += `
984
+ <section class="doi-block">
985
+ <p class="footer-heading" role="heading" aria-level="2">DOI</p>
986
+ <p><a href="https://doi.org/${escapeHtml(meta.doi)}" target="_blank" rel="noopener noreferrer">${escapeHtml(meta.doi)}</a></p>
987
+ </section>`;
988
+ }
989
+
990
+ // Licence / Reuse block
991
+ if (meta.licence) {
992
+ sections += `
993
+ <section class="reuse-block">
994
+ <p class="footer-heading" role="heading" aria-level="2">Reuse</p>
995
+ <p>${meta.licence}</p>
996
+ </section>`;
997
+ }
998
+
999
+ // References placeholder (JS will move bibliography + footnotes here)
1000
+ sections += `
1001
+ <section class="references-block"></section>`;
1002
+
1003
+ // Template credit
1004
+ sections += `
1005
+ <div class="template-credit">
1006
+ <p>Made with &#10084;&#65039; with <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">research article template</a></p>
1007
+ </div>`;
1008
+
1009
+ return `<footer class="footer"><div class="footer-inner">${sections}</div></footer>`;
1010
+ }
backend/src/publisher/index.ts CHANGED
@@ -236,6 +236,7 @@ export async function publishDocument(docName: string, token?: string): Promise<
236
  affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
237
  date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
238
  doi: (frontmatter.doi as string) || undefined,
 
239
  };
240
 
241
  // Pre-compute public URLs so og:image and PDF link are embedded in HTML
 
236
  affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
237
  date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
238
  doi: (frontmatter.doi as string) || undefined,
239
+ licence: (frontmatter.licence as string) || (frontmatter.license as string) || undefined,
240
  };
241
 
242
  // Pre-compute public URLs so og:image and PDF link are embedded in HTML
frontend/src/components/CommentsSidebar.tsx CHANGED
@@ -86,22 +86,29 @@ export function CommentsSidebar({ editor, commentStore, user, editorContainer }:
86
  setPositioned(result);
87
  }, [editor, editorContainer, comments]);
88
 
 
 
89
  useEffect(() => {
90
  updatePositions();
91
 
92
- const onScroll = () => updatePositions();
 
 
 
 
93
  const editorEl = editor?.view?.dom;
94
  const scrollTarget = editorContainer;
95
 
96
  if (editorEl) {
97
  if (scrollTarget) {
98
- scrollTarget.addEventListener("scroll", onScroll, { passive: true });
99
  }
100
- const observer = new MutationObserver(updatePositions);
101
  observer.observe(editorEl, { childList: true, subtree: true, characterData: true });
102
  return () => {
103
- scrollTarget?.removeEventListener("scroll", onScroll);
104
  observer.disconnect();
 
105
  };
106
  }
107
  }, [editor, editorContainer, updatePositions]);
 
86
  setPositioned(result);
87
  }, [editor, editorContainer, comments]);
88
 
89
+ const posTimerRef = useRef(0);
90
+
91
  useEffect(() => {
92
  updatePositions();
93
 
94
+ const debouncedPositions = () => {
95
+ clearTimeout(posTimerRef.current);
96
+ posTimerRef.current = window.setTimeout(updatePositions, 200);
97
+ };
98
+
99
  const editorEl = editor?.view?.dom;
100
  const scrollTarget = editorContainer;
101
 
102
  if (editorEl) {
103
  if (scrollTarget) {
104
+ scrollTarget.addEventListener("scroll", debouncedPositions, { passive: true });
105
  }
106
+ const observer = new MutationObserver(debouncedPositions);
107
  observer.observe(editorEl, { childList: true, subtree: true, characterData: true });
108
  return () => {
109
+ scrollTarget?.removeEventListener("scroll", debouncedPositions);
110
  observer.disconnect();
111
+ clearTimeout(posTimerRef.current);
112
  };
113
  }
114
  }, [editor, editorContainer, updatePositions]);
frontend/src/components/TableOfContents.tsx CHANGED
@@ -60,12 +60,23 @@ export function TableOfContents({ editor }: TableOfContentsProps) {
60
  const [activePos, setActivePos] = useState<number | null>(null);
61
  const rafRef = useRef(0);
62
 
 
 
63
  useEffect(() => {
64
  if (!editor) return;
65
- const update = () => setHeadings(extractHeadings(editor));
66
- update();
67
- editor.on("update", update);
68
- return () => { editor.off("update", update); };
 
 
 
 
 
 
 
 
 
69
  }, [editor]);
70
 
71
  useEffect(() => {
 
60
  const [activePos, setActivePos] = useState<number | null>(null);
61
  const rafRef = useRef(0);
62
 
63
+ const debounceRef = useRef(0);
64
+
65
  useEffect(() => {
66
  if (!editor) return;
67
+ setHeadings(extractHeadings(editor));
68
+ const debouncedUpdate = () => {
69
+ clearTimeout(debounceRef.current);
70
+ debounceRef.current = window.setTimeout(
71
+ () => setHeadings(extractHeadings(editor)),
72
+ 300,
73
+ );
74
+ };
75
+ editor.on("update", debouncedUpdate);
76
+ return () => {
77
+ editor.off("update", debouncedUpdate);
78
+ clearTimeout(debounceRef.current);
79
+ };
80
  }, [editor]);
81
 
82
  useEffect(() => {