personal website
1#!/usr/bin/env bun
2import plugin from "bun-plugin-tailwind";
3import { existsSync } from "fs";
4import { rm, cp, readdir, writeFile, unlink } from "fs/promises";
5import path from "path";
6
7export function buildVibesHtml(files: string[]) {
8 const items = files.map((f) => {
9 const src = `/vibes/${f}`
10 if (/\.(mp4|mov|webm)$/i.test(f))
11 return `<video src="${src}" controls loop muted preload="none"></video>`
12 return `<img src="${src}" alt="" loading="lazy">`
13 }).join("\n ")
14
15 return `<!DOCTYPE html>
16<html lang="en">
17<head>
18 <meta charset="UTF-8">
19 <meta name="viewport" content="width=device-width, initial-scale=1.0">
20 <title>vibes</title>
21 <style>
22 * { margin: 0; padding: 0; box-sizing: border-box; }
23 body { background: #1a1a1a; }
24 a.back {
25 position: fixed; top: 1.5rem; left: 1.5rem; z-index: 10;
26 padding: 0.4rem 1rem; border-radius: 999px;
27 background: #2a2a2a; color: #ccc; text-decoration: none;
28 font: 0.85rem monospace; border: 1px solid #444;
29 }
30 a.back:hover { background: #333; color: #fff; }
31 .gallery {
32 columns: 4 180px; column-gap: 6px;
33 padding: 5rem 1rem 2rem;
34 }
35 .gallery img, .gallery video {
36 width: 100%; height: auto; display: block; margin-bottom: 6px;
37 }
38 </style>
39</head>
40<body>
41 <a class="back" href="/">← back</a>
42 <div class="gallery">
43 ${items}
44 </div>
45</body>
46</html>`
47}
48
49if (process.argv.includes("--help") || process.argv.includes("-h")) {
50 console.log(`
51🏗️ Bun Build Script
52
53Usage: bun run build.ts [options]
54
55Common Options:
56 --outdir <path> Output directory (default: "dist")
57 --minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
58 --sourcemap <type> Sourcemap type: none|linked|inline|external
59 --target <target> Build target: browser|bun|node
60 --format <format> Output format: esm|cjs|iife
61 --splitting Enable code splitting
62 --packages <type> Package handling: bundle|external
63 --public-path <path> Public path for assets
64 --env <mode> Environment handling: inline|disable|prefix*
65 --conditions <list> Package.json export conditions (comma separated)
66 --external <list> External packages (comma separated)
67 --banner <text> Add banner text to output
68 --footer <text> Add footer text to output
69 --define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
70 --help, -h Show this help message
71
72Example:
73 bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
74`);
75 process.exit(0);
76}
77
78const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
79
80const parseValue = (value: string): any => {
81 if (value === "true") return true;
82 if (value === "false") return false;
83
84 if (/^\d+$/.test(value)) return parseInt(value, 10);
85 if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
86
87 if (value.includes(",")) return value.split(",").map(v => v.trim());
88
89 return value;
90};
91
92function parseArgs(): Partial<Bun.BuildConfig> {
93 const config: Partial<Bun.BuildConfig> = {};
94 const args = process.argv.slice(2);
95
96 for (let i = 0; i < args.length; i++) {
97 const arg = args[i];
98 if (arg === undefined) continue;
99 if (!arg.startsWith("--")) continue;
100
101 if (arg.startsWith("--no-")) {
102 const key = toCamelCase(arg.slice(5));
103 config[key] = false;
104 continue;
105 }
106
107 if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
108 const key = toCamelCase(arg.slice(2));
109 config[key] = true;
110 continue;
111 }
112
113 let key: string;
114 let value: string;
115
116 if (arg.includes("=")) {
117 [key, value] = arg.slice(2).split("=", 2) as [string, string];
118 } else {
119 key = arg.slice(2);
120 value = args[++i] ?? "";
121 }
122
123 key = toCamelCase(key);
124
125 if (key.includes(".")) {
126 const [parentKey, childKey] = key.split(".");
127 config[parentKey] = config[parentKey] || {};
128 config[parentKey][childKey] = parseValue(value);
129 } else {
130 config[key] = parseValue(value);
131 }
132 }
133
134 return config;
135}
136
137const formatFileSize = (bytes: number): string => {
138 const units = ["B", "KB", "MB", "GB"];
139 let size = bytes;
140 let unitIndex = 0;
141
142 while (size >= 1024 && unitIndex < units.length - 1) {
143 size /= 1024;
144 unitIndex++;
145 }
146
147 return `${size.toFixed(2)} ${units[unitIndex]}`;
148};
149
150console.log("\n🚀 Starting build process...\n");
151
152const cliConfig = parseArgs();
153const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
154
155if (existsSync(outdir)) {
156 console.log(`🗑️ Cleaning previous build at ${outdir}`);
157 await rm(outdir, { recursive: true, force: true });
158}
159
160const start = performance.now();
161
162const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
163 .map(a => path.resolve("src", a))
164 .filter(dir => !dir.includes("node_modules"));
165console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
166
167const result = await Bun.build({
168 entrypoints,
169 outdir,
170 plugins: [plugin],
171 minify: true,
172 target: "browser",
173 sourcemap: "linked",
174 define: {
175 "process.env.NODE_ENV": JSON.stringify("production"),
176 },
177 ...cliConfig,
178});
179
180const end = performance.now();
181
182const outputTable = result.outputs.map(output => ({
183 File: path.relative(process.cwd(), output.path),
184 Type: output.kind,
185 Size: formatFileSize(output.size),
186}));
187
188console.table(outputTable);
189const buildTime = (end - start).toFixed(2);
190
191// Copy public folder to dist
192const publicDir = path.join(process.cwd(), "public");
193if (existsSync(publicDir)) {
194 console.log("📁 Copying public folder to dist...");
195 await cp(publicDir, outdir, { recursive: true });
196}
197
198// Convert vibes images to WebP in dist (originals in public/ stay untouched)
199const distVibesDir = path.join(outdir, "vibes");
200if (existsSync(distVibesDir)) {
201 const files = await readdir(distVibesDir);
202 const convertable = files.filter((f) => /\.(jpg|jpeg|png|gif|avif|bmp|tiff)$/i.test(f));
203
204 if (convertable.length > 0) {
205 console.log(`\n🖼️ Converting ${convertable.length} images to WebP...`);
206 await Promise.all(
207 convertable.map(async (f) => {
208 const src = path.join(distVibesDir, f);
209 const dest = path.join(distVibesDir, f.replace(/\.[^.]+$/, ".webp"));
210 const proc = Bun.spawn(["magick", src, "-quality", "80", dest]);
211 await proc.exited;
212 await unlink(src);
213 })
214 );
215 console.log("✅ WebP conversion done");
216 }
217
218 // Generate vibes/index.html
219 const finalFiles = (await readdir(distVibesDir)).filter((f) => f !== "index.html");
220 await writeFile(path.join(distVibesDir, "index.html"), buildVibesHtml(finalFiles));
221 console.log(`📦 Generated vibes/index.html with ${finalFiles.length} files`);
222}
223
224console.log(`\n✅ Build completed in ${buildTime}ms\n`);