setup-assets.js
Rama main · réplica de lectura del repositorio original.
/**
* Emberline — Asset download script
* ──────────────────────────────────
* Run once: node setup-assets.js
*
* Downloads the fonts and copies the crypto library (from node_modules) so
* Emberline runs fully self-hosted with zero external requests.
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const FONTS_DIR = path.join(__dirname, 'public', 'fonts');
const JS_DIR = path.join(__dirname, 'public', 'vendor');
// Create directories
if (!fs.existsSync(FONTS_DIR)) fs.mkdirSync(FONTS_DIR, { recursive: true });
if (!fs.existsSync(JS_DIR)) fs.mkdirSync(JS_DIR, { recursive: true });
// Robust file download:
// - rejects on any non-2xx status (the original would write a 404 HTML body
// into nacl-fast.min.js and produce a silently broken build)
// - follows 301, 302, 303, 307, 308 redirects up to 5 hops
// - resolves relative Location headers against the current URL
// - cleans up partial files on any failure path
function download(url, dest, redirectsLeft = 5) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
const cleanup = () => { try { file.close(); } catch {} fs.unlink(dest, () => {}); };
const req = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
const code = res.statusCode;
// Redirect: drain body, clean placeholder, recurse
if ([301, 302, 303, 307, 308].includes(code)) {
res.resume();
file.close();
fs.unlink(dest, () => {});
if (redirectsLeft <= 0) return reject(new Error(`Too many redirects: ${url}`));
const next = new URL(res.headers.location, url).toString();
return download(next, dest, redirectsLeft - 1).then(resolve, reject);
}
// Any other non-2xx is a hard failure — don't save the body
if (code < 200 || code >= 300) {
res.resume();
cleanup();
return reject(new Error(`HTTP ${code} fetching ${url}`));
}
res.pipe(file);
file.on('finish', () => { file.close(); resolve(dest); });
file.on('error', err => { cleanup(); reject(err); });
});
req.on('error', err => { cleanup(); reject(err); });
});
}
// Google Fonts picks the font format from the User-Agent. A generic UA gets
// .ttf links; a current desktop browser UA gets .woff2 split by unicode-range.
const FONT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
// Unicode subsets to self-host. Browsers only fetch the subsets a page needs,
// so cyrillic/greek/vietnamese would just be dead weight in the image.
const FONT_SUBSETS = new Set(['latin', 'latin-ext']);
function fetchFontCSS(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': FONT_UA } }, res => {
if (res.statusCode < 200 || res.statusCode >= 300) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
}
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(data));
res.on('error', reject);
}).on('error', reject);
});
}
async function main() {
console.log('Emberline asset setup\n');
// ── 1. TweetNaCl ────────────────────────────────────────────────────────────
// Copied from node_modules rather than downloaded: npm verifies each package
// against the integrity hash pinned in package-lock.json, so a tampered
// CDN or registry response fails `npm ci` instead of shipping to users.
console.log('Copying NaCl libraries from node_modules...');
for (const [pkgFile, name] of [
['tweetnacl/nacl-fast.min.js', 'nacl-fast.min.js'],
['tweetnacl-util/nacl-util.min.js', 'nacl-util.min.js'],
]) {
fs.copyFileSync(require.resolve(pkgFile), path.join(JS_DIR, name));
console.log(` ✓ public/vendor/${name}`);
}
// ── 2. Google Fonts ──────────────────────────────────────────────────────────
console.log('\nFetching font CSS...');
const fontCSS = await fetchFontCSS(
'https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;700&family=Playfair+Display:ital,wght@1,400&display=swap'
);
// Google prefixes each @font-face block with a /* subset */ comment.
// Keep only the blocks for the subsets we self-host.
const blocks = [...fontCSS.matchAll(/\/\*\s*([\w-]+)\s*\*\/\s*(@font-face\s*\{[^}]+\})/g)]
.filter(m => FONT_SUBSETS.has(m[1]))
.map(m => m[2]);
if (blocks.length === 0) throw new Error('No @font-face blocks found for subsets: ' + [...FONT_SUBSETS].join(', '));
const urlRegex = /url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/g;
const uniqueUrls = [...new Set(blocks.flatMap(b => [...b.matchAll(urlRegex)].map(m => m[1])))];
console.log(` Found ${blocks.length} @font-face blocks, ${uniqueUrls.length} font files`);
// Download each font file and track filename → url mapping
const fontMap = {};
for (const url of uniqueUrls) {
const filename = url.split('/').pop().split('?')[0];
const dest = path.join(FONTS_DIR, filename);
await download(url, dest);
fontMap[url] = `/fonts/${filename}`;
console.log(` ✓ public/fonts/${filename}`);
}
// ── 3. Build local @font-face CSS ────────────────────────────────────────────
console.log('\nGenerating public/fonts/fonts.css...');
const fontFaceBlocks = blocks
.map(b => b.replace(urlRegex, (_, remoteUrl) => `url(${fontMap[remoteUrl]})`))
.join('\n\n');
// Any remaining remote URL would be blocked by CSP (font-src 'self') and
// silently fall back to system fonts. Fail the build instead.
if (/url\(\s*['"]?https?:/i.test(fontFaceBlocks)) {
throw new Error('fonts.css still references remote URLs (unexpected format from Google Fonts?)');
}
fs.writeFileSync(path.join(FONTS_DIR, 'fonts.css'), fontFaceBlocks);
console.log(' ✓ public/fonts/fonts.css');
console.log('\nAll assets downloaded. Your folder structure:');
console.log(' (project root)');
console.log(' ├── server.js');
console.log(' ├── package.json');
console.log(' └── public/');
console.log(' ├── index.html');
console.log(' ├── vendor/');
console.log(' │ ├── nacl-fast.min.js');
console.log(' │ └── nacl-util.min.js');
console.log(' └── fonts/');
console.log(' ├── fonts.css');
console.log(' └── [woff2 font files]');
console.log('\nRestart your server and you\'re fully self-hosted.');
}
main().catch(err => {
console.error('Setup failed:', err.message);
process.exit(1);
});