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
1 change: 1 addition & 0 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const notFoundRoute = {
path: "/404/",
title: "404",
description: "요청한 페이지를 찾을 수 없습니다.",
noindex: true,
}
const notFoundHtml = injectAppHtml(withHead(template, notFoundRoute), await render(notFoundRoute.path))
fs.writeFileSync(path.resolve(distDir, "404.html"), notFoundHtml)
Expand Down
92 changes: 92 additions & 0 deletions scripts/sitemap-lastmod.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,39 @@ function parseSitemapEntries() {
return [...sitemap.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((match) => ({
loc: match[1].match(/<loc>(.*?)<\/loc>/)?.[1],
lastmod: match[1].match(/<lastmod>(.*?)<\/lastmod>/)?.[1],
alternates: [...match[1].matchAll(/<xhtml:link rel="alternate" hreflang="([^"]+)" href="([^"]+)"\/>/g)]
.map((alternate) => ({ hreflang: alternate[1], href: alternate[2] })),
}))
}

function readPrerenderedProjectSlugs(language) {
const prefix = language === "en" ? ["en"] : []
const projectsDir = path.join(rootDir, "dist", ...prefix, "about", "projects")
return fs.readdirSync(projectsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(projectsDir, entry.name, "index.html")))
.map((entry) => entry.name)
.sort()
}

function projectUrl(slug, language) {
const prefix = language === "en" ? "/en" : ""
return `${baseUrl}${prefix}/about/projects/${slug}/`
}

function readPrerenderedProjectHtml(slug, language) {
const prefix = language === "en" ? ["en"] : []
return fs.readFileSync(path.join(rootDir, "dist", ...prefix, "about", "projects", slug, "index.html"), "utf8")
}

function htmlText(value) {
return value
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim()
}

function postUrl(post) {
const prefix = post.language === "en" ? "/en" : ""
return `${baseUrl}${prefix}/posts/${post.slug}/`
Expand Down Expand Up @@ -82,6 +112,68 @@ test("static and aggregate pages omit unreliable sitemap lastmod", () => {
}
})

test("all localized project pages are linked and included in sitemap with reciprocal alternates", () => {
const entries = new Map(parseSitemapEntries().map((entry) => [entry.loc, entry]))
const koSlugs = readPrerenderedProjectSlugs("ko")
const enSlugs = readPrerenderedProjectSlugs("en")

assert.ok(koSlugs.length > 0, "missing prerendered Korean project pages")
assert.deepEqual(enSlugs, koSlugs, "Korean and English project routes differ")

for (const slug of koSlugs) {
const koUrl = projectUrl(slug, "ko")
const enUrl = projectUrl(slug, "en")
const expectedAlternates = [
{ hreflang: "ko-KR", href: koUrl },
{ hreflang: "en", href: enUrl },
{ hreflang: "x-default", href: koUrl },
]

for (const url of [koUrl, enUrl]) {
const entry = entries.get(url)
assert.ok(entry, `missing sitemap entry for ${url}`)
assert.equal(entry.lastmod, undefined, `unexpected lastmod for ${url}`)
assert.deepEqual(entry.alternates, expectedAlternates, `wrong alternates for ${url}`)
}
}

for (const language of ["ko", "en"]) {
const prefix = language === "en" ? ["en"] : []
const aboutHtml = fs.readFileSync(path.join(rootDir, "dist", ...prefix, "about", "index.html"), "utf8")
for (const slug of koSlugs) {
const expectedPath = language === "en"
? `/en/about/projects/${slug}/`
: `/about/projects/${slug}/`
assert.match(aboutHtml, new RegExp(`href="${expectedPath}"`), `missing About link to ${expectedPath}`)
}
}
})

test("localized project HTML has matching language, title, canonical, and alternates", () => {
for (const language of ["ko", "en"]) {
for (const slug of readPrerenderedProjectSlugs(language)) {
const html = readPrerenderedProjectHtml(slug, language)
const url = projectUrl(slug, language)
const title = htmlText(html.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? "")
const heading = htmlText(html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)?.[1] ?? "")

assert.match(html, new RegExp(`<html lang="${language}"`), `wrong HTML language for ${url}`)
assert.ok(heading, `missing project heading for ${url}`)
assert.equal(title, `${heading} | Devy Archive`, `title and rendered project name differ for ${url}`)
assert.match(html, /<meta name="robots" content="index, follow"/, `project is not indexable: ${url}`)
assert.match(html, new RegExp(`<link rel="canonical" href="${url}"`), `wrong canonical for ${url}`)
assert.match(html, /hreflang="ko-KR"/, `missing Korean alternate for ${url}`)
assert.match(html, /hreflang="en"/, `missing English alternate for ${url}`)
assert.match(html, /hreflang="x-default"/, `missing x-default alternate for ${url}`)
}
}
})

