Skip to content

Commit f2d337f

Browse files
feat(www): add Fumadocs blog section (#1781)
## Summary - Add blogPosts Fumadocs MDX collection with Zod/pageSchema frontmatter (author, date, optional draft) - Blog listing + post routes under (home) with aurora chrome, InlineTOC, OG metadata - RSS feed at /blog/rss.xml via the rss package - New app/sitemap.ts covering home, blog, roadmap, posts, and docs - Draft posts filtered: visible only in next dev; omitted from production listing/RSS/sitemap - Nav + footer Blog links; MDX img mapped to Next.js Image - Seed post: content/blog/hello-arkenv.mdx ## Test plan - [ ] typecheck www - [ ] run www dev and visit /blog, /blog/hello-arkenv, /blog/rss.xml, /sitemap.xml - [ ] Confirm Blog appears in site nav and footer Resources - [ ] Confirm draft posts stay out of production listing/RSS/sitemap --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 948591b commit f2d337f

15 files changed

Lines changed: 556 additions & 2 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import "../blog.css";
2+
import { InlineTOC } from "fumadocs-ui/components/inline-toc";
3+
import type { Metadata } from "next";
4+
import Link from "next/link";
5+
import { notFound } from "next/navigation";
6+
import { SiteFooter } from "~/components/site-footer";
7+
import { blog, getBlogPages, SITE_URL } from "~/lib/source";
8+
import { getMDXComponents } from "~/mdx-components";
9+
10+
function formatDate(date: string | Date): string {
11+
const value = typeof date === "string" ? new Date(date) : date;
12+
return value.toLocaleDateString("en-US", {
13+
year: "numeric",
14+
month: "long",
15+
day: "numeric",
16+
timeZone: "UTC",
17+
});
18+
}
19+
20+
export default async function BlogPostPage(props: {
21+
params: Promise<{ slug: string }>;
22+
}) {
23+
const params = await props.params;
24+
const page = blog.getPage([params.slug]);
25+
if (!page) notFound();
26+
if (page.data.draft && process.env.NODE_ENV !== "development") notFound();
27+
28+
const MDX = page.data.body;
29+
30+
return (
31+
<div className="home-aurora__shell">
32+
<div className="home-aurora__rails" aria-hidden="true" />
33+
<article className="blog-page">
34+
<div className="blog-page__post">
35+
<Link
36+
href="/blog"
37+
className="blog-page__back"
38+
data-no-underline
39+
data-no-arrow
40+
>
41+
← Blog
42+
</Link>
43+
<header className="blog-page__header">
44+
<h1 className="blog-page__title">{page.data.title}</h1>
45+
{page.data.description ? (
46+
<p className="blog-page__lede">{page.data.description}</p>
47+
) : null}
48+
<p className="blog-page__post-meta">
49+
{formatDate(page.data.date)} · {page.data.author}
50+
</p>
51+
</header>
52+
<div className="blog-page__post-body prose">
53+
<InlineTOC items={page.data.toc} />
54+
<MDX components={getMDXComponents({})} />
55+
</div>
56+
</div>
57+
</article>
58+
<SiteFooter reveal />
59+
</div>
60+
);
61+
}
62+
63+
export function generateStaticParams() {
64+
return getBlogPages().map((page) => ({
65+
slug: page.slugs[0],
66+
}));
67+
}
68+
69+
export async function generateMetadata(props: {
70+
params: Promise<{ slug: string }>;
71+
}): Promise<Metadata> {
72+
const params = await props.params;
73+
const page = blog.getPage([params.slug]);
74+
if (!page) notFound();
75+
if (page.data.draft && process.env.NODE_ENV !== "development") notFound();
76+
77+
const ogUrl = new URL(`${SITE_URL}/api/og`);
78+
ogUrl.searchParams.set("title", page.data.title);
79+
if (page.data.description) {
80+
ogUrl.searchParams.set("description", page.data.description);
81+
}
82+
83+
return {
84+
title: `${page.data.title} | ArkEnv`,
85+
description: page.data.description,
86+
openGraph: {
87+
title: `${page.data.title} | ArkEnv`,
88+
description: page.data.description,
89+
images: [
90+
{
91+
url: ogUrl.toString(),
92+
width: 1200,
93+
height: 630,
94+
alt: page.data.title,
95+
},
96+
],
97+
},
98+
twitter: {
99+
card: "summary_large_image",
100+
title: `${page.data.title} | ArkEnv`,
101+
description: page.data.description,
102+
images: [ogUrl.toString()],
103+
},
104+
};
105+
}

apps/www/app/(home)/blog/blog.css

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/* Blog — home chrome; lean list / post reading layout */
2+
3+
.home-aurora__shell:has(> .blog-page) {
4+
padding-top: 0;
5+
}
6+
7+
.blog-page {
8+
--fd-page-pad-top: 1.5rem;
9+
display: flex;
10+
flex-direction: column;
11+
gap: var(--space-sm);
12+
padding-block: var(--fd-page-pad-top) var(--space-3xl);
13+
padding-inline: var(--home-content-inset);
14+
width: 100%;
15+
max-width: none;
16+
margin-inline: 0;
17+
box-sizing: border-box;
18+
-webkit-font-smoothing: antialiased;
19+
-moz-osx-font-smoothing: grayscale;
20+
text-rendering: optimizeLegibility;
21+
font-feature-settings:
22+
"calt" 0,
23+
"rlig",
24+
"ss11";
25+
}
26+
27+
@media (min-width: 48rem) {
28+
.blog-page {
29+
--fd-page-pad-top: 2.5rem;
30+
}
31+
}
32+
33+
.blog-page__header,
34+
.blog-page__list,
35+
.blog-page__post {
36+
max-width: 48rem;
37+
width: 100%;
38+
}
39+
40+
.blog-page__header {
41+
display: flex;
42+
flex-direction: column;
43+
gap: var(--space-sm);
44+
}
45+
46+
.blog-page h1.blog-page__title {
47+
margin: 0;
48+
font-family: var(--font-display);
49+
font-size: 2.5rem;
50+
font-weight: 450;
51+
line-height: 3rem;
52+
letter-spacing: -0.05em;
53+
overflow-wrap: normal;
54+
color: var(--color-ink);
55+
}
56+
57+
.blog-page__lede {
58+
margin: 0;
59+
color: var(--color-ink-2);
60+
font-size: 1.0625rem;
61+
line-height: 1.6;
62+
}
63+
64+
.blog-page__rss {
65+
font-size: 0.875rem;
66+
color: var(--color-ink-2);
67+
text-decoration: none;
68+
border-bottom: var(--rule-hair) solid var(--color-rule);
69+
width: fit-content;
70+
transition: color var(--dur-short) var(--ease-out);
71+
}
72+
73+
.blog-page__rss:hover {
74+
color: var(--color-ink);
75+
}
76+
77+
.blog-page__list {
78+
list-style: none;
79+
margin: var(--space-lg) 0 0;
80+
padding: 0;
81+
display: flex;
82+
flex-direction: column;
83+
gap: 0;
84+
}
85+
86+
.blog-page__item {
87+
padding-block: var(--space-md);
88+
border-top: var(--rule-hair) solid var(--color-rule);
89+
}
90+
91+
.blog-page__item:last-child {
92+
border-bottom: var(--rule-hair) solid var(--color-rule);
93+
}
94+
95+
.blog-page__item-link {
96+
display: flex;
97+
flex-direction: column;
98+
gap: 0.35rem;
99+
text-decoration: none;
100+
color: inherit;
101+
}
102+
103+
.blog-page__item-link:hover .blog-page__item-title {
104+
color: var(--color-ink);
105+
}
106+
107+
.blog-page__item-meta {
108+
font-size: 0.8125rem;
109+
color: var(--color-ink-3);
110+
font-variant-numeric: tabular-nums;
111+
}
112+
113+
.blog-page__item-title {
114+
font-family: var(--font-display);
115+
font-size: 1.25rem;
116+
font-weight: 450;
117+
letter-spacing: -0.03em;
118+
color: var(--color-ink);
119+
line-height: 1.35;
120+
}
121+
122+
.blog-page__item-desc {
123+
margin: 0;
124+
font-size: 0.9375rem;
125+
line-height: 1.5;
126+
color: var(--color-ink-2);
127+
}
128+
129+
.blog-page__back {
130+
font-size: 0.875rem;
131+
color: var(--color-ink-2);
132+
text-decoration: none;
133+
width: fit-content;
134+
margin-bottom: var(--space-sm);
135+
}
136+
137+
.blog-page__back:hover {
138+
color: var(--color-ink);
139+
}
140+
141+
.blog-page__post-meta {
142+
margin: 0;
143+
font-size: 0.875rem;
144+
color: var(--color-ink-3);
145+
}
146+
147+
.blog-page__post-body {
148+
margin-top: var(--space-lg);
149+
}

apps/www/app/(home)/blog/page.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import "./blog.css";
2+
import type { Metadata } from "next";
3+
import Link from "next/link";
4+
import { SiteFooter } from "~/components/site-footer";
5+
import { getBlogPages } from "~/lib/source";
6+
7+
export const metadata: Metadata = {
8+
title: "Blog | ArkEnv",
9+
description:
10+
"Release notes, typed environment variable deep dives, and agent-friendly CLI guides from ArkEnv.",
11+
};
12+
13+
function formatDate(date: string | Date): string {
14+
const value = typeof date === "string" ? new Date(date) : date;
15+
return value.toLocaleDateString("en-US", {
16+
year: "numeric",
17+
month: "short",
18+
day: "numeric",
19+
timeZone: "UTC",
20+
});
21+
}
22+
23+
function toTime(date: string | Date): number {
24+
return (typeof date === "string" ? new Date(date) : date).getTime();
25+
}
26+
27+
export default function BlogIndexPage() {
28+
const pages = [...getBlogPages()].sort(
29+
(a, b) => toTime(b.data.date) - toTime(a.data.date),
30+
);
31+
32+
return (
33+
<div className="home-aurora__shell">
34+
<div className="home-aurora__rails" aria-hidden="true" />
35+
<article className="blog-page">
36+
<header className="blog-page__header">
37+
<h1 className="blog-page__title">Blog</h1>
38+
<p className="blog-page__lede">
39+
Release notes, typed env deep dives, and agent-friendly CLI guides.
40+
</p>
41+
<a className="blog-page__rss" href="/blog/rss.xml">
42+
RSS feed
43+
</a>
44+
</header>
45+
46+
<ul className="blog-page__list">
47+
{pages.map((page) => (
48+
<li key={page.url} className="blog-page__item">
49+
<Link
50+
href={page.url}
51+
className="blog-page__item-link"
52+
data-no-underline
53+
data-no-arrow
54+
>
55+
<span className="blog-page__item-meta">
56+
{formatDate(page.data.date)} · {page.data.author}
57+
</span>
58+
<span className="blog-page__item-title">{page.data.title}</span>
59+
{page.data.description ? (
60+
<p className="blog-page__item-desc">
61+
{page.data.description}
62+
</p>
63+
) : null}
64+
</Link>
65+
</li>
66+
))}
67+
</ul>
68+
</article>
69+
<SiteFooter reveal />
70+
</div>
71+
);
72+
}

apps/www/app/blog/rss.xml/route.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import RSS from "rss";
2+
import { getBlogPages, SITE_URL } from "~/lib/source";
3+
4+
export const revalidate = false;
5+
6+
export function GET() {
7+
const feed = new RSS({
8+
title: "ArkEnv Blog",
9+
description:
10+
"Release notes, typed environment variable deep dives, and agent-friendly CLI guides.",
11+
feed_url: `${SITE_URL}/blog/rss.xml`,
12+
site_url: SITE_URL,
13+
language: "en",
14+
});
15+
16+
for (const page of getBlogPages()) {
17+
feed.item({
18+
title: page.data.title,
19+
description: page.data.description ?? "",
20+
url: `${SITE_URL}${page.url}`,
21+
date: page.data.date,
22+
author: page.data.author,
23+
});
24+
}
25+
26+
return new Response(feed.xml({ indent: true }), {
27+
headers: {
28+
"Content-Type": "application/rss+xml; charset=utf-8",
29+
},
30+
});
31+
}

apps/www/app/sitemap.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { MetadataRoute } from "next";
2+
import { getBlogPages, SITE_URL, source } from "~/lib/source";
3+
4+
function toDate(value: string | Date | undefined): Date | undefined {
5+
if (!value) return undefined;
6+
return typeof value === "string" ? new Date(value) : value;
7+
}
8+
9+
export default function sitemap(): MetadataRoute.Sitemap {
10+
// Omit lastModified on static routes: a build-time `new Date()` churns
11+
// crawlers on every deploy and mislabels the runtime-revalidated roadmap.
12+
const staticRoutes: MetadataRoute.Sitemap = [
13+
{ url: SITE_URL },
14+
{ url: `${SITE_URL}/blog` },
15+
{ url: `${SITE_URL}/roadmap` },
16+
];
17+
18+
const blogRoutes: MetadataRoute.Sitemap = getBlogPages().map((page) => {
19+
const lastModified = toDate(page.data.date);
20+
return {
21+
url: `${SITE_URL}${page.url}`,
22+
...(lastModified ? { lastModified } : {}),
23+
};
24+
});
25+
26+
const docsRoutes: MetadataRoute.Sitemap = source.getPages().map((page) => ({
27+
url: `${SITE_URL}${page.url}`,
28+
}));
29+
30+
return [...staticRoutes, ...blogRoutes, ...docsRoutes];
31+
}

0 commit comments

Comments
 (0)