personal-homepage/scripts/sync-static-assets.ts

315 lines
8.3 KiB
TypeScript

import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
type StaticAssetManifestItem = {
source: string;
target: string;
};
type StaticAssetManifestGroup = {
target_dir: string;
files: Array<{
url: string;
filename: string;
}>;
};
type StaticAssetConfig = {
rootDir: string;
manifestPath: string;
seafileBaseUrl: string;
strict: boolean;
requestTimeoutMs: number;
};
async function main() {
const config = loadConfig(process.cwd());
const items = await readManifest(config.manifestPath);
console.log('[sync-static-assets] start');
console.log(`[sync-static-assets] manifest: ${config.manifestPath}`);
if (items.length === 0) {
console.log('[sync-static-assets] no assets configured, skip');
return;
}
let failureCount = 0;
for (const item of items) {
try {
await syncAsset(item, config);
} catch (error) {
failureCount += 1;
const message = error instanceof Error ? error.message : String(error);
if (config.strict) {
throw new Error(`[sync-static-assets] failed: ${message}`);
}
console.warn(`[sync-static-assets] skip failed asset: ${message}`);
}
}
console.log(
`[sync-static-assets] done: total=${items.length} failed=${failureCount} strict=${config.strict}`,
);
}
function loadConfig(rootDir: string): StaticAssetConfig {
return {
rootDir,
manifestPath: resolveFromRoot(
rootDir,
process.env.STATIC_ASSET_MANIFEST ?? 'src/content/static-assets/index.json',
),
seafileBaseUrl: process.env.SEAFILE_BASE_URL?.trim() ?? '',
strict: getBooleanEnv('STATIC_ASSET_SYNC_STRICT', false),
requestTimeoutMs: getNumberEnv('STATIC_ASSET_REQUEST_TIMEOUT_MS', 15000),
};
}
async function readManifest(filePath: string): Promise<StaticAssetManifestItem[]> {
try {
const raw = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
throw new Error('manifest must be a JSON array');
}
return parsed.flatMap((item, index) => normalizeManifestEntry(item, index));
} catch (error) {
if (isMissingFileError(error)) {
return [];
}
throw error;
}
}
function normalizeManifestEntry(item: unknown, index: number): StaticAssetManifestItem[] {
if (!item || typeof item !== 'object') {
throw new Error(`manifest item #${index + 1} must be an object`);
}
const record = item as Record<string, unknown>;
const targetDir = typeof record.target_dir === 'string' ? record.target_dir.trim() : '';
const files = Array.isArray(record.files) ? record.files : null;
if (targetDir || files) {
return normalizeManifestGroup({ target_dir: targetDir, files: files ?? [] }, index);
}
return [normalizeLegacyManifestItem(record, index)];
}
function normalizeLegacyManifestItem(
record: Record<string, unknown>,
index: number,
): StaticAssetManifestItem {
const source = typeof record.url === 'string' ? record.url.trim() : '';
const target = typeof record.target === 'string' ? record.target.trim() : '';
if (!source) {
throw new Error(`manifest item #${index + 1} is missing url`);
}
if (!target) {
throw new Error(`manifest item #${index + 1} is missing target`);
}
return { source, target };
}
function normalizeManifestGroup(
group: StaticAssetManifestGroup,
index: number,
): StaticAssetManifestItem[] {
const targetDir = group.target_dir.trim();
if (!targetDir) {
throw new Error(`manifest group #${index + 1} is missing target_dir`);
}
if (!Array.isArray(group.files)) {
throw new Error(`manifest group #${index + 1} files must be an array`);
}
return group.files.map((item, fileIndex) => {
if (!item || typeof item !== 'object') {
throw new Error(`manifest group #${index + 1} file #${fileIndex + 1} must be an object`);
}
const record = item as Record<string, unknown>;
const source = typeof record.url === 'string' ? record.url.trim() : '';
const filename = typeof record.filename === 'string' ? record.filename.trim() : '';
if (!source) {
throw new Error(`manifest group #${index + 1} file #${fileIndex + 1} is missing url`);
}
if (!filename) {
throw new Error(
`manifest group #${index + 1} file #${fileIndex + 1} is missing filename`,
);
}
return {
source,
target: path.posix.join(targetDir.replace(/\\/g, '/'), filename),
};
});
}
async function syncAsset(item: StaticAssetManifestItem, config: StaticAssetConfig) {
const targetPath = ensurePathInsideRoot(config.rootDir, item.target);
await mkdir(path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.tmp`;
const resolvedUrl = resolveManifestUrl(item.source, config.seafileBaseUrl);
const response = await fetchWithTimeout(resolvedUrl, config.requestTimeoutMs);
try {
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await writeFile(tempPath, buffer);
await rename(tempPath, targetPath);
console.log(
`[sync-static-assets] wrote ${path.relative(config.rootDir, targetPath)} <- ${resolvedUrl}`,
);
} catch (error) {
await safeRemove(tempPath);
throw error;
}
}
function ensurePathInsideRoot(rootDir: string, target: string) {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(rootDir, target);
const relative = path.relative(resolvedRoot, resolvedTarget);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`target path escapes repo root: ${target}`);
}
return resolvedTarget;
}
async function fetchWithTimeout(url: string, timeoutMs: number) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: 'GET',
signal: controller.signal,
});
if (!response.ok) {
const body = await safeReadText(response);
throw new Error(`HTTP ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
}
return response;
} finally {
clearTimeout(timeout);
}
}
function getBooleanEnv(name: string, fallback: boolean) {
const value = process.env[name];
if (value == null || value.trim() === '') {
return fallback;
}
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
function getNumberEnv(name: string, fallback: number) {
const value = process.env[name];
if (value == null || value.trim() === '') {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveManifestUrl(value: string, baseUrl: string) {
const normalizedPath = normalizeSeafilePath(value);
if (!normalizedPath) {
throw new Error('manifest item url is empty');
}
if (isAbsoluteUrl(normalizedPath)) {
return normalizedPath;
}
if (!baseUrl) {
throw new Error(
`relative asset url "${normalizedPath}" requires SEAFILE_BASE_URL in .env`,
);
}
return new URL(normalizedPath, `${trimTrailingSlash(baseUrl)}/`).toString();
}
function isAbsoluteUrl(value: string) {
return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value);
}
function normalizeSeafilePath(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
if (isAbsoluteUrl(trimmed) || trimmed.startsWith('/')) {
return trimmed;
}
if (/^[A-Za-z0-9_-]+$/.test(trimmed)) {
return `/f/${trimmed}/?dl=1`;
}
return trimmed;
}
function resolveFromRoot(rootDir: string, targetPath: string) {
return path.isAbsolute(targetPath) ? targetPath : path.join(rootDir, targetPath);
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, '');
}
function isMissingFileError(error: unknown) {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
async function safeReadText(response: Response) {
try {
return (await response.text()).slice(0, 300);
} catch {
return '';
}
}
async function safeRemove(filePath: string) {
try {
await unlink(filePath);
} catch {
// Ignore temp cleanup errors.
}
}
main().catch((error) => {
console.error('[sync-static-assets] failed');
console.error(error);
process.exitCode = 1;
});