Portfolio/server/utils/providers/local.ts
2026-09-08 19:54:06 +02:00

89 lines
2.8 KiB
TypeScript

import { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
import type { ContentProvider, ProviderOptions, RemoteFile, WriteOptions, WriteResult } from './types'
import { isMarkdown, normalizeDir } from './types'
/**
* Reads and writes posts on the local filesystem. Useful for drafting before
* the repo exists, and for CI where you do not want to hit the network.
*
* There is no git here, so "sha" is just a content hash — enough to give the
* dashboard the same optimistic-concurrency guarantee as the git backends.
*/
export function createLocalProvider(opts: ProviderOptions): ContentProvider {
// `localDir` stands in for the repo root, so paths look exactly like the git
// backends': `posts/foo.md`, `site.json`.
const root = resolve(process.cwd(), opts.localDir)
const dir = normalizeDir(opts.dir)
const hash = (content: string) => createHash('sha256').update(content).digest('hex').slice(0, 40)
function safeResolve(path: string): string {
const full = resolve(root, path)
if (full !== root && !full.startsWith(root + sep)) {
throw new Error(`refusing to touch a path outside ${root}`)
}
return full
}
async function walk(current: string): Promise<string[]> {
const entries = await readdir(current, { withFileTypes: true }).catch(() => [])
const out: string[] = []
for (const entry of entries) {
const full = join(current, entry.name)
if (entry.isDirectory()) out.push(...(await walk(full)))
else if (isMarkdown(entry.name)) out.push(full)
}
return out
}
const toRepoPath = (full: string) => relative(root, full).split(sep).join('/')
return {
kind: 'local',
writable: true,
async list(): Promise<RemoteFile[]> {
const files = await walk(dir ? join(root, dir) : root)
return await Promise.all(
files.map(async (full) => ({
path: toRepoPath(full),
sha: hash(await readFile(full, 'utf8')),
})),
)
},
async read(path: string): Promise<string> {
return await readFile(safeResolve(path), 'utf8')
},
async stat(path: string) {
try {
return { sha: hash(await readFile(safeResolve(path), 'utf8')) }
} catch {
return null
}
},
async write({ path, content }: WriteOptions): Promise<WriteResult> {
const full = safeResolve(path)
await mkdir(dirname(full), { recursive: true })
await writeFile(full, content, 'utf8')
return { path, sha: hash(content) }
},
async remove({ path }) {
await rm(safeResolve(path), { force: true })
},
sourceUrl(path: string): string {
return `file://${join(root, path)}`
},
assetUrl(path: string): string {
return `/${path}`
},
}
}