380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { desc, eq, or } from "drizzle-orm";
|
|
import matter from "gray-matter";
|
|
import { UTApi, UTFile } from "uploadthing/server";
|
|
import z from "zod";
|
|
import { isAdmin } from "~/app/actions";
|
|
import { env } from "~/env.js";
|
|
import { db } from "~/server/db";
|
|
import { blogPost } from "~/server/dbschema/schema";
|
|
import { publicProcedure, router } from "~/server/trpc";
|
|
|
|
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
|
|
|
|
const blogPostInput = z.object({
|
|
slug: z.string().min(1),
|
|
title: z.string().min(1),
|
|
date: z.string().optional(),
|
|
description: z.string().optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
content: z.string(),
|
|
});
|
|
|
|
type BlogPostInput = z.infer<typeof blogPostInput>;
|
|
|
|
type UploadThingFile = Awaited<ReturnType<typeof utapi.listFiles>>["files"][number];
|
|
|
|
function cleanPrefix(): string {
|
|
return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, "");
|
|
}
|
|
|
|
function blogFileName(slug: string): string {
|
|
const prefix = cleanPrefix();
|
|
return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`;
|
|
}
|
|
|
|
function blogCustomId(slug: string): string {
|
|
const prefix = cleanPrefix();
|
|
const id = crypto.randomUUID();
|
|
return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`;
|
|
}
|
|
|
|
function optionalText(value: string | undefined): string | null {
|
|
const trimmed = value?.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
function frontmatterText(value: unknown): string | null {
|
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
return optionalText(typeof value === "string" ? value : undefined);
|
|
}
|
|
|
|
function normalizeTags(tags: unknown): string[] {
|
|
const values = Array.isArray(tags)
|
|
? tags
|
|
: typeof tags === "string"
|
|
? tags.split(",")
|
|
: [];
|
|
|
|
return Array.from(
|
|
new Set(
|
|
values
|
|
.map((tag) => String(tag).trim())
|
|
.filter(Boolean),
|
|
),
|
|
);
|
|
}
|
|
|
|
function createMdxContent(input: BlogPostInput): string {
|
|
const date = optionalText(input.date);
|
|
const description = optionalText(input.description);
|
|
const tags = normalizeTags(input.tags);
|
|
const frontmatter: Record<string, unknown> = { slug: input.slug, title: input.title };
|
|
if (date) frontmatter.date = date;
|
|
if (description) frontmatter.description = description;
|
|
if (tags.length > 0) frontmatter.tags = tags;
|
|
return matter.stringify(input.content, frontmatter);
|
|
}
|
|
|
|
function summaryFromInput(input: BlogPostInput) {
|
|
return {
|
|
slug: input.slug,
|
|
title: input.title,
|
|
date: optionalText(input.date),
|
|
description: optionalText(input.description),
|
|
tags: normalizeTags(input.tags),
|
|
};
|
|
}
|
|
|
|
async function assertAdmin() {
|
|
const admin = await isAdmin();
|
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
|
}
|
|
|
|
async function uploadMdx(input: BlogPostInput) {
|
|
const mdxContent = createMdxContent(input);
|
|
const customId = blogCustomId(input.slug);
|
|
const file = new UTFile([mdxContent], blogFileName(input.slug), {
|
|
customId,
|
|
type: "text/plain",
|
|
});
|
|
const result = await utapi.uploadFiles(file);
|
|
|
|
if (result.error) {
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
|
}
|
|
|
|
if (!result.data.ufsUrl) {
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" });
|
|
}
|
|
|
|
return {
|
|
fileKey: result.data.key,
|
|
fileUrl: result.data.ufsUrl,
|
|
fileName: result.data.name,
|
|
customId: result.data.customId ?? customId,
|
|
};
|
|
}
|
|
|
|
async function fetchMdx(fileUrl: string): Promise<string> {
|
|
const res = await fetch(fileUrl, { next: { revalidate: 3600 } });
|
|
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
|
|
return res.text();
|
|
}
|
|
|
|
function fileUrl(file: Pick<UploadThingFile, "key">): string {
|
|
return `https://utfs.io/f/${file.key}`;
|
|
}
|
|
|
|
function slugFromFileName(name: string): string {
|
|
const prefix = cleanPrefix();
|
|
const withoutExtension = name.replace(/\.mdx?$/, "");
|
|
if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1);
|
|
if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1);
|
|
return withoutExtension;
|
|
}
|
|
|
|
function slugFromCustomId(customId: string): string {
|
|
const prefix = cleanPrefix();
|
|
const value = prefix && customId.startsWith(`${prefix}:`)
|
|
? customId.slice(prefix.length + 1)
|
|
: customId;
|
|
return value.split(":")[0] ?? value;
|
|
}
|
|
|
|
function fileMatchesPrefix(file: Pick<UploadThingFile, "name">): boolean {
|
|
if (!/\.mdx?$/.test(file.name)) return false;
|
|
|
|
const prefix = cleanPrefix();
|
|
if (!prefix) return true;
|
|
|
|
return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`);
|
|
}
|
|
|
|
function metadataFromFile(file: UploadThingFile, raw: string) {
|
|
const parsed = matter(raw);
|
|
const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name);
|
|
|
|
return {
|
|
slug: String(parsed.data.slug ?? fallbackSlug),
|
|
title: String(parsed.data.title ?? fallbackSlug),
|
|
date: frontmatterText(parsed.data.date),
|
|
description: frontmatterText(parsed.data.description),
|
|
tags: normalizeTags(parsed.data.tags),
|
|
fileKey: file.key,
|
|
fileUrl: fileUrl(file),
|
|
fileName: file.name,
|
|
customId: file.customId ?? blogCustomId(fallbackSlug),
|
|
};
|
|
}
|
|
|
|
async function listAllFiles(): Promise<readonly UploadThingFile[]> {
|
|
const files: UploadThingFile[] = [];
|
|
let offset = 0;
|
|
const limit = 500;
|
|
let hasMore = true;
|
|
|
|
while (hasMore) {
|
|
const page = await utapi.listFiles({ limit, offset });
|
|
files.push(...page.files);
|
|
hasMore = page.hasMore;
|
|
offset += limit;
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
export const blogRouter = router({
|
|
insert: publicProcedure
|
|
.input(blogPostInput)
|
|
.mutation(async ({ input }) => {
|
|
await assertAdmin();
|
|
|
|
const existing = await db.query.blogPost.findFirst({
|
|
where(fields, operators) {
|
|
return operators.eq(fields.slug, input.slug);
|
|
},
|
|
});
|
|
if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
|
|
|
const uploaded = await uploadMdx(input);
|
|
|
|
try {
|
|
await db.insert(blogPost).values({
|
|
slug: input.slug,
|
|
title: input.title,
|
|
date: optionalText(input.date),
|
|
description: optionalText(input.description),
|
|
tags: normalizeTags(input.tags),
|
|
...uploaded,
|
|
});
|
|
return [summaryFromInput(input)];
|
|
} catch (error) {
|
|
await utapi.deleteFiles(uploaded.fileKey);
|
|
throw error;
|
|
}
|
|
}),
|
|
|
|
update: publicProcedure
|
|
.input(blogPostInput.extend({ originalSlug: z.string().min(1) }))
|
|
.mutation(async ({ input }) => {
|
|
await assertAdmin();
|
|
|
|
const existing = await db.query.blogPost.findFirst({
|
|
where(fields, operators) {
|
|
return operators.eq(fields.slug, input.originalSlug);
|
|
},
|
|
});
|
|
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` });
|
|
|
|
if (input.slug !== input.originalSlug) {
|
|
const slugConflict = await db.query.blogPost.findFirst({
|
|
where(fields, operators) {
|
|
return operators.eq(fields.slug, input.slug);
|
|
},
|
|
});
|
|
if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
|
}
|
|
|
|
const uploaded = await uploadMdx(input);
|
|
|
|
try {
|
|
await db.update(blogPost).set({
|
|
slug: input.slug,
|
|
title: input.title,
|
|
date: optionalText(input.date),
|
|
description: optionalText(input.description),
|
|
tags: normalizeTags(input.tags),
|
|
...uploaded,
|
|
}).where(eq(blogPost.id, existing.id));
|
|
|
|
await utapi.deleteFiles(existing.fileKey);
|
|
return [summaryFromInput(input)];
|
|
} catch (error) {
|
|
await utapi.deleteFiles(uploaded.fileKey);
|
|
throw error;
|
|
}
|
|
}),
|
|
|
|
delete: publicProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ input }) => {
|
|
await assertAdmin();
|
|
|
|
const post = await db.query.blogPost.findFirst({
|
|
where(fields, operators) {
|
|
return operators.eq(fields.slug, input.id);
|
|
},
|
|
});
|
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
|
|
|
|
await db.delete(blogPost).where(eq(blogPost.id, post.id));
|
|
await utapi.deleteFiles(post.fileKey);
|
|
return [];
|
|
}),
|
|
|
|
list: publicProcedure.query(async () => {
|
|
return db.select({
|
|
slug: blogPost.slug,
|
|
title: blogPost.title,
|
|
date: blogPost.date,
|
|
description: blogPost.description,
|
|
tags: blogPost.tags,
|
|
}).from(blogPost).orderBy(desc(blogPost.date), desc(blogPost.createdAt));
|
|
}),
|
|
|
|
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
|
const post = await db.query.blogPost.findFirst({
|
|
where(fields, operators) {
|
|
return operators.eq(fields.slug, slug);
|
|
},
|
|
});
|
|
|
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
|
|
|
const raw = await fetchMdx(post.fileUrl);
|
|
const { content, data } = matter(raw);
|
|
|
|
return {
|
|
slug: post.slug,
|
|
content,
|
|
title: (data.title as string | undefined) ?? post.title,
|
|
date: frontmatterText(data.date) ?? post.date,
|
|
description: frontmatterText(data.description) ?? post.description,
|
|
tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []),
|
|
};
|
|
}),
|
|
|
|
metadataBySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
|
const post = await db.query.blogPost.findFirst({
|
|
where(fields, operators) {
|
|
return operators.eq(fields.slug, slug);
|
|
},
|
|
});
|
|
|
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
|
|
|
return {
|
|
slug: post.slug,
|
|
title: post.title,
|
|
date: post.date,
|
|
description: post.description,
|
|
tags: post.tags ?? [],
|
|
fileUrl: post.fileUrl,
|
|
};
|
|
}),
|
|
|
|
syncFromUploadThing: publicProcedure.mutation(async () => {
|
|
await assertAdmin();
|
|
|
|
const files = (await listAllFiles()).filter(fileMatchesPrefix);
|
|
const seenFileKeys = new Set<string>();
|
|
const seenSlugs = new Set<string>();
|
|
let created = 0;
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
let deleted = 0;
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const raw = await fetchMdx(fileUrl(file));
|
|
const metadata = metadataFromFile(file, raw);
|
|
seenFileKeys.add(file.key);
|
|
seenSlugs.add(metadata.slug);
|
|
const existing = await db.query.blogPost.findFirst({
|
|
where(fields, operators) {
|
|
return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug));
|
|
},
|
|
});
|
|
|
|
if (existing) {
|
|
await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id));
|
|
updated += 1;
|
|
} else {
|
|
await db.insert(blogPost).values(metadata);
|
|
created += 1;
|
|
}
|
|
} catch {
|
|
skipped += 1;
|
|
}
|
|
}
|
|
|
|
const posts = await db.select({
|
|
id: blogPost.id,
|
|
fileKey: blogPost.fileKey,
|
|
slug: blogPost.slug,
|
|
}).from(blogPost);
|
|
|
|
const stalePostIds = posts
|
|
.filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug))
|
|
.map((post) => post.id);
|
|
|
|
for (const id of stalePostIds) {
|
|
await db.delete(blogPost).where(eq(blogPost.id, id));
|
|
deleted += 1;
|
|
}
|
|
|
|
return { created, updated, skipped, deleted };
|
|
}),
|
|
});
|