Skip to content

Commit 6e2230b

Browse files
authored
feat: add Markdown versions of public pages (#12)
* feat: add markdown versions of public pages * Address PR review feedback * Address PR review feedback
1 parent efcacb9 commit 6e2230b

15 files changed

Lines changed: 461 additions & 75 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ All notable changes to this project are documented here. The format is based on
77

88
### Added
99

10+
- **Markdown page mirrors**: public pages now expose `text/markdown` versions
11+
at `/.md`, `/index.md`, `/blog.md`, and `/blog/<slug>.md`. Blog posts are
12+
converted directly from Payload Lexical content, and HTML pages advertise
13+
their Markdown alternate for discovery.
1014
- **Blog reading sidebar** — a sticky table of contents that scroll-spies the
1115
current section as you read, with smooth-scroll anchor links to every heading.
1216
Hierarchy is conveyed through indentation and dimming: `h2` headings are

‎app/(frontend)/blog/[slug]/page.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
3434
return {
3535
title: `${post.meta?.title || post.title} | Khushal Bhardwaj`,
3636
description: post.meta?.description || post.excerpt,
37+
alternates: {
38+
types: {
39+
"text/markdown": `/blog/${slug}.md`,
40+
},
41+
},
3742
};
3843
}
3944

‎app/(frontend)/blog/page.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ export const dynamic = "force-dynamic";
1010
export const metadata: Metadata = {
1111
title: "Blog | Khushal Bhardwaj",
1212
description: "Blog posts by Khushal Bhardwaj",
13+
alternates: {
14+
types: {
15+
"text/markdown": "/blog.md",
16+
},
17+
},
1318
};
1419

