basecampjs 0.0.12 → 0.0.14

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.
Files changed (77) hide show
  1. package/dist/build/assets.d.ts +36 -0
  2. package/dist/build/assets.d.ts.map +1 -0
  3. package/dist/build/assets.js +272 -0
  4. package/dist/build/assets.js.map +1 -0
  5. package/dist/build/data.d.ts +6 -0
  6. package/dist/build/data.d.ts.map +1 -0
  7. package/dist/build/data.js +33 -0
  8. package/dist/build/data.js.map +1 -0
  9. package/dist/build/pages.d.ts +19 -0
  10. package/dist/build/pages.d.ts.map +1 -0
  11. package/dist/build/pages.js +110 -0
  12. package/dist/build/pages.js.map +1 -0
  13. package/dist/build/pipeline.d.ts +6 -0
  14. package/dist/build/pipeline.d.ts.map +1 -0
  15. package/dist/build/pipeline.js +98 -0
  16. package/dist/build/pipeline.js.map +1 -0
  17. package/dist/cli.d.ts +3 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +140 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/config.d.ts +11 -0
  22. package/dist/config.d.ts.map +1 -0
  23. package/dist/config.js +60 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/dev/server.d.ts +6 -0
  26. package/dist/dev/server.d.ts.map +1 -0
  27. package/dist/dev/server.js +64 -0
  28. package/dist/dev/server.js.map +1 -0
  29. package/dist/dev/watcher.d.ts +5 -0
  30. package/dist/dev/watcher.d.ts.map +1 -0
  31. package/dist/dev/watcher.js +49 -0
  32. package/dist/dev/watcher.js.map +1 -0
  33. package/dist/index.d.ts +17 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +25 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/render/engines.d.ts +30 -0
  38. package/dist/render/engines.d.ts.map +1 -0
  39. package/dist/render/engines.js +102 -0
  40. package/dist/render/engines.js.map +1 -0
  41. package/dist/scaffolding.d.ts +25 -0
  42. package/dist/scaffolding.d.ts.map +1 -0
  43. package/dist/scaffolding.js +596 -0
  44. package/dist/scaffolding.js.map +1 -0
  45. package/dist/types.d.ts +91 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +5 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/utils/fs.d.ts +29 -0
  50. package/dist/utils/fs.d.ts.map +1 -0
  51. package/dist/utils/fs.js +104 -0
  52. package/dist/utils/fs.js.map +1 -0
  53. package/dist/utils/logger.d.ts +9 -0
  54. package/dist/utils/logger.d.ts.map +1 -0
  55. package/dist/utils/logger.js +21 -0
  56. package/dist/utils/logger.js.map +1 -0
  57. package/dist/utils/paths.d.ts +17 -0
  58. package/dist/utils/paths.d.ts.map +1 -0
  59. package/dist/utils/paths.js +55 -0
  60. package/dist/utils/paths.js.map +1 -0
  61. package/package.json +36 -6
  62. package/src/build/assets.ts +314 -0
  63. package/src/build/data.ts +32 -0
  64. package/src/build/pages.ts +143 -0
  65. package/src/build/pipeline.ts +111 -0
  66. package/src/cli.ts +155 -0
  67. package/src/config.ts +61 -0
  68. package/src/dev/server.ts +66 -0
  69. package/src/dev/watcher.ts +52 -0
  70. package/src/index.ts +45 -0
  71. package/src/render/engines.ts +139 -0
  72. package/src/scaffolding.ts +656 -0
  73. package/src/types.ts +110 -0
  74. package/src/utils/fs.ts +109 -0
  75. package/src/utils/logger.ts +27 -0
  76. package/src/utils/paths.ts +56 -0
  77. package/index.js +0 -1516
