@daz4126/swifty 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md ADDED
@@ -0,0 +1,60 @@
1
+ # Migrating to Swifty 4
2
+
3
+ Swifty 4 focuses on deterministic builds, safer deployment, and explicit errors.
4
+
5
+ ## Requirements
6
+
7
+ - Upgrade Node.js to version 22 or 24. Older Node.js releases are no longer supported.
8
+ - Run `npm install` after upgrading so the tracked lockfile installs the new minifiers.
9
+
10
+ ## Clean Output and Public Files
11
+
12
+ Every full build empties the output directory. Files written directly to `dist/` are removed.
13
+
14
+ Move files that must pass through unchanged into `public/`:
15
+
16
+ ```text
17
+ public/manifest.webmanifest -> dist/manifest.webmanifest
18
+ public/fonts/site.woff2 -> dist/fonts/site.woff2
19
+ ```
20
+
21
+ Optimized images remain cached in `.swifty-cache/`, which should not be committed.
22
+
23
+ ## Folder Index Pages
24
+
25
+ `pages/blog/index.md` is now the content and front matter for `/blog`. It no longer creates `/blog/index` or gets treated as a child page.
26
+
27
+ ## Permalinks and Base Paths
28
+
29
+ Use `permalink` in page front matter to override a page URL:
30
+
31
+ ```yaml
32
+ permalink: /company/about.html
33
+ ```
34
+
35
+ For deployment below an origin path, set `base_path` and keep `site_url` as the origin:
36
+
37
+ ```yaml
38
+ site_url: https://example.com
39
+ base_path: /project
40
+ ```
41
+
42
+ The base path changes public URLs but does not add a `dist/project/` directory.
43
+
44
+ ## Strict Build Errors
45
+
46
+ Invalid configuration, front matter, JSON/YAML data, Eta templates, partial references, images, CSS, JavaScript, or generated HTML now fail the build. Fix the path reported in the error before deploying.
47
+
48
+ ## Minification
49
+
50
+ Swifty now uses CleanCSS, Terser, and html-minifier-terser. JavaScript is compressed but identifiers are not mangled. Set `minify`, `minify_html`, `minify_css`, or `minify_js` to `false` if source formatting must be retained.
51
+
52
+ ## Development Server
53
+
54
+ `swifty start` now uses Swifty's built-in static server. Set `server_port` in `config.yaml` to change its default port from `3000`.
55
+
56
+ ## Deploy Command
57
+
58
+ `swifty deploy` stages only the configured output directory. It refuses to proceed when other changes are already staged, and it does not commit source changes automatically.
59
+
60
+ Commit source changes separately before deploying.
package/README.md CHANGED
@@ -25,6 +25,10 @@ Swifty uses convention over configuration to make it super simple to build blazi
25
25
  - **Previous/next navigation** - Auto-generated links between sibling pages
26
26
  - **[Eta templating](https://eta.js.org/)** - Full JavaScript in templates with EJS syntax
27
27
  - **Idiomorph navigation** with optional intent prefetching for SPA-like transitions
28
+ - **Custom permalinks and base paths** for flexible deployment URLs
29
+ - **Public asset passthrough** for files that should be copied unchanged
30
+
31
+ Requires Node.js 22 or newer. See [Migrating to Swifty 4](MIGRATION.md) when upgrading an existing site.
28
32
 
29
33
  ## Quickstart
30
34
 
@@ -48,6 +52,7 @@ your-site/
48
52
  ├── css/ # Stylesheets (auto-injected)
49
53
  ├── js/ # JavaScript (auto-injected)
50
54
  ├── images/ # Images (auto-optimized to responsive WebP)
55
+ ├── public/ # Files copied unchanged to the output root
51
56
  ├── template.html # Base HTML template
52
57
  └── config.yaml # Site configuration
53
58
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/swifty",
3
- "version": "3.2.0",
3
+ "version": "4.1.0",
4
4
  "main": "./src/index.js",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,15 +10,18 @@
10
10
  "swifty": "./src/cli.js"
11
11
  },
12
12
  "engines": {
13
- "node": ">=18"
13
+ "node": ">=22"
14
14
  },
