/**
* prerender.mjs — Genera HTML statico per le route pubbliche.
*
* Uso:
* pnpm build:static → quasar build + node scripts/prerender.mjs
*
* Output:
* dist/spa/index.html (route /)
* dist/spa/someroute/index.html (any additional public route)
*
* ─── Handler Go (esempio GoFiber) ────────────────────────────────────────────
*
* app.Static("/", "./dist/spa", fiber.Static{
* Index: "index.html",
* Browse: false,
* MaxAge: 0, // nessuna cache per l'HTML
* })
*
* // Fallback: serve index.html per qualsiasi percorso sconosciuto (routing client SPA)
* app.Use(func(c *fiber.Ctx) error {
* // Serve l'HTML prerenderizzato se esiste, altrimenti ripiega sulla shell SPA
* candidate := "./dist/spa" + c.Path() + "/index.html"
* if _, err := os.Stat(candidate); err == nil {
* return c.SendFile(candidate)
* }
* return c.SendFile("./dist/spa/index.html")
* })
*
* ─── Esempio net/http ─────────────────────────────────────────────────────────
*
* fs := http.FileServer(http.Dir("./dist/spa"))
* http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
* candidate := "./dist/spa" + path.Clean(r.URL.Path) + "/index.html"
* if _, err := os.Stat(candidate); err == nil {
* http.ServeFile(w, r, candidate)
* return
* }
* // Serve la root prerenderizzata o la shell SPA
* r2 := r.Clone(r.Context())
* r2.URL.Path = "/"
* fs.ServeHTTP(w, r2)
* })
*
* ─────────────────────────────────────────────────────────────────────────────
*/
import {
copyFileSync,
cpSync,
createReadStream,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { createServer } from 'node:http';
import { extname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const DIST_DIR = resolve(__dirname, '../dist/spa');
const ENV_FILE = resolve(__dirname, '../.env');
const PORT = 4173;
/**
* Route pubbliche da prerenderizzare.
* Ogni voce produrra dist/spa{route}/index.html
*/
export const PUBLIC_ROUTES = [
'/',
// '/about',
// '/terms',
// '/privacy',
];
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.json': 'application/json',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
};
function startServer() {
const server = createServer((req, res) => {
const url = req.url?.split('?')[0] ?? '/';
let filePath = join(DIST_DIR, url === '/' ? 'index.html' : url);
// Fallback SPA: percorsi sconosciuti o senza estensione -> index.html
if (!existsSync(filePath) || !extname(filePath)) {
filePath = join(DIST_DIR, 'index.html');
}
const mime = MIME_TYPES[extname(filePath)] ?? 'application/octet-stream';
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'no-store');
createReadStream(filePath)
.on('error', () => {
res.writeHead(404);
res.end('Not found');
})
.pipe(res);
});
return new Promise((resolve) => server.listen(PORT, () => resolve(server)));
}
function parseEnvFile(filePath) {
if (!existsSync(filePath)) {
return {};
}
const data = readFileSync(filePath, 'utf-8');
const env = {};
for (const line of data.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const idx = trimmed.indexOf('=');
if (idx <= 0) {
continue;
}
const key = trimmed.slice(0, idx).trim();
let value = trimmed.slice(idx + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
function copyDirectoryContent(srcDir, destDir) {
mkdirSync(destDir, { recursive: true });
for (const entry of readdirSync(srcDir)) {
const srcPath = join(srcDir, entry);
const destPath = join(destDir, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
cpSync(srcPath, destPath, { recursive: true, force: true });
} else {
copyFileSync(srcPath, destPath);
}
}
}
function copySpaToGoTarget() {
const envFromFile = parseEnvFile(ENV_FILE);
const goProjectDir = process.env.GO_PROJECT_DIR ?? envFromFile.GO_PROJECT_DIR;
const goSpaTargetDir =
process.env.GO_SPA_TARGET_DIR ??
envFromFile.GO_SPA_TARGET_DIR ??
(goProjectDir ? join(goProjectDir, 'spa') : null);
if (!goSpaTargetDir) {
console.log('│ copy : skipped (set GO_PROJECT_DIR or GO_SPA_TARGET_DIR in .env)');
return;
}
const resolvedTarget = resolve(goSpaTargetDir);
rmSync(resolvedTarget, { recursive: true, force: true });
mkdirSync(resolvedTarget, { recursive: true });
copyDirectoryContent(DIST_DIR, resolvedTarget);
console.log(`│ copy : ${DIST_DIR} -> ${resolvedTarget}`);
}
async function prerender() {
console.log('┌─ Static prerender ────────────────────────────────────────────');
console.log(`│ dist dir : ${DIST_DIR}`);
console.log(`│ routes : ${PUBLIC_ROUTES.join(', ')}`);
console.log('│');
if (!existsSync(DIST_DIR)) {
console.error('│ ERROR: dist/spa not found — run `pnpm build` first.');
process.exit(1);
}
const server = await startServer();
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
for (const route of PUBLIC_ROUTES) {
process.stdout.write(`│ ${route.padEnd(30)} → `);
const page = await browser.newPage();
// Silenzia il rumore della console del browser
page.on('console', () => {});
await page.goto(`http://localhost:${PORT}${route}`, {
waitUntil: 'networkidle0',
timeout: 30_000,
});
const html = await page.content();
const outDir = route === '/' ? DIST_DIR : join(DIST_DIR, route);
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'index.html'), html, 'utf-8');
console.log(`${outDir}/index.html`);
await page.close();
}
await browser.close();
server.close();
copySpaToGoTarget();
console.log('│');
console.log('└─ Done ✓');
}
prerender().catch((err) => {
console.error(err);
process.exit(1);
});