@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 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, git add, commit, and push (message optional)
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 commits/pushes to git in one command.
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.0.0",
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/daz4126/swifty.git"
54
+ "url": "git+https://github.com/daz-codes/swifty.git"
49
55
  },
50
56
  "bugs": {
51
- "url": "https://github.com/daz4126/swifty/issues"
57
+ "url": "https://github.com/daz-codes/swifty/issues"
52
58
  },
53
- "homepage": "https://github.com/daz4126/swifty#readme"
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 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,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(outputDir);
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(outputDir);
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, outputDir);
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, outputDir);
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, outputDir);
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, execSync } from "child_process";
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 reservedCommands = ["build", "start", "watch", "deploy"];
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 commitMessage = args[1] || "Deploying latest build";
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
- console.log("Adding files to git...");
78
- execSync("git add .", { stdio: "inherit" });
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
- main();
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 loadedConfig = await loadConfig(baseDir);
60
- const hasConfig = (key) => Object.prototype.hasOwnProperty.call(loadedConfig, key);
67
+ const defaultConfig = {};
61
68
 
62
- if (hasConfig('turbo')) {
63
- console.warn('The "turbo" config option is deprecated. Use "morphing" and "prefetching" instead.');
64
- if (!hasConfig('morphing')) {
65
- loadedConfig.morphing = loadedConfig.turbo;
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
- export { baseDir, dirs, defaultConfig, loadConfig };
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
@@ -0,0 +1,3 @@
1
+ export { default, default as build, prepareOutputDirectory } from "./build.js";
2
+ export { defaultConfig, loadConfig, reloadConfig } from "./config.js";
3
+ export { addLinks, createPages, generatePages } from "./pages.js";
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,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 "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) => {
@@ -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
- const markdownContent = await fs.readFile(filePath, "utf-8");
127
- const { data, content } = matter(markdownContent);
128
- Object.assign(page, { meta: { ...page.meta, ...data }, content });
129
- page.title = page.meta.title || page.title;
130
- page.name = page.meta.title || page.name;
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
- page.content = await generateLinkList(page.filename, chunks[0]);
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
- page.content = await generateLinkList(page.filename, page.pages);
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 html = await render(page);
346
- const pageDir = path.join(distDir, page.url);
347
- const pagePath = path.join(distDir, page.url, "index.html");
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 = pages.filter((p) => !p.folder && p.filename !== 'index');
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
- pages.filter((p) => p.url !== page.url),
468
+ linkablePages.filter((p) => p.url !== page.url),
418
469
  ),
419
- generateLinkList(page.parent?.filename || "pages", 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
- 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) => {
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...`);