@@ -0,0 +1,314 @@
1
+ import { readFile, rename, rm, stat, writeFile } from "fs/promises";
2
+ import { basename, dirname, extname, join, relative } from "path";
3
+ import { createHash } from "crypto";
4
+ import { minify as minifyCss } from "csso";
5
+ import { minify as minifyHtml } from "html-minifier-terser";
6
+ import sharp from "sharp";
7
+ import { walkFiles, getExt, formatBytes } from "../utils/fs.js";
8
+ import { kolor } from "../utils/logger.js";
9
+ import type { CampsiteConfig, ProcessedImage, AssetMap, SitemapUrl } from "../types.js";
10
+
11
+ interface ImageSettings {
12
+ quality: number;
13
+ formats: string[];
14
+ preserveOriginal: boolean;
15
+ }
16
+
17
+ /**
18
+ * Check if an image should be processed for compression
19
+ */
20
+ export function shouldProcessImage(filePath: string, config: CampsiteConfig): boolean {
21
+ if (!config.compressPhotos) return false;
22
+ const ext = getExt(filePath);
23
+ const inputFormats = config.compressionSettings?.inputFormats || [".jpg", ".jpeg", ".png"];
24
+ return inputFormats.includes(ext);
25
+ }
26
+
27
+ /**
28
+ * Process a single image file (convert to specified formats)
29
+ */
30
+ export async function processImage(inputPath: string, _outDir: string, settings: ImageSettings): Promise<ProcessedImage[]> {
31
+ const ext = extname(inputPath);
32
+ const baseName = basename(inputPath, ext);
33
+ const dir = dirname(inputPath);
34
+
35
+ const results: ProcessedImage[] = [];
36
+ const quality = settings.quality || 80;
37
+ const formats = settings.formats || [".webp"];
38
+
39
+ for (const format of formats) {
40
+ try {
41
+ const outputName = `${baseName}${format}`;
42
+ const outputPath = join(dir, outputName);
43
+
44
+ const sharpInstance = sharp(inputPath);
45
+
46
+ if (format === ".webp") {
47
+ await sharpInstance.webp({ quality }).toFile(outputPath);
48
+ } else if (format === ".avif") {
49
+ await sharpInstance.avif({ quality }).toFile(outputPath);
50
+ } else if (format === ".jpg" || format === ".jpeg") {
51
+ await sharpInstance.jpeg({ quality }).toFile(outputPath);
52
+ } else if (format === ".png") {
53
+ await sharpInstance.png({ quality }).toFile(outputPath);
54
+ } else {
55
+ continue;
56
+ }
57
+
58
+ const stats = await stat(outputPath);
59
+ results.push({
60
+ path: outputPath,
61
+ format,
62
+ size: stats.size
63
+ });
64
+ } catch (err) {
65
+ const message = err instanceof Error ? err.message : String(err);
66
+ console.error(kolor.red(`Failed to convert ${basename(inputPath)} to ${format}: ${message}`));
67
+ }
68
+ }
69
+
70
+ return results;
71
+ }
72
+
73
+ /**
74
+ * Process all images in output directory
75
+ */
76
+ export async function processImages(outDir: string, config: CampsiteConfig): Promise<void> {
77
+ if (!config.compressPhotos) return;
78
+
79
+ const settings: ImageSettings = {
80
+ quality: config.compressionSettings?.quality || 80,
81
+ formats: config.compressionSettings?.formats || [".webp"],
82
+ preserveOriginal: config.compressionSettings?.preserveOriginal !== false
83
+ };
84
+
85
+ console.log(kolor.cyan("🖼️ Processing images..."));
86
+
87
+ const files = await walkFiles(outDir);
88
+ const imageFiles = files.filter((file) => shouldProcessImage(file, config));
89
+
90
+ if (imageFiles.length === 0) {
91
+ console.log(kolor.dim("No images found to process"));
92
+ return;
93
+ }
94
+
95
+ let totalGenerated = 0;
96
+ let totalOriginalSize = 0;
97
+ let totalConvertedSize = 0;
98
+
99
+ await Promise.all(imageFiles.map(async (file) => {
100
+ const originalStats = await stat(file);
101
+ totalOriginalSize += originalStats.size;
102
+
103
+ const results = await processImage(file, outDir, settings);
104
+
105
+ if (results.length > 0) {
106
+ const formats = results.map(r => r.format.slice(1)).join(", ");
107
+ const rel = relative(outDir, file);
108
+ console.log(kolor.dim(` ${rel} → ${formats}`));
109
+
110
+ results.forEach(r => {
111
+ totalConvertedSize += r.size;
112
+ totalGenerated++;
113
+ });
114
+ }
115
+
116
+ // Remove original if preserveOriginal is false
117
+ if (!settings.preserveOriginal && results.length > 0) {
118
+ await rm(file, { force: true });
119
+ }
120
+ }));
121
+
122
+ const savedBytes = totalOriginalSize - totalConvertedSize;
123
+ const savedPercent = totalOriginalSize > 0 ? ((savedBytes / totalOriginalSize) * 100).toFixed(1) : 0;
124
+
125
+ console.log(kolor.green(`✓ Generated ${totalGenerated} image(s)`));
126
+ if (!settings.preserveOriginal) {
127
+ console.log(kolor.green(` Saved ${formatBytes(savedBytes)} (${savedPercent}% reduction)`));
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Minify all CSS files in output directory
133
+ */
134
+ export async function minifyCSSFiles(outDir: string): Promise<void> {
135
+ const files = await walkFiles(outDir);
136
+ const cssFiles = files.filter((file) => getExt(file) === ".css");
137
+
138
+ await Promise.all(cssFiles.map(async (file) => {
139
+ try {
140
+ const css = await readFile(file, "utf8");
141
+ const { css: minified } = minifyCss(css);
142
+ await writeFile(file, minified, "utf8");
143
+ } catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ console.error(kolor.red(`Failed to minify CSS ${relative(outDir, file)}: ${message}`));
146
+ }
147
+ }));
148
+ }
149
+
150
+ /**
151
+ * Minify all HTML files in output directory
152
+ */
153
+ export async function minifyHTMLFiles(outDir: string, config: CampsiteConfig): Promise<void> {
154
+ const files = await walkFiles(outDir);
155
+ const htmlFiles = files.filter((file) => getExt(file) === ".html");
156
+
157
+ await Promise.all(htmlFiles.map(async (file) => {
158
+ try {
159
+ const html = await readFile(file, "utf8");
160
+ const minified = await minifyHtml(html, {
161
+ collapseWhitespace: true,
162
+ removeComments: true,
163
+ minifyCSS: !!config.minifyCSS,
164
+ minifyJS: true,
165
+ keepClosingSlash: true,
166
+ removeRedundantAttributes: true,
167
+ removeScriptTypeAttributes: true,
168
+ removeStyleLinkTypeAttributes: true
169
+ });
170
+ await writeFile(file, minified, "utf8");
171
+ } catch (err) {
172
+ const message = err instanceof Error ? err.message : String(err);
173
+ console.error(kolor.red(`Failed to minify HTML ${relative(outDir, file)}: ${message}`));
174
+ }
175
+ }));
176
+ }
177
+
178
+ /**
179
+ * Add content hashes to CSS and JS filenames for cache busting
180
+ */
181
+ export async function cacheBustAssets(outDir: string): Promise<AssetMap> {
182
+ const assetMap: AssetMap = {}; // original path -> hashed path
183
+ const files = await walkFiles(outDir);
184
+ const assetFiles = files.filter((file) => {
185
+ const ext = getExt(file);
186
+ return ext === ".css" || ext === ".js";
187
+ });
188
+
189
+ // Hash and rename each asset
190
+ for (const file of assetFiles) {
191
+ try {
192
+ const content = await readFile(file);
193
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 10);
194
+ const ext = extname(file);
195
+ const base = basename(file, ext);
196
+ const dir = dirname(file);
197
+ const hashedName = `${base}-${hash}${ext}`;
198
+ const hashedPath = join(dir, hashedName);
199
+
200
+ await rename(file, hashedPath);
201
+
202
+ // Store mapping of original relative path to hashed relative path
203
+ const originalRel = relative(outDir, file).replace(/\\/g, "/");
204
+ const hashedRel = relative(outDir, hashedPath).replace(/\\/g, "/");
205
+ assetMap[originalRel] = hashedRel;
206
+
207
+ // Log the cache-busted file
208
+ console.log(kolor.dim(` ${originalRel}`) + kolor.cyan(` → `) + kolor.green(hashedRel));
209
+ } catch (err) {
210
+ const message = err instanceof Error ? err.message : String(err);
211
+ console.error(kolor.red(`Failed to cache-bust ${relative(outDir, file)}: ${message}`));
212
+ }
213
+ }
214
+
215
+ // Update HTML files to reference hashed assets
216
+ const htmlFiles = files.filter((file) => getExt(file) === ".html");
217
+
218
+ for (const htmlFile of htmlFiles) {
219
+ try {
220
+ let html = await readFile(htmlFile, "utf8");
221
+ let updated = false;
222
+
223
+ // Update <link> tags (CSS)
224
+ for (const [original, hashed] of Object.entries(assetMap)) {
225
+ if (original.endsWith(".css")) {
226
+ // Match href="/path" or href="path" but ensure we stay within the tag
227
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
228
+ const pattern = new RegExp(`(<link[^>]*?\\shref=["'])/?${escaped}(["'])`, "gi");
229
+
230
+ const newHtml = html.replace(pattern, `$1/${hashed}$2`);
231
+ if (newHtml !== html) {
232
+ html = newHtml;
233
+ updated = true;
234
+ }
235
+ }
236
+ }
237
+
238
+ // Update <script> tags (JS)
239
+ for (const [original, hashed] of Object.entries(assetMap)) {
240
+ if (original.endsWith(".js")) {
241
+ // Match src="/path" or src="path" but ensure we stay within the tag
242
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
243
+ const pattern = new RegExp(`(<script[^>]*?\\ssrc=["'])/?${escaped}(["'])`, "gi");
244
+
245
+ const newHtml = html.replace(pattern, `$1/${hashed}$2`);
246
+ if (newHtml !== html) {
247
+ html = newHtml;
248
+ updated = true;
249
+ }
250
+ }
251
+ }
252
+
253
+ if (updated) {
254
+ await writeFile(htmlFile, html, "utf8");
255
+ }
256
+ } catch (err) {
257
+ const message = err instanceof Error ? err.message : String(err);
258
+ console.error(kolor.red(`Failed to update asset references in ${relative(outDir, htmlFile)}: ${message}`));
259
+ }
260
+ }
261
+
262
+ return assetMap;
263
+ }
264
+
265
+ /**
266
+ * Generate sitemap.xml for all HTML files
267
+ */
268
+ export async function generateSitemap(outDir: string, siteUrl: string): Promise<string> {
269
+ const htmlFiles = await walkFiles(outDir);
270
+ const urls: SitemapUrl[] = [];
271
+
272
+ for (const file of htmlFiles) {
273
+ if (getExt(file) !== ".html") continue;
274
+
275
+ // Get relative path from output directory
276
+ const rel = relative(outDir, file);
277
+
278
+ // Convert file path to URL path
279
+ let urlPath = rel.replace(/\\/g, "/");
280
+
281
+ // Convert index.html to directory path
282
+ if (urlPath === "index.html") {
283
+ urlPath = "";
284
+ } else if (urlPath.endsWith("/index.html")) {
285
+ urlPath = urlPath.slice(0, -11); // Remove "/index.html"
286
+ } else if (urlPath.endsWith(".html")) {
287
+ urlPath = urlPath.slice(0, -5); // Remove ".html"
288
+ }
289
+
290
+ // Get file modification time for lastmod
291
+ const stats = await stat(file);
292
+ const lastmod = stats.mtime.toISOString().split("T")[0];
293
+
294
+ // Build full URL
295
+ const fullUrl = siteUrl.replace(/\/$/, "") + "/" + urlPath;
296
+
297
+ urls.push({ loc: fullUrl, lastmod });
298
+ }
299
+
300
+ // Generate XML sitemap
301
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
302
+ xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
303
+
304
+ for (const url of urls) {
305
+ xml += " <url>\n";
306
+ xml += ` <loc>${url.loc}</loc>\n`;
307
+ xml += ` <lastmod>${url.lastmod}</lastmod>\n`;
308
+ xml += " </url>\n";
309
+ }
310
+
311
+ xml += "</urlset>\n";
312
+
313
+ return xml;
314
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync } from "fs";
2
+ import { readFile } from "fs/promises";
3
+ import { basename, relative } from "path";
4
+ import { walkFiles, getExt } from "../utils/fs.js";
5
+ import { kolor } from "../utils/logger.js";
6
+ import type { Collections } from "../types.js";
7
+
8
+ /**
9
+ * Load JSON data/collections from one or more directories
10
+ */
11
+ export async function loadData(dataDirs: string | string[]): Promise<Collections> {
12
+ const collections: Collections = {};
13
+ // Support both string and array input
14
+ const dirs = Array.isArray(dataDirs) ? dataDirs : [dataDirs];
15
+
16
+ for (const dataDir of dirs) {
17
+ if (!existsSync(dataDir)) continue;
18
+ const files = await walkFiles(dataDir);
19
+ for (const file of files) {
20
+ if (getExt(file) !== ".json") continue;
21
+ const name = basename(file, ".json");
22
+ try {
23
+ const raw = await readFile(file, "utf8");
24
+ collections[name] = JSON.parse(raw);
25
+ } catch (err) {
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ console.error(kolor.red(`Failed to load data ${relative(dataDir, file)}: ${message}`));
28
+ }
29
+ }
30
+ }
31
+ return collections;
32
+ }
@@ -0,0 +1,143 @@
1
+ import { existsSync } from "fs";
2
+ import { cp, readFile, writeFile } from "fs/promises";
3
+ import { dirname, join, relative } from "path";
4
+ import matter from "gray-matter";
5
+ import nunjucks from "nunjucks";
6
+ import { Liquid } from "liquidjs";
7
+ import { ensureDir, getExt } from "../utils/fs.js";
8
+ import { toUrlPath, normalizeUrl } from "../utils/paths.js";
9
+ import { md, loadMustachePartials, renderWithLayout, Mustache } from "../render/engines.js";
10
+ import type { CampsiteConfig, PageFrontmatter, PageContext, Collections, RenderOptions } from "../types.js";
11
+
12
+ /**
13
+ * Read and parse frontmatter from a file
14
+ */
15
+ export async function readWithFrontmatter(filePath: string): Promise<matter.GrayMatterFile<string>> {
16
+ const raw = await readFile(filePath, "utf8");
17
+ return matter(raw);
18
+ }
19
+
20
+ /**
21
+ * Build page context object for template rendering
22
+ */
23
+ export function pageContext(
24
+ frontmatter: PageFrontmatter,
25
+ html: string,
26
+ config: CampsiteConfig,
27
+ relPath: string,
28
+ data: Collections,
29
+ path: string = "/"
30
+ ): PageContext {
31
+ // Helper function to check if a URL is active/current
32
+ const isActive = (url: string): boolean => {
33
+ if (!url) return false;
34
+ return normalizeUrl(path) === normalizeUrl(url);
35
+ };
36
+
37
+ return {
38
+ site: { name: config.siteName, config },
39
+ page: { ...frontmatter, content: html, source: relPath, path },
40
+ collections: data,
41
+ isActive,
42
+ ...data
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Determine if markdown rendering should be applied
48
+ */
49
+ export function shouldRenderMarkdown(
50
+ frontmatter: PageFrontmatter | undefined,
51
+ _config: CampsiteConfig,
52
+ defaultValue: boolean
53
+ ): boolean {
54
+ if (typeof frontmatter?.markdown === "boolean") return frontmatter.markdown;
55
+ return defaultValue;
56
+ }
57
+
58
+ /**
59
+ * Render a single page file
60
+ */
61
+ export async function renderPage(filePath: string, options: RenderOptions): Promise<void> {
62
+ const { pagesDir, layoutsDir, outDir, env, liquidEnv, config, data, partialsDir } = options;
63
+ const rel = relative(pagesDir, filePath);
64
+ const ext = getExt(filePath);
65
+ const outRel = rel.replace(/\.liquid(\.html)?$/i, ".html").replace(ext, ".html");
66
+ const outPath = join(outDir, outRel);
67
+ const path = toUrlPath(outRel);
68
+ await ensureDir(dirname(outPath));
69
+
70
+ // Load Mustache partials if needed
71
+ const fileContent = await readFile(filePath, "utf8");
72
+ const partials = (ext === ".mustache" || fileContent.match(/layout:.*\.mustache/))
73
+ ? await loadMustachePartials(partialsDir)
74
+ : {};
75
+
76
+ // Helper function to finalize and write the rendered page
77
+ const finalizePage = async (pageHtml: string, frontmatter: PageFrontmatter): Promise<void> => {
78
+ const ctx = pageContext(frontmatter, pageHtml, config, rel, data, path);
79
+ const rendered = await renderWithLayout(
80
+ frontmatter.layout,
81
+ pageHtml,
82
+ ctx,
83
+ env as nunjucks.Environment,
84
+ liquidEnv as Liquid,
85
+ layoutsDir,
86
+ partials
87
+ );
88
+ await writeFile(outPath, rendered, "utf8");
89
+ };
90
+
91
+ if (ext === ".md") {
92
+ const parsed = await readWithFrontmatter(filePath);
93
+ const html = md.render(parsed.content);
94
+ await finalizePage(html, parsed.data as PageFrontmatter);
95
+ return;
96
+ }
97
+
98
+ if (ext === ".njk") {
99
+ const parsed = await readWithFrontmatter(filePath);
100
+ const ctx = pageContext(parsed.data as PageFrontmatter, parsed.content, config, rel, data, path);
101
+ // Note: path option not officially in types but supported by nunjucks
102
+ let pageHtml: string = (env as nunjucks.Environment).renderString(parsed.content, ctx);
103
+ if (shouldRenderMarkdown(parsed.data as PageFrontmatter, config, false)) {
104
+ pageHtml = md.render(pageHtml);
105
+ }
106
+ await finalizePage(pageHtml, parsed.data as PageFrontmatter);
107
+ return;
108
+ }
109
+
110
+ if (ext === ".liquid" || filePath.toLowerCase().endsWith(".liquid.html")) {
111
+ const parsed = await readWithFrontmatter(filePath);
112
+ const ctx = pageContext(parsed.data as PageFrontmatter, parsed.content, config, rel, data, path);
113
+ let pageHtml = await (liquidEnv as Liquid).parseAndRender(parsed.content, ctx) as string;
114
+ if (shouldRenderMarkdown(parsed.data as PageFrontmatter, config, false)) {
115
+ pageHtml = md.render(pageHtml);
116
+ }
117
+ await finalizePage(pageHtml, parsed.data as PageFrontmatter);
118
+ return;
119
+ }
120
+
121
+ if (ext === ".mustache") {
122
+ const parsed = await readWithFrontmatter(filePath);
123
+ const ctx = pageContext(parsed.data as PageFrontmatter, parsed.content, config, rel, data, path);
124
+ let pageHtml = Mustache.render(parsed.content, ctx, partials);
125
+ if (shouldRenderMarkdown(parsed.data as PageFrontmatter, config, false)) {
126
+ pageHtml = md.render(pageHtml);
127
+ }
128
+ await finalizePage(pageHtml, parsed.data as PageFrontmatter);
129
+ return;
130
+ }
131
+
132
+ if (ext === ".html") {
133
+ const parsed = await readWithFrontmatter(filePath);
134
+ let pageHtml = parsed.content;
135
+ if (shouldRenderMarkdown(parsed.data as PageFrontmatter, config, false)) {
136
+ pageHtml = md.render(pageHtml);
137
+ }
138
+ await finalizePage(pageHtml, parsed.data as PageFrontmatter);
139
+ return;
140
+ }
141
+
142
+ await cp(filePath, outPath);
143
+ }
@@ -0,0 +1,111 @@
1
+ import { existsSync } from "fs";
2
+ import { writeFile } from "fs/promises";
3
+ import { join, relative, resolve } from "path";
4
+ import { loadConfig } from "../config.js";
5
+ import { cleanDir, copyPublic, walkFiles } from "../utils/fs.js";
6
+ import { kolor } from "../utils/logger.js";
7
+ import { createNunjucksEnv, createLiquidEnv } from "../render/engines.js";
8
+ import { loadData } from "./data.js";
9
+ import { renderPage } from "./pages.js";
10
+ import { processImages, minifyCSSFiles, minifyHTMLFiles, cacheBustAssets, generateSitemap } from "./assets.js";
11
+ import type { BuildOptions } from "../types.js";
12
+
13
+ /**
14
+ * Main build function - orchestrates the entire build pipeline
15
+ */
16
+ export async function build(cwdArg: string = process.cwd(), options: BuildOptions = {}): Promise<void> {
17
+ const config = await loadConfig(cwdArg);
18
+ const srcDir = resolve(cwdArg, config.srcDir || "src");
19
+ const pagesDir = join(srcDir, "pages");
20
+ const layoutsDir = join(srcDir, "layouts");
21
+ const partialsDir = join(srcDir, "partials");
22
+ const dataDir = join(srcDir, "data");
23
+ const collectionsDir = join(srcDir, "collections");
24
+ const publicDir = resolve(cwdArg, "public");
25
+ const outDir = resolve(cwdArg, config.outDir || "dist");
26
+ const env = createNunjucksEnv(layoutsDir, pagesDir, srcDir, partialsDir);
27
+
28
+ // Allow user config to extend the Nunjucks environment (e.g., custom filters)
29
+ if (config?.hooks?.nunjucksEnv && typeof config.hooks.nunjucksEnv === "function") {
30
+ try {
31
+ config.hooks.nunjucksEnv(env);
32
+ } catch (err) {
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ console.error(kolor.red(`Failed to apply nunjucksEnv hook: ${message}`));
35
+ }
36
+ }
37
+
38
+ const liquidEnv = createLiquidEnv(layoutsDir, pagesDir, srcDir, partialsDir);
39
+
40
+ // Allow user config to extend the Liquid environment (e.g., custom filters)
41
+ if (config?.hooks?.liquidEnv && typeof config.hooks.liquidEnv === "function") {
42
+ try {
43
+ config.hooks.liquidEnv(liquidEnv);
44
+ } catch (err) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ console.error(kolor.red(`Failed to apply liquidEnv hook: ${message}`));
47
+ }
48
+ }
49
+
50
+ const data = await loadData([dataDir, collectionsDir]);
51
+
52
+ await cleanDir(outDir);
53
+ await copyPublic(publicDir, outDir, config.excludeFiles);
54
+
55
+ // Only compress photos during production builds, not during dev mode
56
+ const shouldCompressPhotos = options.skipImageCompression !== true && config.compressPhotos;
57
+ if (shouldCompressPhotos) {
58
+ await processImages(outDir, config);
59
+ }
60
+
61
+ const files = await walkFiles(pagesDir);
62
+ if (files.length === 0) {
63
+ console.log(kolor.yellow("No pages found in src/pages."));
64
+ return;
65
+ }
66
+
67
+ await Promise.all(files.map((file) => renderPage(file, { pagesDir, layoutsDir, outDir, env, liquidEnv, config, data, partialsDir })));
68
+
69
+ // Skip minification and cache busting in dev mode for faster rebuilds
70
+ const isDevMode = options.devMode === true;
71
+
72
+ if (!isDevMode && config.minifyCSS) {
73
+ await minifyCSSFiles(outDir);
74
+ console.log(kolor.green("CSS minified"));
75
+ }
76
+
77
+ if (!isDevMode && config.minifyHTML) {
78
+ await minifyHTMLFiles(outDir, config);
79
+ console.log(kolor.green("HTML minified"));
80
+ }
81
+
82
+ if (!isDevMode && config.cacheBustAssets) {
83
+ console.log(kolor.cyan("Cache-busting assets..."));
84
+ const assetMap = await cacheBustAssets(outDir);
85
+ const assetCount = Object.keys(assetMap).length;
86
+ if (assetCount > 0) {
87
+ console.log(kolor.green(`✓ Cache-busted ${assetCount} asset(s)`));
88
+ } else {
89
+ console.log(kolor.yellow("No assets found to cache-bust"));
90
+ }
91
+ }
92
+
93
+ // Generate robots.txt dynamically if it doesn't exist in public directory
94
+ const publicRobotsTxt = join(publicDir, "robots.txt");
95
+ const distRobotsTxt = join(outDir, "robots.txt");
96
+ if (!existsSync(publicRobotsTxt) && !existsSync(distRobotsTxt)) {
97
+ const robotsTxt = `User-agent: *\nAllow: /\n\nSitemap: ${config.siteUrl}/sitemap.xml\n`;
98
+ await writeFile(distRobotsTxt, robotsTxt, "utf8");
99
+ }
100
+
101
+ // Generate sitemap.xml dynamically if it doesn't exist in public directory
102
+ const publicSitemap = join(publicDir, "sitemap.xml");
103
+ const distSitemap = join(outDir, "sitemap.xml");
104
+ if (!existsSync(publicSitemap) && !existsSync(distSitemap)) {
105
+ const sitemapXml = await generateSitemap(outDir, config.siteUrl);
106
+ await writeFile(distSitemap, sitemapXml, "utf8");
107
+ console.log(kolor.green("✓ Generated sitemap.xml"));
108
+ }
109
+
110
+ console.log(kolor.green(`Built ${files.length} page(s) → ${relative(cwdArg, outDir)}`));
111
+ }