Skip to content

Commit a506a45

Browse files
saiyam1814claude
andcommitted
Self-host blog with 178 posts migrated from Hashnode
Replaces the Hashnode-hosted blog with a fully self-hosted Next.js static export. Hashnode shut down free API access; this migration keeps content under our control and preserves every existing backlink. Content - 178 posts imported from saiyam1814/hashnode-backup with frontmatter normalized (title, slug, date, tags, author, cover) - 1469 post images downloaded into public/img/blog/<slug>/ (no CDN deps) - 61 unique authors resolved from Hashnode dashboard paste, with 60 avatars and 37 bios scraped from Wayback Machine archives - content/authors.json as the multi-author registry Routes - /blog (Latest 3, Series, Topic hubs, Browse by tag, paginated archive) - /blog/<slug> (post with TOC, share rail, reading progress, comments) - /blog/page/[2..12] (Docker-blog-style 3-col grid, 15 posts per page) - /blog/tag/[tag], /blog/author/[handle], /blog/authors - /blog/hub/[slug] (6 curated topic hubs: kubernetes, docker, ai-ml, devops, security, linux) - /blog/series, /blog/series/[slug] (7 Days of Docker shipped, 7 Days of DGX Spark placeholder) - /blog/write (PR-based contributor guide) Backlink preservation - vercel.json: 179 explicit 301s mapping kubesimplify.com/<old-slug> to /blog/<slug>, plus host-based rewrites for blog.kubesimplify.com - public/_redirects: 381-line Cloudflare Pages config that serves clean /<slug> URLs on the blog subdomain and 301s any /blog/<slug> hits back to the canonical short form - /blogs (legacy index) 301s to /blog - not-found.js retains its client-side fallback for typo'd slugs SEO / AEO / GEO - BlogPosting + Person + Organization + BreadcrumbList JSON-LD on every post, with sameAs, image, isPartOf (for series), about - CollectionPage + ItemList schema on tag/hub/author/series pages - ProfilePage schema on individual author pages - Self-referencing canonicals pointing to blog.kubesimplify.com - Open Graph + Twitter Card on every page - Dynamic sitemap.xml covering posts, tags, hubs, authors, pagination, series (~280 URLs) - robots.txt with explicit allowlist for GPTBot, ClaudeBot, anthropic-ai, PerplexityBot, OAI-SearchBot, Google-Extended, Applebot-Extended, CCBot, meta-externalagent, cohere-ai - llms.txt with full site map, hubs, top contributors, recent posts - RSS + Atom feeds at /rss.xml and /atom.xml Reading experience - ReactMarkdown via unified pipeline (remark-gfm + rehype-pretty-code) - Shiki github-dark code highlighting in both light and dark site themes - Sticky desktop TOC, collapsible mobile TOC - Share bar (X, LinkedIn, Hacker News, copy) - Reading progress bar - Related posts via tag overlap - Author bio card with socials + Edit on GitHub link - Pagefind static search (20,500+ words indexed, no service required) - Substack newsletter CTA (saiyampathak.substack.com/s/kubesimplify-diaries) - Giscus comments wired (GitHub Discussions backend, repo IDs configured) Build pipeline - npm run build runs generate-feeds + generate-redirects, then next build, then pagefind --site out - All deploys idempotent; scripts/ directory contains the migration tools for repeatability vercel.json + wrangler.toml + public/_redirects let the same static output deploy unchanged to both Vercel (kubesimplify.com) and Cloudflare Pages or Workers (blog.kubesimplify.com). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 15a1f30 commit a506a45

1,764 files changed

