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
40 changes: 40 additions & 0 deletions scripts/seo.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from "node:fs"
import path from "node:path"
import test from "node:test"
import { fileURLToPath } from "node:url"
import { selectRelatedPosts } from "../src/lib/related-posts.ts"

const dist = fileURLToPath(new URL("../dist/", import.meta.url))
const origin = "https://dev.devy.dev"
Expand All @@ -24,6 +25,45 @@ function text(html) {
return html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").trim()
}

test("related article selection keeps focused topics and excludes unavailable or duplicate destinations", () => {
const post = (slug, overrides = {}) => ({ slug, language: "ko", availableLanguages: ["ko"], title: slug, date: "2026-01-01", updated: "2026-01-01", description: "", tags: ["java", "spring-boot", "authentication"], ...overrides })
const current = post("current", { series: "Login" })
const candidates = [
current,
post("same-series", { series: "Login" }),
post("draft", { draft: true }),
post("scheduled", { publishDate: "9999-01-01" }),
post("translation", { language: "en" }),
post("adjacent"),
post("framework-only", { tags: ["java", "spring-boot", "payment"] }),
post("jwt", { tags: ["authentication", "jwt"] }),
post("jwt", { tags: ["authentication", "jwt"] }),
]
assert.deepEqual(selectRelatedPosts(current, candidates, ["adjacent"]).map((entry) => entry.slug), ["jwt"])
assert.deepEqual(selectRelatedPosts(current, [post("framework-only", { tags: ["java", "spring-boot"] })]), [])
})

test("initial article HTML includes crawlable related links to published pages in the same language", () => {
let sections = 0
for (const [url, html] of pages) {
const section = html.match(/<section aria-labelledby="related-posts-heading"[^>]*>([\s\S]*?)<\/section>/)?.[1]
if (!section) continue
sections++
const hrefs = [...section.matchAll(/<a\b[^>]*href="([^"]+)"/g)].map((match) => match[1])
assert.ok(hrefs.length > 0 && hrefs.length <= 3, url)
assert.equal(new Set(hrefs).size, hrefs.length, `duplicate related links: ${url}`)
for (const href of hrefs) {
assert.match(href, /^\/(en\/)?posts\/[^/]+\/$/, url)
const destination = new URL(href, origin).href
assert.notEqual(destination, url, `self link: ${url}`)
assert.ok(pages.has(destination), `unpublished or missing related article: ${destination}`)
assert.equal(href.startsWith("/en/"), new URL(url).pathname.startsWith("/en/"), `related link changes language: ${url}`)
assert.equal(schemas(pages.get(destination))[0]?.["@type"], "BlogPosting", destination)
}
}
assert.ok(sections > 0, "related links must exist in the initial HTML")
})

