Skip to content
Open
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
4 changes: 3 additions & 1 deletion blogs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ To keep a post as a draft (written but not published), move it into the `drafts/
---
title: "Tuning Reth for payments: how we hit 21,200 TPS"
excerpt: "One or two sentences shown on the index and as the post's lede."
metaTitle: "Tuning Reth for payments: how we hit 21,200 TPS" # optional SEO title
metaDescription: "Custom search and social preview copy." # optional SEO description
date: 2026-06-02
category: technical # network-upgrades | events | technical | case-studies
featured: true # optional — pins the post to the hero card on /blog
---
```

`category` must be one of the four slugs above (the build fails loudly otherwise). At most one post should be `featured`; if none is, the newest post takes the hero card.
`category` must be one of the four slugs above (the build fails loudly otherwise). `metaTitle` and `metaDescription` override the browser, OpenGraph, Twitter, and JSON-LD metadata. Blog OpenGraph images are generated dynamically from the post title with the `DEV BLOG` label. At most one post should be `featured`; if none is, the newest post takes the hero card.

## Body

Expand Down
2 changes: 2 additions & 0 deletions blogs/t6.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
---
title: "T6 network upgrade: Receive policies, admin access keys, and more"
excerpt: "The T6 network upgrade adds two new account-level controls to Tempo: receive policies, which let an account decide which tokens and senders it accepts, and admin access keys, which let an account delegate key management without using the root key."
metaTitle: "T6 network upgrade: Receive policies and admin access keys"
metaDescription: "Learn how Tempo's T6 network upgrade adds account-level receive policies, held-transfer recovery, and admin access keys for safer key management."
date: 2026-06-23
category: network-upgrades
---
Expand Down
9 changes: 7 additions & 2 deletions src/marketing/blogPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { unified } from 'unified'
import type { Plugin } from 'vite'

