Skip to content

Commit 3bf9f69

Browse files
authored
fix(seo): 본문 보존과 구조화 데이터 갱신 개선 (#222)
초기 스크립트 실패 시 프리렌더 본문을 보존하고 페이지 이동에 맞춰 구조화 데이터를 갱신한다. 사이트맵 전체 URL의 SEO 검사를 CI와 배포 과정에 추가한다.
1 parent f90837e commit 3bf9f69

13 files changed

Lines changed: 246 additions & 120 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ jobs:
3131
- run: pnpm type-check
3232
- run: pnpm test:post-dates
3333
- run: pnpm build
34+
- run: pnpm test:seo
3435
- run: pnpm test:loading
3536
- run: pnpm test:sitemap-lastmod

‎.github/workflows/deploy.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131

3232
- run: pnpm install --frozen-lockfile
3333
- run: pnpm build
34+
- run: pnpm test:seo
3435

3536
- uses: actions/upload-pages-artifact@v3
3637
with:

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"check:internal-links": "node scripts/check-internal-post-links.mjs",
1414
"test:post-dates": "node --test scripts/post-dates.test.mjs",
1515
"test:loading": "node --test scripts/async-loader.test.mjs scripts/loading.test.mjs",
16+
"test:seo": "node --test scripts/seo.test.mjs",
1617
"test:sitemap-lastmod": "node --test scripts/sitemap-lastmod.test.mjs"
1718
},
1819
"dependencies": {

‎scripts/prerender.mjs‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@ function withHead(templateHtml, route) {
8181
}
8282
}
8383

84-
if (route.jsonLd) {
85-
html = html.replace("</head>", ` <script type="application/ld+json">${safeJsonLd(route.jsonLd)}</script>\n </head>`)
86-
}
87-
8884
return html
8985
}
9086

