@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/swifty",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
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 template = await createTemplate();
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, template };
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 { template, applyLayoutAndWrapContent } from "./layout.js";
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.mtime).toLocaleDateString(
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(a.updated_at) - new Date(b.updated_at);
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(dirs.partials, defaultConfig.default_link_name || "links");
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
- // Replace partial includes
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
- // Replace other placeholders **only outside of code blocks**
48
- // Fenced blocks require closing ``` to be at start of line (after newline)
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
- export { loadPartial, replacePlaceholders };
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, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&apos;");
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