@daz4126/swifty 2.6.0 → 2.7.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 +1 -0
- package/package.json +1 -1
- package/src/build.js +2 -0
- package/src/layout.js +14 -2
- package/src/pages.js +8 -4
- package/src/partials.js +19 -12
- package/src/rss.js +145 -0
- package/src/watcher.js +9 -3
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ Swifty uses convention over configuration to make it super simple to build blazi
|
|
|
12
12
|
- **Auto-injected CSS/JS** from your css/ and js/ folders
|
|
13
13
|
- **Code syntax highlighting** via highlight.js
|
|
14
14
|
- **Tags and navigation** generated automatically
|
|
15
|
+
- **RSS feed generation** for blogs and content folders
|
|
15
16
|
- **Optional [Turbo](https://turbo.hotwired.dev/)** for SPA-like transitions
|
|
16
17
|
|
|
17
18
|
## Quickstart
|
package/package.json
CHANGED
package/src/build.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { copyAssets, optimizeImages } from "./assets.js";
|
|
2
2
|
import { generatePages, createPages, addLinks } from "./pages.js";
|
|
3
|
+
import { generateRssFeeds } from "./rss.js";
|
|
3
4
|
import { dirs } from "./config.js";
|
|
4
5
|
|
|
5
6
|
export default async function build(outputDir) {
|
|
@@ -8,4 +9,5 @@ export default async function build(outputDir) {
|
|
|
8
9
|
const pages = await generatePages(dirs.pages);
|
|
9
10
|
await addLinks(pages);
|
|
10
11
|
await createPages(pages, outputDir);
|
|
12
|
+
await generateRssFeeds(pages, outputDir);
|
|
11
13
|
}
|
package/src/layout.js
CHANGED
|
@@ -5,6 +5,8 @@ import { dirs, baseDir, defaultConfig } from "./config.js";
|
|
|
5
5
|
import { getCssImports, getJsImports } from "./assets.js";
|
|
6
6
|
|
|
7
7
|
const layoutCache = new Map();
|
|
8
|
+
let template = null;
|
|
9
|
+
|
|
8
10
|
const getLayout = async (layoutName) => {
|
|
9
11
|
if (!layoutName) return null;
|
|
10
12
|
if (!layoutCache.has(layoutName)) {
|
|
@@ -44,6 +46,16 @@ const applyLayoutAndWrapContent = async (page,content) => {
|
|
|
44
46
|
return layoutContent.replace(/\{\{\s*content\s*\}\}/g, () => content);
|
|
45
47
|
};
|
|
46
48
|
|
|
47
|
-
const
|
|
49
|
+
const getTemplate = async () => {
|
|
50
|
+
if (!template) {
|
|
51
|
+
template = await createTemplate();
|
|
52
|
+
}
|
|
53
|
+
return template;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const resetCaches = async () => {
|
|
57
|
+
layoutCache.clear();
|
|
58
|
+
template = await createTemplate();
|
|
59
|
+
};
|
|
48
60
|
|
|
49
|
-
export { getLayout, applyLayoutAndWrapContent,
|
|
61
|
+
export { getLayout, applyLayoutAndWrapContent, getTemplate, resetCaches };
|
package/src/pages.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// pages.js
|
|
2
2
|
import { replacePlaceholders } from "./partials.js";
|
|
3
3
|
import { dirs, defaultConfig, loadConfig } from "./config.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getTemplate, applyLayoutAndWrapContent } from "./layout.js";
|
|
5
5
|
import { marked } from "marked";
|
|
6
6
|
import matter from "gray-matter";
|
|
7
7
|
import fs from "fs/promises";
|
|
@@ -78,7 +78,7 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
78
78
|
undefined,
|
|
79
79
|
config.dateFormat,
|
|
80
80
|
),
|
|
81
|
-
date: new Date(stats.
|
|
81
|
+
date: new Date(stats.birthtime).toLocaleDateString(
|
|
82
82
|
undefined,
|
|
83
83
|
config.dateFormat,
|
|
84
84
|
),
|
|
@@ -111,7 +111,7 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
111
111
|
if (a.data.position && b.data.position) {
|
|
112
112
|
return a.data.position - b.data.position;
|
|
113
113
|
}
|
|
114
|
-
return new Date(
|
|
114
|
+
return new Date(b.created_at) - new Date(b.created_at);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
page.content = await generateLinkList(page.filename, page.pages);
|
|
@@ -177,7 +177,10 @@ const generatePages = async (sourceDir, baseDir = sourceDir, parent) => {
|
|
|
177
177
|
const generateLinkList = async (name, pages) => {
|
|
178
178
|
const partial = `${name}.md`;
|
|
179
179
|
const partialPath = path.join(dirs.partials, partial);
|
|
180
|
-
const linksPath = path.join(
|
|
180
|
+
const linksPath = path.join(
|
|
181
|
+
dirs.partials,
|
|
182
|
+
defaultConfig.default_link_name || "links",
|
|
183
|
+
);
|
|
181
184
|
// Check if either file exists in the 'partials' folder
|
|
182
185
|
const fileExists = await fsExtra.pathExists(partialPath);
|
|
183
186
|
const defaultExists = await fsExtra.pathExists(linksPath);
|
|
@@ -200,6 +203,7 @@ const render = async (page) => {
|
|
|
200
203
|
const htmlContent = marked.parse(replacedContent); // Markdown processed once
|
|
201
204
|
const wrappedContent = await applyLayoutAndWrapContent(page, htmlContent);
|
|
202
205
|
// Use function to avoid $` special replacement patterns in content
|
|
206
|
+
const template = await getTemplate();
|
|
203
207
|
const htmlWithTemplate = template.replace(
|
|
204
208
|
/\{\{\s*content\s*\}\}/g,
|
|
205
209
|
() => wrappedContent,
|
package/src/partials.js
CHANGED
|
@@ -34,7 +34,17 @@ const replacePlaceholders = async (template, values) => {
|
|
|
34
34
|
return str.replace(regex, () => results.shift());
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
//
|
|
37
|
+
// Protect code blocks BEFORE any placeholder replacement
|
|
38
|
+
// Fenced blocks require closing ``` to be at start of line (after newline)
|
|
39
|
+
const codeBlockRegex =
|
|
40
|
+
/```[\s\S]*?\n```|`[^`\n]+`|<(pre|code)[^>]*>[\s\S]*?<\/\1>/g;
|
|
41
|
+
const codeBlocks = [];
|
|
42
|
+
template = template.replace(codeBlockRegex, (match) => {
|
|
43
|
+
codeBlocks.push(match);
|
|
44
|
+
return `{{CODE_BLOCK_${codeBlocks.length - 1}}}`; // Temporary placeholder
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Replace partial includes (now only outside code blocks)
|
|
38
48
|
template = await replaceAsync(
|
|
39
49
|
template,
|
|
40
50
|
partialRegex,
|
|
@@ -44,16 +54,8 @@ const replacePlaceholders = async (template, values) => {
|
|
|
44
54
|
return marked(partialContent); // Convert Markdown to HTML
|
|
45
55
|
},
|
|
46
56
|
);
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
const codeBlockRegex =
|
|
50
|
-
/```[\s\S]*?\n```|`[^`\n]+`|<(pre|code)[^>]*>[\s\S]*?<\/\1>/g;
|
|
51
|
-
const codeBlocks = [];
|
|
52
|
-
template = template.replace(codeBlockRegex, (match) => {
|
|
53
|
-
codeBlocks.push(match);
|
|
54
|
-
return `{{CODE_BLOCK_${codeBlocks.length - 1}}}`; // Temporary placeholder
|
|
55
|
-
});
|
|
56
|
-
// Replace placeholders outside of code blocks
|
|
57
|
+
|
|
58
|
+
// Replace other placeholders outside of code blocks
|
|
57
59
|
template = template.replace(/{{\s*([^}\s]+)\s*}}/g, (match, key) => {
|
|
58
60
|
return values.data && key in values?.data
|
|
59
61
|
? values.data[key]
|
|
@@ -61,6 +63,7 @@ const replacePlaceholders = async (template, values) => {
|
|
|
61
63
|
? values[key]
|
|
62
64
|
: match;
|
|
63
65
|
});
|
|
66
|
+
|
|
64
67
|
// Restore code blocks
|
|
65
68
|
template = template.replace(
|
|
66
69
|
/{{CODE_BLOCK_(\d+)}}/g,
|
|
@@ -71,4 +74,8 @@ const replacePlaceholders = async (template, values) => {
|
|
|
71
74
|
return template;
|
|
72
75
|
};
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
const clearCache = () => {
|
|
78
|
+
partialCache.clear();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export { loadPartial, replacePlaceholders, clearCache };
|
package/src/rss.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import fsExtra from "fs-extra";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { defaultConfig } from "./config.js";
|
|
5
|
+
|
|
6
|
+
// Escape XML special characters
|
|
7
|
+
const escapeXml = (str) => {
|
|
8
|
+
if (!str) return "";
|
|
9
|
+
return str
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """)
|
|
14
|
+
.replace(/'/g, "'");
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Strip HTML tags for description
|
|
18
|
+
const stripHtml = (html) => {
|
|
19
|
+
if (!html) return "";
|
|
20
|
+
return html.replace(/<[^>]*>/g, "").trim();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Truncate text for description
|
|
24
|
+
const truncate = (text, maxLength = 200) => {
|
|
25
|
+
if (!text || text.length <= maxLength) return text;
|
|
26
|
+
return text.substring(0, maxLength).trim() + "...";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Convert date to RFC 822 format for RSS
|
|
30
|
+
const toRfc822 = (date) => {
|
|
31
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
32
|
+
return d.toUTCString();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Generate RSS XML for a set of pages
|
|
36
|
+
const generateRssFeed = (feedConfig, pages, siteUrl) => {
|
|
37
|
+
const {
|
|
38
|
+
title = defaultConfig.sitename || "RSS Feed",
|
|
39
|
+
description = `Latest updates from ${title}`,
|
|
40
|
+
folder,
|
|
41
|
+
} = typeof feedConfig === "string" ? { folder: feedConfig } : feedConfig;
|
|
42
|
+
|
|
43
|
+
const feedUrl = `${siteUrl}/${folder}/rss.xml`;
|
|
44
|
+
const feedLink = `${siteUrl}/${folder}`;
|
|
45
|
+
|
|
46
|
+
// Sort pages by date (newest first)
|
|
47
|
+
const sortedPages = [...pages].sort((a, b) => {
|
|
48
|
+
const dateA = new Date(a.data?.date || a.updated_at || 0);
|
|
49
|
+
const dateB = new Date(b.data?.date || b.updated_at || 0);
|
|
50
|
+
return dateB - dateA;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Limit to most recent items (default 20)
|
|
54
|
+
const maxItems = defaultConfig.rss_max_items || 20;
|
|
55
|
+
const feedPages = sortedPages.slice(0, maxItems);
|
|
56
|
+
|
|
57
|
+
const items = feedPages
|
|
58
|
+
.map((page) => {
|
|
59
|
+
const itemUrl = `${siteUrl}${page.url}`;
|
|
60
|
+
const itemTitle = escapeXml(page.data?.title || page.title || page.name);
|
|
61
|
+
const itemDate = toRfc822(page.data?.date || page.updated_at || new Date());
|
|
62
|
+
const itemDescription = escapeXml(
|
|
63
|
+
truncate(stripHtml(page.content), 300)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return ` <item>
|
|
67
|
+
<title>${itemTitle}</title>
|
|
68
|
+
<link>${itemUrl}</link>
|
|
69
|
+
<guid isPermaLink="true">${itemUrl}</guid>
|
|
70
|
+
<pubDate>${itemDate}</pubDate>
|
|
71
|
+
<description>${itemDescription}</description>
|
|
72
|
+
</item>`;
|
|
73
|
+
})
|
|
74
|
+
.join("\n");
|
|
75
|
+
|
|
76
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
77
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
78
|
+
<channel>
|
|
79
|
+
<title>${escapeXml(title)}</title>
|
|
80
|
+
<link>${feedLink}</link>
|
|
81
|
+
<description>${escapeXml(description)}</description>
|
|
82
|
+
<language>${defaultConfig.language || "en"}</language>
|
|
83
|
+
<lastBuildDate>${toRfc822(new Date())}</lastBuildDate>
|
|
84
|
+
<atom:link href="${feedUrl}" rel="self" type="application/rss+xml"/>
|
|
85
|
+
${items}
|
|
86
|
+
</channel>
|
|
87
|
+
</rss>`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Find pages belonging to a specific folder
|
|
91
|
+
const findPagesInFolder = (pages, folderName) => {
|
|
92
|
+
const found = [];
|
|
93
|
+
|
|
94
|
+
const searchPages = (pageList) => {
|
|
95
|
+
for (const page of pageList) {
|
|
96
|
+
// Check if this page's URL starts with the folder path
|
|
97
|
+
if (page.url && page.url.startsWith(`/${folderName}/`) && !page.folder) {
|
|
98
|
+
found.push(page);
|
|
99
|
+
}
|
|
100
|
+
// Recursively search nested pages
|
|
101
|
+
if (page.pages) {
|
|
102
|
+
searchPages(page.pages);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
searchPages(pages);
|
|
108
|
+
return found;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Generate all RSS feeds based on config
|
|
112
|
+
const generateRssFeeds = async (pages, outputDir) => {
|
|
113
|
+
const rssFeeds = defaultConfig.rss_feeds;
|
|
114
|
+
if (!rssFeeds || !Array.isArray(rssFeeds) || rssFeeds.length === 0) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const siteUrl = defaultConfig.site_url || defaultConfig.url || "";
|
|
119
|
+
|
|
120
|
+
for (const feedConfig of rssFeeds) {
|
|
121
|
+
const folder =
|
|
122
|
+
typeof feedConfig === "string" ? feedConfig : feedConfig.folder;
|
|
123
|
+
|
|
124
|
+
if (!folder) {
|
|
125
|
+
console.warn("RSS feed config missing folder name, skipping");
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const folderPages = findPagesInFolder(pages, folder);
|
|
130
|
+
|
|
131
|
+
if (folderPages.length === 0) {
|
|
132
|
+
console.warn(`No pages found for RSS feed: ${folder}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const feedXml = generateRssFeed(feedConfig, folderPages, siteUrl);
|
|
137
|
+
const feedPath = path.join(outputDir, folder, "rss.xml");
|
|
138
|
+
|
|
139
|
+
await fsExtra.ensureDir(path.dirname(feedPath));
|
|
140
|
+
await fs.writeFile(feedPath, feedXml);
|
|
141
|
+
console.log(`Generated RSS feed: ${folder}/rss.xml (${folderPages.length} items)`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export { generateRssFeeds, generateRssFeed, findPagesInFolder };
|
package/src/watcher.js
CHANGED
|
@@ -80,6 +80,8 @@ function getChangeType(filePath) {
|
|
|
80
80
|
export default async function watch(outDir = "dist") {
|
|
81
81
|
const build = await import("./build.js");
|
|
82
82
|
const { copySingleAsset, optimizeSingleImage } = await import("./assets.js");
|
|
83
|
+
const { resetCaches } = await import("./layout.js");
|
|
84
|
+
const { clearCache: clearPartialCache } = await import("./partials.js");
|
|
83
85
|
const watchPaths = getWatchPaths();
|
|
84
86
|
|
|
85
87
|
if (watchPaths.length === 0) {
|
|
@@ -122,9 +124,11 @@ export default async function watch(outDir = "dist") {
|
|
|
122
124
|
if (event === "deleted") {
|
|
123
125
|
// For deletions, do a full rebuild to clean up
|
|
124
126
|
console.log(`File deleted: ${filename}. Running full build...`);
|
|
127
|
+
clearPartialCache();
|
|
128
|
+
await resetCaches();
|
|
125
129
|
await build.default(outDir);
|
|
126
|
-
} else if (changeType === "css" || changeType === "js") {
|
|
127
|
-
// Incremental: just copy the changed asset
|
|
130
|
+
} else if ((changeType === "css" || changeType === "js") && event === "changed") {
|
|
131
|
+
// Incremental: just copy the changed asset (only for modifications, not additions)
|
|
128
132
|
console.log(`Asset ${event}: ${filename}`);
|
|
129
133
|
await copySingleAsset(filePath, outDir);
|
|
130
134
|
} else if (changeType === "image") {
|
|
@@ -132,8 +136,10 @@ export default async function watch(outDir = "dist") {
|
|
|
132
136
|
console.log(`Image ${event}: ${filename}`);
|
|
133
137
|
await optimizeSingleImage(filePath, outDir);
|
|
134
138
|
} else {
|
|
135
|
-
// Full rebuild for pages, layouts, partials, config
|
|
139
|
+
// Full rebuild for pages, layouts, partials, config, and new CSS/JS files
|
|
136
140
|
console.log(`File ${event}: ${filename}. Running full build...`);
|
|
141
|
+
clearPartialCache();
|
|
142
|
+
await resetCaches();
|
|
137
143
|
await build.default(outDir);
|
|
138
144
|
}
|
|
139
145
|
// Trigger browser refresh
|