blumenjs 0.1.7 → 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.
- package/dist/cli/blumen.js +258 -13
- package/dist/cli/commands/build.js +13 -4
- 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/shared/RouterContext.tsx +4 -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 +208 -40
- package/dist/templates/node-ssr/server.ts +120 -8
- package/dist/templates/scripts/generate-routes.ts +43 -3
- package/package.json +7 -3
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/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
|
|
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, {
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
+
};
|
|
@@ -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
|
-
//
|
|
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
|
+
|