test("404 fallback is explicitly excluded from indexing", () => {
const html = fs.readFileSync(path.join(rootDir, "dist", "404.html"), "utf8")
assert.match(html, /<meta name="robots" content="noindex, nofollow"/)
})

test("post JSON-LD uses date for publication and updated for modification", () => {
for (const post of posts) {
const jsonLd = readBlogPostingJsonLd(post)
Expand Down
130 changes: 130 additions & 0 deletions src/components/LanguageSuggestionBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useEffect, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import { Languages } from "lucide-react"
import { Button } from "@/components/ui/button"
import { en } from "@/i18n/translations"
import { analytics } from "@/lib/analytics"
import {
getRouteLanguage,
getStoredLanguage,
localizePath,
prefersEnglishBrowser,
setStoredLanguage,
stripLanguagePrefix,
} from "@/lib/i18n-routing"

const SESSION_DISMISS_KEY = "language-suggestion-dismissed"
const englishPostFiles = import.meta.glob("/content/posts/en/*.md", {
query: "?raw",
import: "default",
})
const localizedStaticPaths = new Set(["/", "/posts", "/tags", "/series", "/analytics", "/about", "/privacy"])

function isSessionDismissed() {
try {
return window.sessionStorage.getItem(SESSION_DISMISS_KEY) === "true"
} catch {
return false
}
}

function dismissForSession() {
try {
window.sessionStorage.setItem(SESSION_DISMISS_KEY, "true")
} catch {
// Restricted browser storage should not prevent dismissing the current render.
}
}

async function hasEnglishAlternative(pathname: string) {
if (getRouteLanguage(pathname) === "en") return false

const normalizedPath = stripLanguagePrefix(pathname).replace(/\/+$/, "") || "/"
if (localizedStaticPaths.has(normalizedPath)) return true

const projectSlug = normalizedPath.match(/^\/about\/projects\/([^/]+)$/)?.[1]
if (projectSlug) {
const { getResumeData } = await import("@/data/resume-i18n")
return getResumeData("en").projects.some((project) => project.slug === decodeURIComponent(projectSlug))
}

const postSlug = normalizedPath.match(/^\/posts\/([^/]+)$/)?.[1]
if (!postSlug) return false

try {
return Boolean(englishPostFiles[`/content/posts/en/${decodeURIComponent(postSlug)}.md`])
} catch {
return false
}
}

export function LanguageSuggestionBanner() {
const location = useLocation()
const navigate = useNavigate()
const [visible, setVisible] = useState(false)
const copy = en.components

useEffect(() => {
let active = true

async function updateVisibility() {
if (isSessionDismissed()) {
if (active) setVisible(false)
return
}

const storedLanguage = getStoredLanguage()
const prefersEnglish = storedLanguage ? storedLanguage === "en" : prefersEnglishBrowser()
const hasAlternative = prefersEnglish && await hasEnglishAlternative(location.pathname)
if (active) setVisible(hasAlternative)
}

void updateVisibility()
return () => {
active = false
}
}, [location.pathname])

if (!visible) return null

function viewInEnglish() {
setStoredLanguage("en")
analytics.changeLanguage("en")
setVisible(false)
navigate(
localizePath(`${location.pathname}${location.search}${location.hash}`, "en"),
{ viewTransition: true },
)
}

function dismiss() {
dismissForSession()
setVisible(false)
}

return (
<section
data-nosnippet
aria-label={copy.languageSuggestionTitle}
className="border-y border-blue-200/70 bg-blue-50/80 px-4 py-3 dark:border-blue-900/70 dark:bg-blue-950/40 md:px-20"
>
<div className="mx-auto flex max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3 sm:items-center">
<Languages className="mt-0.5 size-4 shrink-0 text-primary sm:mt-0" aria-hidden="true" />
<div className="min-w-0">
<p className="font-medium">{copy.languageSuggestionTitle}</p>
<p className="text-sm text-muted-foreground">{copy.languageSuggestionDescription}</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" size="sm" className="flex-1 sm:flex-none" onClick={viewInEnglish}>
{copy.viewInEnglish}
</Button>
<Button type="button" size="sm" variant="outline" className="flex-1 bg-background sm:flex-none" onClick={dismiss}>
{copy.notNow}
</Button>
</div>
</div>
</section>
)
}
85 changes: 40 additions & 45 deletions src/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { renderToPipeableStream } from "react-dom/server"
import { PassThrough } from "node:stream"
import { AppProviders } from "./app-shell"
import { createServerRoutes } from "./routes.server"
import { PROJECTS } from "./data/resume"
import { getResumeData } from "./data/resume-i18n"
import type { ProjectDetail } from "./data/resume"
import { getAllPosts, getPostBySlug } from "./lib/posts"
import { getPostModifiedDate } from "./lib/post-dates"
import { getRouteLanguage, localizePath, postPath } from "./lib/i18n-routing"
Expand Down Expand Up @@ -180,9 +181,45 @@ function localizedStaticRoutes(language: Language, posts: PostMeta[]): Prerender
]
}

function localizedProjectRoutes(language: Language, projects: ProjectDetail[]): PrerenderRoute[] {
const isEnglish = language === "en"

return projects.map((project) => {
const path = localizePath(`/about/projects/${project.slug}`, language)
const description = `${project.company} — ${project.name}`

return {
path,
language,
title: project.name,
description,
alternates: {
ko: localizePath(`/about/projects/${project.slug}`, "ko"),
en: localizePath(`/about/projects/${project.slug}`, "en"),
},
jsonLd: {
"@context": "https://schema.org",
"@type": "WebPage",
name: project.name,
description,
url: `${BASE_URL}${path}`,
inLanguage: isEnglish ? "en" : "ko-KR",
mainEntity: {
"@type": "CreativeWork",
name: project.name,
description: project.tasks.map((task) => task.content).join(" "),
dateCreated: project.period,
},
},
}
})
}

export function getPrerenderRoutes(): PrerenderRoute[] {
const koPosts = getAllPosts("ko")
const enPosts = getAllPosts("en")
const koProjects = getResumeData("ko").projects
const enProjects = getResumeData("en").projects

return [
...localizedStaticRoutes("ko", koPosts),
Expand All @@ -203,50 +240,8 @@ export function getPrerenderRoutes(): PrerenderRoute[] {
description: "GitHub 로그인 처리 중입니다.",
noindex: true,
},
...PROJECTS.map((project) => ({
path: `/about/projects/${project.slug}/`,
language: "ko" as const,
title: project.name,
description: `${project.company} - ${project.name}`,
jsonLd: {
"@context": "https://schema.org",
"@type": "WebPage",
name: project.name,
description: `${project.company} - ${project.name}`,
url: `${BASE_URL}/about/projects/${project.slug}/`,
inLanguage: "ko-KR",
mainEntity: {
"@type": "CreativeWork",
name: project.name,
description: project.tasks.map((task) => task.content).join(" "),
dateCreated: project.period,
},
},
})),
...PROJECTS.map((project) => ({
path: `/en/about/projects/${project.slug}/`,
language: "en" as const,
title: project.name,
description: `${project.company} - ${project.name}`,
alternates: {
ko: `/about/projects/${project.slug}/`,
en: `/en/about/projects/${project.slug}/`,
},
jsonLd: {
"@context": "https://schema.org",
"@type": "WebPage",
name: project.name,
description: `${project.company} - ${project.name}`,
url: `${BASE_URL}/en/about/projects/${project.slug}/`,
inLanguage: "en",
mainEntity: {
"@type": "CreativeWork",
name: project.name,
description: project.tasks.map((task) => task.content).join(" "),
dateCreated: project.period,
},
},
})),
...localizedProjectRoutes("ko", koProjects),
...localizedProjectRoutes("en", enProjects),
...koPosts.map((post) => {
const fullPost = getPostBySlug(post.slug, "ko")
const articleBody = fullPost ? markdownToText(fullPost.content).slice(0, 5000) : ""
Expand Down
Loading
Loading