@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 CHANGED
@@ -15,13 +15,21 @@ Swifty uses convention over configuration to make it super simple to build blazi
15
15
  - **RSS feed generation** for blogs and content folders
16
16
  - **Draft mode** for work-in-progress pages (visible in dev, hidden in production)
17
17
  - **Scheduled publishing** via future dates in front matter
18
- - **Optional [Turbo](https://turbo.hotwired.dev/)** for SPA-like transitions
18
+ - **Contact forms** via third-party services (Formspree, Netlify Forms, etc.)
19
+ - **Pagination** for folders with many pages
20
+ - **Data files** - Load JSON/YAML data and use in templates
21
+ - **Open Graph tags** - Auto-generated social sharing meta tags
22
+ - **Word count & reading time** - Auto-calculated for blog posts
23
+ - **Previous/next navigation** - Auto-generated links between sibling pages
24
+ - **[Eta templating](https://eta.js.org/)** - Full JavaScript in templates with EJS syntax
25
+ - **Idiomorph navigation** with optional intent prefetching for SPA-like transitions
19
26
 
20
27
  ## Quickstart
21
28
 
22
29
  ```bash
23
- npm install @daz4126/swifty
24
- npx swifty init
30
+ npm install -g @daz4126/swifty
31
+ swifty my-site
32
+ cd my-site
25
33
  npx swifty start
26
34
  ```
27
35
 
@@ -34,6 +42,7 @@ your-site/
34
42
  ├── pages/ # Markdown content (folder structure = URLs)
35
43
  ├── layouts/ # HTML layout templates
36
44
  ├── partials/ # Reusable content snippets
45
+ ├── data/ # JSON/YAML data files
37
46
  ├── css/ # Stylesheets (auto-injected)
38
47
  ├── js/ # JavaScript (auto-injected)
39
48
  ├── images/ # Images (auto-optimized to WebP)
@@ -44,16 +53,18 @@ your-site/
44
53
  ## Commands
45
54
 
46
55
  ```bash
47
- npx swifty init # Create new project structure
56
+ npx swifty my-site # Create new site in my-site/ folder
48
57
  npx swifty build # Build static site to dist/ (for production)
49
58
  npx swifty start # Build, watch, and serve at localhost:3000 (for development)
50
59
  npx swifty build --out dir # Build to custom output directory
60
+ npx swifty deploy "message" # Build, git add, commit, and push (message optional)
51
61
  ```
52
62
 
53
63
  ### Development vs Production
54
64
 
55
65
  - **`swifty start`** - For development. Includes live reload (auto-refreshes browser on file changes) and file watching with incremental builds for CSS/JS/images.
56
66
  - **`swifty build`** - For production deployment. Produces clean output without any development scripts.
67
+ - **`swifty deploy "message"`** - Builds the site and commits/pushes to git in one command.
57
68
 
58
69
  ## Documentation
59
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/swifty",
3
- "version": "2.11.0",
3
+ "version": "3.0.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "description": "Super Speedy Static Site Generator",
27
27
  "dependencies": {
28
28
  "chokidar": "^4.0.0",
29
+ "eta": "^4.5.0",
29
30
  "fs-extra": "^11.2.0",
30
31
  "gray-matter": "^4.0.3",
31
32
  "highlight.js": "^11.11.1",
@@ -33,7 +34,7 @@
33
34
  "livereload": "^0.10.3",
34
35
  "marked": "^14.1.3",
35
36
  "marked-highlight": "^2.2.1",
36
- "serve": "^14.2.4",
37
+ "serve": "^14.2.5",
37
38
  "sharp": "^0.33.5"
38
39
  },
39
40
  "devDependencies": {
package/src/assets.js CHANGED
@@ -2,7 +2,13 @@ import fs from "fs/promises";
2
2
  import fsExtra from "fs-extra";
3
3
  import path from "path";
4
4
  import sharp from "sharp";
5
+ import { fileURLToPath } from "url";
5
6
  import { dirs, defaultConfig } from "./config.js";
7
+ import { mapLimit } from "./concurrency.js";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const clientAssetsDir = path.join(__dirname, "client");
6
12
 
7
13
  // Get file modification timestamp for cache busting
8
14
  const getFileMtime = async (filePath) => {
@@ -13,7 +19,54 @@ const getFileMtime = async (filePath) => {
13
19
  const validExtensions = {
14
20
  css: [".css"],
15
21
  js: [".js"],
16
- images: [".png", ".jpg", ".jpeg", ".gif", ".svg", " .webp"],
22
+ images: [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"],
23
+ };
24
+
25
+ const optimizableImageExtensions = [".jpg", ".jpeg", ".png"];
26
+
27
+ const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
28
+ const navigationEnabled = () => defaultConfig.morphing !== false;
29
+
30
+ const isDestinationFresh = async (source, destination) => {
31
+ try {
32
+ const [sourceStats, destinationStats] = await Promise.all([
33
+ fs.stat(source),
34
+ fs.stat(destination),
35
+ ]);
36
+ return destinationStats.mtimeMs >= sourceStats.mtimeMs;
37
+ } catch (err) {
38
+ return false;
39
+ }
40
+ };
41
+
42
+ const copyIfStale = async (source, destination) => {
43
+ if (await isDestinationFresh(source, destination)) {
44
+ return false;
45
+ }
46
+
47
+ await fsExtra.copy(source, destination);
48
+ return true;
49
+ };
50
+
51
+ const optimizeImageToWebp = async (source, destination) => {
52
+ if (await isDestinationFresh(source, destination)) {
53
+ return false;
54
+ }
55
+
56
+ const image = sharp(source);
57
+ const metadata = await image.metadata();
58
+ const originalWidth = metadata.width || 0;
59
+ const maxWidth = defaultConfig.max_image_width || 800;
60
+ const imageQuality = defaultConfig.image_quality || 80;
61
+ const resizeOptions =
62
+ originalWidth > 0 ? { width: Math.min(originalWidth, maxWidth) } : {};
63
+
64
+ await image
65
+ .resize(resizeOptions)
66
+ .toFormat("webp", { quality: imageQuality })
67
+ .toFile(destination);
68
+
69
+ return true;
17
70
  };
18
71
 
19
72
  const ensureAndCopy = async (source, destination, validExts) => {
@@ -33,6 +86,29 @@ const ensureAndCopy = async (source, destination, validExts) => {
33
86
  console.log(`No ${path.basename(source)} found in ${source}`);
34
87
  }
35
88
  };
89
+
90
+ const copyNavigationAssets = async (outputDir = dirs.dist) => {
91
+ if (!navigationEnabled()) return;
92
+ if (!(await fsExtra.pathExists(clientAssetsDir))) return;
93
+
94
+ const destination = path.join(outputDir, "swifty");
95
+ await fsExtra.ensureDir(destination);
96
+
97
+ const files = await fs.readdir(clientAssetsDir);
98
+ await mapLimit(
99
+ files,
100
+ async (file) => {
101
+ const sourcePath = path.join(clientAssetsDir, file);
102
+ const destinationPath = path.join(destination, file);
103
+ const copied = await copyIfStale(sourcePath, destinationPath);
104
+ if (copied) {
105
+ console.log(`Copied Swifty navigation asset ${file}`);
106
+ }
107
+ },
108
+ getBuildConcurrency(),
109
+ );
110
+ };
111
+
36
112
  const copyAssets = async (outputDir = dirs.dist) => {
37
113
  await ensureAndCopy(
38
114
  dirs.css,
@@ -40,47 +116,48 @@ const copyAssets = async (outputDir = dirs.dist) => {
40
116
  validExtensions.css,
41
117
  );
42
118
  await ensureAndCopy(dirs.js, path.join(outputDir, "js"), validExtensions.js);
43
- await ensureAndCopy(
44
- dirs.images,
45
- path.join(outputDir, "images"),
46
- validExtensions.images,
47
- );
119
+ await copyNavigationAssets(outputDir);
48
120
  };
49
121
  async function optimizeImages(outputDir = dirs.dist) {
50
122
  try {
51
- const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png"];
52
- const images_folder = path.join(outputDir, "images");
53
- const files = await fs.readdir(images_folder);
54
-
55
- await Promise.all(
56
- files.map(async (file) => {
57
- const filePath = path.join(images_folder, file);
123
+ if (!(await fsExtra.pathExists(dirs.images))) {
124
+ console.log(`No ${path.basename(dirs.images)} found in ${dirs.images}`);
125
+ return;
126
+ }
127
+
128
+ const imagesFolder = path.join(outputDir, "images");
129
+ await fsExtra.ensureDir(imagesFolder);
130
+
131
+ const files = await fs.readdir(dirs.images);
132
+
133
+ await mapLimit(
134
+ files.filter((file) =>
135
+ validExtensions.images.includes(path.extname(file).toLowerCase()),
136
+ ),
137
+ async (file) => {
138
+ const filePath = path.join(dirs.images, file);
58
139
  const ext = path.extname(file).toLowerCase();
59
140
 
60
- if (!IMAGE_EXTENSIONS.includes(ext)) return;
141
+ if (!optimizableImageExtensions.includes(ext)) {
142
+ const destination = path.join(imagesFolder, file);
143
+ const copied = await copyIfStale(filePath, destination);
144
+ if (copied) {
145
+ console.log(`Copied ${file}`);
146
+ }
147
+ return;
148
+ }
61
149
 
62
150
  const optimizedPath = path.join(
63
- images_folder,
151
+ imagesFolder,
64
152
  `${path.basename(file, ext)}.webp`,
65
153
  );
66
154
 
67
- if (filePath !== optimizedPath) {
68
- const image = sharp(filePath);
69
- const metadata = await image.metadata();
70
- const originalWidth = metadata.width || 0;
71
- const maxWidth = defaultConfig.max_image_size || 800;
72
- const resizeWidth = Math.min(originalWidth, maxWidth);
73
-
74
- await image
75
- .resize({ width: resizeWidth })
76
- .toFormat("webp", { quality: 80 })
77
- .toFile(optimizedPath);
78
-
79
- await fs.unlink(filePath);
80
-
155
+ const optimized = await optimizeImageToWebp(filePath, optimizedPath);
156
+ if (optimized) {
81
157
  console.log(`Optimized ${file} -> ${optimizedPath}`);
82
158
  }
83
- }),
159
+ },
160
+ getBuildConcurrency(),
84
161
  );
85
162
  } catch (error) {
86
163
  console.error("Error optimizing images:", error);
@@ -148,7 +225,6 @@ const copySingleAsset = async (filePath, outputDir = dirs.dist) => {
148
225
 
149
226
  // Process a single image
150
227
  const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
151
- const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png"];
152
228
  const filename = path.basename(filePath);
153
229
  const ext = path.extname(filePath).toLowerCase();
154
230
  const imagesFolder = path.join(outputDir, "images");
@@ -156,26 +232,20 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
156
232
  await fsExtra.ensureDir(imagesFolder);
157
233
 
158
234
  // For non-optimizable images (svg, webp, gif), just copy
159
- if (!IMAGE_EXTENSIONS.includes(ext)) {
160
- await fsExtra.copy(filePath, path.join(imagesFolder, filename));
161
- console.log(`Copied ${filename}`);
235
+ if (!optimizableImageExtensions.includes(ext)) {
236
+ const copied = await copyIfStale(filePath, path.join(imagesFolder, filename));
237
+ if (copied) {
238
+ console.log(`Copied ${filename}`);
239
+ }
162
240
  return true;
163
241
  }
164
242
 
165
243
  // Optimize jpg/jpeg/png to webp
166
244
  const optimizedPath = path.join(imagesFolder, `${path.basename(filename, ext)}.webp`);
167
- const image = sharp(filePath);
168
- const metadata = await image.metadata();
169
- const originalWidth = metadata.width || 0;
170
- const maxWidth = defaultConfig.max_image_size || 800;
171
- const resizeWidth = Math.min(originalWidth, maxWidth);
172
-
173
- await image
174
- .resize({ width: resizeWidth })
175
- .toFormat("webp", { quality: 80 })
176
- .toFile(optimizedPath);
177
-
178
- console.log(`Optimized ${filename} -> ${path.basename(optimizedPath)}`);
245
+ const optimized = await optimizeImageToWebp(filePath, optimizedPath);
246
+ if (optimized) {
247
+ console.log(`Optimized ${filename} -> ${path.basename(optimizedPath)}`);
248
+ }
179
249
  return true;
180
250
  };
181
251
 
package/src/build.js CHANGED
@@ -1,13 +1,69 @@
1
1
  import { copyAssets, optimizeImages } from "./assets.js";
2
2
  import { generatePages, createPages, addLinks } from "./pages.js";
3
3
  import { generateRssFeeds } from "./rss.js";
4
+ import { generateSeoFiles } from "./sitemap.js";
4
5
  import { dirs } from "./config.js";
5
6
 
6
- export default async function build(outputDir) {
7
+ export default async function build(outputDir = dirs.dist) {
8
+ const startTime = performance.now();
9
+ console.log("🚀 Starting build...");
10
+
11
+ const copyStart = performance.now();
7
12
  await copyAssets(outputDir);
13
+ const copyTime = performance.now() - copyStart;
14
+ console.log(`📁 Assets copied in ${(copyTime / 1000).toFixed(2)}s`);
15
+
16
+ const imageStart = performance.now();
8
17
  await optimizeImages(outputDir);
18
+ const imageTime = performance.now() - imageStart;
19
+ console.log(`🖼️ Images optimized in ${(imageTime / 1000).toFixed(2)}s`);
20
+
21
+ const pagesStart = performance.now();
9
22
  const pages = await generatePages(dirs.pages);
23
+ const pagesTime = performance.now() - pagesStart;
24
+ console.log(`📄 Pages generated in ${(pagesTime / 1000).toFixed(2)}s`);
25
+
26
+ const linksStart = performance.now();
10
27
  await addLinks(pages);
28
+ const linksTime = performance.now() - linksStart;
29
+ console.log(`🔗 Links added in ${(linksTime / 1000).toFixed(2)}s`);
30
+
31
+ const createStart = performance.now();
11
32
  await createPages(pages, outputDir);
33
+ const createTime = performance.now() - createStart;
34
+ console.log(`✨ Pages created in ${(createTime / 1000).toFixed(2)}s`);
35
+
36
+ const rssStart = performance.now();
12
37
  await generateRssFeeds(pages, outputDir);
38
+ const rssTime = performance.now() - rssStart;
39
+ console.log(`📡 RSS feeds generated in ${(rssTime / 1000).toFixed(2)}s`);
40
+
41
+ const seoStart = performance.now();
42
+ await generateSeoFiles(pages, outputDir);
43
+ const seoTime = performance.now() - seoStart;
44
+ console.log(`🧭 SEO files generated in ${(seoTime / 1000).toFixed(2)}s`);
45
+
46
+ const totalTime = performance.now() - startTime;
47
+ console.log(`\n✅ Build completed in ${(totalTime / 1000).toFixed(2)}s`);
48
+
49
+ // Performance summary
50
+ const stages = [
51
+ { name: "Assets", time: copyTime },
52
+ { name: "Images", time: imageTime },
53
+ { name: "Pages", time: pagesTime },
54
+ { name: "Links", time: linksTime },
55
+ { name: "Create", time: createTime },
56
+ { name: "RSS", time: rssTime },
57
+ { name: "SEO", time: seoTime }
58
+ ];
59
+
60
+ const slowest = stages.sort((a, b) => b.time - a.time)[0];
61
+ if (slowest.time > totalTime * 0.3) {
62
+ console.log(`⚠️ Slowest stage: ${slowest.name} (${(slowest.time / 1000).toFixed(2)}s)`);
63
+ }
64
+
65
+ // Force output to appear
66
+ if (process.stdout.isTTY) {
67
+ process.stdout.write('\n');
68
+ }
13
69
  }
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from "child_process";
3
+ import { spawn, execSync } from "child_process";
4
4
 
5
5
  const args = process.argv.slice(2);
6
6
  let outDir = "dist"; // default
@@ -14,13 +14,22 @@ if (outIndex !== -1 && args[outIndex + 1]) {
14
14
  // Pass outDir as an environment variable too (optional, still useful)
15
15
  process.env.OUT_DIR = outDir;
16
16
 
17
+ const reservedCommands = ["build", "start", "watch", "deploy"];
18
+
17
19
  async function main() {
18
20
  const command = args[0];
19
21
 
22
+ // No arguments - show usage
23
+ if (!command) {
24
+ console.log(`Usage:`);
25
+ console.log(` swifty <sitename> Create a new site in <sitename> folder`);
26
+ console.log(` swifty build [--out folder] Build the site`);
27
+ console.log(` swifty start [--out folder] Build and serve with live reload`);
28
+ console.log(` swifty deploy ["message"] Build, commit, and push to git`);
29
+ return;
30
+ }
31
+
20
32
  switch (command) {
21
- case "init":
22
- await import("./init.js");
23
- break;
24
33
  case "build": {
25
34
  const build = await import("./build.js");
26
35
  if (typeof build.default === "function") {
@@ -54,9 +63,44 @@ async function main() {
54
63
  }
55
64
  break;
56
65
  }
57
- default:
58
- console.log(`Unknown command: ${command}`);
59
- console.log(`Usage: swifty [init|build|start|watch] [--out folder]`);
66
+ case "deploy": {
67
+ const commitMessage = args[1] || "Deploying latest build";
68
+
69
+ // Build the site
70
+ const build = await import("./build.js");
71
+ if (typeof build.default === "function") {
72
+ await build.default(outDir);
73
+ }
74
+
75
+ // Git operations
76
+ 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!");
89
+ } catch (error) {
90
+ console.error("Deploy failed:", error.message);
91
+ process.exit(1);
92
+ }
93
+ break;
94
+ }
95
+ default: {
96
+ // Treat as sitename for new project creation
97
+ const sitename = command;
98
+ const init = await import("./init.js");
99
+ if (typeof init.default === "function") {
100
+ await init.default(sitename);
101
+ }
102
+ break;
103
+ }
60
104
  }
61
105
  }
62
106
 
@@ -0,0 +1,13 @@
1
+ Zero-Clause BSD
2
+ =============
3
+
4
+ Permission to use, copy, modify, and/or distribute this software for
5
+ any purpose with or without fee is hereby granted.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
8
+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
9
+ OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
10
+ FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
11
+ DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13
+ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.