From dbf8c3aeed651fe0b99234af78214f87a206e57c Mon Sep 17 00:00:00 2001 From: Feitong Yang Date: Thu, 14 May 2026 13:11:15 -0700 Subject: [PATCH 1/4] feat(seo): related-essays footer for internal linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Related essays" block at the bottom of every essay detail page, computed from shared topics with a recency fallback. Internal linking is the single most undervalued lever for both SEO and GEO: - SEO: distributes link equity across essays, improves crawl depth, lifts orphan pages. Previously every essay was a dead end. - GEO: gives AI engines more contexts to encounter each essay, increasing the chance an essay is cited from a topically adjacent query. - UX: real readers stay on site longer. Algorithm (lib/essays.ts → getRelatedEssays): 1. Filter to same language, non-draft, not the essay itself, not the essay's translation (in either direction). 2. Score each candidate by count of topics it shares with the source. 3. Sort by score desc, then date desc. 4. Take top `limit` (default 3). 5. If fewer than `limit` candidates share a topic, fill the remainder with the most recent essays in the same language. Avoids an empty block for niche topics. Component (components/essay/RelatedEssays.tsx): Server component. Renders nothing when the essays array is empty. Each item shows topic eyebrow, title (serif, link-on-hover), description (2-line clamp), and date + reading time. Composition uses the same hairline-rule / restrained-typography idiom as the OG card so the on-page block and the social card share a visual language. i18n: Added `essay.related.heading` to en.json and zh.json. Tests: 5 new tests covering ranking, exclusion of self/translation, language filter, limit, and missing-source. 169 total tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/blog/__tests__/essays.test.ts | 115 +++++++++++++++ apps/blog/app/(en)/essays/[slug]/page.tsx | 6 +- apps/blog/app/(zh)/zh/essays/[slug]/page.tsx | 6 +- .../essay/RelatedEssays.stories.tsx | 139 ++++++++++++++++++ apps/blog/components/essay/RelatedEssays.tsx | 83 +++++++++++ apps/blog/components/essay/index.ts | 1 + apps/blog/lib/essays.ts | 52 +++++++ apps/blog/lib/i18n/locales/en.json | 1 + apps/blog/lib/i18n/locales/zh.json | 1 + 9 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 apps/blog/components/essay/RelatedEssays.stories.tsx create mode 100644 apps/blog/components/essay/RelatedEssays.tsx diff --git a/apps/blog/__tests__/essays.test.ts b/apps/blog/__tests__/essays.test.ts index 05d50f6..19b4c61 100644 --- a/apps/blog/__tests__/essays.test.ts +++ b/apps/blog/__tests__/essays.test.ts @@ -6,6 +6,7 @@ import { getEssaysByType, getEssaysByTopic, getEssaysByLanguage, + getRelatedEssays, getTranslation, getEssaySlugsByLanguage, } from '@/lib/essays'; @@ -274,6 +275,120 @@ Content`; }); }); + describe('getRelatedEssays', () => { + beforeEach(() => { + vi.mocked(fs.readdirSync).mockReturnValue([ + 'source.mdx', + 'shares-two-topics.mdx', + 'shares-one-topic.mdx', + 'unrelated.mdx', + 'other-language.mdx', + ] as unknown as ReturnType); + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const file = String(p); + return [ + 'source.mdx', + 'shares-two-topics.mdx', + 'shares-one-topic.mdx', + 'unrelated.mdx', + 'other-language.mdx', + ].some((name) => file.endsWith(name)); + }); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const file = String(p); + if (file.includes('source.mdx')) { + return `--- +title: Source +description: Source +date: 2024-03-01 +type: guide +topics: ['technical', 'ai'] +lang: en +--- +Content`; + } + if (file.includes('shares-two-topics.mdx')) { + return `--- +title: Shares Two +description: Two +date: 2024-01-01 +type: guide +topics: ['technical', 'ai'] +lang: en +--- +Content`; + } + if (file.includes('shares-one-topic.mdx')) { + return `--- +title: Shares One +description: One +date: 2024-02-01 +type: guide +topics: ['ai'] +lang: en +--- +Content`; + } + if (file.includes('unrelated.mdx')) { + return `--- +title: Unrelated +description: Unrelated +date: 2024-02-15 +type: narrative +topics: ['career'] +lang: en +--- +Content`; + } + if (file.includes('other-language.mdx')) { + return `--- +title: Other Language +description: zh +date: 2024-02-20 +type: guide +topics: ['technical', 'ai'] +lang: zh +--- +Content`; + } + const err = new Error(`ENOENT: no such file or directory, open '${file}'`); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + }); + }); + + it('ranks by shared-topic count, then by recency', () => { + const related = getRelatedEssays('source', { limit: 3 }); + expect(related.map((e) => e.slug)).toEqual([ + 'shares-two-topics', + 'shares-one-topic', + 'unrelated', // recency fallback after the topic-matching candidates + ]); + }); + + it('excludes the source essay itself', () => { + const related = getRelatedEssays('source', { limit: 5 }); + expect(related.map((e) => e.slug)).not.toContain('source'); + }); + + it('excludes other-language essays', () => { + const related = getRelatedEssays('source', { limit: 5 }); + expect(related.map((e) => e.slug)).not.toContain('other-language'); + }); + + it('respects the limit', () => { + const related = getRelatedEssays('source', { limit: 1 }); + expect(related).toHaveLength(1); + expect(related[0].slug).toBe('shares-two-topics'); + }); + + it('returns empty when the source essay does not exist', () => { + expect(getRelatedEssays('nonexistent')).toEqual([]); + }); + }); + describe('getEssaysByLanguage', () => { const mockEnglishEssay = `--- title: English Essay diff --git a/apps/blog/app/(en)/essays/[slug]/page.tsx b/apps/blog/app/(en)/essays/[slug]/page.tsx index 6e221cf..d63c0bc 100644 --- a/apps/blog/app/(en)/essays/[slug]/page.tsx +++ b/apps/blog/app/(en)/essays/[slug]/page.tsx @@ -1,10 +1,10 @@ import { notFound, redirect } from 'next/navigation'; import type { Metadata } from 'next'; import { MDXRemote } from 'next-mdx-remote/rsc'; -import { getEssayBySlug, getEssaySlugs, getTranslation } from '@/lib/essays'; +import { getEssayBySlug, getEssaySlugs, getRelatedEssays, getTranslation } from '@/lib/essays'; import { getMDXComponents } from '@/components/mdx/MDXComponents'; import { mdxOptions } from '@/lib/mdx-options'; -import { EssayLayout, EssayHeader } from '@/components/essay'; +import { EssayLayout, EssayHeader, RelatedEssays } from '@/components/essay'; import { JsonLd } from '@/components/seo'; import { breadcrumbSchema, essayPostingSchema } from '@/lib/jsonld'; @@ -165,6 +165,7 @@ export default async function EssayPage({ params }: EssayPageProps) { const { title, description, date, type, topics, readingTime, content, toc } = essay; const urlPath = `/essays/${slug}`; + const related = getRelatedEssays(slug, { limit: 3 }); return ( <> @@ -190,6 +191,7 @@ export default async function EssayPage({ params }: EssayPageProps) { } > + ); diff --git a/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx b/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx index db794f0..242fffa 100644 --- a/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx +++ b/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx @@ -1,10 +1,10 @@ import { notFound, redirect } from 'next/navigation'; import type { Metadata } from 'next'; import { MDXRemote } from 'next-mdx-remote/rsc'; -import { getEssayBySlug, getEssaySlugs, getTranslation } from '@/lib/essays'; +import { getEssayBySlug, getEssaySlugs, getRelatedEssays, getTranslation } from '@/lib/essays'; import { getMDXComponents } from '@/components/mdx/MDXComponents'; import { mdxOptions } from '@/lib/mdx-options'; -import { EssayLayout, EssayHeader } from '@/components/essay'; +import { EssayLayout, EssayHeader, RelatedEssays } from '@/components/essay'; import { JsonLd } from '@/components/seo'; import { breadcrumbSchema, essayPostingSchema } from '@/lib/jsonld'; @@ -163,6 +163,7 @@ export default async function ZhEssayPage({ params }: ZhEssayPageProps) { const { title, description, date, type, topics, readingTime, content, toc } = essay; const urlPath = `/zh/essays/${slug}`; + const related = getRelatedEssays(slug, { limit: 3 }); return ( <> @@ -189,6 +190,7 @@ export default async function ZhEssayPage({ params }: ZhEssayPageProps) { } > + ); diff --git a/apps/blog/components/essay/RelatedEssays.stories.tsx b/apps/blog/components/essay/RelatedEssays.stories.tsx new file mode 100644 index 0000000..d99fe3c --- /dev/null +++ b/apps/blog/components/essay/RelatedEssays.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LanguageProvider } from '@/lib/i18n'; +import { ThemeProvider } from '@/components/layout/ThemeProvider'; +import { RelatedEssays } from './RelatedEssays'; +import type { EssayMeta } from '@/types/content'; + +const sampleEssaysEn: EssayMeta[] = [ + { + slug: 'job-reflection-2023', + title: 'Career Reflection — 2023', + description: + 'A mid-career stocktake. From a self-portrait through team expectations to a review of academia, Google, and Citadel, then a look at what comes next.', + date: '2023-07-05', + type: 'narrative', + topics: ['career'], + lang: 'en', + readingTime: 18, + }, + { + slug: 'offer-negotiation', + title: 'Negotiating Software Engineering Job Offers', + description: + 'A practical guide to negotiating job offers, covering key strategies like keeping doors open, leveraging information, having alternatives, and understanding what companies value.', + date: '2023-02-10', + type: 'guide', + topics: ['career'], + lang: 'en', + readingTime: 12, + }, + { + slug: 'job-search-reflection', + title: 'What I Want from a Job', + description: + "After several research and internship experiences, I reflect on what I'm looking for in a position: intellectual challenges, collaborative colleagues, personal growth, and real impact.", + date: '2017-09-12', + type: 'narrative', + topics: ['career', 'research'], + lang: 'en', + readingTime: 8, + }, +]; + +const sampleEssaysZh: EssayMeta[] = [ + { + slug: 'job-reflection-2023-zh', + title: '职业反思 — 2023', + description: + '一份职业的中段盘点。从自我画像、团队期望,到对学术界、Google、Citadel 三段经历的复盘,再到对下一阶段的思考。', + date: '2023-07-05', + type: 'narrative', + topics: ['career'], + lang: 'zh', + readingTime: 18, + }, + { + slug: 'reverse-interview-zh', + title: '反向面试', + description: '面试工作应该是一个相互选择的过程。这里收集了一些帮助应聘者评估公司和团队的资源。', + date: '2020-05-01', + type: 'guide', + topics: ['career'], + lang: 'zh', + readingTime: 10, + }, + { + slug: '10k-code-zh', + title: '第一个一万行代码', + description: + '工作头半年写完第一个10,000行代码后,对代码审阅、测试、版本控制、历史遗留代码和软件设计的思考与经验总结。', + date: '2018-06-12', + type: 'narrative', + topics: ['technical', 'career'], + lang: 'zh', + readingTime: 14, + }, +]; + +const meta = { + title: 'Essay/RelatedEssays', + component: RelatedEssays, + parameters: { + layout: 'centered', + docs: { + description: { + component: + '"Related essays" footer rendered at the bottom of essay detail pages. Computes the list from shared topics (with a recency fallback), then renders an editorial list. Distributes internal link equity, which is the single most undervalued SEO/GEO lever for a long-tail blog.', + }, + }, + }, + decorators: [ + (Story) => ( + + +
+ +
+
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const English: Story = { + args: { + essays: sampleEssaysEn, + language: 'en', + }, +}; + +export const Chinese: Story = { + args: { + essays: sampleEssaysZh, + language: 'zh', + }, +}; + +export const SingleResult: Story = { + args: { + essays: sampleEssaysEn.slice(0, 1), + language: 'en', + }, +}; + +export const Empty: Story = { + args: { + essays: [], + language: 'en', + }, + parameters: { + docs: { + description: { + story: 'When there are no related essays, the component renders nothing (no empty state).', + }, + }, + }, +}; diff --git a/apps/blog/components/essay/RelatedEssays.tsx b/apps/blog/components/essay/RelatedEssays.tsx new file mode 100644 index 0000000..6b8dde8 --- /dev/null +++ b/apps/blog/components/essay/RelatedEssays.tsx @@ -0,0 +1,83 @@ +import Link from 'next/link'; +import { cn } from '@/lib/utils'; +import { translate, formatDate, formatReadingTime } from '@/lib/i18n/translations'; +import { getTopicLabel } from '@/lib/constants'; +import type { EssayMeta, Language } from '@/types/content'; + +export interface RelatedEssaysProps { + /** Pre-computed list of related essays. Empty list renders nothing. */ + essays: EssayMeta[]; + /** Language for labels, dates, and link paths. */ + language: Language; + /** Optional className for the wrapping ); } diff --git a/apps/blog/lib/i18n/locales/en.json b/apps/blog/lib/i18n/locales/en.json index a25c770..6153c79 100644 --- a/apps/blog/lib/i18n/locales/en.json +++ b/apps/blog/lib/i18n/locales/en.json @@ -44,7 +44,7 @@ "essay.readingTime": "{minutes} min read", "essay.draft": "Draft", - "essay.related.heading": "Related essays", + "essay.related.heading": "Related", "periodic.issue": "Issue #{issue}", "periodic.readingTime": "{minutes} min read", diff --git a/apps/blog/lib/i18n/locales/zh.json b/apps/blog/lib/i18n/locales/zh.json index 044d921..c3330d1 100644 --- a/apps/blog/lib/i18n/locales/zh.json +++ b/apps/blog/lib/i18n/locales/zh.json @@ -44,7 +44,7 @@ "essay.readingTime": "阅读时间 {minutes} 分钟", "essay.draft": "草稿", - "essay.related.heading": "相关文章", + "essay.related.heading": "相关", "periodic.issue": "第 {issue} 期", "periodic.readingTime": "阅读时间 {minutes} 分钟", From 3de6bc79dc835e642d3fc1e81d7be6e89ff906ca Mon Sep 17 00:00:00 2001 From: Feitong Yang Date: Thu, 14 May 2026 14:17:41 -0700 Subject: [PATCH 4/4] fix(seo): related-essays footer escapes .essay-content cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous render had three visible problems caused by the same root: the RelatedEssays block was rendered inside .essay-content, which applies an editorial typography cascade (h2 → huge headline, a → underlined link, ol → decimal markers with padding-left). Result: duplicated numbering ("1." from .essay-content ol AND "01" from my decorative span), an oversized "RELATED" eyebrow, and underlined links throughout. Fixes: - EssayLayout: add a `footer` prop that renders OUTSIDE the .essay-content div. The footer wrapper has no .essay-content class so its descendants are not subject to the article typography cascade. Width matches the article body (38-42rem responsive) so the block aligns visually below the content. - RelatedEssays: drop the decorative `01` prefix per feedback. Keep
    for semantics, restore list-decimal + marker:text-figure-muted so the native numbering ("1.", "2.") renders cleanly in the proper muted color. Add no-underline on the Link to defeat any remaining anchor-underline inheritance. - Both essay detail pages: pass RelatedEssays via EssayLayout's new `footer` prop instead of as a child of . Now renders as a minimalist editorial list: 相关 1. 程序扩展人类智能 阅读时间 2 分钟 2. 10000行C++之后对C++的思考 阅读时间 15 分钟 3. 一些细节看程序员的质量和习惯 阅读时间 2 分钟 174 tests pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/blog/app/(en)/essays/[slug]/page.tsx | 2 +- apps/blog/app/(zh)/zh/essays/[slug]/page.tsx | 2 +- apps/blog/components/essay/EssayLayout.tsx | 18 +++++++++++++++++- apps/blog/components/essay/RelatedEssays.tsx | 14 ++++---------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/blog/app/(en)/essays/[slug]/page.tsx b/apps/blog/app/(en)/essays/[slug]/page.tsx index d63c0bc..06c9a62 100644 --- a/apps/blog/app/(en)/essays/[slug]/page.tsx +++ b/apps/blog/app/(en)/essays/[slug]/page.tsx @@ -189,9 +189,9 @@ export default async function EssayPage({ params }: EssayPageProps) { readingTime={readingTime} /> } + footer={} > - ); diff --git a/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx b/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx index 242fffa..ccd3a61 100644 --- a/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx +++ b/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx @@ -188,9 +188,9 @@ export default async function ZhEssayPage({ params }: ZhEssayPageProps) { language="zh" /> } + footer={} > - ); diff --git a/apps/blog/components/essay/EssayLayout.tsx b/apps/blog/components/essay/EssayLayout.tsx index aeffd0b..56f35eb 100644 --- a/apps/blog/components/essay/EssayLayout.tsx +++ b/apps/blog/components/essay/EssayLayout.tsx @@ -23,6 +23,13 @@ export interface EssayLayoutProps { header?: React.ReactNode; /** Main essay content */ children: React.ReactNode; + /** + * Footer content rendered after the main essay body, outside the + * `.essay-content` typography cascade. Use this for blocks like + * "Related essays" that need a different visual register than the + * article body. + */ + footer?: React.ReactNode; /** Table of contents items */ toc?: TocItem[]; /** Additional CSS classes for the layout container */ @@ -46,7 +53,7 @@ export interface EssayLayoutProps { * The layout wraps content in NoteProvider and ReferenceProvider for * sidenote numbering and citation management. */ -export function EssayLayout({ header, children, toc, className }: EssayLayoutProps) { +export function EssayLayout({ header, children, footer, toc, className }: EssayLayoutProps) { const [activeId, setActiveId] = React.useState(null); // Track active heading via Intersection Observer @@ -164,6 +171,15 @@ export function EssayLayout({ header, children, toc, className }: EssayLayoutPro > {children} + + {/* Footer slot — sits OUTSIDE .essay-content so its typography + is not overridden by the article's cascading h2/a/ol styles. + Width matches the article body so the block aligns visually. */} + {footer && ( +
    + {footer} +
    + )} {/* Right column placeholder for sidenotes (they float into this space) */} diff --git a/apps/blog/components/essay/RelatedEssays.tsx b/apps/blog/components/essay/RelatedEssays.tsx index 2a46346..91e0695 100644 --- a/apps/blog/components/essay/RelatedEssays.tsx +++ b/apps/blog/components/essay/RelatedEssays.tsx @@ -43,22 +43,16 @@ export function RelatedEssays({ essays, language, className }: RelatedEssaysProp {heading} -
      - {essays.map((essay, i) => ( +
        + {essays.map((essay) => (
      1. - {essay.title}