src / wikiPages.ts
import * as path from "node:path";
import * as fs from "node:fs/promises";
import {
WikiError,
WikiLayout,
backupFile,
ensureWikiInitialized,
pageFileName,
pagePath,
pathExists,
} from "./wikiPaths";
import { appendLog, listPageEntries, refreshIndex } from "./wikiCore";
export async function listPages(
layout: WikiLayout,
filter?: string,
): Promise<{ pages: Array<{ name: string; file: string; summary: string | null }> }> {
await ensureWikiInitialized(layout);
const all = await listPageEntries(layout);
const filtered = filter
? all.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()))
: all;
return { pages: filtered };
}
export async function readPage(
layout: WikiLayout,
name: string,
maxBytes: number,
): Promise<{
name: string;
file: string;
path: string;
size: number;
content: string;
}> {
await ensureWikiInitialized(layout);
const file = pageFileName(name);
const abs = pagePath(layout, name);
const stat = await fs.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) {
throw new WikiError(`Page "${file}" does not exist.`);
}
if (stat.size > maxBytes) {
throw new WikiError(
`Page is ${stat.size} bytes, larger than the configured cap. Increase the cap in plugin config to read it.`,
);
}
const content = await fs.readFile(abs, "utf-8");
return {
name: file.replace(/\.md$/i, ""),
file,
path: abs,
size: stat.size,
content,
};
}
export async function writePage(
layout: WikiLayout,
args: { name: string; content: string; summary?: string },
): Promise<{
name: string;
file: string;
path: string;
bytes: number;
created: boolean;
backup_path: string | null;
index_rebuilt: boolean;
}> {
await ensureWikiInitialized(layout);
const file = pageFileName(args.name);
const abs = pagePath(layout, args.name);
await fs.mkdir(path.dirname(abs), { recursive: true });
const existed = await pathExists(abs);
let backupPath: string | null = null;
if (existed) {
backupPath = await backupFile(layout, abs, "pages", file);
}
await fs.writeFile(abs, args.content, "utf-8");
const idx = await refreshIndex(layout);
const baseName = file.replace(/\.md$/i, "");
const message = args.summary?.trim()
? args.summary.trim()
: existed
? "page updated"
: "page created";
await appendLog(layout, existed ? "update" : "write", baseName, message);
return {
name: baseName,
file,
path: abs,
bytes: Buffer.byteLength(args.content, "utf-8"),
created: !existed,
backup_path: backupPath,
index_rebuilt: idx.rebuilt,
};
}
export async function deletePage(
layout: WikiLayout,
name: string,
): Promise<{
name: string;
file: string;
deleted: boolean;
backup_path: string;
index_rebuilt: boolean;
}> {
await ensureWikiInitialized(layout);
const file = pageFileName(name);
const abs = pagePath(layout, name);
const stat = await fs.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) {
throw new WikiError(`Page "${file}" does not exist.`);
}
const backupPath = await backupFile(layout, abs, "pages", file);
await fs.unlink(abs);
const idx = await refreshIndex(layout);
const baseName = file.replace(/\.md$/i, "");
await appendLog(layout, "delete", baseName, `backup at ${path.relative(layout.root, backupPath)}`);
return {
name: baseName,
file,
deleted: true,
backup_path: backupPath,
index_rebuilt: idx.rebuilt,
};
}
export async function renamePage(
layout: WikiLayout,
args: { old_name: string; new_name: string },
): Promise<{
old_name: string;
new_name: string;
old_file: string;
new_file: string;
renamed: boolean;
backup_path: string;
index_rebuilt: boolean;
potential_referrers: string[];
}> {
await ensureWikiInitialized(layout);
const oldFile = pageFileName(args.old_name);
const newFile = pageFileName(args.new_name);
if (oldFile === newFile) {
throw new WikiError("Old and new names resolve to the same file.");
}
const oldAbs = pagePath(layout, args.old_name);
const newAbs = pagePath(layout, args.new_name);
const oldStat = await fs.stat(oldAbs).catch(() => null);
if (!oldStat || !oldStat.isFile()) {
throw new WikiError(`Page "${oldFile}" does not exist.`);
}
if (await pathExists(newAbs)) {
throw new WikiError(`Page "${newFile}" already exists. Pick a different name or delete it first.`);
}
const backupPath = await backupFile(layout, oldAbs, "pages", oldFile);
await fs.rename(oldAbs, newAbs);
const idx = await refreshIndex(layout);
const oldBase = oldFile.replace(/\.md$/i, "");
const newBase = newFile.replace(/\.md$/i, "");
const referrers = await findReferrers(layout, oldBase, oldFile);
await appendLog(
layout,
"rename",
`${oldBase} -> ${newBase}`,
referrers.length
? `${referrers.length} page(s) may contain stale links: ${referrers.slice(0, 5).join(", ")}${referrers.length > 5 ? ", …" : ""}`
: "no referrers detected",
);
return {
old_name: oldBase,
new_name: newBase,
old_file: oldFile,
new_file: newFile,
renamed: true,
backup_path: backupPath,
index_rebuilt: idx.rebuilt,
potential_referrers: referrers,
};
}
async function findReferrers(
layout: WikiLayout,
oldBase: string,
oldFile: string,
): Promise<string[]> {
if (!(await pathExists(layout.pagesDir))) return [];
const entries = await fs.readdir(layout.pagesDir, { withFileTypes: true });
const escaped = oldFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`\\(${escaped}\\)|\\[\\[${oldBase}\\]\\]`);
const out: string[] = [];
for (const e of entries) {
if (!e.isFile() || !e.name.toLowerCase().endsWith(".md")) continue;
if (e.name === oldFile) continue;
try {
const c = await fs.readFile(path.join(layout.pagesDir, e.name), "utf-8");
if (re.test(c)) out.push(e.name);
} catch {
/* skip */
}
}
return out;
}