89 lines
2.4 KiB
TypeScript
89 lines
2.4 KiB
TypeScript
import { mkdir, open, readdir, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export type AnnotationPageInfo = {
|
|
name: string;
|
|
title: string;
|
|
size: number;
|
|
modifiedAt: string;
|
|
href: string;
|
|
};
|
|
|
|
const annotationPagesRoot = path.resolve(
|
|
process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages",
|
|
);
|
|
|
|
export async function listAnnotationPages(): Promise<AnnotationPageInfo[]> {
|
|
await mkdir(annotationPagesRoot, { recursive: true });
|
|
const entries = await readdir(annotationPagesRoot, { withFileTypes: true });
|
|
const pages = await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.isFile() && isSafeHtmlName(entry.name))
|
|
.map(async (entry) => {
|
|
const filePath = path.join(annotationPagesRoot, entry.name);
|
|
const [fileStat, title] = await Promise.all([
|
|
stat(filePath),
|
|
readHtmlTitle(filePath, entry.name),
|
|
]);
|
|
return {
|
|
name: entry.name,
|
|
title,
|
|
size: fileStat.size,
|
|
modifiedAt: fileStat.mtime.toISOString(),
|
|
href: `/annotations/${encodeURIComponent(entry.name)}`,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return pages.sort(
|
|
(left, right) =>
|
|
Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) ||
|
|
left.name.localeCompare(right.name, "zh-CN"),
|
|
);
|
|
}
|
|
|
|
export function resolveAnnotationPage(name: string): string | null {
|
|
if (!isSafeHtmlName(name)) return null;
|
|
const resolved = path.resolve(annotationPagesRoot, name);
|
|
if (path.dirname(resolved) !== annotationPagesRoot) return null;
|
|
return resolved;
|
|
}
|
|
|
|
function isSafeHtmlName(name: string): boolean {
|
|
return (
|
|
name === path.basename(name) &&
|
|
!name.startsWith(".") &&
|
|
/\.html?$/i.test(name)
|
|
);
|
|
}
|
|
|
|
async function readHtmlTitle(filePath: string, fileName: string) {
|
|
const handle = await open(filePath, "r");
|
|
try {
|
|
const buffer = Buffer.alloc(16 * 1024);
|
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
const head = buffer.subarray(0, bytesRead).toString("utf8");
|
|
const match = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
const title = match?.[1]
|
|
?.replace(/<[^>]+>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return title ? decodeHtmlEntities(title) : stripHtmlExtension(fileName);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
function stripHtmlExtension(fileName: string) {
|
|
return fileName.replace(/\.html?$/i, "");
|
|
}
|
|
|
|
function decodeHtmlEntities(value: string) {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll(""", '"')
|
|
.replaceAll("'", "'");
|
|
}
|