@kenjura/ursa 0.88.0 → 0.90.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ # 0.90.0
2
+ 2026-08-26
3
+
4
+ Article images get a zoom and a download button on hover, and a full-screen viewer behind the zoom.
5
+
6
+ Images in a built site are downscaled WebP previews — 800px at quality 80 — wrapped in a link to the original. That is right for page weight and wrong for looking at anything: the only way to see the pixels that were actually shot was to click through to a raw image file in a new tab, losing the page, and the only way to keep a copy was the browser's context menu, which on that page would have saved the preview rather than the original.
7
+
8
+ - **Hover controls** on every article image large enough to be a picture rather than an icon (80px in both axes). Zoom opens the viewer; download saves the **original** — not the preview — under its own filename. On touch devices, where there is no hover, they are simply always visible.
9
+ - **The viewer** opens the original at native resolution when it fits the viewport, and contained within it when it does not, over the standard translucent black backdrop. It loads the preview first as a placeholder, so a multi-megabyte original arrives into a full-size blurred image rather than an empty box.
10
+ - **Zoom in/out appear only when there is resolution left to see** — an image already showing every pixel it has gets no zoom controls. Steps of 1.5× between contain-fit and 100%, keeping the centre of the view fixed; drag to pan, double-click to toggle fit and 100%.
11
+ - **Closing**: the backdrop, the letterboxing around the image, the X, or Escape. Tab stays inside the dialog while it is open, and focus returns to where it was on close.
12
+ - `meta/templates/default-template/lightbox.js` and `lightbox.css`, picked up by the existing asset bundler; `data-no-lightbox` on an image or any ancestor opts out.
13
+
14
+ Clicking the image itself is unchanged — it still opens the original in a new tab.
15
+
16
+ # 0.89.0
17
+ 2026-08-20
18
+
19
+ Fixed `generate` dropping every static file that was not an image or an HTML page — fonts, video, audio, PDFs. A site that used any of them worked perfectly in `ursa serve` and shipped broken.
20
+
21
+ `serve` reads static files straight off disk through one list of extensions, while `generate` had two narrower ideas of what to copy: images, and `.html`. Nothing else ever reached the output. Because dev and build disagreed rather than both being wrong, the gap was invisible until deploy — and then invisible again, since a static host that rewrites 404s to `index.html` answers a missing `.mp4` with a page of HTML rather than an error.
22
+
23
+ - **`generate` now copies fonts, audio, video, documents and archives**: `woff`, `woff2`, `ttf`, `eot`, `otf`, `pdf`, `mp3`, `m4a`, `wav`, `flac`, `mp4`, `m4v`, `webm`, `ogv`, `ogg`, `zip`. Images keep their own path, because they also get previews.
24
+ - **One shared list**, in `helper/staticAssets.js`, used by both `generate` and `serve`, so they cannot drift apart again. It is the drift, not either list, that caused this.
25
+ - The static-files progress line now counts HTML and media separately.
26
+
1
27
  # 0.88.0
2
28
  2026-08-19
3
29
 
@@ -6,6 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
7
  <title>${title}</title>
8
8
  <link rel="stylesheet" href="/public/default.css" />
9
+ <link rel="stylesheet" href="/public/lightbox.css" />
9
10
  ${styleLink}
10
11
  </head>
11
12
 
@@ -122,6 +123,7 @@
122
123
  <script src="/public/widgets.js"></script>
123
124
  <script src="/public/sectionify.js"></script>
124
125
  <script src="/public/sticky.js"></script>
126
+ <script src="/public/lightbox.js"></script>
125
127
  ${customScript}
126
128
  </body>
127
129
 