test("every sitemap URL has indexable HTML with one matching canonical and language", () => {
assert.ok(urls.length > 0)
assert.equal(new Set(urls).size, urls.length)
Expand Down
30 changes: 30 additions & 0 deletions src/components/RelatedPosts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Link } from "react-router-dom"
import { useT } from "@/i18n"
import { postPath } from "@/lib/i18n-routing"
import { Separator } from "@/components/ui/separator"
import type { PostMeta } from "@/types/post"

export function RelatedPosts({ posts }: { posts: PostMeta[] }) {
const t = useT()
if (!posts.length) return null

return (
<section aria-labelledby="related-posts-heading" className="mt-10">
<Separator className="mb-6" />
<h2 id="related-posts-heading" className="text-lg font-semibold mb-3">{t.post.relatedPosts}</h2>
<ul className="space-y-1">
{posts.map((post) => (
<li key={post.slug}>
<Link
to={postPath(post.slug, post.language)}
className="group block rounded-md px-3 py-3 -mx-3 transition-colors hover:bg-accent/50"
>
<span className="text-sm font-medium group-hover:text-primary transition-colors">{post.title}</span>
{post.description && <p className="mt-1 text-sm text-muted-foreground line-clamp-2">{post.description}</p>}
</Link>
</li>
))}
</ul>
</section>
)
}
3 changes: 3 additions & 0 deletions src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface Translations {
backToList: string
prevPost: string
nextPost: string
relatedPosts: string
readingTime: (minutes: number) => string
views: string
updatedAt: (date: string) => string
Expand Down Expand Up @@ -291,6 +292,7 @@ export const ko: Translations = {
backToList: "목록으로",
prevPost: "이전 글",
nextPost: "다음 글",
relatedPosts: "관련 글",
readingTime: (minutes) => `${minutes}분 읽기`,
views: "조회",
updatedAt: (date) => `수정 ${date}`,
Expand Down Expand Up @@ -540,6 +542,7 @@ export const en: Translations = {
backToList: "Back to list",
prevPost: "Previous",
nextPost: "Next",
relatedPosts: "Related Posts",
readingTime: (minutes) => `${minutes} min read`,
views: "views",
updatedAt: (date) => `Updated ${date}`,
Expand Down
36 changes: 36 additions & 0 deletions src/lib/related-posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { PostMeta } from "@/types/post"

// A shared language or framework alone does not make two articles related.
const generalTags = new Set([
"java", "kotlin", "typescript", "javascript", "spring", "spring-boot",
"react", "nextjs", "css", "shadcn-ui", "backend", "frontend", "devops", "migration",
])

export function selectRelatedPosts(
current: PostMeta,
posts: PostMeta[],
excludedSlugs: string[] = [],
limit = 3,
): PostMeta[] {
const excluded = new Set([current.slug, ...excludedSlugs])
const tags = new Set(current.tags)
const today = new Date().toISOString().split("T")[0]!

return posts
.filter((post) => {
if (post.language !== current.language || excluded.has(post.slug)) return false
if (post.draft || (post.publishDate && post.publishDate > today)) return false
if (current.series && post.series === current.series) return false
excluded.add(post.slug)
return true
})
.map((post) => {
const shared = post.tags.filter((tag) => tags.has(tag))
const topics = shared.filter((tag) => !generalTags.has(tag)).length
return { post, score: topics ? topics * 3 + shared.length : 0 }
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score || b.post.date.localeCompare(a.post.date) || a.post.slug.localeCompare(b.post.slug))
.slice(0, limit)
.map(({ post }) => post)
}
7 changes: 6 additions & 1 deletion src/pages/PostPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ function rehypeCodeBrFix() {
})(tree)
}
}
import { getPostBySlug, getAdjacentPosts } from "@/lib/posts"
import { getPostBySlug, getAdjacentPosts, getAllPosts } from "@/lib/posts"
import { selectRelatedPosts } from "@/lib/related-posts"
import { usePostContent } from "@/hooks/usePostData"
import { getReadingMinutes } from "@/lib/reading-time"
import { Alert, AlertDescription } from "@/components/ui/alert"
Expand All @@ -41,6 +42,7 @@ import { PageContainer } from "@/components/PageContainer"
import { TableOfContents } from "@/components/TableOfContents"
import { Comments } from "@/components/Comments"
import { SeriesNavigator } from "@/components/SeriesNavigator"
import { RelatedPosts } from "@/components/RelatedPosts"
import { AlertCircle, ArrowLeft, ArrowRight, Clock, Eye } from "lucide-react"
import { useMetaTags } from "@/hooks/useMetaTags"
import { usePageViews } from "@/hooks/usePageViews"
Expand Down Expand Up @@ -100,6 +102,7 @@ export function PostPage() {
const post = slug ? getPostBySlug(slug, language) : undefined
const fallbackPost = !post && slug && language === "en" ? getPostBySlug(slug, "ko") : undefined
const { prev, next } = slug && post ? getAdjacentPosts(slug, language) : { prev: null, next: null }
const relatedPosts = post ? selectRelatedPosts(post, getAllPosts(language), [prev?.slug, next?.slug].filter((slug): slug is string => !!slug)) : []
const { getPostViews } = usePageViews()
const views = slug ? getPostViews(slug, language) : null
const availableLanguages = post?.availableLanguages ?? fallbackPost?.availableLanguages ?? []
Expand Down Expand Up @@ -280,6 +283,8 @@ export function PostPage() {
</ReactMarkdown>}
</div>

<RelatedPosts posts={relatedPosts} />

{(prev || next) && (
<>
<Separator className="my-10" />
Expand Down
19 changes: 19 additions & 0 deletions tests/browser/reader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ test.beforeEach(async ({ page }) => {
})
})

for (const language of ["ko", "en"]) {
test(`related article links preserve ${language} through hydration and navigation`, async ({ page }) => {
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
page.on("console", message => { if (/hydration|React error #418/i.test(message.text())) errors.push(message.text()) })
const response = await page.goto(`${language === "en" ? "/en" : ""}/posts/spring-ai-pipeline-real-world/`)
expect(await response!.text()).toContain('aria-labelledby="related-posts-heading"')
const section = page.getByRole("region", { name: language === "en" ? "Related Posts" : "관련 글", exact: true })
const link = section.getByRole("link").first()
const title = await link.locator("span").textContent()
const href = await link.getAttribute("href")
expect(href).toMatch(language === "en" ? /^\/en\/posts\/[^/]+\/$/ : /^\/posts\/[^/]+\/$/)
await link.click()
await expect(page).toHaveURL(new RegExp(`${href}$`))
await expect(page.locator("article h1")).toHaveText(title!)
expect(errors).toEqual([])
})
}

for (const url of ["/posts/?q=JSON-RPC", "/tags/?tag=java", "/series/?q=인증", "/posts/missing-post/", "/en/missing-page/"]) {
test(`direct URL hydrates without errors: ${url}`, async ({ page }) => {
const errors: string[] = []
Expand Down
Loading