@docmd/template-summer 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,660 @@
1
+ /* =========================================================================
2
+ @docmd/template-summer — summer.js
3
+ Runtime interactions for the Summer template. Vanilla JS, no deps.
4
+
5
+ What it does (the rest is handled by docmd-main.js, already loaded
6
+ by templates/layout.ejs — see packages/ui/assets/js/docmd-main.js for
7
+ SPA routing, theme toggle, sidebar drawer, version/project/language
8
+ switchers, code-block copy, page copy, banner, cookie consent and
9
+ search-trigger event delegation):
10
+
11
+ - Inline topbar search dropdown that re-homes docmd-search.js's
12
+ full-screen modal into the topbar, forwarding keyboard nav
13
+ (↑↓ Enter Esc) to the hidden plugin input
14
+ - TOC scroll-spy with a custom SVG path track (the GitBook-style
15
+ "folding line" that follows the active heading's indent level)
16
+ - TOC smooth-scroll (offset for the sticky topbar + subnav + pageheader)
17
+ - Scroll-to-top button (revealed on scroll)
18
+ - Git "last-updated" popover (relative dates + recent commits)
19
+ - Relative date rendering for any [data-timestamp] element
20
+
21
+ Everything is idempotent — per-page wires re-run after every
22
+ docmd:page-mounted event (fired by docmd-main.js on SPA nav).
23
+ ========================================================================= */
24
+ (function () {
25
+ 'use strict';
26
+
27
+ // -------- Utilities ----------------------------------------------------
28
+
29
+ function $(sel, root) { return (root || document).querySelector(sel); }
30
+ function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
31
+ function ready(fn) {
32
+ if (document.readyState !== 'loading') fn();
33
+ else document.addEventListener('DOMContentLoaded', fn);
34
+ }
35
+
36
+ function debounce(fn, wait) {
37
+ let timer;
38
+ return function () {
39
+ const args = arguments;
40
+ clearTimeout(timer);
41
+ timer = setTimeout(function () { fn.apply(null, args); }, wait);
42
+ };
43
+ }
44
+
45
+
46
+ // -------- TOC: SVG path track (Fumadocs-style) -----------------------
47
+
48
+ var _tocActiveIdx = -1;
49
+ var _tocScrollDir = 1; // 1 = down, -1 = up
50
+ var _tocLastScrollY = window.pageYOffset || 0;
51
+
52
+ function buildTocSvgTrack() {
53
+ var list = $('.summer-toc__list');
54
+ if (!list) return;
55
+ var items = $$('.summer-toc__item', list);
56
+ if (!items.length) return;
57
+
58
+ // === Tweakable layout ===
59
+ var BASE_X = 5; // x position of level-1 items (px from track left)
60
+ var INDENT = 7; // px indent per level step
61
+
62
+ // === Tweakable bend shape ===
63
+ // Cubic Bézier between consecutive items at different indent levels.
64
+ // BEND_HORIZ_MULT — scales with the horizontal |Δx| (typical Δx = INDENT).
65
+ // Higher = more curve per indent step.
66
+ // BEND_VERT_FRAC — caps the bend as a fraction of the actual vertical
67
+ // gap between item centres. 1.0 = max smooth S-curve,
68
+ // < 1.0 = subtler. Must stay ≤ 1.0 to avoid overshoot.
69
+ var BEND_HORIZ_MULT = 2.0;
70
+ var BEND_VERT_FRAC = 1.0;
71
+
72
+ // Measure each item's actual centre y from the DOM. The track is
73
+ // position:absolute at top:0 of the list, so list-relative coords match
74
+ // track-local coords and the path lines up with the items exactly.
75
+ var positions = items.map(function (li) {
76
+ var lvl = parseInt(li.dataset.level || li.className.match(/level-(\d)/)?.[1] || '1', 10);
77
+ return {
78
+ x: BASE_X + (lvl - 1) * INDENT,
79
+ cy: li.offsetTop + li.offsetHeight / 2
80
+ };
81
+ });
82
+ var xPositions = positions.map(function (p) { return p.x; });
83
+ var yCentres = positions.map(function (p) { return p.cy; });
84
+ // Track height = full list content (top of list to bottom of last item)
85
+ var totalH = items[items.length - 1].offsetTop + items[items.length - 1].offsetHeight;
86
+
87
+ // Build the SVG path d-string
88
+ var d = '';
89
+ for (var i = 0; i < positions.length; i++) {
90
+ var x = positions[i].x;
91
+ var y = yCentres[i];
92
+ if (i === 0) {
93
+ d += 'M ' + x + ' 0 L ' + x + ' ' + y;
94
+ } else {
95
+ var px = positions[i - 1].x;
96
+ var py = yCentres[i - 1];
97
+ if (px === x) {
98
+ // same level — straight line
99
+ d += ' L ' + x + ' ' + y;
100
+ } else {
101
+ // level change — cubic bezier bend, clamped so the curve never
102
+ // overshoots past the destination
103
+ var gap = y - py;
104
+ var bend = Math.min(gap * BEND_VERT_FRAC, Math.abs(x - px) * BEND_HORIZ_MULT);
105
+ d += ' C ' + px + ' ' + (py + bend) + ' ' + x + ' ' + (y - bend) + ' ' + x + ' ' + y;
106
+ }
107
+ }
108
+ }
109
+ // extend to bottom
110
+ var lastX = positions[positions.length - 1].x;
111
+ d += ' L ' + lastX + ' ' + totalH;
112
+
113
+ var svgW = BASE_X + (4 - 1) * INDENT + 4; // max possible width
114
+
115
+ // Create track container
116
+ var track = document.createElement('div');
117
+ track.className = 'summer-toc__track';
118
+ track.style.width = svgW + 'px';
119
+ track.style.height = totalH + 'px';
120
+
121
+ var NS = 'http://www.w3.org/2000/svg';
122
+
123
+ // Full (grey) path
124
+ var svgFull = document.createElementNS(NS, 'svg');
125
+ svgFull.setAttribute('class', 'summer-toc__track-full');
126
+ svgFull.setAttribute('width', svgW);
127
+ svgFull.setAttribute('height', totalH);
128
+ svgFull.setAttribute('viewBox', '0 0 ' + svgW + ' ' + totalH);
129
+ var pathFull = document.createElementNS(NS, 'path');
130
+ pathFull.setAttribute('d', d);
131
+ svgFull.appendChild(pathFull);
132
+ track.appendChild(svgFull);
133
+
134
+ // Active (accent) path — clipped
135
+ var svgActive = document.createElementNS(NS, 'svg');
136
+ svgActive.setAttribute('class', 'summer-toc__track-active');
137
+ svgActive.setAttribute('width', svgW);
138
+ svgActive.setAttribute('height', totalH);
139
+ svgActive.setAttribute('viewBox', '0 0 ' + svgW + ' ' + totalH);
140
+ svgActive.style.clipPath = 'polygon(0 0, ' + svgW + 'px 0, ' + svgW + 'px 0, 0 0)';
141
+ var pathActive = document.createElementNS(NS, 'path');
142
+ pathActive.setAttribute('d', d);
143
+ svgActive.appendChild(pathActive);
144
+ track.appendChild(svgActive);
145
+
146
+ list.insertBefore(track, list.firstChild);
147
+
148
+ return { track: track, svgActive: svgActive, xPositions: xPositions, yCentres: yCentres, d: d, totalH: totalH, svgW: svgW };
149
+ }
150
+
151
+ function wireTocScrollSpy() {
152
+ var tocLinks = $$('.summer-toc__link');
153
+ if (!tocLinks.length) return;
154
+
155
+ var headings = tocLinks
156
+ .map(function (link) {
157
+ var id = (link.getAttribute('href') || '').replace(/^#/, '');
158
+ if (!id) return null;
159
+ return { id: id, el: document.getElementById(id), link: link };
160
+ })
161
+ .filter(function (x) { return x && x.el; });
162
+
163
+ if (!headings.length) return;
164
+
165
+ var track = buildTocSvgTrack();
166
+
167
+ function setActive(idx) {
168
+ if (idx === _tocActiveIdx) return;
169
+ _tocActiveIdx = idx;
170
+
171
+ tocLinks.forEach(function (l) { l.classList.remove('active'); });
172
+ if (idx < 0 || idx >= tocLinks.length) return;
173
+
174
+ tocLinks[idx].classList.add('active');
175
+
176
+ if (!track) return;
177
+
178
+ var totalH = track.totalH;
179
+ var svgW = track.svgW;
180
+
181
+ // Fill from top down to the active item's centre Y.
182
+ // If the user has scrolled to the bottom of the page (footer visible),
183
+ // fill all the way to the bottom of the track so it doesn't appear cut off.
184
+ var docH = document.documentElement.scrollHeight;
185
+ var winH = window.innerHeight;
186
+ var atPageBottom = (window.pageYOffset + winH) >= (docH - 40);
187
+ var activeY = (atPageBottom || idx === tocLinks.length - 1)
188
+ ? totalH
189
+ : track.yCentres[idx];
190
+
191
+ track.svgActive.style.clipPath =
192
+ 'polygon(0 0, ' + svgW + 'px 0, ' +
193
+ svgW + 'px ' + activeY + 'px, 0 ' + activeY + 'px)';
194
+ }
195
+
196
+ // Scroll-spy using IntersectionObserver — each heading fills the TOC
197
+ // as soon as it enters the viewport (visible = active).
198
+ var TOPBAR_H = parseInt(
199
+ getComputedStyle(document.documentElement).getPropertyValue('--summer-topbar-height') || '64', 10
200
+ );
201
+ var SUBNAV_H = parseInt(
202
+ getComputedStyle(document.documentElement).getPropertyValue('--summer-subnav-height') || '44', 10
203
+ );
204
+ var rootMarginTop = -(TOPBAR_H + SUBNAV_H + 8) + 'px';
205
+
206
+ // Build a map from heading id → index
207
+ var idxMap = {};
208
+ headings.forEach(function (h, i) { idxMap[h.id] = i; });
209
+
210
+ // Track which headings are currently intersecting
211
+ var visibleSet = {};
212
+
213
+ var observer = new IntersectionObserver(function (entries) {
214
+ entries.forEach(function (entry) {
215
+ if (entry.isIntersecting) {
216
+ visibleSet[entry.target.id] = true;
217
+ } else {
218
+ delete visibleSet[entry.target.id];
219
+ }
220
+ });
221
+ // Activate the last visible heading (lowest on screen = most advanced)
222
+ var bestIdx = -1;
223
+ headings.forEach(function (h, i) {
224
+ if (visibleSet[h.id] && i >= bestIdx) bestIdx = i;
225
+ });
226
+ // Fallback: if nothing visible, find last heading above viewport
227
+ if (bestIdx === -1) {
228
+ for (var i = headings.length - 1; i >= 0; i--) {
229
+ var rect = headings[i].el.getBoundingClientRect();
230
+ if (rect.bottom < TOPBAR_H + SUBNAV_H + 8) { bestIdx = i; break; }
231
+ }
232
+ }
233
+ if (bestIdx === -1 && headings.length) bestIdx = 0;
234
+ setActive(bestIdx);
235
+ }, {
236
+ rootMargin: rootMarginTop + ' 0px -10% 0px',
237
+ threshold: 0
238
+ });
239
+
240
+ headings.forEach(function (h) { observer.observe(h.el); });
241
+ // Initial state
242
+ updateActiveOnce();
243
+
244
+ function updateActiveOnce() {
245
+ var bestIdx = -1;
246
+ for (var i = headings.length - 1; i >= 0; i--) {
247
+ var rect = headings[i].el.getBoundingClientRect();
248
+ if (rect.top <= TOPBAR_H + SUBNAV_H + 80) { bestIdx = i; break; }
249
+ }
250
+ if (bestIdx === -1 && headings.length) bestIdx = 0;
251
+ setActive(bestIdx);
252
+ }
253
+ }
254
+
255
+ function wireTocSmoothScroll() {
256
+ $$('.summer-toc__link').forEach(function (link) {
257
+ link.addEventListener('click', function (e) {
258
+ var href = link.getAttribute('href') || '';
259
+ if (!href.startsWith('#')) return;
260
+ var target = document.getElementById(href.slice(1));
261
+ if (!target) return;
262
+ e.preventDefault();
263
+ var top = target.getBoundingClientRect().top + window.pageYOffset - 130;
264
+ window.scrollTo({ top: top, behavior: 'smooth' });
265
+ history.pushState(null, '', href);
266
+ });
267
+ });
268
+ }
269
+
270
+ // -------- Scroll to top button -----------------------------------------
271
+
272
+ function wireScrollToTop() {
273
+ var btn = $('.summer-totop');
274
+ if (!btn) return;
275
+ var onScroll = debounce(function () {
276
+ var y = window.pageYOffset || document.documentElement.scrollTop;
277
+ btn.classList.toggle('is-visible', y > 480);
278
+ }, 50);
279
+ window.addEventListener('scroll', onScroll, { passive: true });
280
+ onScroll();
281
+ btn.addEventListener('click', function () {
282
+ window.scrollTo({ top: 0, behavior: 'smooth' });
283
+ });
284
+ }
285
+
286
+ // -------- Inline Header Search & Dropdown ----------------------------
287
+
288
+ function wireHeaderSearch() {
289
+ var headerInput = $('.summer-search-input');
290
+ if (!headerInput) return;
291
+
292
+ var dropdown = $('.summer-search-dropdown');
293
+ var resultsWrapper = $('.summer-search-results-wrapper');
294
+
295
+ var indexInitialized = false;
296
+ function initSearchIndex() {
297
+ if (indexInitialized) return;
298
+ indexInitialized = true;
299
+ // Use programmatic click WITHOUT triggering focus shifts that would
300
+ // scroll the page. We also restore our scroll position afterwards.
301
+ var scrollY = window.pageYOffset;
302
+ var trigger = $('.docmd-search-trigger, [data-docmd-search-trigger]');
303
+ if (trigger) {
304
+ trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
305
+ } else {
306
+ var dummy = document.createElement('div');
307
+ dummy.className = 'docmd-search-trigger';
308
+ dummy.style.position = 'fixed';
309
+ dummy.style.top = '0';
310
+ dummy.style.left = '0';
311
+ dummy.style.width = '1px';
312
+ dummy.style.height = '1px';
313
+ dummy.style.opacity = '0';
314
+ dummy.style.pointerEvents = 'none';
315
+ document.body.appendChild(dummy);
316
+ dummy.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
317
+ // Don't remove — the plugin's listener may capture bubbles asynchronously
318
+ }
319
+ // Restore scroll & refocus our header input (NOT the plugin's input)
320
+ requestAnimationFrame(function () {
321
+ window.scrollTo(0, scrollY);
322
+ headerInput.focus({ preventScroll: true });
323
+ });
324
+ }
325
+
326
+ function tryInitPluginSearch() {
327
+ var searchModal = $('#docmd-search-modal');
328
+ var pluginInput = $('#docmd-search-input');
329
+ var pluginResults = $('#docmd-search-results');
330
+
331
+ if (!searchModal || !pluginInput || !pluginResults) {
332
+ setTimeout(tryInitPluginSearch, 100);
333
+ return;
334
+ }
335
+
336
+ if (resultsWrapper && pluginResults.parentNode !== resultsWrapper) {
337
+ resultsWrapper.appendChild(pluginResults);
338
+ }
339
+
340
+ // Force the modal off-screen (CSS already does this, but also set inline
341
+ // as a belt-and-braces measure so it can never auto-scroll into view).
342
+ searchModal.style.setProperty('position', 'fixed', 'important');
343
+ searchModal.style.setProperty('top', '-9999px', 'important');
344
+ searchModal.style.setProperty('left', '-9999px', 'important');
345
+ searchModal.style.setProperty('display', 'none', 'important');
346
+ searchModal.style.setProperty('opacity', '0', 'important');
347
+ searchModal.style.setProperty('visibility', 'hidden', 'important');
348
+ searchModal.style.setProperty('pointer-events', 'none', 'important');
349
+
350
+ // Make the plugin input also off-screen so it cannot grab focus
351
+ pluginInput.tabIndex = -1;
352
+ pluginInput.setAttribute('aria-hidden', 'true');
353
+ }
354
+
355
+ tryInitPluginSearch();
356
+
357
+ headerInput.addEventListener('focus', function () {
358
+ initSearchIndex();
359
+ dropdown.style.display = 'block';
360
+ });
361
+
362
+ headerInput.addEventListener('input', function () {
363
+ initSearchIndex();
364
+ var pluginInput = $('#docmd-search-input');
365
+ if (pluginInput) {
366
+ pluginInput.value = headerInput.value;
367
+ pluginInput.dispatchEvent(new Event('input', { bubbles: true }));
368
+ }
369
+ dropdown.style.display = 'block';
370
+ });
371
+
372
+ headerInput.addEventListener('keydown', function (e) {
373
+ var pluginInput = $('#docmd-search-input');
374
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
375
+ if (pluginInput) {
376
+ var clone = new KeyboardEvent('keydown', {
377
+ key: e.key,
378
+ code: e.code,
379
+ keyCode: e.keyCode,
380
+ bubbles: true,
381
+ cancelable: true
382
+ });
383
+ pluginInput.dispatchEvent(clone);
384
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
385
+ e.preventDefault();
386
+ }
387
+ }
388
+ } else if (e.key === 'Escape') {
389
+ headerInput.value = '';
390
+ if (pluginInput) {
391
+ pluginInput.value = '';
392
+ pluginInput.dispatchEvent(new Event('input', { bubbles: true }));
393
+ }
394
+ headerInput.blur();
395
+ dropdown.style.display = 'none';
396
+ }
397
+ });
398
+
399
+ document.addEventListener('click', function (e) {
400
+ if (!e.target.closest('.summer-search-container')) {
401
+ dropdown.style.display = 'none';
402
+ }
403
+ });
404
+
405
+ document.addEventListener('keydown', function (e) {
406
+ var isK = e.key === 'k' || e.key === 'K';
407
+ if (isK && (e.metaKey || e.ctrlKey)) {
408
+ e.preventDefault();
409
+ headerInput.focus();
410
+ }
411
+ if (e.key === '/' && !/^(input|textarea|select)$/i.test(e.target.tagName) && !e.target.isContentEditable) {
412
+ e.preventDefault();
413
+ headerInput.focus();
414
+ }
415
+ });
416
+ }
417
+
418
+ // -------- Git last-updated popover toggle (keyboard) ---------------
419
+ // Hover is handled in CSS; this adds click + keyboard support so the
420
+ // popover is reachable without a mouse.
421
+
422
+ function wireGitPopover() {
423
+ $$('.summer-pagefooter__time.has-commits').forEach(function (el) {
424
+ el.addEventListener('click', function (e) {
425
+ e.preventDefault();
426
+ var open = el.classList.toggle('open');
427
+ el.setAttribute('aria-expanded', open ? 'true' : 'false');
428
+ });
429
+ el.addEventListener('keydown', function (e) {
430
+ if (e.key === 'Enter' || e.key === ' ') {
431
+ e.preventDefault();
432
+ el.click();
433
+ } else if (e.key === 'Escape') {
434
+ el.classList.remove('open');
435
+ el.setAttribute('aria-expanded', 'false');
436
+ el.blur();
437
+ }
438
+ });
439
+ });
440
+ document.addEventListener('click', function (e) {
441
+ if (!e.target.closest('.summer-pagefooter__time.has-commits')) {
442
+ $$('.summer-pagefooter__time.has-commits.open').forEach(function (el) {
443
+ el.classList.remove('open');
444
+ el.setAttribute('aria-expanded', 'false');
445
+ });
446
+ }
447
+ });
448
+ }
449
+
450
+ // -------- Relative date rendering (lightweight) --------------------
451
+ // Renders any [data-timestamp] as a human-readable relative date.
452
+ // We avoid pulling in a date library for this tiny feature.
453
+
454
+ function formatRelative(ts) {
455
+ var now = Date.now();
456
+ var diff = Math.max(0, now - ts);
457
+ var sec = Math.floor(diff / 1000);
458
+ if (sec < 45) return 'just now';
459
+ var min = Math.floor(sec / 60);
460
+ if (min < 60) return min + ' min ago';
461
+ var hr = Math.floor(min / 60);
462
+ if (hr < 24) return hr + ' hr ago';
463
+ var day = Math.floor(hr / 24);
464
+ if (day < 7) return day + ' day' + (day === 1 ? '' : 's') + ' ago';
465
+ if (day < 30) {
466
+ var wk = Math.floor(day / 7);
467
+ return wk + ' week' + (wk === 1 ? '' : 's') + ' ago';
468
+ }
469
+ if (day < 365) {
470
+ var mo = Math.floor(day / 30);
471
+ return mo + ' month' + (mo === 1 ? '' : 's') + ' ago';
472
+ }
473
+ var yr = Math.floor(day / 365);
474
+ return yr + ' year' + (yr === 1 ? '' : 's') + ' ago';
475
+ }
476
+
477
+ function renderRelativeTimestamps() {
478
+ $$('[data-timestamp]').forEach(function (el) {
479
+ var raw = el.getAttribute('data-timestamp');
480
+ if (!raw) return;
481
+ var ts = parseInt(raw, 10);
482
+ if (!isFinite(ts) || ts <= 0) return;
483
+ // Use a child span if one exists (git popover meta), else replace
484
+ var target = el.querySelector('.git-time, .summer-git-popover__date') || el;
485
+ if (target !== el && target.children.length > 0) return;
486
+ if (!target.__renderedAt || (Date.now() - target.__renderedAt) > 60000) {
487
+ target.textContent = formatRelative(ts);
488
+ target.__renderedAt = Date.now();
489
+ }
490
+ });
491
+ }
492
+
493
+ // -------- Codeblocks -------------------------------------------------
494
+ // docmd-main.js wraps every <pre> in <div class="code-wrapper"> and
495
+ // appends a <button class="copy-code-button">. The parser also wraps
496
+ // ```lang "title"``` in <div class="docmd-code-block-wrapper"> with a
497
+ // header that holds the title. We turn the bottom-floating copy button
498
+ // into a thin title bar at the top of the codeblock.
499
+ //
500
+ // Left side:
501
+ // - icon (always)
502
+ // - filename (only when the source had a title, e.g. ```js "file.js"```)
503
+ // - lang pill (always — shows the language, or "codeblock" if none)
504
+ // Right side:
505
+ // - copy button
506
+ function summerCodeblocks() {
507
+ // 1. Codeblocks WITH a parser-rendered title wrapper.
508
+ // The header already exists with a <span class="docmd-code-block-title">.
509
+ // We add a lang pill next to it (or replace the header content
510
+ // with our own titlebar) and re-home the copy button.
511
+ $$('.docmd-code-block-wrapper').forEach(function (wrap) {
512
+ if (wrap.dataset.summerCbWired === '1') return;
513
+ wrap.dataset.summerCbWired = '1';
514
+
515
+ var header = wrap.querySelector('.docmd-code-block-header');
516
+ var inner = wrap.querySelector('.code-wrapper');
517
+ var copyBtn = inner && inner.querySelector('.copy-code-button');
518
+ if (!header) return;
519
+
520
+ // Read language from the <code class="language-xxx">
521
+ var lang = '';
522
+ var pre = inner ? inner.querySelector('pre') : null;
523
+ var code = pre ? pre.querySelector('code') : null;
524
+ if (code) {
525
+ var m = code.className.match(/language-([\w-]+)/);
526
+ if (m) lang = m[1];
527
+ }
528
+
529
+ // Clear header and rebuild as our titlebar.
530
+ // docmd-code-block-title already holds the filename (if set).
531
+ var titleEl = header.querySelector('.docmd-code-block-title');
532
+ var filename = titleEl ? titleEl.textContent : '';
533
+
534
+ // Strip everything in the header and rebuild
535
+ while (header.firstChild) header.removeChild(header.firstChild);
536
+ header.classList.add('summer-cb__titlebar');
537
+
538
+ // LEFT: icon + filename (if any) + lang pill
539
+ var left = document.createElement('div');
540
+ left.className = 'summer-cb__left';
541
+ left.appendChild(makeFileIcon());
542
+ if (filename) {
543
+ var fname = document.createElement('span');
544
+ fname.className = 'summer-cb__filename';
545
+ fname.textContent = filename;
546
+ left.appendChild(fname);
547
+ }
548
+ var pill = document.createElement('span');
549
+ pill.className = 'summer-cb__lang';
550
+ pill.textContent = lang || 'codeblock';
551
+ left.appendChild(pill);
552
+ header.appendChild(left);
553
+
554
+ // RIGHT: copy button (re-home from inner wrapper)
555
+ if (copyBtn) {
556
+ copyBtn.classList.add('summer-cb__copy');
557
+ header.appendChild(copyBtn);
558
+ }
559
+ });
560
+
561
+ // 2. Codeblocks WITHOUT a parser title — build our own titlebar.
562
+ $$('.code-wrapper').forEach(function (wrap) {
563
+ if (wrap.dataset.summerCbWired === '1') return;
564
+ // Skip ones already inside a parser wrapper (handled above).
565
+ if (wrap.closest('.docmd-code-block-wrapper')) {
566
+ wrap.dataset.summerCbWired = '1';
567
+ return;
568
+ }
569
+ wrap.dataset.summerCbWired = '1';
570
+
571
+ var pre = wrap.querySelector('pre');
572
+ var copyBtn = wrap.querySelector('.copy-code-button');
573
+ if (!pre) return;
574
+
575
+ // Read language from <code class="language-xxx">
576
+ var lang = '';
577
+ var code = pre.querySelector('code');
578
+ if (code) {
579
+ var m = code.className.match(/language-([\w-]+)/);
580
+ if (m) lang = m[1];
581
+ }
582
+
583
+ // Build header
584
+ var header = document.createElement('div');
585
+ header.className = 'summer-cb__titlebar';
586
+
587
+ // LEFT: icon + lang pill (filename omitted — no source title).
588
+ var left = document.createElement('div');
589
+ left.className = 'summer-cb__left';
590
+ left.appendChild(makeFileIcon());
591
+ var pill = document.createElement('span');
592
+ pill.className = 'summer-cb__lang';
593
+ pill.textContent = lang || 'codeblock';
594
+ left.appendChild(pill);
595
+ header.appendChild(left);
596
+
597
+ // RIGHT: copy button (re-home from wrapper bottom)
598
+ if (copyBtn) {
599
+ copyBtn.classList.add('summer-cb__copy');
600
+ wrap.removeChild(copyBtn);
601
+ header.appendChild(copyBtn);
602
+ }
603
+
604
+ // Insert header at the top of the wrapper
605
+ wrap.insertBefore(header, wrap.firstChild);
606
+ wrap.classList.add('summer-cb');
607
+ });
608
+ }
609
+
610
+ // Build a small file-icon SVG (shared by both codeblock paths above).
611
+ function makeFileIcon() {
612
+ var icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
613
+ icon.setAttribute('viewBox', '0 0 24 24');
614
+ icon.setAttribute('fill', 'none');
615
+ icon.setAttribute('stroke', 'currentColor');
616
+ icon.setAttribute('stroke-width', '2');
617
+ icon.setAttribute('stroke-linecap', 'round');
618
+ icon.setAttribute('stroke-linejoin', 'round');
619
+ icon.innerHTML = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>';
620
+ return icon;
621
+ }
622
+
623
+ // -------- Init --------------------------------------------------------
624
+
625
+ // Re-runnable body of init logic. Idempotent: every wire is guarded
626
+ // with a data-attribute check so calling this twice is safe.
627
+ //
628
+ // Cross-cutting behaviour (theme toggle, sidebar drawer, version /
629
+ // project / language switchers, code-block copy, page copy, banner
630
+ // close, SPA router) is owned by packages/ui/assets/js/docmd-main.js,
631
+ // already loaded by templates/layout.ejs. What stays here is the
632
+ // summer-specific stuff: topbar search dropdown (re-homes the
633
+ // docmd-search.js modal into the topbar), TOC scroll-spy, git commit
634
+ // popover, scroll-to-top button, and relative-date rendering.
635
+ function summerInit() {
636
+ if (document.documentElement.dataset.summerWired !== '1') {
637
+ // First run: bind document-level listeners + topbar/footer wires
638
+ document.documentElement.dataset.summerWired = '1';
639
+ wireScrollToTop();
640
+ wireHeaderSearch();
641
+ }
642
+ // Per-page wires — always re-run after SPA nav (the page content
643
+ // was swapped). The header search is also re-attempted on every
644
+ // page so its polling can find a modal that didn't exist on the
645
+ // first page (e.g. when docmd-search.js loads lazily).
646
+ wireTocScrollSpy();
647
+ wireTocSmoothScroll();
648
+ summerCodeblocks();
649
+ wireGitPopover();
650
+ renderRelativeTimestamps();
651
+ }
652
+
653
+ ready(function () {
654
+ // Mark HTML as ready (reveal the page even if docmd core is slow to set data-theme)
655
+ document.documentElement.classList.add('summer-ready');
656
+ summerInit();
657
+ // Re-wire after SPA navigation (docmd core fires this on the document)
658
+ document.addEventListener('docmd:page-mounted', summerInit);
659
+ });
660
+ })();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * --------------------------------------------------------------------
3
+ * @docmd/template-summer
4
+ * A bright, hopeful, summer-feel layout for docmd 0.8.7+.
5
+ *
6
+ * • Top: logo + (new) centred search bar + menubar at the BOTTOM of the logo bar
7
+ * • Side: clean section list with icons
8
+ * • Main: airy content with right-rail TOC
9
+ * • Below: centred "last updated + edit" footer
10
+ *
11
+ * Implements the `template` plugin capability. Partial override set
12
+ * intentionally small — the resolver will fall back to the default
13
+ * for any slot not listed here.
14
+ *
15
+ * File-system layout: this package ships `.ejs`, `.css`, and `.js` files
16
+ * alongside the compiled JS. Path resolution uses `import.meta.url`
17
+ * (URL-relative) so the same code works in dev (`src/index.ts`) and
18
+ * after `tsc` (`dist/index.js`). The build step copies `templates/` and
19
+ * `assets/` into `dist/` so the URL math is identical in both places.
20
+ * --------------------------------------------------------------------
21
+ */
22
+ import type { PluginDescriptor, PluginModule, TemplateHook, TemplateAssetHook } from '@docmd/api';
23
+ export declare const plugin: PluginDescriptor;
24
+ declare const summerTemplate: PluginModule & {
25
+ templates: TemplateHook[];
26
+ templateAssets: TemplateAssetHook[];
27
+ };
28
+ export default summerTemplate;