@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/swifty",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
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 optimizeImageToWebp = async (source, destination) => {
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 image
65
- .resize(resizeOptions)
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
- fsExtra.copy(path.join(source, file), path.join(destination, file)),
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 destinationPath = path.join(destination, file);
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 ${file}`);
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 optimizedPath = path.join(
278
+ const optimized = await optimizeImageToWebp(
279
+ filePath,
151
280
  imagesFolder,
152
- `${path.basename(file, ext)}.webp`,
281
+ file,
282
+ ext,
153
283
  );
154
-
155
- const optimized = await optimizeImageToWebp(filePath, optimizedPath);
156
284
  if (optimized) {
157
- console.log(`Optimized ${file} -> ${optimizedPath}`);
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 fsExtra.ensureDir(destDir);
221
- await fsExtra.copy(filePath, path.join(destDir, filename));
348
+ await processTextAsset(
349
+ filePath,
350
+ path.join(destDir, filename),
351
+ validExtensions.css.includes(ext) ? "css" : "js",
352
+ );
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
- // Optimize jpg/jpeg/png to webp
244
- const optimizedPath = path.join(imagesFolder, `${path.basename(filename, ext)}.webp`);
245
- const optimized = await optimizeImageToWebp(filePath, optimizedPath);
374
+ const optimized = await optimizeImageToWebp(
375
+ filePath,
376
+ imagesFolder,
377
+ filename,
378
+ ext,
379
+ );
246
380
  if (optimized) {
247
- console.log(`Optimized ${filename} -> ${path.basename(optimizedPath)}`);
381
+ console.log(`Optimized ${filename}`);
248
382
  }
249
383
  return true;
250
384
  };
251
385
 
252
- export { copyAssets, optimizeImages, getCssImports, getJsImports, getCssPreloads, getJsPreloads, copySingleAsset, optimizeSingleImage };
386
+ export {
387
+ copyAssets,
388
+ optimizeImages,
389
+ getCssImports,
390
+ getJsImports,
391
+ getCssPreloads,
392
+ getJsPreloads,
393
+ copySingleAsset,
394
+ optimizeSingleImage,
395
+ getNavigationScriptSrc,
396
+ getResponsiveImage,
397
+ };
package/src/build.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { argv } from "process";
2
+ import { fileURLToPath } from "url";
1
3
  import { copyAssets, optimizeImages } from "./assets.js";
2
4
  import { generatePages, createPages, addLinks } from "./pages.js";
3
5
  import { generateRssFeeds } from "./rss.js";
@@ -67,3 +69,12 @@ export default async function build(outputDir = dirs.dist) {
67
69
  process.stdout.write('\n');
68
70
  }
69
71
  }
72
+
73
+ // Run the build when invoked directly (e.g. `npm run build`, `npm start`),
74
+ // not when imported as a module (e.g. by cli.js).
75
+ if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
76
+ build().catch((error) => {
77
+ console.error("Build failed:", error);
78
+ process.exit(1);
79
+ });
80
+ }
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
- max_image_size: 800
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 { getCssImports, getJsImports, getCssPreloads, getJsPreloads } from "./assets.js";
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, "&lt;")
15
21
  .replace(/>/g, "&gt;");
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 navigationScript = navigationEnabled()
45
- ? `<script type="module" src="/swifty/swifty-navigation.js" 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>`
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.7.0/styles/monokai-sublime.min.css">`;
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>`);
@@ -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 "marked";
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 html = await render(page);
346
- const pageDir = path.join(distDir, page.url);
347
- const pagePath = path.join(distDir, page.url, "index.html");
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 = pages.filter((p) => !p.folder && p.filename !== 'index');
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
- pages.filter((p) => p.url !== page.url),
431
+ linkablePages.filter((p) => p.url !== page.url),
418
432
  ),
419
- generateLinkList(page.parent?.filename || "pages", 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
- import { dirs, defaultConfig } from "./config.js";
5
- import { marked } from "marked";
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
- const image = meta.image || meta.og_image || '';
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
- return tag.replace(
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) => {