blumenjs 0.1.6 → 0.2.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.
@@ -68,7 +68,7 @@ async function select(question, options) {
68
68
  input: process.stdin,
69
69
  output: process.stdout
70
70
  });
71
- return new Promise((resolve2) => {
71
+ return new Promise((resolve3) => {
72
72
  console.log(`
73
73
  ${c.bold}${question}${c.reset}`);
74
74
  options.forEach((opt, i) => {
@@ -79,9 +79,9 @@ async function select(question, options) {
79
79
  rl.close();
80
80
  const idx = parseInt(answer, 10) - 1;
81
81
  if (idx >= 0 && idx < options.length) {
82
- resolve2(options[idx]);
82
+ resolve3(options[idx]);
83
83
  } else {
84
- resolve2(options[0]);
84
+ resolve3(options[0]);
85
85
  }
86
86
  });
87
87
  });
@@ -93,14 +93,14 @@ async function confirm(question, defaultYes = true) {
93
93
  output: process.stdout
94
94
  });
95
95
  const hint = defaultYes ? "Y/n" : "y/N";
96
- return new Promise((resolve2) => {
96
+ return new Promise((resolve3) => {
97
97
  rl.question(` ${c.bold}${question}${c.reset} ${c.dim}(${hint})${c.reset} `, (answer) => {
98
98
  rl.close();
99
99
  const a = answer.trim().toLowerCase();
100
100
  if (a === "")
101
- resolve2(defaultYes);
101
+ resolve3(defaultYes);
102
102
  else
103
- resolve2(a === "y" || a === "yes");
103
+ resolve3(a === "y" || a === "yes");
104
104
  });
105
105
  });
106
106
  }
@@ -243,7 +243,7 @@ async function dev() {
243
243
  label: " go",
244
244
  color: c.green,
245
245
  cmd: "go",
246
- args: ["run", "go-server/main.go"],
246
+ args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
247
247
  readyPattern: /Go server starting/
248
248
  }
249
249
  ];
@@ -321,9 +321,13 @@ async function dev() {
321
321
 
322
322
  // cli/commands/build.ts
323
323
  import { execSync as execSync3 } from "child_process";
324
- async function build() {
324
+ async function build(args = []) {
325
325
  banner();
326
+ const analyze = args.includes("--analyze");
326
327
  log.info("Creating production build...");
328
+ if (analyze) {
329
+ log.info(`${c.magenta}Bundle analyzer${c.reset} will open after build.`);
330
+ }
327
331
  log.blank();
328
332
  const steps = [
329
333
  {
@@ -332,11 +336,12 @@ async function build() {
332
336
  },
333
337
  {
334
338
  label: "Building client bundle",
335
- cmd: "npx webpack --mode production"
339
+ cmd: "npx webpack --mode production",
340
+ env: analyze ? { BLUMEN_ANALYZE: "1" } : void 0
336
341
  },
337
342
  {
338
343
  label: "Building SSR server",
339
- cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --external:react --external:react-dom"
344
+ cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external"
340
345
  }
341
346
  ];
342
347
  const startTime = Date.now();
@@ -344,7 +349,11 @@ async function build() {
344
349
  const step = steps[i];
345
350
  log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
346
351
  try {
347
- execSync3(step.cmd, { stdio: "inherit", cwd: process.cwd() });
352
+ execSync3(step.cmd, {
353
+ stdio: "inherit",
354
+ cwd: process.cwd(),
355
+ env: { ...process.env, ...step.env || {} }
356
+ });
348
357
  log.success(step.label);
349
358
  } catch {
350
359
  log.error(`Failed: ${step.label}`);
@@ -392,7 +401,7 @@ async function start() {
392
401
  label: " go",
393
402
  color: c.green,
394
403
  cmd: "go",
395
- args: ["run", "go-server/main.go"],
404
+ args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
396
405
  readyPattern: /Go server starting/
397
406
  }
398
407
  ];
@@ -691,7 +700,7 @@ RUN npm install --production=false
691
700
  RUN npx tsx scripts/generate-routes.ts
692
701
  RUN npx webpack --mode production
693
702
  RUN npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm \\
694
- --outfile=dist/ssr-server.js --external:react --external:react-dom
703
+ --outfile=dist/ssr-server.js --packages=external
695
704
 
696
705
  # \u2500\u2500 Stage 3: Production image \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
697
706
  FROM node:20-alpine
@@ -1139,6 +1148,236 @@ async function deploy(subcommand) {
1139
1148
  }
1140
1149
  }
1141
1150
 
1151
+ // cli/commands/fonts.ts
1152
+ import * as fs5 from "fs";
1153
+ import * as path4 from "path";
1154
+ import * as https from "https";
1155
+ var FONTS_DIR = path4.resolve("static/fonts");
1156
+ var FONTS_CSS = path4.resolve("static/css/fonts.css");
1157
+ var FONTS_CONFIG = path4.resolve("blumen.fonts.json");
1158
+ var DEFAULT_MANIFEST = {
1159
+ fonts: []
1160
+ };
1161
+ var FALLBACK_MAP = {
1162
+ "Inter": { fallback: "system-ui, -apple-system, sans-serif", sizeAdjust: "107%" },
1163
+ "Roboto": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "100.3%" },
1164
+ "Open Sans": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "105.2%" },
1165
+ "Lato": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "97.4%" },
1166
+ "Montserrat": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "112.5%" },
1167
+ "Poppins": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "112%" },
1168
+ "Outfit": { fallback: "system-ui, -apple-system, sans-serif", sizeAdjust: "105%" },
1169
+ "Roboto Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "101%" },
1170
+ "Source Code Pro": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "97.5%" },
1171
+ "Fira Code": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "99%" },
1172
+ "JetBrains Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "103%" }
1173
+ };
1174
+ function downloadFile(url, dest) {
1175
+ return new Promise((resolve3, reject) => {
1176
+ const file = fs5.createWriteStream(dest);
1177
+ https.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (response) => {
1178
+ if (response.statusCode === 301 || response.statusCode === 302) {
1179
+ const redirectUrl = response.headers.location;
1180
+ if (redirectUrl) {
1181
+ downloadFile(redirectUrl, dest).then(resolve3).catch(reject);
1182
+ return;
1183
+ }
1184
+ }
1185
+ if (response.statusCode !== 200) {
1186
+ reject(new Error(`HTTP ${response.statusCode} for ${url}`));
1187
+ return;
1188
+ }
1189
+ response.pipe(file);
1190
+ file.on("finish", () => {
1191
+ file.close();
1192
+ resolve3();
1193
+ });
1194
+ }).on("error", (err) => {
1195
+ fs5.unlink(dest, () => {
1196
+ });
1197
+ reject(err);
1198
+ });
1199
+ });
1200
+ }
1201
+ function fetchGoogleFontsCSS(family, weights, subsets) {
1202
+ return new Promise((resolve3, reject) => {
1203
+ const weightStr = weights.join(";");
1204
+ const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weightStr}&display=swap&subset=${subsets.join(",")}`;
1205
+ https.get(url, {
1206
+ headers: {
1207
+ // Request WOFF2 format by pretending to be a modern browser
1208
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
1209
+ }
1210
+ }, (response) => {
1211
+ let data = "";
1212
+ response.on("data", (chunk) => {
1213
+ data += chunk.toString();
1214
+ });
1215
+ response.on("end", () => resolve3(data));
1216
+ }).on("error", reject);
1217
+ });
1218
+ }
1219
+ function parseFontUrls(css) {
1220
+ const results = [];
1221
+ const blocks = css.split("@font-face");
1222
+ for (const block of blocks) {
1223
+ if (!block.includes("url("))
1224
+ continue;
1225
+ const urlMatch = block.match(/url\((https?:\/\/[^)]+)\)/);
1226
+ const weightMatch = block.match(/font-weight:\s*(\d+)/);
1227
+ const styleMatch = block.match(/font-style:\s*(\w+)/);
1228
+ const formatMatch = block.match(/format\('([^']+)'\)/);
1229
+ const unicodeMatch = block.match(/unicode-range:\s*([^;]+)/);
1230
+ if (urlMatch) {
1231
+ results.push({
1232
+ url: urlMatch[1],
1233
+ weight: weightMatch?.[1] || "400",
1234
+ style: styleMatch?.[1] || "normal",
1235
+ format: formatMatch?.[1] || "woff2",
1236
+ unicodeRange: unicodeMatch?.[1]?.trim()
1237
+ });
1238
+ }
1239
+ }
1240
+ return results;
1241
+ }
1242
+ function generateFontFaceCSS(family, weight, style, fileName, display, unicodeRange) {
1243
+ let css = `@font-face {
1244
+ font-family: '${family}';
1245
+ font-style: ${style};
1246
+ font-weight: ${weight};
1247
+ font-display: ${display};
1248
+ src: url('/static/fonts/${fileName}') format('woff2');`;
1249
+ if (unicodeRange) {
1250
+ css += `
1251
+ unicode-range: ${unicodeRange};`;
1252
+ }
1253
+ css += "\n}\n";
1254
+ return css;
1255
+ }
1256
+ function generateFallbackCSS(family) {
1257
+ const fallback = FALLBACK_MAP[family];
1258
+ if (!fallback)
1259
+ return "";
1260
+ return `/* Fallback font with size-adjust to prevent layout shift */
1261
+ @font-face {
1262
+ font-family: '${family} Fallback';
1263
+ src: local(${fallback.fallback.split(",")[0].trim()});
1264
+ size-adjust: ${fallback.sizeAdjust};
1265
+ ascent-override: 90%;
1266
+ descent-override: 22%;
1267
+ line-gap-override: 0%;
1268
+ }
1269
+ `;
1270
+ }
1271
+ async function processFont(config) {
1272
+ console.log(` Downloading: ${config.family} (weights: ${config.weights.join(", ")})`);
1273
+ const css = await fetchGoogleFontsCSS(config.family, config.weights, config.subsets);
1274
+ const fontUrls = parseFontUrls(css);
1275
+ if (fontUrls.length === 0) {
1276
+ console.log(` Warning: No font files found for ${config.family}`);
1277
+ return "";
1278
+ }
1279
+ let generatedCSS = "";
1280
+ generatedCSS += generateFallbackCSS(config.family);
1281
+ for (const font of fontUrls) {
1282
+ const ext = font.format === "woff2" ? "woff2" : "woff";
1283
+ const safeName = config.family.replace(/\s+/g, "-").toLowerCase();
1284
+ const fileName = `${safeName}-${font.weight}-${font.style}.${ext}`;
1285
+ const filePath = path4.join(FONTS_DIR, fileName);
1286
+ if (!fs5.existsSync(filePath)) {
1287
+ try {
1288
+ await downloadFile(font.url, filePath);
1289
+ const size = fs5.statSync(filePath).size;
1290
+ console.log(` Downloaded: ${fileName} (${(size / 1024).toFixed(1)} KB)`);
1291
+ } catch (err) {
1292
+ console.error(` Failed to download ${fileName}: ${err}`);
1293
+ continue;
1294
+ }
1295
+ } else {
1296
+ console.log(` Cached: ${fileName}`);
1297
+ }
1298
+ generatedCSS += generateFontFaceCSS(
1299
+ config.family,
1300
+ font.weight,
1301
+ font.style,
1302
+ fileName,
1303
+ config.display,
1304
+ font.unicodeRange
1305
+ );
1306
+ }
1307
+ return generatedCSS;
1308
+ }
1309
+ function loadManifest() {
1310
+ if (fs5.existsSync(FONTS_CONFIG)) {
1311
+ return JSON.parse(fs5.readFileSync(FONTS_CONFIG, "utf-8"));
1312
+ }
1313
+ return { ...DEFAULT_MANIFEST };
1314
+ }
1315
+ function saveManifest(manifest) {
1316
+ fs5.writeFileSync(FONTS_CONFIG, JSON.stringify(manifest, null, 2), "utf-8");
1317
+ }
1318
+ async function fontsCommand(args) {
1319
+ console.log("\n \u{1F338} Blumen Font Optimizer\n");
1320
+ const addIndex = args.indexOf("--add");
1321
+ const listMode = args.includes("--list");
1322
+ const manifest = loadManifest();
1323
+ if (listMode) {
1324
+ if (manifest.fonts.length === 0) {
1325
+ console.log(" No fonts configured.");
1326
+ console.log(' Add a font: blumen fonts --add "Inter"');
1327
+ } else {
1328
+ console.log(" Configured fonts:");
1329
+ for (const font of manifest.fonts) {
1330
+ console.log(` - ${font.family} (weights: ${font.weights.join(", ")})`);
1331
+ }
1332
+ }
1333
+ return;
1334
+ }
1335
+ if (addIndex !== -1 && args[addIndex + 1]) {
1336
+ const family = args[addIndex + 1].replace(/"/g, "").replace(/'/g, "");
1337
+ const existing = manifest.fonts.find((f) => f.family === family);
1338
+ if (existing) {
1339
+ console.log(` Font "${family}" is already configured.`);
1340
+ return;
1341
+ }
1342
+ manifest.fonts.push({
1343
+ family,
1344
+ weights: [400, 500, 600, 700],
1345
+ subsets: ["latin"],
1346
+ display: "swap",
1347
+ style: "normal"
1348
+ });
1349
+ saveManifest(manifest);
1350
+ console.log(` Added "${family}" to blumen.fonts.json`);
1351
+ }
1352
+ if (manifest.fonts.length === 0) {
1353
+ console.log(" No fonts configured. Add one first:");
1354
+ console.log(' blumen fonts --add "Inter"');
1355
+ console.log(' blumen fonts --add "Roboto"');
1356
+ return;
1357
+ }
1358
+ fs5.mkdirSync(FONTS_DIR, { recursive: true });
1359
+ fs5.mkdirSync(path4.dirname(FONTS_CSS), { recursive: true });
1360
+ let allCSS = `/* Auto-generated by Blumen Font Optimizer */
1361
+ /* Do not edit manually - run 'blumen fonts' to regenerate */
1362
+
1363
+ `;
1364
+ for (const fontConfig of manifest.fonts) {
1365
+ const css = await processFont(fontConfig);
1366
+ allCSS += css + "\n";
1367
+ }
1368
+ fs5.writeFileSync(FONTS_CSS, allCSS, "utf-8");
1369
+ console.log(`
1370
+ \u2705 Font CSS written to: ${path4.relative(process.cwd(), FONTS_CSS)}`);
1371
+ console.log(` \u2705 Font files saved to: ${path4.relative(process.cwd(), FONTS_DIR)}/`);
1372
+ console.log(`
1373
+ To use these fonts, import the CSS in your _app.tsx or _document.tsx:`);
1374
+ console.log(` import '../../static/css/fonts.css';`);
1375
+ console.log(`
1376
+ Or add to your CSS:`);
1377
+ console.log(` body { font-family: '${manifest.fonts[0].family}', '${manifest.fonts[0].family} Fallback', sans-serif; }`);
1378
+ console.log();
1379
+ }
1380
+
1142
1381
  // cli/blumen.ts
1143
1382
  async function main() {
1144
1383
  const command = process.argv[2];
@@ -1160,6 +1399,9 @@ async function main() {
1160
1399
  console.log(
1161
1400
  ` deploy Deploy to Docker, Fly.io, or Railway`
1162
1401
  );
1402
+ console.log(
1403
+ ` fonts Download and optimize fonts`
1404
+ );
1163
1405
  console.log("");
1164
1406
  console.log(` ${c.bold}Options${c.reset}`);
1165
1407
  console.log(` --help Show this help message`);
@@ -1187,6 +1429,9 @@ async function main() {
1187
1429
  case "deploy":
1188
1430
  await deploy(process.argv[3]);
1189
1431
  break;
1432
+ case "fonts":
1433
+ await fontsCommand(process.argv.slice(3));
1434
+ break;
1190
1435
  default:
1191
1436
  log.error(
1192
1437
  `Unknown command: ${c.bold}${command}${c.reset}`
@@ -61,9 +61,13 @@ function divider() {
61
61
  }
62
62
 
63
63
  // cli/commands/build.ts
64
- async function build() {
64
+ async function build(args = []) {
65
65
  banner();
66
+ const analyze = args.includes("--analyze");
66
67
  log.info("Creating production build...");
68
+ if (analyze) {
69
+ log.info(`${c.magenta}Bundle analyzer${c.reset} will open after build.`);
70
+ }
67
71
  log.blank();
68
72
  const steps = [
69
73
  {
@@ -72,11 +76,12 @@ async function build() {
72
76
  },
73
77
  {
74
78
  label: "Building client bundle",
75
- cmd: "npx webpack --mode production"
79
+ cmd: "npx webpack --mode production",
80
+ env: analyze ? { BLUMEN_ANALYZE: "1" } : void 0
76
81
  },
77
82
  {
78
83
  label: "Building SSR server",
79
- cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --external:react --external:react-dom"
84
+ cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external"
80
85
  }
81
86
  ];
82
87
  const startTime = Date.now();
@@ -84,7 +89,11 @@ async function build() {
84
89
  const step = steps[i];
85
90
  log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
86
91
  try {
87
- execSync(step.cmd, { stdio: "inherit", cwd: process.cwd() });
92
+ execSync(step.cmd, {
93
+ stdio: "inherit",
94
+ cwd: process.cwd(),
95
+ env: { ...process.env, ...step.env || {} }
96
+ });
88
97
  log.success(step.label);
89
98
  } catch {
90
99
  log.error(`Failed: ${step.label}`);
@@ -435,7 +435,7 @@ RUN npm install --production=false
435
435
  RUN npx tsx scripts/generate-routes.ts
436
436
  RUN npx webpack --mode production
437
437
  RUN npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm \\
438
- --outfile=dist/ssr-server.js --external:react --external:react-dom
438
+ --outfile=dist/ssr-server.js --packages=external
439
439
 
440
440
  # \u2500\u2500 Stage 3: Production image \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
441
441
  FROM node:20-alpine
@@ -218,7 +218,7 @@ async function dev() {
218
218
  label: " go",
219
219
  color: c.green,
220
220
  cmd: "go",
221
- args: ["run", "go-server/main.go"],
221
+ args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
222
222
  readyPattern: /Go server starting/
223
223
  }
224
224
  ];
@@ -0,0 +1,232 @@
1
+ // cli/commands/fonts.ts
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import * as https from "https";
5
+ var FONTS_DIR = path.resolve("static/fonts");
6
+ var FONTS_CSS = path.resolve("static/css/fonts.css");
7
+ var FONTS_CONFIG = path.resolve("blumen.fonts.json");
8
+ var DEFAULT_MANIFEST = {
9
+ fonts: []
10
+ };
11
+ var FALLBACK_MAP = {
12
+ "Inter": { fallback: "system-ui, -apple-system, sans-serif", sizeAdjust: "107%" },
13
+ "Roboto": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "100.3%" },
14
+ "Open Sans": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "105.2%" },
15
+ "Lato": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "97.4%" },
16
+ "Montserrat": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "112.5%" },
17
+ "Poppins": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "112%" },
18
+ "Outfit": { fallback: "system-ui, -apple-system, sans-serif", sizeAdjust: "105%" },
19
+ "Roboto Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "101%" },
20
+ "Source Code Pro": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "97.5%" },
21
+ "Fira Code": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "99%" },
22
+ "JetBrains Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "103%" }
23
+ };
24
+ function downloadFile(url, dest) {
25
+ return new Promise((resolve2, reject) => {
26
+ const file = fs.createWriteStream(dest);
27
+ https.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (response) => {
28
+ if (response.statusCode === 301 || response.statusCode === 302) {
29
+ const redirectUrl = response.headers.location;
30
+ if (redirectUrl) {
31
+ downloadFile(redirectUrl, dest).then(resolve2).catch(reject);
32
+ return;
33
+ }
34
+ }
35
+ if (response.statusCode !== 200) {
36
+ reject(new Error(`HTTP ${response.statusCode} for ${url}`));
37
+ return;
38
+ }
39
+ response.pipe(file);
40
+ file.on("finish", () => {
41
+ file.close();
42
+ resolve2();
43
+ });
44
+ }).on("error", (err) => {
45
+ fs.unlink(dest, () => {
46
+ });
47
+ reject(err);
48
+ });
49
+ });
50
+ }
51
+ function fetchGoogleFontsCSS(family, weights, subsets) {
52
+ return new Promise((resolve2, reject) => {
53
+ const weightStr = weights.join(";");
54
+ const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weightStr}&display=swap&subset=${subsets.join(",")}`;
55
+ https.get(url, {
56
+ headers: {
57
+ // Request WOFF2 format by pretending to be a modern browser
58
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
59
+ }
60
+ }, (response) => {
61
+ let data = "";
62
+ response.on("data", (chunk) => {
63
+ data += chunk.toString();
64
+ });
65
+ response.on("end", () => resolve2(data));
66
+ }).on("error", reject);
67
+ });
68
+ }
69
+ function parseFontUrls(css) {
70
+ const results = [];
71
+ const blocks = css.split("@font-face");
72
+ for (const block of blocks) {
73
+ if (!block.includes("url("))
74
+ continue;
75
+ const urlMatch = block.match(/url\((https?:\/\/[^)]+)\)/);
76
+ const weightMatch = block.match(/font-weight:\s*(\d+)/);
77
+ const styleMatch = block.match(/font-style:\s*(\w+)/);
78
+ const formatMatch = block.match(/format\('([^']+)'\)/);
79
+ const unicodeMatch = block.match(/unicode-range:\s*([^;]+)/);
80
+ if (urlMatch) {
81
+ results.push({
82
+ url: urlMatch[1],
83
+ weight: weightMatch?.[1] || "400",
84
+ style: styleMatch?.[1] || "normal",
85
+ format: formatMatch?.[1] || "woff2",
86
+ unicodeRange: unicodeMatch?.[1]?.trim()
87
+ });
88
+ }
89
+ }
90
+ return results;
91
+ }
92
+ function generateFontFaceCSS(family, weight, style, fileName, display, unicodeRange) {
93
+ let css = `@font-face {
94
+ font-family: '${family}';
95
+ font-style: ${style};
96
+ font-weight: ${weight};
97
+ font-display: ${display};
98
+ src: url('/static/fonts/${fileName}') format('woff2');`;
99
+ if (unicodeRange) {
100
+ css += `
101
+ unicode-range: ${unicodeRange};`;
102
+ }
103
+ css += "\n}\n";
104
+ return css;
105
+ }
106
+ function generateFallbackCSS(family) {
107
+ const fallback = FALLBACK_MAP[family];
108
+ if (!fallback)
109
+ return "";
110
+ return `/* Fallback font with size-adjust to prevent layout shift */
111
+ @font-face {
112
+ font-family: '${family} Fallback';
113
+ src: local(${fallback.fallback.split(",")[0].trim()});
114
+ size-adjust: ${fallback.sizeAdjust};
115
+ ascent-override: 90%;
116
+ descent-override: 22%;
117
+ line-gap-override: 0%;
118
+ }
119
+ `;
120
+ }
121
+ async function processFont(config) {
122
+ console.log(` Downloading: ${config.family} (weights: ${config.weights.join(", ")})`);
123
+ const css = await fetchGoogleFontsCSS(config.family, config.weights, config.subsets);
124
+ const fontUrls = parseFontUrls(css);
125
+ if (fontUrls.length === 0) {
126
+ console.log(` Warning: No font files found for ${config.family}`);
127
+ return "";
128
+ }
129
+ let generatedCSS = "";
130
+ generatedCSS += generateFallbackCSS(config.family);
131
+ for (const font of fontUrls) {
132
+ const ext = font.format === "woff2" ? "woff2" : "woff";
133
+ const safeName = config.family.replace(/\s+/g, "-").toLowerCase();
134
+ const fileName = `${safeName}-${font.weight}-${font.style}.${ext}`;
135
+ const filePath = path.join(FONTS_DIR, fileName);
136
+ if (!fs.existsSync(filePath)) {
137
+ try {
138
+ await downloadFile(font.url, filePath);
139
+ const size = fs.statSync(filePath).size;
140
+ console.log(` Downloaded: ${fileName} (${(size / 1024).toFixed(1)} KB)`);
141
+ } catch (err) {
142
+ console.error(` Failed to download ${fileName}: ${err}`);
143
+ continue;
144
+ }
145
+ } else {
146
+ console.log(` Cached: ${fileName}`);
147
+ }
148
+ generatedCSS += generateFontFaceCSS(
149
+ config.family,
150
+ font.weight,
151
+ font.style,
152
+ fileName,
153
+ config.display,
154
+ font.unicodeRange
155
+ );
156
+ }
157
+ return generatedCSS;
158
+ }
159
+ function loadManifest() {
160
+ if (fs.existsSync(FONTS_CONFIG)) {
161
+ return JSON.parse(fs.readFileSync(FONTS_CONFIG, "utf-8"));
162
+ }
163
+ return { ...DEFAULT_MANIFEST };
164
+ }
165
+ function saveManifest(manifest) {
166
+ fs.writeFileSync(FONTS_CONFIG, JSON.stringify(manifest, null, 2), "utf-8");
167
+ }
168
+ async function fontsCommand(args) {
169
+ console.log("\n \u{1F338} Blumen Font Optimizer\n");
170
+ const addIndex = args.indexOf("--add");
171
+ const listMode = args.includes("--list");
172
+ const manifest = loadManifest();
173
+ if (listMode) {
174
+ if (manifest.fonts.length === 0) {
175
+ console.log(" No fonts configured.");
176
+ console.log(' Add a font: blumen fonts --add "Inter"');
177
+ } else {
178
+ console.log(" Configured fonts:");
179
+ for (const font of manifest.fonts) {
180
+ console.log(` - ${font.family} (weights: ${font.weights.join(", ")})`);
181
+ }
182
+ }
183
+ return;
184
+ }
185
+ if (addIndex !== -1 && args[addIndex + 1]) {
186
+ const family = args[addIndex + 1].replace(/"/g, "").replace(/'/g, "");
187
+ const existing = manifest.fonts.find((f) => f.family === family);
188
+ if (existing) {
189
+ console.log(` Font "${family}" is already configured.`);
190
+ return;
191
+ }
192
+ manifest.fonts.push({
193
+ family,
194
+ weights: [400, 500, 600, 700],
195
+ subsets: ["latin"],
196
+ display: "swap",
197
+ style: "normal"
198
+ });
199
+ saveManifest(manifest);
200
+ console.log(` Added "${family}" to blumen.fonts.json`);
201
+ }
202
+ if (manifest.fonts.length === 0) {
203
+ console.log(" No fonts configured. Add one first:");
204
+ console.log(' blumen fonts --add "Inter"');
205
+ console.log(' blumen fonts --add "Roboto"');
206
+ return;
207
+ }
208
+ fs.mkdirSync(FONTS_DIR, { recursive: true });
209
+ fs.mkdirSync(path.dirname(FONTS_CSS), { recursive: true });
210
+ let allCSS = `/* Auto-generated by Blumen Font Optimizer */
211
+ /* Do not edit manually - run 'blumen fonts' to regenerate */
212
+
213
+ `;
214
+ for (const fontConfig of manifest.fonts) {
215
+ const css = await processFont(fontConfig);
216
+ allCSS += css + "\n";
217
+ }
218
+ fs.writeFileSync(FONTS_CSS, allCSS, "utf-8");
219
+ console.log(`
220
+ \u2705 Font CSS written to: ${path.relative(process.cwd(), FONTS_CSS)}`);
221
+ console.log(` \u2705 Font files saved to: ${path.relative(process.cwd(), FONTS_DIR)}/`);
222
+ console.log(`
223
+ To use these fonts, import the CSS in your _app.tsx or _document.tsx:`);
224
+ console.log(` import '../../static/css/fonts.css';`);
225
+ console.log(`
226
+ Or add to your CSS:`);
227
+ console.log(` body { font-family: '${manifest.fonts[0].family}', '${manifest.fonts[0].family} Fallback', sans-serif; }`);
228
+ console.log();
229
+ }
230
+ export {
231
+ fontsCommand
232
+ };
@@ -87,7 +87,7 @@ async function start() {
87
87
  label: " go",
88
88
  color: c.green,
89
89
  cmd: "go",
90
- args: ["run", "go-server/main.go"],
90
+ args: ["run", "go-server/main.go", "go-server/image.go", "go-server/cache.go"],
91
91
  readyPattern: /Go server starting/
92
92
  }
93
93
  ];
@@ -19,7 +19,8 @@ function init() {
19
19
  const initialProps = propsText ? JSON.parse(propsText) : {};
20
20
 
21
21
  // Hydrate the app with the RouterProvider.
22
- // The provider handles route matching, page rendering, and SPA navigation.
22
+ // Error boundaries are handled inside RouterProvider to ensure
23
+ // the DOM tree matches what the SSR server produces.
23
24
  hydrateRoot(
24
25
  container,
25
26
  <RouterProvider
@@ -39,3 +40,4 @@ if (document.readyState === "loading") {
39
40
  } else {
40
41
  init();
41
42
  }
43
+