@daz4126/swifty 3.2.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 +60 -0
- package/README.md +5 -0
- package/package.json +11 -7
- package/src/assets.js +124 -29
- package/src/build.js +8 -1
- package/src/cli.js +7 -4
- package/src/config.js +87 -5
- package/src/data.js +26 -21
- package/src/frontmatter.js +27 -0
- package/src/index.js +7 -0
- package/src/init.js +4 -0
- package/src/layout.js +8 -1
- package/src/minify.js +33 -127
- package/src/pages.js +69 -28
- package/src/partials.js +10 -6
- package/src/rss.js +4 -3
- package/src/server.js +90 -0
- package/src/sitemap.js +4 -1
- package/src/urls.js +116 -0
- package/src/watcher.js +1 -0
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
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daz4126/swifty",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
"swifty": "./src/cli.js"
|
|
11
11
|
},
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
13
|
+
"node": ">=22"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
-
"src/"
|
|
16
|
+
"src/",
|
|
17
|
+
"MIGRATION.md"
|
|
17
18
|
],
|
|
18
19
|
"scripts": {
|
|
19
20
|
"test": "mocha",
|
|
21
|
+
"test:package": "node scripts/verify-package.js",
|
|
22
|
+
"check": "npm test && npm run test:package",
|
|
20
23
|
"build": "node src/build.js",
|
|
21
|
-
"start": "node src/
|
|
24
|
+
"start": "node src/cli.js start",
|
|
22
25
|
"init": "node src/init.js",
|
|
23
26
|
"watch": "node src/watcher.js"
|
|
24
27
|
},
|
|
@@ -32,16 +35,17 @@
|
|
|
32
35
|
"description": "Super Speedy Static Site Generator",
|
|
33
36
|
"dependencies": {
|
|
34
37
|
"chokidar": "^4.0.0",
|
|
38
|
+
"clean-css": "^5.3.3",
|
|
35
39
|
"eta": "^4.5.0",
|
|
36
40
|
"fs-extra": "^11.2.0",
|
|
37
|
-
"gray-matter": "^4.0.3",
|
|
38
41
|
"highlight.js": "^11.11.1",
|
|
42
|
+
"html-minifier-terser": "^7.2.0",
|
|
39
43
|
"js-yaml": "^4.1.0",
|
|
40
44
|
"livereload": "^0.10.3",
|
|
41
45
|
"marked": "^14.1.3",
|
|
42
46
|
"marked-highlight": "^2.2.1",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
47
|
+
"sharp": "^0.33.5",
|
|
48
|
+
"terser": "^5.48.0"
|
|
45
49
|
},
|
|
46
50
|
"devDependencies": {
|
|
47
51
|
"mocha": "^11.1.0"
|
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) =>
|
|
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 (
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
@@ -18,8 +18,15 @@ const prepareOutputDirectory = async (outputDir) => {
|
|
|
18
18
|
const outputPath = path.resolve(baseDir, outputDir);
|
|
19
19
|
const outputContainsProject =
|
|
20
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
|
+
);
|
|
21
28
|
|
|
22
|
-
if (outputContainsProject) {
|
|
29
|
+
if (outputContainsProject || outputOverlapsSource) {
|
|
23
30
|
throw new Error(`Refusing to empty unsafe output directory: ${outputPath}`);
|
|
24
31
|
}
|
|
25
32
|
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { execFileSync,
|
|
3
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
|
|
6
6
|
const args = process.argv.slice(2);
|
|
@@ -92,8 +92,8 @@ async function main() {
|
|
|
92
92
|
watcher.default(outDir);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
const server = await import("./server.js");
|
|
96
|
+
await server.default(outDir);
|
|
97
97
|
break;
|
|
98
98
|
}
|
|
99
99
|
case "watch": {
|
|
@@ -139,7 +139,10 @@ async function main() {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
142
|
-
main()
|
|
142
|
+
main().catch((error) => {
|
|
143
|
+
console.error(`Swifty failed: ${error.message}`);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
});
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
export { commitAndPushOutput, main };
|
package/src/config.js
CHANGED
|
@@ -17,24 +17,105 @@ const dirs = {
|
|
|
17
17
|
js: path.join(baseDir, "js"),
|
|
18
18
|
partials: path.join(baseDir, "partials"),
|
|
19
19
|
data: path.join(baseDir, "data"),
|
|
20
|
+
public: path.join(baseDir, "public"),
|
|
21
|
+
cache: path.join(baseDir, ".swifty-cache"),
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
async function loadConfig(dir) {
|
|
23
25
|
const configFiles = ['config.yaml', 'config.yml', 'config.json'];
|
|
24
26
|
for (const file of configFiles) {
|
|
25
27
|
const filePath = path.join(dir, file);
|
|
28
|
+
let content;
|
|
29
|
+
|
|
26
30
|
try {
|
|
27
|
-
await fs.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === "ENOENT") continue;
|
|
34
|
+
throw new Error(`Unable to read config ${filePath}: ${error.message}`, {
|
|
35
|
+
cause: error,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const config = file.endsWith('.json') ? JSON.parse(content) : yaml.load(content);
|
|
41
|
+
return validateConfig(config || {}, filePath);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`Unable to parse config ${filePath}: ${error.message}`, {
|
|
44
|
+
cause: error,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
31
47
|
}
|
|
32
48
|
return {};
|
|
33
49
|
}
|
|
34
50
|
|
|
51
|
+
const validateConfig = (config, filePath = "config") => {
|
|
52
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
53
|
+
throw new TypeError(`${filePath} must contain a configuration object`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const booleans = [
|
|
57
|
+
"minify",
|
|
58
|
+
"minify_html",
|
|
59
|
+
"minify_css",
|
|
60
|
+
"minify_js",
|
|
61
|
+
"morphing",
|
|
62
|
+
"prefetching",
|
|
63
|
+
];
|
|
64
|
+
const positiveNumbers = [
|
|
65
|
+
"build_concurrency",
|
|
66
|
+
"image_quality",
|
|
67
|
+
"livereload_port",
|
|
68
|
+
"max_image_width",
|
|
69
|
+
"server_port",
|
|
70
|
+
"watcher_delay",
|
|
71
|
+
"watcher_interval",
|
|
72
|
+
"words_per_minute",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const key of booleans) {
|
|
76
|
+
if (config[key] !== undefined && typeof config[key] !== "boolean") {
|
|
77
|
+
throw new TypeError(`${key} in ${filePath} must be a boolean`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const key of positiveNumbers) {
|
|
81
|
+
if (
|
|
82
|
+
config[key] !== undefined &&
|
|
83
|
+
(!Number.isFinite(config[key]) || config[key] <= 0)
|
|
84
|
+
) {
|
|
85
|
+
throw new TypeError(`${key} in ${filePath} must be a positive number`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
config.base_path !== undefined &&
|
|
90
|
+
(typeof config.base_path !== "string" ||
|
|
91
|
+
/^(?:[a-z]+:)?\/\//i.test(config.base_path) ||
|
|
92
|
+
config.base_path.includes("..") ||
|
|
93
|
+
/[?#]/.test(config.base_path))
|
|
94
|
+
) {
|
|
95
|
+
throw new TypeError(`base_path in ${filePath} must be a URL path`);
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
config.responsive_image_widths !== undefined &&
|
|
99
|
+
(!Array.isArray(config.responsive_image_widths) ||
|
|
100
|
+
config.responsive_image_widths.some(
|
|
101
|
+
(width) => !Number.isInteger(width) || width <= 0,
|
|
102
|
+
))
|
|
103
|
+
) {
|
|
104
|
+
throw new TypeError(
|
|
105
|
+
`responsive_image_widths in ${filePath} must contain positive integers`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (config.rss_feeds !== undefined && !Array.isArray(config.rss_feeds)) {
|
|
109
|
+
throw new TypeError(`rss_feeds in ${filePath} must be an array`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return config;
|
|
113
|
+
};
|
|
114
|
+
|
|
35
115
|
// Hardcoded defaults (used if not specified in config file)
|
|
36
116
|
const builtInDefaults = {
|
|
37
117
|
default_layout_name: 'default',
|
|
118
|
+
base_path: '',
|
|
38
119
|
// Reading time calculation
|
|
39
120
|
words_per_minute: 200,
|
|
40
121
|
// Image optimization settings
|
|
@@ -49,6 +130,7 @@ const builtInDefaults = {
|
|
|
49
130
|
minify_css: true,
|
|
50
131
|
minify_js: true,
|
|
51
132
|
// LiveReload and watcher settings
|
|
133
|
+
server_port: 3000,
|
|
52
134
|
livereload_port: 35729,
|
|
53
135
|
watcher_delay: 100,
|
|
54
136
|
watcher_interval: 500,
|
|
@@ -87,4 +169,4 @@ const reloadConfig = async () => {
|
|
|
87
169
|
|
|
88
170
|
await reloadConfig();
|
|
89
171
|
|
|
90
|
-
export { baseDir, dirs, defaultConfig, loadConfig, reloadConfig };
|
|
172
|
+
export { baseDir, dirs, defaultConfig, loadConfig, reloadConfig, validateConfig };
|
package/src/data.js
CHANGED
|
@@ -24,36 +24,41 @@ async function loadData() {
|
|
|
24
24
|
return data;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
let files;
|
|
27
28
|
try {
|
|
28
|
-
|
|
29
|
+
files = await fs.readdir(dirs.data);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(`Unable to read data directory ${dirs.data}: ${error.message}`, {
|
|
32
|
+
cause: error,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const filePath = path.join(dirs.data, file);
|
|
38
|
+
const stat = await fs.stat(filePath);
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
// Skip directories
|
|
41
|
+
if (stat.isDirectory()) continue;
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
const ext = path.extname(file).toLowerCase();
|
|
44
|
+
const name = path.basename(file, ext);
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// Only process JSON and YAML files
|
|
47
|
+
if (!['.json', '.yaml', '.yml'].includes(ext)) continue;
|
|
42
48
|
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
try {
|
|
50
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
} catch (error) {
|
|
52
|
-
console.warn(`Error loading data file ${file}: ${error.message}`);
|
|
52
|
+
if (ext === '.json') {
|
|
53
|
+
data[name] = JSON.parse(content);
|
|
54
|
+
} else {
|
|
55
|
+
data[name] = yaml.load(content);
|
|
53
56
|
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new Error(`Unable to parse data file ${filePath}: ${error.message}`, {
|
|
59
|
+
cause: error,
|
|
60
|
+
});
|
|
54
61
|
}
|
|
55
|
-
} catch (error) {
|
|
56
|
-
console.warn(`Error reading data directory: ${error.message}`);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
dataCache = data;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
|
|
3
|
+
const frontMatterPattern =
|
|
4
|
+
/^---[ \t]*\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m;
|
|
5
|
+
|
|
6
|
+
const parseFrontMatter = (source) => {
|
|
7
|
+
if (!/^---[ \t]*(?:\r?\n|$)/.test(source)) {
|
|
8
|
+
return { data: {}, content: source };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const match = source.match(frontMatterPattern);
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error("Front matter is missing a closing --- delimiter");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const data = yaml.load(match[1]) || {};
|
|
17
|
+
if (typeof data !== "object" || Array.isArray(data)) {
|
|
18
|
+
throw new TypeError("Front matter must contain a YAML object");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
data,
|
|
23
|
+
content: source.slice(match[0].length),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export { parseFrontMatter };
|