@daz4126/swifty 3.1.0 → 4.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/MIGRATION.md ADDED
@@ -0,0 +1,60 @@
1
+ # Migrating to Swifty 4
2
+
3
+ Swifty 4 focuses on deterministic builds, safer deployment, and explicit errors.
4
+
5
+ ## Requirements
6
+
7
+ - Upgrade Node.js to version 22 or 24. Older Node.js releases are no longer supported.
8
+ - Run `npm install` after upgrading so the tracked lockfile installs the new minifiers.
9
+
10
+ ## Clean Output and Public Files
11
+
12
+ Every full build empties the output directory. Files written directly to `dist/` are removed.
13
+
14
+ Move files that must pass through unchanged into `public/`:
15
+
16
+ ```text
17
+ public/manifest.webmanifest -> dist/manifest.webmanifest
18
+ public/fonts/site.woff2 -> dist/fonts/site.woff2
19
+ ```
20
+
21
+ Optimized images remain cached in `.swifty-cache/`, which should not be committed.
22
+
23
+ ## Folder Index Pages
24
+
25
+ `pages/blog/index.md` is now the content and front matter for `/blog`. It no longer creates `/blog/index` or gets treated as a child page.
26
+
27
+ ## Permalinks and Base Paths
28
+
29
+ Use `permalink` in page front matter to override a page URL:
30
+
31
+ ```yaml
32
+ permalink: /company/about.html
33
+ ```
34
+
35
+ For deployment below an origin path, set `base_path` and keep `site_url` as the origin:
36
+
37
+ ```yaml
38
+ site_url: https://example.com
39
+ base_path: /project
40
+ ```
41
+
42
+ The base path changes public URLs but does not add a `dist/project/` directory.
43
+
44
+ ## Strict Build Errors
45
+
46
+ Invalid configuration, front matter, JSON/YAML data, Eta templates, partial references, images, CSS, JavaScript, or generated HTML now fail the build. Fix the path reported in the error before deploying.
47
+
48
+ ## Minification
49
+
50
+ Swifty now uses CleanCSS, Terser, and html-minifier-terser. JavaScript is compressed but identifiers are not mangled. Set `minify`, `minify_html`, `minify_css`, or `minify_js` to `false` if source formatting must be retained.
51
+
52
+ ## Development Server
53
+
54
+ `swifty start` now uses Swifty's built-in static server. Set `server_port` in `config.yaml` to change its default port from `3000`.
55
+
56
+ ## Deploy Command
57
+
58
+ `swifty deploy` stages only the configured output directory. It refuses to proceed when other changes are already staged, and it does not commit source changes automatically.
59
+
60
+ Commit source changes separately before deploying.
package/README.md CHANGED
@@ -25,6 +25,10 @@ Swifty uses convention over configuration to make it super simple to build blazi
25
25
  - **Previous/next navigation** - Auto-generated links between sibling pages
