@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.
@@ -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
- const defaultConfig = await loadConfig(baseDir);
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
- // Needed to emulate __dirname in ES modules
9
- const __filename = fileURLToPath(import.meta.url);
10
- const __dirname = dirname(__filename);
11
-
12
- // Define the structure
13
- const structure = {
14
- "pages/index.md": "This my home page.",
15
- "layouts/": null,
16
- "partials/": null,
17
- "css/": null,
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: "&raquo;"
22
17
  breadcrumb_class: swifty_breadcrumb
23
18
  link_class: swifty_link
24
- tag_class: tag
25
- default_layout_name: site
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
- "template.html": `<!DOCTYPE html>
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
- <link rel="icon" href="favicon.ico" sizes="48x48" type="image/x-icon">
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>Swifty</h1>
41
+ <h1>${sitename}</h1>
50
42
  </header>
51
43
  <main>
52
- {{ content }}
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
- const projectRoot = process.cwd();
75
- createStructure(projectRoot, structure);
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
- console.log("✅ Swifty project initialized successfully!");
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
+ }