@daz4126/swifty 2.11.0 → 3.0.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/partials.js CHANGED
@@ -1,76 +1,270 @@
1
1
  import fs from "fs/promises";
2
2
  import fsExtra from "fs-extra";
3
3
  import path from "path";
4
- import { dirs } from "./config.js";
4
+ import { dirs, defaultConfig } from "./config.js";
5
5
  import { marked } from "marked";
6
+ import { Eta } from "eta";
7
+ import { loadData } from "./data.js";
6
8
 
7
9
  const partialCache = new Map();
10
+ const imageExtensionRegex = /\.(png|jpe?g|webp)(?=([?#]|$))/i;
11
+ const rewriteableImageTags = new Set(["a", "img", "source"]);
8
12
 
9
- const loadPartial = async (partialName) => {
13
+ // Helper to escape HTML attribute values
14
+ const escapeAttr = (str) => {
15
+ if (!str) return '';
16
+ return String(str)
17
+ .replace(/&/g, '&')
18
+ .replace(/"/g, '"')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;');
21
+ };
22
+
23
+ // Count words in content (strips HTML and markdown)
24
+ const countWords = (content) => {
25
+ if (!content) return 0;
26
+ // Strip HTML tags
27
+ let text = content.replace(/<[^>]*>/g, ' ');
28
+ // Strip markdown syntax (links, images, emphasis, etc.)
29
+ text = text.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1'); // links/images
30
+ text = text.replace(/[*_~`#]+/g, ''); // emphasis, headers
31
+ text = text.replace(/\n/g, ' ');
32
+ // Split on whitespace and filter empty strings
33
+ const words = text.split(/\s+/).filter(word => word.length > 0);
34
+ return words.length;
35
+ };
36
+
37
+ // Calculate reading time using config value or default 200 words per minute
38
+ const calculateReadingTime = (wordCount, wordsPerMinute = defaultConfig.words_per_minute || 200) => {
39
+ const minutes = Math.ceil(wordCount / wordsPerMinute);
40
+ return minutes === 1 ? '1 min read' : `${minutes} min read`;
41
+ };
42
+
43
+ // Generate Open Graph meta tags from page data
44
+ const generateOgTags = (values) => {
45
+ const meta = values.meta || {};
46
+ const tags = [];
47
+
48
+ // Basic OG tags
49
+ const title = meta.title || values.title || '';
50
+ const sitename = meta.sitename || values.sitename || '';
51
+ const description = meta.description || meta.summary || '';
52
+ const url = values.url || '';
53
+ const siteUrl = (meta.site_url || values.site_url || '').replace(/\/$/, '');
54
+ const image = meta.image || meta.og_image || '';
55
+ const type = meta.og_type || (values.folder ? 'website' : 'article');
56
+ const author = meta.author || values.author || '';
57
+
58
+ // Open Graph tags
59
+ if (title) tags.push(`<meta property="og:title" content="${escapeAttr(title)}">`);
60
+ if (sitename) tags.push(`<meta property="og:site_name" content="${escapeAttr(sitename)}">`);
61
+ if (siteUrl && url) tags.push(`<meta property="og:url" content="${escapeAttr(siteUrl + url)}">`);
62
+ tags.push(`<meta property="og:type" content="${escapeAttr(type)}">`);
63
+ if (description) tags.push(`<meta property="og:description" content="${escapeAttr(description)}">`);
64
+ if (image) {
65
+ const imageUrl = image.startsWith('http') ? image : siteUrl + image;
66
+ tags.push(`<meta property="og:image" content="${escapeAttr(imageUrl)}">`);
67
+ }
68
+
69
+ // Twitter Card tags
70
+ tags.push(`<meta name="twitter:card" content="${image ? 'summary_large_image' : 'summary'}">`);
71
+ if (title) tags.push(`<meta name="twitter:title" content="${escapeAttr(title)}">`);
72
+ if (description) tags.push(`<meta name="twitter:description" content="${escapeAttr(description)}">`);
73
+ if (image) {
74
+ const imageUrl = image.startsWith('http') ? image : siteUrl + image;
75
+ tags.push(`<meta name="twitter:image" content="${escapeAttr(imageUrl)}">`);
76
+ }
77
+
78
+ // Article-specific tags
79
+ if (type === 'article') {
80
+ if (author) tags.push(`<meta property="article:author" content="${escapeAttr(author)}">`);
81
+ if (values.dateObj) {
82
+ tags.push(`<meta property="article:published_time" content="${values.dateObj.toISOString()}">`);
83
+ }
84
+ if (meta.tags && Array.isArray(meta.tags)) {
85
+ meta.tags.forEach(tag => {
86
+ tags.push(`<meta property="article:tag" content="${escapeAttr(tag)}">`);
87
+ });
88
+ }
89
+ }
90
+
91
+ return tags.join('\n ');
92
+ };
93
+
94
+ // Configure Eta with useWith for cleaner variable access
95
+ const eta = new Eta({
96
+ views: dirs.partials,
97
+ autoEscape: false, // All output is raw (no HTML escaping)
98
+ autoTrim: false,
99
+ useWith: true, // Allows direct variable access without 'it.' prefix
100
+ });
101
+
102
+ const loadPartialData = async (partialName) => {
10
103
  if (partialCache.has(partialName)) {
11
104
  return partialCache.get(partialName);
12
105
  }
13
106
 
14
- const partialPath = path.join(dirs.partials, `${partialName}.md`);
15
- if (await fsExtra.pathExists(partialPath)) {
107
+ const partialFiles = [
108
+ { path: path.join(dirs.partials, `${partialName}.md`), raw: false },
109
+ { path: path.join(dirs.partials, `${partialName}.html`), raw: true },
110
+ ];
111
+
112
+ for (const partialFile of partialFiles) {
113
+ const partialPath = partialFile.path;
114
+ if (!(await fsExtra.pathExists(partialPath))) continue;
115
+
16
116
  const partialContent = await fs.readFile(partialPath, "utf-8");
17
- partialCache.set(partialName, partialContent); // Store in cache
18
- return partialContent;
19
- } else {
20
- console.warn(`Include "${partialName}" not found.`);
21
- return `<p>Include "${partialName}" not found.</p>`;
117
+ const partialData = { content: partialContent, raw: partialFile.raw };
118
+ partialCache.set(partialName, partialData);
119
+ return partialData;
22
120
  }
121
+
122
+ console.warn(`Include "${partialName}" not found.`);
123
+ return { content: `<p>Include "${partialName}" not found.</p>`, raw: true };
124
+ };
125
+
126
+ const loadPartial = async (partialName) => {
127
+ const partialData = await loadPartialData(partialName);
128
+ return partialData.content;
23
129
  };
24
130
 
131
+ const shouldRewriteImageUrl = (url) => {
132
+ if (!url || !url.startsWith("/images/")) return false;
133
+ return imageExtensionRegex.test(url);
134
+ };
135
+
136
+ const rewriteImageUrl = (url) => {
137
+ if (!shouldRewriteImageUrl(url)) return url;
138
+ return url.replace(imageExtensionRegex, ".webp");
139
+ };
140
+
141
+ const rewriteSrcset = (srcset) =>
142
+ srcset
143
+ .split(",")
144
+ .map((candidate) => {
145
+ const parts = candidate.trim().split(/\s+/);
146
+ if (!parts[0]) return candidate;
147
+ return [rewriteImageUrl(parts[0]), ...parts.slice(1)].join(" ");
148
+ })
149
+ .join(", ");
150
+
151
+ const rewriteLocalImageReferences = (html) =>
152
+ html.replace(/<([a-z][\w:-]*)(\s[^<>]*?)?>/gi, (tag, tagName) => {
153
+ const normalizedTagName = tagName.toLowerCase();
154
+ if (!rewriteableImageTags.has(normalizedTagName)) {
155
+ return tag;
156
+ }
157
+
158
+ return tag.replace(
159
+ /\s(src|href|srcset)=(["'])(.*?)\2/gi,
160
+ (attribute, attributeName, quote, value) => {
161
+ const nextValue =
162
+ attributeName.toLowerCase() === "srcset"
163
+ ? rewriteSrcset(value)
164
+ : rewriteImageUrl(value);
165
+ return ` ${attributeName}=${quote}${nextValue}${quote}`;
166
+ },
167
+ );
168
+ });
169
+
25
170
  const replacePlaceholders = async (template, values) => {
26
- const partialRegex = /{{\s*partial:\s*([\w-]+)\s*}}/g;
27
- const replaceAsync = async (str, regex, asyncFn) => {
28
- const matches = [];
29
- str.replace(regex, (match, ...args) => {
30
- matches.push(asyncFn(match, ...args));
31
- return match;
32
- });
33
- const results = await Promise.all(matches);
34
- return str.replace(regex, () => results.shift());
171
+ // Default values for optional variables
172
+ const defaults = {
173
+ pagination: '',
174
+ breadcrumbs: '',
175
+ nav_links: '',
176
+ links_to_children: '',
177
+ links_to_siblings: '',
178
+ links_to_tags: '',
179
+ links_to_self_and_siblings: '',
180
+ content: '',
181
+ title: '',
182
+ sitename: '',
183
+ author: '',
184
+ date: '',
185
+ created_at: '',
186
+ updated_at: '',
187
+ og_tags: '',
188
+ word_count: 0,
189
+ reading_time: '',
190
+ prev_page: '',
191
+ next_page: '',
35
192
  };
36
193
 
37
- // Protect code blocks BEFORE any placeholder replacement
38
- // Fenced blocks require closing ``` to be at start of line (after newline)
194
+ // Load data files from data/ folder
195
+ const dataFiles = await loadData();
196
+
197
+ // Generate OG tags
198
+ const og_tags = generateOgTags(values);
199
+
200
+ // Calculate word count and reading time from content
201
+ const word_count = countWords(values.content);
202
+ const reading_time = calculateReadingTime(word_count);
203
+
204
+ // Build the data object for Eta
205
+ // Merge defaults, config values, page metadata, and computed values
206
+ const templateData = {
207
+ ...defaults,
208
+ ...values,
209
+ ...(values.meta || {}),
210
+ og_tags,
211
+ word_count,
212
+ reading_time,
213
+ // Expose page namespace with meta
214
+ page: {
215
+ ...defaults,
216
+ ...values,
217
+ ...(values.meta || {}),
218
+ meta: values.meta || {},
219
+ og_tags,
220
+ word_count,
221
+ reading_time,
222
+ },
223
+ // Expose data folder contents
224
+ data: dataFiles,
225
+ };
226
+
227
+ // Protect code blocks BEFORE Eta processing
39
228
  const codeBlockRegex =
40
229
  /```[\s\S]*?\n```|`[^`\n]+`|<(pre|code)[^>]*>[\s\S]*?<\/\1>/g;
41
230
  const codeBlocks = [];
42
231
  template = template.replace(codeBlockRegex, (match) => {
43
232
  codeBlocks.push(match);
44
- return `{{CODE_BLOCK_${codeBlocks.length - 1}}}`; // Temporary placeholder
233
+ return `__CODE_BLOCK_${codeBlocks.length - 1}__`;
45
234
  });
46
235
 
47
- // Replace partial includes (now only outside code blocks)
48
- template = await replaceAsync(
49
- template,
50
- partialRegex,
51
- async (match, partialName) => {
52
- let partialContent = await loadPartial(partialName);
53
- partialContent = await replacePlaceholders(partialContent, values); // Recursive replacement
54
- return marked(partialContent); // Convert Markdown to HTML
55
- },
56
- );
236
+ // Handle <%= partial: name %> syntax
237
+ const partialRegex = /<%=\s*partial:\s*([\w-]+)\s*%>/g;
238
+ const partialMatches = [...template.matchAll(partialRegex)];
57
239
 
58
- // Replace other placeholders outside of code blocks
59
- template = template.replace(/{{\s*([^}\s]+)\s*}}/g, (match, key) => {
60
- return values.data && key in values?.data
61
- ? values.data[key]
62
- : key in values
63
- ? values[key]
64
- : match;
65
- });
240
+ for (const match of partialMatches) {
241
+ const [fullMatch, partialName] = match;
242
+ const partialData = await loadPartialData(partialName);
243
+ let partialContent = partialData.content;
244
+ partialContent = await replacePlaceholders(partialContent, values);
245
+ const renderedPartial = partialData.raw ? partialContent : marked(partialContent);
246
+ template = template.replace(fullMatch, renderedPartial);
247
+ }
248
+
249
+ // Convert <%- to <%= since autoEscape is false (all output is raw)
250
+ // This provides EJS-style syntax compatibility
251
+ template = template.replace(/<%-/g, '<%=');
252
+
253
+ // Render with Eta
254
+ try {
255
+ template = eta.renderString(template, templateData);
256
+ } catch (error) {
257
+ console.warn(`Eta template error: ${error.message}`);
258
+ }
66
259
 
67
260
  // Restore code blocks
68
261
  template = template.replace(
69
- /{{CODE_BLOCK_(\d+)}}/g,
262
+ /__CODE_BLOCK_(\d+)__/g,
70
263
  (_, index) => codeBlocks[index],
71
264
  );
72
- // replace image extensions with optimized extension
73
- template = template.replace(/\.(png|jpe?g|webp)/gi, ".webp");
265
+
266
+ template = rewriteLocalImageReferences(template);
267
+
74
268
  return template;
75
269
  };
76
270
 
package/src/rss.js CHANGED
@@ -57,8 +57,8 @@ const generateRssFeed = (feedConfig, pages, siteUrl) => {
57
57
  const items = feedPages
58
58
  .map((page) => {
59
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());
60
+ const itemTitle = escapeXml(page.meta?.title || page.title || page.name);
61
+ const itemDate = toRfc822(page.meta?.date || page.updated_at || new Date());
62
62
  const itemDescription = escapeXml(
63
63
  truncate(stripHtml(page.content), 300)
64
64
  );
@@ -98,8 +98,10 @@ const findPagesInFolder = (pages, folderName) => {
98
98
  found.push(page);
99
99
  }
100
100
  // Recursively search nested pages
101
- if (page.pages) {
102
- searchPages(page.pages);
101
+ // Use allPages for paginated folders to include all items in RSS
102
+ const childPages = page.allPages || page.pages;
103
+ if (childPages) {
104
+ searchPages(childPages);
103
105
  }
104
106
  }
105
107
  };
package/src/sitemap.js ADDED
@@ -0,0 +1,89 @@
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
+ const escapeXml = (str) =>
7
+ String(str || "")
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;")
12
+ .replace(/'/g, "&apos;");
13
+
14
+ const formatSitemapDate = (date) => {
15
+ const parsed = date instanceof Date ? date : new Date(date);
16
+ return isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
17
+ };
18
+
19
+ const flattenPages = (pages) => {
20
+ const flattened = [];
21
+
22
+ const addPages = (pageList) => {
23
+ for (const page of pageList) {
24
+ if (page.url && page.meta?.sitemap !== false) {
25
+ flattened.push(page);
26
+ }
27
+
28
+ if (page.pages) addPages(page.pages);
29
+ if (page.paginatedPages) addPages(page.paginatedPages);
30
+ }
31
+ };
32
+
33
+ addPages(pages);
34
+ return flattened;
35
+ };
36
+
37
+ const pageUrl = (siteUrl, page) => {
38
+ const baseUrl = siteUrl.replace(/\/$/, "");
39
+ return `${baseUrl}${page.url === "/" ? "/" : page.url}`;
40
+ };
41
+
42
+ const generateSitemapXml = (pages, siteUrl = "") => {
43
+ const seenUrls = new Set();
44
+ const urls = flattenPages(pages)
45
+ .filter((page) => {
46
+ if (seenUrls.has(page.url)) return false;
47
+ seenUrls.add(page.url);
48
+ return true;
49
+ })
50
+ .map((page) => {
51
+ const lastmod = formatSitemapDate(
52
+ page.updatedAtObj || page.dateObj || page.updated_at || new Date(),
53
+ );
54
+ const lastmodLine = lastmod ? `\n <lastmod>${lastmod}</lastmod>` : "";
55
+
56
+ return ` <url>
57
+ <loc>${escapeXml(pageUrl(siteUrl, page))}</loc>${lastmodLine}
58
+ </url>`;
59
+ })
60
+ .join("\n");
61
+
62
+ return `<?xml version="1.0" encoding="UTF-8"?>
63
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
64
+ ${urls}
65
+ </urlset>
66
+ `;
67
+ };
68
+
69
+ const generateRobotsTxt = (siteUrl = "") => {
70
+ const lines = ["User-agent: *", "Allow: /"];
71
+ if (siteUrl) {
72
+ lines.push(`Sitemap: ${siteUrl.replace(/\/$/, "")}/sitemap.xml`);
73
+ }
74
+ return `${lines.join("\n")}\n`;
75
+ };
76
+
77
+ const generateSeoFiles = async (pages, outputDir) => {
78
+ const siteUrl = defaultConfig.site_url || defaultConfig.url || "";
79
+ const sitemapXml = generateSitemapXml(pages, siteUrl);
80
+ const robotsTxt = generateRobotsTxt(siteUrl);
81
+
82
+ await fsExtra.ensureDir(outputDir);
83
+ await Promise.all([
84
+ fs.writeFile(path.join(outputDir, "sitemap.xml"), sitemapXml),
85
+ fs.writeFile(path.join(outputDir, "robots.txt"), robotsTxt),
86
+ ]);
87
+ };
88
+
89
+ export { flattenPages, generateRobotsTxt, generateSeoFiles, generateSitemapXml };
package/src/watcher.js CHANGED
@@ -2,6 +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
6
 
6
7
  // Directory to extension mapping for filtering
7
8
  const dirExtensions = {
@@ -11,6 +12,7 @@ const dirExtensions = {
11
12
  css: [".css"],
12
13
  js: [".js"],
13
14
  partials: [".md", ".html"],
15
+ data: [".json", ".yaml", ".yml"],
14
16
  };
15
17
 
16
18
  // Build watch paths
@@ -74,6 +76,7 @@ function getChangeType(filePath) {
74
76
  if (topDir === "css") return "css";
75
77
  if (topDir === "js") return "js";
76
78
  if (topDir === "images") return "image";
79
+ if (topDir === "data") return "data";
77
80
  return "full"; // pages, layouts, partials, config files
78
81
  }
79
82
 
@@ -82,6 +85,7 @@ export default async function watch(outDir = "dist") {
82
85
  const { copySingleAsset, optimizeSingleImage } = await import("./assets.js");
83
86
  const { resetCaches } = await import("./layout.js");
84
87
  const { clearCache: clearPartialCache } = await import("./partials.js");
88
+ const { clearDataCache } = await import("./data.js");
85
89
  const watchPaths = getWatchPaths();
86
90
 
87
91
  if (watchPaths.length === 0) {
@@ -92,22 +96,25 @@ export default async function watch(outDir = "dist") {
92
96
  // Start livereload server
93
97
  const lrServer = livereload.createServer({
94
98
  usePolling: true,
95
- delay: 100,
99
+ delay: defaultConfig.watcher_delay || 100,
96
100
  });
97
101
  const outPath = path.join(process.cwd(), outDir);
102
+ const livereloadPort = defaultConfig.livereload_port || 35729;
98
103
  lrServer.watch(outPath);
99
- console.log("LiveReload server started on port 35729");
104
+ console.log(`LiveReload server started on port ${livereloadPort}`);
100
105
 
101
106
  // Initialize watcher (chokidar 4.x)
102
107
  // usePolling needed for network/cloud drives that don't emit native fs events
108
+ const watcherInterval = defaultConfig.watcher_interval || 500;
109
+ const watcherDelay = defaultConfig.watcher_delay || 100;
103
110
  const watcher = chokidar.watch(watchPaths, {
104
111
  persistent: true,
105
112
  ignoreInitial: true,
106
113
  usePolling: true,
107
- interval: 500,
114
+ interval: watcherInterval,
108
115
  awaitWriteFinish: {
109
- stabilityThreshold: 200,
110
- pollInterval: 100,
116
+ stabilityThreshold: watcherDelay * 2,
117
+ pollInterval: watcherDelay,
111
118
  },
112
119
  });
113
120
 
@@ -123,25 +130,45 @@ export default async function watch(outDir = "dist") {
123
130
  try {
124
131
  if (event === "deleted") {
125
132
  // For deletions, do a full rebuild to clean up
126
- console.log(`File deleted: ${filename}. Running full build...`);
133
+ console.log(`🗑️ File deleted: ${filename}. Running full build...`);
134
+ const rebuildStart = performance.now();
127
135
  clearPartialCache();
128
136
  await resetCaches();
129
137
  await build.default(outDir);
138
+ const rebuildTime = performance.now() - rebuildStart;
139
+ console.log(` Rebuild completed in ${(rebuildTime / 1000).toFixed(2)}s`);
130
140
  } else if (changeType === "css" || changeType === "js") {
131
141
  // Full rebuild for CSS/JS to update cache-busting query strings in HTML
132
- console.log(`Asset ${event}: ${filename}. Rebuilding...`);
142
+ console.log(`🎨 Asset ${event}: ${filename}. Rebuilding...`);
143
+ const rebuildStart = performance.now();
133
144
  await resetCaches();
134
145
  await build.default(outDir);
146
+ const rebuildTime = performance.now() - rebuildStart;
147
+ console.log(` Rebuild completed in ${(rebuildTime / 1000).toFixed(2)}s`);
135
148
  } else if (changeType === "image") {
136
149
  // Incremental: just process the changed image
137
- console.log(`Image ${event}: ${filename}`);
150
+ const imageStart = performance.now();
151
+ console.log(`🖼️ Image ${event}: ${filename}`);
138
152
  await optimizeSingleImage(filePath, outDir);
153
+ const imageTime = performance.now() - imageStart;
154
+ console.log(` Optimized in ${(imageTime / 1000).toFixed(2)}s`);
155
+ } else if (changeType === "data") {
156
+ // Data file changed - clear data cache and rebuild
157
+ console.log(`📊 Data ${event}: ${filename}. Rebuilding...`);
158
+ const rebuildStart = performance.now();
159
+ clearDataCache();
160
+ await build.default(outDir);
161
+ const rebuildTime = performance.now() - rebuildStart;
162
+ console.log(` Rebuild completed in ${(rebuildTime / 1000).toFixed(2)}s`);
139
163
  } else {
140
164
  // Full rebuild for pages, layouts, partials, config, and new CSS/JS files
141
- console.log(`File ${event}: ${filename}. Running full build...`);
165
+ console.log(`📝 File ${event}: ${filename}. Running full build...`);
166
+ const rebuildStart = performance.now();
142
167
  clearPartialCache();
143
168
  await resetCaches();
144
169
  await build.default(outDir);
170
+ const rebuildTime = performance.now() - rebuildStart;
171
+ console.log(` Rebuild completed in ${(rebuildTime / 1000).toFixed(2)}s`);
145
172
  }
146
173
  // Trigger browser refresh
147
174
  lrServer.refresh("/");