This commit is contained in:
2026-04-23 11:08:45 +02:00
parent c527391259
commit daab745c13
13 changed files with 504 additions and 25 deletions

142
src/server/routers/blog.ts Normal file
View File

@@ -0,0 +1,142 @@
import { TRPCError } from "@trpc/server";
import matter from "gray-matter";
import { UTApi } from "uploadthing/server";
import z from "zod";
import { env } from "~/env.js";
import { publicProcedure, router } from "~/server/trpc";
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
function fileToSlug(name: string, folder: string): string {
const prefix = folder.endsWith("/") ? folder : `${folder}/`;
const withoutFolder = name.startsWith(prefix) ? name.slice(prefix.length) : name;
return withoutFolder.replace(/\.mdx?$/, "");
}
function fileUrl(key: string): string {
return `https://utfs.io/f/${key}`;
}
async function fetchMdx(key: string): Promise<string> {
const res = await fetch(fileUrl(key), { next: { revalidate: 3600 } });
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
return res.text();
}
async function getBlogFiles() {
const folder = env.BLOG_MDX_FOLDER;
const { files } = await utapi.listFiles();
return files.filter(
(f) => f.name.startsWith(`${folder}/`) && /\.mdx?$/.test(f.name),
);
}
export const blogRouter = router({
insert: publicProcedure
.input(
z.object({
slug: z.string().min(1),
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
content: z.string(),
}),
)
.mutation(async ({ input }) => {
const folder = env.BLOG_MDX_FOLDER;
const frontmatter: Record<string, unknown> = { title: input.title };
if (input.date) frontmatter.date = input.date;
if (input.description) frontmatter.description = input.description;
const mdxContent = matter.stringify(input.content, frontmatter);
const file = new File([mdxContent], `${folder}/${input.slug}.mdx`, { type: "text/plain" });
const result = await utapi.uploadFiles(file);
if (result.error) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
return [{ slug: input.slug, title: input.title, date: input.date, description: input.description }];
}),
update: publicProcedure
.input(
z.object({
slug: z.string().min(1),
originalSlug: z.string().min(1),
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
content: z.string(),
}),
)
.mutation(async ({ input }) => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const old = files.find(
(f) => f.name === `${folder}/${input.originalSlug}.mdx` || f.name === `${folder}/${input.originalSlug}.md`,
);
if (old) await utapi.deleteFiles(old.key);
const frontmatter: Record<string, unknown> = { title: input.title };
if (input.date) frontmatter.date = input.date;
if (input.description) frontmatter.description = input.description;
const mdxContent = matter.stringify(input.content, frontmatter);
const file = new File([mdxContent], `${folder}/${input.slug}.mdx`, { type: "text/plain" });
const result = await utapi.uploadFiles(file);
if (result.error) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
return [{ slug: input.slug, title: input.title, date: input.date, description: input.description, content: input.content }];
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const file = files.find(
(f) => f.name === `${folder}/${input.id}.mdx` || f.name === `${folder}/${input.id}.md`,
);
if (!file) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
await utapi.deleteFiles(file.key);
return [];
}),
list: publicProcedure.query(async () => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const posts = await Promise.all(
files.map(async (file) => {
const raw = await fetchMdx(file.key);
const { data } = matter(raw);
return {
slug: fileToSlug(file.name, folder),
title: (data.title as string | undefined) ?? fileToSlug(file.name, folder),
date: data.date as string | undefined,
description: data.description as string | undefined,
};
}),
);
return posts.sort((a, b) => {
if (!a.date || !b.date) return 0;
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
}),
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const file = files.find(
(f) => f.name === `${folder}/${slug}.mdx` || f.name === `${folder}/${slug}.md`,
);
if (!file) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
const raw = await fetchMdx(file.key);
const { content, data } = matter(raw);
return {
slug,
content,
title: (data.title as string | undefined) ?? slug,
date: data.date as string | undefined,
description: data.description as string | undefined,
};
}),
});