@@ -0,0 +1,248 @@
1
+ /* Image hover controls and the full-screen viewer (see lightbox.js). */
2
+
3
+ /* --- hover controls --- */
4
+
5
+ .ursa-image-frame {
6
+ position: relative;
7
+ display: inline-block;
8
+ max-width: 100%;
9
+ line-height: 0;
10
+ }
11
+
12
+ .ursa-image-controls {
13
+ position: absolute;
14
+ top: 8px;
15
+ right: 8px;
16
+ display: flex;
17
+ gap: 6px;
18
+ opacity: 0;
19
+ transition: opacity 0.15s ease;
20
+ line-height: 0;
21
+ }
22
+
23
+ .ursa-image-frame:hover .ursa-image-controls,
24
+ .ursa-image-frame:focus-within .ursa-image-controls {
25
+ opacity: 1;
26
+ }
27
+
28
+ /* No hover to reveal them on touch devices, so keep them visible but quiet. */
29
+ @media (hover: none) {
30
+ .ursa-image-controls {
31
+ opacity: 0.65;
32
+ }
33
+ }
34
+
35
+ .ursa-image-btn {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ width: 30px;
40
+ height: 30px;
41
+ padding: 0;
42
+ border: none;
43
+ border-radius: 50%;
44
+ background: rgba(0, 0, 0, 0.55);
45
+ color: white;
46
+ cursor: pointer;
47
+ backdrop-filter: blur(2px);
48
+ transition: background-color 0.15s ease;
49
+ }
50
+
51
+ .ursa-image-btn:hover,
52
+ .ursa-image-btn:focus-visible {
53
+ background: rgba(0, 0, 0, 0.8);
54
+ }
55
+
56
+ .ursa-image-btn svg {
57
+ width: 17px;
58
+ height: 17px;
59
+ fill: none;
60
+ stroke: currentColor;
61
+ stroke-width: 2;
62
+ stroke-linecap: round;
63
+ stroke-linejoin: round;
64
+ }
65
+
66
+ /* --- full-screen viewer --- */
67
+
68
+ body.ursa-lightbox-open {
69
+ overflow: hidden;
70
+ }
71
+
72
+ .ursa-lightbox {
73
+ position: fixed;
74
+ inset: 0;
75
+ z-index: 2000;
76
+ display: flex;
77
+ }
78
+
79
+ .ursa-lightbox[hidden] {
80
+ display: none;
81
+ }
82
+
83
+ .ursa-lightbox-backdrop {
84
+ position: absolute;
85
+ inset: 0;
86
+ background: rgba(0, 0, 0, 0.85);
87
+ }
88
+
89
+ .ursa-lightbox-stage {
90
+ position: relative;
91
+ flex: 1;
92
+ display: flex;
93
+ overflow: auto;
94
+ padding: 32px;
95
+ box-sizing: border-box;
96
+ outline: none;
97
+ overscroll-behavior: contain;
98
+ }
99
+
100
+ /* margin:auto centres the image while it fits, and collapses to 0 once it is
101
+ larger than the stage, so the overflow stays scrollable in both directions. */
102
+ .ursa-lightbox-image {
103
+ margin: auto;
104
+ display: block;
105
+ user-select: none;
106
+ -webkit-user-drag: none;
107
+ }
108
+
109
+ .ursa-lightbox-image.is-placeholder {
110
+ filter: blur(1px);
111
+ }
112
+
113
+ .ursa-lightbox-stage.is-pannable {
114
+ cursor: grab;
115
+ }
116
+
117
+ .ursa-lightbox-stage.is-panning {
118
+ cursor: grabbing;
119
+ }
120
+
121
+ /* Centred over the blurred placeholder, clear of the toolbar. */
122
+ .ursa-lightbox-loading {
123
+ position: absolute;
124
+ left: 50%;
125
+ top: 50%;
126
+ transform: translate(-50%, -50%);
127
+ display: flex;
128
+ padding: 10px;
129
+ border-radius: 50%;
130
+ background: rgba(0, 0, 0, 0.55);
131
+ pointer-events: none;
132
+ }
133
+
134
+ .ursa-lightbox-loading[hidden] {
135
+ display: none;
136
+ }
137
+
138
+ .ursa-lightbox-loading .ursa-spinner {
139
+ width: 18px;
140
+ height: 18px;
141
+ border-color: rgba(255, 255, 255, 0.3);
142
+ border-top-color: rgba(255, 255, 255, 0.9);
143
+ }
144
+
145
+ .ursa-lightbox-toolbar {
146
+ position: absolute;
147
+ left: 50%;
148
+ bottom: 20px;
149
+ transform: translateX(-50%);
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 4px;
153
+ padding: 4px;
154
+ border-radius: 999px;
155
+ background: rgba(0, 0, 0, 0.6);
156
+ backdrop-filter: blur(2px);
157
+ }
158
+
159
+ .ursa-lightbox-zoom {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 4px;
163
+ }
164
+
165
+ .ursa-lightbox-zoom[hidden] {
166
+ display: none;
167
+ }
168
+
169
+ .ursa-lightbox-level {
170
+ min-width: 3.5em;
171
+ text-align: center;
172
+ color: white;
173
+ font-family: sans-serif;
174
+ font-size: 0.8rem;
175
+ font-variant-numeric: tabular-nums;
176
+ }
177
+
178
+ .ursa-lightbox-btn {
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ width: 34px;
183
+ height: 34px;
184
+ padding: 0;
185
+ border: none;
186
+ border-radius: 50%;
187
+ background: transparent;
188
+ color: white;
189
+ font-size: 1.2rem;
190
+ font-family: sans-serif;
191
+ line-height: 1;
192
+ cursor: pointer;
193
+ transition: background-color 0.15s ease, opacity 0.15s ease;
194
+ }
195
+
196
+ .ursa-lightbox-btn:hover,
197
+ .ursa-lightbox-btn:focus-visible {
198
+ background: rgba(255, 255, 255, 0.18);
199
+ }
200
+
201
+ .ursa-lightbox-btn:disabled {
202
+ opacity: 0.35;
203
+ cursor: default;
204
+ background: transparent;
205
+ }
206
+
207
+ .ursa-lightbox-btn svg {
208
+ width: 18px;
209
+ height: 18px;
210
+ fill: none;
211
+ stroke: currentColor;
212
+ stroke-width: 2;
213
+ stroke-linecap: round;
214
+ stroke-linejoin: round;
215
+ }
216
+
217
+ .ursa-lightbox-close {
218
+ position: absolute;
219
+ top: 16px;
220
+ right: 16px;
221
+ display: flex;
222
+ align-items: center;
223
+ justify-content: center;
224
+ width: 44px;
225
+ height: 44px;
226
+ padding: 0;
227
+ border: none;
228
+ border-radius: 50%;
229
+ background: rgba(0, 0, 0, 0.6);
230
+ color: white;
231
+ cursor: pointer;
232
+ backdrop-filter: blur(2px);
233
+ transition: background-color 0.15s ease;
234
+ }
235
+
236
+ .ursa-lightbox-close:hover,
237
+ .ursa-lightbox-close:focus-visible {
238
+ background: rgba(255, 255, 255, 0.22);
239
+ }
240
+
241
+ .ursa-lightbox-close svg {
242
+ width: 22px;
243
+ height: 22px;
244
+ fill: none;
245
+ stroke: currentColor;
246
+ stroke-width: 2;
247
+ stroke-linecap: round;
248
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Image lightbox.
3
+ *
4
+ * Adds a zoom and a download button to every article image on hover, and a
5
+ * full-screen viewer behind the zoom button.
6
+ *
7
+ * Article images are served as downscaled WebP previews (see
8
+ * helper/imageProcessor.js), wrapped in <a class="image-link" href="original">.
9
+ * The viewer therefore loads the anchor's href, not the img's own src — the
10
+ * preview is only used as an instant placeholder while the original arrives.
11
+ */
12
+ (() => {
13
+ // Rendered smaller than this in either axis and it is an icon, not a picture.
14
+ const MIN_RENDERED_SIZE = 80;
15
+ const ZOOM_STEP = 1.5;
16
+ const IMAGE_HREF = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(?:[?#]|$)/i;
17
+
18
+ const ICON_ZOOM =
19
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
20
+ '<circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5 L21 21" />' +
21
+ '<path d="M7.5 10.5h6M10.5 7.5v6" /></svg>';
22
+ const ICON_DOWNLOAD =
23
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
24
+ '<path d="M12 3.5v11" /><path d="M7.5 10.5 12 15l4.5-4.5" />' +
25
+ '<path d="M4.5 18.5h15" /></svg>';
26
+ const ICON_CLOSE =
27
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
28
+ '<path d="M5.5 5.5l13 13M18.5 5.5l-13 13" /></svg>';
29
+
30
+ let viewer = null; // built on first use
31
+
32
+ /** Filename to offer the download as, taken from the URL. */
33
+ function fileNameFor(url) {
34
+ try {
35
+ const name = new URL(url, window.location.href).pathname.split('/').pop();
36
+ return decodeURIComponent(name) || 'image';
37
+ } catch {
38
+ return 'image';
39
+ }
40
+ }
41
+
42
+ /** Same file, ignoring cache-busting query strings? */
43
+ function samePath(a, b) {
44
+ try {
45
+ const ua = new URL(a, window.location.href);
46
+ const ub = new URL(b, window.location.href);
47
+ return ua.origin === ub.origin && ua.pathname === ub.pathname;
48
+ } catch {
49
+ return a === b;
50
+ }
51
+ }
52
+
53
+ /** The full-resolution URL for an article image. */
54
+ function fullSizeUrl(img) {
55
+ const link = img.closest('a');
56
+ const href = link && link.getAttribute('href');
57
+ if (link && href && (link.classList.contains('image-link') || IMAGE_HREF.test(href))) {
58
+ return link.href;
59
+ }
60
+ return img.currentSrc || img.src;
61
+ }
62
+
63
+ function runWhenSized(img, fn) {
64
+ if (img.complete && img.naturalWidth) fn();
65
+ else img.addEventListener('load', fn, { once: true });
66
+ }
67
+
68
+ // --- hover controls -------------------------------------------------------
69
+
70
+ function buildControls(img, url) {
71
+ const bar = document.createElement('span');
72
+ bar.className = 'ursa-image-controls';
73
+
74
+ const zoom = document.createElement('button');
75
+ zoom.type = 'button';
76
+ zoom.className = 'ursa-image-btn';
77
+ zoom.title = 'View full size';
78
+ zoom.setAttribute('aria-label', 'View full size');
79
+ zoom.innerHTML = ICON_ZOOM;
80
+ zoom.addEventListener('click', (e) => {
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ open(img, url);
84
+ });
85
+
86
+ const download = document.createElement('a');
87
+ download.className = 'ursa-image-btn';
88
+ download.href = url;
89
+ download.download = fileNameFor(url);
90
+ download.title = 'Download image';
91
+ download.setAttribute('aria-label', 'Download image');
92
+ download.innerHTML = ICON_DOWNLOAD;
93
+ // Stop the click reaching an enclosing <a class="image-link">.
94
+ download.addEventListener('click', (e) => e.stopPropagation());
95
+
96
+ bar.appendChild(zoom);
97
+ bar.appendChild(download);
98
+ return bar;
99
+ }
100
+
101
+ function decorate(img) {
102
+ if (img.dataset.ursaLightbox) return;
103
+ if (img.closest('[data-no-lightbox]')) return;
104
+
105
+ const url = fullSizeUrl(img);
106
+ if (!url) return;
107
+
108
+ runWhenSized(img, () => {
109
+ const rect = img.getBoundingClientRect();
110
+ // Fall back to the intrinsic size for images that are not laid out yet.
111
+ const w = rect.width || img.naturalWidth;
112
+ const h = rect.height || img.naturalHeight;
113
+ if (Math.min(w, h) < MIN_RENDERED_SIZE) return;
114
+ if (img.dataset.ursaLightbox) return;
115
+ img.dataset.ursaLightbox = 'on';
116
+
117
+ // Wrap the anchor rather than the image when there is one, so the
118
+ // controls sit outside it and their clicks are not swallowed by the link.
119
+ const link = img.closest('a.image-link');
120
+ const wrapped = link && link.parentNode ? link : img;
121
+ const frame = document.createElement('span');
122
+ frame.className = 'ursa-image-frame';
123
+ wrapped.parentNode.insertBefore(frame, wrapped);
124
+ frame.appendChild(wrapped);
125
+ frame.appendChild(buildControls(img, url));
126
+ });
127
+ }
128
+
129
+ // --- viewer ---------------------------------------------------------------
130
+
131
+ function ensureViewer() {
132
+ if (viewer) return viewer;
133
+
134
+ const el = document.createElement('div');
135
+ el.className = 'ursa-lightbox';
136
+ el.setAttribute('role', 'dialog');
137
+ el.setAttribute('aria-modal', 'true');
138
+ el.setAttribute('aria-label', 'Image viewer');
139
+ el.hidden = true;
140
+ el.innerHTML =
141
+ '<div class="ursa-lightbox-backdrop"></div>' +
142
+ '<div class="ursa-lightbox-stage" tabindex="-1">' +
143
+ '<img class="ursa-lightbox-image" alt="">' +
144
+ '</div>' +
145
+ '<div class="ursa-lightbox-loading" hidden><span class="ursa-spinner"></span></div>' +
146
+ '<div class="ursa-lightbox-toolbar">' +
147
+ '<span class="ursa-lightbox-zoom" hidden>' +
148
+ '<button type="button" class="ursa-lightbox-btn" data-zoom="out" title="Zoom out" aria-label="Zoom out">&minus;</button>' +
149
+ '<span class="ursa-lightbox-level">100%</span>' +
150
+ '<button type="button" class="ursa-lightbox-btn" data-zoom="in" title="Zoom in" aria-label="Zoom in">+</button>' +
151
+ '</span>' +
152
+ '<a class="ursa-lightbox-btn ursa-lightbox-download" download title="Download image" aria-label="Download image">' + ICON_DOWNLOAD + '</a>' +
153
+ '</div>' +
154
+ '<button type="button" class="ursa-lightbox-close" title="Close (Esc)" aria-label="Close">' + ICON_CLOSE + '</button>';
155
+ document.body.appendChild(el);
156
+
157
+ viewer = {
158
+ el,
159
+ stage: el.querySelector('.ursa-lightbox-stage'),
160
+ img: el.querySelector('.ursa-lightbox-image'),
161
+ loading: el.querySelector('.ursa-lightbox-loading'),
162
+ zoomGroup: el.querySelector('.ursa-lightbox-zoom'),
163
+ level: el.querySelector('.ursa-lightbox-level'),
164
+ zoomIn: el.querySelector('[data-zoom="in"]'),
165
+ zoomOut: el.querySelector('[data-zoom="out"]'),
166
+ download: el.querySelector('.ursa-lightbox-download'),
167
+ natural: { w: 0, h: 0 },
168
+ scale: 1,
169
+ fitScale: 1,
170
+ ready: false,
171
+ zoomable: false,
172
+ atFit: true,
173
+ lastFocus: null,
174
+ token: 0,
175
+ dragged: false,
176
+ };
177
+
178
+ el.querySelector('.ursa-lightbox-backdrop').addEventListener('click', close);
179
+ el.querySelector('.ursa-lightbox-close').addEventListener('click', close);
180
+ // Letterbox area around the image closes too; a drag that ends there does not.
181
+ viewer.stage.addEventListener('click', (e) => {
182
+ if (e.target === viewer.stage && !viewer.dragged) close();
183
+ });
184
+ viewer.zoomIn.addEventListener('click', () => zoomBy(ZOOM_STEP));
185
+ viewer.zoomOut.addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
186
+ viewer.img.addEventListener('dblclick', () => {
187
+ if (!viewer.zoomable) return;
188
+ zoomTo(viewer.atFit ? 1 : viewer.fitScale);
189
+ });
190
+
191
+ enablePanning(viewer.stage);
192
+ window.addEventListener('resize', () => {
193
+ if (!el.hidden && viewer.ready) relayout();
194
+ });
195
+
196
+ return viewer;
197
+ }
198
+
199
+ /** Drag anywhere on the stage to pan a zoomed-in image. */
200
+ function enablePanning(stage) {
201
+ let panning = false;
202
+ let start = null;
203
+
204
+ stage.addEventListener('pointerdown', (e) => {
205
+ if (e.button !== 0) return;
206
+ // Cleared on every press, not just pannable ones: a stale flag from an
207
+ // earlier pan would otherwise swallow the next click-to-close.
208
+ viewer.dragged = false;
209
+ if (stage.scrollWidth <= stage.clientWidth && stage.scrollHeight <= stage.clientHeight) return;
210
+ panning = true;
211
+ start = { x: e.clientX, y: e.clientY, left: stage.scrollLeft, top: stage.scrollTop };
212
+ stage.setPointerCapture(e.pointerId);
213
+ stage.classList.add('is-panning');
214
+ });
215
+
216
+ stage.addEventListener('pointermove', (e) => {
217
+ if (!panning) return;
218
+ const dx = e.clientX - start.x;
219
+ const dy = e.clientY - start.y;
220
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) viewer.dragged = true;
221
+ stage.scrollLeft = start.left - dx;
222
+ stage.scrollTop = start.top - dy;
223
+ });
224
+
225
+ const end = (e) => {
226
+ if (!panning) return;
227
+ panning = false;
228
+ stage.classList.remove('is-panning');
229
+ try { stage.releasePointerCapture(e.pointerId); } catch {}
230
+ };
231
+ stage.addEventListener('pointerup', end);
232
+ stage.addEventListener('pointercancel', end);
233
+ }
234
+
235
+ /**
236
+ * Run a layout pass now and again on the next frame. A dialog that has just
237
+ * been unhidden can still report a flex-unresolved stage width in the same
238
+ * task, which would size the image to nothing.
239
+ */
240
+ function layoutTwice(fn) {
241
+ fn();
242
+ requestAnimationFrame(fn);
243
+ }
244
+
245
+ /** Space available for the image, inside the stage's padding. */
246
+ function stageSize() {
247
+ const style = getComputedStyle(viewer.stage);
248
+ const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
249
+ const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
250
+ return {
251
+ w: Math.max(1, viewer.stage.clientWidth - padX),
252
+ h: Math.max(1, viewer.stage.clientHeight - padY),
253
+ };
254
+ }
255
+
256
+ function containScale(w, h) {
257
+ const stage = stageSize();
258
+ return Math.min(stage.w / w, stage.h / h);
259
+ }
260
+
261
+ function applyScale(scale) {
262
+ viewer.scale = scale;
263
+ viewer.img.style.width = Math.round(viewer.natural.w * scale) + 'px';
264
+ viewer.img.style.height = Math.round(viewer.natural.h * scale) + 'px';
265
+ viewer.atFit = Math.abs(scale - viewer.fitScale) < 0.001;
266
+ updateZoomControls();
267
+ }
268
+
269
+ function updateZoomControls() {
270
+ viewer.zoomGroup.hidden = !viewer.zoomable;
271
+ viewer.level.textContent = Math.round(viewer.scale * 100) + '%';
272
+ viewer.zoomIn.disabled = viewer.scale >= 1 - 0.001;
273
+ viewer.zoomOut.disabled = viewer.scale <= viewer.fitScale + 0.001;
274
+ viewer.stage.classList.toggle('is-pannable', viewer.scale > viewer.fitScale + 0.001);
275
+ }
276
+
277
+ /** Zoom, keeping whatever is at the centre of the stage at the centre. */
278
+ function zoomTo(next) {
279
+ const stage = viewer.stage;
280
+ const clamped = Math.min(1, Math.max(viewer.fitScale, next));
281
+ if (Math.abs(clamped - viewer.scale) < 0.001) return;
282
+
283
+ const before = viewer.img.getBoundingClientRect();
284
+ const stageRect = stage.getBoundingClientRect();
285
+ const centreX = stageRect.left + stageRect.width / 2;
286
+ const centreY = stageRect.top + stageRect.height / 2;
287
+ const pointX = (centreX - before.left) / viewer.scale;
288
+ const pointY = (centreY - before.top) / viewer.scale;
289
+
290
+ applyScale(clamped);
291
+
292
+ const after = viewer.img.getBoundingClientRect();
293
+ stage.scrollLeft += after.left + pointX * clamped - centreX;
294
+ stage.scrollTop += after.top + pointY * clamped - centreY;
295
+ }
296
+
297
+ function zoomBy(factor) {
298
+ if (!viewer.ready || !viewer.zoomable) return;
299
+ zoomTo(viewer.scale * factor);
300
+ }
301
+
302
+ /** Fit the loaded original: native resolution if it fits, contained if not. */
303
+ function relayout() {
304
+ const wasAtFit = viewer.atFit;
305
+ viewer.fitScale = containScale(viewer.natural.w, viewer.natural.h);
306
+ // Zoom controls are only meaningful while there is unseen resolution left.
307
+ viewer.zoomable = viewer.fitScale < 1 - 0.001;
308
+ const target = wasAtFit
309
+ ? Math.min(1, viewer.fitScale)
310
+ : Math.min(1, Math.max(viewer.fitScale, viewer.scale));
311
+ applyScale(target);
312
+ viewer.atFit = Math.abs(target - Math.min(1, viewer.fitScale)) < 0.001;
313
+ }
314
+
315
+ function open(img, url) {
316
+ const v = ensureViewer();
317
+ const token = ++v.token;
318
+
319
+ v.lastFocus = document.activeElement;
320
+ v.img.alt = img.alt || '';
321
+ v.download.href = url;
322
+ v.download.download = fileNameFor(url);
323
+ v.ready = false;
324
+ v.zoomable = false;
325
+ v.zoomGroup.hidden = true;
326
+ v.stage.scrollTop = 0;
327
+ v.stage.scrollLeft = 0;
328
+
329
+ // Reveal first: the stage cannot be measured while the dialog is hidden.
330
+ v.img.classList.add('is-placeholder');
331
+ v.img.removeAttribute('style');
332
+ v.loading.hidden = false;
333
+ v.el.hidden = false;
334
+ document.body.classList.add('ursa-lightbox-open');
335
+ document.addEventListener('keydown', onKeydown);
336
+ v.stage.focus({ preventScroll: true });
337
+
338
+ // Show the already-loaded preview stretched to fill the stage, so there is
339
+ // something on screen while the (potentially large) original downloads.
340
+ const placeholder = img.currentSrc || img.src;
341
+ if (placeholder && !samePath(placeholder, url) && img.naturalWidth) {
342
+ v.img.src = placeholder;
343
+ v.natural = { w: img.naturalWidth, h: img.naturalHeight };
344
+ layoutTwice(() => {
345
+ if (token !== v.token || v.ready) return; // superseded by the original
346
+ v.fitScale = containScale(v.natural.w, v.natural.h);
347
+ applyScale(v.fitScale);
348
+ });
349
+ }
350
+
351
+ const full = new Image();
352
+ full.onload = () => {
353
+ if (token !== v.token) return; // a later image won the race
354
+ v.img.classList.remove('is-placeholder');
355
+ v.loading.hidden = true;
356
+ v.img.src = url;
357
+ v.natural = { w: full.naturalWidth, h: full.naturalHeight };
358
+ v.ready = true;
359
+ v.atFit = true;
360
+ layoutTwice(() => {
361
+ if (token !== v.token) return;
362
+ relayout();
363
+ });
364
+ };
365
+ full.onerror = () => {
366
+ if (token !== v.token) return;
367
+ v.loading.hidden = true;
368
+ };
369
+ full.src = url;
370
+ }
371
+
372
+ function close() {
373
+ if (!viewer || viewer.el.hidden) return;
374
+ viewer.token++; // abandon any in-flight load
375
+ viewer.el.hidden = true;
376
+ viewer.img.removeAttribute('src');
377
+ viewer.loading.hidden = true;
378
+ document.body.classList.remove('ursa-lightbox-open');
379
+ document.removeEventListener('keydown', onKeydown);
380
+ if (viewer.lastFocus && viewer.lastFocus.focus) viewer.lastFocus.focus();
381
+ }
382
+
383
+ function onKeydown(e) {
384
+ if (e.key === 'Escape') {
385
+ e.preventDefault();
386
+ close();
387
+ } else if (e.key === '+' || e.key === '=') {
388
+ zoomBy(ZOOM_STEP);
389
+ } else if (e.key === '-' || e.key === '_') {
390
+ zoomBy(1 / ZOOM_STEP);
391
+ } else if (e.key === 'Tab') {
392
+ trapFocus(e);
393
+ }
394
+ }
395
+
396
+ /** Keep Tab inside the dialog while it is open. */
397
+ function trapFocus(e) {
398
+ const focusable = Array.from(viewer.el.querySelectorAll('button, a[href]'))
399
+ .filter((el) => !el.disabled && el.getClientRects().length > 0);
400
+ if (focusable.length === 0) return;
401
+ const first = focusable[0];
402
+ const last = focusable[focusable.length - 1];
403
+ const active = document.activeElement;
404
+ // Anywhere but a control of the dialog — the stage, or the page behind it.
405
+ if (focusable.indexOf(active) === -1) {
406
+ e.preventDefault();
407
+ (e.shiftKey ? last : first).focus();
408
+ } else if (e.shiftKey && active === first) {
409
+ e.preventDefault();
410
+ last.focus();
411
+ } else if (!e.shiftKey && active === last) {
412
+ e.preventDefault();
413
+ first.focus();
414
+ }
415
+ }
416
+
417
+ // --- init -----------------------------------------------------------------
418
+
419
+ function init() {
420
+ const article = document.querySelector('article#main-content');
421
+ if (!article) return;
422
+ article.querySelectorAll('img').forEach(decorate);
423
+ // Build the dialog up front so the first open measures a laid-out stage.
424
+ ensureViewer();
425
+ }
426
+
427
+ if (document.readyState === 'loading') {
428
+ document.addEventListener('DOMContentLoaded', init);
429
+ } else {
430
+ init();
431
+ }
432
+ })();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.88.0",
5
+ "version": "0.90.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -0,0 +1,47 @@
1
+ import {
2
+ isImage,
3
+ isMedia,
4
+ isStaticAsset,
5
+ } from "../staticAssets.js";
6
+
7
+ describe("staticAssets", () => {
8
+ it("treats the image formats as images", () => {
9
+ for (const f of ["a.jpg", "a.jpeg", "a.PNG", "a.gif", "a.webp", "a.svg", "a.ico"]) {
10
+ expect(isImage(f)).toBe(true);
11
+ expect(isMedia(f)).toBe(false);
12
+ }
13
+ });
14
+
15
+ // The regression this module exists for: generate() copied images and HTML
16
+ // and nothing else, so every one of these 404'd in a built site.
17
+ it("treats fonts, audio, video and documents as media", () => {
18
+ for (const f of ["a.woff", "a.woff2", "a.ttf", "a.eot", "a.otf", "a.pdf",
19
+ "a.mp3", "a.m4a", "a.wav", "a.flac", "a.mp4", "a.m4v", "a.webm", "a.ogv", "a.ogg", "a.zip"]) {
20
+ expect(isMedia(f)).toBe(true);
21
+ expect(isImage(f)).toBe(false);
22
+ }
23
+ });
24
+
25
+ it("counts both as static assets", () => {
26
+ expect(isStaticAsset("clip.mp4")).toBe(true);
27
+ expect(isStaticAsset("Herculanum Regular.ttf")).toBe(true);
28
+ expect(isStaticAsset("photo.jpg")).toBe(true);
29
+ });
30
+
31
+ it("leaves the processed formats alone", () => {
32
+ for (const f of ["page.md", "page.mdx", "page.html", "style.css", "script.js", "data.json"]) {
33
+ expect(isStaticAsset(f)).toBe(false);
34
+ }
35
+ });
36
+
37
+ it("matches on the extension, not on a name that merely contains one", () => {
38
+ expect(isStaticAsset("mp4")).toBe(false);
39
+ expect(isStaticAsset("notes-about-mp4-encoding.md")).toBe(false);
40
+ expect(isStaticAsset("my.mp4.md")).toBe(false);
41
+ });
42
+
43
+ it("is case insensitive, as filesystems are not", () => {
44
+ expect(isStaticAsset("CLIP.MP4")).toBe(true);
45
+ expect(isStaticAsset("Font.TTF")).toBe(true);
46
+ });
47
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What counts as a static asset.
3
+ *
4
+ * This list used to be written out separately in serve.js and in
5
+ * dependencyTracker.js and — fatally — not at all in generate.js, which knew
6
+ * only about *image* extensions. `ursa serve` therefore served fonts, audio and
7
+ * video quite happily while `ursa generate` left them out of the build
8
+ * entirely, so they worked all the way through development and 404'd in
9
+ * production. One list, in one place, used by both.
10
+ */
11
+
12
+ /** Extensions that get preview generation and image transformation. */
13
+ export const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
14
+
15
+ /**
16
+ * Everything else copied through untouched: fonts, documents, audio, video.
17
+ * Deliberately not images, which take a different path, and deliberately not
18
+ * .css, .js, .html or the document formats, all of which are processed rather
19
+ * than copied.
20
+ */
21
+ export const MEDIA_EXTENSIONS = /\.(woff2?|ttf|eot|otf|pdf|mp3|m4a|wav|flac|mp4|m4v|webm|ogv|ogg|zip)$/i;
22
+
23
+ /** Any file the build should place in the output as-is. */
24
+ export const STATIC_ASSET_EXTENSIONS = new RegExp(
25
+ `(?:${IMAGE_EXTENSIONS.source})|(?:${MEDIA_EXTENSIONS.source})`,
26
+ 'i'
27
+ );
28
+
29
+ export const isImage = (filename) => IMAGE_EXTENSIONS.test(filename);
30
+ export const isMedia = (filename) => MEDIA_EXTENSIONS.test(filename);
31
+ export const isStaticAsset = (filename) => STATIC_ASSET_EXTENSIONS.test(filename);
@@ -5,6 +5,7 @@ import { filterAsync } from "../helper/filterAsync.js";
5
5
  import { isDirectory } from "../helper/isDirectory.js";
6
6
  import { isFolderHidden, clearConfigCache } from "../helper/folderConfig.js";
7
7
  import { isHiddenOrSystemPath } from "../helper/hiddenPaths.js";
8
+ import { IMAGE_EXTENSIONS, isMedia } from "../helper/staticAssets.js";
8
9
  import {
9
10
  extractMetadata,
10
11
  extractRawMetadata,
@@ -449,7 +450,7 @@ export async function generate({
449
450
  const copiedCssFiles = new Set();
450
451
 
451
452
  // Identify all image files from the filtered source list
452
- const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/;
453
+ const imageExtensions = IMAGE_EXTENSIONS;
453
454
  let allSourceFilenamesThatAreImages = allSourceFilenames.filter(
454
455
  (filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
455
456
  );
@@ -1172,11 +1173,23 @@ export async function generate({
1172
1173
  (filename) => filename.match(/\.html$/) && !isHiddenOrSystem(filename)
1173
1174
  );
1174
1175
 
1175
- const allStaticFiles = allSourceFilenamesThatAreHtml;
1176
+ // Fonts, audio, video, PDFs: copied through untouched. Images are handled
1177
+ // separately above because they also get previews; everything else that is
1178
+ // neither an article nor a stylesheet belongs here. Leaving this out is what
1179
+ // let `ursa serve` and `ursa generate` disagree — serve reads these straight
1180
+ // off disk, so the missing copy step only ever showed up in a built site.
1181
+ const allSourceFilenamesThatAreMedia = allSourceFilenames.filter(
1182
+ (filename) => isMedia(filename) && !isHiddenOrSystem(filename)
1183
+ );
1184
+
1185
+ const allStaticFiles = [...allSourceFilenamesThatAreHtml, ...allSourceFilenamesThatAreMedia];
1176
1186
  const totalStatic = allStaticFiles.length;
1177
1187
  let processedStatic = 0;
1178
1188
  let copiedStatic = 0;
1179
- progress.log(`Processing ${totalStatic} static HTML files...`);
1189
+ progress.log(
1190
+ `Processing ${totalStatic} static files ` +
1191
+ `(${allSourceFilenamesThatAreHtml.length} HTML, ${allSourceFilenamesThatAreMedia.length} media)...`
1192
+ );
1180
1193
  await processBatched(allStaticFiles, async (file) => {
1181
1194
  try {
1182
1195
  processedStatic++;
package/src/serve.js CHANGED
@@ -7,6 +7,7 @@ import fs from "fs";
7
7
  import { promises } from "fs";
8
8
  import { copy as copyDir, outputFile } from "fs-extra";
9
9
  import { processImage } from "./helper/imageProcessor.js";
10
+ import { STATIC_ASSET_EXTENSIONS, IMAGE_EXTENSIONS } from "./helper/staticAssets.js";
10
11
  import { watchModeCache } from "./helper/build/watchCache.js";
11
12
  import { dependencyTracker } from "./helper/dependencyTracker.js";
12
13
  import { bundleMetaTemplateAssets, clearMetaBundleCache } from "./helper/assetBundler.js";
@@ -297,10 +298,9 @@ async function copyCssFile(cssPath, sourceDir, outputDir) {
297
298
  }
298
299
  }
299
300
 
300
- // Static file extensions that should be copied (images, fonts, etc.)
301
- const STATIC_FILE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
302
- // Image extensions that get preview processing
303
- const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
301
+ // Shared with generate so the two cannot drift apart again — that drift is
302
+ // exactly how fonts and video came to work in dev and 404 in production.
303
+ const STATIC_FILE_EXTENSIONS = STATIC_ASSET_EXTENSIONS;
304
304
 
305
305
  /**
306
306
  * Copy a single static file to the output directory