blumenjs 0.1.7 → 0.2.1

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/"],
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,18 @@ 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 --alias:@=./app"
345
+ },
346
+ {
347
+ label: "Pre-rendering static pages (SSG)",
348
+ cmd: "npx tsx scripts/ssg-prerender.ts",
349
+ optional: true
350
+ // Don't fail if no SSG pages exist
340
351
  }
341
352
  ];
342
353
  const startTime = Date.now();
@@ -344,11 +355,19 @@ async function build() {
344
355
  const step = steps[i];
345
356
  log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
346
357
  try {
347
- execSync3(step.cmd, { stdio: "inherit", cwd: process.cwd() });
358
+ execSync3(step.cmd, {
359
+ stdio: "inherit",
360
+ cwd: process.cwd(),
361
+ env: { ...process.env, ...step.env || {} }
362
+ });
348
363
  log.success(step.label);
349
364
  } catch {
350
- log.error(`Failed: ${step.label}`);
351
- process.exit(1);
365
+ if (step.optional) {
366
+ log.success(step.label);
367
+ } else {
368
+ log.error(`Failed: ${step.label}`);
369
+ process.exit(1);
370
+ }
352
371
  }
353
372
  }
354
373
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -392,7 +411,7 @@ async function start() {
392
411
  label: " go",
393
412
  color: c.green,
394
413
  cmd: "go",
395
- args: ["run", "go-server/main.go"],
414
+ args: ["run", "./go-server/"],
396
415
  readyPattern: /Go server starting/
397
416
  }
398
417
  ];
@@ -691,7 +710,7 @@ RUN npm install --production=false
691
710
  RUN npx tsx scripts/generate-routes.ts
692
711
  RUN npx webpack --mode production
693
712
  RUN npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm \\
694
- --outfile=dist/ssr-server.js --external:react --external:react-dom
713
+ --outfile=dist/ssr-server.js --packages=external
695
714
 
696
715
  # \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
716
  FROM node:20-alpine
@@ -1139,6 +1158,236 @@ async function deploy(subcommand) {
1139
1158
  }
1140
1159
  }
1141
1160
 
