@daz4126/swifty 2.12.0 → 3.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/README.md CHANGED
@@ -7,7 +7,8 @@ Swifty uses convention over configuration to make it super simple to build blazi
7
7
  ## Features
8
8
 
9
9
  - **Markdown pages** with YAML front matter
10
- - **Automatic image optimization** to WebP
10
+ - **Automatic image optimization** to responsive WebP images with `srcset`
11
+ - **HTML, CSS, and JS minification** during production builds
11
12
  - **Layouts and partials** for reusable templates
12
13
  - **Auto-injected CSS/JS** from your css/ and js/ folders
13
14
  - **Code syntax highlighting** via highlight.js
@@ -19,10 +20,11 @@ Swifty uses convention over configuration to make it super simple to build blazi
19
20
  - **Pagination** for folders with many pages
20
21
  - **Data files** - Load JSON/YAML data and use in templates
21
22
  - **Open Graph tags** - Auto-generated social sharing meta tags
23
+ - **404 page convention** - `pages/404.md` builds to `dist/404.html`
22
24
  - **Word count & reading time** - Auto-calculated for blog posts
23
25
  - **Previous/next navigation** - Auto-generated links between sibling pages
24
26
  - **[Eta templating](https://eta.js.org/)** - Full JavaScript in templates with EJS syntax
25
- - **Optional [Turbo](https://turbo.hotwired.dev/)** for SPA-like transitions
27
+ - **Idiomorph navigation** with optional intent prefetching for SPA-like transitions
26
28
 
27
29
  ## Quickstart
28
30
 
@@ -45,7 +47,7 @@ your-site/
45
47
  ├── data/ # JSON/YAML data files
46
48
  ├── css/ # Stylesheets (auto-injected)
47
49
  ├── js/ # JavaScript (auto-injected)
48
- ├── images/ # Images (auto-optimized to WebP)
50
+ ├── images/ # Images (auto-optimized to responsive WebP)
49
51
  ├── template.html # Base HTML template
50
52
  └── config.yaml # Site configuration
51
53
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/swifty",
3
- "version": "2.12.0",
3
+ "version": "3.1.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assets.js CHANGED
@@ -1,9 +1,18 @@
1
+ import crypto from "crypto";
1
2
  import fs from "fs/promises";
2
- import fsExtra from "fs-extra";
3
3
  import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ import fsExtra from "fs-extra";
4
7
  import sharp from "sharp";
8
+
5
9
  import { dirs, defaultConfig } from "./config.js";
6
10
  import { mapLimit } from "./concurrency.js";
11
+ import { minifyCss, minifyJs } from "./minify.js";
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+ const clientAssetsDir = path.join(__dirname, "client");
7
16
 
8
17
  // Get file modification timestamp for cache busting
9
18
  const getFileMtime = async (filePath) => {
@@ -18,8 +27,72 @@ const validExtensions = {
18
27
  };
19
28
 
20
29
  const optimizableImageExtensions = [".jpg", ".jpeg", ".png"];
30
+ const responsiveImageMap = new Map();
21
31
 
22
32
  const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
33
+ const navigationEnabled = () => defaultConfig.morphing !== false;
34
+ const minificationEnabled = (type) =>
35
+ defaultConfig.minify !== false && defaultConfig[`minify_${type}`] !== false;
36
+ const normalizeImageUrl = (url) => (url || "").split(/[?#]/)[0];
37
+
38
+ const getResponsiveWidths = (originalWidth) => {
39
+ const maxWidth = defaultConfig.max_image_width || 800;
40
+ const configuredWidths = Array.isArray(defaultConfig.responsive_image_widths)
41
+ ? defaultConfig.responsive_image_widths
42
+ : [320, 640, maxWidth];
43
+ const outputWidth =
44
+ originalWidth > 0 ? Math.min(originalWidth, maxWidth) : maxWidth;
45
+ const widths = configuredWidths
46
+ .map((width) => Number(width))
47
+ .filter((width) => Number.isInteger(width) && width > 0)
48
+ .map((width) => Math.min(width, maxWidth));
49
+
50
+ widths.push(outputWidth);
51
+
52
+ return [...new Set(widths)]
53
+ .filter((width) => !originalWidth || width <= originalWidth)
54
+ .sort((a, b) => a - b);
55
+ };
56
+
57
+ const variantFilename = (filename, ext, width, outputWidth) =>
58
+ width === outputWidth
59
+ ? `${path.basename(filename, ext)}.webp`
60
+ : `${path.basename(filename, ext)}-${width}.webp`;
61
+
62
+ const registerResponsiveImage = (filename, ext, variants) => {
63
+ const basename = path.basename(filename, ext);
64
+ const entry = {
65
+ src: `/images/${basename}.webp`,
66
+ srcset: variants
67
+ .map((variant) => `${variant.url} ${variant.width}w`)
68
+ .join(", "),
69
+ sizes: defaultConfig.responsive_image_sizes || "100vw",
70
+ variants,
71
+ };
72
+
73
+ responsiveImageMap.set(normalizeImageUrl(`/images/${filename}`), entry);
74
+ responsiveImageMap.set(normalizeImageUrl(entry.src), entry);
75
+ };
76
+
77
+ const getResponsiveImage = (url) =>
78
+ responsiveImageMap.get(normalizeImageUrl(url)) || null;
79
+
80
+ const getNavigationAssetName = async (filename = "swifty-navigation.js") => {
81
+ const sourcePath = path.join(clientAssetsDir, filename);
82
+ const content = await fs.readFile(sourcePath);
83
+ const hash = crypto
84
+ .createHash("sha256")
85
+ .update(content)
86
+ .digest("hex")
87
+ .slice(0, 10);
88
+ const ext = path.extname(filename);
89
+ return `${path.basename(filename, ext)}.${hash}${ext}`;
90
+ };
91
+
92
+ const getNavigationScriptSrc = async () => {
93
+ if (!navigationEnabled()) return "";
94
+ return `/swifty/${await getNavigationAssetName()}`;
95
+ };
23
96
 
24
97
  const isDestinationFresh = async (source, destination) => {
25
98
  try {
@@ -42,27 +115,76 @@ const copyIfStale = async (source, destination) => {
42
115
  return true;
43
116
  };
44
117
 
45
- const optimizeImageToWebp = async (source, destination) => {
118
+ const optimizeImageVariantToWebp = async (source, destination, width) => {
46
119
  if (await isDestinationFresh(source, destination)) {
47
120
  return false;
48
121
  }
49
122
 
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
123
  const imageQuality = defaultConfig.image_quality || 80;
55
- const resizeOptions =
56
- originalWidth > 0 ? { width: Math.min(originalWidth, maxWidth) } : {};
57
124
 
58
- await image
59
- .resize(resizeOptions)
125
+ await sharp(source)
126
+ .resize({ width })
60
127
  .toFormat("webp", { quality: imageQuality })
61
128
  .toFile(destination);
62
129
 
63
130
  return true;
64
131
  };
65
132
 
133
+ const optimizeImageToWebp = async (source, imagesFolder, filename, ext) => {
134
+ const metadata = await sharp(source).metadata();
135
+ const originalWidth = metadata.width || 0;
136
+ const maxWidth = defaultConfig.max_image_width || 800;
137
+ const outputWidth =
138
+ originalWidth > 0 ? Math.min(originalWidth, maxWidth) : maxWidth;
139
+ const variants = getResponsiveWidths(originalWidth).map((width) => {
140
+ const file = variantFilename(filename, ext, width, outputWidth);
141
+ return {
142
+ width,
143
+ file,
144
+ url: `/images/${file}`,
145
+ destination: path.join(imagesFolder, file),
146
+ };
147
+ });
148
+
149
+ const results = await Promise.all(
150
+ variants.map((variant) =>
151
+ optimizeImageVariantToWebp(source, variant.destination, variant.width),
152
+ ),
153
+ );
154
+
155
+ registerResponsiveImage(
156
+ filename,
157
+ ext,
158
+ variants.map(({ width, file, url }) => ({ width, file, url })),
159
+ );
160
+
161
+ return results.some(Boolean);
162
+ };
163
+
164
+ const processTextAsset = async (source, destination, type) => {
165
+ 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;
172
+
173
+ try {
174
+ const currentContent = await fs.readFile(destination, "utf-8");
175
+ if (
176
+ currentContent === nextContent &&
177
+ (await isDestinationFresh(source, destination))
178
+ ) {
179
+ return false;
180
+ }
181
+ } catch (err) {}
182
+
183
+ await fsExtra.ensureDir(path.dirname(destination));
184
+ await fs.writeFile(destination, nextContent);
185
+ return true;
186
+ };
187
+
66
188
  const ensureAndCopy = async (source, destination, validExts) => {
67
189
  if (await fsExtra.pathExists(source)) {
68
190
  await fsExtra.ensureDir(destination);
@@ -71,15 +193,48 @@ const ensureAndCopy = async (source, destination, validExts) => {
71
193
  await Promise.all(
72
194
  files
73
195
  .filter((file) => validExts.includes(path.extname(file).toLowerCase()))
74
- .map((file) =>
75
- fsExtra.copy(path.join(source, file), path.join(destination, file)),
76
- ),
196
+ .map((file) => {
197
+ const ext = path.extname(file).toLowerCase();
198
+ const type = validExtensions.css.includes(ext) ? "css" : "js";
199
+ return processTextAsset(
200
+ path.join(source, file),
201
+ path.join(destination, file),
202
+ type,
203
+ );
204
+ }),
77
205
  );
78
206
  console.log(`Copied valid files from ${source} to ${destination}`);
79
207
  } else {
80
208
  console.log(`No ${path.basename(source)} found in ${source}`);
81
209
  }
82
210
  };
211
+
212
+ const copyNavigationAssets = async (outputDir = dirs.dist) => {
213
+ if (!navigationEnabled()) return;
214
+ if (!(await fsExtra.pathExists(clientAssetsDir))) return;
215
+
216
+ const destination = path.join(outputDir, "swifty");
217
+ await fsExtra.ensureDir(destination);
218
+
219
+ const files = await fs.readdir(clientAssetsDir);
220
+ await mapLimit(
221
+ files,
222
+ async (file) => {
223
+ const sourcePath = path.join(clientAssetsDir, file);
224
+ const destinationFilename =
225
+ file === "swifty-navigation.js"
226
+ ? await getNavigationAssetName(file)
227
+ : file;
228
+ const destinationPath = path.join(destination, destinationFilename);
229
+ const copied = await copyIfStale(sourcePath, destinationPath);
230
+ if (copied) {
231
+ console.log(`Copied Swifty navigation asset ${destinationFilename}`);
232
+ }
233
+ },
234
+ getBuildConcurrency(),
235
+ );
236
+ };
237
+
83
238
  const copyAssets = async (outputDir = dirs.dist) => {
84
239
  await ensureAndCopy(
85
240
  dirs.css,
@@ -87,8 +242,11 @@ const copyAssets = async (outputDir = dirs.dist) => {
87
242
  validExtensions.css,
88
243
  );
89
244
  await ensureAndCopy(dirs.js, path.join(outputDir, "js"), validExtensions.js);
245
+ await copyNavigationAssets(outputDir);
90
246
  };
91
247
  async function optimizeImages(outputDir = dirs.dist) {
248
+ responsiveImageMap.clear();
249
+
92
250
  try {
93
251
  if (!(await fsExtra.pathExists(dirs.images))) {
94
252
  console.log(`No ${path.basename(dirs.images)} found in ${dirs.images}`);
@@ -117,14 +275,14 @@ async function optimizeImages(outputDir = dirs.dist) {
117
275
  return;
118
276
  }
119
277
 
120
- const optimizedPath = path.join(
278
+ const optimized = await optimizeImageToWebp(
279
+ filePath,
121
280
  imagesFolder,
122
- `${path.basename(file, ext)}.webp`,
281
+ file,
282
+ ext,
123
283
  );
124
-
125
- const optimized = await optimizeImageToWebp(filePath, optimizedPath);
126
284
  if (optimized) {
127
- console.log(`Optimized ${file} -> ${optimizedPath}`);
285
+ console.log(`Optimized ${file}`);
128
286
  }
129
287
  },
130
288
  getBuildConcurrency(),
@@ -187,8 +345,11 @@ const copySingleAsset = async (filePath, outputDir = dirs.dist) => {
187
345
  return false;
188
346
  }
189
347
 
190
- await fsExtra.ensureDir(destDir);
191
- await fsExtra.copy(filePath, path.join(destDir, filename));
348
+ await processTextAsset(
349
+ filePath,
350
+ path.join(destDir, filename),
351
+ validExtensions.css.includes(ext) ? "css" : "js",
352
+ );
192
353
  console.log(`Copied ${filename}`);
193
354
  return true;
194
355
  };
@@ -210,13 +371,27 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
210
371
  return true;
211
372
  }
212
373
 
213
- // Optimize jpg/jpeg/png to webp
214
- const optimizedPath = path.join(imagesFolder, `${path.basename(filename, ext)}.webp`);
215
- const optimized = await optimizeImageToWebp(filePath, optimizedPath);
374
+ const optimized = await optimizeImageToWebp(
375
+ filePath,
376
+ imagesFolder,
377
+ filename,
378
+ ext,
379
+ );
216
380
  if (optimized) {
217
- console.log(`Optimized ${filename} -> ${path.basename(optimizedPath)}`);
381
+ console.log(`Optimized ${filename}`);
218
382
  }
219
383
  return true;
220
384
  };
221
385
 
222
- export { copyAssets, optimizeImages, getCssImports, getJsImports, getCssPreloads, getJsPreloads, copySingleAsset, optimizeSingleImage };
386
+ export {
387
+ copyAssets,
388
+ optimizeImages,
389
+ getCssImports,
390
+ getJsImports,
391
+ getCssPreloads,
392
+ getJsPreloads,
393
+ copySingleAsset,
394
+ optimizeSingleImage,
395
+ getNavigationScriptSrc,
396
+ getResponsiveImage,
397
+ };
package/src/build.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { argv } from "process";
2
+ import { fileURLToPath } from "url";
1
3
  import { copyAssets, optimizeImages } from "./assets.js";
2
4
  import { generatePages, createPages, addLinks } from "./pages.js";
3
5
  import { generateRssFeeds } from "./rss.js";
@@ -67,3 +69,12 @@ export default async function build(outputDir = dirs.dist) {
67
69
  process.stdout.write('\n');
68
70
  }
69
71
  }
72
+
73
+ // Run the build when invoked directly (e.g. `npm run build`, `npm start`),
74
+ // not when imported as a module (e.g. by cli.js).
75
+ if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
76
+ build().catch((error) => {
77
+ console.error("Build failed:", error);
78
+ process.exit(1);
79
+ });
80
+ }
@@ -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.