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

182 lines
6.0 KiB
TypeScript

import matter from 'gray-matter'
import type { PostDraft, PostFrontmatterInput, SavePayload } from '#shared/types/dash'
import { useContentProvider } from './providers'
import { assertWritable } from './providers/types'
import { contentConfig } from './config'
import { bustCache } from './cache'
import { findEntry, getIndex, slugify } from './posts'
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const today = () => new Date().toISOString().slice(0, 10)
function asStringList(value: unknown): string[] {
if (Array.isArray(value)) return value.map((v) => String(v).trim().toLowerCase()).filter(Boolean)
if (typeof value === 'string') {
return value.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean)
}
return []
}
function asDate(value: unknown, fallback: string): string {
if (value instanceof Date && !Number.isNaN(value.valueOf())) {
return value.toISOString().slice(0, 10)
}
const text = String(value ?? '').trim()
if (DATE_RE.test(text)) return text
if (text) {
const parsed = new Date(text)
if (!Number.isNaN(parsed.valueOf())) return parsed.toISOString().slice(0, 10)
}
return fallback
}
/** what the editor sends -> what actually goes in the file */
function cleanFrontmatter(
input: Partial<PostFrontmatterInput>,
fallbackSlug: string,
): PostFrontmatterInput {
const title = String(input.title ?? '').trim()
if (!title) {
throw createError({ statusCode: 422, message: 'a post needs a title' })
}
const slug = slugify(String(input.slug ?? '').trim() || title) || fallbackSlug
if (!slug) {
throw createError({ statusCode: 422, message: 'could not derive a slug from that title' })
}
return {
title,
description: String(input.description ?? '').trim(),
date: asDate(input.date, today()),
updated: input.updated ? asDate(input.updated, '') : '',
tags: asStringList(input.tags),
slug,
draft: input.draft === true,
}
}
/** drop empty fields so the file stays readable */
function serialize(frontmatter: PostFrontmatterInput, body: string): string {
const data: Record<string, unknown> = {
title: frontmatter.title,
date: frontmatter.date,
slug: frontmatter.slug,
}
if (frontmatter.description) data.description = frontmatter.description
if (frontmatter.updated) data.updated = frontmatter.updated
if (frontmatter.tags.length) data.tags = frontmatter.tags
if (frontmatter.draft) data.draft = true
return matter.stringify(`${body.trimEnd()}\n`, data)
}
export async function getPostDraft(slug: string): Promise<PostDraft | null> {
const entry = await findEntry(slug)
if (!entry) return null
const provider = useContentProvider()
const raw = await provider.read(entry.path)
const { data, content } = matter(raw)
return {
path: entry.path,
sha: entry.sha,
sourceUrl: provider.sourceUrl(entry.path),
frontmatter: {
title: String(data.title ?? entry.meta.title),
description: String(data.description ?? data.summary ?? ''),
date: asDate(data.date, entry.meta.date.slice(0, 10)),
updated: data.updated ? asDate(data.updated, '') : '',
tags: asStringList(data.tags),
slug: entry.meta.slug,
draft: data.draft === true || data.published === false,
},
body: content.replace(/^\n+/, ''),
}
}
export async function createPost(payload: SavePayload) {
const provider = useContentProvider()
assertWritable(provider)
const frontmatter = cleanFrontmatter(payload.frontmatter, '')
const dir = contentConfig().dir.replace(/^\/+|\/+$/g, '')
const path = `${dir ? `${dir}/` : ''}${frontmatter.date}-${frontmatter.slug}.md`
const index = await getIndex()
if (index.some((e) => e.meta.slug === frontmatter.slug)) {
throw createError({ statusCode: 409, message: `a post already uses the slug "${frontmatter.slug}"` })
}
if (await provider.stat(path)) {
throw createError({ statusCode: 409, message: `${path} already exists in the repo` })
}
const result = await provider.write({
path,
content: serialize(frontmatter, payload.body ?? ''),
message: payload.message?.trim() || `post: add ${frontmatter.slug}`,
})
bustCache('posts:')
return { slug: frontmatter.slug, path, sha: result.sha, commit: result.commit }
}
export async function updatePost(slug: string, payload: SavePayload) {
const provider = useContentProvider()
assertWritable(provider)
const entry = await findEntry(slug)
if (!entry) throw createError({ statusCode: 404, message: 'post not found' })
const frontmatter = cleanFrontmatter(payload.frontmatter, slug)
// the file keeps its path even when the slug changes — the `slug:` field is
// what drives the url, so renaming never orphans a commit history.
const live = await provider.stat(entry.path)
if (payload.sha && live?.sha && payload.sha !== live.sha) {
throw createError({
statusCode: 409,
message: 'this post changed in the repo since you opened it — reload and reapply',
})
}
if (frontmatter.slug !== slug) {
const clash = (await getIndex()).find(
(e) => e.meta.slug === frontmatter.slug && e.path !== entry.path,
)
if (clash) {
throw createError({ statusCode: 409, message: `the slug "${frontmatter.slug}" is taken` })
}
}
const result = await provider.write({
path: entry.path,
content: serialize(frontmatter, payload.body ?? ''),
message: payload.message?.trim() || `post: update ${frontmatter.slug}`,
sha: live?.sha ?? entry.sha,
})
bustCache('posts:')
return { slug: frontmatter.slug, path: entry.path, sha: result.sha, commit: result.commit }
}
export async function deletePost(slug: string, message?: string) {
const provider = useContentProvider()
assertWritable(provider)
const entry = await findEntry(slug)
if (!entry) throw createError({ statusCode: 404, message: 'post not found' })
const live = await provider.stat(entry.path)
await provider.remove({
path: entry.path,
message: message?.trim() || `post: remove ${slug}`,
sha: live?.sha ?? entry.sha,
})
bustCache('posts:')
return { slug, path: entry.path }
}