@daz4126/swifty 2.11.0 → 3.0.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 +15 -4
- package/package.json +3 -2
- package/src/assets.js +116 -46
- package/src/build.js +57 -1
- package/src/cli.js +51 -7
- 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/concurrency.js +25 -0
- package/src/config.js +36 -2
- package/src/data.js +70 -0
- package/src/init.js +39 -33
- package/src/layout.js +16 -11
- package/src/pages.js +231 -119
- package/src/pagination.js +58 -0
- package/src/partials.js +236 -42
- package/src/rss.js +6 -4
- package/src/sitemap.js +89 -0
- package/src/watcher.js +36 -9
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DEFAULT_CONCURRENCY = 16;
|
|
2
|
+
|
|
3
|
+
const getConcurrencyLimit = (value = DEFAULT_CONCURRENCY) => {
|
|
4
|
+
const limit = Number(value);
|
|
5
|
+
return Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CONCURRENCY;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const mapLimit = async (items, mapper, concurrency = DEFAULT_CONCURRENCY) => {
|
|
9
|
+
const limit = Math.min(getConcurrencyLimit(concurrency), items.length || 1);
|
|
10
|
+
const results = new Array(items.length);
|
|
11
|
+
let index = 0;
|
|
12
|
+
|
|
13
|
+
const workers = Array.from({ length: limit }, async () => {
|
|
14
|
+
while (index < items.length) {
|
|
15
|
+
const currentIndex = index;
|
|
16
|
+
index += 1;
|
|
17
|
+
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
await Promise.all(workers);
|
|
22
|
+
return results;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export { getConcurrencyLimit, mapLimit };
|
package/src/config.js
CHANGED
|
@@ -16,6 +16,7 @@ const dirs = {
|
|
|
16
16
|
css: path.join(baseDir, "css"),
|
|
17
17
|
js: path.join(baseDir, "js"),
|
|
18
18
|
partials: path.join(baseDir, "partials"),
|
|
19
|
+
data: path.join(baseDir, "data"),
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
async function loadConfig(dir) {
|
|
@@ -31,6 +32,39 @@ async function loadConfig(dir) {
|
|
|
31
32
|
return {};
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
// Hardcoded defaults (used if not specified in config file)
|
|
36
|
+
const builtInDefaults = {
|
|
37
|
+
default_layout_name: 'default',
|
|
38
|
+
// Reading time calculation
|
|
39
|
+
words_per_minute: 200,
|
|
40
|
+
// Image optimization settings
|
|
41
|
+
max_image_width: 800,
|
|
42
|
+
image_quality: 80,
|
|
43
|
+
// LiveReload and watcher settings
|
|
44
|
+
livereload_port: 35729,
|
|
45
|
+
watcher_delay: 100,
|
|
46
|
+
watcher_interval: 500,
|
|
47
|
+
// Pagination
|
|
48
|
+
default_page_count: 2,
|
|
49
|
+
// Build safety
|
|
50
|
+
build_concurrency: 16,
|
|
51
|
+
// Navigation
|
|
52
|
+
morphing: true,
|
|
53
|
+
prefetching: true,
|
|
54
|
+
morph_target: 'main',
|
|
55
|
+
navigation_cache_size: 20,
|
|
56
|
+
navigation_cache_ttl: 15,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const loadedConfig = await loadConfig(baseDir);
|
|
60
|
+
const hasConfig = (key) => Object.prototype.hasOwnProperty.call(loadedConfig, key);
|
|
61
|
+
|
|
62
|
+
if (hasConfig('turbo')) {
|
|
63
|
+
console.warn('The "turbo" config option is deprecated. Use "morphing" and "prefetching" instead.');
|
|
64
|
+
if (!hasConfig('morphing')) {
|
|
65
|
+
loadedConfig.morphing = loadedConfig.turbo;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const defaultConfig = { ...builtInDefaults, ...loadedConfig };
|
|
35
69
|
|
|
36
|
-
export { baseDir, dirs, defaultConfig, loadConfig };
|
|
70
|
+
export { baseDir, dirs, defaultConfig, loadConfig };
|
package/src/data.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import fsExtra from "fs-extra";
|
|
5
|
+
import { dirs } from "./config.js";
|
|
6
|
+
|
|
7
|
+
let dataCache = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load all data files from the data/ folder
|
|
11
|
+
* Supports .json and .yaml/.yml files
|
|
12
|
+
* File name becomes the key: data/team.json -> data.team
|
|
13
|
+
*/
|
|
14
|
+
async function loadData() {
|
|
15
|
+
if (dataCache !== null) {
|
|
16
|
+
return dataCache;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const data = {};
|
|
20
|
+
|
|
21
|
+
// Check if data directory exists
|
|
22
|
+
if (!(await fsExtra.pathExists(dirs.data))) {
|
|
23
|
+
dataCache = data;
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const files = await fs.readdir(dirs.data);
|
|
29
|
+
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const filePath = path.join(dirs.data, file);
|
|
32
|
+
const stat = await fs.stat(filePath);
|
|
33
|
+
|
|
34
|
+
// Skip directories
|
|
35
|
+
if (stat.isDirectory()) continue;
|
|
36
|
+
|
|
37
|
+
const ext = path.extname(file).toLowerCase();
|
|
38
|
+
const name = path.basename(file, ext);
|
|
39
|
+
|
|
40
|
+
// Only process JSON and YAML files
|
|
41
|
+
if (!['.json', '.yaml', '.yml'].includes(ext)) continue;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
45
|
+
|
|
46
|
+
if (ext === '.json') {
|
|
47
|
+
data[name] = JSON.parse(content);
|
|
48
|
+
} else {
|
|
49
|
+
data[name] = yaml.load(content);
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.warn(`Error loading data file ${file}: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.warn(`Error reading data directory: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dataCache = data;
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clear the data cache (useful for watch mode)
|
|
65
|
+
*/
|
|
66
|
+
function clearDataCache() {
|
|
67
|
+
dataCache = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { loadData, clearDataCache };
|
package/src/init.js
CHANGED
|
@@ -2,62 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import { fileURLToPath } from "url";
|
|
6
|
-
import { dirname } from "path";
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"js/": null,
|
|
19
|
-
"images/": null,
|
|
20
|
-
"config.yaml": `sitename: Swifty
|
|
6
|
+
function getStructure(sitename) {
|
|
7
|
+
return {
|
|
8
|
+
"pages/index.md": `# Welcome to ${sitename}\n\nThis is my home page.`,
|
|
9
|
+
"layouts/": null,
|
|
10
|
+
"partials/": null,
|
|
11
|
+
"css/": null,
|
|
12
|
+
"js/": null,
|
|
13
|
+
"images/": null,
|
|
14
|
+
"data/": null,
|
|
15
|
+
"config.yaml": `sitename: ${sitename}
|
|
21
16
|
breadcrumb_separator: "»"
|
|
22
17
|
breadcrumb_class: swifty_breadcrumb
|
|
23
18
|
link_class: swifty_link
|
|
24
|
-
tag_class:
|
|
25
|
-
default_layout_name:
|
|
19
|
+
tag_class: swifty_tag
|
|
20
|
+
default_layout_name: default
|
|
26
21
|
default_link_name: links
|
|
27
22
|
max_image_size: 800
|
|
23
|
+
morphing: true
|
|
24
|
+
prefetching: true
|
|
25
|
+
morph_target: main
|
|
28
26
|
|
|
29
|
-
dateFormat:
|
|
27
|
+
dateFormat:
|
|
30
28
|
weekday: short
|
|
31
29
|
month: short
|
|
32
30
|
day: numeric
|
|
33
31
|
year: numeric`,
|
|
34
|
-
|
|
32
|
+
"template.html": `<!DOCTYPE html>
|
|
35
33
|
<html lang="en">
|
|
36
34
|
<head>
|
|
37
35
|
<meta charset="UTF-8">
|
|
38
36
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
39
|
-
<
|
|
40
|
-
<link rel="icon" href="favicon-16x16.png" sizes="16x16" type="image/x-icon">
|
|
41
|
-
<link rel="icon" href="favicon-32x32.png" sizes="32x32" type="image/png">
|
|
42
|
-
<link rel="apple-touch-icon" href="path/to/apple-touch-icon.png">
|
|
43
|
-
<link rel="icon" sizes="192x192" href="android-chrome-192x19.png">
|
|
44
|
-
<link rel="icon" sizes="512x512" href="android-chrome-512x512.png">
|
|
45
|
-
<title>{{ sitename }} || {{ title }}</title>
|
|
37
|
+
<title><%= sitename %> | <%= title %></title>
|
|
46
38
|
</head>
|
|
47
39
|
<body>
|
|
48
40
|
<header>
|
|
49
|
-
<h1
|
|
41
|
+
<h1>${sitename}</h1>
|
|
50
42
|
</header>
|
|
51
43
|
<main>
|
|
52
|
-
|
|
44
|
+
<%= content %>
|
|
53
45
|
</main>
|
|
54
46
|
<footer>
|
|
55
47
|
<p>This site was built with Swifty, the super speedy static site generator.</p>
|
|
56
48
|
</footer>
|
|
57
49
|
</body>
|
|
58
50
|
</html>
|
|
59
|
-
|
|
60
|
-
};
|
|
51
|
+
`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
61
54
|
|
|
62
55
|
function createStructure(basePath, structure) {
|
|
63
56
|
Object.entries(structure).forEach(([filePath, content]) => {
|
|
@@ -71,7 +64,20 @@ function createStructure(basePath, structure) {
|
|
|
71
64
|
});
|
|
72
65
|
}
|
|
73
66
|
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
export default function init(sitename) {
|
|
68
|
+
const projectRoot = path.join(process.cwd(), sitename);
|
|
69
|
+
|
|
70
|
+
// Check if folder already exists
|
|
71
|
+
if (fs.existsSync(projectRoot)) {
|
|
72
|
+
console.error(`Error: Folder "${sitename}" already exists.`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
76
75
|
|
|
77
|
-
|
|
76
|
+
const structure = getStructure(sitename);
|
|
77
|
+
createStructure(projectRoot, structure);
|
|
78
|
+
|
|
79
|
+
console.log(`Site "${sitename}" created successfully!`);
|
|
80
|
+
console.log(`\nNext steps:`);
|
|
81
|
+
console.log(` cd ${sitename}`);
|
|
82
|
+
console.log(` npx swifty start`);
|
|
83
|
+
}
|