26
26
  - **[Eta templating](https://eta.js.org/)** - Full JavaScript in templates with EJS syntax
27
27
  - **Idiomorph navigation** with optional intent prefetching for SPA-like transitions
28
+ - **Custom permalinks and base paths** for flexible deployment URLs
29
+ - **Public asset passthrough** for files that should be copied unchanged
30
+
31
+ Requires Node.js 22 or newer. See [Migrating to Swifty 4](MIGRATION.md) when upgrading an existing site.
28
32
 
29
33
  ## Quickstart
30
34
 
@@ -48,6 +52,7 @@ your-site/
48
52
  ├── css/ # Stylesheets (auto-injected)
49
53
  ├── js/ # JavaScript (auto-injected)
50
54
  ├── images/ # Images (auto-optimized to responsive WebP)
55
+ ├── public/ # Files copied unchanged to the output root
51
56
  ├── template.html # Base HTML template
52
57
  └── config.yaml # Site configuration
53
58
  ```
@@ -59,14 +64,14 @@ npx swifty my-site # Create new site in my-site/ folder
59
64
  npx swifty build # Build static site to dist/ (for production)
60
65
  npx swifty start # Build, watch, and serve at localhost:3000 (for development)
61
66
  npx swifty build --out dir # Build to custom output directory
62
- npx swifty deploy "message" # Build, git add, commit, and push (message optional)
67
+ npx swifty deploy "message" # Build, commit the output folder, and push
63
68
  ```
64
69
 
65
70
  ### Development vs Production
66
71
 
67
72
  - **`swifty start`** - For development. Includes live reload (auto-refreshes browser on file changes) and file watching with incremental builds for CSS/JS/images.
68
73
  - **`swifty build`** - For production deployment. Produces clean output without any development scripts.
69
- - **`swifty deploy "message"`** - Builds the site and commits/pushes to git in one command.
74
+ - **`swifty deploy "message"`** - Builds the site, commits only the generated output folder, and pushes it to git.
70
75
 
71
76
  ## Documentation
72
77
 
package/package.json CHANGED
@@ -1,18 +1,27 @@
1
1
  {
2
2
  "name": "@daz4126/swifty",
3
- "version": "3.1.0",
4
- "main": "index.js",
3
+ "version": "4.1.0",
4
+ "main": "./src/index.js",
5
5
  "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js"
8
+ },
6
9
  "bin": {
7
10
  "swifty": "./src/cli.js"
8
11
  },
12
+ "engines": {
13
+ "node": ">=22"
14
+ },
9
15
  "files": [
10
- "src/"
16
+ "src/",
17
+ "MIGRATION.md"
11
18
  ],
12
19
  "scripts": {
13
20
  "test": "mocha",
21
+ "test:package": "node scripts/verify-package.js",
22
+ "check": "npm test && npm run test:package",
14
23
  "build": "node src/build.js",
15
- "start": "node src/build.js && serve dist",
24
+ "start": "node src/cli.js start",
16
25
  "init": "node src/init.js",
17
26
  "watch": "node src/watcher.js"
18
27
  },
@@ -26,16 +35,17 @@
26
35
  "description": "Super Speedy Static Site Generator",
27
36
  "dependencies": {
28
37
  "chokidar": "^4.0.0",
38
+ "clean-css": "^5.3.3",
29
39
  "eta": "^4.5.0",
30
40
  "fs-extra": "^11.2.0",
31
- "gray-matter": "^4.0.3",
32
41
  "highlight.js": "^11.11.1",
42
+ "html-minifier-terser": "^7.2.0",
33
43
  "js-yaml": "^4.1.0",
34
44
  "livereload": "^0.10.3",
35
45
  "marked": "^14.1.3",
36
46
  "marked-highlight": "^2.2.1",
37
- "serve": "^14.2.5",
38
- "sharp": "^0.33.5"
47
+ "sharp": "^0.33.5",
48
+ "terser": "^5.48.0"
39
49
  },
40
50
  "devDependencies": {
41
51
  "mocha": "^11.1.0"
@@ -45,10 +55,10 @@
45
55
  },
46
56
  "repository": {
47
57
  "type": "git",
48
- "url": "git+https://github.com/daz4126/swifty.git"
58
+ "url": "git+https://github.com/daz-codes/swifty.git"
49
59
  },
50
60
  "bugs": {
51
- "url": "https://github.com/daz4126/swifty/issues"
61
+ "url": "https://github.com/daz-codes/swifty/issues"
52
62
  },
53
- "homepage": "https://github.com/daz4126/swifty#readme"
63
+ "homepage": "https://github.com/daz-codes/swifty#readme"
54
64
  }
package/src/assets.js CHANGED
@@ -9,6 +9,11 @@ import sharp from "sharp";
9
9
  import { dirs, defaultConfig } from "./config.js";
10
10
  import { mapLimit } from "./concurrency.js";
11
11
  import { minifyCss, minifyJs } from "./minify.js";
12
+ import {
13
+ applyBasePathToCss,
14
+ withBasePath,
15
+ withoutBasePath,
16
+ } from "./urls.js";
12
17
 
13
18
  const __filename = fileURLToPath(import.meta.url);
14
19
  const __dirname = path.dirname(__filename);
@@ -33,7 +38,52 @@ const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
33
38
  const navigationEnabled = () => defaultConfig.morphing !== false;
34
39
  const minificationEnabled = (type) =>
35
40
  defaultConfig.minify !== false && defaultConfig[`minify_${type}`] !== false;