‎scripts/seo.test.mjs‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import assert from "node:assert/strict"
2+
import fs from "node:fs"
3+
import path from "node:path"
4+
import test from "node:test"
5+
import { fileURLToPath } from "node:url"
6+
7+
const dist = fileURLToPath(new URL("../dist/", import.meta.url))
8+
const origin = "https://dev.devy.dev"
9+
const sitemap = fs.readFileSync(path.join(dist, "sitemap.xml"), "utf8")
10+
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1])
11+
const pages = new Map(urls.map((url) => [url, fs.readFileSync(path.join(dist, new URL(url).pathname, "index.html"), "utf8")]))
12+
13+
function schemas(html) {
14+
return [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)]
15+
.map((match) => JSON.parse(match[1]))
16+
}
17+
18+
function alternates(html) {
19+
return [...html.matchAll(/<link rel="alternate" hreflang="([^"]+)" href="([^"]+)"/g)]
20+
.map((match) => ({ language: match[1], url: match[2] }))
21+
}
22+
23+
function text(html) {
24+
return html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").trim()
25+
}
26+
27+
test("every sitemap URL has indexable HTML with one matching canonical and language", () => {
28+
assert.ok(urls.length > 0)
29+
assert.equal(new Set(urls).size, urls.length)
30+
for (const [url, html] of pages) {
31+
const pathname = new URL(url).pathname
32+
assert.equal(new URL(url).origin, origin)
33+
assert.ok(pathname.endsWith("/"), url)
34+
assert.deepEqual([...html.matchAll(/<link rel="canonical" href="([^"]+)"/g)].map((m) => m[1]), [url], url)
35+
assert.match(html, /<meta name="robots" content="index, follow"/, url)
36+
assert.ok(!/<meta name="robots" content="[^"]*noindex/.test(html), url)
37+
assert.ok(html.includes(`<html lang="${pathname.startsWith("/en/") ? "en" : "ko"}"`), url)
38+
assert.match(html, /<h1\b/, url)
39+
assert.ok(!html.includes('id="S:'), `unfinished Suspense segment: ${url}`)
40+
}
41+
})
42+
43+
test("language alternatives resolve to reciprocal, indexable pages", () => {
44+
for (const [url, html] of pages) {
45+
for (const alternate of alternates(html)) {
46+
assert.ok(pages.has(alternate.url), `missing alternate ${alternate.url} on ${url}`)
47+
assert.ok(alternates(pages.get(alternate.url)).some((entry) => entry.url === url), `non-reciprocal alternate ${alternate.url} on ${url}`)
48+
}
49+
}
50+
})
51+
52+
test("all published articles include visible content and matching React-owned structured data", () => {
53+
const articles = [...pages].filter(([url]) => /\/(?:en\/)?posts\/[^/]+\/$/.test(new URL(url).pathname))
54+
assert.ok(articles.length > 0)
55+
for (const [url, html] of articles) {
56+
const documents = schemas(html)
57+
assert.equal(documents.length, 1, url)
58+
const article = documents[0]
59+
assert.equal(article["@type"], "BlogPosting", url)
60+
assert.equal(article.url, url)
61+
assert.equal(article.mainEntityOfPage["@id"], url)
62+
assert.equal(article.image, html.match(/<meta property="og:image" content="([^"]+)"/)?.[1], `article and Open Graph image differ: ${url}`)
63+
assert.equal(article.inLanguage, new URL(url).pathname.startsWith("/en/") ? "en" : "ko-KR", url)
64+
assert.equal(article.headline, text(html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/)?.[1] ?? ""), url)
65+
assert.ok(article.articleBody.trim().length > 0, `empty article schema: ${url}`)
66+
assert.ok(pages.has(article.author.url), `missing author page: ${url}`)
67+
assert.ok(!schemas(html.split("</head>")[0]).length, `unmanaged head schema: ${url}`)
68+
assert.match(html, /<div class="prose[^\"]*">\s*<(?:p|h[1-6]|blockquote|ul|ol)/, `missing rendered article body: ${url}`)
69+
assert.match(html, /id="post-hydration-data"/, url)
70+
assert.ok(!html.includes("본문을 불러오는 중"), url)
71+
}
72+
})
73+
74+
test("home, collection, and project metadata is rendered by the active React page", () => {
75+
for (const [url, html] of pages) {
76+
const pathname = new URL(url).pathname
77+
const expected = /^\/(en\/)?$/.test(pathname) ? "Blog"
78+
: /^\/(en\/)?posts\/$/.test(pathname) ? "CollectionPage"
79+
: pathname.includes("/about/projects/") ? "WebPage" : null
80+
if (!expected) continue
81+
const documents = schemas(html)
82+
assert.equal(documents.length, 1, url)
83+
assert.equal(documents[0]["@type"], expected, url)
84+
assert.equal(documents[0].url, url)
85+
assert.equal(documents[0].inLanguage, pathname.startsWith("/en/") ? "en" : "ko-KR", url)
86+
assert.ok(!schemas(html.split("</head>")[0]).length, `unmanaged head schema: ${url}`)
87+
}
88+
})
89+
90+
test("non-indexable prerendered routes do not publish article structured data", () => {
91+
function visit(directory) {
92+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
93+
const file = path.join(directory, entry.name)
94+
if (entry.isDirectory()) visit(file)
95+
else if (entry.name.endsWith(".html")) {
96+
const html = fs.readFileSync(file, "utf8")
97+
if (/<meta name="robots" content="[^"]*noindex/.test(html)) {
98+
assert.ok(!schemas(html).some((schema) => schema["@type"] === "BlogPosting"), file)
99+
}
100+
}
101+
}
102+
}
103+
visit(dist)
104+
assert.match(fs.readFileSync(path.join(dist, "404.html"), "utf8"), /<meta name="robots" content="noindex, nofollow"/)
105+
})

‎src/components/StructuredData.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function StructuredData({ data }: { data: Record<string, unknown> }) {
2+
return (
3+
<script
4+
type="application/ld+json"
5+
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
6+
/>
7+
)
8+
}

‎src/entry-server.tsx‎

Lines changed: 4 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@ import { AppProviders } from "./app-shell"
55
import { createServerRoutes } from "./routes.server"
66
import { getResumeData } from "./data/resume-i18n"
77
import type { ProjectDetail } from "./data/resume"
8-
import { getAllPosts, getPostBySlug } from "./lib/posts"
9-
import { getPostModifiedDate } from "./lib/post-dates"
8+
import { getAllPosts } from "./lib/posts"
109
import { getRouteLanguage, localizePath, postPath } from "./lib/i18n-routing"
1110
import type { Language } from "./i18n"
12-
import type { PostMeta } from "./types/post"
1311
export { preparePostContentForPrerender, getPostHydrationData } from "./lib/posts"
1412