Lines changed: 69089 additions & 702 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/blog/[slug]/page.js‎

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import Link from 'next/link';
2+
import { notFound } from 'next/navigation';
3+
import { getAllSlugs, getPostBySlug, getRelatedPosts, SITE, postUrl, blogIndexUrl } from '@/lib/blog';
4+
import { getSeriesForPost } from '@/lib/series';
5+
import SeriesBanner from '@/components/SeriesBanner';
6+
import { extractToc } from '@/lib/mdx-options';
7+
import { renderMarkdown } from '@/lib/markdown';
8+
import BlogReadingProgress from '@/components/BlogReadingProgress';
9+
import BlogToc from '@/components/BlogToc';
10+
import BlogShareBar from '@/components/BlogShareBar';
11+
import CodeBlockEnhancer from '@/components/CodeBlockEnhancer';
12+
import NewsletterCTA from '@/components/NewsletterCTA';
13+
import AuthorSocials from '@/components/AuthorSocials';
14+
import Comments from '@/components/Comments';
15+
16+
export const dynamicParams = false;
17+
18+
export async function generateStaticParams() {
19+
return getAllSlugs().map((slug) => ({ slug }));
20+
}
21+
22+
export async function generateMetadata({ params }) {
23+
const post = getPostBySlug(params.slug);
24+
if (!post) return {};
25+
const url = postUrl(post.slug);
26+
const ogImage = post.cover
27+
? post.cover.startsWith('http')
28+
? post.cover
29+
: `${SITE.url}${post.cover}`
30+
: `${SITE.url}${SITE.defaultOgImage}`;
31+
return {
32+
title: post.seoTitle || post.title,
33+
description: post.seoDescription || undefined,
34+
keywords: post.tags,
35+
authors: [{ name: post.author.name, url: post.author.url }],
36+
alternates: { canonical: url },
37+
openGraph: {
38+
type: 'article',
39+
url,
40+
title: post.seoTitle || post.title,
41+
description: post.seoDescription || undefined,
42+
siteName: SITE.name,
43+
images: [{ url: ogImage, width: 1200, height: 630, alt: post.title }],
44+
publishedTime: post.datePublished,
45+
authors: [post.author.name],
46+
tags: post.tags,
47+
},
48+
twitter: {
49+
card: 'summary_large_image',
50+
site: SITE.twitter,
51+
title: post.seoTitle || post.title,
52+
description: post.seoDescription || undefined,
53+
images: [ogImage],
54+
},
55+
};
56+
}
57+
58+
function formatDate(iso) {
59+
return new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
60+
}
61+
62+
export default async function BlogPost({ params }) {
63+
const post = getPostBySlug(params.slug);
64+
if (!post) notFound();
65+
66+
const toc = extractToc(post.content);
67+
const rendered = await renderMarkdown(post.content);
68+
const related = getRelatedPosts(post.slug, 3);
69+
const seriesInfo = getSeriesForPost(post.slug);
70+
const url = postUrl(post.slug);
71+
const ogImage = post.cover
72+
? post.cover.startsWith('http')
73+
? post.cover
74+
: `${SITE.url}${post.cover}`
75+
: `${SITE.url}${SITE.defaultOgImage}`;
76+
77+
const jsonLd = {
78+
'@context': 'https://schema.org',
79+
'@type': 'BlogPosting',
80+
headline: post.title,
81+
description: post.seoDescription || undefined,
82+
image: [ogImage],
83+
datePublished: post.datePublished,
84+
dateModified: post.datePublished,
85+
inLanguage: 'en-US',
86+
isAccessibleForFree: true,
87+
author: {
88+
'@type': 'Person',
89+
'@id': post.author.url,
90+
name: post.author.name,
91+
url: post.author.url,
92+
image: post.author.avatar?.startsWith('http') ? post.author.avatar : `${SITE.url}${post.author.avatar}`,
93+
sameAs: post.author.sameAs?.length ? post.author.sameAs : undefined,
94+
...(post.author.bio ? { description: post.author.bio } : {}),
95+
},
96+
publisher: {
97+
'@type': 'Organization',
98+
'@id': 'https://kubesimplify.com#org',
99+
name: 'Kubesimplify',
100+
url: 'https://kubesimplify.com',
101+
logo: { '@type': 'ImageObject', url: `${SITE.url}/img/color.svg`, width: 64, height: 64 },
102+
sameAs: [
103+
'https://www.youtube.com/@kubesimplify',
104+
'https://x.com/kubesimplify',
105+
'https://github.com/kubesimplify',
106+
'https://www.linkedin.com/company/kubesimplify/',
107+
],
108+
},
109+
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
110+
keywords: post.tags,
111+
wordCount: post.words,
112+
timeRequired: `PT${post.readMinutes}M`,
113+
articleSection: post.tags[0] || 'Cloud Native',
114+
about: post.tags.map((t) => ({ '@type': 'Thing', name: t })),
115+
...(seriesInfo
116+
? {
117+
isPartOf: {
118+
'@type': 'CreativeWorkSeries',
119+
name: seriesInfo.series.name,
120+
url: `${SITE.url}/series/${seriesInfo.series.slug}`,
121+
numberOfItems: seriesInfo.total,
122+
},
123+
position: seriesInfo.position,
124+
}
125+
: {}),
126+
};
127+
128+
const breadcrumbLd = {
129+
'@context': 'https://schema.org',
130+
'@type': 'BreadcrumbList',
131+
itemListElement: [
132+
{ '@type': 'ListItem', position: 1, name: 'Blog', item: blogIndexUrl() },
133+
{ '@type': 'ListItem', position: 2, name: post.title, item: url },
134+
],
135+
};
136+
137+
const editUrl = `https://github.com/${SITE.githubRepo}/blob/main/content/blog/${post.slug}.md`;
138+
139+
return (
140+
<>
141+
<BlogReadingProgress />
142+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
143+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }} />
144+
145+
<main className="pt-24 pb-20">
146+
<div className="max-w-7xl mx-auto px-6 lg:px-8">
147+
<nav aria-label="Breadcrumb" className="mb-8 text-sm">
148+
<Link href="/blog" className="text-[var(--accent)] hover:underline">
149+
&larr; All posts
150+
</Link>
151+
</nav>
152+
153+
<article
154+
className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(0,42rem)_1fr] gap-8"
155+
data-pagefind-body
156+
data-pagefind-meta={`title:${post.title}, author:${post.author.name}, date:${post.datePublished}, slug:${post.slug}, cover[src]:${post.cover || ''}, read:${post.readMinutes} min`}
157+
data-pagefind-filter={`tag:${post.tags.join(',')}`}
158+
itemScope
159+
itemType="https://schema.org/BlogPosting"
160+
>
161+
<aside className="hidden lg:block">
162+
<div className="sticky top-28">
163+
<BlogShareBar url={url} title={post.title} />
164+
</div>
165+
</aside>
166+
167+
<div className="min-w-0">
168+
<header className="mb-10">
169+
<div className="flex flex-wrap gap-2 mb-5">
170+
{post.tags.map((tag) => (
171+
<Link
172+
key={tag}
173+
href={`/blog/tag/${encodeURIComponent(tag)}`}
174+
className="px-2.5 py-0.5 rounded-full bg-[var(--accent)]/10 text-[var(--accent)] text-[10px] font-bold uppercase tracking-wider hover:bg-[var(--accent)]/20 transition-colors"
175+
>
176+
{tag}
177+
</Link>
178+
))}
179+
</div>
180+
<h1
181+
className="text-4xl md:text-5xl font-bold leading-tight mb-6"
182+
style={{ color: 'var(--text-primary)' }}
183+
>
184+
{post.title}
185+
</h1>
186+
{post.seoDescription && (
187+
<p className="text-lg leading-relaxed mb-6" style={{ color: 'var(--text-secondary)' }}>
188+
{post.seoDescription}
189+
</p>
190+
)}
191+
<div className="flex items-center gap-3 text-sm" style={{ color: 'var(--text-muted)' }}>
192+
{/* eslint-disable-next-line @next/next/no-img-element */}
193+
<img src={post.author.avatar} alt="" className="w-8 h-8 rounded-full" />
194+
<div>
195+
<Link
196+
href={`/blog/author/${post.author.handle}`}
197+
className="font-semibold hover:text-[var(--accent)] transition-colors"
198+
style={{ color: 'var(--text-primary)' }}
199+
>
200+
{post.author.name}
201+
</Link>
202+
<span className="mx-2">·</span>
203+
<time dateTime={post.datePublished}>{formatDate(post.datePublished)}</time>
204+
<span className="mx-2">·</span>
205+
<span>{post.readMinutes} min read</span>
206+
</div>
207+
</div>
208+
209+
<div className="md:hidden mt-6">
210+
<BlogShareBar url={url} title={post.title} />
211+
</div>
212+
</header>
213+
214+
{post.cover && (
215+
/* eslint-disable-next-line @next/next/no-img-element */
216+
<img
217+
src={post.cover}
218+
alt={`Cover image for ${post.title}`}
219+
className="w-full rounded-xl md:rounded-2xl mb-8 md:mb-10 border"
220+
style={{ borderColor: 'var(--border-subtle)' }}
221+
fetchPriority="high"
222+
itemProp="image"
223+
/>
224+
)}
225+
226+
<SeriesBanner info={seriesInfo} />
227+
228+
{/* Mobile TOC (desktop has it in the right sidebar) */}
229+
{toc.length > 2 && (
230+
<details className="lg:hidden mb-8 rounded-xl border p-4" style={{ borderColor: 'var(--border-subtle)', background: 'var(--bg-elevated)' }}>
231+
<summary className="cursor-pointer text-sm font-bold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
232+
On this page ({toc.length})
233+
</summary>
234+
<ul className="mt-3 space-y-1.5 text-sm border-l" style={{ borderColor: 'var(--border-subtle)' }}>
235+
{toc.map((h) => (
236+
<li key={h.id} style={{ paddingLeft: `${(h.depth - 2) * 12 + 12}px` }}>
237+
<a href={`#${h.id}`} className="block py-0.5 hover:text-[var(--accent)]" style={{ color: 'var(--text-secondary)' }}>
238+
{h.text}
239+
</a>
240+
</li>
241+
))}
242+
</ul>
243+
</details>
244+
)}
245+
246+
<div className="prose-blog">{rendered}</div>
247+
248+
<CodeBlockEnhancer />
249+
250+
<hr className="my-12" style={{ borderColor: 'var(--border-subtle)' }} />
251+
252+
<section className="glass-card rounded-2xl p-6 mb-10 flex items-start gap-4 flex-wrap">
253+
{/* eslint-disable-next-line @next/next/no-img-element */}
254+
<img src={post.author.avatar} alt="" className="w-14 h-14 rounded-full object-cover" />
255+
<div className="flex-1 min-w-0">
256+
<Link
257+
href={`/blog/author/${post.author.handle}`}
258+
className="font-bold mb-1 inline-block hover:text-[var(--accent)] transition-colors"
259+
style={{ color: 'var(--text-primary)' }}
260+
>
261+
{post.author.name}
262+
</Link>
263+
{post.author.bio && (
264+
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
265+
{post.author.bio}
266+
</p>
267+
)}
268+
<AuthorSocials socials={post.author.socials} />
269+
</div>
270+
</section>
271+
272+
<NewsletterCTA variant="inline" />
273+
274+
<p className="text-sm mb-10 mt-10" style={{ color: 'var(--text-muted)' }}>
275+
Spotted a typo or want to improve this post?{' '}
276+
<a
277+
href={editUrl}
278+
target="_blank"
279+
rel="noopener noreferrer"
280+
className="text-[var(--accent)] font-semibold hover:underline"
281+
>
282+
Edit on GitHub &rarr;
283+
</a>
284+
</p>
285+
286+
<Comments />
287+
288+
{related.length > 0 && (
289+
<section className="mt-12">
290+
<h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--text-primary)' }}>
291+
Related posts
292+
</h2>
293+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
294+
{related.map((p) => (
295+
<Link
296+
key={p.slug}
297+
href={`/blog/${p.slug}`}
298+
className="glass-card rounded-xl p-4 transition-all hover:-translate-y-1"
299+
>
300+
<p
301+
className="text-[10px] font-bold uppercase tracking-wider text-[var(--accent)] mb-2"
302+
>
303+
{p.tags[0] || 'Article'}
304+
</p>
305+
<p
306+
className="font-semibold leading-snug mb-2 line-clamp-3"
307+
style={{ color: 'var(--text-primary)' }}
308+
>
309+
{p.title}
310+
</p>
311+
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
312+
{p.readMinutes} min read
313+
</p>
314+
</Link>
315+
))}
316+
</div>
317+
</section>
318+
)}
319+
</div>
320+
321+
<aside className="hidden lg:block">
322+
<div className="sticky top-28 max-h-[calc(100vh-8rem)] overflow-y-auto pr-2">
323+
<BlogToc toc={toc} />
324+
</div>
325+
</aside>
326+
</article>
327+
</div>
328+
</main>
329+
</>
330+
);
331+
}

0 commit comments

Comments
 (0)