Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 92 additions & 55 deletions apps/docs/app/(home)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getRelatedPosts,
postSlug,
} from '@/lib/blog';
import { jsonLdHtml } from '@/lib/json-ld';
import { appName, siteUrl } from '@/lib/shared';

import { DocsBody } from 'fumadocs-ui/layouts/docs/page';
Expand All @@ -21,63 +22,96 @@ export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
const MDX = post.data.body;
const related = getRelatedPosts(post);

return (
<main className="container mx-auto max-w-3xl px-4 py-16">
<Link
href="/blog"
className="text-fd-muted-foreground hover:text-fd-foreground mb-8 inline-block text-sm transition-colors"
>
← Back to blog
</Link>
<article>
<header className="mb-10">
<h1 className="mb-3 text-4xl font-semibold tracking-tight">
{post.data.title}
</h1>
<p className="text-fd-muted-foreground text-sm">
<span>{post.data.author}</span>
<span aria-hidden> · </span>
<time dateTime={post.data.date}>
{formatPostDate(post.data.date)}
</time>
</p>
</header>
<Prerequisites hrefs={post.data.prerequisites} />
<DocsBody>
<MDX components={getMDXComponents()} />
</DocsBody>
</article>
// Per-post structured data. The root layout emits site-wide WebSite +
// SoftwareApplication nodes; this classifies each post as an Article so a
// crawler can model author, publish date, and headline instead of guessing —
// the content-quality signal that helps Google prioritize a young blog's URLs
// for indexing (and unlocks Article rich results). `image`/`url` must resolve,
// so they mirror the OG route and canonical exactly.
const canonical = `${siteUrl}/blog/${slug}`;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.data.title,
...(post.data.description
? { description: post.data.description }
: {}),
datePublished: post.data.date,
dateModified: post.data.date,
author: { '@type': 'Person', name: post.data.author },
publisher: { '@type': 'Organization', name: appName, url: siteUrl },
image: `${siteUrl}/og/blog/${slug}`,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
url: canonical,
...(post.data.tags?.length
? { keywords: post.data.tags.join(', ') }
: {}),
inLanguage: 'en-US',
};

{related.length > 0 ? (
<aside
aria-label="Related posts"
className="border-fd-border mt-16 border-t pt-10"
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLdHtml(jsonLd) }}
/>
<main className="container mx-auto max-w-3xl px-4 py-16">
<Link
href="/blog"
className="text-fd-muted-foreground hover:text-fd-foreground mb-8 inline-block text-sm transition-colors"
>
<h2 className="mb-6 text-sm font-semibold tracking-wide uppercase">
Related reading
</h2>
<ul className="flex flex-col gap-6">
{related.map((other) => (
<li key={other.url}>
<Link
href={`/blog/${postSlug(other)}`}
className="group block"
>
<h3 className="group-hover:text-fd-primary text-lg font-medium tracking-tight transition-colors">
{other.data.title}
</h3>
{other.data.description ? (
<p className="text-fd-muted-foreground mt-1 text-sm">
{other.data.description}
</p>
) : null}
</Link>
</li>
))}
</ul>
</aside>
) : null}
</main>
← Back to blog
</Link>
<article>
<header className="mb-10">
<h1 className="mb-3 text-4xl font-semibold tracking-tight">
{post.data.title}
</h1>
<p className="text-fd-muted-foreground text-sm">
<span>{post.data.author}</span>
<span aria-hidden> · </span>
<time dateTime={post.data.date}>
{formatPostDate(post.data.date)}
</time>
</p>
</header>
<Prerequisites hrefs={post.data.prerequisites} />
<DocsBody>
<MDX components={getMDXComponents()} />
</DocsBody>
</article>

{related.length > 0 ? (
<aside
aria-label="Related posts"
className="border-fd-border mt-16 border-t pt-10"
>
<h2 className="mb-6 text-sm font-semibold tracking-wide uppercase">
Related reading
</h2>
<ul className="flex flex-col gap-6">
{related.map((other) => (
<li key={other.url}>
<Link
href={`/blog/${postSlug(other)}`}
className="group block"
>
<h3 className="group-hover:text-fd-primary text-lg font-medium tracking-tight transition-colors">
{other.data.title}
</h3>
{other.data.description ? (
<p className="text-fd-muted-foreground mt-1 text-sm">
{other.data.description}
</p>
) : null}
</Link>
</li>
))}
</ul>
</aside>
) : null}
</main>
</>
);
}

Expand All @@ -97,6 +131,7 @@ export async function generateMetadata(
if (!post) notFound();

const canonical = `${siteUrl}/blog/${slug}`;
const ogImage = `/og/blog/${slug}`;

return {
title: post.data.title,
Expand All @@ -110,11 +145,13 @@ export async function generateMetadata(
description: post.data.description,
authors: [post.data.author],
publishedTime: post.data.date,
images: ogImage,
},
twitter: {
card: 'summary_large_image',
title: post.data.title,
description: post.data.description,
images: ogImage,
},
};
}
63 changes: 63 additions & 0 deletions apps/docs/app/(home)/components/from-the-blog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Section, SectionHeading } from './primitives';

import { formatPostDate, getSortedPosts, postSlug } from '@/lib/blog';

import { ArrowRight } from 'lucide-react';
import Link from 'next/link';

