From d16005383d03f2069cf35c20f7c400862a6565c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Wed, 28 Oct 2026 18:42:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(seo):=20=ED=95=84=ED=84=B0=20URL=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=EC=9D=84=20=EC=A4=84=EC=9D=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=A3=BC=EC=9A=94=20=EA=B8=80=20=ED=83=90=EC=83=89=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 + .../posts/en/i18n-03-rollout-killswitch.md | 55 ++++++++- .../posts/ko/i18n-03-rollout-killswitch.md | 55 ++++++++- public/robots.txt | 13 +++ scripts/seo.test.mjs | 65 +++++++++++ scripts/sitemap-lastmod.test.mjs | 5 +- src/components/ReadingGuides.tsx | 40 +++++++ src/components/RoutePreloader.tsx | 31 +++-- src/components/ScrollToTop.tsx | 3 +- src/components/Sidebar.tsx | 4 - src/data/page-updates.ts | 7 ++ src/data/reading-guides.ts | 47 ++++++++ src/hooks/useCollectionFilters.ts | 21 ++++ src/i18n/translations.ts | 6 + src/lib/collection-filters.ts | 23 ++++ src/pages/HomePage.tsx | 3 + src/pages/PostsPage.tsx | 6 +- src/pages/SearchPage.tsx | 11 +- src/pages/SeriesPage.tsx | 12 +- src/pages/TagsPage.tsx | 12 +- tests/browser/collection-filters.spec.ts | 108 ++++++++++++++++++ tests/browser/rendering.spec.ts | 2 +- vite.config.ts | 3 +- 23 files changed, 481 insertions(+), 57 deletions(-) create mode 100644 src/components/ReadingGuides.tsx create mode 100644 src/data/page-updates.ts create mode 100644 src/data/reading-guides.ts create mode 100644 src/hooks/useCollectionFilters.ts create mode 100644 src/lib/collection-filters.ts create mode 100644 tests/browser/collection-filters.spec.ts diff --git a/README.md b/README.md index 30a7666..597bd84 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,12 @@ publishDate: "2025-12-01" # optional, 예약 발행 관련 글처럼 자동 생성되는 주요 링크를 추가하거나 바꿀 때도 실제 HTML이 달라지는 글의 `updated`를 함께 기록합니다. 글의 태그·제목·설명을 수정하면 다른 글의 관련 링크도 달라질 수 있으므로 빌드 결과를 비교해 영향을 받은 글을 확인합니다. 변경이 없는 글의 날짜는 유지하며 빌드 날짜로 일괄 갱신하지 않습니다. +홈·목록 등 고정 페이지의 주요 내용이나 링크가 바뀌면 `src/data/page-updates.ts`에 확인한 변경일을 기록합니다. 기록이 없는 페이지는 사이트맵의 `lastmod`를 생략합니다. + +목록·태그·시리즈의 필터는 `/tags/#filters:tag=java`처럼 문서 fragment에 저장합니다. 새 내부 링크는 `collectionFilterPath`로 만들며, 기존 `?tag=` 주소도 필터와 `noindex` 처리를 유지합니다. 필터 fragment는 일반 목차 앵커와 별개로 처리합니다. + +`robots.txt`는 기존 목록·태그·시리즈의 쿼리 주소만 수집을 제한합니다. 개별 글과 쿼리가 없는 목록은 허용합니다. 이 규칙은 불필요한 재수집을 줄이기 위한 것이며, Search Console에서 과거 URL을 즉시 지우는 기능은 아닙니다. + ## 프로젝트 구조 ```text diff --git a/content/posts/en/i18n-03-rollout-killswitch.md b/content/posts/en/i18n-03-rollout-killswitch.md index 503720d..97c73f4 100644 --- a/content/posts/en/i18n-03-rollout-killswitch.md +++ b/content/posts/en/i18n-03-rollout-killswitch.md @@ -1,7 +1,7 @@ --- title: "Service Internationalization (3) - Zero-Downtime Rollout with a GrowthBook Kill Switch" date: "2026-06-17" -updated: "2026-06-17" +updated: "2026-10-28" description: "Even with all translations ready, flipping the switch for everyone at once is risky. The story of funneling locale decisions into a single choke point and rolling out i18n with zero downtime, gradually, via a GrowthBook kill switch." tags: ["i18n", "nextjs", "growthbook", "feature-flag"] series: "Service Internationalization" @@ -17,7 +17,9 @@ In parts 1 and 2, I moved copy into a message catalog and got the language packs - The same sentence differs in length across Japanese and English, so layouts can break - Awkward or wrong translations might surface only later -So the goal was singular. **Turn it on gradually, but if something goes wrong, roll it back instantly without a deploy.** That mechanism is the GrowthBook kill switch. +So the goal was singular. **Turn it on gradually, but retain a way to roll it back without a deploy.** That mechanism is the GrowthBook kill switch. + +This article follows the decision from **the server reading a flag to the client using the same locale for its first render**. The prerequisites are [locale precedence](/posts/i18n-01-foundation/) and [language-pack delivery and caching through GCS](/posts/i18n-02-language-pack-gcs/). ## Funneling locale decisions into one place: a single choke point @@ -43,6 +45,19 @@ export const resolveEffectiveLocale = ({ When the flag is off, whether the user's setting is `en` or `ja`, whatever the system language is, it **falls back to Korean unconditionally.** It's as if I put a switch on the one gate every locale must pass through. The flag value is managed by a single `i18n_enabled` in GrowthBook. +### What the flag changes, and what it preserves + +The kill switch changes the **effective locale for this render**, not the user’s saved language preference. Keeping the saved preference allows the user’s choice to take effect again when the feature is enabled. + +| Flag | User preference | System or browser setting | Decision for this render | +|---|---|---|---| +| Off | `en` or `ja` | Any value | `ko` | +| On | Explicit `en` | `ja-JP` | `en` | +| On | `system` | Supported system locale is available | System locale | +| On | `system` | System locale cannot be resolved | `Accept-Language`, then the fallback locale | + +These branches follow directly from the function above. Checking the flag first prevents a lower-priority setting from overriding the disabled state. + ## Making the server and client reach the same decision Because it's the Next.js App Router, the locale is used on both the server (the layout) and the client (the provider). If the two pick different locales, hydration breaks. So **both feed the same flag into the same `resolveEffectiveLocale`.** @@ -58,13 +73,27 @@ The server reads the flag to decide `html lang` and the first render, and the cl ## True zero-downtime means being able to roll back -The real worth of a kill switch is that you can **roll it back instantly without a deploy.** +The value of a kill switch is that you can **restore the effective locale without deploying rollback code.** - **Gradual rollout** - In GrowthBook, turn it on first for only a certain percentage or a certain user group -- **Instant cutoff** - If a translation incident is found, the moment you flip the flag off, everyone safely reverts to `ko`. No need to wait for a rollback deploy +- **Cutoff** - Turn the flag off when a translation problem appears. Requests that read the new flag value return to `ko` without waiting for a rollback deploy "If you can't roll a feature back, you can't turn it on" was the principle, and the kill switch upheld that principle. +### An already open page may still have the previous value + +The code above does not guarantee an immediate update in every open browser. The server passes its flag value to the provider, so an existing document can retain the old value. Propagation depends on flag caching and on when a new request or render occurs. + +Before a rollout, check these boundaries. + +1. **Targeting**: Do the server and client evaluate the same user or group? +2. **First render**: Do `html lang`, the message catalog, and the client’s initial locale agree? +3. **Flag caching**: When does a new request see the disabled value, and where can an older value remain cached? +4. **Existing pages**: Are refreshed pages and already open pages checked separately? +5. **Preserved data**: Does disabling the feature retain the user’s language preference and the Korean language pack? + +Fetching a language pack from a CDN and selecting a locale are separate decisions. A cached non-Korean pack must not override an effective locale of `ko`. + ## Pin tests to ko If the locale wavers from test to test, snapshots and E2E become flaky. So I pinned the locale in the test environment to `ko` by default. And I **nailed down the invariant itself in a unit test** - "if the flag is off, always ko." @@ -77,7 +106,21 @@ test('forces ko when i18n is disabled, regardless of language/system', () => { }) ``` -As long as this test stays green, no matter who touches the locale logic, the safety net of "kill switch off = everyone ko" won't break. +This test checks the function’s rule that a disabled flag produces an effective locale of `ko`. It does not prove that the server and browser receive the same flag, so a rendered-page check is needed as well. + +The following test example covers more combinations of user and browser preferences. The disabled flag must take precedence in every case. + +```ts +test.each([ + { languageCode: 'en', systemLocale: 'ja-JP', acceptLanguage: 'en-US' }, + { languageCode: 'ja', systemLocale: 'en-US', acceptLanguage: 'ja-JP' }, + { languageCode: 'system', systemLocale: 'ja-JP', acceptLanguage: 'en-US' }, +])('keeps ko while the kill switch is off: %o', (input) => { + expect(resolveEffectiveLocale({ ...input, i18nEnabled: false })).toBe('ko') +}) +``` + +After the unit test, open the first request for an `en` user with the feature enabled and disabled. Compare the initial HTML with the hydrated page: the locale should not change unexpectedly, and raw translation keys should not appear. ## Wrap-up @@ -87,4 +130,4 @@ The core of a zero-downtime rollout was ultimately **a structure you can roll ba - Control server and client at once with a single GrowthBook `i18n_enabled` - Pin the "if off, always ko" invariant with a test -That's the frontend story so far. But no matter how well you translate on the frontend, if the server hands down a Korean response or a Korean document, Korean gets mixed back into the screen. In the final part, I'll cover how the backend stored the user's language and time zone and carried the locale all the way through to responses and documents. +That's the frontend story so far. But no matter how well you translate on the frontend, if the server hands down a Korean response or a Korean document, Korean gets mixed back into the screen. [Backend locale handling](/posts/i18n-04-backend-locale/) covers the boundary between stored language preferences and API responses. [Localized document rendering](/posts/i18n-05-document-render/) continues that flow into generated documents. diff --git a/content/posts/ko/i18n-03-rollout-killswitch.md b/content/posts/ko/i18n-03-rollout-killswitch.md index a1a80d3..734aa9e 100644 --- a/content/posts/ko/i18n-03-rollout-killswitch.md +++ b/content/posts/ko/i18n-03-rollout-killswitch.md @@ -1,7 +1,7 @@ --- title: "서비스 국제화 적용기 (3) - GrowthBook 킬스위치로 무중단 점진 롤아웃" date: "2026-06-17" -updated: "2026-06-17" +updated: "2026-10-28" description: "번역을 다 만들었어도 전원에게 한 번에 켜면 위험하다. 로케일 결정을 단일 차단점으로 모으고 GrowthBook 킬스위치로 i18n을 무중단·점진 롤아웃한 이야기." tags: ["i18n", "nextjs", "growthbook", "feature-flag"] series: "서비스 국제화 적용기" @@ -17,7 +17,9 @@ draft: false - 일본어·영어는 같은 문장도 길이가 달라 레이아웃이 깨질 수 있고 - 번역 자체가 어색하거나 틀린 곳이 뒤늦게 발견될 수 있다 -그래서 목표는 하나였다. **점진적으로 켜되, 문제가 생기면 배포 없이 즉시 되돌린다.** 그 장치가 GrowthBook 킬스위치다. +그래서 목표는 하나였다. **점진적으로 켜되, 문제가 생기면 배포 없이 되돌릴 수 있게 한다.** 그 장치가 GrowthBook 킬스위치다. + +이 글은 **서버가 플래그를 읽고 로케일을 결정한 뒤, 같은 결정을 클라이언트의 첫 렌더까지 전달하는 흐름**을 다룬다. 로케일 우선순위는 [국제화 기반 설계](/posts/i18n-01-foundation/), 번역 파일의 배포와 캐시는 [GCS 언어팩 구성](/posts/i18n-02-language-pack-gcs/)에서 이어진다. ## 로케일 결정을 한 곳으로 모은다 @@ -43,6 +45,19 @@ export const resolveEffectiveLocale = ({ 플래그가 꺼지면 사용자 설정이 `en`이든 `ja`든, 시스템 언어가 뭐든 **무조건 한국어로 떨어진다.** 로케일이 흘러갈 수 있는 유일한 문(門)에 스위치를 단 셈이다. 플래그 값은 GrowthBook에서 `i18n_enabled` 하나로 관리한다. +### 플래그가 바꾸는 것과 남겨두는 것 + +킬스위치는 사용자가 저장한 언어 설정을 지우는 기능이 아니다. 저장된 설정은 그대로 두고, 이번 렌더에 사용할 **유효 로케일**만 바꾼다. 다시 켰을 때 사용자의 선택을 복원할 수 있어야 하기 때문이다. + +| 플래그 | 사용자 설정 | 시스템·브라우저 설정 | 이번 렌더의 결정 | +|---|---|---|---| +| 꺼짐 | `en` 또는 `ja` | 어떤 값이든 | `ko` | +| 켜짐 | 명시적인 `en` | `ja-JP` | `en` | +| 켜짐 | `system` | 지원하는 시스템 로케일 있음 | 시스템 로케일 | +| 켜짐 | `system` | 시스템 로케일을 결정하지 못함 | `Accept-Language`, 이후 기본 로케일 | + +위 표는 앞의 함수에서 읽을 수 있는 분기 규칙이다. 플래그 분기를 가장 먼저 검사하므로, 꺼져 있을 때는 하위 우선순위가 결과를 뒤집을 수 없다. + ## 서버와 클라이언트가 같은 결정을 하게 Next.js App Router라 로케일은 서버(레이아웃)와 클라이언트(프로바이더) 양쪽에서 쓰인다. 두 곳이 서로 다른 로케일을 고르면 hydration이 깨진다. 그래서 **둘 다 같은 `resolveEffectiveLocale`에 같은 플래그를 먹인다.** @@ -58,13 +73,27 @@ const i18nEnabled = flagResult.values[FEATURE_FLAG_KEYS.I18N_ENABLED]?.enabled ? ## 되돌릴 수 있어야 진짜 무중단 -킬스위치의 진짜 값어치는 **배포 없이 즉시 되돌릴 수 있다**는 데 있다. +킬스위치의 값어치는 **롤백용 코드를 다시 배포하지 않고도 유효 로케일을 되돌릴 수 있다**는 데 있다. - **점진 롤아웃** - GrowthBook에서 특정 비율·특정 사용자 그룹에만 먼저 켠다 -- **즉시 차단** - 번역 사고가 발견되면 플래그를 끄는 순간 전원이 `ko`로 안전 복귀한다. 롤백 배포를 기다릴 필요가 없다 +- **차단** - 번역 사고가 발견되면 플래그를 끈다. 새 플래그 값을 읽은 요청은 `ko`로 돌아가므로 롤백 배포를 기다리지 않아도 된다 "기능을 못 되돌리면 못 켠다"가 원칙이었고 킬스위치가 그 원칙을 지켜줬다. +### 열린 화면까지 즉시 바뀐다는 뜻은 아니다 + +위 코드만으로는 이미 열린 모든 브라우저에 변경 사항이 즉시 전파된다고 보장할 수 없다. 서버가 읽은 플래그를 프로바이더에 전달하므로, 열린 문서에는 이전 값이 남아 있을 수 있다. 실제 반영 시점은 플래그 캐시와 새 요청·새 렌더가 발생하는 시점에 달려 있다. + +운영 시에는 다음 경계를 확인해야 한다. + +1. **대상 식별**: 서버와 클라이언트가 같은 사용자·그룹을 기준으로 플래그를 평가하는가. +2. **첫 렌더**: 서버의 `html lang`, 메시지 카탈로그, 클라이언트 초기 로케일이 같은가. +3. **플래그 캐시**: 꺼진 값을 새 요청이 언제 읽는지, 캐시 때문에 이전 값이 남는 구간이 있는가. +4. **기존 화면**: 새로고침한 화면과 이미 열려 있던 화면을 구분해 확인했는가. +5. **데이터 보존**: 차단 과정에서 사용자 언어 설정이나 한국어 언어팩을 삭제하지 않는가. + +언어팩을 CDN에서 다시 받는 문제와 어떤 로케일을 선택할지는 별개다. 언어팩이 캐시에 남아 있어도 유효 로케일이 `ko`면 한국어 메시지로 렌더해야 한다. + ## 테스트는 ko로 고정 로케일이 테스트마다 흔들리면 스냅샷·E2E가 불안정해진다. 그래서 테스트 환경의 로케일은 기본 `ko`로 고정했다. 그리고 "플래그가 꺼지면 무조건 ko"라는 **불변식 자체를 유닛 테스트로 박아뒀다.** @@ -77,7 +106,21 @@ test('forces ko when i18n is disabled, regardless of language/system', () => { }) ``` -이 테스트가 초록불인 한, 누가 로케일 로직을 건드려도 "킬스위치 off = 전원 ko"라는 안전판은 깨지지 않는다. +이 테스트는 "플래그 off = 유효 로케일 ko"라는 함수의 규칙을 확인한다. 서버와 브라우저가 실제로 같은 플래그를 받는지까지 증명하지는 않으므로, 화면 검증도 함께 필요하다. + +다음은 위 함수의 차단 규칙을 더 넓게 확인하는 테스트 예제다. 사용자 설정과 브라우저 언어를 바꿔도 꺼진 플래그가 우선해야 한다. + +```ts +test.each([ + { languageCode: 'en', systemLocale: 'ja-JP', acceptLanguage: 'en-US' }, + { languageCode: 'ja', systemLocale: 'en-US', acceptLanguage: 'ja-JP' }, + { languageCode: 'system', systemLocale: 'ja-JP', acceptLanguage: 'en-US' }, +])('keeps ko while the kill switch is off: %o', (input) => { + expect(resolveEffectiveLocale({ ...input, i18nEnabled: false })).toBe('ko') +}) +``` + +이 단위 테스트 다음에는 `en` 사용자의 첫 요청을 켜진 상태와 꺼진 상태에서 각각 열어본다. 초기 HTML과 hydration 뒤 화면에서 로케일이 바뀌거나 번역 키 자체가 노출되지 않는지 확인하는 순서다. ## 마치며 @@ -87,4 +130,4 @@ test('forces ko when i18n is disabled, regardless of language/system', () => { - GrowthBook `i18n_enabled` 하나로 서버·클라이언트를 동시에 제어하고 - "off면 무조건 ko" 불변식을 테스트로 고정 -여기까지가 프론트엔드 이야기다. 그런데 프론트에서 아무리 잘 번역해도, 서버가 한국어 응답이나 한국어 문서를 내려주면 화면에 다시 한국어가 섞인다. 마지막 편에서 백엔드가 사용자 언어·시간대를 어떻게 저장하고 응답·문서까지 로케일을 흘려보냈는지 다룬다. +여기까지가 프론트엔드 이야기다. 그런데 프론트에서 아무리 잘 번역해도, 서버가 한국어 응답이나 한국어 문서를 내려주면 화면에 다시 한국어가 섞인다. [백엔드 로케일 처리](/posts/i18n-04-backend-locale/)에서는 사용자 언어·시간대를 저장하고 응답으로 전달하는 경계를, [다국어 문서 렌더링](/posts/i18n-05-document-render/)에서는 그 로케일을 문서까지 이어가는 과정을 다룬다. diff --git a/public/robots.txt b/public/robots.txt index 6daa9c5..9a0f63a 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,3 +1,16 @@ User-agent: * Allow: / +# Reader-only legacy filters. Article URLs and unfiltered collections stay crawlable. +Disallow: /posts? +Disallow: /posts/? +Disallow: /tags? +Disallow: /tags/? +Disallow: /series? +Disallow: /series/? +Disallow: /en/posts? +Disallow: /en/posts/? +Disallow: /en/tags? +Disallow: /en/tags/? +Disallow: /en/series? +Disallow: /en/series/? Sitemap: https://dev.devy.dev/sitemap.xml diff --git a/scripts/seo.test.mjs b/scripts/seo.test.mjs index aaad1bf..d5546eb 100644 --- a/scripts/seo.test.mjs +++ b/scripts/seo.test.mjs @@ -4,6 +4,7 @@ import path from "node:path" import test from "node:test" import { fileURLToPath } from "node:url" import { selectRelatedPosts } from "../src/lib/related-posts.ts" +import { collectionFilterHash, collectionFilterPath, isCollectionFilterHash, readCollectionFilters } from "../src/lib/collection-filters.ts" const dist = fileURLToPath(new URL("../dist/", import.meta.url)) const origin = "https://dev.devy.dev" @@ -25,6 +26,70 @@ function text(html) { return html.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'").trim() } +test("collection fragments preserve encoded filters and remain distinct from article anchors", () => { + const params = new URLSearchParams({ tag: "C++", name: "서비스 & 롤백", q: "a#b" }) + const hash = collectionFilterHash(params) + assert.deepEqual([...readCollectionFilters("?tag=legacy", hash)], [...params]) + assert.equal(collectionFilterPath("/tags", params, "en"), `/en/tags/${hash}`) + assert.equal(readCollectionFilters("?tag=java", "#main-content").get("tag"), "java") + assert.equal(collectionFilterHash(new URLSearchParams()), "") + assert.equal(isCollectionFilterHash("/en/tags/", hash), true) + assert.equal(isCollectionFilterHash("/posts/example/", hash), false) + assert.equal(isCollectionFilterHash("/tags/", "#main-content"), false) +}) + +test("initial HTML exposes collection filters as fragments without new query URLs", () => { + let filterLinks = 0 + for (const [source, html] of pages) { + for (const [, href] of html.matchAll(/]*href="([^"]+)"/g)) { + const destination = new URL(href.replace(/&/g, "&"), source) + if (destination.origin !== origin || !/^\/(en\/)?(posts|tags|series)\/$/.test(destination.pathname)) continue + assert.equal(destination.search, "", `crawlable query link on ${source}: ${href}`) + if (destination.hash.startsWith("#filters:")) { + filterLinks++ + assert.ok(pages.has(`${origin}${destination.pathname}`), href) + } + } + } + assert.ok(filterLinks > 0) +}) + +test("robots rules restrict legacy collection queries while allowing every sitemap document", () => { + const robots = fs.readFileSync(path.join(dist, "robots.txt"), "utf8") + const blockedPrefixes = [...robots.matchAll(/^Disallow:\s*(\S+)$/gm)].map(match => match[1]) + // These rules deliberately use literal URL prefixes; '?' is not a wildcard in robots.txt. + for (const prefix of blockedPrefixes) assert.ok(!/[*$]/.test(prefix), "extend this prefix-policy check before adding wildcard rules") + const blocked = url => { + const parsed = new URL(url, origin) + return blockedPrefixes.some(prefix => `${parsed.pathname}${parsed.search}`.startsWith(prefix)) + } + for (const url of urls) assert.equal(blocked(url), false, `blocked sitemap URL: ${url}`) + for (const prefix of ["", "/en"]) { + for (const route of ["posts", "tags", "series"]) { + for (const slash of ["", "/"]) assert.equal(blocked(`${prefix}/${route}${slash}?tag=java`), true) + } + assert.equal(blocked(`${prefix}/posts/example/?utm_source=share`), false) + assert.equal(blocked(`${prefix}/tags/#filters:tag=java`), false) + } +}) + +test("localized home reading guides link directly to published articles in initial HTML", () => { + for (const prefix of ["", "/en"]) { + const html = pages.get(`${origin}${prefix}/`) + const section = html.match(/
]*>([\s\S]*?)<\/section>/)?.[1] + assert.ok(section, `missing reading guides: ${prefix}`) + const links = [...section.matchAll(/href="([^"]+)"/g)].map(match => match[1]) + assert.equal(links.length, 12) + assert.equal(new Set(links).size, links.length) + for (const href of links) { + assert.ok(href.startsWith(`${prefix}/posts/`), href) + assert.ok(pages.has(`${origin}${href}`), `missing or unpublished guide target: ${href}`) + } + assert.ok(links.includes(`${prefix}/posts/i18n-03-rollout-killswitch/`)) + assert.ok(links.includes(`${prefix}/posts/gitops-argocd-n8n-deployment/`)) + } +}) + 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" }) diff --git a/scripts/sitemap-lastmod.test.mjs b/scripts/sitemap-lastmod.test.mjs index 5e3078f..3c3333c 100644 --- a/scripts/sitemap-lastmod.test.mjs +++ b/scripts/sitemap-lastmod.test.mjs @@ -98,7 +98,7 @@ test("published posts use explicit updated as sitemap lastmod", () => { } }) -test("static and aggregate pages omit unreliable sitemap lastmod", () => { +test("static pages only publish explicitly recorded significant modification dates", () => { const entries = new Map(parseSitemapEntries().map((entry) => [entry.loc, entry])) const staticPaths = ["/", "/posts/", "/tags/", "/series/", "/analytics/", "/about/", "/privacy/"] @@ -107,7 +107,8 @@ test("static and aggregate pages omit unreliable sitemap lastmod", () => { const localizedPath = languagePrefix && staticPath === "/" ? "/en/" : `${languagePrefix}${staticPath}` const url = `${baseUrl}${localizedPath}` assert.ok(entries.has(url), `missing sitemap entry for ${url}`) - assert.equal(entries.get(url).lastmod, undefined, `unexpected lastmod for ${url}`) + const expected = ["/", "/tags/", "/series/"].includes(staticPath) ? "2026-10-28" : undefined + assert.equal(entries.get(url).lastmod, expected, `wrong lastmod for ${url}`) } } }) diff --git a/src/components/ReadingGuides.tsx b/src/components/ReadingGuides.tsx new file mode 100644 index 0000000..cc79342 --- /dev/null +++ b/src/components/ReadingGuides.tsx @@ -0,0 +1,40 @@ +import { Link } from "react-router-dom" +import { READING_GUIDES } from "@/data/reading-guides" +import { useLanguage } from "@/i18n" +import { postPath } from "@/lib/i18n-routing" +import type { PostMeta } from "@/types/post" + +export function ReadingGuides({ posts }: { posts: PostMeta[] }) { + const { language, t } = useLanguage() + const bySlug = new Map(posts.filter(post => post.language === language).map(post => [post.slug, post])) + + return ( +
+

+ {t.home.readingGuides} +

+

{t.home.readingGuidesDescription}

+
+ {READING_GUIDES.map(guide => { + const articles = guide.slugs.map(slug => bySlug.get(slug)).filter((post): post is PostMeta => !!post) + if (!articles.length) return null + return ( +
+

{guide.title[language]}

+

{guide.description[language]}

+
    + {articles.map(post => ( +
  • + + {post.title} + +
  • + ))} +
+
+ ) + })} +
+
+ ) +} diff --git a/src/components/RoutePreloader.tsx b/src/components/RoutePreloader.tsx index 5004a14..f096243 100644 --- a/src/components/RoutePreloader.tsx +++ b/src/components/RoutePreloader.tsx @@ -6,26 +6,21 @@ export function RoutePreloader() { const connection = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection if (connection?.saveData || /(^|-)2g$/.test(connection?.effectiveType ?? "")) return - const paths = ["/posts", "/tags", "/series", "/about", "/analytics"] - let active = true - let idleId: number | undefined - let timer: ReturnType | undefined - - function schedule() { - if (!active || paths.length === 0) return - const run = () => { - if (!active) return - const path = paths.shift()! - void prefetchRoute(path).then(schedule) - } - if ("requestIdleCallback" in window) idleId = window.requestIdleCallback(run, { timeout: 2000 }) - else timer = setTimeout(run, 1000) + function prefetchOnIntent(event: Event) { + const link = event.target instanceof Element ? event.target.closest("a[href]") : null + if (!link || link.hasAttribute("download") || link.target === "_blank") return + const url = new URL(link.href, window.location.href) + if (url.origin !== window.location.origin || url.pathname === window.location.pathname) return + void prefetchRoute(url.pathname) } - schedule() + + document.addEventListener("pointerover", prefetchOnIntent, { passive: true }) + document.addEventListener("focusin", prefetchOnIntent) + document.addEventListener("touchstart", prefetchOnIntent, { passive: true }) return () => { - active = false - if (idleId !== undefined) window.cancelIdleCallback(idleId) - if (timer !== undefined) clearTimeout(timer) + document.removeEventListener("pointerover", prefetchOnIntent) + document.removeEventListener("focusin", prefetchOnIntent) + document.removeEventListener("touchstart", prefetchOnIntent) } }, []) return null diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx index 294efd6..5bf1afb 100644 --- a/src/components/ScrollToTop.tsx +++ b/src/components/ScrollToTop.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react" import { useLocation, useNavigationType } from "react-router-dom" import { getScrollBehavior } from "@/lib/motion" import { followScrollAnchor } from "@/lib/scroll-anchor" +import { isCollectionFilterHash } from "@/lib/collection-filters" const positions = new Map() @@ -38,7 +39,7 @@ export function ScrollToTop() { initialNavigation.current = false if (saved !== undefined) { window.scrollTo(0, saved) - } else if (hash) { + } else if (hash && !isCollectionFilterHash(pathname, hash)) { let id: string try { id = decodeURIComponent(hash.slice(1)) } catch { id = hash.slice(1) } const scroll = () => { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index db095ce..11f87be 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -24,7 +24,6 @@ import { SidebarInfoMenu } from "@/components/SidebarInfoMenu" import { useLanguage } from "@/i18n" import { useAdminAuth } from "@/lib/admin/useAdminAuth" import { localizePath, stripLanguagePrefix } from "@/lib/i18n-routing" -import { prefetchRoute } from "@/lib/route-modules" import { SearchCommandTrigger } from "./SearchCommandTrigger" import { useIsHydrated } from "@/hooks/useHydratedSearchParams" @@ -130,9 +129,6 @@ function SidebarContents() { { void prefetchRoute(item.to) }} - onFocus={() => { void prefetchRoute(item.to) }} - onTouchStart={() => { void prefetchRoute(item.to) }} onClick={(e) => handleMobileNav(e, item.to)} > {({ isPending }) => ( diff --git a/src/data/page-updates.ts b/src/data/page-updates.ts new file mode 100644 index 0000000..6ccc588 --- /dev/null +++ b/src/data/page-updates.ts @@ -0,0 +1,7 @@ +// Dates of significant, reviewed page-content changes. Never use the build clock. +// The same change was published in both languages. +export const PAGE_UPDATED: Readonly>> = { + "/": "2026-10-28", // Curated reading guides and direct article links. + "/tags/": "2026-10-28", // Filter links now use document fragments. + "/series/": "2026-10-28", +} diff --git a/src/data/reading-guides.ts b/src/data/reading-guides.ts new file mode 100644 index 0000000..573a1a1 --- /dev/null +++ b/src/data/reading-guides.ts @@ -0,0 +1,47 @@ +import type { Language } from "@/i18n" + +interface ReadingGuide { + id: string + title: Record + description: Record + slugs: string[] +} + +export const READING_GUIDES: ReadingGuide[] = [ + { + id: "authentication", + title: { ko: "로그인과 토큰 검증", en: "Login and token verification" }, + description: { ko: "로그인 책임을 백엔드로 옮기는 과정부터 JWT 서명과 키 배포까지.", en: "From moving login ownership to the backend to JWT signing and key distribution." }, + slugs: ["backend-owned-login-flow", "jwt-hs256-to-rs256-jwks-kms"], + }, + { + id: "payments", + title: { ko: "결제 시스템 전환", en: "Payment system migration" }, + description: { ko: "PHP 결제를 Spring으로 이관하고 승인 결과를 동기 API로 연결한 기록.", en: "Migrating PHP payments to Spring and returning approval results through a synchronous API." }, + slugs: ["payment-system-migration-php-to-spring", "payment-system-redesign-sync-api"], + }, + { + id: "deployment", + title: { ko: "Kubernetes와 배포 자동화", en: "Kubernetes and deployment automation" }, + description: { ko: "ECS에서 EKS로 옮긴 이유와 GitOps·Slack 승인 배포를 구성한 과정.", en: "Why the service moved from ECS to EKS and how GitOps deployments gained a Slack approval gate." }, + slugs: ["ecs-to-eks-migration", "gitops-argocd-n8n-deployment"], + }, + { + id: "runtime", + title: { ko: "Java 업그레이드와 동시성", en: "Java upgrades and concurrency" }, + description: { ko: "JDK·Spring Boot 호환성 문제와 동기·가상 스레드·코루틴의 비교.", en: "JDK and Spring Boot compatibility, followed by synchronous, virtual-thread, and coroutine comparisons." }, + slugs: ["jdk-migration-strategy-11-to-25", "sync-virtual-coroutine-benchmark"], + }, + { + id: "internationalization", + title: { ko: "다국어 전환과 롤백", en: "Internationalization and rollback" }, + description: { ko: "로케일의 우선순위를 정하고 기능 플래그로 다국어 적용 범위를 제어하기.", en: "Defining locale precedence and controlling internationalization with a feature flag." }, + slugs: ["i18n-01-foundation", "i18n-03-rollout-killswitch"], + }, + { + id: "applied-ai", + title: { ko: "서비스에 AI 붙이기", en: "Putting AI into a service" }, + description: { ko: "Spring AI 파이프라인과 CS 자동화에서 모델 호출을 운영 흐름에 연결한 경험.", en: "Connecting model calls to operational workflows through Spring AI pipelines and customer-support automation." }, + slugs: ["spring-ai-pipeline-real-world", "spring-ai-cs-automation"], + }, +] diff --git a/src/hooks/useCollectionFilters.ts b/src/hooks/useCollectionFilters.ts new file mode 100644 index 0000000..d364ec5 --- /dev/null +++ b/src/hooks/useCollectionFilters.ts @@ -0,0 +1,21 @@ +import { useCallback, useMemo } from "react" +import { createSearchParams, useLocation, useNavigate, type SetURLSearchParams } from "react-router-dom" +import { useIsHydrated } from "./useHydratedSearchParams" +import { collectionFilterHash, readCollectionFilters } from "@/lib/collection-filters" + +const emptyParams = new URLSearchParams() + +export function useCollectionFilters(): [URLSearchParams, SetURLSearchParams, string] { + const hydrated = useIsHydrated() + const location = useLocation() + const navigate = useNavigate() + const params = useMemo(() => readCollectionFilters(location.search, location.hash), [location.search, location.hash]) + const setParams: SetURLSearchParams = useCallback((next, options) => { + const nextParams = createSearchParams(typeof next === "function" ? next(params) : next) + void navigate({ pathname: location.pathname, search: "", hash: collectionFilterHash(nextParams) }, options) + }, [location.pathname, navigate, params]) + + // Keep SSR and the first client render identical. Legacy query links still work + // and retain their noindex metadata until the reader changes the filters. + return [hydrated ? params : emptyParams, setParams, hydrated ? location.search.slice(1) : ""] +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 28ef8a2..a1d26d3 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -31,6 +31,8 @@ export interface Translations { viewPosts: string introduction: string popularPosts: string + readingGuides: string + readingGuidesDescription: string statsPosts: string statsSeries: string statsTags: string @@ -281,6 +283,8 @@ export const ko: Translations = { viewPosts: "글 목록 보기", introduction: "소개", popularPosts: "인기 글", + readingGuides: "문제별로 읽기", + readingGuidesDescription: "지금 해결하려는 문제에 맞춰 설계 배경과 구현 과정을 이어서 읽어보세요.", statsPosts: "글", statsSeries: "시리즈", statsTags: "태그", @@ -531,6 +535,8 @@ export const en: Translations = { viewPosts: "View Posts", introduction: "About", popularPosts: "Popular Posts", + readingGuides: "Read by problem", + readingGuidesDescription: "Start with the problem you are solving and follow the design decisions through to the implementation.", statsPosts: "posts", statsSeries: "series", statsTags: "tags", diff --git a/src/lib/collection-filters.ts b/src/lib/collection-filters.ts new file mode 100644 index 0000000..81b2353 --- /dev/null +++ b/src/lib/collection-filters.ts @@ -0,0 +1,23 @@ +import type { Language } from "@/i18n" +import { localizePath, stripLanguagePrefix } from "./i18n-routing.ts" + +const filterPrefix = "#filters:" + +// Collection filters are reader state, not separate documents for search engines. +export function readCollectionFilters(search: string, hash: string): URLSearchParams { + return new URLSearchParams(hash.startsWith(filterPrefix) ? hash.slice(filterPrefix.length) : search) +} + +export function collectionFilterHash(params: URLSearchParams): string { + const query = params.toString() + return query ? `${filterPrefix}${query}` : "" +} + +export function collectionFilterPath(path: string, params: URLSearchParams, language: Language): string { + return `${localizePath(path, language)}${collectionFilterHash(params)}` +} + +export function isCollectionFilterHash(pathname: string, hash: string): boolean { + const path = stripLanguagePrefix(pathname).replace(/\/+$/, "") + return ["/posts", "/tags", "/series"].includes(path) && hash.startsWith(filterPrefix) +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index c878f8a..43cb6b4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -11,6 +11,7 @@ import { useLanguage } from "@/i18n" import { localizePath, postPath } from "@/lib/i18n-routing" import { StructuredData } from "@/components/StructuredData" import { blogStructuredData, websiteStructuredData } from "@/lib/structured-data" +import { ReadingGuides } from "@/components/ReadingGuides" export function HomePage() { const { language, t } = useLanguage() @@ -103,6 +104,8 @@ export function HomePage() {
+ + {/* Popular Posts Top 5 */} {popularPosts.length > 0 && (
diff --git a/src/pages/PostsPage.tsx b/src/pages/PostsPage.tsx index e812aab..4c5c08a 100644 --- a/src/pages/PostsPage.tsx +++ b/src/pages/PostsPage.tsx @@ -1,4 +1,4 @@ -import { useHydratedSearchParams } from "@/hooks/useHydratedSearchParams" +import { useCollectionFilters } from "@/hooks/useCollectionFilters" import { useMemo, useState } from "react" import { usePostSearchIndex } from "@/hooks/usePostData" import { useMetaTags } from "@/hooks/useMetaTags" @@ -53,12 +53,12 @@ function postMatchesTags(post: PostMeta, query: string) { export function PostsPage() { const { language, t } = useLanguage() - const [searchParams, setSearchParams] = useHydratedSearchParams() + const [searchParams, setSearchParams, legacyQuery] = useCollectionFilters() useMetaTags({ title: t.common.posts, description: t.posts.description, url: localizePath("/posts", language), - ...filteredViewMeta("/posts", searchParams.toString(), language), + ...filteredViewMeta("/posts", legacyQuery, language), }) const { getPostViews } = usePageViews() const allPosts = useMemo(() => getAllPosts(language), [language]) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 187d0e8..5c9ee54 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -5,22 +5,27 @@ import { Button } from "@/components/ui/button" import { useMetaTags } from "@/hooks/useMetaTags" import { useLanguage } from "@/i18n" import { localizePath } from "@/lib/i18n-routing" +import { collectionFilterPath, readCollectionFilters } from "@/lib/collection-filters" +import { useIsHydrated } from "@/hooks/useHydratedSearchParams" export function SearchPage() { const { language, t } = useLanguage() const location = useLocation() const navigate = useNavigate() - const postsPath = localizePath(`/posts${location.search}`, language) + const hydrated = useIsHydrated() + const filters = hydrated ? readCollectionFilters(location.search, location.hash) : new URLSearchParams() + const postsPath = collectionFilterPath("/posts", filters, language) useMetaTags({ title: t.common.posts, description: t.posts.description, url: localizePath("/posts", language), + noindex: true, }) useEffect(() => { - navigate(postsPath, { replace: true }) - }, [navigate, postsPath]) + if (hydrated) void navigate(postsPath, { replace: true }) + }, [hydrated, navigate, postsPath]) return ( diff --git a/src/pages/SeriesPage.tsx b/src/pages/SeriesPage.tsx index b8ad46a..017e733 100644 --- a/src/pages/SeriesPage.tsx +++ b/src/pages/SeriesPage.tsx @@ -1,4 +1,4 @@ -import { useHydratedSearchParams } from "@/hooks/useHydratedSearchParams" +import { useCollectionFilters } from "@/hooks/useCollectionFilters" import { useMemo } from "react" import { Link } from "react-router-dom" import { SearchIcon, XIcon } from "lucide-react" @@ -11,6 +11,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { PageContainer } from "@/components/PageContainer" import { useLanguage } from "@/i18n" import { filteredViewMeta, localizePath, postPath } from "@/lib/i18n-routing" +import { collectionFilterPath } from "@/lib/collection-filters" type SeriesSort = "recent" | "count" | "name" @@ -80,7 +81,7 @@ function getGroupScore(series: SeriesInfo, group: SeriesGroup) { export function SeriesPage() { const { language, t } = useLanguage() - const [searchParams, setSearchParams] = useHydratedSearchParams() + const [searchParams, setSearchParams, legacyQuery] = useCollectionFilters() const selectedSeriesName = searchParams.get("name") ?? "" const query = searchParams.get("q") ?? "" const sortMode = toSeriesSort(searchParams.get("sort")) @@ -89,7 +90,7 @@ export function SeriesPage() { title: t.common.series, description: t.series.description, url: localizePath("/series", language), - ...filteredViewMeta("/series", searchParams.toString(), language), + ...filteredViewMeta("/series", legacyQuery, language), }) const posts = useMemo(() => getAllPosts(language), [language]) @@ -152,8 +153,7 @@ export function SeriesPage() { function seriesPathFor(seriesName: string) { const next = new URLSearchParams(searchParams) next.set("name", seriesName) - const suffix = next.toString() - return localizePath(`/series${suffix ? `?${suffix}` : ""}`, language) + return collectionFilterPath("/series", next, language) } const hasFilters = Boolean(query.trim() || selectedSeriesName || sortMode !== "recent") @@ -339,7 +339,7 @@ export function SeriesPage() {
{activeSeries.tags.slice(0, 8).map((tag) => ( - + {tag} diff --git a/src/pages/TagsPage.tsx b/src/pages/TagsPage.tsx index 5a78838..ea07585 100644 --- a/src/pages/TagsPage.tsx +++ b/src/pages/TagsPage.tsx @@ -1,4 +1,4 @@ -import { useHydratedSearchParams } from "@/hooks/useHydratedSearchParams" +import { useCollectionFilters } from "@/hooks/useCollectionFilters" import { useMemo } from "react" import { Link } from "react-router-dom" import { SearchIcon, XIcon } from "lucide-react" @@ -11,6 +11,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { PageContainer } from "@/components/PageContainer" import { useLanguage } from "@/i18n" import { filteredViewMeta, localizePath, postPath } from "@/lib/i18n-routing" +import { collectionFilterPath } from "@/lib/collection-filters" import type { PostMeta } from "@/types/post" type TagSort = "popular" | "recent" | "name" @@ -107,7 +108,7 @@ export function TagsPage() { const { language, t } = useLanguage() const posts = useMemo(() => getAllPosts(language), [language]) - const [searchParams, setSearchParams] = useHydratedSearchParams() + const [searchParams, setSearchParams, legacyQuery] = useCollectionFilters() const selectedTag = searchParams.get("tag") ?? "" const query = searchParams.get("q") ?? "" const sortMode = toTagSort(searchParams.get("sort")) @@ -116,7 +117,7 @@ export function TagsPage() { title: t.common.tags, description: t.tags.description, url: localizePath("/tags", language), - ...filteredViewMeta("/tags", searchParams.toString(), language), + ...filteredViewMeta("/tags", legacyQuery, language), }) const tagStats = useMemo(() => buildTagStats(posts), [posts]) @@ -199,8 +200,7 @@ export function TagsPage() { function tagsPathFor(tag: string) { const next = new URLSearchParams(searchParams) next.set("tag", tag) - const suffix = next.toString() - return localizePath(`/tags${suffix ? `?${suffix}` : ""}`, language) + return collectionFilterPath("/tags", next, language) } const hasFilters = Boolean(query.trim() || selectedTag || sortMode !== "popular") @@ -390,7 +390,7 @@ export function TagsPage() {
diff --git a/tests/browser/collection-filters.spec.ts b/tests/browser/collection-filters.spec.ts new file mode 100644 index 0000000..c963eb7 --- /dev/null +++ b/tests/browser/collection-filters.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "@playwright/test" +import fs from "node:fs" + +test.beforeEach(async ({ page }) => { + await page.route("**/*", route => { + const url = new URL(route.request().url()) + if (url.hostname === "127.0.0.1") return route.continue() + if (url.hostname === "script.google.com") return route.fulfill({ json: { totalViews: 8, pages: {}, daily: [] } }) + return route.abort() + }) +}) + +for (const prefix of ["", "/en"]) { + test(`home reading guides remain readable on mobile and desktop: ${prefix || "ko"}`, async ({ page }, testInfo) => { + for (const width of [390, 1440]) { + await page.setViewportSize({ width, height: 900 }) + await page.goto(`${prefix}/`) + const guides = page.getByRole("region", { name: prefix ? "Read by problem" : "문제별로 읽기", exact: true }) + await expect(guides.getByRole("link")).toHaveCount(12) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await guides.screenshot({ path: testInfo.outputPath(`reading-guides-${width}.png`) }) + } + }) + + test(`fragment tag filters hydrate, reload and navigate Back correctly: ${prefix || "ko"}`, 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 path = `${prefix}/tags/` + await page.goto(`${path}#filters:tag=java`) + await expect(page.locator("main aside h2")).toHaveText("java") + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "index, follow") + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", `https://dev.devy.dev${path}`) + const link = page.locator(`main a[href="${path}#filters:tag=react"]`).first() + await link.scrollIntoViewIfNeeded() + const before = await page.evaluate(() => scrollY) + await link.click() + await expect(page.locator("main aside h2")).toHaveText("react") + expect(Math.abs(await page.evaluate(() => scrollY) - before)).toBeLessThan(3) + await page.goBack() + await expect(page.locator("main aside h2")).toHaveText("java") + await page.reload() + await expect(page.locator("main aside h2")).toHaveText("java") + expect(errors).toEqual([]) + }) + + test(`legacy tag queries remain usable and new navigation uses fragments: ${prefix || "ko"}`, async ({ page }) => { + await page.goto(`${prefix}/tags/?tag=java`) + await expect(page.locator("main aside h2")).toHaveText("java") + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex, nofollow") + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", `https://dev.devy.dev${prefix}/tags/?tag=java`) + const viewPosts = page.locator(`main aside a[href="${prefix}/posts/#filters:tag=java"]`) + await viewPosts.click() + await expect(page).toHaveURL(new RegExp(`${prefix}/posts/#filters:tag=java$`)) + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "index, follow") + const cards = page.locator('main a[href^="' + prefix + '/posts/"]').filter({ has: page.locator("h3") }) + expect(await cards.count()).toBeGreaterThan(0) + for (const card of await cards.all()) await expect(card).toContainText("java") + }) + + test(`series fragments keep the reading order and article links: ${prefix || "ko"}`, async ({ page }) => { + const series = prefix ? "Service Internationalization" : "서비스 국제화 적용기" + await page.goto(`${prefix}/series/#filters:name=${encodeURIComponent(series)}`) + await expect(page.locator("main aside h2")).toHaveText(series) + const links = page.locator(`main aside a[href^="${prefix}/posts/"]`) + await expect(links.first()).toHaveAttribute("href", `${prefix}/posts/i18n-01-foundation/`) + await links.first().click() + await expect(page.locator("article h1")).toBeVisible() + await expect(page.locator("html")).toHaveAttribute("lang", prefix ? "en" : "ko") + }) +} + +test("language switching preserves tag filter fragments", async ({ page }) => { + await page.goto("/tags/#filters:tag=java") + await expect(page.locator("main aside h2")).toHaveText("java") + await page.getByRole("button", { name: "언어 변경", exact: true }).click() + await page.getByRole("menuitemcheckbox", { name: "English", exact: true }).click() + await expect(page).toHaveURL(/\/en\/tags\/#filters:tag=java$/) + await expect(page.locator("main aside h2")).toHaveText("java") +}) + +test("legacy search redirects preserve the query as reader state", 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()) }) + await page.goto("/search/?q=JSON-RPC") + await expect(page).toHaveURL(/\/posts\/#filters:q=JSON-RPC$/) + await expect(page.getByRole("textbox", { name: "제목, 설명, 태그, 본문 검색" })).toHaveValue("JSON-RPC") + expect(errors).toEqual([]) +}) + +test("article reading avoids unrelated route downloads until navigation intent", async ({ page }) => { + const manifest = JSON.parse(fs.readFileSync("dist/.vite/manifest.json", "utf8")) + const unrelatedRoutes = ["PostsPage", "TagsPage", "SeriesPage", "AboutPage", "AnalyticsPage"] + .map(name => manifest[`src/pages/${name}.tsx`].file as string) + const requests: string[] = [] + page.on("request", request => requests.push(new URL(request.url()).pathname.slice(1))) + await page.goto("/posts/react-hooks-deep-dive/") + await expect(page.locator("article h1")).toBeVisible() + // The old idle loop started loading these routes after at most two seconds. + await page.waitForTimeout(3500) + expect(requests.filter(file => unrelatedRoutes.includes(file))).toEqual([]) + await page.getByRole("link", { name: "소개", exact: true }).hover() + await expect.poll(() => requests.includes(manifest["src/pages/AboutPage.tsx"].file)).toBe(true) + await page.getByRole("link", { name: "소개", exact: true }).click() + await expect(page).toHaveURL(/\/about\/$/) + await expect(page.locator("main h1")).toBeVisible() +}) diff --git a/tests/browser/rendering.spec.ts b/tests/browser/rendering.spec.ts index 6bc30e7..21153e6 100644 --- a/tests/browser/rendering.spec.ts +++ b/tests/browser/rendering.spec.ts @@ -125,7 +125,7 @@ test("comments update theme without recreating the iframe and follow the next ar await expect(page.frameLocator("giscus-widget iframe").locator("#config")).toHaveAttribute("data-theme", "dark") expect(iframeLoads).toBe(1) await page.getByRole("link", { name: "홈", exact: true }).click() - await page.locator('main a[href="/posts/spring-ai-cs-automation/"]').click() + await page.locator('section[aria-labelledby="reading-guides-heading"] a[href="/posts/spring-ai-cs-automation/"]').click() await expect(widget).toBeAttached() await widget.scrollIntoViewIfNeeded() await expect(widget.locator("iframe")).not.toHaveAttribute("data-persistent-frame", "yes") diff --git a/vite.config.ts b/vite.config.ts index d830a94..041e6dd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import react from "@vitejs/plugin-react" import { postAssetsPlugin } from "./scripts/post-assets" import { assertValidPostDates, getPostModifiedDate } from "./src/lib/post-dates" import { PROJECTS } from "./src/data/resume" +import { PAGE_UPDATED } from "./src/data/page-updates" import { imageAssetsPlugin } from "./scripts/image-assets" import { previewPagesPlugin } from "./scripts/preview-pages" import { ko, en } from "./src/i18n/translations" @@ -160,7 +161,7 @@ function sitemapPlugin(): Plugin { { hreflang: "en", href: staticPageUrl(page, "en") }, { hreflang: "x-default", href: staticPageUrl(page, "ko") }, ] - return LANGUAGES.map((language) => urlEntry(staticPageUrl(page, language), undefined, alternates)) + return LANGUAGES.map((language) => urlEntry(staticPageUrl(page, language), PAGE_UPDATED[page], alternates)) }), ...PROJECTS.flatMap((project) => { const koUrl = `${BASE_URL}/about/projects/${project.slug}/` From 3fd4ffcca1e1e77b6c94beda201d03949aa93f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Wed, 28 Oct 2026 18:46:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(deps):=20Mermaid=EC=9D=98=20DOMPurify?= =?UTF-8?q?=20=EB=B3=B4=EC=95=88=20=EC=88=98=EC=A0=95=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 9 +++++---- pnpm-workspace.yaml | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f91667..bba51c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: uuid@11.1.0: 11.1.1 + mermaid>dompurify: 3.4.17 importers: @@ -2116,8 +2117,8 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dompurify@3.4.16: - resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} electron-to-chromium@1.5.328: resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} @@ -5390,7 +5391,7 @@ snapshots: '@babel/runtime': 7.29.2 csstype: 3.2.3 - dompurify@3.4.16: + dompurify@3.4.17: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -6056,7 +6057,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.23 - dompurify: 3.4.16 + dompurify: 3.4.17 es-toolkit: 1.52.0 fastdom: 1.0.12 katex: 0.16.47 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ed82771..e9e3c9c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,4 @@ enableGlobalVirtualStore: false overrides: 'uuid@11.1.0': 11.1.1 + 'mermaid>dompurify': 3.4.17