From be2ea5de64f2cb19385a3186c1867c0e84c1c429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Fri, 21 Aug 2026 16:05:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(seo):=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=EC=83=89=EC=9D=B8=20=EC=8B=A0=ED=98=B8=EC=99=80=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=20=EC=95=88=EB=82=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/prerender.mjs | 1 + scripts/sitemap-lastmod.test.mjs | 92 ++++++++++++++ src/components/LanguageSuggestionBanner.tsx | 130 ++++++++++++++++++++ src/entry-server.tsx | 85 ++++++------- src/i18n/translations.ts | 15 +++ src/layouts/RootLayout.tsx | 55 +-------- src/lib/i18n-routing.ts | 9 ++ src/pages/AboutPage.tsx | 72 ++++++----- src/pages/NotFoundPage.tsx | 2 +- src/pages/ProjectDetailPage.tsx | 5 + vite.config.ts | 14 +++ 11 files changed, 350 insertions(+), 130 deletions(-) create mode 100644 src/components/LanguageSuggestionBanner.tsx diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 6e480fe..f6b853a 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -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) diff --git a/scripts/sitemap-lastmod.test.mjs b/scripts/sitemap-lastmod.test.mjs index ae5de5b..5e3078f 100644 --- a/scripts/sitemap-lastmod.test.mjs +++ b/scripts/sitemap-lastmod.test.mjs @@ -38,9 +38,39 @@ function parseSitemapEntries() { return [...sitemap.matchAll(/([\s\S]*?)<\/url>/g)].map((match) => ({ loc: match[1].match(/(.*?)<\/loc>/)?.[1], lastmod: match[1].match(/(.*?)<\/lastmod>/)?.[1], + alternates: [...match[1].matchAll(//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(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim() +} + function postUrl(post) { const prefix = post.language === "en" ? "/en" : "" return `${baseUrl}${prefix}/posts/${post.slug}/` @@ -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(/([\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) diff --git a/src/components/LanguageSuggestionBanner.tsx b/src/components/LanguageSuggestionBanner.tsx new file mode 100644 index 0000000..3295d0f --- /dev/null +++ b/src/components/LanguageSuggestionBanner.tsx @@ -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> + ) +} diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 0efd522..8777bbe 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -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" @@ -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), @@ -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) : "" diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index fe1a5a4..6fef920 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -178,6 +178,7 @@ export interface Translations { achievements: string certifications: string relatedPosts: string + projectDetails: string backToAbout: string } components: { @@ -203,6 +204,10 @@ export interface Translations { shortcutSearch: string shortcutSidebar: string shortcutHelp: string + languageSuggestionTitle: string + languageSuggestionDescription: string + viewInEnglish: string + notNow: string general: string } meta: { @@ -390,6 +395,7 @@ export const ko: Translations = { achievements: "성과", certifications: "자격증", relatedPosts: "관련 글", + projectDetails: "프로젝트 상세", backToAbout: "소개로 돌아가기", }, components: { @@ -415,6 +421,10 @@ export const ko: Translations = { shortcutSearch: "검색 열기", shortcutHelp: "단축키 안내 열기", shortcutSidebar: "사이드바 토글", + languageSuggestionTitle: "영문 페이지도 제공됩니다.", + languageSuggestionDescription: "현재 페이지를 유지하면서 영어로 전환할 수 있습니다.", + viewInEnglish: "영어로 보기", + notNow: "나중에", general: "일반", }, meta: { @@ -602,6 +612,7 @@ export const en: Translations = { achievements: "Achievements", certifications: "Certifications", relatedPosts: "Related Posts", + projectDetails: "Project details", backToAbout: "Back to About", }, components: { @@ -627,6 +638,10 @@ export const en: Translations = { shortcutSearch: "Open search", shortcutHelp: "Open shortcuts guide", shortcutSidebar: "Toggle sidebar", + languageSuggestionTitle: "This page is available in English.", + languageSuggestionDescription: "Switch without losing your current page.", + viewInEnglish: "View in English", + notNow: "Not now", general: "General", }, meta: { diff --git a/src/layouts/RootLayout.tsx b/src/layouts/RootLayout.tsx index a942800..54cd551 100644 --- a/src/layouts/RootLayout.tsx +++ b/src/layouts/RootLayout.tsx @@ -1,20 +1,14 @@ import { useEffect, useRef } from "react" -import { Outlet, useLocation, useNavigate } from "react-router-dom" +import { Outlet, useLocation } from "react-router-dom" import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar" import { AppSidebar } from "@/components/Sidebar" import { ScrollToTop } from "@/components/ScrollToTop" import { SearchCommand } from "@/components/SearchCommand" import { ScrollToTopButton } from "@/components/ScrollToTopButton" import { Confetti } from "@/components/Confetti" +import { LanguageSuggestionBanner } from "@/components/LanguageSuggestionBanner" import { trackPageView } from "@/lib/analytics" -import { - detectBrowserLanguage, - getRouteLanguage, - getStoredLanguage, - localizePath, - stripLanguagePrefix, -} from "@/lib/i18n-routing" -import { getPostBySlug } from "@/lib/posts" +import { getRouteLanguage } from "@/lib/i18n-routing" import { useLanguage } from "@/i18n" function RouteAnalytics() { @@ -47,54 +41,12 @@ function RouteLanguageSync() { return null } -function isAutoLocaleRedirectCandidate(pathname: string): boolean { - const normalizedPath = stripLanguagePrefix(pathname).replace(/\/+$/, "") || "/" - const staticPaths = new Set(["/", "/posts", "/tags", "/series", "/search", "/analytics", "/about", "/privacy"]) - - if (staticPaths.has(normalizedPath) || /^\/about\/projects\/[^/]+$/.test(normalizedPath)) { - return true - } - - const postSlug = normalizedPath.match(/^\/posts\/([^/]+)$/)?.[1] - if (!postSlug) return false - - try { - return Boolean(getPostBySlug(decodeURIComponent(postSlug), "en")) - } catch { - return false - } -} - -function AutoLocaleRedirect() { - const location = useLocation() - const navigate = useNavigate() - const lastRedirectTarget = useRef<string | null>(null) - - useEffect(() => { - if (getRouteLanguage(location.pathname) === "en") return - - const preferredLanguage = getStoredLanguage() ?? detectBrowserLanguage() - if (preferredLanguage !== "en") return - if (!isAutoLocaleRedirectCandidate(location.pathname)) return - - const target = `${localizePath(location.pathname, "en")}${location.search}${location.hash}` - if (target === `${location.pathname}${location.search}${location.hash}`) return - if (lastRedirectTarget.current === target) return - - lastRedirectTarget.current = target - navigate(target, { replace: true }) - }, [location.hash, location.pathname, location.search, navigate]) - - return null -} - export function RootLayout() { return ( <SidebarProvider> <ScrollToTop /> <RouteAnalytics /> <RouteLanguageSync /> - <AutoLocaleRedirect /> <Confetti /> <AppSidebar /> <SidebarInset> @@ -105,6 +57,7 @@ export function RootLayout() { <SearchCommand /> </div> </header> + <LanguageSuggestionBanner /> <main className="px-4 md:px-20 py-8"> <Outlet /> </main> diff --git a/src/lib/i18n-routing.ts b/src/lib/i18n-routing.ts index 9875f0f..0bb04fb 100644 --- a/src/lib/i18n-routing.ts +++ b/src/lib/i18n-routing.ts @@ -34,6 +34,15 @@ export function detectBrowserLanguage(): Language { return primaryLanguage?.startsWith("ko") ? "ko" : "en" } +export function prefersEnglishBrowser(): boolean { + if (typeof navigator === "undefined") return false + + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + const primaryLanguage = languages.find((language) => language.trim().length > 0)?.toLowerCase() + + return primaryLanguage?.startsWith("en") ?? false +} + export function getRouteLanguage(pathname: string): Language { const normalized = pathname.replace(/\/+$/, "") || "/" return normalized === "/en" || normalized.startsWith("/en/") ? "en" : "ko" diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index 457d92b..cee1184 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -1,5 +1,6 @@ import { useState } from "react" -import { Mail, Phone, ChevronDown, Download, Loader2, FileText, ExternalLink } from "lucide-react" +import { Link } from "react-router-dom" +import { Mail, Phone, ChevronDown, Download, Loader2, FileText, ExternalLink, ArrowRight } from "lucide-react" function Github({ className }: { className?: string }) { return ( @@ -32,7 +33,7 @@ function phoneHref(phone: string, language: Language) { import { useMetaTags } from "@/hooks/useMetaTags" import { Button } from "@/components/ui/button" import { PageContainer } from "@/components/PageContainer" -import { useLanguage } from "@/i18n" +import { useLanguage, useT } from "@/i18n" import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" @@ -57,6 +58,8 @@ function CompanySection({ projects: ProjectDetail[] language: Language }) { + const t = useT() + return ( <div className="grid grid-cols-1 md:grid-cols-[160px_1fr] gap-4 md:gap-8"> {/* Left: Company Info */} @@ -71,6 +74,8 @@ function CompanySection({ {company.projects.map((ps, index) => { const project = projects.find((p) => p.slug === ps.slug) if (!project) return null + const relatedPostData = project.relatedPosts?.map((slug) => getPostBySlug(slug, language)).filter(Boolean) ?? [] + const relatedLinks = project.relatedLinks ?? [] return ( <Collapsible @@ -121,37 +126,38 @@ function CompanySection({ </ul> </div> </CollapsibleContent> - {(() => { - const relatedPostData = project.relatedPosts?.map((slug) => getPostBySlug(slug, language)).filter(Boolean) ?? [] - const relatedLinks = project.relatedLinks ?? [] - if (relatedPostData.length === 0 && relatedLinks.length === 0) return null - return ( - <div className="border-t border-dashed px-3 py-2 space-y-1.5"> - {relatedPostData.map((rp) => ( - <a - key={rp!.slug} - href={postPath(rp!.slug, language)} - className="flex items-center gap-2 text-sm text-primary hover:underline" - > - <FileText className="h-3.5 w-3.5 shrink-0" /> - <span>{rp!.title}</span> - </a> - ))} - {relatedLinks.map((link) => ( - <a - key={link.url} - href={link.url} - target="_blank" - rel="noopener noreferrer" - className="flex items-center gap-2 text-sm text-primary hover:underline" - > - <ExternalLink className="h-3.5 w-3.5 shrink-0" /> - <span>{link.title}</span> - </a> - ))} - </div> - ) - })()} + <div className="border-t border-dashed px-3 py-2 space-y-1.5"> + <Link + to={localizePath(`/about/projects/${project.slug}`, language)} + viewTransition + className="flex items-center gap-2 text-sm font-medium text-primary hover:underline" + > + <ArrowRight className="h-3.5 w-3.5 shrink-0" /> + <span>{t.about.projectDetails}</span> + </Link> + {relatedPostData.map((rp) => ( + <a + key={rp!.slug} + href={postPath(rp!.slug, language)} + className="flex items-center gap-2 text-sm text-primary hover:underline" + > + <FileText className="h-3.5 w-3.5 shrink-0" /> + <span>{rp!.title}</span> + </a> + ))} + {relatedLinks.map((link) => ( + <a + key={link.url} + href={link.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-2 text-sm text-primary hover:underline" + > + <ExternalLink className="h-3.5 w-3.5 shrink-0" /> + <span>{link.title}</span> + </a> + ))} + </div> </Collapsible> ) })} diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx index c7abfe0..af70419 100644 --- a/src/pages/NotFoundPage.tsx +++ b/src/pages/NotFoundPage.tsx @@ -6,7 +6,7 @@ import { useLanguage } from "@/i18n" import { localizePath } from "@/lib/i18n-routing" export function NotFoundPage() { - useMetaTags({ title: "404" }) + useMetaTags({ title: "404", noindex: true }) const { language, t } = useLanguage() return ( <PageContainer className="flex flex-col items-center justify-center min-h-[60vh] text-center"> diff --git a/src/pages/ProjectDetailPage.tsx b/src/pages/ProjectDetailPage.tsx index 1cbb9bf..6fb1edf 100644 --- a/src/pages/ProjectDetailPage.tsx +++ b/src/pages/ProjectDetailPage.tsx @@ -21,6 +21,11 @@ export function ProjectDetailPage() { title: project?.name, description: project ? `${project.company} — ${project.name}` : undefined, url: slug ? localizePath(`/about/projects/${slug}`, language) : undefined, + noindex: !project, + alternateUrls: slug && project ? { + ko: localizePath(`/about/projects/${slug}`, "ko"), + en: localizePath(`/about/projects/${slug}`, "en"), + } : undefined, }) if (!project) { diff --git a/vite.config.ts b/vite.config.ts index fd823e2..ed969a6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import fs from "fs" import { defineConfig, type Plugin } from "vite" import react from "@vitejs/plugin-react" import { assertValidPostDates, getPostModifiedDate } from "./src/lib/post-dates" +import { PROJECTS } from "./src/data/resume" const BASE_URL = "https://dev.devy.dev" const LANGUAGES = ["ko", "en"] as const @@ -156,6 +157,19 @@ function sitemapPlugin(): Plugin { ] return LANGUAGES.map((language) => urlEntry(staticPageUrl(page, language), undefined, alternates)) }), + ...PROJECTS.flatMap((project) => { + const koUrl = `${BASE_URL}/about/projects/${project.slug}/` + const enUrl = `${BASE_URL}/en/about/projects/${project.slug}/` + const alternates = [ + { hreflang: "ko-KR", href: koUrl }, + { hreflang: "en", href: enUrl }, + { hreflang: "x-default", href: koUrl }, + ] + return [ + urlEntry(koUrl, undefined, alternates), + urlEntry(enUrl, undefined, alternates), + ] + }), ...koPosts.map((p) => { const alternates = [ { hreflang: "ko-KR", href: postUrl(p) },