1513
export interface PrerenderRoute {
@@ -21,40 +19,18 @@ export interface PrerenderRoute {
2119
type?: "website" | "article"
2220
date?: string
2321
tags?: string[]
24-
articleBody?: string
2522
language?: Language
2623
noindex?: boolean
2724
canonicalPath?: string
2825
alternates?: Partial<Record<Language, string>>
29-
jsonLd?: Record<string, unknown>
30-
}
31-
32-
const BASE_URL = "https://dev.devy.dev"
33-
const OG_IMAGE_URL = `${BASE_URL}/og-image.png?v=20260922-5`
34-
35-
function markdownToText(md: string) {
36-
return md
37-
.replace(/```[\s\S]*?```/g, "")
38-
.replace(/`([^`]+)`/g, "$1")
39-
.replace(/!\[.*?\]\(.*?\)/g, "")
40-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
41-
.replace(/#{1,6}\s+/g, "")
42-
.replace(/\*\*([^*]+)\*\*/g, "$1")
43-
.replace(/\*([^*]+)\*/g, "$1")
44-
.replace(/^\s*[-*+]\s+/gm, "")
45-
.replace(/^\s*\d+\.\s+/gm, "")
46-
.replace(/^\s*>/gm, "")
47-
.replace(/---/g, "")
48-
.replace(/\n{3,}/g, "\n\n")
49-
.trim()
5026
}
5127

5228
function toCanonicalPath(path: string) {
5329
if (path === "/") return "/"
5430
return path.endsWith("/") ? path : `${path}/`
5531
}
5632

57-
function localizedStaticRoutes(language: Language, posts: PostMeta[]): PrerenderRoute[] {
33+
function localizedStaticRoutes(language: Language): PrerenderRoute[] {
5834
const isEnglish = language === "en"
5935
const path = (basePath: string) => toCanonicalPath(localizePath(basePath, language))
6036
const homeDescription = isEnglish
@@ -73,23 +49,6 @@ function localizedStaticRoutes(language: Language, posts: PostMeta[]): Prerender
7349
ko: "/",
7450
en: "/en/",
7551
},
76-
jsonLd: {
77-
"@context": "https://schema.org",
78-
"@type": "Blog",
79-
name: "Devy Archive",
80-
description: homeDescription,
81-
url: `${BASE_URL}${path("/") === "/" ? "" : path("/")}`,
82-
inLanguage: isEnglish ? "en" : "ko-KR",
83-
author: { "@type": "Person", name: "Devy" },
84-
blogPost: posts.slice(0, 10).map((post) => ({
85-
"@type": "BlogPosting",
86-
headline: post.title,
87-
description: post.description,
88-
datePublished: post.date,
89-
dateModified: getPostModifiedDate(post),
90-
url: `${BASE_URL}${postPath(post.slug, language)}`,
91-
})),
92-
},
9352
},
9453
{
9554
path: path("/posts/"),
@@ -100,22 +59,6 @@ function localizedStaticRoutes(language: Language, posts: PostMeta[]): Prerender
10059
ko: "/posts/",
10160
en: "/en/posts/",
10261
},
103-
jsonLd: {
104-
"@context": "https://schema.org",
105-
"@type": "CollectionPage",
106-
name: isEnglish ? "Posts" : "글 목록",
107-
description: postsDescription,
108-
url: `${BASE_URL}${path("/posts/")}`,
109-
mainEntity: {
110-
"@type": "ItemList",
111-
itemListElement: posts.map((post, index) => ({
112-
"@type": "ListItem",
113-
position: index + 1,
114-
url: `${BASE_URL}${postPath(post.slug, language)}`,
115-
name: post.title,
116-
})),
117-
},
118-
},
11962
},
12063
{
12164
path: path("/tags/"),
@@ -183,8 +126,6 @@ function localizedStaticRoutes(language: Language, posts: PostMeta[]): Prerender
183126
}
184127

185128
function localizedProjectRoutes(language: Language, projects: ProjectDetail[]): PrerenderRoute[] {
186-
const isEnglish = language === "en"
187-
188129
return projects.map((project) => {
189130
const path = localizePath(`/about/projects/${project.slug}`, language)
190131
const description = `${project.company} — ${project.name}`
@@ -198,20 +139,6 @@ function localizedProjectRoutes(language: Language, projects: ProjectDetail[]):
198139
ko: localizePath(`/about/projects/${project.slug}`, "ko"),
199140
en: localizePath(`/about/projects/${project.slug}`, "en"),
200141
},
201-
jsonLd: {
202-
"@context": "https://schema.org",
203-
"@type": "WebPage",
204-
name: project.name,
205-
description,
206-
url: `${BASE_URL}${path}`,
207-
inLanguage: isEnglish ? "en" : "ko-KR",
208-
mainEntity: {
209-
"@type": "CreativeWork",
210-
name: project.name,
211-
description: project.tasks.map((task) => task.content).join(" "),
212-
dateCreated: project.period,
213-
},
214-
},
215142
}
216143
})
217144
}
@@ -223,8 +150,8 @@ export function getPrerenderRoutes(): PrerenderRoute[] {
223150
const enProjects = getResumeData("en").projects
224151

225152
return [
226-
...localizedStaticRoutes("ko", koPosts),
227-
...localizedStaticRoutes("en", enPosts),
153+
...localizedStaticRoutes("ko"),
154+
...localizedStaticRoutes("en"),
228155
// 어드민 진입 화면은 클라이언트 전용 동작이지만, 정적 셸을 프리렌더해서
229156
// SPA fallback(404.html) hydration 불일치를 피한다. 색인은 막는다(noindex).
230157
{
@@ -244,8 +171,6 @@ export function getPrerenderRoutes(): PrerenderRoute[] {
244171
...localizedProjectRoutes("ko", koProjects),
245172
...localizedProjectRoutes("en", enProjects),
246173
...koPosts.map((post) => {
247-
const fullPost = getPostBySlug(post.slug, "ko")
248-
const articleBody = fullPost ? markdownToText(fullPost.content).slice(0, 5000) : ""
249174
const alternates: Partial<Record<Language, string>> = { ko: postPath(post.slug, "ko") }
250175
if (post.availableLanguages.includes("en")) alternates.en = postPath(post.slug, "en")
251176

@@ -257,29 +182,11 @@ export function getPrerenderRoutes(): PrerenderRoute[] {
257182
type: "article" as const,
258183
date: post.date,
259184
tags: post.tags,
260-
articleBody,
261185
alternates,
262-
jsonLd: {
263-
"@context": "https://schema.org",
264-
"@type": "BlogPosting",
265-
headline: post.title,
266-
description: post.description,
267-
datePublished: post.date,
268-
dateModified: getPostModifiedDate(post),
269-
url: `${BASE_URL}/posts/${post.slug}/`,
270-
image: OG_IMAGE_URL,
271-
author: { "@type": "Person", name: "Devy" },
272-
publisher: { "@type": "Organization", name: "Devy Archive" },
273-
mainEntityOfPage: { "@type": "WebPage", "@id": `${BASE_URL}/posts/${post.slug}/` },
274-
articleBody,
275-
...(post.tags.length > 0 ? { keywords: post.tags.join(", ") } : {}),
276-
},
277186
}
278187
}),
279188
...koPosts.map((koPost) => {
280189
const post = enPosts.find((candidate) => candidate.slug === koPost.slug)
281-
const fullPost = post ? getPostBySlug(post.slug, "en") : undefined
282-
const articleBody = fullPost ? markdownToText(fullPost.content).slice(0, 5000) : ""
283190
const path = postPath(koPost.slug, "en")
284191
const alternates: Partial<Record<Language, string>> = { ko: postPath(koPost.slug, "ko") }
285192
if (post) alternates.en = path
@@ -304,24 +211,7 @@ export function getPrerenderRoutes(): PrerenderRoute[] {
304211
type: "article" as const,
305212
date: post.date,
306213
tags: post.tags,
307-
articleBody,
308214
alternates,
309-
jsonLd: {
310-
"@context": "https://schema.org",
311-
"@type": "BlogPosting",
312-
headline: post.title,
313-
description: post.description,
314-
datePublished: post.date,
315-
dateModified: getPostModifiedDate(post),
316-
url: `${BASE_URL}${toCanonicalPath(path)}`,
317-
image: OG_IMAGE_URL,
318-
author: { "@type": "Person", name: "Devy" },
319-
publisher: { "@type": "Organization", name: "Devy Archive" },
320-
mainEntityOfPage: { "@type": "WebPage", "@id": `${BASE_URL}${toCanonicalPath(path)}` },
321-
articleBody,
322-
inLanguage: "en",
323-
...(post.tags.length > 0 ? { keywords: post.tags.join(", ") } : {}),
324-
},
325215
}
326216
}),
327217
].map((route) => ({ ...route, path: toCanonicalPath(route.path) }))

0 commit comments

Comments
 (0)