1161
+ // cli/commands/fonts.ts
1162
+ import * as fs5 from "fs";
1163
+ import * as path4 from "path";
1164
+ import * as https from "https";
1165
+ var FONTS_DIR = path4.resolve("static/fonts");
1166
+ var FONTS_CSS = path4.resolve("static/css/fonts.css");
1167
+ var FONTS_CONFIG = path4.resolve("blumen.fonts.json");
1168
+ var DEFAULT_MANIFEST = {
1169
+ fonts: []
1170
+ };
1171
+ var FALLBACK_MAP = {
1172
+ "Inter": { fallback: "system-ui, -apple-system, sans-serif", sizeAdjust: "107%" },
1173
+ "Roboto": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "100.3%" },
1174
+ "Open Sans": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "105.2%" },
1175
+ "Lato": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "97.4%" },
1176
+ "Montserrat": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "112.5%" },
1177
+ "Poppins": { fallback: "Arial, Helvetica, sans-serif", sizeAdjust: "112%" },
1178
+ "Outfit": { fallback: "system-ui, -apple-system, sans-serif", sizeAdjust: "105%" },
1179
+ "Roboto Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "101%" },
1180
+ "Source Code Pro": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "97.5%" },
1181
+ "Fira Code": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "99%" },
1182
+ "JetBrains Mono": { fallback: "'Courier New', Courier, monospace", sizeAdjust: "103%" }
1183
+ };
1184
+ function downloadFile(url, dest) {
1185
+ return new Promise((resolve3, reject) => {
1186
+ const file = fs5.createWriteStream(dest);
1187
+ https.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (response) => {
1188
+ if (response.statusCode === 301 || response.statusCode === 302) {
1189
+ const redirectUrl = response.headers.location;
1190
+ if (redirectUrl) {
1191
+ downloadFile(redirectUrl, dest).then(resolve3).catch(reject);
1192
+ return;
1193
+ }
1194
+ }
1195
+ if (response.statusCode !== 200) {
1196
+ reject(new Error(`HTTP ${response.statusCode} for ${url}`));
1197
+ return;
1198
+ }
1199
+ response.pipe(file);
1200
+ file.on("finish", () => {
1201
+ file.close();
1202
+ resolve3();
1203
+ });
1204
+ }).on("error", (err) => {
1205
+ fs5.unlink(dest, () => {
1206
+ });
1207
+ reject(err);
1208
+ });
1209
+ });
1210
+ }
1211
+ function fetchGoogleFontsCSS(family, weights, subsets) {
1212
+ return new Promise((resolve3, reject) => {
1213
+ const weightStr = weights.join(";");
1214
+ const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weightStr}&display=swap&subset=${subsets.join(",")}`;
1215
+ https.get(url, {
1216
+ headers: {
1217
+ // Request WOFF2 format by pretending to be a modern browser
1218
+ "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"
1219
+ }
1220
+ }, (response) => {
1221
+ let data = "";
1222
+ response.on("data", (chunk) => {
1223
+ data += chunk.toString();
1224
+ });
1225
+ response.on("end", () => resolve3(data));
1226
+ }).on("error", reject);
1227
+ });
1228
+ }
1229
+ function parseFontUrls(css) {
1230
+ const results = [];
1231
+ const blocks = css.split("@font-face");
1232
+ for (const block of blocks) {
1233
+ if (!block.includes("url("))
1234
+ continue;
1235
+ const urlMatch = block.match(/url\((https?:\/\/[^)]+)\)/);
1236
+ const weightMatch = block.match(/font-weight:\s*(\d+)/);
1237
+ const styleMatch = block.match(/font-style:\s*(\w+)/);
1238
+ const formatMatch = block.match(/format\('([^']+)'\)/);
1239
+ const unicodeMatch = block.match(/unicode-range:\s*([^;]+)/);
1240
+ if (urlMatch) {
1241
+ results.push({
1242
+ url: urlMatch[1],
1243
+ weight: weightMatch?.[1] || "400",
1244
+ style: styleMatch?.[1] || "normal",
1245
+ format: formatMatch?.[1] || "woff2",
1246
+ unicodeRange: unicodeMatch?.[1]?.trim()
1247
+ });
1248
+ }
1249
+ }
1250
+ return results;
1251
+ }
1252
+ function generateFontFaceCSS(family, weight, style, fileName, display, unicodeRange) {
1253
+ let css = `@font-face {
1254
+ font-family: '${family}';
1255
+ font-style: ${style};
1256
+ font-weight: ${weight};
1257
+ font-display: ${display};
1258
+ src: url('/static/fonts/${fileName}') format('woff2');`;
1259
+ if (unicodeRange) {
1260
+ css += `
1261
+ unicode-range: ${unicodeRange};`;
1262
+ }
1263
+ css += "\n}\n";
1264
+ return css;
1265
+ }
1266
+ function generateFallbackCSS(family) {
1267
+ const fallback = FALLBACK_MAP[family];
1268
+ if (!fallback)
1269
+ return "";
1270
+ return `/* Fallback font with size-adjust to prevent layout shift */
1271
+ @font-face {
1272
+ font-family: '${family} Fallback';
1273
+ src: local(${fallback.fallback.split(",")[0].trim()});
1274
+ size-adjust: ${fallback.sizeAdjust};
1275
+ ascent-override: 90%;
1276
+ descent-override: 22%;
1277
+ line-gap-override: 0%;
1278
+ }
1279
+ `;
1280
+ }
1281
+ async function processFont(config) {
1282
+ console.log(` Downloading: ${config.family} (weights: ${config.weights.join(", ")})`);
1283
+ const css = await fetchGoogleFontsCSS(config.family, config.weights, config.subsets);
1284
+ const fontUrls = parseFontUrls(css);
1285
+ if (fontUrls.length === 0) {
1286
+ console.log(` Warning: No font files found for ${config.family}`);
1287
+ return "";
1288
+ }
1289
+ let generatedCSS = "";
1290
+ generatedCSS += generateFallbackCSS(config.family);
1291
+ for (const font of fontUrls) {
1292
+ const ext = font.format === "woff2" ? "woff2" : "woff";
1293
+ const safeName = config.family.replace(/\s+/g, "-").toLowerCase();
1294
+ const fileName = `${safeName}-${font.weight}-${font.style}.${ext}`;
1295
+ const filePath = path4.join(FONTS_DIR, fileName);
1296
+ if (!fs5.existsSync(filePath)) {
1297
+ try {
1298
+ await downloadFile(font.url, filePath);
1299
+ const size = fs5.statSync(filePath).size;
1300
+ console.log(` Downloaded: ${fileName} (${(size / 1024).toFixed(1)} KB)`);
1301
+ } catch (err) {
1302
+ console.error(` Failed to download ${fileName}: ${err}`);
1303
+ continue;
1304
+ }
1305
+ } else {
1306
+ console.log(` Cached: ${fileName}`);
1307
+ }
1308
+ generatedCSS += generateFontFaceCSS(
1309
+ config.family,
1310
+ font.weight,
1311
+ font.style,
1312
+ fileName,
1313
+ config.display,
1314
+ font.unicodeRange
1315
+ );
1316
+ }
1317
+ return generatedCSS;
1318
+ }
1319
+ function loadManifest() {
1320
+ if (fs5.existsSync(FONTS_CONFIG)) {
1321
+ return JSON.parse(fs5.readFileSync(FONTS_CONFIG, "utf-8"));
1322
+ }
1323
+ return { ...DEFAULT_MANIFEST };
1324
+ }
1325
+ function saveManifest(manifest) {
1326
+ fs5.writeFileSync(FONTS_CONFIG, JSON.stringify(manifest, null, 2), "utf-8");
1327
+ }
1328
+ async function fontsCommand(args) {
1329
+ console.log("\n \u{1F338} Blumen Font Optimizer\n");
1330
+ const addIndex = args.indexOf("--add");
1331
+ const listMode = args.includes("--list");
1332
+ const manifest = loadManifest();
1333
+ if (listMode) {
1334
+ if (manifest.fonts.length === 0) {
1335
+ console.log(" No fonts configured.");
1336
+ console.log(' Add a font: blumen fonts --add "Inter"');
1337
+ } else {
1338
+ console.log(" Configured fonts:");
1339
+ for (const font of manifest.fonts) {
1340
+ console.log(` - ${font.family} (weights: ${font.weights.join(", ")})`);
1341
+ }
1342
+ }
1343
+ return;
1344
+ }
1345
+ if (addIndex !== -1 && args[addIndex + 1]) {
1346
+ const family = args[addIndex + 1].replace(/"/g, "").replace(/'/g, "");
1347
+ const existing = manifest.fonts.find((f) => f.family === family);
1348
+ if (existing) {
1349
+ console.log(` Font "${family}" is already configured.`);
1350
+ return;
1351
+ }
1352
+ manifest.fonts.push({
1353
+ family,
1354
+ weights: [400, 500, 600, 700],
1355
+ subsets: ["latin"],
1356
+ display: "swap",
1357
+ style: "normal"
1358
+ });
1359
+ saveManifest(manifest);
1360
+ console.log(` Added "${family}" to blumen.fonts.json`);
1361
+ }
1362
+ if (manifest.fonts.length === 0) {
1363
+ console.log(" No fonts configured. Add one first:");
1364
+ console.log(' blumen fonts --add "Inter"');
1365
+ console.log(' blumen fonts --add "Roboto"');
1366
+ return;
1367
+ }
1368
+ fs5.mkdirSync(FONTS_DIR, { recursive: true });
1369
+ fs5.mkdirSync(path4.dirname(FONTS_CSS), { recursive: true });
1370
+ let allCSS = `/* Auto-generated by Blumen Font Optimizer */
1371
+ /* Do not edit manually - run 'blumen fonts' to regenerate */
1372
+
1373
+ `;
1374
+ for (const fontConfig of manifest.fonts) {
1375
+ const css = await processFont(fontConfig);
1376
+ allCSS += css + "\n";
1377
+ }
1378
+ fs5.writeFileSync(FONTS_CSS, allCSS, "utf-8");
1379
+ console.log(`
1380
+ \u2705 Font CSS written to: ${path4.relative(process.cwd(), FONTS_CSS)}`);
1381
+ console.log(` \u2705 Font files saved to: ${path4.relative(process.cwd(), FONTS_DIR)}/`);
1382
+ console.log(`
1383
+ To use these fonts, import the CSS in your _app.tsx or _document.tsx:`);
1384
+ console.log(` import '../../static/css/fonts.css';`);
1385
+ console.log(`
1386
+ Or add to your CSS:`);
1387
+ console.log(` body { font-family: '${manifest.fonts[0].family}', '${manifest.fonts[0].family} Fallback', sans-serif; }`);
1388
+ console.log();
1389
+ }
1390
+
1142
1391
  // cli/blumen.ts
1143
1392
  async function main() {
1144
1393
  const command = process.argv[2];
@@ -1160,6 +1409,9 @@ async function main() {
1160
1409
  console.log(
1161
1410
  ` deploy Deploy to Docker, Fly.io, or Railway`
1162
1411
  );
1412
+ console.log(
1413
+ ` fonts Download and optimize fonts`
1414
+ );
1163
1415
  console.log("");
1164
1416
  console.log(` ${c.bold}Options${c.reset}`);
1165
1417
  console.log(` --help Show this help message`);
@@ -1187,6 +1439,9 @@ async function main() {
1187
1439
  case "deploy":
1188
1440
  await deploy(process.argv[3]);
1189
1441
  break;
1442
+ case "fonts":
1443
+ await fontsCommand(process.argv.slice(3));
1444
+ break;
1190
1445
  default:
1191
1446
  log.error(
1192
1447
  `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,18 @@ 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 --alias:@=./app"
85
+ },
86
+ {
87
+ label: "Pre-rendering static pages (SSG)",
88
+ cmd: "npx tsx scripts/ssg-prerender.ts",
89
+ optional: true
90
+ // Don't fail if no SSG pages exist
80
91
  }
81
92
  ];
82
93
  const startTime = Date.now();
@@ -84,11 +95,19 @@ async function build() {
84
95
  const step = steps[i];
85
96
  log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
86
97
  try {
87
- execSync(step.cmd, { stdio: "inherit", cwd: process.cwd() });
98
+ execSync(step.cmd, {
99
+ stdio: "inherit",
100
+ cwd: process.cwd(),
101
+ env: { ...process.env, ...step.env || {} }
102
+ });
88
103
  log.success(step.label);
89
104
  } catch {
90
- log.error(`Failed: ${step.label}`);
91
- process.exit(1);
105
+ if (step.optional) {
106
+ log.success(step.label);
107
+ } else {
108
+ log.error(`Failed: ${step.label}`);
109
+ process.exit(1);
110
+ }
92
111
  }
93
112
  }
94
113
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -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/"],
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
+ };