36
- const normalizeImageUrl = (url) => (url || "").split(/[?#]/)[0];
41
+ const normalizeImageUrl = (url) =>
42
+ withoutBasePath((url || "").split(/[?#]/)[0]);
43
+
44
+ const getImageCacheDirectory = () => {
45
+ const signature = crypto
46
+ .createHash("sha256")
47
+ .update(
48
+ JSON.stringify({
49
+ image_quality: defaultConfig.image_quality || 80,
50
+ max_image_width: defaultConfig.max_image_width || 800,
51
+ responsive_image_widths:
52
+ defaultConfig.responsive_image_widths || [320, 640, 800],
53
+ }),
54
+ )
55
+ .digest("hex")
56
+ .slice(0, 12);
57
+ return path.join(dirs.cache, "images", signature);
58
+ };
59
+
60
+ const prepareImageCache = async () => {
61
+ const cacheRoot = path.join(dirs.cache, "images");
62
+ const cacheDirectory = getImageCacheDirectory();
63
+ await fsExtra.ensureDir(cacheDirectory);
64
+
65
+ const entries = await fs.readdir(cacheRoot, { withFileTypes: true });
66
+ await Promise.all(
67
+ entries
68
+ .filter(
69
+ (entry) =>
70
+ entry.isDirectory() &&
71
+ path.join(cacheRoot, entry.name) !== cacheDirectory,
72
+ )
73
+ .map((entry) => fsExtra.remove(path.join(cacheRoot, entry.name))),
74
+ );
75
+
76
+ return cacheDirectory;
77
+ };
78
+
79
+ const removeStaleCachedImages = async (cacheDirectory, expectedFiles) => {
80
+ const files = await fs.readdir(cacheDirectory);
81
+ await Promise.all(
82
+ files
83
+ .filter((file) => !expectedFiles.has(file))
84
+ .map((file) => fsExtra.remove(path.join(cacheDirectory, file))),
85
+ );
86
+ };
37
87
 
38
88
  const getResponsiveWidths = (originalWidth) => {
39
89
  const maxWidth = defaultConfig.max_image_width || 800;
@@ -62,7 +112,7 @@ const variantFilename = (filename, ext, width, outputWidth) =>
62
112
  const registerResponsiveImage = (filename, ext, variants) => {
63
113
  const basename = path.basename(filename, ext);
64
114
  const entry = {
65
- src: `/images/${basename}.webp`,
115
+ src: withBasePath(`/images/${basename}.webp`),
66
116
  srcset: variants
67
117
  .map((variant) => `${variant.url} ${variant.width}w`)
68
118
  .join(", "),
@@ -70,7 +120,7 @@ const registerResponsiveImage = (filename, ext, variants) => {
70
120
  variants,
71
121
  };
72
122
 
73
- responsiveImageMap.set(normalizeImageUrl(`/images/${filename}`), entry);
123
+ responsiveImageMap.set(normalizeImageUrl(withBasePath(`/images/${filename}`)), entry);
74
124
  responsiveImageMap.set(normalizeImageUrl(entry.src), entry);
75
125
  };
76
126
 
@@ -91,7 +141,7 @@ const getNavigationAssetName = async (filename = "swifty-navigation.js") => {
91
141
 
92
142
  const getNavigationScriptSrc = async () => {
93
143
  if (!navigationEnabled()) return "";
94
- return `/swifty/${await getNavigationAssetName()}`;
144
+ return withBasePath(`/swifty/${await getNavigationAssetName()}`);
95
145
  };
96
146
 
97
147
  const isDestinationFresh = async (source, destination) => {
@@ -115,22 +165,37 @@ const copyIfStale = async (source, destination) => {
115
165
  return true;
116
166
  };
117
167
 
118
- const optimizeImageVariantToWebp = async (source, destination, width) => {
119
- if (await isDestinationFresh(source, destination)) {
120
- return false;
168
+ const optimizeImageVariantToWebp = async (
169
+ source,
170
+ destination,
171
+ cacheDestination,
172
+ width,
173
+ ) => {
174
+ let optimized = false;
175
+
176
+ if (!(await isDestinationFresh(source, cacheDestination))) {
177
+ const imageQuality = defaultConfig.image_quality || 80;
178
+ await fsExtra.ensureDir(path.dirname(cacheDestination));
179
+
180
+ await sharp(source)
181
+ .resize({ width })
182
+ .toFormat("webp", { quality: imageQuality })
183
+ .toFile(cacheDestination);
184
+ optimized = true;
121
185
  }
122
186
 
123
- const imageQuality = defaultConfig.image_quality || 80;
124
-
125
- await sharp(source)
126
- .resize({ width })
127
- .toFormat("webp", { quality: imageQuality })
128
- .toFile(destination);
129
-
130
- return true;
187
+ await copyIfStale(cacheDestination, destination);
188
+ return optimized;
131
189
  };
132
190
 
133
- const optimizeImageToWebp = async (source, imagesFolder, filename, ext) => {
191
+ const optimizeImageToWebp = async (
192
+ source,
193
+ imagesFolder,
194
+ cacheDirectory,
195
+ filename,
196
+ ext,
197
+ expectedCacheFiles,
198
+ ) => {
134
199
  const metadata = await sharp(source).metadata();
135
200
  const originalWidth = metadata.width || 0;
136
201
  const maxWidth = defaultConfig.max_image_width || 800;
@@ -138,17 +203,24 @@ const optimizeImageToWebp = async (source, imagesFolder, filename, ext) => {
138
203
  originalWidth > 0 ? Math.min(originalWidth, maxWidth) : maxWidth;
139
204
  const variants = getResponsiveWidths(originalWidth).map((width) => {
140
205
  const file = variantFilename(filename, ext, width, outputWidth);
206
+ expectedCacheFiles?.add(file);
141
207
  return {
142
208
  width,
143
209
  file,
144
- url: `/images/${file}`,
210
+ url: withBasePath(`/images/${file}`),
145
211
  destination: path.join(imagesFolder, file),
212
+ cacheDestination: path.join(cacheDirectory, file),
146
213
  };
147
214
  });
148
215
 
149
216
  const results = await Promise.all(
150
217
  variants.map((variant) =>
151
- optimizeImageVariantToWebp(source, variant.destination, variant.width),
218
+ optimizeImageVariantToWebp(
219
+ source,
220
+ variant.destination,
221
+ variant.cacheDestination,
222
+ variant.width,
223
+ ),
152
224
  ),
153
225
  );
154
226
 
@@ -163,12 +235,21 @@ const optimizeImageToWebp = async (source, imagesFolder, filename, ext) => {
163
235
 
164
236
  const processTextAsset = async (source, destination, type) => {
165
237
  const content = await fs.readFile(source, "utf-8");
166
- const nextContent =
167
- type === "css" && minificationEnabled("css")
168
- ? minifyCss(content)
169
- : type === "js" && minificationEnabled("js")
170
- ? minifyJs(content)
171
- : content;
238
+ let nextContent = content;
239
+
240
+ try {
241
+ if (type === "css" && minificationEnabled("css")) {
242
+ nextContent = minifyCss(applyBasePathToCss(content));
243
+ } else if (type === "js" && minificationEnabled("js")) {
244
+ nextContent = await minifyJs(content);
245
+ } else if (type === "css") {
246
+ nextContent = applyBasePathToCss(content);
247
+ }
248
+ } catch (error) {
249
+ throw new Error(`Unable to minify ${source}: ${error.message}`, {
250
+ cause: error,
251
+ });
252
+ }
172
253
 
173
254
  try {
174
255
  const currentContent = await fs.readFile(destination, "utf-8");
@@ -236,6 +317,9 @@ const copyNavigationAssets = async (outputDir = dirs.dist) => {
236
317
  };
237
318
 
238
319
  const copyAssets = async (outputDir = dirs.dist) => {
320
+ if (await fsExtra.pathExists(dirs.public)) {
321
+ await fsExtra.copy(dirs.public, outputDir, { overwrite: true });
322
+ }
239
323
  await ensureAndCopy(
240
324
  dirs.css,
241
325
  path.join(outputDir, "css"),
@@ -249,11 +333,14 @@ async function optimizeImages(outputDir = dirs.dist) {
249
333
 
250
334
  try {
251
335
  if (!(await fsExtra.pathExists(dirs.images))) {
336
+ await fsExtra.remove(path.join(dirs.cache, "images"));
252
337
  console.log(`No ${path.basename(dirs.images)} found in ${dirs.images}`);
253
338
  return;
254
339
  }
255
340
 
256
341
  const imagesFolder = path.join(outputDir, "images");
342
+ const cacheDirectory = await prepareImageCache();
343
+ const expectedCacheFiles = new Set();
257
344
  await fsExtra.ensureDir(imagesFolder);
258
345
 
259
346
  const files = await fs.readdir(dirs.images);
@@ -278,8 +365,10 @@ async function optimizeImages(outputDir = dirs.dist) {
278
365
  const optimized = await optimizeImageToWebp(
279
366
  filePath,
280
367
  imagesFolder,
368
+ cacheDirectory,
281
369
  file,
282
370
  ext,
371
+ expectedCacheFiles,
283
372
  );
284
373
  if (optimized) {
285
374
  console.log(`Optimized ${file}`);
@@ -287,8 +376,11 @@ async function optimizeImages(outputDir = dirs.dist) {
287
376
  },
288
377
  getBuildConcurrency(),
289
378
  );
379
+ await removeStaleCachedImages(cacheDirectory, expectedCacheFiles);
290
380
  } catch (error) {
291
- console.error("Error optimizing images:", error);
381
+ throw new Error(`Unable to optimize images: ${error.message}`, {
382
+ cause: error,
383
+ });
292
384
  }
293
385
  }
294
386
  const generateAssetImports = async (dir, tagTemplate, validExts) => {
@@ -309,25 +401,25 @@ const generateAssetImports = async (dir, tagTemplate, validExts) => {
309
401
  const getCssImports = () =>
310
402
  generateAssetImports(
311
403
  dirs.css,
312
- (file, mtime) => `<link rel="stylesheet" href="/css/${file}?v=${mtime}" />`,
404
+ (file, mtime) => `<link rel="stylesheet" href="${withBasePath(`/css/${file}`)}?v=${mtime}" />`,
313
405
  validExtensions.css,
314
406
  );
315
407
  const getJsImports = () =>
316
408
  generateAssetImports(
317
409
  dirs.js,
318
- (file, mtime) => `<script src="/js/${file}?v=${mtime}"></script>`,
410
+ (file, mtime) => `<script src="${withBasePath(`/js/${file}`)}?v=${mtime}"></script>`,
319
411
  validExtensions.js,
320
412
  );
321
413
  const getCssPreloads = () =>
322
414
  generateAssetImports(
323
415
  dirs.css,
324
- (file, mtime) => `<link rel="preload" href="/css/${file}?v=${mtime}" as="style" />`,
416
+ (file, mtime) => `<link rel="preload" href="${withBasePath(`/css/${file}`)}?v=${mtime}" as="style" />`,
325
417
  validExtensions.css,
326
418
  );
327
419
  const getJsPreloads = () =>
328
420
  generateAssetImports(
329
421
  dirs.js,
330
- (file, mtime) => `<link rel="preload" href="/js/${file}?v=${mtime}" as="script" />`,
422
+ (file, mtime) => `<link rel="preload" href="${withBasePath(`/js/${file}`)}?v=${mtime}" as="script" />`,
331
423
  validExtensions.js,
332
424
  );
333
425
 
@@ -359,6 +451,7 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
359
451
  const filename = path.basename(filePath);
360
452
  const ext = path.extname(filePath).toLowerCase();
361
453
  const imagesFolder = path.join(outputDir, "images");
454
+ const cacheDirectory = await prepareImageCache();
362
455
 
363
456
  await fsExtra.ensureDir(imagesFolder);
364
457
 
@@ -374,6 +467,7 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
374
467
  const optimized = await optimizeImageToWebp(
375
468
  filePath,
376
469
  imagesFolder,
470
+ cacheDirectory,
377
471
  filename,
378
472
  ext,
379
473
  );
@@ -394,4 +488,5 @@ export {
394
488
  optimizeSingleImage,
395
489
  getNavigationScriptSrc,
396
490
  getResponsiveImage,
491
+ getImageCacheDirectory,
397
492
  };
package/src/build.js CHANGED
@@ -1,22 +1,54 @@
1
1
  import { argv } from "process";
2
+ import path from "path";
2
3
  import { fileURLToPath } from "url";
4
+
5
+ import fsExtra from "fs-extra";
6
+
3
7
  import { copyAssets, optimizeImages } from "./assets.js";
4
8
  import { generatePages, createPages, addLinks } from "./pages.js";
5
9
  import { generateRssFeeds } from "./rss.js";
6
10
  import { generateSeoFiles } from "./sitemap.js";
7
- import { dirs } from "./config.js";
11
+ import { baseDir, dirs } from "./config.js";
12
+ import { clearDataCache } from "./data.js";
13
+ import { resetCaches } from "./layout.js";
14
+ import { clearCache as clearPartialCache } from "./partials.js";
15
+
16
+ const prepareOutputDirectory = async (outputDir) => {
17
+ const projectPath = path.resolve(baseDir);
18
+ const outputPath = path.resolve(baseDir, outputDir);
19
+ const outputContainsProject =
20
+ projectPath === outputPath || projectPath.startsWith(`${outputPath}${path.sep}`);
21
+ const sourceDirectories = Object.entries(dirs)
22
+ .filter(([name]) => name !== "dist")
23
+ .map(([, directory]) => path.resolve(directory));
24
+ const outputOverlapsSource = sourceDirectories.some(
25
+ (directory) =>
26
+ outputPath === directory || outputPath.startsWith(`${directory}${path.sep}`),
27
+ );
28
+
29
+ if (outputContainsProject || outputOverlapsSource) {
30
+ throw new Error(`Refusing to empty unsafe output directory: ${outputPath}`);
31
+ }
32
+
33
+ await fsExtra.emptyDir(outputPath);
34
+ return outputPath;
35
+ };
8
36
 
9
37
  export default async function build(outputDir = dirs.dist) {
10
38
  const startTime = performance.now();
11
39
  console.log("🚀 Starting build...");
40
+ const resolvedOutputDir = await prepareOutputDirectory(outputDir);
41
+ clearDataCache();
42
+ clearPartialCache();
12
43
 
13
44
  const copyStart = performance.now();
14
- await copyAssets(outputDir);
45
+ await copyAssets(resolvedOutputDir);
15
46
  const copyTime = performance.now() - copyStart;
16
47
  console.log(`📁 Assets copied in ${(copyTime / 1000).toFixed(2)}s`);
48
+ await resetCaches();
17
49
 
18
50
  const imageStart = performance.now();
19
- await optimizeImages(outputDir);
51
+ await optimizeImages(resolvedOutputDir);
20
52
  const imageTime = performance.now() - imageStart;
21
53
  console.log(`🖼️ Images optimized in ${(imageTime / 1000).toFixed(2)}s`);
22
54
 
@@ -31,17 +63,17 @@ export default async function build(outputDir = dirs.dist) {
31
63
  console.log(`🔗 Links added in ${(linksTime / 1000).toFixed(2)}s`);
32
64
 
33
65
  const createStart = performance.now();
34
- await createPages(pages, outputDir);
66
+ await createPages(pages, resolvedOutputDir);
35
67
  const createTime = performance.now() - createStart;
36
68
  console.log(`✨ Pages created in ${(createTime / 1000).toFixed(2)}s`);
37
69
 
38
70
  const rssStart = performance.now();
39
- await generateRssFeeds(pages, outputDir);
71
+ await generateRssFeeds(pages, resolvedOutputDir);
40
72
  const rssTime = performance.now() - rssStart;
41
73
  console.log(`📡 RSS feeds generated in ${(rssTime / 1000).toFixed(2)}s`);
42
74
 
43
75
  const seoStart = performance.now();
44
- await generateSeoFiles(pages, outputDir);
76
+ await generateSeoFiles(pages, resolvedOutputDir);
45
77
  const seoTime = performance.now() - seoStart;
46
78
  console.log(`🧭 SEO files generated in ${(seoTime / 1000).toFixed(2)}s`);
47
79
 
@@ -70,6 +102,8 @@ export default async function build(outputDir = dirs.dist) {
70
102
  }
71
103
  }
72
104
 
105
+ export { prepareOutputDirectory };
106
+
73
107
  // Run the build when invoked directly (e.g. `npm run build`, `npm start`),
74
108
  // not when imported as a module (e.g. by cli.js).
75
109
  if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn, execSync } from "child_process";
3
+ import { execFileSync, spawnSync } from "child_process";
4
+ import { fileURLToPath } from "url";
4
5
 
5
6
  const args = process.argv.slice(2);
6
7
  let outDir = "dist"; // default
@@ -14,7 +15,46 @@ if (outIndex !== -1 && args[outIndex + 1]) {
14
15
  // Pass outDir as an environment variable too (optional, still useful)
15
16
  process.env.OUT_DIR = outDir;
16
17
 
17
- const reservedCommands = ["build", "start", "watch", "deploy"];
18
+ const commitAndPushOutput = (
19
+ outputDir,
20
+ commitMessage,
21
+ { execFile = execFileSync, spawnFile = spawnSync } = {},
22
+ ) => {
23
+ const existingDiff = spawnFile("git", ["diff", "--cached", "--quiet"], {
24
+ stdio: "ignore",
25
+ });
26
+ if (existingDiff.error) throw existingDiff.error;
27
+ if (existingDiff.status === 1) {
28
+ throw new Error("Refusing to deploy while unrelated changes are already staged.");
29
+ }
30
+ if (existingDiff.status !== 0) {
31
+ throw new Error(
32
+ `Unable to inspect staged changes (git exited ${existingDiff.status}).`,
33
+ );
34
+ }
35
+
36
+ console.log(`Adding generated output from ${outputDir} to git...`);
37
+ execFile("git", ["add", "--", outputDir], { stdio: "inherit" });
38
+
39
+ const diffResult = spawnFile("git", ["diff", "--cached", "--quiet"], {
40
+ stdio: "ignore",
41
+ });
42
+ if (diffResult.error) throw diffResult.error;
43
+ if (diffResult.status === 0) {
44
+ console.log("No generated output changes to deploy.");
45
+ return false;
46
+ }
47
+ if (diffResult.status !== 1) {
48
+ throw new Error(`Unable to inspect staged changes (git exited ${diffResult.status}).`);
49
+ }
50
+
51
+ console.log("Committing generated output...");
52
+ execFile("git", ["commit", "-m", commitMessage], { stdio: "inherit" });
53
+
54
+ console.log("Pushing to remote...");
55
+ execFile("git", ["push"], { stdio: "inherit" });
56
+ return true;
57
+ };
18
58
 
19
59
  async function main() {
20
60
  const command = args[0];
@@ -52,8 +92,8 @@ async function main() {
52
92
  watcher.default(outDir);
53
93
  }
54
94
 
55
- // Start the server (blocking)
56
- spawn("npx", ["serve", outDir], { stdio: "inherit" });
95
+ const server = await import("./server.js");
96
+ await server.default(outDir);
57
97
  break;
58
98
  }
59
99
  case "watch": {
@@ -64,7 +104,11 @@ async function main() {
64
104
  break;
65
105
  }
66
106
  case "deploy": {
67
- const commitMessage = args[1] || "Deploying latest build";
107
+ const commandArgs = args.slice(1).filter((arg, index, values) => {
108
+ if (arg === "--out") return false;
109
+ return index === 0 || values[index - 1] !== "--out";
110
+ });
111
+ const commitMessage = commandArgs[0] || "Deploying latest build";
68
112
 
69
113
  // Build the site
70
114
  const build = await import("./build.js");
@@ -74,18 +118,8 @@ async function main() {
74
118
 
75
119
  // Git operations
76
120
  try {
77
- console.log("Adding files to git...");
78
- execSync("git add .", { stdio: "inherit" });
79
-
80
- console.log("Committing changes...");
81
- execSync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
82
- stdio: "inherit",
83
- });
84
-
85
- console.log("Pushing to remote...");
86
- execSync("git push", { stdio: "inherit" });
87
-
88
- console.log("Deployed successfully!");
121
+ const deployed = commitAndPushOutput(outDir, commitMessage);
122
+ if (deployed) console.log("Deployed successfully!");
89
123
  } catch (error) {
90
124
  console.error("Deploy failed:", error.message);
91
125
  process.exit(1);
@@ -104,4 +138,11 @@ async function main() {
104
138
  }
105
139
  }
106
140
 
107
- main();
141
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
142
+ main().catch((error) => {
143
+ console.error(`Swifty failed: ${error.message}`);
144
+ process.exitCode = 1;
145
+ });
146
+ }
147
+
148
+ export { commitAndPushOutput, main };