15
15
  "files": [
16
- "src/"
16
+ "src/",
17
+ "MIGRATION.md"
17
18
  ],
18
19
  "scripts": {
19
20
  "test": "mocha",
21
+ "test:package": "node scripts/verify-package.js",
22
+ "check": "npm test && npm run test:package",
20
23
  "build": "node src/build.js",
21
- "start": "node src/build.js && serve dist",
24
+ "start": "node src/cli.js start",
22
25
  "init": "node src/init.js",
23
26
  "watch": "node src/watcher.js"
24
27
  },
@@ -32,16 +35,17 @@
32
35
  "description": "Super Speedy Static Site Generator",
33
36
  "dependencies": {
34
37
  "chokidar": "^4.0.0",
38
+ "clean-css": "^5.3.3",
35
39
  "eta": "^4.5.0",
36
40
  "fs-extra": "^11.2.0",
37
- "gray-matter": "^4.0.3",
38
41
  "highlight.js": "^11.11.1",
42
+ "html-minifier-terser": "^7.2.0",
39
43
  "js-yaml": "^4.1.0",
40
44
  "livereload": "^0.10.3",
41
45
  "marked": "^14.1.3",
42
46
  "marked-highlight": "^2.2.1",
43
- "serve": "^14.2.5",
44
- "sharp": "^0.33.5"
47
+ "sharp": "^0.33.5",
48
+ "terser": "^5.48.0"
45
49
  },
