Skip to content

Commit

Permalink
blog
Browse files Browse the repository at this point in the history
  • Loading branch information
nichtsam committed Mar 23, 2024
1 parent d8a47f1 commit 363f095
Show file tree
Hide file tree
Showing 14 changed files with 504 additions and 6 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ COPY --from=build /app/public /app/public
COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/server.js /app/server.js
COPY --from=build /app/server-utils.js /app/server-utils.js
COPY ./mdx ./mdx

CMD [ "pnpm","start" ]
23 changes: 23 additions & 0 deletions app/components/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,26 @@ export function CloudinaryImage({
/>
);
}

export function BlogImage({
id,
alt,
}: {
id: CloudinaryImageProps["id"];
alt: string;
}) {
return (
<CloudinaryImage
id={id}
alt={alt}
className="w-full"
sizes={[
"(min-width: 1536px) 97.5ch",
"(min-width: 1280px) 73.125ch",
"(min-width: 655px) 65ch",
"100vw",
]}
widths={[300, 600, 900, 1200, 1500, 1800]}
/>
);
}
5 changes: 5 additions & 0 deletions app/components/ui/aspect-ratio.tsx
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 }
87 changes: 87 additions & 0 deletions app/routes/blog.tsx
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>
);
}
59 changes: 59 additions & 0 deletions app/routes/blog_.$slug.tsx
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>
);
}
75 changes: 75 additions & 0 deletions app/utils/mdx/blog.server.ts
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;
}
18 changes: 18 additions & 0 deletions app/utils/mdx/compile-mdx.server.ts
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,
};
}
10 changes: 10 additions & 0 deletions app/utils/mdx/mdx-meta.server.ts
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> };
}
89 changes: 89 additions & 0 deletions app/utils/mdx/mdx.server.ts
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;
}
}
Loading

0 comments on commit 363f095

Please sign in to comment.