docs/scripts/docs-site/r2-prepare.mjs
2026-05-07 01:22:46 +01:00

129 lines
4.1 KiB
JavaScript

#!/usr/bin/env node
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const sourceDir = path.join(root, "dist", "docs-site");
const outputDir = path.join(root, "dist", "docs-r2");
const manifestPath = path.join(root, "dist", "docs-r2-manifest.json");
if (!fs.existsSync(sourceDir)) throw new Error("dist/docs-site does not exist; run docs:build first");
fs.rmSync(outputDir, { recursive: true, force: true });
copyTree(sourceDir, outputDir);
const entries = [];
for (const file of walk(outputDir)) {
const key = toKey(path.relative(outputDir, file));
if (key === "_headers") continue;
entries.push(entryFor(key, file, key));
}
for (const file of walk(outputDir)) {
const rel = toKey(path.relative(outputDir, file));
if (rel === "_headers") continue;
if (!rel.endsWith("/index.html") || rel === "index.html") continue;
const slashlessKey = rel.slice(0, -"/index.html".length);
entries.push(entryFor(slashlessKey, file, rel));
}
entries.sort((a, b) => a.key.localeCompare(b.key));
const manifest = {
version: 1,
generatedAt: new Date().toISOString(),
sourceDir: "dist/docs-site",
outputDir: "dist/docs-r2",
objectCount: entries.length,
entries,
};
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const physicalFiles = countFiles(outputDir);
const virtualFiles = entries.length - physicalFiles;
console.log(`r2 prepare ok: ${physicalFiles} files, ${virtualFiles} slashless html aliases, ${entries.length} objects`);
function copyTree(from, to) {
fs.mkdirSync(to, { recursive: true });
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
const source = path.join(from, entry.name);
const target = path.join(to, entry.name);
if (entry.isDirectory()) {
copyTree(source, target);
} else if (entry.isFile()) {
fs.copyFileSync(source, target);
}
}
}
function entryFor(key, file, sourceKey) {
const data = fs.readFileSync(file);
return {
key,
sourceKey,
file: toKey(path.relative(root, file)),
size: data.byteLength,
sha256: crypto.createHash("sha256").update(data).digest("hex"),
contentType: contentTypeFor(key),
cacheControl: cacheControlFor(key),
};
}
function contentTypeFor(key) {
const ext = path.extname(key).toLowerCase();
if (!ext || key.endsWith("/index.html")) return "text/html; charset=utf-8";
switch (ext) {
case ".avif": return "image/avif";
case ".css": return "text/css; charset=utf-8";
case ".gif": return "image/gif";
case ".html": return "text/html; charset=utf-8";
case ".ico": return "image/x-icon";
case ".jpeg":
case ".jpg": return "image/jpeg";
case ".js": return "text/javascript; charset=utf-8";
case ".json": return "application/json; charset=utf-8";
case ".jsonl": return "application/x-ndjson; charset=utf-8";
case ".md": return "text/markdown; charset=utf-8";
case ".png": return "image/png";
case ".svg": return "image/svg+xml";
case ".txt": return "text/plain; charset=utf-8";
case ".webp": return "image/webp";
case ".wasm": return "application/wasm";
case ".xml": return "application/xml; charset=utf-8";
default: return "application/octet-stream";
}
}
function cacheControlFor(key) {
if (key === "CNAME") return "public, max-age=300, s-maxage=300";
if (key.endsWith(".html") || !path.extname(key)) {
return "public, max-age=60, s-maxage=86400, stale-while-revalidate=604800";
}
if (key.endsWith(".md") || key.endsWith(".txt") || key.endsWith(".json") || key.endsWith(".jsonl")) {
return "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400";
}
return "public, max-age=31536000, immutable";
}
function countFiles(dir) {
let count = 0;
for (const _file of walk(dir)) count += 1;
return count;
}
function* walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
function toKey(value) {
return value.split(path.sep).join("/");
}