46
50
  "devDependencies": {
47
51
  "mocha": "^11.1.0"
package/src/assets.js CHANGED
@@ -9,6 +9,11 @@ import sharp from "sharp";
9
9
  import { dirs, defaultConfig } from "./config.js";
10
10
  import { mapLimit } from "./concurrency.js";
11
11
  import { minifyCss, minifyJs } from "./minify.js";
12
+ import {
13
+ applyBasePathToCss,
14
+ withBasePath,
15
+ withoutBasePath,
16
+ } from "./urls.js";
12
17
 
13
18
  const __filename = fileURLToPath(import.meta.url);
14
19
  const __dirname = path.dirname(__filename);
@@ -33,7 +38,52 @@ const getBuildConcurrency = () => defaultConfig.build_concurrency || 16;
33
38
  const navigationEnabled = () => defaultConfig.morphing !== false;
34
39
  const minificationEnabled = (type) =>
35
40
  defaultConfig.minify !== false && defaultConfig[`minify_${type}`] !== false;
36
- const normalizeImageUrl = (url) => (url || "").split(/[?#]/)[0];
41
+ const normalizeImageUrl = (url) =>
42
+ withoutBasePath((url || "").split(/[?#]/)[0]);
43
+
44
+ const getImageCacheDirectory = () => {
45
+ const signature = crypto
46
+ .createHash("sha256")
47
+ .update(
48
+ JSON.stringify({
49
+ image_quality: defaultConfig.image_quality || 80,
50
+ max_image_width: defaultConfig.max_image_width || 800,
51
+ responsive_image_widths:
52
+ defaultConfig.responsive_image_widths || [320, 640, 800],
53
+ }),
54
+ )
55
+ .digest("hex")
56
+ .slice(0, 12);
57
+ return path.join(dirs.cache, "images", signature);
58
+ };
59
+
60
+ const prepareImageCache = async () => {
61
+ const cacheRoot = path.join(dirs.cache, "images");
62
+ const cacheDirectory = getImageCacheDirectory();
63
+ await fsExtra.ensureDir(cacheDirectory);
64
+
65
+ const entries = await fs.readdir(cacheRoot, { withFileTypes: true });
66
+ await Promise.all(
67
+ entries
68
+ .filter(
69
+ (entry) =>
70
+ entry.isDirectory() &&
71
+ path.join(cacheRoot, entry.name) !== cacheDirectory,
72
+ )
73
+ .map((entry) => fsExtra.remove(path.join(cacheRoot, entry.name))),
74
+ );
75
+
76
+ return cacheDirectory;
77
+ };
78
+
79
+ const removeStaleCachedImages = async (cacheDirectory, expectedFiles) => {
80
+ const files = await fs.readdir(cacheDirectory);
81
+ await Promise.all(
82
+ files
83
+ .filter((file) => !expectedFiles.has(file))
84
+ .map((file) => fsExtra.remove(path.join(cacheDirectory, file))),
85
+ );
86
+ };
37
87
 
38
88
  const getResponsiveWidths = (originalWidth) => {
39
89
  const maxWidth = defaultConfig.max_image_width || 800;
@@ -62,7 +112,7 @@ const variantFilename = (filename, ext, width, outputWidth) =>
62
112
  const registerResponsiveImage = (filename, ext, variants) => {
63
113
  const basename = path.basename(filename, ext);
64
114
  const entry = {
65
- src: `/images/${basename}.webp`,
115
+ src: withBasePath(`/images/${basename}.webp`),
66
116
  srcset: variants
67
117
  .map((variant) => `${variant.url} ${variant.width}w`)
68
118
  .join(", "),
@@ -70,7 +120,7 @@ const registerResponsiveImage = (filename, ext, variants) => {
70
120
  variants,
71
121
  };
72
122
 
73
- responsiveImageMap.set(normalizeImageUrl(`/images/${filename}`), entry);
123
+ responsiveImageMap.set(normalizeImageUrl(withBasePath(`/images/${filename}`)), entry);
74
124
  responsiveImageMap.set(normalizeImageUrl(entry.src), entry);
75
125
  };
76
126
 
@@ -91,7 +141,7 @@ const getNavigationAssetName = async (filename = "swifty-navigation.js") => {
91
141
 
92
142
  const getNavigationScriptSrc = async () => {
93
143
  if (!navigationEnabled()) return "";
94
- return `/swifty/${await getNavigationAssetName()}`;
144
+ return withBasePath(`/swifty/${await getNavigationAssetName()}`);
95
145
  };
96
146
 
97
147
  const isDestinationFresh = async (source, destination) => {
@@ -115,22 +165,37 @@ const copyIfStale = async (source, destination) => {
115
165
  return true;
116
166
  };
117
167
 
118
- const optimizeImageVariantToWebp = async (source, destination, width) => {
119
- if (await isDestinationFresh(source, destination)) {
120
- return false;
168
+ const optimizeImageVariantToWebp = async (
169
+ source,
170
+ destination,
171
+ cacheDestination,
172
+ width,
173
+ ) => {
174
+ let optimized = false;
175
+
176
+ if (!(await isDestinationFresh(source, cacheDestination))) {
177
+ const imageQuality = defaultConfig.image_quality || 80;
178
+ await fsExtra.ensureDir(path.dirname(cacheDestination));
179
+
180
+ await sharp(source)
181
+ .resize({ width })
182
+ .toFormat("webp", { quality: imageQuality })
183
+ .toFile(cacheDestination);
184
+ optimized = true;
121
185
  }
122
186
 
123
- const imageQuality = defaultConfig.image_quality || 80;
124
-
125
- await sharp(source)
126
- .resize({ width })
127
- .toFormat("webp", { quality: imageQuality })
128
- .toFile(destination);
129
-
130
- return true;
187
+ await copyIfStale(cacheDestination, destination);
188
+ return optimized;
131
189
  };
132
190
 
133
- const optimizeImageToWebp = async (source, imagesFolder, filename, ext) => {
191
+ const optimizeImageToWebp = async (
192
+ source,
193
+ imagesFolder,
194
+ cacheDirectory,
195
+ filename,
196
+ ext,
197
+ expectedCacheFiles,
198
+ ) => {
134
199
  const metadata = await sharp(source).metadata();
135
200
  const originalWidth = metadata.width || 0;
136
201
  const maxWidth = defaultConfig.max_image_width || 800;
@@ -138,17 +203,24 @@ const optimizeImageToWebp = async (source, imagesFolder, filename, ext) => {
138
203
  originalWidth > 0 ? Math.min(originalWidth, maxWidth) : maxWidth;
139
204
  const variants = getResponsiveWidths(originalWidth).map((width) => {
140
205
  const file = variantFilename(filename, ext, width, outputWidth);
206
+ expectedCacheFiles?.add(file);
141
207
  return {
142
208
  width,
143
209
  file,
144
- url: `/images/${file}`,
210
+ url: withBasePath(`/images/${file}`),
145
211
  destination: path.join(imagesFolder, file),
212
+ cacheDestination: path.join(cacheDirectory, file),
146
213
  };
147
214
  });
148
215
 
149
216
  const results = await Promise.all(
150
217
  variants.map((variant) =>
151
- optimizeImageVariantToWebp(source, variant.destination, variant.width),
218
+ optimizeImageVariantToWebp(
219
+ source,
220
+ variant.destination,
221
+ variant.cacheDestination,
222
+ variant.width,
223
+ ),
152
224
  ),
153
225
  );
154
226
 
@@ -163,12 +235,21 @@ const optimizeImageToWebp = async (source, imagesFolder, filename, ext) => {
163
235
 
164
236
  const processTextAsset = async (source, destination, type) => {
165
237
  const content = await fs.readFile(source, "utf-8");
166
- const nextContent =
167
- type === "css" && minificationEnabled("css")
168
- ? minifyCss(content)
169
- : type === "js" && minificationEnabled("js")
170
- ? minifyJs(content)
171
- : content;
238
+ let nextContent = content;
239
+
240
+ try {
241
+ if (type === "css" && minificationEnabled("css")) {
242
+ nextContent = minifyCss(applyBasePathToCss(content));
243
+ } else if (type === "js" && minificationEnabled("js")) {
244
+ nextContent = await minifyJs(content);
245
+ } else if (type === "css") {
246
+ nextContent = applyBasePathToCss(content);
247
+ }
248
+ } catch (error) {
249
+ throw new Error(`Unable to minify ${source}: ${error.message}`, {
250
+ cause: error,
251
+ });
252
+ }
172
253
 
173
254
  try {
174
255
  const currentContent = await fs.readFile(destination, "utf-8");
@@ -236,6 +317,9 @@ const copyNavigationAssets = async (outputDir = dirs.dist) => {
236
317
  };
237
318
 
238
319
  const copyAssets = async (outputDir = dirs.dist) => {
320
+ if (await fsExtra.pathExists(dirs.public)) {
321
+ await fsExtra.copy(dirs.public, outputDir, { overwrite: true });
322
+ }
239
323
  await ensureAndCopy(
240
324
  dirs.css,
241
325
  path.join(outputDir, "css"),
@@ -249,11 +333,14 @@ async function optimizeImages(outputDir = dirs.dist) {
249
333
 
250
334
  try {
251
335
  if (!(await fsExtra.pathExists(dirs.images))) {
336
+ await fsExtra.remove(path.join(dirs.cache, "images"));
252
337
  console.log(`No ${path.basename(dirs.images)} found in ${dirs.images}`);
253
338
  return;
254
339
  }
255
340
 
256
341
  const imagesFolder = path.join(outputDir, "images");
342
+ const cacheDirectory = await prepareImageCache();
343
+ const expectedCacheFiles = new Set();
257
344
  await fsExtra.ensureDir(imagesFolder);
258
345
 
259
346
  const files = await fs.readdir(dirs.images);
@@ -278,8 +365,10 @@ async function optimizeImages(outputDir = dirs.dist) {
278
365
  const optimized = await optimizeImageToWebp(
279
366
  filePath,
280
367
  imagesFolder,
368
+ cacheDirectory,
281
369
  file,
282
370
  ext,
371
+ expectedCacheFiles,
283
372
  );
284
373
  if (optimized) {
285
374
  console.log(`Optimized ${file}`);
@@ -287,8 +376,11 @@ async function optimizeImages(outputDir = dirs.dist) {
287
376
  },
288
377
  getBuildConcurrency(),
289
378
  );
379
+ await removeStaleCachedImages(cacheDirectory, expectedCacheFiles);
290
380
  } catch (error) {
291
- console.error("Error optimizing images:", error);
381
+ throw new Error(`Unable to optimize images: ${error.message}`, {
382
+ cause: error,
383
+ });
292
384
  }
293
385
  }
294
386
  const generateAssetImports = async (dir, tagTemplate, validExts) => {
@@ -309,25 +401,25 @@ const generateAssetImports = async (dir, tagTemplate, validExts) => {
309
401
  const getCssImports = () =>
310
402
  generateAssetImports(
311
403
  dirs.css,
312
- (file, mtime) => `<link rel="stylesheet" href="/css/${file}?v=${mtime}" />`,
404
+ (file, mtime) => `<link rel="stylesheet" href="${withBasePath(`/css/${file}`)}?v=${mtime}" />`,
313
405
  validExtensions.css,
314
406
  );
315
407
  const getJsImports = () =>
316
408
  generateAssetImports(
317
409
  dirs.js,
318
- (file, mtime) => `<script src="/js/${file}?v=${mtime}"></script>`,
410
+ (file, mtime) => `<script src="${withBasePath(`/js/${file}`)}?v=${mtime}"></script>`,
319
411
  validExtensions.js,
320
412
  );
321
413
  const getCssPreloads = () =>
322
414
  generateAssetImports(
323
415
  dirs.css,
324
- (file, mtime) => `<link rel="preload" href="/css/${file}?v=${mtime}" as="style" />`,
416
+ (file, mtime) => `<link rel="preload" href="${withBasePath(`/css/${file}`)}?v=${mtime}" as="style" />`,
325
417
  validExtensions.css,
326
418
  );
327
419
  const getJsPreloads = () =>
328
420
  generateAssetImports(
329
421
  dirs.js,
330
- (file, mtime) => `<link rel="preload" href="/js/${file}?v=${mtime}" as="script" />`,
422
+ (file, mtime) => `<link rel="preload" href="${withBasePath(`/js/${file}`)}?v=${mtime}" as="script" />`,
331
423
  validExtensions.js,
332
424
  );
333
425
 
@@ -359,6 +451,7 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
359
451
  const filename = path.basename(filePath);
360
452
  const ext = path.extname(filePath).toLowerCase();
361
453
  const imagesFolder = path.join(outputDir, "images");
454
+ const cacheDirectory = await prepareImageCache();
362
455
 
363
456
  await fsExtra.ensureDir(imagesFolder);
364
457
 
@@ -374,6 +467,7 @@ const optimizeSingleImage = async (filePath, outputDir = dirs.dist) => {
374
467
  const optimized = await optimizeImageToWebp(
375
468
  filePath,
376
469
  imagesFolder,
470
+ cacheDirectory,
377
471
  filename,
378
472
  ext,
379
473
  );
@@ -394,4 +488,5 @@ export {
394
488
  optimizeSingleImage,
395
489
  getNavigationScriptSrc,
396
490
  getResponsiveImage,
491
+ getImageCacheDirectory,
397
492
  };
package/src/build.js CHANGED
@@ -18,8 +18,15 @@ const prepareOutputDirectory = async (outputDir) => {
18
18
  const outputPath = path.resolve(baseDir, outputDir);
19
19
  const outputContainsProject =
20
20
  projectPath === outputPath || projectPath.startsWith(`${outputPath}${path.sep}`);
21
+ const sourceDirectories = Object.entries(dirs)
22
+ .filter(([name]) => name !== "dist")
23
+ .map(([, directory]) => path.resolve(directory));
24
+ const outputOverlapsSource = sourceDirectories.some(
25
+ (directory) =>
26
+ outputPath === directory || outputPath.startsWith(`${directory}${path.sep}`),
27
+ );
21
28
 
22
- if (outputContainsProject) {
29
+ if (outputContainsProject || outputOverlapsSource) {
23
30
  throw new Error(`Refusing to empty unsafe output directory: ${outputPath}`);
24
31
  }
25
32
 
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { execFileSync, spawn, spawnSync } from "child_process";
3
+ import { execFileSync, spawnSync } from "child_process";
4
4
  import { fileURLToPath } from "url";
5
5
 
6
6
  const args = process.argv.slice(2);
@@ -92,8 +92,8 @@ async function main() {
92
92
  watcher.default(outDir);
93
93
  }
94
94
 
95
- // Start the server (blocking)
96
- spawn("npx", ["serve", outDir], { stdio: "inherit" });
95
+ const server = await import("./server.js");
96
+ await server.default(outDir);
97
97
  break;
98
98
  }
99
99
  case "watch": {
@@ -139,7 +139,10 @@ async function main() {
139
139
  }
140
140
 
141
141
  if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
142
- main();
142
+ main().catch((error) => {
143
+ console.error(`Swifty failed: ${error.message}`);
144
+ process.exitCode = 1;
145
+ });
143
146
  }
144
147
 
145
148
  export { commitAndPushOutput, main };
package/src/config.js CHANGED
@@ -17,24 +17,105 @@ const dirs = {
17
17
  js: path.join(baseDir, "js"),
18
18
  partials: path.join(baseDir, "partials"),
19
19
  data: path.join(baseDir, "data"),
20
+ public: path.join(baseDir, "public"),
21
+ cache: path.join(baseDir, ".swifty-cache"),
20
22
  };
21
23
 
22
24
  async function loadConfig(dir) {
23
25
  const configFiles = ['config.yaml', 'config.yml', 'config.json'];
24
26
  for (const file of configFiles) {
25
27
  const filePath = path.join(dir, file);
28
+ let content;
29
+
26
30
  try {
27
- await fs.access(filePath);
28
- const content = await fs.readFile(filePath, 'utf-8');
29
- return file.endsWith('.json') ? JSON.parse(content) : yaml.load(content);
30
- } catch {}
31
+ content = await fs.readFile(filePath, 'utf-8');
32
+ } catch (error) {
33
+ if (error.code === "ENOENT") continue;
34
+ throw new Error(`Unable to read config ${filePath}: ${error.message}`, {
35
+ cause: error,
36
+ });
37
+ }
38
+
39
+ try {
40
+ const config = file.endsWith('.json') ? JSON.parse(content) : yaml.load(content);
41
+ return validateConfig(config || {}, filePath);
42
+ } catch (error) {
43
+ throw new Error(`Unable to parse config ${filePath}: ${error.message}`, {
44
+ cause: error,
45
+ });
46
+ }
31
47
  }
32
48
  return {};
33
49
  }
34
50
 
51
+ const validateConfig = (config, filePath = "config") => {
52
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
53
+ throw new TypeError(`${filePath} must contain a configuration object`);
54
+ }
55
+
56
+ const booleans = [
57
+ "minify",
58
+ "minify_html",
59
+ "minify_css",
60
+ "minify_js",
61
+ "morphing",
62
+ "prefetching",
63
+ ];
64
+ const positiveNumbers = [
65
+ "build_concurrency",
66
+ "image_quality",
67
+ "livereload_port",
68
+ "max_image_width",
69
+ "server_port",
70
+ "watcher_delay",
71
+ "watcher_interval",
72
+ "words_per_minute",
73
+ ];
74
+
75
+ for (const key of booleans) {
76
+ if (config[key] !== undefined && typeof config[key] !== "boolean") {
77
+ throw new TypeError(`${key} in ${filePath} must be a boolean`);
78
+ }
79
+ }
80
+ for (const key of positiveNumbers) {
81
+ if (
82
+ config[key] !== undefined &&
83
+ (!Number.isFinite(config[key]) || config[key] <= 0)
84
+ ) {
85
+ throw new TypeError(`${key} in ${filePath} must be a positive number`);
86
+ }
87
+ }
88
+ if (
89
+ config.base_path !== undefined &&
90
+ (typeof config.base_path !== "string" ||
91
+ /^(?:[a-z]+:)?\/\//i.test(config.base_path) ||
92
+ config.base_path.includes("..") ||
93
+ /[?#]/.test(config.base_path))
94
+ ) {
95
+ throw new TypeError(`base_path in ${filePath} must be a URL path`);
96
+ }
97
+ if (
98
+ config.responsive_image_widths !== undefined &&
99
+ (!Array.isArray(config.responsive_image_widths) ||
100
+ config.responsive_image_widths.some(
101
+ (width) => !Number.isInteger(width) || width <= 0,
102
+ ))
103
+ ) {
104
+ throw new TypeError(
105
+ `responsive_image_widths in ${filePath} must contain positive integers`,
106
+ );
107
+ }
108
+ if (config.rss_feeds !== undefined && !Array.isArray(config.rss_feeds)) {
109
+ throw new TypeError(`rss_feeds in ${filePath} must be an array`);
110
+ }
111
+
112
+ return config;
113
+ };
114
+
35
115
  // Hardcoded defaults (used if not specified in config file)
36
116
  const builtInDefaults = {
37
117
  default_layout_name: 'default',
118
+ base_path: '',
38
119
  // Reading time calculation
39
120
  words_per_minute: 200,
40
121
  // Image optimization settings
@@ -49,6 +130,7 @@ const builtInDefaults = {
49
130
  minify_css: true,
50
131
  minify_js: true,
51
132
  // LiveReload and watcher settings
133
+ server_port: 3000,
52
134
  livereload_port: 35729,
53
135
  watcher_delay: 100,
54
136
  watcher_interval: 500,
@@ -87,4 +169,4 @@ const reloadConfig = async () => {
87
169
 
88
170
  await reloadConfig();
89
171
 
90
- export { baseDir, dirs, defaultConfig, loadConfig, reloadConfig };
172
+ export { baseDir, dirs, defaultConfig, loadConfig, reloadConfig, validateConfig };
package/src/data.js CHANGED
@@ -24,36 +24,41 @@ async function loadData() {
24
24
  return data;
25
25
  }
26
26
 
27
+ let files;
27
28
  try {
28
- const files = await fs.readdir(dirs.data);
29
+ files = await fs.readdir(dirs.data);
30
+ } catch (error) {
31
+ throw new Error(`Unable to read data directory ${dirs.data}: ${error.message}`, {
32
+ cause: error,
33
+ });
34
+ }
29
35
 
30
- for (const file of files) {
31
- const filePath = path.join(dirs.data, file);
32
- const stat = await fs.stat(filePath);
36
+ for (const file of files) {
37
+ const filePath = path.join(dirs.data, file);
38
+ const stat = await fs.stat(filePath);
33
39
 
34
- // Skip directories
35
- if (stat.isDirectory()) continue;
40
+ // Skip directories
41
+ if (stat.isDirectory()) continue;
36
42
 
37
- const ext = path.extname(file).toLowerCase();
38
- const name = path.basename(file, ext);
43
+ const ext = path.extname(file).toLowerCase();
44
+ const name = path.basename(file, ext);
39
45
 
40
- // Only process JSON and YAML files
41
- if (!['.json', '.yaml', '.yml'].includes(ext)) continue;
46
+ // Only process JSON and YAML files
47
+ if (!['.json', '.yaml', '.yml'].includes(ext)) continue;
42
48
 
43
- try {
44
- const content = await fs.readFile(filePath, 'utf-8');
49
+ try {
50
+ const content = await fs.readFile(filePath, 'utf-8');
45
51
 
46
- if (ext === '.json') {
47
- data[name] = JSON.parse(content);
48
- } else {
49
- data[name] = yaml.load(content);
50
- }
51
- } catch (error) {
52
- console.warn(`Error loading data file ${file}: ${error.message}`);
52
+ if (ext === '.json') {
53
+ data[name] = JSON.parse(content);
54
+ } else {
55
+ data[name] = yaml.load(content);
53
56
  }
57
+ } catch (error) {
58
+ throw new Error(`Unable to parse data file ${filePath}: ${error.message}`, {
59
+ cause: error,
60
+ });
54
61
  }
55
- } catch (error) {
56
- console.warn(`Error reading data directory: ${error.message}`);
57
62
  }
58
63
 
59
64
  dataCache = data;
@@ -0,0 +1,27 @@
1
+ import yaml from "js-yaml";
2
+
3
+ const frontMatterPattern =
4
+ /^---[ \t]*\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m;
5
+
6
+ const parseFrontMatter = (source) => {
7
+ if (!/^---[ \t]*(?:\r?\n|$)/.test(source)) {
8
+ return { data: {}, content: source };
9
+ }
10
+
11
+ const match = source.match(frontMatterPattern);
12
+ if (!match) {
13
+ throw new Error("Front matter is missing a closing --- delimiter");
14
+ }
15
+
16
+ const data = yaml.load(match[1]) || {};
17
+ if (typeof data !== "object" || Array.isArray(data)) {
18
+ throw new TypeError("Front matter must contain a YAML object");
19
+ }
20
+
21
+ return {
22
+ data,
23
+ content: source.slice(match[0].length),
24
+ };
25
+ };
26
+
27
+ export { parseFrontMatter };