@daz4126/swifty 3.1.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/src/pages.js CHANGED
@@ -3,7 +3,6 @@ import fs from "fs/promises";
3
3
  import path from "path";
4
4
 
5
5
  import fsExtra from "fs-extra";
6
- import matter from "gray-matter";
7
6
 
8
7
  import { replacePlaceholders } from "./partials.js";
9
8
  import { dirs, defaultConfig, loadConfig } from "./config.js";
@@ -12,6 +11,13 @@ import { chunkPages, generatePaginationNav } from "./pagination.js";
12
11
  import { marked } from "./markdown.js";
13
12
  import { mapLimit } from "./concurrency.js";
14
13
  import { minifyHtml } from "./minify.js";
14
+ import { parseFrontMatter } from "./frontmatter.js";
15
+ import {
16
+ applyBasePathToHtml,
17
+ normalizePermalink,
18
+ routeToOutputPath,
19
+ withBasePath,
20
+ } from "./urls.js";
15
21
 
16
22
  // Returns stats if valid (directory or .md file), null otherwise
17
23
  const getValidStats = async (filePath) => {
@@ -44,6 +50,67 @@ const parseDate = (dateValue) => {
44
50
  return isNaN(parsed.getTime()) ? null : parsed;
45
51
  };
46
52
 
53
+ const applyMarkdownFileToPage = async (
54
+ page,
55
+ filePath,
56
+ { notFound = false, folderIndex = false } = {},
57
+ ) => {
58
+ let parsed;
59
+ try {
60
+ const markdownContent = await fs.readFile(filePath, "utf-8");
61
+ parsed = parseFrontMatter(markdownContent);
62
+ } catch (error) {
63
+ throw new Error(`Unable to parse Markdown file ${filePath}: ${error.message}`, {
64
+ cause: error,
65
+ });
66
+ }
67
+ const { data, content } = parsed;
68
+ Object.assign(page, { meta: { ...page.meta, ...data }, content });
69
+
70
+ if (data.permalink !== undefined) {
71
+ page.route = normalizePermalink(data.permalink);
72
+ page.url = withBasePath(page.route);
73
+ }
74
+
75
+ if (folderIndex) {
76
+ const stats = await fs.stat(filePath);
77
+ const { dateFormat } = page.meta;
78
+ page.hasIndexContent = true;
79
+ page.indexFilePath = filePath;
80
+ page.createdAtObj = stats.birthtime;
81
+ page.updatedAtObj = stats.mtime;
82
+ page.created_at = new Date(stats.birthtime).toLocaleDateString(
83
+ undefined,
84
+ dateFormat,
85
+ );
86
+ page.updated_at = new Date(stats.mtime).toLocaleDateString(
87
+ undefined,
88
+ dateFormat,
89
+ );
90
+ page.date = new Date(stats.birthtime).toLocaleDateString(undefined, dateFormat);
91
+ }
92
+
93
+ if (notFound && data.sitemap === undefined) {
94
+ page.meta.sitemap = false;
95
+ }
96
+
97
+ page.title = page.meta.title || page.title;
98
+ page.name = page.meta.title || page.name;
99
+
100
+ if (typeof data.nav === "boolean") {
101
+ page.nav = data.nav;
102
+ }
103
+
104
+ if (data.date) {
105
+ const parsedDate = parseDate(data.date);
106
+ if (parsedDate) {
107
+ page.dateObj = parsedDate;
108
+ page.date = parsedDate.toLocaleDateString(undefined, page.meta.dateFormat);
109
+ page.meta.date = page.date;
110
+ }
111
+ }
112
+ };
113
+
47
114
  const tagsMap = new Map();
48
115
  const pageIndex = [];
49
116
  const pageIndexUrls = new Set();
@@ -78,6 +145,8 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
78
145
  files,
79
146
  async (file) => {
80
147
  const filePath = path.join(sourceDir, file.name);
148
+ if (parent && file.name === "index.md") return null;
149
+
81
150
  const stats = await getValidStats(filePath);
82
151
  if (!stats) return null;
83
152
 
@@ -98,13 +167,15 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
98
167
  ? parent.layout
99
168
  : config.default_layout_name;
100
169
 
170
+ const route = root ? "/" : finalPath;
101
171
  const page = {
102
172
  name,
103
173
  root,
104
174
  layout,
105
175
  filePath,
106
176
  filename: file.name.replace(/\.md$/, ""),
107
- url: root ? "/" : finalPath,
177
+ route,
178
+ url: withBasePath(route),
108
179
  nav: !parent && !root && !notFound,
109
180
  parent: parent
110
181
  ? { title: parent.meta.title, url: parent.url }
@@ -130,28 +201,11 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
130
201
  };
131
202
 
132
203
  if (path.extname(file.name) === ".md") {
133
- const markdownContent = await fs.readFile(filePath, "utf-8");
134
- const { data, content } = matter(markdownContent);
135
- Object.assign(page, { meta: { ...page.meta, ...data }, content });
136
- if (notFound && data.sitemap === undefined) {
137
- page.meta.sitemap = false;
138
- }
139
- page.title = page.meta.title || page.title;
140
- page.name = page.meta.title || page.name;
141
-
142
- // Allow front matter to override nav (opt in or opt out)
143
- if (typeof data.nav === "boolean") {
144
- page.nav = data.nav;
145
- }
146
-
147
- // If front matter has a date, parse and format it
148
- if (data.date) {
149
- const parsedDate = parseDate(data.date);
150
- if (parsedDate) {
151
- page.dateObj = parsedDate;
152
- page.date = parsedDate.toLocaleDateString(undefined, dateFormat);
153
- page.meta.date = page.date;
154
- }
204
+ await applyMarkdownFileToPage(page, filePath, { notFound });
205
+ } else if (isDirectory) {
206
+ const indexPath = path.join(filePath, "index.md");
207
+ if (await fsExtra.pathExists(indexPath)) {
208
+ await applyMarkdownFileToPage(page, indexPath, { folderIndex: true });
155
209
  }
156
210
  }
157
211
 
@@ -178,11 +232,11 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
178
232
 
179
233
  // Load folder's own config for pagination settings
180
234
  const dirConfig = await loadConfig(page.filePath);
181
- const mergedConfig = {
182
- ...page.meta,
235
+ const mergedConfig = {
183
236
  ...dirConfig,
237
+ ...page.meta,
184
238
  // Only set default page_count if explicitly specified in either page meta or dir config
185
- page_count: dirConfig.page_count || page.meta.page_count
239
+ page_count: page.meta.page_count ?? dirConfig.page_count,
186
240
  };
187
241
  page.meta = mergedConfig;
188
242
 
@@ -208,7 +262,9 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
208
262
  page.visiblePages = chunks[0];
209
263
 
210
264
  // First page content
211
- page.content = await generateLinkList(page.filename, chunks[0]);
265
+ if (!page.hasIndexContent) {
266
+ page.content = await generateLinkList(page.filename, chunks[0]);
267
+ }
212
268
  page.meta.pagination = generatePaginationNav({
213
269
  currentPage: 1,
214
270
  totalPages,
@@ -223,6 +279,7 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
223
279
  const paginatedPage = {
224
280
  name: `${page.name} - Page ${pageNum}`,
225
281
  title: `${page.meta.title || page.name} - Page ${pageNum}`,
282
+ route: `${page.route}/page/${pageNum}`,
226
283
  url: `${page.url}/page/${pageNum}`,
227
284
  folder: false,
228
285
  layout: page.layout,
@@ -242,7 +299,9 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
242
299
  page.paginatedPages.push(paginatedPage);
243
300
  }
244
301
  } else {
245
- page.content = await generateLinkList(page.filename, page.pages);
302
+ if (!page.hasIndexContent) {
303
+ page.content = await generateLinkList(page.filename, page.pages);
304
+ }
246
305
  page.meta.pagination = '';
247
306
  }
248
307
  }
@@ -258,15 +317,18 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
258
317
  pageIndex.push({ url: page.url, title: page.title, nav: page.nav });
259
318
  }
260
319
  }, getBuildConcurrency());
261
- } catch (err) {
262
- console.error("Error reading directory:", err);
320
+ } catch (error) {
321
+ throw new Error(`Unable to generate pages from ${sourceDir}: ${error.message}`, {
322
+ cause: error,
323
+ });
263
324
  }
