@daz4126/swifty 3.0.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 +4 -2
- package/package.json +1 -1
- package/src/assets.js +173 -28
- package/src/build.js +11 -0
- package/src/config.js +8 -0
- package/src/init.js +8 -1
- package/src/layout.js +11 -6
- 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,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
|
```
|
package/package.json
CHANGED
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,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
|
+
}
|
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,
|
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,129 @@
|
|
|
1
|
+
const protectBlocks = (content, pattern) => {
|
|
2
|
+
const blocks = [];
|
|
3
|
+
const protectedContent = content.replace(pattern, (match) => {
|
|
4
|
+
const token = `__SWIFTY_MINIFY_BLOCK_${blocks.length}__`;
|
|
5
|
+
blocks.push(match);
|
|
6
|
+
return token;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
content: protectedContent,
|
|
11
|
+
restore: (value) =>
|
|
12
|
+
value.replace(/__SWIFTY_MINIFY_BLOCK_(\d+)__/g, (_, index) => blocks[index]),
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const protectCssValues = (css) => {
|
|
17
|
+
const blocks = [];
|
|
18
|
+
let content = "";
|
|
19
|
+
|
|
20
|
+
const protect = (start, end) => {
|
|
21
|
+
const token = `__SWIFTY_MINIFY_BLOCK_${blocks.length}__`;
|
|
22
|
+
blocks.push(css.slice(start, end));
|
|
23
|
+
content += token;
|
|
24
|
+
return end;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const readStringEnd = (start) => {
|
|
28
|
+
const quote = css[start];
|
|
29
|
+
let index = start + 1;
|
|
30
|
+
|
|
31
|
+
while (index < css.length) {
|
|
32
|
+
if (css[index] === "\\") {
|
|
33
|
+
index += 2;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (css[index] === quote) return index + 1;
|
|
37
|
+
index += 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return css.length;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const readUrlEnd = (start) => {
|
|
44
|
+
let index = start + 4;
|
|
45
|
+
|
|
46
|
+
while (index < css.length) {
|
|
47
|
+
const char = css[index];
|
|
48
|
+
if (char === "\"" || char === "'") {
|
|
49
|
+
index = readStringEnd(index);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (char === "\\") {
|
|
53
|
+
index += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (char === ")") return index + 1;
|
|
57
|
+
index += 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return css.length;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (let index = 0; index < css.length; ) {
|
|
64
|
+
const char = css[index];
|
|
65
|
+
const isUrl = css.slice(index, index + 4).toLowerCase() === "url(";
|
|
66
|
+
|
|
67
|
+
if (char === "/" && css[index + 1] === "*") {
|
|
68
|
+
const commentEnd = css.indexOf("*/", index + 2);
|
|
69
|
+
const end = commentEnd === -1 ? css.length : commentEnd + 2;
|
|
70
|
+
content += css.slice(index, end);
|
|
71
|
+
index = end;
|
|
72
|
+
} else if (char === "\"" || char === "'") {
|
|
73
|
+
index = protect(index, readStringEnd(index));
|
|
74
|
+
} else if (isUrl) {
|
|
75
|
+
index = protect(index, readUrlEnd(index));
|
|
76
|
+
} else {
|
|
77
|
+
content += char;
|
|
78
|
+
index += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
content,
|
|
84
|
+
restore: (value) =>
|
|
85
|
+
value.replace(/__SWIFTY_MINIFY_BLOCK_(\d+)__/g, (_, index) => blocks[index]),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const minifyCss = (css) => {
|
|
90
|
+
const values = protectCssValues(css);
|
|
91
|
+
|
|
92
|
+
const minified = values.content
|
|
93
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
94
|
+
.replace(/\s+/g, " ")
|
|
95
|
+
.replace(/\s*([{}:;,])\s*/g, "$1")
|
|
96
|
+
.replace(/;}/g, "}")
|
|
97
|
+
.trim();
|
|
98
|
+
|
|
99
|
+
return values.restore(minified);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const minifyJs = (js) => {
|
|
103
|
+
const strings = protectBlocks(js, /(["'`])(?:\\[\s\S]|(?!\1)[^\\])*\1/g);
|
|
104
|
+
|
|
105
|
+
const minified = strings.content
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((line) => line.trim())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join("\n");
|
|
110
|
+
|
|
111
|
+
return strings.restore(minified);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const minifyHtml = (html) => {
|
|
115
|
+
const blocks = protectBlocks(
|
|
116
|
+
html,
|
|
117
|
+
/<(pre|code|textarea|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const minified = blocks.content
|
|
121
|
+
.replace(/<!--(?!\[if|<!|>)[\s\S]*?-->/g, "")
|
|
122
|
+
.replace(/\s{2,}/g, " ")
|
|
123
|
+
.replace(/>\s+</g, "><")
|
|
124
|
+
.trim();
|
|
125
|
+
|
|
126
|
+
return blocks.restore(minified);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
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) => {
|
|
@@ -47,6 +50,8 @@ const pageIndexUrls = new Set();
|
|
|
47
50
|
const partialExtensions = [".md", ".html"];
|
|
48
51
|
|
|
49
52
|
const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
|
|
53
|
+
const minificationEnabled = () =>
|
|
54
|
+
defaultConfig.minify !== false && defaultConfig.minify_html !== false;
|
|
50
55
|
|
|
51
56
|
const addToTagMap = (tag, page) => {
|
|
52
57
|
if (!tagsMap.has(tag)) tagsMap.set(tag, []);
|
|
@@ -78,6 +83,7 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
78
83
|
|
|
79
84
|
const root = file.name === "index.md" && !parent;
|
|
80
85
|
const relativePath = path.relative(baseDir, filePath).replace(/\\/g, "/");
|
|
86
|
+
const notFound = relativePath === "404.md" && !parent;
|
|
81
87
|
const finalPath = `/${relativePath.replace(/\.md$/, "")}`;
|
|
82
88
|
const name = root
|
|
83
89
|
? "Home"
|
|
@@ -99,11 +105,12 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
99
105
|
filePath,
|
|
100
106
|
filename: file.name.replace(/\.md$/, ""),
|
|
101
107
|
url: root ? "/" : finalPath,
|
|
102
|
-
nav: !parent && !root,
|
|
108
|
+
nav: !parent && !root && !notFound,
|
|
103
109
|
parent: parent
|
|
104
110
|
? { title: parent.meta.title, url: parent.url }
|
|
105
111
|
: undefined,
|
|
106
112
|
folder: isDirectory,
|
|
113
|
+
notFound,
|
|
107
114
|
title: name,
|
|
108
115
|
createdAtObj: stats.birthtime,
|
|
109
116
|
updatedAtObj: stats.mtime,
|
|
@@ -126,6 +133,9 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
126
133
|
const markdownContent = await fs.readFile(filePath, "utf-8");
|
|
127
134
|
const { data, content } = matter(markdownContent);
|
|
128
135
|
Object.assign(page, { meta: { ...page.meta, ...data }, content });
|
|
136
|
+
if (notFound && data.sitemap === undefined) {
|
|
137
|
+
page.meta.sitemap = false;
|
|
138
|
+
}
|
|
129
139
|
page.title = page.meta.title || page.title;
|
|
130
140
|
page.name = page.meta.title || page.name;
|
|
131
141
|
|
|
@@ -342,9 +352,12 @@ const createPages = async (pages, distDir = dirs.dist) => {
|
|
|
342
352
|
await mapLimit(
|
|
343
353
|
pages,
|
|
344
354
|
async (page) => {
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
355
|
+
const renderedHtml = await render(page);
|
|
356
|
+
const html = minificationEnabled() ? minifyHtml(renderedHtml) : renderedHtml;
|
|
357
|
+
const pageDir = page.notFound ? distDir : path.join(distDir, page.url);
|
|
358
|
+
const pagePath = page.notFound
|
|
359
|
+
? path.join(distDir, "404.html")
|
|
360
|
+
: path.join(distDir, page.url, "index.html");
|
|
348
361
|
await fsExtra.ensureDir(pageDir);
|
|
349
362
|
await fs.writeFile(pagePath, html);
|
|
350
363
|
if (page.folder) {
|
|
@@ -360,8 +373,9 @@ const createPages = async (pages, distDir = dirs.dist) => {
|
|
|
360
373
|
};
|
|
361
374
|
|
|
362
375
|
const addLinks = async (pages, parent) => {
|
|
376
|
+
const linkablePages = pages.filter((p) => !p.notFound);
|
|
363
377
|
// Filter out folders and index pages for prev/next calculation (only content pages)
|
|
364
|
-
const contentPages =
|
|
378
|
+
const contentPages = linkablePages.filter((p) => !p.folder && p.filename !== 'index');
|
|
365
379
|
|
|
366
380
|
await mapLimit(
|
|
367
381
|
pages,
|
|
@@ -388,7 +402,7 @@ const addLinks = async (pages, parent) => {
|
|
|
388
402
|
const linkClass = defaultConfig.prev_next_class || defaultConfig.link_class || '';
|
|
389
403
|
const classAttr = linkClass ? ` class="${linkClass}"` : '';
|
|
390
404
|
|
|
391
|
-
if (!page.folder && page.filename !== 'index') {
|
|
405
|
+
if (!page.folder && page.filename !== 'index' && !page.notFound) {
|
|
392
406
|
const pageIndex = contentPages.indexOf(page);
|
|
393
407
|
const prevSibling = pageIndex > 0 ? contentPages[pageIndex - 1] : null;
|
|
394
408
|
const nextSibling = pageIndex < contentPages.length - 1 ? contentPages[pageIndex + 1] : null;
|
|
@@ -414,9 +428,9 @@ const addLinks = async (pages, parent) => {
|
|
|
414
428
|
: Promise.resolve(""),
|
|
415
429
|
generateLinkList(
|
|
416
430
|
page.parent?.filename || "pages",
|
|
417
|
-
|
|
431
|
+
linkablePages.filter((p) => p.url !== page.url),
|
|
418
432
|
),
|
|
419
|
-
generateLinkList(page.parent?.filename || "pages",
|
|
433
|
+
generateLinkList(page.parent?.filename || "pages", linkablePages),
|
|
420
434
|
generateLinkList("nav", pageIndex.filter((p) => p.nav)),
|
|
421
435
|
]);
|
|
422
436
|
|
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) => {
|