@daz4126/swifty 3.0.0 → 3.2.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 +6 -4
- package/package.json +11 -5
- package/src/assets.js +173 -28
- package/src/build.js +44 -6
- package/src/cli.js +54 -16
- package/src/config.js +29 -9
- package/src/index.js +3 -0
- package/src/init.js +8 -1
- package/src/layout.js +11 -6
- package/src/markdown.js +20 -0
- package/src/minify.js +138 -0
- package/src/pages.js +85 -34
- package/src/partials.js +50 -5
- package/src/watcher.js +15 -2
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,6 +20,7 @@ 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
|
|
@@ -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
|
```
|
|
@@ -57,14 +59,14 @@ npx swifty my-site # Create new site in my-site/ folder
|
|
|
57
59
|
npx swifty build # Build static site to dist/ (for production)
|
|
58
60
|
npx swifty start # Build, watch, and serve at localhost:3000 (for development)
|
|
59
61
|
npx swifty build --out dir # Build to custom output directory
|
|
60
|
-
npx swifty deploy "message" # Build,
|
|
62
|
+
npx swifty deploy "message" # Build, commit the output folder, and push
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
### Development vs Production
|
|
64
66
|
|
|
65
67
|
- **`swifty start`** - For development. Includes live reload (auto-refreshes browser on file changes) and file watching with incremental builds for CSS/JS/images.
|
|
66
68
|
- **`swifty build`** - For production deployment. Produces clean output without any development scripts.
|
|
67
|
-
- **`swifty deploy "message"`** - Builds the site and
|
|
69
|
+
- **`swifty deploy "message"`** - Builds the site, commits only the generated output folder, and pushes it to git.
|
|
68
70
|
|
|
69
71
|
## Documentation
|
|
70
72
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daz4126/swifty",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"main": "index.js",
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"main": "./src/index.js",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js"
|
|
8
|
+
},
|
|
6
9
|
"bin": {
|
|
7
10
|
"swifty": "./src/cli.js"
|
|
8
11
|
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
9
15
|
"files": [
|
|
10
16
|
"src/"
|
|
11
17
|
],
|
|
@@ -45,10 +51,10 @@
|
|
|
45
51
|
},
|
|
46
52
|
"repository": {
|
|
47
53
|
"type": "git",
|
|
48
|
-
"url": "git+https://github.com/
|
|
54
|
+
"url": "git+https://github.com/daz-codes/swifty.git"
|
|
49
55
|
},
|
|
50
56
|
"bugs": {
|
|
51
|
-
"url": "https://github.com/
|
|
57
|
+
"url": "https://github.com/daz-codes/swifty/issues"
|
|
52
58
|
},
|
|
53
|
-
"homepage": "https://github.com/
|
|
59
|
+
"homepage": "https://github.com/daz-codes/swifty#readme"
|
|
54
60
|
}
|
package/src/assets.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
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 sharp from "sharp";
|
|
5
4
|
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
import fsExtra from "fs-extra";
|
|
7
|
+
import sharp from "sharp";
|
|
8
|
+
|
|
6
9
|
import { dirs, defaultConfig } from "./config.js";
|
|
7
10
|
import { mapLimit } from "./concurrency.js";
|
|
11
|
+
import { minifyCss, minifyJs } from "./minify.js";
|
|
8
12
|
|
|
9
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
14
|
const __dirname = path.dirname(__filename);
|
|
@@ -23,9 +27,72 @@ const validExtensions = {
|
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
const optimizableImageExtensions = [".jpg", ".jpeg", ".png"];
|
|
30
|
+
const responsiveImageMap = new Map();
|
|
26
31
|
|
|
27
32
|
const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
|
|
28
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
|
+
};
|
|
29
96
|
|
|
30
97
|
const isDestinationFresh = async (source, destination) => {
|
|
31
98
|
try {
|
|
@@ -48,27 +115,76 @@ const copyIfStale = async (source, destination) => {
|
|
|
48
115
|
return true;
|
|
49
116
|
};
|
|
50
117
|
|
|
51
|
-
const
|
|
118
|
+
const optimizeImageVariantToWebp = async (source, destination, width) => {
|
|
52
119
|
if (await isDestinationFresh(source, destination)) {
|
|
53
120
|
return false;
|
|
54
121
|
}
|
|
55
122
|
|
|
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
123
|
const imageQuality = defaultConfig.image_quality || 80;
|
|
61
|
-
const resizeOptions =
|
|
62
|
-
originalWidth > 0 ? { width: Math.min(originalWidth, maxWidth) } : {};
|
|
63
124
|
|
|
64
|
-
await
|
|
65
|
-
.resize(
|
|
125
|
+
await sharp(source)
|
|
126
|
+
.resize({ width })
|
|
66
127
|
.toFormat("webp", { quality: imageQuality })
|
|
67
128
|
.toFile(destination);
|
|
68
129
|
|
|
69
130
|
return true;
|
|
70
131
|
};
|
|
71
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
|
+
|
|
72
188
|
const ensureAndCopy = async (source, destination, validExts) => {
|
|
73
189
|
if (await fsExtra.pathExists(source)) {
|
|
74
190
|
await fsExtra.ensureDir(destination);
|
|
@@ -77,9 +193,15 @@ const ensureAndCopy = async (source, destination, validExts) => {
|
|
|
77
193
|
await Promise.all(
|
|
78
194
|
files
|
|
79
195
|
.filter((file) => validExts.includes(path.extname(file).toLowerCase()))
|
|
80
|
-
.map((file) =>
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}),
|
|
83
205
|
);
|
|
84
206
|
console.log(`Copied valid files from ${source} to ${destination}`);
|
|
85
207
|
} else {
|
|
@@ -99,10 +221,14 @@ const copyNavigationAssets = async (outputDir = dirs.dist) => {
|
|
|
99
221
|
files,
|
|
100
222
|
async (file) => {
|
|
101
223
|
const sourcePath = path.join(clientAssetsDir, file);
|
|
102
|
-
const
|
|
224
|
+
const destinationFilename =
|
|
225
|
+
file === "swifty-navigation.js"
|
|
226
|
+
? await getNavigationAssetName(file)
|
|
227
|
+
: file;
|
|
228
|
+
const destinationPath = path.join(destination, destinationFilename);
|
|
103
229
|
const copied = await copyIfStale(sourcePath, destinationPath);
|
|
104
230
|
if (copied) {
|
|
105
|
-
console.log(`Copied Swifty navigation asset ${
|
|
231
|
+
console.log(`Copied Swifty navigation asset ${destinationFilename}`);
|
|
106
232
|
}
|
|
107
233
|
},
|
|
108
234
|
getBuildConcurrency(),
|
|
@@ -119,6 +245,8 @@ const copyAssets = async (outputDir = dirs.dist) => {
|
|
|
119
245
|
await copyNavigationAssets(outputDir);
|
|
120
246
|
};
|
|
121
247
|
async function optimizeImages(outputDir = dirs.dist) {
|
|
248
|
+
responsiveImageMap.clear();
|
|
249
|
+
|
|
122
250
|
try {
|
|
123
251
|
if (!(await fsExtra.pathExists(dirs.images))) {
|
|
124
252
|
console.log(`No ${path.basename(dirs.images)} found in ${dirs.images}`);
|
|
@@ -147,14 +275,14 @@ async function optimizeImages(outputDir = dirs.dist) {
|
|
|
147
275
|
return;
|
|
148
276
|
}
|
|
149
277
|
|
|
150
|
-
const
|
|
278
|
+
const optimized = await optimizeImageToWebp(
|
|
279
|
+
filePath,
|
|
151
280
|
imagesFolder,
|
|
152
|
-
|
|
281
|
+
file,
|
|
282
|
+
ext,
|
|
153
283
|
);
|
|
154
|
-
|
|
155
|
-
const optimized = await optimizeImageToWebp(filePath, optimizedPath);
|
|
156
284
|
if (optimized) {
|
|
157
|
-
console.log(`Optimized ${file}
|
|
285
|
+
console.log(`Optimized ${file}`);
|
|
158
286
|
}
|
|
159
287
|
},
|
|
160
288
|
getBuildConcurrency(),
|
|
@@ -217,8 +345,11 @@ const copySingleAsset = async (filePath, outputDir = dirs.dist) => {
|
|
|
217
345
|
return false;
|
|
218
346
|
}
|
|
219
347
|
|
|
220
|
-
await
|
|
221
|
-
|
|
348
|
+
await processTextAsset(
|
|
349
|
+
filePath,
|
|
350
|
+
path.join(destDir, filename),
|
|
351
|
+
validExtensions.css.includes(ext) ? "css" : "js",
|
|
352
|
+
);
|
|
222
353
|
console.log(`Copied ${filename}`);
|
|
223
354
|
return true;
|
|
224
355
|
};
|
|
@@ -240,13 +371,27 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
|
|
|
240
371
|
return true;
|
|
241
372
|
}
|
|
242
373
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
374
|
+
const optimized = await optimizeImageToWebp(
|
|
375
|
+
filePath,
|
|
376
|
+
imagesFolder,
|
|
377
|
+
filename,
|
|
378
|
+
ext,
|
|
379
|
+
);
|
|
246
380
|
if (optimized) {
|
|
247
|
-
console.log(`Optimized ${filename}
|
|
381
|
+
console.log(`Optimized ${filename}`);
|
|
248
382
|
}
|
|
249
383
|
return true;
|
|
250
384
|
};
|
|
251
385
|
|
|
252
|
-
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,20 +1,47 @@
|
|
|
1
|
+
import { argv } from "process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
import fsExtra from "fs-extra";
|
|
6
|
+
|
|
1
7
|
import { copyAssets, optimizeImages } from "./assets.js";
|
|
2
8
|
import { generatePages, createPages, addLinks } from "./pages.js";
|
|
3
9
|
import { generateRssFeeds } from "./rss.js";
|
|
4
10
|
import { generateSeoFiles } from "./sitemap.js";
|
|
5
|
-
import { dirs } from "./config.js";
|
|
11
|
+
import { baseDir, dirs } from "./config.js";
|
|
12
|
+
import { clearDataCache } from "./data.js";
|
|
13
|
+
import { resetCaches } from "./layout.js";
|
|
14
|
+
import { clearCache as clearPartialCache } from "./partials.js";
|
|
15
|
+
|
|
16
|
+
const prepareOutputDirectory = async (outputDir) => {
|
|
17
|
+
const projectPath = path.resolve(baseDir);
|
|
18
|
+
const outputPath = path.resolve(baseDir, outputDir);
|
|
19
|
+
const outputContainsProject =
|
|
20
|
+
projectPath === outputPath || projectPath.startsWith(`${outputPath}${path.sep}`);
|
|
21
|
+
|
|
22
|
+
if (outputContainsProject) {
|
|
23
|
+
throw new Error(`Refusing to empty unsafe output directory: ${outputPath}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await fsExtra.emptyDir(outputPath);
|
|
27
|
+
return outputPath;
|
|
28
|
+
};
|
|
6
29
|
|
|
7
30
|
export default async function build(outputDir = dirs.dist) {
|
|
8
31
|
const startTime = performance.now();
|
|
9
32
|
console.log("🚀 Starting build...");
|
|
33
|
+
const resolvedOutputDir = await prepareOutputDirectory(outputDir);
|
|
34
|
+
clearDataCache();
|
|
35
|
+
clearPartialCache();
|
|
10
36
|
|
|
11
37
|
const copyStart = performance.now();
|
|
12
|
-
await copyAssets(
|
|
38
|
+
await copyAssets(resolvedOutputDir);
|
|
13
39
|
const copyTime = performance.now() - copyStart;
|
|
14
40
|
console.log(`📁 Assets copied in ${(copyTime / 1000).toFixed(2)}s`);
|
|
41
|
+
await resetCaches();
|
|
15
42
|
|
|
16
43
|
const imageStart = performance.now();
|
|
17
|
-
await optimizeImages(
|
|
44
|
+
await optimizeImages(resolvedOutputDir);
|
|
18
45
|
const imageTime = performance.now() - imageStart;
|
|
19
46
|
console.log(`🖼️ Images optimized in ${(imageTime / 1000).toFixed(2)}s`);
|
|
20
47
|
|
|
@@ -29,17 +56,17 @@ export default async function build(outputDir = dirs.dist) {
|
|
|
29
56
|
console.log(`🔗 Links added in ${(linksTime / 1000).toFixed(2)}s`);
|
|
30
57
|
|
|
31
58
|
const createStart = performance.now();
|
|
32
|
-
await createPages(pages,
|
|
59
|
+
await createPages(pages, resolvedOutputDir);
|
|
33
60
|
const createTime = performance.now() - createStart;
|
|
34
61
|
console.log(`✨ Pages created in ${(createTime / 1000).toFixed(2)}s`);
|
|
35
62
|
|
|
36
63
|
const rssStart = performance.now();
|
|
37
|
-
await generateRssFeeds(pages,
|
|
64
|
+
await generateRssFeeds(pages, resolvedOutputDir);
|
|
38
65
|
const rssTime = performance.now() - rssStart;
|
|
39
66
|
console.log(`📡 RSS feeds generated in ${(rssTime / 1000).toFixed(2)}s`);
|
|
40
67
|
|
|
41
68
|
const seoStart = performance.now();
|
|
42
|
-
await generateSeoFiles(pages,
|
|
69
|
+
await generateSeoFiles(pages, resolvedOutputDir);
|
|
43
70
|
const seoTime = performance.now() - seoStart;
|
|
44
71
|
console.log(`🧭 SEO files generated in ${(seoTime / 1000).toFixed(2)}s`);
|
|
45
72
|
|
|
@@ -67,3 +94,14 @@ export default async function build(outputDir = dirs.dist) {
|
|
|
67
94
|
process.stdout.write('\n');
|
|
68
95
|
}
|
|
69
96
|
}
|
|
97
|
+
|
|
98
|
+
export { prepareOutputDirectory };
|
|
99
|
+
|
|
100
|
+
// Run the build when invoked directly (e.g. `npm run build`, `npm start`),
|
|
101
|
+
// not when imported as a module (e.g. by cli.js).
|
|
102
|
+
if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
|
|
103
|
+
build().catch((error) => {
|
|
104
|
+
console.error("Build failed:", error);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
|
107
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { spawn,
|
|
3
|
+
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
4
5
|
|
|
5
6
|
const args = process.argv.slice(2);
|
|
6
7
|
let outDir = "dist"; // default
|
|
@@ -14,7 +15,46 @@ if (outIndex !== -1 && args[outIndex + 1]) {
|
|
|
14
15
|
// Pass outDir as an environment variable too (optional, still useful)
|
|
15
16
|
process.env.OUT_DIR = outDir;
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
+
const commitAndPushOutput = (
|
|
19
|
+
outputDir,
|
|
20
|
+
commitMessage,
|
|
21
|
+
{ execFile = execFileSync, spawnFile = spawnSync } = {},
|
|
22
|
+
) => {
|
|
23
|
+
const existingDiff = spawnFile("git", ["diff", "--cached", "--quiet"], {
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
});
|
|
26
|
+
if (existingDiff.error) throw existingDiff.error;
|
|
27
|
+
if (existingDiff.status === 1) {
|
|
28
|
+
throw new Error("Refusing to deploy while unrelated changes are already staged.");
|
|
29
|
+
}
|
|
30
|
+
if (existingDiff.status !== 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Unable to inspect staged changes (git exited ${existingDiff.status}).`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`Adding generated output from ${outputDir} to git...`);
|
|
37
|
+
execFile("git", ["add", "--", outputDir], { stdio: "inherit" });
|
|
38
|
+
|
|
39
|
+
const diffResult = spawnFile("git", ["diff", "--cached", "--quiet"], {
|
|
40
|
+
stdio: "ignore",
|
|
41
|
+
});
|
|
42
|
+
if (diffResult.error) throw diffResult.error;
|
|
43
|
+
if (diffResult.status === 0) {
|
|
44
|
+
console.log("No generated output changes to deploy.");
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (diffResult.status !== 1) {
|
|
48
|
+
throw new Error(`Unable to inspect staged changes (git exited ${diffResult.status}).`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log("Committing generated output...");
|
|
52
|
+
execFile("git", ["commit", "-m", commitMessage], { stdio: "inherit" });
|
|
53
|
+
|
|
54
|
+
console.log("Pushing to remote...");
|
|
55
|
+
execFile("git", ["push"], { stdio: "inherit" });
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
18
58
|
|
|
19
59
|
async function main() {
|
|
20
60
|
const command = args[0];
|
|
@@ -64,7 +104,11 @@ async function main() {
|
|
|
64
104
|
break;
|
|
65
105
|
}
|
|
66
106
|
case "deploy": {
|
|
67
|
-
const
|
|
107
|
+
const commandArgs = args.slice(1).filter((arg, index, values) => {
|
|
108
|
+
if (arg === "--out") return false;
|
|
109
|
+
return index === 0 || values[index - 1] !== "--out";
|
|
110
|
+
});
|
|
111
|
+
const commitMessage = commandArgs[0] || "Deploying latest build";
|
|
68
112
|
|
|
69
113
|
// Build the site
|
|
70
114
|
const build = await import("./build.js");
|
|
@@ -74,18 +118,8 @@ async function main() {
|
|
|
74
118
|
|
|
75
119
|
// Git operations
|
|
76
120
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
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!");
|
|
121
|
+
const deployed = commitAndPushOutput(outDir, commitMessage);
|
|
122
|
+
if (deployed) console.log("Deployed successfully!");
|
|
89
123
|
} catch (error) {
|
|
90
124
|
console.error("Deploy failed:", error.message);
|
|
91
125
|
process.exit(1);
|
|
@@ -104,4 +138,8 @@ async function main() {
|
|
|
104
138
|
}
|
|
105
139
|
}
|
|
106
140
|
|
|
107
|
-
|
|
141
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
142
|
+
main();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { commitAndPushOutput, main };
|
package/src/config.js
CHANGED
|
@@ -40,6 +40,14 @@ const builtInDefaults = {
|
|
|
40
40
|
// Image optimization settings
|
|
41
41
|
max_image_width: 800,
|
|
42
42
|
image_quality: 80,
|
|
43
|
+
responsive_image_widths: [320, 640, 800],
|
|
44
|
+
responsive_image_sizes: '100vw',
|
|
45
|
+
default_og_image: '',
|
|
46
|
+
// Output minification
|
|
47
|
+
minify: true,
|
|
48
|
+
minify_html: true,
|
|
49
|
+
minify_css: true,
|
|
50
|
+
minify_js: true,
|
|
43
51
|
// LiveReload and watcher settings
|
|
44
52
|
livereload_port: 35729,
|
|
45
53
|
watcher_delay: 100,
|
|
@@ -56,15 +64,27 @@ const builtInDefaults = {
|
|
|
56
64
|
navigation_cache_ttl: 15,
|
|
57
65
|
};
|
|
58
66
|
|
|
59
|
-
const
|
|
60
|
-
const hasConfig = (key) => Object.prototype.hasOwnProperty.call(loadedConfig, key);
|
|
67
|
+
const defaultConfig = {};
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
loadedConfig
|
|
69
|
+
const reloadConfig = async () => {
|
|
70
|
+
const loadedConfig = await loadConfig(baseDir);
|
|
71
|
+
const hasConfig = (key) =>
|
|
72
|
+
Object.prototype.hasOwnProperty.call(loadedConfig, key);
|
|
73
|
+
|
|
74
|
+
if (hasConfig('turbo')) {
|
|
75
|
+
console.warn('The "turbo" config option is deprecated. Use "morphing" and "prefetching" instead.');
|
|
76
|
+
if (!hasConfig('morphing')) {
|
|
77
|
+
loadedConfig.morphing = loadedConfig.turbo;
|
|
78
|
+
}
|
|
66
79
|
}
|
|
67
|
-
}
|
|
68
|
-
const defaultConfig = { ...builtInDefaults, ...loadedConfig };
|
|
69
80
|
|
|
70
|
-
|
|
81
|
+
for (const key of Object.keys(defaultConfig)) {
|
|
82
|
+
delete defaultConfig[key];
|
|
83
|
+
}
|
|
84
|
+
Object.assign(defaultConfig, builtInDefaults, loadedConfig);
|
|
85
|
+
return defaultConfig;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await reloadConfig();
|
|
89
|
+
|
|
90
|
+
export { baseDir, dirs, defaultConfig, loadConfig, reloadConfig };
|
package/src/index.js
ADDED
package/src/init.js
CHANGED
|
@@ -19,7 +19,14 @@ link_class: swifty_link
|
|
|
19
19
|
tag_class: swifty_tag
|
|
20
20
|
default_layout_name: default
|
|
21
21
|
default_link_name: links
|
|
22
|
-
|
|
22
|
+
max_image_width: 800
|
|
23
|
+
responsive_image_widths:
|
|
24
|
+
- 320
|
|
25
|
+
- 640
|
|
26
|
+
- 800
|
|
27
|
+
responsive_image_sizes: 100vw
|
|
28
|
+
default_og_image: ""
|
|
29
|
+
minify: true
|
|
23
30
|
morphing: true
|
|
24
31
|
prefetching: true
|
|
25
32
|
morph_target: main
|
package/src/layout.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 { dirs, baseDir, defaultConfig } from "./config.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getCssImports,
|
|
7
|
+
getJsImports,
|
|
8
|
+
getCssPreloads,
|
|
9
|
+
getJsPreloads,
|
|
10
|
+
getNavigationScriptSrc,
|
|
11
|
+
} from "./assets.js";
|
|
6
12
|
|
|
7
13
|
const layoutCache = new Map();
|
|
8
14
|
let template = null;
|
|
@@ -14,8 +20,6 @@ const escapeAttr = (value) =>
|
|
|
14
20
|
.replace(/</g, "<")
|
|
15
21
|
.replace(/>/g, ">");
|
|
16
22
|
|
|
17
|
-
const navigationEnabled = () => defaultConfig.morphing !== false;
|
|
18
|
-
|
|
19
23
|
const getLayout = async (layoutName) => {
|
|
20
24
|
if (!layoutName) return null;
|
|
21
25
|
if (!layoutCache.has(layoutName)) {
|
|
@@ -41,8 +45,9 @@ const createTemplate = async () => {
|
|
|
41
45
|
'<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">',
|
|
42
46
|
].join('\n');
|
|
43
47
|
|
|
44
|
-
const
|
|
45
|
-
|
|
48
|
+
const navigationScriptSrc = await getNavigationScriptSrc();
|
|
49
|
+
const navigationScript = navigationScriptSrc
|
|
50
|
+
? `<script type="module" src="${navigationScriptSrc}" data-swifty-navigation data-target="${escapeAttr(defaultConfig.morph_target || "main")}" data-prefetching="${defaultConfig.prefetching === false ? "off" : "intent"}" data-cache-size="${escapeAttr(defaultConfig.navigation_cache_size || 20)}" data-cache-ttl="${escapeAttr(defaultConfig.navigation_cache_ttl || 15)}"></script>`
|
|
46
51
|
: '';
|
|
47
52
|
const livereloadScript = process.env.SWIFTY_WATCH
|
|
48
53
|
? `<script>document.write('<script src="http://' + (location.host || 'localhost').split(':')[0] + ':${defaultConfig.livereload_port || 35729}/livereload.js?snipver=1"></' + 'script>')</script>`
|
|
@@ -56,7 +61,7 @@ const createTemplate = async () => {
|
|
|
56
61
|
const css = await getCssImports();
|
|
57
62
|
const js = await getJsImports();
|
|
58
63
|
const imports = css + js;
|
|
59
|
-
const highlightCSS = `<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.
|
|
64
|
+
const highlightCSS = `<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/monokai-sublime.min.css">`;
|
|
60
65
|
|
|
61
66
|
// Order: preconnect hints -> preloads -> actual assets -> scripts
|
|
62
67
|
const template = templateContent.replace('</head>', `${preconnectHints}\n${preloads}\n${navigationScript}\n${highlightCSS}\n${imports}\n${livereloadScript}\n</head>`);
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// markdown.js
|
|
2
|
+
// Central marked instance with syntax highlighting wired up via highlight.js.
|
|
3
|
+
// Importing marked from here (instead of "marked" directly) guarantees the
|
|
4
|
+
// highlight extension is configured before any markdown is parsed.
|
|
5
|
+
import { marked } from "marked";
|
|
6
|
+
import { markedHighlight } from "marked-highlight";
|
|
7
|
+
import hljs from "highlight.js";
|
|
8
|
+
|
|
9
|
+
marked.use(
|
|
10
|
+
markedHighlight({
|
|
11
|
+
emptyLangClass: "hljs",
|
|
12
|
+
langPrefix: "hljs language-",
|
|
13
|
+
highlight(code, lang) {
|
|
14
|
+
const language = hljs.getLanguage(lang) ? lang : "plaintext";
|
|
15
|
+
return hljs.highlight(code, { language }).value;
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export { marked };
|
package/src/minify.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
let protectionSequence = 0;
|
|
2
|
+
|
|
3
|
+
const protectBlocks = (content, pattern) => {
|
|
4
|
+
const blocks = [];
|
|
5
|
+
const tokenPrefix = `__SWIFTY_MINIFY_BLOCK_${protectionSequence++}_`;
|
|
6
|
+
const protectedContent = content.replace(pattern, (match) => {
|
|
7
|
+
const token = `${tokenPrefix}${blocks.length}__`;
|
|
8
|
+
blocks.push(match);
|
|
9
|
+
return token;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
content: protectedContent,
|
|
14
|
+
restore: (value) =>
|
|
15
|
+
value.replace(
|
|
16
|
+
new RegExp(`${tokenPrefix}(\\d+)__`, "g"),
|
|
17
|
+
(_, index) => blocks[index],
|
|
18
|
+
),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const protectCssValues = (css) => {
|
|
23
|
+
const blocks = [];
|
|
24
|
+
let content = "";
|
|
25
|
+
|
|
26
|
+
const protect = (start, end) => {
|
|
27
|
+
const token = `__SWIFTY_MINIFY_BLOCK_${blocks.length}__`;
|
|
28
|
+
blocks.push(css.slice(start, end));
|
|
29
|
+
content += token;
|
|
30
|
+
return end;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readStringEnd = (start) => {
|
|
34
|
+
const quote = css[start];
|
|
35
|
+
let index = start + 1;
|
|
36
|
+
|
|
37
|
+
while (index < css.length) {
|
|
38
|
+
if (css[index] === "\\") {
|
|
39
|
+
index += 2;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (css[index] === quote) return index + 1;
|
|
43
|
+
index += 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return css.length;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const readUrlEnd = (start) => {
|
|
50
|
+
let index = start + 4;
|
|
51
|
+
|
|
52
|
+
while (index < css.length) {
|
|
53
|
+
const char = css[index];
|
|
54
|
+
if (char === "\"" || char === "'") {
|
|
55
|
+
index = readStringEnd(index);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === "\\") {
|
|
59
|
+
index += 2;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (char === ")") return index + 1;
|
|
63
|
+
index += 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return css.length;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < css.length; ) {
|
|
70
|
+
const char = css[index];
|
|
71
|
+
const isUrl = css.slice(index, index + 4).toLowerCase() === "url(";
|
|
72
|
+
|
|
73
|
+
if (char === "/" && css[index + 1] === "*") {
|
|
74
|
+
const commentEnd = css.indexOf("*/", index + 2);
|
|
75
|
+
const end = commentEnd === -1 ? css.length : commentEnd + 2;
|
|
76
|
+
content += css.slice(index, end);
|
|
77
|
+
index = end;
|
|
78
|
+
} else if (char === "\"" || char === "'") {
|
|
79
|
+
index = protect(index, readStringEnd(index));
|
|
80
|
+
} else if (isUrl) {
|
|
81
|
+
index = protect(index, readUrlEnd(index));
|
|
82
|
+
} else {
|
|
83
|
+
content += char;
|
|
84
|
+
index += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
content,
|
|
90
|
+
restore: (value) =>
|
|
91
|
+
value.replace(/__SWIFTY_MINIFY_BLOCK_(\d+)__/g, (_, index) => blocks[index]),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const minifyCss = (css) => {
|
|
96
|
+
const values = protectCssValues(css);
|
|
97
|
+
|
|
98
|
+
const minified = values.content
|
|
99
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
100
|
+
.replace(/\s+/g, " ")
|
|
101
|
+
.replace(/\s*([{}:;,])\s*/g, "$1")
|
|
102
|
+
.replace(/;}/g, "}")
|
|
103
|
+
.trim();
|
|
104
|
+
|
|
105
|
+
return values.restore(minified);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const minifyJs = (js) => {
|
|
109
|
+
const strings = protectBlocks(js, /(["'`])(?:\\[\s\S]|(?!\1)[^\\])*\1/g);
|
|
110
|
+
|
|
111
|
+
const minified = strings.content
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((line) => line.trim())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join("\n");
|
|
116
|
+
|
|
117
|
+
return strings.restore(minified);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const minifyHtml = (html) => {
|
|
121
|
+
const blocks = protectBlocks(
|
|
122
|
+
html,
|
|
123
|
+
/<(pre|code|textarea|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi,
|
|
124
|
+
);
|
|
125
|
+
const strings = protectBlocks(
|
|
126
|
+
blocks.content,
|
|
127
|
+
/(["'])(?:\\[\s\S]|(?!\1)[^\\])*\1/g,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const minified = strings.content
|
|
131
|
+
.replace(/<!--(?!\[if|<!|>)[\s\S]*?-->/g, "")
|
|
132
|
+
.replace(/\s+/g, " ")
|
|
133
|
+
.trim();
|
|
134
|
+
|
|
135
|
+
return blocks.restore(strings.restore(minified));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export { minifyCss, minifyHtml, minifyJs };
|
package/src/pages.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// pages.js
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
import fsExtra from "fs-extra";
|
|
6
|
+
import matter from "gray-matter";
|
|
7
|
+
|
|
2
8
|
import { replacePlaceholders } from "./partials.js";
|
|
3
9
|
import { dirs, defaultConfig, loadConfig } from "./config.js";
|
|
4
10
|
import { getTemplate, applyLayoutAndWrapContent } from "./layout.js";
|
|
5
11
|
import { chunkPages, generatePaginationNav } from "./pagination.js";
|
|
6
|
-
import { marked } from "
|
|
7
|
-
import matter from "gray-matter";
|
|
8
|
-
import fs from "fs/promises";
|
|
9
|
-
import fsExtra from "fs-extra";
|
|
10
|
-
import path from "path";
|
|
12
|
+
import { marked } from "./markdown.js";
|
|
11
13
|
import { mapLimit } from "./concurrency.js";
|
|
14
|
+
import { minifyHtml } from "./minify.js";
|
|
12
15
|
|
|
13
16
|
// Returns stats if valid (directory or .md file), null otherwise
|
|
14
17
|
const getValidStats = async (filePath) => {
|
|
@@ -41,12 +44,62 @@ const parseDate = (dateValue) => {
|
|
|
41
44
|
return isNaN(parsed.getTime()) ? null : parsed;
|
|
42
45
|
};
|
|
43
46
|
|
|
47
|
+
const applyMarkdownFileToPage = async (
|
|
48
|
+
page,
|
|
49
|
+
filePath,
|
|
50
|
+
{ notFound = false, folderIndex = false } = {},
|
|
51
|
+
) => {
|
|
52
|
+
const markdownContent = await fs.readFile(filePath, "utf-8");
|
|
53
|
+
const { data, content } = matter(markdownContent);
|
|
54
|
+
Object.assign(page, { meta: { ...page.meta, ...data }, content });
|
|
55
|
+
|
|
56
|
+
if (folderIndex) {
|
|
57
|
+
const stats = await fs.stat(filePath);
|
|
58
|
+
const { dateFormat } = page.meta;
|
|
59
|
+
page.hasIndexContent = true;
|
|
60
|
+
page.indexFilePath = filePath;
|
|
61
|
+
page.createdAtObj = stats.birthtime;
|
|
62
|
+
page.updatedAtObj = stats.mtime;
|
|
63
|
+
page.created_at = new Date(stats.birthtime).toLocaleDateString(
|
|
64
|
+
undefined,
|
|
65
|
+
dateFormat,
|
|
66
|
+
);
|
|
67
|
+
page.updated_at = new Date(stats.mtime).toLocaleDateString(
|
|
68
|
+
undefined,
|
|
69
|
+
dateFormat,
|
|
70
|
+
);
|
|
71
|
+
page.date = new Date(stats.birthtime).toLocaleDateString(undefined, dateFormat);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (notFound && data.sitemap === undefined) {
|
|
75
|
+
page.meta.sitemap = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
page.title = page.meta.title || page.title;
|
|
79
|
+
page.name = page.meta.title || page.name;
|
|
80
|
+
|
|
81
|
+
if (typeof data.nav === "boolean") {
|
|
82
|
+
page.nav = data.nav;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (data.date) {
|
|
86
|
+
const parsedDate = parseDate(data.date);
|
|
87
|
+
if (parsedDate) {
|
|
88
|
+
page.dateObj = parsedDate;
|
|
89
|
+
page.date = parsedDate.toLocaleDateString(undefined, page.meta.dateFormat);
|
|
90
|
+
page.meta.date = page.date;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
44
95
|
const tagsMap = new Map();
|
|
45
96
|
const pageIndex = [];
|
|
46
97
|
const pageIndexUrls = new Set();
|
|
47
98
|
const partialExtensions = [".md", ".html"];
|
|
48
99
|
|
|
49
100
|
const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
|
|
101
|
+
const minificationEnabled = () =>
|
|
102
|
+
defaultConfig.minify !== false && defaultConfig.minify_html !== false;
|
|
50
103
|
|
|
51
104
|
const addToTagMap = (tag, page) => {
|
|
52
105
|
if (!tagsMap.has(tag)) tagsMap.set(tag, []);
|
|
@@ -73,11 +126,14 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
73
126
|
files,
|
|
74
127
|
async (file) => {
|
|
75
128
|
const filePath = path.join(sourceDir, file.name);
|
|
129
|
+
if (parent && file.name === "index.md") return null;
|
|
130
|
+
|
|
76
131
|
const stats = await getValidStats(filePath);
|
|
77
132
|
if (!stats) return null;
|
|
78
133
|
|
|
79
134
|
const root = file.name === "index.md" && !parent;
|
|
80
135
|
const relativePath = path.relative(baseDir, filePath).replace(/\\/g, "/");
|
|
136
|
+
const notFound = relativePath === "404.md" && !parent;
|
|
81
137
|
const finalPath = `/${relativePath.replace(/\.md$/, "")}`;
|
|
82
138
|
const name = root
|
|
83
139
|
? "Home"
|
|
@@ -99,11 +155,12 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
99
155
|
filePath,
|
|
100
156
|
filename: file.name.replace(/\.md$/, ""),
|
|
101
157
|
url: root ? "/" : finalPath,
|
|
102
|
-
nav: !parent && !root,
|
|
158
|
+
nav: !parent && !root && !notFound,
|
|
103
159
|
parent: parent
|
|
104
160
|
? { title: parent.meta.title, url: parent.url }
|
|
105
161
|
: undefined,
|
|
106
162
|
folder: isDirectory,
|
|
163
|
+
notFound,
|
|
107
164
|
title: name,
|
|
108
165
|
createdAtObj: stats.birthtime,
|
|
109
166
|
updatedAtObj: stats.mtime,
|
|
@@ -123,25 +180,11 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
123
180
|
};
|
|
124
181
|
|
|
125
182
|
if (path.extname(file.name) === ".md") {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// Allow front matter to override nav (opt in or opt out)
|
|
133
|
-
if (typeof data.nav === "boolean") {
|
|
134
|
-
page.nav = data.nav;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// If front matter has a date, parse and format it
|
|
138
|
-
if (data.date) {
|
|
139
|
-
const parsedDate = parseDate(data.date);
|
|
140
|
-
if (parsedDate) {
|
|
141
|
-
page.dateObj = parsedDate;
|
|
142
|
-
page.date = parsedDate.toLocaleDateString(undefined, dateFormat);
|
|
143
|
-
page.meta.date = page.date;
|
|
144
|
-
}
|
|
183
|
+
await applyMarkdownFileToPage(page, filePath, { notFound });
|
|
184
|
+
} else if (isDirectory) {
|
|
185
|
+
const indexPath = path.join(filePath, "index.md");
|
|
186
|
+
if (await fsExtra.pathExists(indexPath)) {
|
|
187
|
+
await applyMarkdownFileToPage(page, indexPath, { folderIndex: true });
|
|
145
188
|
}
|
|
146
189
|
}
|
|
147
190
|
|
|
@@ -198,7 +241,9 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
198
241
|
page.visiblePages = chunks[0];
|
|
199
242
|
|
|
200
243
|
// First page content
|
|
201
|
-
|
|
244
|
+
if (!page.hasIndexContent) {
|
|
245
|
+
page.content = await generateLinkList(page.filename, chunks[0]);
|
|
246
|
+
}
|
|
202
247
|
page.meta.pagination = generatePaginationNav({
|
|
203
248
|
currentPage: 1,
|
|
204
249
|
totalPages,
|
|
@@ -232,7 +277,9 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
232
277
|
page.paginatedPages.push(paginatedPage);
|
|
233
278
|
}
|
|
234
279
|
} else {
|
|
235
|
-
|
|
280
|
+
if (!page.hasIndexContent) {
|
|
281
|
+
page.content = await generateLinkList(page.filename, page.pages);
|
|
282
|
+
}
|
|
236
283
|
page.meta.pagination = '';
|
|
237
284
|
}
|
|
238
285
|
}
|
|
@@ -342,9 +389,12 @@ const createPages = async (pages, distDir = dirs.dist) => {
|
|
|
342
389
|
await mapLimit(
|
|
343
390
|
pages,
|
|
344
391
|
async (page) => {
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
392
|
+
const renderedHtml = await render(page);
|
|
393
|
+
const html = minificationEnabled() ? minifyHtml(renderedHtml) : renderedHtml;
|
|
394
|
+
const pageDir = page.notFound ? distDir : path.join(distDir, page.url);
|
|
395
|
+
const pagePath = page.notFound
|
|
396
|
+
? path.join(distDir, "404.html")
|
|
397
|
+
: path.join(distDir, page.url, "index.html");
|
|
348
398
|
await fsExtra.ensureDir(pageDir);
|
|
349
399
|
await fs.writeFile(pagePath, html);
|
|
350
400
|
if (page.folder) {
|
|
@@ -360,8 +410,9 @@ const createPages = async (pages, distDir = dirs.dist) => {
|
|
|
360
410
|
};
|
|
361
411
|
|
|
362
412
|
const addLinks = async (pages, parent) => {
|
|
413
|
+
const linkablePages = pages.filter((p) => !p.notFound);
|
|
363
414
|
// Filter out folders and index pages for prev/next calculation (only content pages)
|
|
364
|
-
const contentPages =
|
|
415
|
+
const contentPages = linkablePages.filter((p) => !p.folder && p.filename !== 'index');
|
|
365
416
|
|
|
366
417
|
await mapLimit(
|
|
367
418
|
pages,
|
|
@@ -388,7 +439,7 @@ const addLinks = async (pages, parent) => {
|
|
|
388
439
|
const linkClass = defaultConfig.prev_next_class || defaultConfig.link_class || '';
|
|
389
440
|
const classAttr = linkClass ? ` class="${linkClass}"` : '';
|
|
390
441
|
|
|
391
|
-
if (!page.folder && page.filename !== 'index') {
|
|
442
|
+
if (!page.folder && page.filename !== 'index' && !page.notFound) {
|
|
392
443
|
const pageIndex = contentPages.indexOf(page);
|
|
393
444
|
const prevSibling = pageIndex > 0 ? contentPages[pageIndex - 1] : null;
|
|
394
445
|
const nextSibling = pageIndex < contentPages.length - 1 ? contentPages[pageIndex + 1] : null;
|
|
@@ -414,9 +465,9 @@ const addLinks = async (pages, parent) => {
|
|
|
414
465
|
: Promise.resolve(""),
|
|
415
466
|
generateLinkList(
|
|
416
467
|
page.parent?.filename || "pages",
|
|
417
|
-
|
|
468
|
+
linkablePages.filter((p) => p.url !== page.url),
|
|
418
469
|
),
|
|
419
|
-
generateLinkList(page.parent?.filename || "pages",
|
|
470
|
+
generateLinkList(page.parent?.filename || "pages", linkablePages),
|
|
420
471
|
generateLinkList("nav", pageIndex.filter((p) => p.nav)),
|
|
421
472
|
]);
|
|
422
473
|
|
package/src/partials.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
-
import fsExtra from "fs-extra";
|
|
3
2
|
import path from "path";
|
|
4
|
-
|
|
5
|
-
import
|
|
3
|
+
|
|
4
|
+
import fsExtra from "fs-extra";
|
|
6
5
|
import { Eta } from "eta";
|
|
6
|
+
|
|
7
|
+
import { dirs, defaultConfig } from "./config.js";
|
|
8
|
+
import { marked } from "./markdown.js";
|
|
7
9
|
import { loadData } from "./data.js";
|
|
10
|
+
import { getResponsiveImage } from "./assets.js";
|
|
8
11
|
|
|
9
12
|
const partialCache = new Map();
|
|
10
13
|
const imageExtensionRegex = /\.(png|jpe?g|webp)(?=([?#]|$))/i;
|
|
@@ -51,7 +54,15 @@ const generateOgTags = (values) => {
|
|
|
51
54
|
const description = meta.description || meta.summary || '';
|
|
52
55
|
const url = values.url || '';
|
|
53
56
|
const siteUrl = (meta.site_url || values.site_url || '').replace(/\/$/, '');
|
|
54
|
-
|
|
57
|
+
// Rewrite local image paths to .webp so they match the optimized output on disk
|
|
58
|
+
const image = rewriteImageUrl(
|
|
59
|
+
meta.image ||
|
|
60
|
+
meta.og_image ||
|
|
61
|
+
meta.default_og_image ||
|
|
62
|
+
values.default_og_image ||
|
|
63
|
+
defaultConfig.default_og_image ||
|
|
64
|
+
'',
|
|
65
|
+
);
|
|
55
66
|
const type = meta.og_type || (values.folder ? 'website' : 'article');
|
|
56
67
|
const author = meta.author || values.author || '';
|
|
57
68
|
|
|
@@ -148,6 +159,36 @@ const rewriteSrcset = (srcset) =>
|
|
|
148
159
|
})
|
|
149
160
|
.join(", ");
|
|
150
161
|
|
|
162
|
+
const getAttributeValue = (tag, name) => {
|
|
163
|
+
const match = tag.match(new RegExp(`\\s${name}=(["'])(.*?)\\1`, "i"));
|
|
164
|
+
return match ? match[2] : "";
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const hasAttribute = (tag, name) =>
|
|
168
|
+
new RegExp(`\\s${name}(?:=|\\s|>|/)`, "i").test(tag);
|
|
169
|
+
|
|
170
|
+
const appendAttribute = (tag, name, value) =>
|
|
171
|
+
tag.replace(/\s*\/?>$/, (ending) => {
|
|
172
|
+
const close = ending.includes("/") ? " />" : ">";
|
|
173
|
+
return ` ${name}="${escapeAttr(value)}"${close}`;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const addResponsiveImageAttributes = (tag) => {
|
|
177
|
+
const imageUrl = getAttributeValue(tag, "src");
|
|
178
|
+
const responsiveImage = getResponsiveImage(imageUrl);
|
|
179
|
+
|
|
180
|
+
if (!responsiveImage || responsiveImage.variants.length < 2) return tag;
|
|
181
|
+
|
|
182
|
+
let nextTag = tag;
|
|
183
|
+
if (!hasAttribute(nextTag, "srcset")) {
|
|
184
|
+
nextTag = appendAttribute(nextTag, "srcset", responsiveImage.srcset);
|
|
185
|
+
}
|
|
186
|
+
if (!hasAttribute(nextTag, "sizes")) {
|
|
187
|
+
nextTag = appendAttribute(nextTag, "sizes", responsiveImage.sizes);
|
|
188
|
+
}
|
|
189
|
+
return nextTag;
|
|
190
|
+
};
|
|
191
|
+
|
|
151
192
|
const rewriteLocalImageReferences = (html) =>
|
|
152
193
|
html.replace(/<([a-z][\w:-]*)(\s[^<>]*?)?>/gi, (tag, tagName) => {
|
|
153
194
|
const normalizedTagName = tagName.toLowerCase();
|
|
@@ -155,7 +196,7 @@ const rewriteLocalImageReferences = (html) =>
|
|
|
155
196
|
return tag;
|
|
156
197
|
}
|
|
157
198
|
|
|
158
|
-
|
|
199
|
+
const rewrittenTag = tag.replace(
|
|
159
200
|
/\s(src|href|srcset)=(["'])(.*?)\2/gi,
|
|
160
201
|
(attribute, attributeName, quote, value) => {
|
|
161
202
|
const nextValue =
|
|
@@ -165,6 +206,10 @@ const rewriteLocalImageReferences = (html) =>
|
|
|
165
206
|
return ` ${attributeName}=${quote}${nextValue}${quote}`;
|
|
166
207
|
},
|
|
167
208
|
);
|
|
209
|
+
|
|
210
|
+
return normalizedTagName === "img"
|
|
211
|
+
? addResponsiveImageAttributes(rewrittenTag)
|
|
212
|
+
: rewrittenTag;
|
|
168
213
|
});
|
|
169
214
|
|
|
170
215
|
const replacePlaceholders = async (template, values) => {
|
package/src/watcher.js
CHANGED
|
@@ -2,7 +2,7 @@ import chokidar from "chokidar";
|
|
|
2
2
|
import livereload from "livereload";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import fs from "fs";
|
|
5
|
-
import { defaultConfig } from "./config.js";
|
|
5
|
+
import { defaultConfig, reloadConfig } from "./config.js";
|
|
6
6
|
|
|
7
7
|
// Directory to extension mapping for filtering
|
|
8
8
|
const dirExtensions = {
|
|
@@ -80,6 +80,14 @@ function getChangeType(filePath) {
|
|
|
80
80
|
return "full"; // pages, layouts, partials, config files
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
const isRootConfigFile = (filePath) => {
|
|
84
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
85
|
+
return (
|
|
86
|
+
!relativePath.includes(path.sep) &&
|
|
87
|
+
["config.yaml", "config.yml", "config.json"].includes(relativePath)
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
83
91
|
export default async function watch(outDir = "dist") {
|
|
84
92
|
const build = await import("./build.js");
|
|
85
93
|
const { copySingleAsset, optimizeSingleImage } = await import("./assets.js");
|
|
@@ -94,12 +102,13 @@ export default async function watch(outDir = "dist") {
|
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
// Start livereload server
|
|
105
|
+
const livereloadPort = defaultConfig.livereload_port || 35729;
|
|
97
106
|
const lrServer = livereload.createServer({
|
|
107
|
+
port: livereloadPort,
|
|
98
108
|
usePolling: true,
|
|
99
109
|
delay: defaultConfig.watcher_delay || 100,
|
|
100
110
|
});
|
|
101
111
|
const outPath = path.join(process.cwd(), outDir);
|
|
102
|
-
const livereloadPort = defaultConfig.livereload_port || 35729;
|
|
103
112
|
lrServer.watch(outPath);
|
|
104
113
|
console.log(`LiveReload server started on port ${livereloadPort}`);
|
|
105
114
|
|
|
@@ -128,6 +137,10 @@ export default async function watch(outDir = "dist") {
|
|
|
128
137
|
const filename = path.basename(filePath);
|
|
129
138
|
|
|
130
139
|
try {
|
|
140
|
+
if (isRootConfigFile(filePath)) {
|
|
141
|
+
await reloadConfig();
|
|
142
|
+
}
|
|
143
|
+
|
|
131
144
|
if (event === "deleted") {
|
|
132
145
|
// For deletions, do a full rebuild to clean up
|
|
133
146
|
console.log(`🗑️ File deleted: ${filename}. Running full build...`);
|