@daz4126/swifty 2.12.0 → 3.1.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/README.md +5 -3
- package/package.json +1 -1
- package/src/assets.js +200 -25
- package/src/build.js +11 -0
- package/src/client/IDIOMORPH-LICENSE.txt +13 -0
- package/src/client/idiomorph.esm.js +1385 -0
- package/src/client/swifty-navigation.js +399 -0
- package/src/config.js +22 -0
- package/src/init.js +11 -1
- package/src/layout.js +20 -10
- package/src/markdown.js +20 -0
- package/src/minify.js +129 -0
- package/src/pages.js +27 -13
- package/src/partials.js +50 -5
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { Idiomorph } from "./idiomorph.esm.js";
|
|
2
|
+
|
|
3
|
+
if (typeof window !== "undefined") window.Idiomorph ||= Idiomorph;
|
|
4
|
+
|
|
5
|
+
class NavigationFallback extends Error {
|
|
6
|
+
constructor(url, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.url = url;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class SwiftyNavigation {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.targetSelector = options.target || "main";
|
|
15
|
+
this.prefetching = options.prefetching !== false;
|
|
16
|
+
this.cacheSize = Number(options.cacheSize || 20);
|
|
17
|
+
this.cacheTTL = Number(options.cacheTTL || 15) * 1000;
|
|
18
|
+
this.cache = new Map();
|
|
19
|
+
this.sequence = 0;
|
|
20
|
+
this.controller = null;
|
|
21
|
+
this.prefetchTimer = null;
|
|
22
|
+
this.boundClick = (event) => this.handleClick(event);
|
|
23
|
+
this.boundPopState = (event) => this.handlePopState(event);
|
|
24
|
+
this.boundPrefetchIntent = (event) => this.handlePrefetchIntent(event);
|
|
25
|
+
this.boundInvalidate = () => this.clearCache();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
start() {
|
|
29
|
+
if (this.started) return;
|
|
30
|
+
this.started = true;
|
|
31
|
+
|
|
32
|
+
history.scrollRestoration = "manual";
|
|
33
|
+
this.rememberScroll();
|
|
34
|
+
document.addEventListener("click", this.boundClick);
|
|
35
|
+
window.addEventListener("popstate", this.boundPopState);
|
|
36
|
+
document.addEventListener("mouseover", this.boundPrefetchIntent);
|
|
37
|
+
document.addEventListener("focusin", this.boundPrefetchIntent);
|
|
38
|
+
document.addEventListener("touchstart", this.boundPrefetchIntent, { passive: true });
|
|
39
|
+
document.addEventListener("swifty:invalidate", this.boundInvalidate);
|
|
40
|
+
requestAnimationFrame(() => {
|
|
41
|
+
this.dispatch("swifty:load", {
|
|
42
|
+
url: window.location.href,
|
|
43
|
+
navigationType: "initial",
|
|
44
|
+
prefetched: false,
|
|
45
|
+
target: document.querySelector(this.targetSelector),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stop() {
|
|
51
|
+
if (!this.started) return;
|
|
52
|
+
|
|
53
|
+
document.removeEventListener("click", this.boundClick);
|
|
54
|
+
window.removeEventListener("popstate", this.boundPopState);
|
|
55
|
+
document.removeEventListener("mouseover", this.boundPrefetchIntent);
|
|
56
|
+
document.removeEventListener("focusin", this.boundPrefetchIntent);
|
|
57
|
+
document.removeEventListener("touchstart", this.boundPrefetchIntent);
|
|
58
|
+
document.removeEventListener("swifty:invalidate", this.boundInvalidate);
|
|
59
|
+
clearTimeout(this.prefetchTimer);
|
|
60
|
+
this.controller?.abort();
|
|
61
|
+
this.started = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleClick(event) {
|
|
65
|
+
const link = this.eligibleLink(event.target);
|
|
66
|
+
if (!link || !this.eligibleClick(event, link)) return;
|
|
67
|
+
|
|
68
|
+
const url = new URL(link.href, window.location.href);
|
|
69
|
+
if (this.onlyChangesHash(url)) return;
|
|
70
|
+
|
|
71
|
+
const detail = { url: url.href, link, navigationType: "link" };
|
|
72
|
+
if (!this.dispatch("swifty:before-navigate", detail, true)) return;
|
|
73
|
+
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
this.rememberScroll();
|
|
76
|
+
this.navigate(url.href, { historyMode: "push", navigationType: "link" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
handlePopState(event) {
|
|
80
|
+
const detail = { url: window.location.href, navigationType: "popstate" };
|
|
81
|
+
if (!this.dispatch("swifty:before-navigate", detail, true)) {
|
|
82
|
+
window.location.reload();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const scroll = event.state?.swifty?.scroll;
|
|
87
|
+
this.navigate(window.location.href, {
|
|
88
|
+
historyMode: "pop",
|
|
89
|
+
navigationType: "popstate",
|
|
90
|
+
scroll,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
handlePrefetchIntent(event) {
|
|
95
|
+
if (!this.prefetchEnabled()) return;
|
|
96
|
+
|
|
97
|
+
const link = this.eligibleLink(event.target);
|
|
98
|
+
if (!link || !this.eligibleURL(new URL(link.href, window.location.href))) return;
|
|
99
|
+
if (link.closest('[data-swifty-prefetch="off"]')) return;
|
|
100
|
+
if (event.type === "mouseover" && link.contains(event.relatedTarget)) return;
|
|
101
|
+
|
|
102
|
+
clearTimeout(this.prefetchTimer);
|
|
103
|
+
const delay = event.type === "mouseover" ? 75 : 0;
|
|
104
|
+
this.prefetchTimer = setTimeout(() => this.prefetch(link.href), delay);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async navigate(url, options = {}) {
|
|
108
|
+
const sequence = ++this.sequence;
|
|
109
|
+
this.controller?.abort();
|
|
110
|
+
this.controller = null;
|
|
111
|
+
this.setLoading(true, { url, navigationType: options.navigationType });
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
let prefetched = false;
|
|
115
|
+
let page = await this.takePrefetch(url);
|
|
116
|
+
if (page?.cacheable) {
|
|
117
|
+
prefetched = true;
|
|
118
|
+
} else {
|
|
119
|
+
this.controller = new AbortController();
|
|
120
|
+
page = await this.fetchPage(url, { signal: this.controller.signal });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (sequence !== this.sequence) return;
|
|
124
|
+
|
|
125
|
+
const beforeMorph = {
|
|
126
|
+
url: page.url,
|
|
127
|
+
navigationType: options.navigationType,
|
|
128
|
+
prefetched,
|
|
129
|
+
newTarget: page.target,
|
|
130
|
+
};
|
|
131
|
+
if (!this.dispatch("swifty:before-morph", beforeMorph, true)) {
|
|
132
|
+
throw new NavigationFallback(page.url, "morph was cancelled");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.morph(page);
|
|
136
|
+
this.updateHistory(page.url, options.historyMode);
|
|
137
|
+
await this.nextFrame();
|
|
138
|
+
if (sequence !== this.sequence) return;
|
|
139
|
+
this.restoreScroll(page.url, options);
|
|
140
|
+
this.manageFocus(page.url, options.navigationType);
|
|
141
|
+
|
|
142
|
+
this.dispatch("swifty:load", {
|
|
143
|
+
url: page.url,
|
|
144
|
+
navigationType: options.navigationType,
|
|
145
|
+
prefetched,
|
|
146
|
+
target: document.querySelector(this.targetSelector),
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.name === "AbortError" || sequence !== this.sequence) return;
|
|
150
|
+
|
|
151
|
+
const fallbackURL = error instanceof NavigationFallback ? error.url : url;
|
|
152
|
+
this.dispatch("swifty:navigation-error", { url: fallbackURL, error });
|
|
153
|
+
this.fullLoad(fallbackURL, options.historyMode === "pop");
|
|
154
|
+
} finally {
|
|
155
|
+
if (sequence === this.sequence) {
|
|
156
|
+
this.setLoading(false, { url, navigationType: options.navigationType });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async fetchPage(url, { prefetch = false, signal } = {}) {
|
|
162
|
+
const response = await fetch(url, {
|
|
163
|
+
method: "GET",
|
|
164
|
+
credentials: "same-origin",
|
|
165
|
+
redirect: "follow",
|
|
166
|
+
signal,
|
|
167
|
+
headers: {
|
|
168
|
+
Accept: "text/html",
|
|
169
|
+
"X-Swifty-Navigation": "true",
|
|
170
|
+
...(prefetch ? { "X-Swifty-Prefetch": "true" } : {}),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const finalURL = response.url || url;
|
|
175
|
+
if (!response.ok) throw new NavigationFallback(finalURL, `HTTP ${response.status}`);
|
|
176
|
+
if (!this.eligibleURL(new URL(finalURL, window.location.href))) {
|
|
177
|
+
throw new NavigationFallback(finalURL, "redirected outside the current origin");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const contentType = response.headers.get("content-type") || "";
|
|
181
|
+
if (!contentType.includes("text/html")) {
|
|
182
|
+
throw new NavigationFallback(finalURL, "response is not HTML");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const source = await response.text();
|
|
186
|
+
const parsed = new DOMParser().parseFromString(source, "text/html");
|
|
187
|
+
const target = parsed.querySelector(this.targetSelector);
|
|
188
|
+
if (!target) throw new NavigationFallback(finalURL, "navigation target is missing");
|
|
189
|
+
|
|
190
|
+
const cacheControl = response.headers.get("cache-control") || "";
|
|
191
|
+
return {
|
|
192
|
+
url: finalURL,
|
|
193
|
+
target,
|
|
194
|
+
title: parsed.title,
|
|
195
|
+
cacheable: !cacheControl.includes("no-store"),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
morph(page) {
|
|
200
|
+
const current = document.querySelector(this.targetSelector);
|
|
201
|
+
if (!current) throw new NavigationFallback(page.url, "current navigation target is missing");
|
|
202
|
+
|
|
203
|
+
Idiomorph.morph(current, page.target, {
|
|
204
|
+
morphStyle: "outerHTML",
|
|
205
|
+
restoreFocus: false,
|
|
206
|
+
callbacks: {
|
|
207
|
+
beforeNodeMorphed: (oldNode) => {
|
|
208
|
+
if (oldNode.nodeType === Node.ELEMENT_NODE && oldNode.hasAttribute("data-swifty-permanent")) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (page.title) document.title = page.title;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async prefetch(url) {
|
|
219
|
+
const key = this.cacheKey(url);
|
|
220
|
+
const existing = this.cache.get(key);
|
|
221
|
+
if (existing && existing.expiresAt > Date.now()) return existing.promise;
|
|
222
|
+
if (existing) this.cache.delete(key);
|
|
223
|
+
|
|
224
|
+
const promise = this.fetchPage(key, { prefetch: true })
|
|
225
|
+
.then((page) => {
|
|
226
|
+
if (!page.cacheable) this.cache.delete(key);
|
|
227
|
+
return page;
|
|
228
|
+
})
|
|
229
|
+
.catch((error) => {
|
|
230
|
+
this.cache.delete(key);
|
|
231
|
+
if (!(error instanceof NavigationFallback)) console.debug("Swifty prefetch failed", error);
|
|
232
|
+
return null;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
this.cache.set(key, { promise, expiresAt: Date.now() + this.cacheTTL });
|
|
236
|
+
this.pruneCache();
|
|
237
|
+
return promise;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async takePrefetch(url) {
|
|
241
|
+
const key = this.cacheKey(url);
|
|
242
|
+
const entry = this.cache.get(key);
|
|
243
|
+
if (!entry) return null;
|
|
244
|
+
|
|
245
|
+
this.cache.delete(key);
|
|
246
|
+
if (entry.expiresAt <= Date.now()) return null;
|
|
247
|
+
return entry.promise;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
pruneCache() {
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
for (const [key, entry] of this.cache) {
|
|
253
|
+
if (entry.expiresAt <= now) this.cache.delete(key);
|
|
254
|
+
}
|
|
255
|
+
while (this.cache.size > this.cacheSize) this.cache.delete(this.cache.keys().next().value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
clearCache() {
|
|
259
|
+
this.cache.clear();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
eligibleClick(event, link) {
|
|
263
|
+
return event.button === 0 &&
|
|
264
|
+
!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey &&
|
|
265
|
+
!event.defaultPrevented &&
|
|
266
|
+
(!link.target || link.target === "_self");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
eligibleLink(node) {
|
|
270
|
+
const link = node instanceof Element ? node.closest("a[href]") : null;
|
|
271
|
+
if (!link || link.hasAttribute("download")) return null;
|
|
272
|
+
if (link.closest('[data-swifty-navigation="off"]')) return null;
|
|
273
|
+
if (link.relList.contains("external")) return null;
|
|
274
|
+
|
|
275
|
+
const url = new URL(link.href, window.location.href);
|
|
276
|
+
return this.eligibleURL(url) ? link : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
eligibleURL(url) {
|
|
280
|
+
return url.origin === window.location.origin && ["http:", "https:"].includes(url.protocol);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
onlyChangesHash(url) {
|
|
284
|
+
return url.pathname === window.location.pathname &&
|
|
285
|
+
url.search === window.location.search &&
|
|
286
|
+
url.hash &&
|
|
287
|
+
url.hash !== window.location.hash;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
prefetchEnabled() {
|
|
291
|
+
const connection = navigator.connection;
|
|
292
|
+
return this.prefetching &&
|
|
293
|
+
!connection?.saveData &&
|
|
294
|
+
!["slow-2g", "2g"].includes(connection?.effectiveType);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
cacheKey(url) {
|
|
298
|
+
const value = new URL(url, window.location.href);
|
|
299
|
+
value.hash = "";
|
|
300
|
+
return value.href;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
rememberScroll() {
|
|
304
|
+
const state = history.state || {};
|
|
305
|
+
history.replaceState({
|
|
306
|
+
...state,
|
|
307
|
+
swifty: {
|
|
308
|
+
...(state.swifty || {}),
|
|
309
|
+
url: window.location.href,
|
|
310
|
+
scroll: { x: window.scrollX, y: window.scrollY },
|
|
311
|
+
},
|
|
312
|
+
}, "", window.location.href);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
updateHistory(url, mode) {
|
|
316
|
+
const state = { swifty: { url, scroll: { x: 0, y: 0 } } };
|
|
317
|
+
if (mode === "push") history.pushState(state, "", url);
|
|
318
|
+
else if (mode === "pop" && url !== window.location.href) history.replaceState(state, "", url);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
restoreScroll(url, options) {
|
|
322
|
+
if (options.historyMode === "pop" && options.scroll) {
|
|
323
|
+
window.scrollTo(options.scroll.x || 0, options.scroll.y || 0);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const hash = new URL(url, window.location.href).hash;
|
|
328
|
+
const anchor = this.anchorFor(hash);
|
|
329
|
+
if (anchor) anchor.scrollIntoView();
|
|
330
|
+
else window.scrollTo(0, 0);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
manageFocus(url, navigationType) {
|
|
334
|
+
if (navigationType === "popstate") return;
|
|
335
|
+
|
|
336
|
+
const hash = new URL(url, window.location.href).hash;
|
|
337
|
+
const target = this.anchorFor(hash) ||
|
|
338
|
+
document.querySelector(`${this.targetSelector} [autofocus]`) ||
|
|
339
|
+
document.querySelector(this.targetSelector);
|
|
340
|
+
if (!target) return;
|
|
341
|
+
|
|
342
|
+
const addedTabIndex = !target.hasAttribute("tabindex") && !target.matches("a,button,input,select,textarea");
|
|
343
|
+
if (addedTabIndex) target.setAttribute("tabindex", "-1");
|
|
344
|
+
target.focus({ preventScroll: true });
|
|
345
|
+
if (addedTabIndex) target.addEventListener("blur", () => target.removeAttribute("tabindex"), { once: true });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
anchorFor(hash) {
|
|
349
|
+
if (!hash) return null;
|
|
350
|
+
try {
|
|
351
|
+
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
nextFrame() {
|
|
358
|
+
return new Promise((resolve) => requestAnimationFrame(resolve));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
setLoading(loading, detail) {
|
|
362
|
+
const root = document.documentElement;
|
|
363
|
+
const target = document.querySelector(this.targetSelector);
|
|
364
|
+
if (loading) {
|
|
365
|
+
root.setAttribute("data-swifty-navigating", "");
|
|
366
|
+
target?.setAttribute("aria-busy", "true");
|
|
367
|
+
this.dispatch("swifty:navigation-start", detail);
|
|
368
|
+
} else {
|
|
369
|
+
root.removeAttribute("data-swifty-navigating");
|
|
370
|
+
target?.removeAttribute("aria-busy");
|
|
371
|
+
this.dispatch("swifty:navigation-end", detail);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
dispatch(name, detail, cancelable = false) {
|
|
376
|
+
return document.dispatchEvent(new CustomEvent(name, { detail, cancelable }));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fullLoad(url, replace = false) {
|
|
380
|
+
if (replace) window.location.replace(url);
|
|
381
|
+
else window.location.assign(url);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const script = document.querySelector("script[data-swifty-navigation]");
|
|
386
|
+
if (script) {
|
|
387
|
+
const navigation = new SwiftyNavigation({
|
|
388
|
+
target: script.dataset.target,
|
|
389
|
+
prefetching: script.dataset.prefetching !== "off",
|
|
390
|
+
cacheSize: script.dataset.cacheSize,
|
|
391
|
+
cacheTTL: script.dataset.cacheTtl,
|
|
392
|
+
});
|
|
393
|
+
window.SwiftyNavigation = navigation;
|
|
394
|
+
if (document.readyState === "loading") {
|
|
395
|
+
document.addEventListener("DOMContentLoaded", () => navigation.start(), { once: true });
|
|
396
|
+
} else {
|
|
397
|
+
navigation.start();
|
|
398
|
+
}
|
|
399
|
+
}
|
package/src/config.js
CHANGED
|
@@ -40,6 +40,14 @@ const builtInDefaults = {
|
|
|
40
40
|
// Image optimization settings
|
|
41
41
|
max_image_width: 800,
|
|
42
42
|
image_quality: 80,
|
|
43
|
+
responsive_image_widths: [320, 640, 800],
|
|
44
|
+
responsive_image_sizes: '100vw',
|
|
45
|
+
default_og_image: '',
|
|
46
|
+
// Output minification
|
|
47
|
+
minify: true,
|
|
48
|
+
minify_html: true,
|
|
49
|
+
minify_css: true,
|
|
50
|
+
minify_js: true,
|
|
43
51
|
// LiveReload and watcher settings
|
|
44
52
|
livereload_port: 35729,
|
|
45
53
|
watcher_delay: 100,
|
|
@@ -48,9 +56,23 @@ const builtInDefaults = {
|
|
|
48
56
|
default_page_count: 2,
|
|
49
57
|
// Build safety
|
|
50
58
|
build_concurrency: 16,
|
|
59
|
+
// Navigation
|
|
60
|
+
morphing: true,
|
|
61
|
+
prefetching: true,
|
|
62
|
+
morph_target: 'main',
|
|
63
|
+
navigation_cache_size: 20,
|
|
64
|
+
navigation_cache_ttl: 15,
|
|
51
65
|
};
|
|
52
66
|
|
|
53
67
|
const loadedConfig = await loadConfig(baseDir);
|
|
68
|
+
const hasConfig = (key) => Object.prototype.hasOwnProperty.call(loadedConfig, key);
|
|
69
|
+
|
|
70
|
+
if (hasConfig('turbo')) {
|
|
71
|
+
console.warn('The "turbo" config option is deprecated. Use "morphing" and "prefetching" instead.');
|
|
72
|
+
if (!hasConfig('morphing')) {
|
|
73
|
+
loadedConfig.morphing = loadedConfig.turbo;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
54
76
|
const defaultConfig = { ...builtInDefaults, ...loadedConfig };
|
|
55
77
|
|
|
56
78
|
export { baseDir, dirs, defaultConfig, loadConfig };
|
package/src/init.js
CHANGED
|
@@ -19,7 +19,17 @@ link_class: swifty_link
|
|
|
19
19
|
tag_class: swifty_tag
|
|
20
20
|
default_layout_name: default
|
|
21
21
|
default_link_name: links
|
|
22
|
-
|
|
22
|
+
max_image_width: 800
|
|
23
|
+
responsive_image_widths:
|
|
24
|
+
- 320
|
|
25
|
+
- 640
|
|
26
|
+
- 800
|
|
27
|
+
responsive_image_sizes: 100vw
|
|
28
|
+
default_og_image: ""
|
|
29
|
+
minify: true
|
|
30
|
+
morphing: true
|
|
31
|
+
prefetching: true
|
|
32
|
+
morph_target: main
|
|
23
33
|
|
|
24
34
|
dateFormat:
|
|
25
35
|
weekday: short
|
package/src/layout.js
CHANGED
|
@@ -2,11 +2,24 @@ import fs from "fs/promises";
|
|
|
2
2
|
import fsExtra from "fs-extra";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { dirs, baseDir, defaultConfig } from "./config.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getCssImports,
|
|
7
|
+
getJsImports,
|
|
8
|
+
getCssPreloads,
|
|
9
|
+
getJsPreloads,
|
|
10
|
+
getNavigationScriptSrc,
|
|
11
|
+
} from "./assets.js";
|
|
6
12
|
|
|
7
13
|
const layoutCache = new Map();
|
|
8
14
|
let template = null;
|
|
9
15
|
|
|
16
|
+
const escapeAttr = (value) =>
|
|
17
|
+
String(value || "")
|
|
18
|
+
.replace(/&/g, "&")
|
|
19
|
+
.replace(/"/g, """)
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">");
|
|
22
|
+
|
|
10
23
|
const getLayout = async (layoutName) => {
|
|
11
24
|
if (!layoutName) return null;
|
|
12
25
|
if (!layoutCache.has(layoutName)) {
|
|
@@ -30,14 +43,11 @@ const createTemplate = async () => {
|
|
|
30
43
|
const preconnectHints = [
|
|
31
44
|
'<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>',
|
|
32
45
|
'<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">',
|
|
33
|
-
...(defaultConfig.turbo ? [
|
|
34
|
-
'<link rel="preconnect" href="https://esm.sh" crossorigin>',
|
|
35
|
-
'<link rel="dns-prefetch" href="https://esm.sh">',
|
|
36
|
-
] : []),
|
|
37
46
|
].join('\n');
|
|
38
47
|
|
|
39
|
-
const
|
|
40
|
-
|
|
48
|
+
const navigationScriptSrc = await getNavigationScriptSrc();
|
|
49
|
+
const navigationScript = navigationScriptSrc
|
|
50
|
+
? `<script type="module" src="${navigationScriptSrc}" data-swifty-navigation data-target="${escapeAttr(defaultConfig.morph_target || "main")}" data-prefetching="${defaultConfig.prefetching === false ? "off" : "intent"}" data-cache-size="${escapeAttr(defaultConfig.navigation_cache_size || 20)}" data-cache-ttl="${escapeAttr(defaultConfig.navigation_cache_ttl || 15)}"></script>`
|
|
41
51
|
: '';
|
|
42
52
|
const livereloadScript = process.env.SWIFTY_WATCH
|
|
43
53
|
? `<script>document.write('<script src="http://' + (location.host || 'localhost').split(':')[0] + ':${defaultConfig.livereload_port || 35729}/livereload.js?snipver=1"></' + 'script>')</script>`
|
|
@@ -51,10 +61,10 @@ const createTemplate = async () => {
|
|
|
51
61
|
const css = await getCssImports();
|
|
52
62
|
const js = await getJsImports();
|
|
53
63
|
const imports = css + js;
|
|
54
|
-
const highlightCSS = `<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.
|
|
64
|
+
const highlightCSS = `<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/monokai-sublime.min.css">`;
|
|
55
65
|
|
|
56
66
|
// Order: preconnect hints -> preloads -> actual assets -> scripts
|
|
57
|
-
const template = templateContent.replace('</head>', `${preconnectHints}\n${preloads}\n${
|
|
67
|
+
const template = templateContent.replace('</head>', `${preconnectHints}\n${preloads}\n${navigationScript}\n${highlightCSS}\n${imports}\n${livereloadScript}\n</head>`);
|
|
58
68
|
return template;
|
|
59
69
|
};
|
|
60
70
|
|
|
@@ -77,4 +87,4 @@ const resetCaches = async () => {
|
|
|
77
87
|
template = await createTemplate();
|
|
78
88
|
};
|
|
79
89
|
|
|
80
|
-
export { getLayout, applyLayoutAndWrapContent, getTemplate, resetCaches };
|
|
90
|
+
export { getLayout, applyLayoutAndWrapContent, getTemplate, resetCaches };
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// markdown.js
|
|
2
|
+
// Central marked instance with syntax highlighting wired up via highlight.js.
|
|
3
|
+
// Importing marked from here (instead of "marked" directly) guarantees the
|
|
4
|
+
// highlight extension is configured before any markdown is parsed.
|
|
5
|
+
import { marked } from "marked";
|
|
6
|
+
import { markedHighlight } from "marked-highlight";
|
|
7
|
+
import hljs from "highlight.js";
|
|
8
|
+
|
|
9
|
+
marked.use(
|
|
10
|
+
markedHighlight({
|
|
11
|
+
emptyLangClass: "hljs",
|
|
12
|
+
langPrefix: "hljs language-",
|
|
13
|
+
highlight(code, lang) {
|
|
14
|
+
const language = hljs.getLanguage(lang) ? lang : "plaintext";
|
|
15
|
+
return hljs.highlight(code, { language }).value;
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export { marked };
|
package/src/minify.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const protectBlocks = (content, pattern) => {
|
|
2
|
+
const blocks = [];
|
|
3
|
+
const protectedContent = content.replace(pattern, (match) => {
|
|
4
|
+
const token = `__SWIFTY_MINIFY_BLOCK_${blocks.length}__`;
|
|
5
|
+
blocks.push(match);
|
|
6
|
+
return token;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
content: protectedContent,
|
|
11
|
+
restore: (value) =>
|
|
12
|
+
value.replace(/__SWIFTY_MINIFY_BLOCK_(\d+)__/g, (_, index) => blocks[index]),
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const protectCssValues = (css) => {
|
|
17
|
+
const blocks = [];
|
|
18
|
+
let content = "";
|
|
19
|
+
|
|
20
|
+
const protect = (start, end) => {
|
|
21
|
+
const token = `__SWIFTY_MINIFY_BLOCK_${blocks.length}__`;
|
|
22
|
+
blocks.push(css.slice(start, end));
|
|
23
|
+
content += token;
|
|
24
|
+
return end;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const readStringEnd = (start) => {
|
|
28
|
+
const quote = css[start];
|
|
29
|
+
let index = start + 1;
|
|
30
|
+
|
|
31
|
+
while (index < css.length) {
|
|
32
|
+
if (css[index] === "\\") {
|
|
33
|
+
index += 2;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (css[index] === quote) return index + 1;
|
|
37
|
+
index += 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return css.length;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const readUrlEnd = (start) => {
|
|
44
|
+
let index = start + 4;
|
|
45
|
+
|
|
46
|
+
while (index < css.length) {
|
|
47
|
+
const char = css[index];
|
|
48
|
+
if (char === "\"" || char === "'") {
|
|
49
|
+
index = readStringEnd(index);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (char === "\\") {
|
|
53
|
+
index += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (char === ")") return index + 1;
|
|
57
|
+
index += 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return css.length;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (let index = 0; index < css.length; ) {
|
|
64
|
+
const char = css[index];
|
|
65
|
+
const isUrl = css.slice(index, index + 4).toLowerCase() === "url(";
|
|
66
|
+
|
|
67
|
+
if (char === "/" && css[index + 1] === "*") {
|
|
68
|
+
const commentEnd = css.indexOf("*/", index + 2);
|
|
69
|
+
const end = commentEnd === -1 ? css.length : commentEnd + 2;
|
|
70
|
+
content += css.slice(index, end);
|
|
71
|
+
index = end;
|
|
72
|
+
} else if (char === "\"" || char === "'") {
|
|
73
|
+
index = protect(index, readStringEnd(index));
|
|
74
|
+
} else if (isUrl) {
|
|
75
|
+
index = protect(index, readUrlEnd(index));
|
|
76
|
+
} else {
|
|
77
|
+
content += char;
|
|
78
|
+
index += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
content,
|
|
84
|
+
restore: (value) =>
|
|
85
|
+
value.replace(/__SWIFTY_MINIFY_BLOCK_(\d+)__/g, (_, index) => blocks[index]),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const minifyCss = (css) => {
|
|
90
|
+
const values = protectCssValues(css);
|
|
91
|
+
|
|
92
|
+
const minified = values.content
|
|
93
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
94
|
+
.replace(/\s+/g, " ")
|
|
95
|
+
.replace(/\s*([{}:;,])\s*/g, "$1")
|
|
96
|
+
.replace(/;}/g, "}")
|
|
97
|
+
.trim();
|
|
98
|
+
|
|
99
|
+
return values.restore(minified);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const minifyJs = (js) => {
|
|
103
|
+
const strings = protectBlocks(js, /(["'`])(?:\\[\s\S]|(?!\1)[^\\])*\1/g);
|
|
104
|
+
|
|
105
|
+
const minified = strings.content
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((line) => line.trim())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join("\n");
|
|
110
|
+
|
|
111
|
+
return strings.restore(minified);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const minifyHtml = (html) => {
|
|
115
|
+
const blocks = protectBlocks(
|
|
116
|
+
html,
|
|
117
|
+
/<(pre|code|textarea|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const minified = blocks.content
|
|
121
|
+
.replace(/<!--(?!\[if|<!|>)[\s\S]*?-->/g, "")
|
|
122
|
+
.replace(/\s{2,}/g, " ")
|
|
123
|
+
.replace(/>\s+</g, "><")
|
|
124
|
+
.trim();
|
|
125
|
+
|
|
126
|
+
return blocks.restore(minified);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export { minifyCss, minifyHtml, minifyJs };
|