@daz4126/swifty 2.10.0 → 2.12.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
@@ -13,13 +13,23 @@ Swifty uses convention over configuration to make it super simple to build blazi
13
13
  - **Code syntax highlighting** via highlight.js
14
14
  - **Tags and navigation** generated automatically
15
15
  - **RSS feed generation** for blogs and content folders
16
+ - **Draft mode** for work-in-progress pages (visible in dev, hidden in production)
17
+ - **Scheduled publishing** via future dates in front matter
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
16
25
  - **Optional [Turbo](https://turbo.hotwired.dev/)** for SPA-like transitions
17
26
 
18
27
  ## Quickstart
19
28
 
20
29
  ```bash
21
- npm install @daz4126/swifty
22
- npx swifty init
30
+ npm install -g @daz4126/swifty
31
+ swifty my-site
32
+ cd my-site
23
33
  npx swifty start
24
34
  ```
25
35
 
@@ -32,6 +42,7 @@ your-site/
32
42
  ├── pages/ # Markdown content (folder structure = URLs)
33
43
  ├── layouts/ # HTML layout templates
34
44
  ├── partials/ # Reusable content snippets
45
+ ├── data/ # JSON/YAML data files
35
46
  ├── css/ # Stylesheets (auto-injected)
36
47
  ├── js/ # JavaScript (auto-injected)
37
48
  ├── images/ # Images (auto-optimized to WebP)
@@ -42,16 +53,18 @@ your-site/
42
53
  ## Commands
43
54
 
44
55
  ```bash
45
- npx swifty init # Create new project structure
56
+ npx swifty my-site # Create new site in my-site/ folder
46
57
  npx swifty build # Build static site to dist/ (for production)
47
58
  npx swifty start # Build, watch, and serve at localhost:3000 (for development)
48
59
  npx swifty build --out dir # Build to custom output directory
60
+ npx swifty deploy "message" # Build, git add, commit, and push (message optional)
49
61
  ```
50
62
 
51
63
  ### Development vs Production
52
64
 
53
65
  - **`swifty start`** - For development. Includes live reload (auto-refreshes browser on file changes) and file watching with incremental builds for CSS/JS/images.
54
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.
55
68
 
56
69
  ## Documentation
57
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/swifty",
3
- "version": "2.10.0",
3
+ "version": "2.12.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
@@ -3,11 +3,64 @@ import fsExtra from "fs-extra";
3
3
  import path from "path";
4
4
  import sharp from "sharp";
5
5
  import { dirs, defaultConfig } from "./config.js";
6
+ import { mapLimit } from "./concurrency.js";
7
+
8
+ // Get file modification timestamp for cache busting
9
+ const getFileMtime = async (filePath) => {
10
+ const stats = await fs.stat(filePath);
11
+ return Math.floor(stats.mtimeMs);
12
+ };
6
13
 
7
14
  const validExtensions = {
8
15
  css: [".css"],
9
16
  js: [".js"],
10
- images: [".png", ".jpg", ".jpeg", ".gif", ".svg", " .webp"],
17
+ images: [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"],
18
+ };
19
+
20
+ const optimizableImageExtensions = [".jpg", ".jpeg", ".png"];
21
+
22
+ const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
23
+
24
+ const isDestinationFresh = async (source, destination) => {
25
+ try {
26
+ const [sourceStats, destinationStats] = await Promise.all([
27
+ fs.stat(source),
28
+ fs.stat(destination),
29
+ ]);
30
+ return destinationStats.mtimeMs >= sourceStats.mtimeMs;
31
+ } catch (err) {
32
+ return false;
33
+ }
34
+ };
35
+
36
+ const copyIfStale = async (source, destination) => {
37
+ if (await isDestinationFresh(source, destination)) {
38
+ return false;
39
+ }
40
+
41
+ await fsExtra.copy(source, destination);
42
+ return true;
43
+ };
44
+
45
+ const optimizeImageToWebp = async (source, destination) => {
46
+ if (await isDestinationFresh(source, destination)) {
47
+ return false;
48
+ }
49
+
50
+ const image = sharp(source);
51
+ const metadata = await image.metadata();
52
+ const originalWidth = metadata.width || 0;
53
+ const maxWidth = defaultConfig.max_image_width || 800;
54
+ const imageQuality = defaultConfig.image_quality || 80;
55
+ const resizeOptions =
56
+ originalWidth > 0 ? { width: Math.min(originalWidth, maxWidth) } : {};
57
+
58
+ await image
59
+ .resize(resizeOptions)
60
+ .toFormat("webp", { quality: imageQuality })
61
+ .toFile(destination);
62
+
63
+ return true;
11
64
  };
12
65
 
13
66
  const ensureAndCopy = async (source, destination, validExts) => {
@@ -34,47 +87,47 @@ const copyAssets = async (outputDir = dirs.dist) => {
34
87
  validExtensions.css,
35
88
  );
36
89
  await ensureAndCopy(dirs.js, path.join(outputDir, "js"), validExtensions.js);
37
- await ensureAndCopy(
38
- dirs.images,
39
- path.join(outputDir, "images"),
40
- validExtensions.images,
41
- );
42
90
  };
43
91
  async function optimizeImages(outputDir = dirs.dist) {
44
92
  try {
45
- const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png"];
46
- const images_folder = path.join(outputDir, "images");
47
- const files = await fs.readdir(images_folder);
48
-
49
- await Promise.all(
50
- files.map(async (file) => {
51
- const filePath = path.join(images_folder, file);
93
+ if (!(await fsExtra.pathExists(dirs.images))) {
94
+ console.log(`No ${path.basename(dirs.images)} found in ${dirs.images}`);
95
+ return;
96
+ }
97
+
98
+ const imagesFolder = path.join(outputDir, "images");
99
+ await fsExtra.ensureDir(imagesFolder);
100
+
101
+ const files = await fs.readdir(dirs.images);
102
+
103
+ await mapLimit(
104
+ files.filter((file) =>
105
+ validExtensions.images.includes(path.extname(file).toLowerCase()),
106
+ ),
107
+ async (file) => {
108
+ const filePath = path.join(dirs.images, file);
52
109
  const ext = path.extname(file).toLowerCase();
53
110
 
54
- if (!IMAGE_EXTENSIONS.includes(ext)) return;
111
+ if (!optimizableImageExtensions.includes(ext)) {
112
+ const destination = path.join(imagesFolder, file);
113
+ const copied = await copyIfStale(filePath, destination);
114
+ if (copied) {
115
+ console.log(`Copied ${file}`);
116
+ }
117
+ return;
118
+ }
55
119
 
56
120
  const optimizedPath = path.join(
57
- images_folder,
121
+ imagesFolder,
58
122
  `${path.basename(file, ext)}.webp`,
59
123
  );
60
124
 
61
- if (filePath !== optimizedPath) {
62
- const image = sharp(filePath);
63
- const metadata = await image.metadata();
64
- const originalWidth = metadata.width || 0;
65
- const maxWidth = defaultConfig.max_image_size || 800;
66
- const resizeWidth = Math.min(originalWidth, maxWidth);
67
-
68
- await image
69
- .resize({ width: resizeWidth })
70
- .toFormat("webp", { quality: 80 })
71
- .toFile(optimizedPath);
72
-
73
- await fs.unlink(filePath);
74
-
125
+ const optimized = await optimizeImageToWebp(filePath, optimizedPath);
126
+ if (optimized) {
75
127
  console.log(`Optimized ${file} -> ${optimizedPath}`);
76
128
  }
77
- }),
129
+ },
130
+ getBuildConcurrency(),
78
131
  );
79
132
  } catch (error) {
80
133
  console.error("Error optimizing images:", error);
@@ -83,34 +136,40 @@ async function optimizeImages(outputDir = dirs.dist) {
83
136
  const generateAssetImports = async (dir, tagTemplate, validExts) => {
84
137
  if (!(await fsExtra.pathExists(dir))) return "";
85
138
  const files = await fs.readdir(dir);
86
- return files
139
+ const validFiles = files
87
140
  .filter((file) => validExts.includes(path.extname(file).toLowerCase()))
88
- .sort()
89
- .map((file) => tagTemplate(file))
90
- .join("\n");
141
+ .sort();
142
+
143
+ const imports = await Promise.all(
144
+ validFiles.map(async (file) => {
145
+ const mtime = await getFileMtime(path.join(dir, file));
146
+ return tagTemplate(file, mtime);
147
+ })
148
+ );
149
+ return imports.join("\n");
91
150
  };
92
151
  const getCssImports = () =>
93
152
  generateAssetImports(
94
153
  dirs.css,
95
- (file) => `<link rel="stylesheet" href="/css/${file}" />`,
154
+ (file, mtime) => `<link rel="stylesheet" href="/css/${file}?v=${mtime}" />`,
96
155
  validExtensions.css,
97
156
  );
98
157
  const getJsImports = () =>
99
158
  generateAssetImports(
100
159
  dirs.js,
101
- (file) => `<script src="/js/${file}"></script>`,
160
+ (file, mtime) => `<script src="/js/${file}?v=${mtime}"></script>`,
102
161
  validExtensions.js,
103
162
  );
104
163
  const getCssPreloads = () =>
105
164
  generateAssetImports(
106
165
  dirs.css,
107
- (file) => `<link rel="preload" href="/css/${file}" as="style" />`,
166
+ (file, mtime) => `<link rel="preload" href="/css/${file}?v=${mtime}" as="style" />`,
108
167
  validExtensions.css,
109
168
  );
110
169
  const getJsPreloads = () =>
111
170
  generateAssetImports(
112
171
  dirs.js,
113
- (file) => `<link rel="preload" href="/js/${file}" as="script" />`,
172
+ (file, mtime) => `<link rel="preload" href="/js/${file}?v=${mtime}" as="script" />`,
114
173
  validExtensions.js,
115
174
  );
116
175
 
@@ -136,7 +195,6 @@ const copySingleAsset = async (filePath, outputDir = dirs.dist) => {
136
195
 
137
196
  // Process a single image
138
197
  const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
139
- const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png"];
140
198
  const filename = path.basename(filePath);
141
199
  const ext = path.extname(filePath).toLowerCase();
142
200
  const imagesFolder = path.join(outputDir, "images");
@@ -144,26 +202,20 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
144
202
  await fsExtra.ensureDir(imagesFolder);
145
203
 
146
204
  // For non-optimizable images (svg, webp, gif), just copy
147
- if (!IMAGE_EXTENSIONS.includes(ext)) {
148
- await fsExtra.copy(filePath, path.join(imagesFolder, filename));
149
- console.log(`Copied ${filename}`);
205
+ if (!optimizableImageExtensions.includes(ext)) {
206
+ const copied = await copyIfStale(filePath, path.join(imagesFolder, filename));
207
+ if (copied) {
208
+ console.log(`Copied ${filename}`);
209
+ }
150
210
  return true;
151
211
  }
152
212
 
153
213
  // Optimize jpg/jpeg/png to webp
154
214
  const optimizedPath = path.join(imagesFolder, `${path.basename(filename, ext)}.webp`);
155
- const image = sharp(filePath);
156
- const metadata = await image.metadata();
157
- const originalWidth = metadata.width || 0;
158
- const maxWidth = defaultConfig.max_image_size || 800;
159
- const resizeWidth = Math.min(originalWidth, maxWidth);
160
-
161
- await image
162
- .resize({ width: resizeWidth })
163
- .toFormat("webp", { quality: 80 })
164
- .toFile(optimizedPath);
165
-
166
- console.log(`Optimized ${filename} -> ${path.basename(optimizedPath)}`);
215
+ const optimized = await optimizeImageToWebp(filePath, optimizedPath);
216
+ if (optimized) {
217
+ console.log(`Optimized ${filename} -> ${path.basename(optimizedPath)}`);
218
+ }
167
219
  return true;
168
220
  };
169
221
 
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,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,25 @@ 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
+ };
52
+
53
+ const loadedConfig = await loadConfig(baseDir);
54
+ const defaultConfig = { ...builtInDefaults, ...loadedConfig };
35
55
 
36
- export { baseDir, dirs, defaultConfig, loadConfig };
56
+ 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 };