@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 +5 -3
- package/package.json +1 -1
- package/src/assets.js +200 -25
- package/src/build.js +11 -0
- package/src/client/IDIOMORPH-LICENSE.txt +13 -0
- package/src/client/idiomorph.esm.js +1385 -0
- package/src/client/swifty-navigation.js +399 -0
- package/src/config.js +22 -0
- package/src/init.js +11 -1
- package/src/layout.js +20 -10
- package/src/markdown.js +20 -0
- package/src/minify.js +129 -0
- package/src/pages.js +27 -13
- package/src/partials.js +50 -5
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
|
-
- **
|
|
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
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
|
|
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
|
|
59
|
-
.resize(
|
|
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
|
-
|
|
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
|
|
278
|
+
const optimized = await optimizeImageToWebp(
|
|
279
|
+
filePath,
|
|
121
280
|
imagesFolder,
|
|
122
|
-
|
|
281
|
+
file,
|
|
282
|
+
ext,
|
|
123
283
|
);
|
|
124
|
-
|
|
125
|
-
const optimized = await optimizeImageToWebp(filePath, optimizedPath);
|
|
126
284
|
if (optimized) {
|
|
127
|
-
console.log(`Optimized ${file}
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
374
|
+
const optimized = await optimizeImageToWebp(
|
|
375
|
+
filePath,
|
|
376
|
+
imagesFolder,
|
|
377
|
+
filename,
|
|
378
|
+
ext,
|
|
379
|
+
);
|
|
216
380
|
if (optimized) {
|
|
217
|
-
console.log(`Optimized ${filename}
|
|
381
|
+
console.log(`Optimized ${filename}`);
|
|
218
382
|
}
|
|
219
383
|
return true;
|
|
220
384
|
};
|
|
221
385
|
|
|
222
|
-
export {
|
|
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.
|