src / wikiPaths.ts
import * as path from "node:path";
import * as fs from "node:fs/promises";
import * as os from "node:os";
export class WikiError extends Error {
constructor(message: string) {
super(message);
this.name = "WikiError";
}
}
export const PAGES_DIR = "pages";
export const SOURCES_DIR = "sources";
export const BACKUP_DIR = ".wiki-backup";
export const INDEX_FILE = "index.md";
export const LOG_FILE = "log.md";
export const SCHEMA_FILE = "WIKI.md";
export function expandTilde(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export async function canonicalizeRoot(raw: string): Promise<string> {
if (!raw || !raw.trim()) {
throw new WikiError(
"No wiki root is configured. Set the `Wiki root` field in the plugin's per-chat config (e.g. ~/Documents/wiki).",
);
}
const expanded = path.resolve(expandTilde(raw.trim()));
try {
return await fs.realpath(expanded);
} catch {
return expanded;
}
}
const NAME_RE = /^[A-Za-z0-9._-][A-Za-z0-9._\- ]*$/;
export function sanitizeName(raw: string, kind: "page" | "source"): string {
const name = raw.trim();
if (!name) throw new WikiError(`${kind} name is required.`);
if (name.includes("/") || name.includes("\\")) {
throw new WikiError(`${kind} name must not contain path separators: "${raw}"`);
}
if (name.startsWith(".")) {
throw new WikiError(`${kind} name must not start with '.': "${raw}"`);
}
if (!NAME_RE.test(name)) {
throw new WikiError(
`${kind} name "${raw}" contains forbidden characters. Use letters, digits, dot, hyphen, underscore, space.`,
);
}
return name;
}
export function pageFileName(raw: string): string {
const clean = sanitizeName(raw, "page");
return clean.toLowerCase().endsWith(".md") ? clean : `${clean}.md`;
}
export function sourceFileName(raw: string): string {
return sanitizeName(raw, "source");
}
export interface WikiLayout {
root: string;
pagesDir: string;
sourcesDir: string;
backupDir: string;
indexPath: string;
logPath: string;
schemaPath: string;
}
export function layoutFromRoot(root: string): WikiLayout {
return {
root,
pagesDir: path.join(root, PAGES_DIR),
sourcesDir: path.join(root, SOURCES_DIR),
backupDir: path.join(root, BACKUP_DIR),
indexPath: path.join(root, INDEX_FILE),
logPath: path.join(root, LOG_FILE),
schemaPath: path.join(root, SCHEMA_FILE),
};
}
export function pagePath(layout: WikiLayout, name: string): string {
return path.join(layout.pagesDir, pageFileName(name));
}
export function sourcePath(layout: WikiLayout, name: string): string {
return path.join(layout.sourcesDir, sourceFileName(name));
}
export function sourceMetaPath(layout: WikiLayout, name: string): string {
return path.join(layout.sourcesDir, `${sourceFileName(name)}.meta.json`);
}
export async function ensureWikiInitialized(layout: WikiLayout): Promise<void> {
try {
await fs.access(layout.indexPath);
} catch {
throw new WikiError(
`Wiki not initialized at ${layout.root}. Call wiki_init first to scaffold the structure.`,
);
}
}
export async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
export function timestampForBackup(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
export async function backupFile(
layout: WikiLayout,
absSource: string,
subdir: "pages" | "sources",
name: string,
): Promise<string> {
const ts = timestampForBackup();
const dest = path.join(layout.backupDir, subdir, `${name}.${ts}.bak`);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(absSource, dest);
return dest;
}