Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
160
docs/build-pdf.mjs
Normal file
160
docs/build-pdf.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Renders docs/manual-de-uso.html to PDF with page numbers.
|
||||
*
|
||||
* Chrome's `--print-to-pdf` flag cannot produce a custom footer, and Blink does
|
||||
* not implement CSS paged-media margin boxes (`@page { @bottom-center { … } }`),
|
||||
* so page numbers are impossible from CSS alone. Driving Chrome through the
|
||||
* DevTools protocol instead gives access to `Page.printToPDF`, which accepts a
|
||||
* footer template.
|
||||
*
|
||||
* Usage: node docs/build-pdf.mjs
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile, writeFile, mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SOURCE = resolve(HERE, 'manual-de-uso.html');
|
||||
const TARGET = resolve(HERE, 'manual-de-uso.pdf');
|
||||
const PORT = 9333;
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
];
|
||||
|
||||
async function findChrome() {
|
||||
for (const path of CHROME_CANDIDATES) {
|
||||
try {
|
||||
await readFile(path);
|
||||
return path;
|
||||
} catch {
|
||||
// Not readable as a file but may still be executable; probe via access.
|
||||
try {
|
||||
const { accessSync, constants } = await import('node:fs');
|
||||
accessSync(path, constants.X_OK);
|
||||
return path;
|
||||
} catch { /* keep looking */ }
|
||||
}
|
||||
}
|
||||
throw new Error('No Chrome or Chromium found. Install one, or add its path to CHROME_CANDIDATES.');
|
||||
}
|
||||
|
||||
/** Poll the debugging endpoint until Chrome is ready to talk. */
|
||||
async function waitForDebugger(timeoutMs = 15000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||||
if (response.ok) return (await response.json()).webSocketDebuggerUrl;
|
||||
} catch { /* not up yet */ }
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
|
||||
throw new Error('Chrome did not expose its debugging port in time.');
|
||||
}
|
||||
|
||||
/** Minimal CDP client over the WebSocket built into Node 22. */
|
||||
function connect(url) {
|
||||
const socket = new WebSocket(url);
|
||||
const pending = new Map();
|
||||
const listeners = new Map();
|
||||
let nextId = 0;
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.id !== undefined && pending.has(message.id)) {
|
||||
const { resolve: ok, reject } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
message.error ? reject(new Error(message.error.message)) : ok(message.result);
|
||||
return;
|
||||
}
|
||||
|
||||
(listeners.get(message.method) ?? []).forEach((fn) => fn(message.params));
|
||||
});
|
||||
|
||||
const ready = new Promise((ok, reject) => {
|
||||
socket.addEventListener('open', ok, { once: true });
|
||||
socket.addEventListener('error', () => reject(new Error('Could not open the CDP socket.')), { once: true });
|
||||
});
|
||||
|
||||
return {
|
||||
ready,
|
||||
send(method, params = {}, sessionId) {
|
||||
const id = ++nextId;
|
||||
return new Promise((ok, reject) => {
|
||||
pending.set(id, { resolve: ok, reject });
|
||||
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
});
|
||||
},
|
||||
on(method, fn) {
|
||||
listeners.set(method, [...(listeners.get(method) ?? []), fn]);
|
||||
},
|
||||
close: () => socket.close(),
|
||||
};
|
||||
}
|
||||
|
||||
const FOOTER = `
|
||||
<div style="width:100%;font-size:8px;font-family:Helvetica,Arial,sans-serif;
|
||||
color:#8a8f9c;padding:0 18mm;display:flex;justify-content:space-between;">
|
||||
<span>Manual de uso · Panel del colectivo</span>
|
||||
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
|
||||
</div>`;
|
||||
|
||||
const chrome = await findChrome();
|
||||
const profile = await mkdtemp(join(tmpdir(), 'pdf-chrome-'));
|
||||
|
||||
const process_ = spawn(chrome, [
|
||||
'--headless',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
'about:blank',
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
try {
|
||||
const browserUrl = await waitForDebugger();
|
||||
const client = connect(browserUrl);
|
||||
await client.ready;
|
||||
|
||||
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
||||
const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
||||
|
||||
await client.send('Page.enable', {}, sessionId);
|
||||
|
||||
const loaded = new Promise((ok) => client.on('Page.loadEventFired', ok));
|
||||
await client.send('Page.navigate', { url: pathToFileURL(SOURCE).href }, sessionId);
|
||||
await loaded;
|
||||
|
||||
// Give web fonts and layout a moment to settle before measuring pages.
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
const { data } = await client.send('Page.printToPDF', {
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: FOOTER,
|
||||
marginTop: 0.79,
|
||||
marginBottom: 0.71,
|
||||
marginLeft: 0.71,
|
||||
marginRight: 0.71,
|
||||
}, sessionId);
|
||||
|
||||
await writeFile(TARGET, Buffer.from(data, 'base64'));
|
||||
client.close();
|
||||
|
||||
console.log(`PDF escrito en ${TARGET}`);
|
||||
} finally {
|
||||
process_.kill();
|
||||
}
|
||||
Reference in New Issue
Block a user