// A "latest posts" band on the home page. Beyond being useful, this is
// deliberate internal linking: the home page is the most-crawled URL on the
// site, so a direct link from here to each post is the strongest crawl-priority
// signal we can give a young blog sitting in "Discovered — currently not
// indexed". Server-rendered, so every card is a static <a> in the HTML.
export function FromTheBlog() {
const posts = getSortedPosts().slice(0, 4);
if (posts.length === 0) return null;

return (
<Section id="from-the-blog" className="border-b border-fd-border">
<SectionHeading
eyebrow="From the blog"
title="Notes on stitching APIs"
lead="Field notes on API stitching, agent-native integrations, and cutting AI cost."
align="center"
/>

<ul className="mx-auto mt-12 grid max-w-4xl gap-5 sm:grid-cols-2">
{posts.map((post) => (
<li key={post.url}>
<Link
href={`/blog/${postSlug(post)}`}
className="group block h-full rounded-2xl border border-fd-border bg-fd-card p-7 transition-colors hover:bg-fd-accent"
>
<time
dateTime={post.data.date}
className="text-xs font-medium uppercase tracking-[0.14em] text-fd-muted-foreground"
>
{formatPostDate(post.data.date)}
</time>
<h3 className="mt-3 text-xl font-semibold tracking-tight text-fd-foreground group-hover:text-stitch">
{post.data.title}
</h3>
{post.data.description ? (
<p className="mt-3 text-sm leading-relaxed text-fd-muted-foreground">
{post.data.description}
</p>
) : null}
</Link>
</li>
))}
</ul>

<div className="mt-10 flex justify-center">
<Link
href="/blog"
className="group inline-flex items-center gap-2 text-sm font-semibold text-stitch hover:text-stitch-strong"
>
Read the blog
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
</Section>
);
}
2 changes: 2 additions & 0 deletions apps/docs/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Cta, Footer } from './components/cta';
import { Demo } from './components/demo';
import { Differentiator } from './components/differentiator';
import { Features } from './components/features';
import { FromTheBlog } from './components/from-the-blog';
import { Hero } from './components/hero';
import { Metrics } from './components/metrics';
import { NotThis } from './components/not-this';
Expand Down Expand Up @@ -51,6 +52,7 @@ export default function HomePage() {
<Features />
<NotThis />
<Differentiator />
<FromTheBlog />
<Cta />
<Footer />
</main>
Expand Down
44 changes: 44 additions & 0 deletions apps/docs/app/og/blog/[slug]/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { blogSource } from '@/lib/blog';
import { appName } from '@/lib/shared';

import { generate as DefaultImage } from 'fumadocs-ui/og';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';

export const revalidate = false;

// Per-post Open Graph image, mirroring the docs OG route (`/og/docs/...`). The
// blog post metadata sets `openGraph.type: 'article'` + a `summary_large_image`
// Twitter card but shipped no image, so social cards rendered blank; this gives
// every post a real 1200×630 card showing its own title. The BlogPosting JSON-LD
// points its `image` here too, so the URL must resolve.
export async function GET(
_req: Request,
{ params }: RouteContext<'/og/blog/[slug]'>,
) {
const { slug } = await params;
const post = blogSource.getPage([slug]);
if (!post) notFound();

return new ImageResponse(
(
<DefaultImage
title={post.data.title}
description={post.data.description}
site={appName}
/>
),
{
width: 1200,
height: 630,
},
);
}

export function generateStaticParams() {
return blogSource.generateParams().map((params) => ({
// Flatten fumadocs' `{ slug: string[] }` to this route's `{ slug: string }`,
// exactly as the blog post page does.
slug: params.slug?.[0] ?? '',
}));
}
32 changes: 24 additions & 8 deletions apps/docs/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,47 @@ export const revalidate = false;
const absolute = (path: string) => new URL(path, siteUrl).toString();

export default function sitemap(): MetadataRoute.Sitemap {
// Static export (`revalidate = false`), so this is the build timestamp —
// a truthful "last generated" signal for crawlers.
const lastModified = new Date();
const posts = getSortedPosts();

// Docs pages carry no per-page date, so the build timestamp is the most
// truthful `lastmod` we have for them (and for the home page). Blog URLs get
// their real content dates below — see the note there.
const buildTime = new Date();

// The newest post's date is when the blog index last meaningfully changed.
const blogIndexModified = posts[0]
? new Date(posts[0].data.date)
: buildTime;

return [
{
url: absolute('/'),
lastModified,
lastModified: buildTime,
changeFrequency: 'monthly',
priority: 1,
},
...source.getPages().map((page) => ({
url: absolute(page.url),
lastModified,
lastModified: buildTime,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
{
url: absolute(blogRoute),
lastModified,
lastModified: blogIndexModified,
changeFrequency: 'weekly' as const,
priority: 0.7,
},
...getSortedPosts().map((post) => ({
// A post's own publication date is a stable, per-URL `lastmod` that only
// moves when the content does. This is deliberate: stamping every URL
// with the build time (as this route used to) tells Google all ~100
// pages changed on every deploy, which it treats as noise and ignores —
// discarding the very signal it uses to schedule crawls. Truthful,
// stable dates are what a young domain stuck in "Discovered — currently
// not indexed" needs.
...posts.map((post) => ({
url: absolute(`${blogRoute}/${postSlug(post)}`),
lastModified,
lastModified: new Date(post.data.date),
changeFrequency: 'monthly' as const,
priority: 0.6,
})),
Expand Down
Loading