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.
- package/dist/cli/blumen.js +270 -15
- package/dist/cli/commands/build.js +25 -6
- package/dist/cli/commands/create.js +1 -1
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fonts.js +232 -0
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +3 -1
- package/dist/templates/app/pages/BlumenStarter.tsx +1 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +49 -4
- package/dist/templates/app/shared/Link.tsx +60 -6
- package/dist/templates/app/shared/RouterContext.tsx +81 -18
- package/dist/templates/app/shared/router.ts +2 -1
- package/dist/templates/go-server/cache.go +147 -0
- package/dist/templates/go-server/image.go +200 -0
- package/dist/templates/go-server/main.go +394 -39
- package/dist/templates/go-server/middleware.go +546 -0
- package/dist/templates/go-server/websocket.go +430 -0
- package/dist/templates/node-ssr/server.ts +364 -8
- package/dist/templates/scripts/generate-routes.ts +355 -7
- package/package.json +12 -6
package/dist/cli/blumen.js
CHANGED
|
@@ -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((
|
|
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
|
-
|
|
82
|
+
resolve3(options[idx]);
|
|
83
83
|
} else {
|
|
84
|
-
|
|
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((
|
|
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
|
-
|
|
101
|
+
resolve3(defaultYes);
|
|
102
102
|
else
|
|
103
|
-
|
|
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/
|
|
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
|
|
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, {
|
|
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
|
-
|
|
351
|
-
|
|
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/
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -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
|
+
};
|