1520
export default async function BlogPage() {

‎app/(frontend)/layout.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { Analytics } from "@vercel/analytics/next";
55
import { SpeedInsights } from "@vercel/speed-insights/next";
66
import { GeistSans } from "geist/font/sans";
77
import { GeistPixelSquare } from "geist/font/pixel";
8+
import { SITE_URL } from "@/lib/site-content";
89

910
export const metadata: Metadata = {
11+
metadataBase: new URL(SITE_URL),
1012
title: "Khushal Bhardwaj",
1113
description: "Full-stack Software Engineer",
1214
icons: {
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { SerializedEditorState } from "lexical";
2+
import { getPayload, type Payload, type Where } from "payload";
3+
import {
4+
convertLexicalToMarkdown,
5+
editorConfigFactory,
6+
} from "@payloadcms/richtext-lexical";
7+
import config from "@payload-config";
8+
import {
9+
markdownResponse,
10+
renderBlogIndexMarkdown,
11+
renderBlogPostMarkdown,
12+
renderHomeMarkdown,
13+
} from "@/lib/markdown-pages";
14+
import { SITE_URL } from "@/lib/site-content";
15+
16+
export const dynamic = "force-dynamic";
17+
18+
const POSTS_WITH_SLUG = {
19+
and: [
20+
{ slug: { exists: true } },
21+
{ slug: { not_equals: "" } },
22+
],
23+
} satisfies Where;
24+
25+
type RouteContext = {
26+
params: Promise<{ path?: string[] }>;
27+
};
28+
29+
function isSerializedEditorState(value: unknown): value is SerializedEditorState {
30+
if (!value || typeof value !== "object" || !("root" in value)) return false;
31+
32+
const root = value.root;
33+
return Boolean(
34+
root &&
35+
typeof root === "object" &&
36+
"type" in root &&
37+
root.type === "root" &&
38+
"children" in root &&
39+
Array.isArray(root.children),
40+
);
41+
}
42+
43+
function getPostEditorConfig(payload: Payload) {
44+
const postsCollection = payload.config.collections.find(
45+
(collection) => collection.slug === "posts",
46+
);
47+
const contentField = postsCollection?.fields.find(
48+
(field) => "name" in field && field.name === "content",
49+
);
50+
51+
if (!contentField || contentField.type !== "richText") {
52+
throw new Error("The posts.content rich text field is not configured.");
53+
}
54+
55+
return editorConfigFactory.fromField({ field: contentField });
56+
}
57+
58+
function notFoundResponse() {
59+
return markdownResponse(
60+
"# 404\n\nThe requested page does not exist.",
61+
"404.md",
62+
404,
63+
);
64+
}
65+
66+
export async function GET(_request: Request, { params }: RouteContext) {
67+
const { path = [] } = await params;
68+
const origin = SITE_URL;
69+
const isHome = path.length === 0 || (path.length === 1 && path[0] === "index");
70+
const isBlogIndex = path.length === 1 && path[0] === "blog";
71+
const isBlogPost = path.length === 2 && path[0] === "blog";
72+
73+
if (!isHome && !isBlogIndex && !isBlogPost) return notFoundResponse();
74+
75+
try {
76+
const payload = await getPayload({ config });
77+
78+
if (isHome) {
79+
const { docs: posts } = await payload.find({
80+
collection: "posts",
81+
where: POSTS_WITH_SLUG,
82+
sort: "-publishedAt",
83+
limit: 5,
84+
});
85+
86+
return markdownResponse(
87+
renderHomeMarkdown(posts, origin),
88+
"index.md",
89+
);
90+
}
91+
92+
if (isBlogIndex) {
93+
const { docs: posts } = await payload.find({
94+
collection: "posts",
95+
where: POSTS_WITH_SLUG,
96+
sort: "-publishedAt",
97+
pagination: false,
98+
depth: 1,
99+
});
100+
101+
return markdownResponse(
102+
renderBlogIndexMarkdown(posts, origin),
103+
"blog.md",
104+
);
105+
}
106+
107+
const slug = path[1];
108+
const {
109+
docs: [post],
110+
} = await payload.find({
111+
collection: "posts",
112+
where: { slug: { equals: slug } },
113+
limit: 1,
114+
depth: 2,
115+
});
116+
117+
if (!post) return notFoundResponse();
118+
119+
if (!isSerializedEditorState(post.content)) {
120+
throw new Error(`Post "${post.slug}" has invalid rich text content.`);
121+
}
122+
123+
const body = convertLexicalToMarkdown({
124+
data: post.content,
125+
editorConfig: getPostEditorConfig(payload),
126+
});
127+
128+
return markdownResponse(
129+
renderBlogPostMarkdown(post, body, origin),
130+
`${post.slug}.md`,
131+
);
132+
} catch (error) {
133+
console.error("Failed to render Markdown page", error);
134+
return markdownResponse(
135+
"# 500\n\nThe Markdown version of this page is temporarily unavailable.",
136+
"500.md",
137+
500,
138+
);
139+
}
140+
}

‎app/(frontend)/page.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export const metadata: Metadata = {
1515
title: "Khushal Bhardwaj | Full Stack Web Developer",
1616
description:
1717
"Portfolio of Khushal Bhardwaj, a Full Stack Web Developer based in Jaipur, India, building web apps with React and Next.js.",
18+
alternates: {
19+
types: {
20+
"text/markdown": "/.md",
21+
},
22+
},
1823
};
1924

2025
export default function Home() {

‎components/about.tsx‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1+
import { about, profile } from "@/lib/site-content";
2+
13
export function About() {
24
return (
35
<section className="space-y-5">
46
<h2 className="text-lg font-medium tracking-tight font-pixel">About</h2>
57
<div className="space-y-4 text-sm text-neutral-400 leading-relaxed">
68
<p>
7-
Hey, I&apos;m{" "}
8-
<span className="text-white font-medium">Khushal Bhardwaj</span>, a
9-
Full Stack Web Developer based in Jaipur, India. Passionate about
10-
building web apps with React/NextJS. I&apos;m also an undergrad at VIT
11-
Bhopal University.
12-
</p>
13-
<p>
14-
Other than programming, I write blogs. I&apos;ve developed a hobby of
15-
writing blogs, mostly technical. Although I may not be following it
16-
that regularly, you might think, but I do love it.
9+
{about.beforeName}
10+
<span className="text-white font-medium">{profile.name}</span>
11+
{about.afterName}
1712
</p>
13+
<p>{about.writing}</p>
1814
</div>
1915
</section>
2016
);

‎components/footer.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import Link from "next/link";
2+
import { profile, writingLinks } from "@/lib/site-content";
23

34
const footerLinks = [
4-
{ name: "GitHub", url: "https://github.com/celeroncoder/celeroncoder.tech" },
5-
{ name: "Dev.to", url: "https://dev.to/celeron" },
6-
{ name: "Hashnode", url: "https://hashnode.com/@celeroncoder" },
7-
{ name: "Medium", url: "https://medium.com/@celeroncoder" },
5+
{
6+
name: "GitHub",
7+
url: "https://github.com/celeroncoder/celeroncoder.tech",
8+
},
9+
...writingLinks,
810
];
911

1012
export function Footer() {
1113
return (
1214
<footer className="border-t border-neutral-800 pt-8 mt-4">
1315
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
1416
<p className="text-neutral-500 text-xs" suppressHydrationWarning>
15-
&copy; {new Date().getFullYear()} Khushal Bhardwaj. Built with
17+
&copy; {new Date().getFullYear()} {profile.name}. Built with
1618
Next.js.
1719
</p>
1820
<div className="flex gap-4">

‎components/greeting.tsx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import Image from "next/image";
2+
import { profile } from "@/lib/site-content";
23

34
export function Greeting() {
45
return (
56
<section className="space-y-6">
67
<div className="size-20 rounded-full overflow-hidden bg-neutral-800 ring-2 ring-neutral-700">
78
<Image
89
src="https://github.com/celeroncoder.png"
9-
alt="Khushal Bhardwaj"
10+
alt={profile.name}
1011
width={80}
1112
height={80}
1213
className="object-cover size-full"
@@ -15,13 +16,11 @@ export function Greeting() {
1516

1617
<div className="space-y-4 max-w-md">
1718
<h1 className="text-2xl font-medium tracking-tight font-pixel">
18-
Hey, I&apos;m Khushal Bhardwaj.
19+
Hey, I&apos;m {profile.name}.
1920
</h1>
2021

2122
<p className="text-neutral-400 text-sm leading-relaxed">
22-
Full Stack Web Developer based in Jaipur, India. Passionate about
23-
building web apps with React and Next.js. Currently an undergrad at
24-
VIT Bhopal University.
23+
{profile.introduction}
2524
</p>
2625
</div>
2726
</section>

‎components/links.tsx‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import Link from "next/link";
2-
3-
const socialLinks = [
4-
{ name: "GitHub", url: "https://github.com/celeroncoder" },
5-
{ name: "Twitter", url: "https://twitter.com/celeroncoder" },
6-
{ name: "LinkedIn", url: "https://linkedin.com/in/celeroncoder" },
7-
];
2+
import { socialLinks } from "@/lib/site-content";
83

94
export function Links() {
105
return (

0 commit comments

Comments
 (0)