← Files DataARCHIVED FILE
scripts/prepare-resource-preview.mjs
11 KB · Nov 7, 2026 · 12:11 UTC
#!/usr/bin/env node
// Local, content-only previews. No provider discovery or browser downloads.
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { isAbsolute, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
const VERSION = "page-preview-2";
const MAX_IMAGE_LENGTH = 262144;
const hash = (value) => createHash("sha256").update(value).digest("hex");
const escape = (value) => value.replace(/[&<>"']/gu, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const text = (value, field, limit) => {
if (value === undefined) return "";
if (typeof value !== "string") throw new Error(`${field} must be a string.`);
return value.slice(0, limit).replace(/\r\n?/gu, "\n");
};
// Deliberately a small display subset, not an HTML-capable Markdown renderer.
function inline(value) {
return escape(value.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1"))
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '<span class="link">$1</span>')
.replace(/`([^`]+)`/gu, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/gu, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/gu, "<em>$1</em>");
}
function openingContent(markdown, title) {
const lines = markdown.trim().split("\n");
const blocks = [];
const cells = (line) => line.trim().replace(/^\||\|$/gu, "").split("|").slice(0, 4);
for (let i = 0; i < lines.length && blocks.length < 12;) {
const line = lines[i].trim();
if (!line) { i++; continue; }
if (/^```/u.test(line)) {
const code = [];
for (i++; i < lines.length && !/^```/u.test(lines[i]); i++) if (code.length < 5) code.push(lines[i]);
i++;
blocks.push(`<pre>${escape(code.join("\n"))}</pre>`);
continue;
}
const heading = /^(#{1,6})\s+(.+)$/u.exec(line);
if (heading) {
if (!(blocks.length === 0 && heading[2].trim().toLowerCase() === title.trim().toLowerCase())) {
const level = Math.min(heading[1].length + 1, 3);
blocks.push(`<h${level}>${inline(heading[2])}</h${level}>`);
}
i++; continue;
}
if (line.includes("|") && /^\s*\|?\s*:?-{3,}/u.test(lines[i + 1] ?? "")) {
const header = cells(line);
const rows = [];
i += 2;
while (i < lines.length && lines[i].includes("|")) {
if (rows.length < 4) rows.push(cells(lines[i]));
i++;
}
blocks.push(`<table><thead><tr>${header.map(c => `<th>${inline(c.trim())}</th>`).join("")}</tr></thead><tbody>${rows.map(row => `<tr>${header.map((_, j) => `<td>${inline((row[j] ?? "").trim())}</td>`).join("")}</tr>`).join("")}</tbody></table>`);
continue;
}
if (/^(?:[-*+] |\d+\. )/u.test(line)) {
const ordered = /^\d/u.test(line), items = [];
while (i < lines.length && /^(?:[-*+] |\d+\. )/u.test(lines[i].trim())) {
if (items.length < 5) items.push(`<li>${inline(lines[i].trim().replace(/^(?:[-*+] |\d+\. )/u, ""))}</li>`);
i++;
}
const tag = ordered ? "ol" : "ul";
blocks.push(`<${tag}>${items.join("")}</${tag}>`);
continue;
}
const paragraph = [line]; i++;
while (i < lines.length && lines[i].trim() && !/^(?:#{1,6}\s|[-*+] |\d+\. |```)/u.test(lines[i].trim())) {
if (lines[i].includes("|") && /^\s*\|?\s*:?-{3,}/u.test(lines[i + 1] ?? "")) break;
paragraph.push(lines[i++].trim());
}
blocks.push(`<p>${inline(paragraph.join(" "))}</p>`);
}
return blocks.join("\n");
}
function documentShell(body, css) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:"><title>Resource preview</title><style>${css}</style></head><body>${body}</body></html>`;
}
export function pagePreview(input) {
if (!input || Array.isArray(input) || typeof input !== "object") throw new Error("Expected Page metadata.");
const uri = input.uri;
if (typeof uri !== "string" || !uri || uri.length > 8192 || !URL.canParse(uri)) throw new Error("Page uri must be an absolute resource identity up to 8192 characters.");
const title = text(input.title, "title", 500).trim();
if (!title) throw new Error("Page title is required.");
const markdown = text(input.markdown, "markdown", 12000);
const preview = text(input.preview, "preview", 2000);
const revision = text(input.revision, "revision", 500);
const updated = text(input.updated_at, "updated_at", 100);
const content = markdown.trim() || preview;
// Match the 16:9 picker card. At 480px wide, body/title text is about 17/24px.
// The host owns the hover label; content can continue beneath it and crop naturally.
const html = documentShell(`<main><h1>${escape(title)}</h1><article>${openingContent(content, title)}</article></main>`, `
*{box-sizing:border-box}html,body{margin:0;width:720px;height:405px;overflow:hidden;background:#fff;color:#202123}
body{font-family:Arial,Helvetica,sans-serif;-webkit-font-smoothing:antialiased}
main{padding:34px 46px 0}
h1{font-size:36px;line-height:43px;letter-spacing:-.7px;font-weight:700;margin:0 0 12px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
article{font-size:25px;line-height:34px;color:#414349;overflow-wrap:anywhere}
h2,h3{color:#202123;font-size:27px;line-height:34px;margin:20px 0 10px;font-weight:700;letter-spacing:-.3px}
p{margin:0 0 14px}ul,ol{padding-left:27px;margin:8px 0 14px}li{padding-left:3px;margin:4px 0}
strong{font-weight:700;color:#27292d}.link{color:#2563a6}code{font-size:.88em;background:#f2f3f4;padding:2px 4px;border-radius:3px}
pre{font-size:22px;line-height:30px;background:#f6f7f8;padding:14px;white-space:pre-wrap;overflow:hidden}
table{width:100%;table-layout:fixed;border-collapse:collapse;font-size:23px;line-height:30px;margin:14px 0}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid #e5e7eb;overflow-wrap:anywhere}th{font-weight:700;background:#f8f9fa;color:#303238}
`);
return { uri, html, cacheKey: hash(JSON.stringify([VERSION, uri, title, markdown, preview, revision, updated])), width: 720, height: 405, kind: "page-content" };
}
export function iconPreview(svg) {
if (typeof svg !== "string" || !/<svg[\s>]/u.test(svg) || Buffer.byteLength(svg) > 262144) throw new Error("Use an authentic SVG asset up to 256 KiB.");
const src = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
return {
html: documentShell(`<img alt="" src="${src}">`, "html,body{margin:0;width:64px;height:64px;background:transparent;overflow:hidden}img{display:block;width:64px;height:64px;object-fit:contain}"),
cacheKey: hash(`${VERSION}:icon:${svg}`), width: 64, height: 64, kind: "icon",
};
}
function imageResult(bytes, path, cached, spec) {
const src = `data:image/png;base64,${bytes.toString("base64")}`;
if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || src.length > MAX_IMAGE_LENGTH) throw new Error("Preview is not a supported PNG within the form image limit.");
return { ...(spec.uri ? { uri: spec.uri } : {}), [spec.kind === "icon" ? "icon" : "thumbnail"]: { src, mimeType: "image/png" }, provenance: spec.kind, imagePath: path, cached };
}
export async function preparePreview(spec, { cacheDir, getBrowser }) {
if (!isAbsolute(cacheDir)) throw new Error("Use an absolute task-owned cache directory.");
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
const path = join(cacheDir, `${spec.cacheKey}.png`);
try { return imageResult(await readFile(path), path, true, spec); }
catch (error) { if (error.code !== "ENOENT") throw error; }
const browser = await getBrowser();
const context = await browser.newContext({ viewport: { width: spec.width, height: spec.height }, deviceScaleFactor: 1, javaScriptEnabled: false, serviceWorkers: "block", offline: true });
try {
await context.route("**/*", route => route.abort());
const page = await context.newPage();
page.setDefaultTimeout(5000);
await page.setContent(spec.html, { waitUntil: "load", timeout: 5000 });
const bytes = await page.screenshot({ type: "png", omitBackground: spec.kind === "icon", timeout: 5000 });
const result = imageResult(bytes, path, false, spec);
await writeFile(path, bytes, { mode: 0o600 });
return result;
} finally { await context.close(); }
}
async function main() {
const { values } = parseArgs({ options: {
input: { type: "string" }, icon: { type: "string" }, "cache-dir": { type: "string" },
"browser-package-json": { type: "string" }, "browser-executable": { type: "string" }, help: { type: "boolean" },
} });
if (values.help) {
console.log("prepare-resource-preview.mjs --input <Page JSON> | --icon <official.svg> --cache-dir <absolute task directory> --browser-package-json <existing package.json> --browser-executable <installed Chromium>");
return;
}
if (Boolean(values.input) === Boolean(values.icon) || !isAbsolute(values["cache-dir"] ?? "")) throw new Error("Specify --input or --icon, and an absolute --cache-dir.");
const inputBytes = await readFile(values.input ?? values.icon);
if (inputBytes.length > 1024 * 1024) throw new Error("Preview input exceeds 1 MiB.");
const input = values.input ? JSON.parse(inputBytes.toString()) : null;
const items = values.input ? (Array.isArray(input) ? input : [input]) : [];
if (values.input && (!items.length || items.length > 24)) throw new Error("Use 1–24 Page candidates.");
const specs = values.icon ? [iconPreview(inputBytes.toString())] : items.map(pagePreview);
let browser;
const getBrowser = async () => {
if (!browser) {
if (!isAbsolute(values["browser-package-json"] ?? "") || !isAbsolute(values["browser-executable"] ?? "")) throw new Error("No existing browser runtime supplied; omit the optional preview instead of installing dependencies.");
const { chromium } = createRequire(values["browser-package-json"])("playwright-core");
browser = await chromium.launch({ executablePath: values["browser-executable"], headless: true, timeout: 10000 });
}
return browser;
};
try {
const options = { cacheDir: values["cache-dir"], getBrowser };
const pageIcon = values.input
? await preparePreview(iconPreview(await readFile(new URL("../assets/page-resource-icon.svg", import.meta.url), "utf8")), options)
: null;
const results = [];
for (const spec of specs) {
const result = await preparePreview(spec, options);
results.push(pageIcon ? { ...result, icons: [pageIcon.icon] } : result);
}
const output = join(values["cache-dir"], `${hash(specs.map(s => s.cacheKey).join(":"))}.json`);
await writeFile(output, JSON.stringify(results), { mode: 0o600 });
console.log(JSON.stringify({ output, images: results.map(({ imagePath, cached, provenance }) => ({ imagePath, cached, provenance })) }));
} finally { await browser?.close(); }
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
main().catch(error => { console.error(error.message); process.exitCode = 1; });
}
SHA-256: 64be8f8cd449229306cc6e7b74e8ba2a9d0e424834a2b9eaffe2e2da3c7f8349