@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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/assets/css/summer.css +3418 -0
- package/dist/assets/js/summer.js +660 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +79 -0
- package/dist/templates/404.ejs +156 -0
- package/dist/templates/layout.ejs +609 -0
- package/dist/templates/partials/footer.ejs +69 -0
- package/dist/templates/partials/menubar.ejs +92 -0
- package/dist/templates/partials/options-menu.ejs +45 -0
- package/dist/templates/toc.ejs +53 -0
- package/package.json +61 -0
|
@@ -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
|
+
})();
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|