// Blog content lives as dev-managed markdown files in /blogs at the repo root.
// Frontmatter schema: title, excerpt, date (YYYY-MM-DD), category, and an
// optional `featured: true` to pin a post to the hero card.
// Frontmatter schema: title, excerpt, date (YYYY-MM-DD), category, optional
// metaTitle/metaDescription SEO overrides, and an optional `featured: true` to
// pin a post to the hero card.
//
// Markdown is rendered to HTML here, in Node, at build/dev time, so the heavy
// markdown + Shiki toolchain never ships to the client bundle. The rendered
Expand All @@ -31,6 +32,8 @@ export type RenderedPost = {
slug: string
title: string
excerpt: string
metaTitle: string
metaDescription: string
date: string
category: string
featured: boolean
Expand Down Expand Up @@ -92,6 +95,8 @@ async function renderPost(filename: string): Promise<RenderedPost> {
slug,
title: data.title,
excerpt: data.excerpt,
metaTitle: data.metaTitle || `${data.title} — Tempo Developers`,
metaDescription: data.metaDescription || data.excerpt,
date: data.date,
category: data.category,
featured: data.featured === 'true',
Expand Down
2 changes: 2 additions & 0 deletions src/marketing/next.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ declare module 'virtual:blog-posts' {
slug: string
title: string
excerpt: string
metaTitle: string
metaDescription: string
date: string
category: string
featured: boolean
Expand Down
20 changes: 17 additions & 3 deletions src/marketing/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type PostSeo = {
slug: string
title: string // raw post title (no " — Tempo Developers" suffix)
excerpt: string
metaTitle: string
metaDescription: string
date: string // YYYY-MM-DD
category: CategorySlug
}
Expand All @@ -33,12 +35,24 @@ export function absoluteUrl(base: string, pathname: string): string {

export function ogImageUrl(
base: string,
params: { title: string; description: string; section: string },
params: { title: string; description: string; section: string; eyebrow?: string },
): string {
const query = new URLSearchParams(params).toString()
return absoluteUrl(base, `/api/og?${query}`)
}

export function blogOgImageUrl(
base: string,
post: Pick<PostSeo, 'title' | 'metaDescription'>,
): string {
return ogImageUrl(base, {
title: post.title,
description: post.metaDescription,
section: 'BLOG',
eyebrow: 'DEV BLOG',
})
}

// schema.org BlogPosting payload for a post, serialized for a
// <script type="application/ld+json"> tag. `ogImage` should already be absolute.
export function blogPostJsonLd(base: string, post: PostSeo, ogImage: string): string {
Expand All @@ -52,8 +66,8 @@ export function blogPostJsonLd(base: string, post: PostSeo, ogImage: string): st
return JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
headline: post.metaTitle || post.title,
description: post.metaDescription || post.excerpt,
datePublished: post.date,
dateModified: post.date,
image: ogImage,
Expand Down
3 changes: 2 additions & 1 deletion src/pages/_api/api/og.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function balanceLines(text: string, fontSize: number): string[] {
export default async function handler(request: Request) {
const url = new URL(request.url)
const title = url.searchParams.get('title') || 'Tempo'
const eyebrow = url.searchParams.get('eyebrow') || 'DOCS'
const section = url.searchParams.get('section') || ''
const subsection = url.searchParams.get('subsection') || ''

Expand Down Expand Up @@ -141,7 +142,7 @@ export default async function handler(request: Request) {
color: '#3D3D3D',
}}
>
DOCS
{eyebrow}
</div>
<div
style={{
Expand Down
16 changes: 6 additions & 10 deletions src/pages/blog/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { type CategorySlug, categoryBySlug } from '../../marketing/app/blog/_lib
import BlogPostRoute from '../../marketing/BlogPostRoute'
import {
absoluteUrl,
blogOgImageUrl,
blogPostJsonLd,
ogImageUrl,
type PostSeo,
resolveBaseUrl,
} from '../../marketing/seo'
Expand All @@ -20,6 +20,8 @@ const postBySlug = new Map<string, PostSeo>(
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
metaTitle: post.metaTitle,
metaDescription: post.metaDescription,
date: post.date,
category: post.category as CategorySlug,
},
Expand All @@ -38,10 +40,10 @@ export default function Page({ slug }: { slug: string }) {
const base = resolveBaseUrl()
const post = postBySlug.get(slug)

const title = post ? `${post.title} — Tempo Developers` : BLOG_TITLE
const description = post?.excerpt ?? BLOG_DESCRIPTION
const title = post?.metaTitle ?? BLOG_TITLE
const description = post?.metaDescription ?? BLOG_DESCRIPTION
const canonical = absoluteUrl(base, post ? `/blog/${slug}` : '/blog')
const ogImage = ogImageUrl(base, { title, description, section: 'BLOG' })
const ogImage = post ? blogOgImageUrl(base, post) : ''

return (
<>
Expand All @@ -50,13 +52,7 @@ export default function Page({ slug }: { slug: string }) {
{base ? <link rel="canonical" href={canonical} /> : null}
<meta property="og:type" content={post ? 'article' : 'website'} />
<meta property="og:url" content={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="twitter:image" content={ogImage} />
{post ? <meta property="article:published_time" content={post.date} /> : null}
{post ? (
<meta property="article:section" content={categoryBySlug(post.category).label} />
Expand Down
11 changes: 9 additions & 2 deletions vite.marketing.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type CategorySlug, categoryBySlug } from './src/marketing/app/blog/_lib
import { blogPostsPlugin, loadRenderedPosts } from './src/marketing/blogPlugin'
import {
absoluteUrl,
blogOgImageUrl,
blogPostJsonLd,
ogImageUrl,
type PostSeo,
Expand Down Expand Up @@ -66,13 +67,15 @@ async function marketingRouteCopiesForBuild() {
blogPostByRoute.clear()
for (const post of posts) {
blogRouteMetadata.set(`blog/${post.slug}`, {
title: `${post.title} — Tempo Developers`,
description: post.excerpt,
title: post.metaTitle,
description: post.metaDescription,
})
blogPostByRoute.set(`blog/${post.slug}`, {
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
metaTitle: post.metaTitle,
metaDescription: post.metaDescription,
date: post.date,
category: post.category as CategorySlug,
})
Expand Down Expand Up @@ -209,6 +212,9 @@ function applyMarketingMetadata(html: string, route: string) {
}

function marketingOgImage(route: string, metadata: { title: string; description: string }) {
const post = blogPostByRoute.get(route)
if (post) return blogOgImageUrl(siteBaseUrl, post)

const sections: Record<string, string> = {
performance: 'PERFORMANCE',
diagrams: 'DIAGRAMS',
Expand All @@ -219,6 +225,7 @@ function marketingOgImage(route: string, metadata: { title: string; description:
title: metadata.title,
description: metadata.description,
section,
eyebrow: route.startsWith('blog') ? 'DEV BLOG' : undefined,
})
}

Expand Down
3 changes: 3 additions & 0 deletions vocs.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export default defineConfig({
const docsPath = path.replace(/^\/docs(?=\/|$)/, '') || '/'
const landingPaths = ['/', '/changelog']
if (landingPaths.includes(docsPath)) return `${urlBase}/og-docs.png`
if (docsPath === '/blog' || docsPath.startsWith('/blog/')) {
return `${urlBase}/api/og?title=%title&description=%description&section=BLOG&eyebrow=DEV+BLOG`
}

const sectionMap: Record<string, string> = {
quickstart: 'INTEGRATE',
Expand Down
Loading