264
325
 
265
326
  // Make Tags page
266
327
  if (!parent && tagsMap.size) {
267
328
  const tagLayout = await fsExtra.pathExists(`${dirs.layouts}/tags.html`);
268
329
  const tagPage = {
269
- url: "/tags",
330
+ route: "/tags",
331
+ url: withBasePath("/tags"),
270
332
  nav: false,
271
333
  folder: true,
272
334
  name: "Tags",
@@ -281,6 +343,7 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
281
343
  };
282
344
 
283
345
  for (const [tag, pagesForTag] of tagsMap) {
346
+ const route = `/tags/${tag}`;
284
347
  const page = {
285
348
  name: tag,
286
349
  title: tag,
@@ -288,7 +351,8 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
288
351
  undefined,
289
352
  defaultConfig.dateFormat,
290
353
  ),
291
- url: `/tags/${tag}`,
354
+ route,
355
+ url: withBasePath(route),
292
356
  layout: tagLayout ? "tags" : defaultConfig.default_layout_name,
293
357
  meta: { ...config, title: `Pages tagged with ${capitalize(tag)}` },
294
358
  };
@@ -335,17 +399,23 @@ const generateLinkList = async (name, pages) => {
335
399
  };
336
400
 
337
401
  const render = async (page) => {
338
- const replacedContent = await replacePlaceholders(page.content, page);
339
- const htmlContent = marked.parse(replacedContent); // Markdown processed once
340
- const wrappedContent = await applyLayoutAndWrapContent(page, htmlContent);
341
- // Use function to avoid $` special replacement patterns in content
342
- const template = await getTemplate();
343
- const htmlWithTemplate = template.replace(
344
- /<%=\s*content\s*%>/g,
345
- () => wrappedContent,
346
- );
347
- const finalContent = await replacePlaceholders(htmlWithTemplate, page);
348
- return finalContent;
402
+ try {
403
+ const replacedContent = await replacePlaceholders(page.content, page);
404
+ const htmlContent = marked.parse(replacedContent); // Markdown processed once
405
+ const wrappedContent = await applyLayoutAndWrapContent(page, htmlContent);
406
+ // Use function to avoid $` special replacement patterns in content
407
+ const template = await getTemplate();
408
+ const htmlWithTemplate = template.replace(
409
+ /<%=\s*content\s*%>/g,
410
+ () => wrappedContent,
411
+ );
412
+ const finalContent = await replacePlaceholders(htmlWithTemplate, page);
413
+ return applyBasePathToHtml(finalContent);
414
+ } catch (error) {
415
+ throw new Error(`Unable to render page ${page.url}: ${error.message}`, {
416
+ cause: error,
417
+ });
418
+ }
349
419
  };
350
420
 
351
421
  const createPages = async (pages, distDir = dirs.dist) => {
@@ -353,12 +423,20 @@ const createPages = async (pages, distDir = dirs.dist) => {
353
423
  pages,
354
424
  async (page) => {
355
425
  const renderedHtml = await render(page);
356
- const html = minificationEnabled() ? minifyHtml(renderedHtml) : renderedHtml;
357
- const pageDir = page.notFound ? distDir : path.join(distDir, page.url);
426
+ let html = renderedHtml;
427
+ if (minificationEnabled()) {
428
+ try {
429
+ html = await minifyHtml(renderedHtml);
430
+ } catch (error) {
431
+ throw new Error(`Unable to minify HTML for ${page.url}: ${error.message}`, {
432
+ cause: error,
433
+ });
434
+ }
435
+ }
358
436
  const pagePath = page.notFound
359
437
  ? path.join(distDir, "404.html")
360
- : path.join(distDir, page.url, "index.html");
361
- await fsExtra.ensureDir(pageDir);
438
+ : path.join(distDir, routeToOutputPath(page.route || page.url));
439
+ await fsExtra.ensureDir(path.dirname(pagePath));
362
440
  await fs.writeFile(pagePath, html);
363
441
  if (page.folder) {
364
442
  await createPages(page.pages, distDir);
@@ -385,7 +463,7 @@ const addLinks = async (pages, parent) => {
385
463
  ? page.meta.tags
386
464
  .map(
387
465
  (tag) =>
388
- `<a class="${defaultConfig.tag_class}" href="/tags/${tag}">${tag}</a>`,
466
+ `<a class="${defaultConfig.tag_class}" href="${withBasePath(`/tags/${tag}`)}">${tag}</a>`,
389
467
  )
390
468
  .join("")
391
469
  : "";
@@ -395,7 +473,7 @@ const addLinks = async (pages, parent) => {
395
473
  : ` ${defaultConfig.breadcrumb_separator} <a class="${defaultConfig.breadcrumb_class}" href="${page.url}">${crumbTitle}</a>`;
396
474
  page.meta.breadcrumbs = parent
397
475
  ? parent.meta.breadcrumbs + crumb
398
- : `<a class="${defaultConfig.breadcrumb_class}" href="/">Home</a>` +
476
+ : `<a class="${defaultConfig.breadcrumb_class}" href="${withBasePath("/")}">Home</a>` +
399
477
  crumb;
400
478
 
401
479
  // Generate prev/next links based on sibling position (only for content pages, not folders or index)
package/src/partials.js CHANGED
@@ -8,6 +8,7 @@ import { dirs, defaultConfig } from "./config.js";
8
8
  import { marked } from "./markdown.js";
9
9
  import { loadData } from "./data.js";
10
10
  import { getResponsiveImage } from "./assets.js";
11
+ import { withBasePath, withoutBasePath } from "./urls.js";
11
12
 
12
13
  const partialCache = new Map();
13
14
  const imageExtensionRegex = /\.(png|jpe?g|webp)(?=([?#]|$))/i;
@@ -130,8 +131,7 @@ const loadPartialData = async (partialName) => {
130
131
  return partialData;
131
132
  }
132
133
 
133
- console.warn(`Include "${partialName}" not found.`);
134
- return { content: `<p>Include "${partialName}" not found.</p>`, raw: true };
134
+ throw new Error(`Partial "${partialName}" was not found in ${dirs.partials}`);
135
135
  };
136
136
 
137
137
  const loadPartial = async (partialName) => {
@@ -140,13 +140,14 @@ const loadPartial = async (partialName) => {
140
140
  };
141
141
 
142
142
  const shouldRewriteImageUrl = (url) => {
143
- if (!url || !url.startsWith("/images/")) return false;
144
- return imageExtensionRegex.test(url);
143
+ const route = withoutBasePath(url);
144
+ if (!route || !route.startsWith("/images/")) return false;
145
+ return imageExtensionRegex.test(route);
145
146
  };
146
147
 
147
148
  const rewriteImageUrl = (url) => {
148
149
  if (!shouldRewriteImageUrl(url)) return url;
149
- return url.replace(imageExtensionRegex, ".webp");
150
+ return withBasePath(withoutBasePath(url).replace(imageExtensionRegex, ".webp"));
150
151
  };
151
152
 
152
153
  const rewriteSrcset = (srcset) =>
@@ -299,7 +300,10 @@ const replacePlaceholders = async (template, values) => {
299
300
  try {
300
301
  template = eta.renderString(template, templateData);
301
302
  } catch (error) {
302
- console.warn(`Eta template error: ${error.message}`);
303
+ const source = values.filePath || values.url || values.title || "template";
304
+ throw new Error(`Unable to render ${source}: ${error.message}`, {
305
+ cause: error,
306
+ });
303
307
  }
304
308
 
305
309
  // Restore code blocks
package/src/rss.js CHANGED
@@ -2,6 +2,7 @@ import fs from "fs/promises";
2
2
  import fsExtra from "fs-extra";
3
3
  import path from "path";
4
4
  import { defaultConfig } from "./config.js";
5
+ import { withBasePath } from "./urls.js";
5
6
 
6
7
  // Escape XML special characters
7
8
  const escapeXml = (str) => {
@@ -40,8 +41,8 @@ const generateRssFeed = (feedConfig, pages, siteUrl) => {
40
41
  folder,
41
42
  } = typeof feedConfig === "string" ? { folder: feedConfig } : feedConfig;
42
43
 
43
- const feedUrl = `${siteUrl}/${folder}/rss.xml`;
44
- const feedLink = `${siteUrl}/${folder}`;
44
+ const feedUrl = `${siteUrl}${withBasePath(`/${folder}/rss.xml`)}`;
45
+ const feedLink = `${siteUrl}${withBasePath(`/${folder}`)}`;
45
46
 
46
47
  // Sort pages by date (newest first)
47
48
  const sortedPages = [...pages].sort((a, b) => {
@@ -94,7 +95,7 @@ const findPagesInFolder = (pages, folderName) => {
94
95
  const searchPages = (pageList) => {
95
96
  for (const page of pageList) {
96
97
  // Check if this page's URL starts with the folder path
97
- if (page.url && page.url.startsWith(`/${folderName}/`) && !page.folder) {
98
+ if (page.route?.startsWith(`/${folderName}/`) && !page.folder) {
98
99
  found.push(page);
99
100
  }
100
101
  // Recursively search nested pages
package/src/server.js ADDED
@@ -0,0 +1,90 @@
1
+ import fs from "fs/promises";
2
+ import http from "http";
3
+ import path from "path";
4
+
5
+ import { baseDir, defaultConfig } from "./config.js";
6
+ import { withoutBasePath } from "./urls.js";
7
+
8
+ const contentTypes = {
9
+ ".css": "text/css; charset=utf-8",
10
+ ".gif": "image/gif",
11
+ ".html": "text/html; charset=utf-8",
12
+ ".ico": "image/x-icon",
13
+ ".jpeg": "image/jpeg",
14
+ ".jpg": "image/jpeg",
15
+ ".js": "text/javascript; charset=utf-8",
16
+ ".json": "application/json; charset=utf-8",
17
+ ".png": "image/png",
18
+ ".svg": "image/svg+xml",
19
+ ".txt": "text/plain; charset=utf-8",
20
+ ".webmanifest": "application/manifest+json; charset=utf-8",
21
+ ".webp": "image/webp",
22
+ ".xml": "application/xml; charset=utf-8",
23
+ };
24
+
25
+ const readResponseFile = async (outputDirectory, pathname) => {
26
+ const route = withoutBasePath(pathname);
27
+ const relativePath = route.replace(/^\/+/, "");
28
+ const candidates = path.extname(relativePath)
29
+ ? [relativePath]
30
+ : [relativePath, path.join(relativePath, "index.html")];
31
+
32
+ for (const candidate of candidates) {
33
+ const filePath = path.resolve(outputDirectory, candidate);
34
+ if (!filePath.startsWith(`${outputDirectory}${path.sep}`)) continue;
35
+
36
+ try {
37
+ return { body: await fs.readFile(filePath), filePath, status: 200 };
38
+ } catch (error) {
39
+ if (error.code !== "ENOENT" && error.code !== "EISDIR") throw error;
40
+ }
41
+ }
42
+
43
+ const notFoundPath = path.join(outputDirectory, "404.html");
44
+ try {
45
+ return { body: await fs.readFile(notFoundPath), filePath: notFoundPath, status: 404 };
46
+ } catch (error) {
47
+ if (error.code !== "ENOENT") throw error;
48
+ return {
49
+ body: Buffer.from("Not Found"),
50
+ filePath: "404.txt",
51
+ status: 404,
52
+ };
53
+ }
54
+ };
55
+
56
+ const startServer = async (
57
+ outputDir = "dist",
58
+ port = defaultConfig.server_port || 3000,
59
+ ) => {
60
+ const outputDirectory = path.resolve(baseDir, outputDir);
61
+ const server = http.createServer(async (request, response) => {
62
+ try {
63
+ const url = new URL(request.url || "/", "http://localhost");
64
+ const pathname = decodeURIComponent(url.pathname);
65
+ const result = await readResponseFile(outputDirectory, pathname);
66
+ const contentType =
67
+ contentTypes[path.extname(result.filePath).toLowerCase()] ||
68
+ "application/octet-stream";
69
+
70
+ response.writeHead(result.status, { "Content-Type": contentType });
71
+ response.end(request.method === "HEAD" ? undefined : result.body);
72
+ } catch (error) {
73
+ response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
74
+ response.end(`Server error: ${error.message}`);
75
+ }
76
+ });
77
+
78
+ await new Promise((resolve, reject) => {
79
+ server.once("error", reject);
80
+ server.listen(port, "127.0.0.1", resolve);
81
+ });
82
+
83
+ const address = server.address();
84
+ const actualPort = typeof address === "object" ? address.port : port;
85
+ console.log(`Swifty server running at http://localhost:${actualPort}`);
86
+ return server;
87
+ };
88
+
89
+ export { readResponseFile, startServer };
90
+ export default startServer;
package/src/sitemap.js CHANGED
@@ -2,6 +2,7 @@ import fs from "fs/promises";
2
2
  import fsExtra from "fs-extra";
3
3
  import path from "path";
4
4
  import { defaultConfig } from "./config.js";
5
+ import { withBasePath } from "./urls.js";
5
6
 
6
7
  const escapeXml = (str) =>
7
8
  String(str || "")
@@ -69,7 +70,9 @@ ${urls}
69
70
  const generateRobotsTxt = (siteUrl = "") => {
70
71
  const lines = ["User-agent: *", "Allow: /"];
71
72
  if (siteUrl) {
72
- lines.push(`Sitemap: ${siteUrl.replace(/\/$/, "")}/sitemap.xml`);
73
+ lines.push(
74
+ `Sitemap: ${siteUrl.replace(/\/$/, "")}${withBasePath("/sitemap.xml")}`,
75
+ );
73
76
  }
74
77
  return `${lines.join("\n")}\n`;
75
78
  };
package/src/urls.js ADDED
@@ -0,0 +1,116 @@
1
+ import path from "path";
2
+
3
+ import { defaultConfig } from "./config.js";
4
+
5
+ const normalizeBasePath = (value = defaultConfig.base_path) => {
6
+ if (!value || value === "/") return "";
7
+ if (typeof value !== "string") {
8
+ throw new TypeError("base_path must be a string");
9
+ }
10
+
11
+ const normalized = `/${value}`.replace(/\/+/g, "/").replace(/\/$/, "");
12
+ if (normalized.includes("..") || /[?#]/.test(normalized)) {
13
+ throw new Error(`Invalid base_path: ${value}`);
14
+ }
15
+ return normalized;
16
+ };
17
+
18
+ const withBasePath = (url, basePath = defaultConfig.base_path) => {
19
+ if (typeof url !== "string" || !url.startsWith("/") || url.startsWith("//")) {
20
+ return url;
21
+ }
22
+
23
+ const base = normalizeBasePath(basePath);
24
+ if (!base || url === base || url.startsWith(`${base}/`)) return url;
25
+ return url === "/" ? `${base}/` : `${base}${url}`;
26
+ };
27
+
28
+ const withoutBasePath = (url, basePath = defaultConfig.base_path) => {
29
+ const base = normalizeBasePath(basePath);
30
+ if (!base || typeof url !== "string") return url;
31
+ if (url === base || url === `${base}/`) return "/";
32
+ return url.startsWith(`${base}/`) ? url.slice(base.length) : url;
33
+ };
34
+
35
+ const normalizePermalink = (value) => {
36
+ if (typeof value !== "string" || !value.trim()) {
37
+ throw new TypeError("permalink must be a non-empty string");
38
+ }
39
+ if (/^(?:[a-z]+:)?\/\//i.test(value.trim())) {
40
+ throw new Error(`Invalid permalink: ${value}`);
41
+ }
42
+
43
+ const route = `/${value.trim()}`
44
+ .replace(/\\/g, "/")
45
+ .replace(/\/{2,}/g, "/")
46
+ .replace(/\/$/, "") || "/";
47
+ if (route.split("/").includes("..") || /[?#]/.test(route)) {
48
+ throw new Error(`Invalid permalink: ${value}`);
49
+ }
50
+ return route;
51
+ };
52
+
53
+ const routeToOutputPath = (route) => {
54
+ const normalized = normalizePermalink(route);
55
+ if (normalized === "/") return "index.html";
56
+
57
+ const relativeRoute = normalized.replace(/^\/+/, "");
58
+ return path.extname(relativeRoute)
59
+ ? relativeRoute
60
+ : path.join(relativeRoute, "index.html");
61
+ };
62
+
63
+ const applyBasePathToHtml = (html, basePath = defaultConfig.base_path) => {
64
+ const base = normalizeBasePath(basePath);
65
+ if (!base) return html;
66
+
67
+ const protectedBlocks = [];
68
+ const protectedHtml = html.replace(
69
+ /(<(script|style|pre|code)\b[^>]*>)([\s\S]*?)(<\/\2>)/gi,
70
+ (block, opening, tagName, content, closing) => {
71
+ const token = `__SWIFTY_BASE_PATH_BLOCK_${protectedBlocks.length}__`;
72
+ protectedBlocks.push(content);
73
+ return `${opening}${token}${closing}`;
74
+ },
75
+ );
76
+
77
+ const rewritten = protectedHtml
78
+ .replace(
79
+ /\b(href|src|action|poster)=(['"])(\/(?!\/)[^'"]*)\2/gi,
80
+ (attribute, name, quote, url) =>
81
+ `${name}=${quote}${withBasePath(url, base)}${quote}`,
82
+ )
83
+ .replace(/\bsrcset=(['"])(.*?)\1/gi, (attribute, quote, value) => {
84
+ if (!/(?:^|,)\s*\//.test(value)) return attribute;
85
+ const nextValue = value.replace(
86
+ /(^|,\s*)(\/(?!\/)[^\s,]+)/g,
87
+ (candidate, prefix, url) => `${prefix}${withBasePath(url, base)}`,
88
+ );
89
+ return `srcset=${quote}${nextValue}${quote}`;
90
+ });
91
+
92
+ return rewritten.replace(
93
+ /__SWIFTY_BASE_PATH_BLOCK_(\d+)__/g,
94
+ (token, index) => protectedBlocks[index],
95
+ );
96
+ };
97
+
98
+ const applyBasePathToCss = (css, basePath = defaultConfig.base_path) => {
99
+ const base = normalizeBasePath(basePath);
100
+ if (!base) return css;
101
+
102
+ return css.replace(
103
+ /url\(\s*(['"]?)(\/(?!\/)[^)'"\s]+)\1\s*\)/gi,
104
+ (value, quote, url) => `url(${quote}${withBasePath(url, base)}${quote})`,
105
+ );
106
+ };
107
+
108
+ export {
109
+ applyBasePathToCss,
110
+ applyBasePathToHtml,
111
+ normalizeBasePath,
112
+ normalizePermalink,
113
+ routeToOutputPath,
114
+ withBasePath,
115
+ withoutBasePath,
116
+ };
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 = {
@@ -13,6 +13,7 @@ const dirExtensions = {
13
13
  js: [".js"],
14
14
  partials: [".md", ".html"],
15
15
  data: [".json", ".yaml", ".yml"],
16
+ public: null,
16
17
  };
17
18
 
18
19
  // Build watch paths
@@ -80,6 +81,14 @@ function getChangeType(filePath) {
80
81
  return "full"; // pages, layouts, partials, config files
81
82
  }
82
83
 
84
+ const isRootConfigFile = (filePath) => {
85
+ const relativePath = path.relative(process.cwd(), filePath);
86
+ return (
87
+ !relativePath.includes(path.sep) &&
88
+ ["config.yaml", "config.yml", "config.json"].includes(relativePath)
89
+ );
90
+ };
91
+
83
92
  export default async function watch(outDir = "dist") {
84
93
  const build = await import("./build.js");
85
94
  const { copySingleAsset, optimizeSingleImage } = await import("./assets.js");
@@ -94,12 +103,13 @@ export default async function watch(outDir = "dist") {
94
103
  }
95
104
 
96
105
  // Start livereload server
106
+ const livereloadPort = defaultConfig.livereload_port || 35729;
97
107
  const lrServer = livereload.createServer({
108
+ port: livereloadPort,
98
109
  usePolling: true,
99
110
  delay: defaultConfig.watcher_delay || 100,
100
111
  });
101
112
  const outPath = path.join(process.cwd(), outDir);
102
- const livereloadPort = defaultConfig.livereload_port || 35729;
103
113
  lrServer.watch(outPath);
104
114
  console.log(`LiveReload server started on port ${livereloadPort}`);
105
115
 
@@ -128,6 +138,10 @@ export default async function watch(outDir = "dist") {
128
138
  const filename = path.basename(filePath);
129
139
 
130
140
  try {
141
+ if (isRootConfigFile(filePath)) {
142
+ await reloadConfig();
143
+ }
144
+
131
145
  if (event === "deleted") {
132
146
  // For deletions, do a full rebuild to clean up
133
147
  console.log(`🗑️ File deleted: ${filename}. Running full build...`);