From d5673bffcf024b01d21f6fea25751911061d34d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Tue, 13 Oct 2026 10:57:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(seo):=20=EA=B4=80=EB=A0=A8=20=EA=B8=80=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=EB=A7=81=ED=81=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/seo.test.mjs | 40 +++++++++++++++++++++++++++++++++ src/components/RelatedPosts.tsx | 30 +++++++++++++++++++++++++ src/i18n/translations.ts | 3 +++ src/lib/related-posts.ts | 36 +++++++++++++++++++++++++++++ src/pages/PostPage.tsx | 7 +++++- tests/browser/reader.spec.ts | 19 ++++++++++++++++ 6 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/components/RelatedPosts.tsx create mode 100644 src/lib/related-posts.ts diff --git a/scripts/seo.test.mjs b/scripts/seo.test.mjs index 0e3bf45..aaad1bf 100644 --- a/scripts/seo.test.mjs +++ b/scripts/seo.test.mjs @@ -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" @@ -24,6 +25,45 @@ function text(html) { return html.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/"/g, '"').replace(/'|'/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(/
]*>([\s\S]*?)<\/section>/)?.[1] + if (!section) continue + sections++ + const hrefs = [...section.matchAll(/]*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) diff --git a/src/components/RelatedPosts.tsx b/src/components/RelatedPosts.tsx new file mode 100644 index 0000000..8c25b9e --- /dev/null +++ b/src/components/RelatedPosts.tsx @@ -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 ( +
+ + +
    + {posts.map((post) => ( +
  • + + {post.title} + {post.description &&

    {post.description}

    } + +
  • + ))} +
+
+ ) +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 81b3ed2..28ef8a2 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -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 @@ -291,6 +292,7 @@ export const ko: Translations = { backToList: "목록으로", prevPost: "이전 글", nextPost: "다음 글", + relatedPosts: "관련 글", readingTime: (minutes) => `${minutes}분 읽기`, views: "조회", updatedAt: (date) => `수정 ${date}`, @@ -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}`, diff --git a/src/lib/related-posts.ts b/src/lib/related-posts.ts new file mode 100644 index 0000000..894c78e --- /dev/null +++ b/src/lib/related-posts.ts @@ -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) +} diff --git a/src/pages/PostPage.tsx b/src/pages/PostPage.tsx index 15f7dfe..3794468 100644 --- a/src/pages/PostPage.tsx +++ b/src/pages/PostPage.tsx @@ -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" @@ -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" @@ -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 ?? [] @@ -280,6 +283,8 @@ export function PostPage() { } + + {(prev || next) && ( <> diff --git a/tests/browser/reader.spec.ts b/tests/browser/reader.spec.ts index e63cb21..47b702d 100644 --- a/tests/browser/reader.spec.ts +++ b/tests/browser/reader.spec.ts @@ -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[] = []