-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
503 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" | ||
|
||
const AspectRatio = AspectRatioPrimitive.Root | ||
|
||
export { AspectRatio } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import { json, type HeadersFunction, type MetaFunction } from "@remix-run/node"; | ||
import { type PostInfo, getPostInfos } from "#app/utils/mdx/blog.server"; | ||
import { Link, useLoaderData } from "@remix-run/react"; | ||
import { AspectRatio } from "#app/components/ui/aspect-ratio"; | ||
import { CloudinaryImage } from "#app/components/image"; | ||
|
||
export const meta: MetaFunction = () => { | ||
return [ | ||
{ title: "Blog | nichtsam" }, | ||
{ name: "description", content: "nichtsam's blog" }, | ||
]; | ||
}; | ||
|
||
export const headers: HeadersFunction = () => ({ | ||
"Cache-Control": "private, max-age=3600", | ||
Vary: "Cookie", | ||
}); | ||
|
||
export const loader = async () => { | ||
const posts = await getPostInfos(); | ||
|
||
return json( | ||
{ posts }, | ||
{ | ||
headers: { | ||
"Cache-Control": "public, max-age=3600", | ||
}, | ||
}, | ||
); | ||
}; | ||
|
||
export default function Blog() { | ||
const data = useLoaderData<typeof loader>(); | ||
return ( | ||
<div className="container"> | ||
<ul className="grid gap-12 sm:grid-cols-2 lg:grid-cols-3"> | ||
{data.posts.map((post) => ( | ||
<li key={post.slug}> | ||
<PostCard post={post} /> | ||
</li> | ||
))} | ||
</ul> | ||
</div> | ||
); | ||
} | ||
|
||
function PostCard({ post }: { post: PostInfo }) { | ||
return ( | ||
<Link to={post.slug}> | ||
<div className="group"> | ||
<div className="transition-transform duration-500 group-hover:-translate-y-3 group-hover:scale-105"> | ||
<AspectRatio | ||
ratio={16 / 9} | ||
className="overflow-hidden rounded-xl bg-gradient-to-br from-muted to-muted-foreground" | ||
> | ||
<CloudinaryImage | ||
id={post.meta.matter.thumbnailId} | ||
className="h-full w-full object-cover" | ||
sizes={[ | ||
"(min-width: 1400px) 385px", | ||
"(min-width: 1024px) 33vw", | ||
"(min-width: 640px) 50vw", | ||
"100vw", | ||
]} | ||
widths={[320, 640, 960, 1280]} | ||
/> | ||
</AspectRatio> | ||
</div> | ||
|
||
<div className="mt-2 transition duration-500 group-hover:-translate-y-2"> | ||
<div className="mx-0 flex items-center justify-between transition-[margin] duration-500 group-hover:mx-7"> | ||
<p className="text-sm text-muted-foreground"> | ||
{post.meta.readingTime.text} | ||
</p> | ||
<p className="text-sm text-muted-foreground"> | ||
{post.meta.matter.publishedDate} | ||
</p> | ||
</div> | ||
|
||
<h3 className="font-bold leading-tight transition-all duration-500 group-hover:ml-7"> | ||
{post.meta.matter.title} | ||
</h3> | ||
</div> | ||
</div> | ||
</Link> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import { getPostMeta } from "#app/utils/mdx/blog.server"; | ||
import { bundleMDX } from "#app/utils/mdx/compile-mdx.server"; | ||
import { useMdxComponent } from "#app/utils/mdx/mdx"; | ||
import { getMdxBundleSource, getMdxEntry } from "#app/utils/mdx/mdx.server"; | ||
import { json } from "@remix-run/node"; | ||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; | ||
import { useLoaderData } from "@remix-run/react"; | ||
|
||
export const meta: MetaFunction<typeof loader> = ({ data }) => { | ||
if (!data) { | ||
return [ | ||
{ title: "Error | nichtsam" }, | ||
{ name: "description", content: "Some error occured" }, | ||
]; | ||
} | ||
return [ | ||
{ title: `${data.meta.matter.title} | nichtsam` }, | ||
{ name: "description", content: data.meta.matter.description }, | ||
]; | ||
}; | ||
|
||
export const loader = async ({ params }: LoaderFunctionArgs) => { | ||
if (!params.slug) { | ||
throw new Error("params.slug is not defined"); | ||
} | ||
|
||
const slug = params.slug; | ||
|
||
const mdxEntry = getMdxEntry("blog", slug); | ||
const mdxBundle = await getMdxBundleSource(mdxEntry); | ||
|
||
const meta = getPostMeta(mdxBundle.source); | ||
const { code } = await bundleMDX(mdxBundle); | ||
|
||
return json( | ||
{ | ||
code, | ||
meta, | ||
}, | ||
{ | ||
headers: { | ||
"Cache-Control": "public, max-age=3600", | ||
}, | ||
}, | ||
); | ||
}; | ||
|
||
export default function BlogPost() { | ||
const data = useLoaderData<typeof loader>(); | ||
const Component = useMdxComponent(data.code); | ||
|
||
return ( | ||
<div> | ||
<article className="container prose dark:prose-invert xl:prose-lg 2xl:prose-2xl"> | ||
<Component /> | ||
</article> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import { resolve } from "path"; | ||
import { readFile, readdir } from "fs/promises"; | ||
import { rootPath } from "../path.server"; | ||
import { getMdxEntry } from "./mdx.server"; | ||
import { getMdxMeta } from "./mdx-meta.server"; | ||
import { z } from "zod"; | ||
import type { ReadTimeResults } from "reading-time"; | ||
import dayjs from "dayjs"; | ||
|
||
export const ENTRY = "blog"; | ||
const blogDirPath = resolve(rootPath, "mdx", ENTRY); | ||
|
||
export const matterSchema = z.object({ | ||
title: z.string(), | ||
description: z.string(), | ||
publishedDate: z.date().transform((date) => dayjs(date).format("YYYY-MM-DD")), | ||
thumbnailId: z.string(), | ||
}); | ||
|
||
export type PostMeta = { | ||
readingTime: ReadTimeResults; | ||
matter: z.infer<typeof matterSchema>; | ||
}; | ||
|
||
export type PostInfo = { | ||
slug: string; | ||
meta: PostMeta; | ||
}; | ||
|
||
export function getPostMeta(file: string): PostMeta { | ||
const { matter: rawMatter, ...restMeta } = getMdxMeta(file); | ||
const matter = matterSchema.parse(rawMatter); | ||
|
||
return { | ||
...restMeta, | ||
matter, | ||
}; | ||
} | ||
|
||
export async function getPostInfos(): Promise<Array<PostInfo>> { | ||
const dir = await readdir(blogDirPath, { withFileTypes: true }); | ||
const postInfos: Array<PostInfo> = []; | ||
await Promise.all( | ||
dir.map(async (dirent) => { | ||
const slug = dirent.name.replace(/.mdx?$/, ""); | ||
|
||
try { | ||
const entry = getMdxEntry("blog", slug); | ||
const file = await readFile(entry.mdxPath, "utf-8"); | ||
const meta = getPostMeta(file); | ||
|
||
postInfos.push({ | ||
slug, | ||
meta, | ||
}); | ||
} catch (err) { | ||
if (err instanceof z.ZodError) { | ||
console.error( | ||
`Error: skipping blog post with slug '${slug}', invalid frontMatter:`, | ||
err.flatten().fieldErrors, | ||
); | ||
} else if (err instanceof Error) { | ||
console.error( | ||
`Error: skipping blog post with slug '${slug}' => `, | ||
err.message, | ||
); | ||
} else { | ||
console.error("Error: corrupted blog post, ", err); | ||
} | ||
} | ||
}), | ||
); | ||
|
||
return postInfos; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { bundleMDX as _bundleMDX } from "mdx-bundler"; | ||
import type { MdxBundleSource } from "./mdx.server"; | ||
|
||
export async function bundleMDX({ source, files }: MdxBundleSource) { | ||
const mdxBundle = await _bundleMDX({ | ||
source, | ||
files, | ||
mdxOptions(options) { | ||
options.remarkPlugins = [...(options.remarkPlugins ?? [])]; | ||
options.rehypePlugins = [...(options.rehypePlugins ?? [])]; | ||
return options; | ||
}, | ||
}); | ||
|
||
return { | ||
code: mdxBundle.code, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import getGrayMatter from "gray-matter"; | ||
import calculateReadingTime from "reading-time"; | ||
|
||
export function getMdxMeta(file: string) { | ||
const grayMatter = getGrayMatter(file); | ||
|
||
const readingTime = calculateReadingTime(grayMatter.content); | ||
|
||
return { readingTime, matter: grayMatter.data as Record<string, unknown> }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { existsSync, statSync } from "fs"; | ||
import { readFile, readdir } from "fs/promises"; | ||
import { relative, resolve } from "path"; | ||
import { rootPath, getFilePathInDirectoryByName } from "../path.server"; | ||
|
||
type Files = Record<string, string>; | ||
export type MdxBundleSource = { | ||
source: string; | ||
files?: Files; | ||
}; | ||
|
||
type Entry = { | ||
name: string; | ||
isFile: boolean; | ||
bundlePath: string; | ||
mdxPath: string; | ||
}; | ||
|
||
const mdxDirPath = resolve(rootPath, "mdx"); | ||
const VALID_EXTENSION = ["md", "mdx"]; | ||
|
||
export function getMdxEntry(category: string, name: string): Entry { | ||
const maybeDirPath = resolve(mdxDirPath, category, name); | ||
if (existsSync(maybeDirPath)) { | ||
const dirPath = maybeDirPath; | ||
return { | ||
name, | ||
isFile: false, | ||
bundlePath: dirPath, | ||
mdxPath: getFilePathInDirectoryByName(dirPath, "index", VALID_EXTENSION), | ||
}; | ||
} | ||
|
||
const filePath = getFilePathInDirectoryByName( | ||
resolve(mdxDirPath, category), | ||
name, | ||
VALID_EXTENSION, | ||
); | ||
|
||
return { | ||
name, | ||
isFile: true, | ||
bundlePath: filePath, | ||
mdxPath: filePath, | ||
}; | ||
} | ||
|
||
export async function getMdxBundleSource( | ||
entry: Entry, | ||
): Promise<MdxBundleSource> { | ||
const sourcePromise = readFile(entry.mdxPath, "utf-8"); | ||
const filesPromise = entry.isFile | ||
? undefined | ||
: getFilesInDirectory(entry.bundlePath); | ||
const [source, files] = await Promise.all([sourcePromise, filesPromise]); | ||
|
||
return { source, files }; | ||
} | ||
|
||
async function getFilesInDirectory( | ||
currentPath: string, | ||
rootPath: string = currentPath, | ||
): Promise<Files> { | ||
if (!existsSync(currentPath)) { | ||
return {}; | ||
} | ||
|
||
if (statSync(currentPath).isFile()) { | ||
const relativePath = relative(rootPath, currentPath); | ||
|
||
return { | ||
[relativePath]: await readFile(currentPath, "utf-8"), | ||
}; | ||
} else { | ||
const dir = await readdir(currentPath, { withFileTypes: true }); | ||
|
||
const fileSets = await Promise.all( | ||
dir.map((dirent) => | ||
getFilesInDirectory(resolve(dirent.path, dirent.name), rootPath), | ||
), | ||
); | ||
|
||
const files = fileSets.reduce<Files>((acc, files) => { | ||
return Object.assign(acc, files); | ||
}, {}); | ||
|
||
return files; | ||
} | ||
} |
Oops, something went wrong.