Skip to content

Commit 667baac

Browse files
authored
feat(seo): add h1 + BreadcrumbList and enrich BlogPosting schema on blog posts / 博客文章增加 h1 与 BreadcrumbList 并丰富 BlogPosting 结构化数据 (#599)
* feat(seo): add h1 + BreadcrumbList and enrich BlogPosting schema on blog posts Render the post title as a real <h1> (was <h2>) so each post carries a single top-level heading with its primary keyword; MDX body content keeps mapping markdown headings to <h2>, guaranteeing exactly one <h1> per page. Extract the BlogPosting JSON-LD into a pure, unit-testable builder and enrich it with the recommended Article fields: image (post OG image URL), inLanguage, mainEntityOfPage, and publisher.logo (ImageObject). Add a separate BreadcrumbList JSON-LD (Home -> Blog -> post) to posts, mirroring the compare pages. All applied to both the English and /zh post pages, with translated breadcrumb labels and zh-CN language/URLs on the Chinese side. 中文:将博客文章标题渲染为真正的 <h1>(原为 <h2>),使每篇文章拥有唯一的顶级标题并承载主关键词; MDX 正文标题仍映射为 <h2>,从而保证每页只有一个 <h1>。将 BlogPosting 结构化数据抽取为纯函数、 可单元测试的构建器,并补齐推荐的 Article 字段:image(文章 OG 图片 URL)、inLanguage、 mainEntityOfPage 与 publisher.logo(ImageObject)。同时为文章新增独立的 BreadcrumbList 结构化数据(首页 -> 博客 -> 文章),与 compare 页面保持一致。以上改动同时应用于英文与 /zh 文章页, 中文侧使用翻译后的面包屑标签及 zh-CN 语言标记与 URL。 * fix(seo): drop conflicting publisher.logo dimensions in blog JSON-LD Cursor Bugbot flagged the BlogPosting publisher.logo height (675) as inconsistent with the codebase. The OG_IMAGE asset is genuinely 1200×675 (verified via sips), but the root layout declares it 1200×630 and its Organization logo omits dimensions entirely. Rather than assert a number that conflicts with the root layout, omit the optional width/height and match the existing Organization-logo precedent in layout.tsx. 中文:修复博客 JSON-LD 中 publisher.logo 尺寸不一致的问题。Cursor Bugbot 指出 logo 声明的高度(675)与代码库其他处不一致:OG_IMAGE 实际为 1200×675(经 sips 验证),但根布局声明为 1200×630,且其 Organization logo 未声明尺寸。为避免与根布局 冲突,这里省略可选的 width/height,与 layout.tsx 中 Organization logo 的既有做法保持一致。
1 parent 93ae9b4 commit 667baac

5 files changed

Lines changed: 289 additions & 38 deletions

File tree

packages/app/cypress/e2e/blog.cy.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ describe('Blog', () => {
3131
cy.visit('/blog/inferencemax-open-source-inference-benchmarking');
3232
});
3333

34-
it('renders the post title', () => {
35-
cy.get('h2').should('contain.text', 'InferenceMAX');
34+
it('renders the post title as the one and only h1', () => {
35+
// The title is the page's single <h1> (primary-keyword top heading);
36+
// MDX body sections map to <h2>, so there must be exactly one h1.
37+
cy.get('h1').should('have.length', 1).and('contain.text', 'InferenceMAX');
3638
});
3739

3840
it('displays post metadata', () => {

packages/app/src/app/blog/[slug]/page.tsx

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
blogDescription,
2020
getAllPosts,
2121
getAdjacentPosts,
22+
buildBlogBreadcrumbJsonLd,
23+
buildBlogPostingJsonLd,
2224
extractHeadings,
2325
getPostBySlug,
2426
hasZhTranslation,
@@ -136,34 +138,21 @@ export default async function BlogPostPage({ params }: Props) {
136138
},
137139
});
138140

139-
const jsonLd = {
140-
'@context': 'https://schema.org',
141-
'@type': 'BlogPosting',
142-
headline: meta.title,
143-
author: { '@type': 'Person', name: AUTHOR_NAME },
144-
publisher: { '@type': 'Organization', name: AUTHOR_NAME },
145-
datePublished: `${meta.date}T00:00:00Z`,
146-
...(meta.modifiedDate && { dateModified: `${meta.modifiedDate}T00:00:00Z` }),
147-
// Keep structured-data description in sync with the SERP/OG/Twitter meta
148-
// (both go through blogDescription) so they never diverge for posts with a
149-
// seoDescription or a long subtitle.
150-
description: blogDescription(meta),
151-
url: `${SITE_URL}/blog/${slug}`,
152-
wordCount: raw.trim().split(/\s+/u).length,
153-
timeRequired: `PT${meta.readingTime}M`,
154-
};
141+
const jsonLd = buildBlogPostingJsonLd(meta, raw);
142+
const breadcrumbJsonLd = buildBlogBreadcrumbJsonLd(slug, meta.title);
155143

156144
return (
157145
<main className="relative">
158146
<HashScroll />
159147
<ReadingProgressBar slug={slug} />
160148
<JsonLd data={jsonLd} />
149+
<JsonLd data={breadcrumbJsonLd} />
161150
<div className="container mx-auto px-4 lg:px-8 flex flex-col gap-4">
162151
<section data-blog-section="true" className="flex flex-col gap-4">
163152
<Card>
164153
<BlogBackLink />
165154
<header>
166-
<h2 className="text-2xl lg:text-4xl font-bold tracking-tight">{meta.title}</h2>
155+
<h1 className="text-2xl lg:text-4xl font-bold tracking-tight">{meta.title}</h1>
167156
<p className="mt-3 text-base lg:text-lg text-muted-foreground">{meta.subtitle}</p>
168157
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground mt-3">
169158
<span>{AUTHOR_NAME}</span>

packages/app/src/app/zh/blog/[slug]/page.tsx

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import {
2020
blogDescription,
2121
getAllPosts,
2222
getAdjacentPosts,
23+
buildBlogBreadcrumbJsonLdZh,
24+
buildBlogPostingJsonLd,
2325
extractHeadings,
2426
getPostBySlug,
2527
} from '@/lib/blog';
26-
import { ZH_LANG_TAG, ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n';
28+
import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n';
2729
import {
2830
AUTHOR_HANDLE,
2931
AUTHOR_NAME,
@@ -132,35 +134,21 @@ export default async function ZhBlogPostPage({ params }: Props) {
132134
},
133135
});
134136

135-
const jsonLd = {
136-
'@context': 'https://schema.org',
137-
'@type': 'BlogPosting',
138-
headline: meta.title,
139-
author: { '@type': 'Person', name: AUTHOR_NAME },
140-
publisher: { '@type': 'Organization', name: AUTHOR_NAME },
141-
datePublished: `${meta.date}T00:00:00Z`,
142-
...(meta.modifiedDate && { dateModified: `${meta.modifiedDate}T00:00:00Z` }),
143-
// Keep structured-data description in sync with the SERP/OG/Twitter meta
144-
// (both go through blogDescription) so they never diverge for posts with a
145-
// seoDescription or a long subtitle.
146-
description: blogDescription(meta),
147-
url: `${SITE_URL}/zh/blog/${slug}`,
148-
inLanguage: ZH_LANG_TAG,
149-
wordCount: raw.trim().split(/\s+/u).length,
150-
timeRequired: `PT${meta.readingTime}M`,
151-
};
137+
const jsonLd = buildBlogPostingJsonLd(meta, raw, 'zh');
138+
const breadcrumbJsonLd = buildBlogBreadcrumbJsonLdZh(slug, meta.title);
152139

153140
return (
154141
<main className="relative">
155142
<HashScroll />
156143
<ReadingProgressBar slug={slug} />
157144
<JsonLd data={jsonLd} />
145+
<JsonLd data={breadcrumbJsonLd} />
158146
<div className="container mx-auto px-4 lg:px-8 flex flex-col gap-4">
159147
<section data-blog-section="true" className="flex flex-col gap-4">
160148
<Card>
161149
<BlogBackLink href="/zh/blog" label="返回文章列表" />
162150
<header>
163-
<h2 className="text-2xl lg:text-4xl font-bold tracking-tight">{meta.title}</h2>
151+
<h1 className="text-2xl lg:text-4xl font-bold tracking-tight">{meta.title}</h1>
164152
<p className="mt-3 text-base lg:text-lg text-muted-foreground">{meta.subtitle}</p>
165153
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground mt-3">
166154
<span>{AUTHOR_NAME}</span>

packages/app/src/lib/blog.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
22
import fs from 'node:fs';
33

4+
import { OG_IMAGE, SITE_URL } from '@semianalysisai/inferencex-constants';
5+
46
import {
7+
type BlogPostMeta,
58
blogDescription,
9+
blogOgImageUrl,
10+
buildBlogBreadcrumbJsonLd,
11+
buildBlogBreadcrumbJsonLdZh,
12+
buildBlogPostingJsonLd,
613
extractHeadings,
714
getAdjacentPosts,
815
getAllPosts,
@@ -592,3 +599,168 @@ describe('extractHeadings', () => {
592599
expect(headings[1].id).toBe('intro-2');
593600
});
594601
});
602+
603+
// ---------------------------------------------------------------------------
604+
// Structured data (JSON-LD) builders — pure, no fs access.
605+
// ---------------------------------------------------------------------------
606+
607+
const SAMPLE_META: BlogPostMeta = {
608+
title: 'GB200 vs MI355X Inference Benchmark',
609+
subtitle: 'Head-to-head throughput and latency.',
610+
date: '2026-03-01',
611+
slug: 'gb200-vs-mi355x',
612+
readingTime: 7,
613+
tags: ['gpu', 'benchmark'],
614+
};
615+
616+
const SAMPLE_META_MODIFIED: BlogPostMeta = {
617+
...SAMPLE_META,
618+
modifiedDate: '2026-04-10',
619+
};
620+
621+
describe('blogOgImageUrl', () => {
622+
it('builds an absolute URL to the English post opengraph-image route', () => {
623+
expect(blogOgImageUrl('gb200-vs-mi355x')).toBe(
624+
`${SITE_URL}/blog/gb200-vs-mi355x/opengraph-image`,
625+
);
626+
});
627+
628+
it('builds an absolute URL to the /zh post opengraph-image route', () => {
629+
expect(blogOgImageUrl('gb200-vs-mi355x', 'zh')).toBe(
630+
`${SITE_URL}/zh/blog/gb200-vs-mi355x/opengraph-image`,
631+
);
632+
});
633+
});
634+
635+
describe('buildBlogPostingJsonLd', () => {
636+
it('emits a BlogPosting with the recommended Article fields (en)', () => {
637+
const jsonLd = buildBlogPostingJsonLd(SAMPLE_META, 'one two three four five', 'en') as Record<
638+
string,
639+
any
640+
>;
641+
642+
expect(jsonLd['@context']).toBe('https://schema.org');
643+
expect(jsonLd['@type']).toBe('BlogPosting');
644+
expect(jsonLd.headline).toBe(SAMPLE_META.title);
645+
646+
// image — absolute OG image URL for the post.
647+
expect(jsonLd.image).toBe(`${SITE_URL}/blog/gb200-vs-mi355x/opengraph-image`);
648+
649+
// inLanguage — English tag on the English page.
650+
expect(jsonLd.inLanguage).toBe('en-US');
651+
652+
// mainEntityOfPage — WebPage pointing at the canonical post URL.
653+
expect(jsonLd.mainEntityOfPage).toEqual({
654+
'@type': 'WebPage',
655+
'@id': `${SITE_URL}/blog/gb200-vs-mi355x`,
656+
});
657+
658+
// publisher.logo — ImageObject with an absolute URL. Dimensions are
659+
// intentionally omitted (optional in schema.org) because the asset's real
660+
// size conflicts with the root layout's OG declaration; see blog.ts.
661+
expect(jsonLd.publisher['@type']).toBe('Organization');
662+
expect(jsonLd.publisher.logo).toEqual({
663+
'@type': 'ImageObject',
664+
url: OG_IMAGE,
665+
});
666+
expect(jsonLd.publisher.logo.width).toBeUndefined();
667+
expect(String(jsonLd.publisher.logo.url)).toMatch(/^https:\/\//u);
668+
669+
// Existing required fields are preserved.
670+
expect(jsonLd.author).toEqual({ '@type': 'Person', name: 'SemiAnalysis' });
671+
expect(jsonLd.datePublished).toBe('2026-03-01T00:00:00Z');
672+
// Description routes through blogDescription (same helper as the meta/OG
673+
// tags) so structured data and SERP snippet never diverge.
674+
expect(jsonLd.description).toBe(blogDescription(SAMPLE_META));
675+
expect(jsonLd.url).toBe(`${SITE_URL}/blog/gb200-vs-mi355x`);
676+
expect(jsonLd.wordCount).toBe(5);
677+
expect(jsonLd.timeRequired).toBe('PT7M');
678+
});
679+
680+
it('omits dateModified when no modifiedDate, includes it when present', () => {
681+
const withoutMod = buildBlogPostingJsonLd(SAMPLE_META, 'a b c') as Record<string, any>;
682+
expect(withoutMod.dateModified).toBeUndefined();
683+
684+
const withMod = buildBlogPostingJsonLd(SAMPLE_META_MODIFIED, 'a b c') as Record<string, any>;
685+
expect(withMod.dateModified).toBe('2026-04-10T00:00:00Z');
686+
});
687+
688+
it('uses the zh language tag and /zh canonical URL for the Chinese page', () => {
689+
const jsonLd = buildBlogPostingJsonLd(SAMPLE_META, 'a b c', 'zh') as Record<string, any>;
690+
expect(jsonLd.inLanguage).toBe('zh-CN');
691+
expect(jsonLd.url).toBe(`${SITE_URL}/zh/blog/gb200-vs-mi355x`);
692+
expect(jsonLd.mainEntityOfPage['@id']).toBe(`${SITE_URL}/zh/blog/gb200-vs-mi355x`);
693+
expect(jsonLd.image).toBe(`${SITE_URL}/zh/blog/gb200-vs-mi355x/opengraph-image`);
694+
});
695+
});
696+
697+
describe('buildBlogBreadcrumbJsonLd', () => {
698+
it('builds a 3-item BreadcrumbList with correct positions and absolute URLs (en)', () => {
699+
const crumb = buildBlogBreadcrumbJsonLd('gb200-vs-mi355x', SAMPLE_META.title) as Record<
700+
string,
701+
any
702+
>;
703+
704+
expect(crumb['@context']).toBe('https://schema.org');
705+
expect(crumb['@type']).toBe('BreadcrumbList');
706+
expect(crumb.itemListElement).toHaveLength(3);
707+
708+
const [home, blog, post] = crumb.itemListElement;
709+
expect(home).toEqual({ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL });
710+
expect(blog).toEqual({
711+
'@type': 'ListItem',
712+
position: 2,
713+
name: 'Blog',
714+
item: `${SITE_URL}/blog`,
715+
});
716+
expect(post).toEqual({
717+
'@type': 'ListItem',
718+
position: 3,
719+
name: SAMPLE_META.title,
720+
item: `${SITE_URL}/blog/gb200-vs-mi355x`,
721+
});
722+
723+
for (const el of crumb.itemListElement) {
724+
expect(el['@type']).toBe('ListItem');
725+
expect(String(el.item)).toMatch(/^https:\/\//u);
726+
}
727+
expect(crumb.itemListElement.map((el: any) => el.position)).toEqual([1, 2, 3]);
728+
});
729+
});
730+
731+
describe('buildBlogBreadcrumbJsonLdZh', () => {
732+
it('mirrors the English breadcrumb with translated labels and /zh URLs', () => {
733+
const crumb = buildBlogBreadcrumbJsonLdZh('gb200-vs-mi355x', SAMPLE_META.title) as Record<
734+
string,
735+
any
736+
>;
737+
738+
expect(crumb['@type']).toBe('BreadcrumbList');
739+
expect(crumb.itemListElement).toHaveLength(3);
740+
741+
const [home, blog, post] = crumb.itemListElement;
742+
expect(home).toEqual({
743+
'@type': 'ListItem',
744+
position: 1,
745+
name: '首页',
746+
item: `${SITE_URL}/zh`,
747+
});
748+
expect(blog).toEqual({
749+
'@type': 'ListItem',
750+
position: 2,
751+
name: '博客',
752+
item: `${SITE_URL}/zh/blog`,
753+
});
754+
expect(post).toEqual({
755+
'@type': 'ListItem',
756+
position: 3,
757+
name: SAMPLE_META.title,
758+
item: `${SITE_URL}/zh/blog/gb200-vs-mi355x`,
759+
});
760+
761+
for (const el of crumb.itemListElement) {
762+
expect(String(el.item)).toMatch(/^https:\/\/[^/]+\/zh/u);
763+
}
764+
expect(crumb.itemListElement.map((el: any) => el.position)).toEqual([1, 2, 3]);
765+
});
766+
});

0 commit comments

Comments
 (0)