@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/MIGRATION.md +60 -0
- package/README.md +7 -2
- package/package.json +20 -10
- package/src/assets.js +124 -29
- package/src/build.js +40 -6
- package/src/cli.js +59 -18
- package/src/config.js +107 -13
- package/src/data.js +26 -21
- package/src/frontmatter.js +27 -0
- package/src/index.js +10 -0
- package/src/init.js +4 -0
- package/src/layout.js +8 -1
- package/src/minify.js +33 -118
- package/src/pages.js +128 -50
- package/src/partials.js +10 -6
- package/src/rss.js +4 -3
- package/src/server.js +90 -0
- package/src/sitemap.js +4 -1
- package/src/urls.js +116 -0
- package/src/watcher.js +16 -2
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
page
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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.
|
|
361
|
-
await fsExtra.ensureDir(
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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}
|
|
44
|
-
const feedLink = `${siteUrl}
|
|
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.
|
|
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(
|
|
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...`);
|