From fa2fbc06613a97725c8ba738215820d782b131a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Sun, 19 Jul 2026 09:54:17 +0900 Subject: [PATCH 1/3] docs: define sitemap lastmod policy --- .../2026-07-19-sitemap-lastmod-design.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-sitemap-lastmod-design.md diff --git a/docs/superpowers/specs/2026-07-19-sitemap-lastmod-design.md b/docs/superpowers/specs/2026-07-19-sitemap-lastmod-design.md new file mode 100644 index 0000000..8f1bc69 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-sitemap-lastmod-design.md @@ -0,0 +1,43 @@ +# Sitemap 최종 수정일 설계 + +## 목표 + +게시글 sitemap의 `lastmod`를 실제 의미 있는 최종 수정일로 관리한다. 현재 게시글은 사용자의 결정에 따라 `date`를 최종 수정일로 간주하고, 앞으로 본문·구조화 데이터·주요 링크가 바뀐 글만 선택적으로 `updated`를 기록한다. + +## 데이터 모델 + +- `date`: 최초 게시일이자 `updated`가 없을 때의 최종 수정일이다. +- `updated`: 선택 필드다. 의미 있는 수정이 있을 때만 `YYYY-MM-DD`로 기록한다. +- 최종 수정일은 `updated ?? date`로 계산한다. +- `updated`는 `date`보다 빠르거나 미래일 수 없다. + +기존 게시글은 backfill하지 않는다. 따라서 현재 공개 게시글의 sitemap 값은 그대로 유지된다. + +## 출력 정책 + +- 게시글 sitemap ``: `updated ?? date`. +- 게시글 JSON-LD: `datePublished`에는 `date`, `dateModified`에는 `updated ?? date`. +- 게시글 화면: `updated`가 있고 `updated !== date`일 때만 수정일을 표시한다. +- 홈·글 목록·태그·시리즈·통계·소개·개인정보처리방침 같은 집계/고정 페이지: 신뢰할 수 없는 빌드 날짜를 넣지 않고 ``를 생략한다. +- RSS의 ``는 게시 시점을 뜻하므로 기존 `date`를 유지한다. + +## 구현 경계 + +- 게시글 타입과 두 frontmatter 파서(`src/lib/posts.ts`, `vite.config.ts`)가 `updated`를 읽는다. +- sitemap 생성기는 URL 항목별로 `lastmod`를 선택적으로 출력한다. +- 서버 프리렌더 메타데이터와 JSON-LD에 `updated`를 전달한다. +- 기존 정렬은 게시일 기준을 유지한다. 수정된 글이 최신 글로 다시 올라오지 않는다. + +## 검증 + +- 순수 날짜 선택/검증 로직을 Node 내장 테스트로 검증한다. +- 빌드 후 공개 게시글 URL의 ``가 `updated ?? date`인지 확인한다. +- 고정 페이지에 ``가 없는지 확인한다. +- 프리렌더 HTML의 `datePublished`와 `dateModified`를 확인한다. +- 기존 내부 링크 검사, 타입 검사, 린트, 전체 빌드를 실행한다. + +## 비범위 + +- Git 커밋 시각이나 파일 시스템 mtime으로 수정일 자동 추론 +- 기존 게시글의 수정일 일괄 추정/backfill +- Search Console의 의도된 NOINDEX 또는 리디렉션 정책 변경 From fb74f07be30b43a169919cdb5526aa0c95aa41bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Sun, 19 Jul 2026 09:59:29 +0900 Subject: [PATCH 2/3] fix: publish accurate post modification dates --- .github/workflows/ci.yml | 2 + README.md | 2 + .../plans/2026-07-19-sitemap-lastmod.md | 75 +++++++++++++++ package.json | 4 +- scripts/post-dates.test.mjs | 48 ++++++++++ scripts/sitemap-lastmod.test.mjs | 91 +++++++++++++++++++ src/entry-server.tsx | 4 + src/i18n/translations.ts | 3 + src/lib/post-dates.ts | 44 +++++++++ src/lib/posts.ts | 8 +- src/pages/PostPage.tsx | 8 +- src/types/post.ts | 1 + vite.config.ts | 26 ++++-- 13 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-19-sitemap-lastmod.md create mode 100644 scripts/post-dates.test.mjs create mode 100644 scripts/sitemap-lastmod.test.mjs create mode 100644 src/lib/post-dates.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e1a127..da51daf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,4 +26,6 @@ jobs: - run: npm ci - run: npm run lint - run: npm run type-check + - run: npm run test:post-dates - run: npm run build + - run: npm run test:sitemap-lastmod diff --git a/README.md b/README.md index fcc0487..502ba40 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ npm run dev --- title: "제목" date: "2025-01-01" +updated: "2025-02-01" # optional, 의미 있는 최종 수정일 description: "설명" tags: ["react", "typescript"] series: "시리즈명" # optional @@ -135,6 +136,7 @@ publishDate: "2025-12-01" # optional, 예약 발행 ``` `draft: true` 또는 미래의 `publishDate`가 있는 글은 production 빌드에서 자동으로 제외됩니다. +`updated`가 없으면 `date`를 최종 수정일로 사용합니다. 본문, 구조화 데이터, 주요 링크처럼 검색 결과에 영향을 주는 내용을 실제로 수정했을 때만 `updated`를 추가하며, 게시일보다 빠르거나 미래인 날짜는 빌드에서 거부됩니다. ## 프로젝트 구조 diff --git a/docs/superpowers/plans/2026-07-19-sitemap-lastmod.md b/docs/superpowers/plans/2026-07-19-sitemap-lastmod.md new file mode 100644 index 0000000..64a7de7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-sitemap-lastmod.md @@ -0,0 +1,75 @@ +# Sitemap 최종 수정일 구현 계획 + +**목표:** 기존 게시글은 `date`를 최종 수정일로 간주하고, 선택적인 `updated`가 있는 글은 sitemap·JSON-LD·화면에 일관되게 반영한다. 신뢰할 수 없는 고정/집계 페이지 빌드 날짜는 sitemap에서 제거한다. + +## 1. 날짜 정책을 테스트로 고정 + +**파일** + +- 추가: `src/lib/post-dates.ts` +- 추가: `scripts/post-dates.test.ts` +- 수정: `package.json` + +**절차** + +1. `updated ?? date` 선택, 과거 `updated`, 미래 `updated`, 잘못된 날짜 형식을 검증하는 테스트를 먼저 작성한다. +2. 테스트가 구현 모듈 부재로 실패하는지 확인한다. +3. 날짜 선택·검증 순수 함수를 최소 구현한다. +4. Node 내장 테스트가 통과하는지 확인한다. + +## 2. 게시글 메타데이터에 updated 추가 + +**파일** + +- 수정: `src/types/post.ts` +- 수정: `src/lib/posts.ts` +- 수정: `vite.config.ts` + +**절차** + +1. `PostMeta`와 빌드용 게시글 타입에 선택적 `updated`를 추가한다. +2. 런타임/빌드 frontmatter 파서가 `updated`를 읽도록 한다. +3. 빌드 단계에서 게시일과 수정일 정책을 검증해 잘못된 콘텐츠가 배포되지 않게 한다. +4. 기존 정렬과 RSS 게시일은 `date` 기준으로 유지한다. + +## 3. sitemap 출력 수정 + +**파일** + +- 수정: `vite.config.ts` +- 추가: `scripts/sitemap-lastmod.test.mjs` + +**절차** + +1. URL 엔트리가 `lastmod`를 선택적으로 출력하도록 바꾼다. +2. 게시글 URL은 `updated ?? date`를 출력한다. +3. 홈·목록·태그·시리즈·통계·소개·개인정보 페이지는 `lastmod`를 생략한다. +4. 빌드 결과의 모든 공개 게시글과 고정 페이지 정책을 검사하는 회귀 테스트를 추가한다. + +## 4. JSON-LD와 화면 표시 일치 + +**파일** + +- 수정: `src/entry-server.tsx` +- 수정: `src/pages/PostPage.tsx` +- 수정: `src/i18n/translations.ts` + +**절차** + +1. 게시글 JSON-LD의 `dateModified`를 `updated ?? date`로 출력한다. +2. `datePublished`는 기존 `date`를 유지한다. +3. `updated`가 실제 게시일과 다를 때만 한국어/영어 수정일 문구를 표시한다. + +## 5. 전체 검증 및 인계 + +**명령** + +- `npm run test:post-dates` +- `npm run build` +- `npm run test:sitemap-lastmod` +- `npm run check:internal-links` +- `npm run type-check` +- `npm run lint` +- `git diff --check` + +빌드된 sitemap과 대표 게시글 HTML을 직접 검사해 ``, `datePublished`, `dateModified`가 설계와 일치하는지 확인한다. Search Console 재제출·재검증은 변경 사항이 실제 배포된 뒤 별도 승인 하에 수행한다. diff --git a/package.json b/package.json index 1312dd9..bca3e7f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "lint": "eslint .", "type-check": "tsc --noEmit", - "check:internal-links": "node scripts/check-internal-post-links.mjs" + "check:internal-links": "node scripts/check-internal-post-links.mjs", + "test:post-dates": "node --test scripts/post-dates.test.mjs", + "test:sitemap-lastmod": "node --test scripts/sitemap-lastmod.test.mjs" }, "dependencies": { "@giscus/react": "^3.1.0", diff --git a/scripts/post-dates.test.mjs b/scripts/post-dates.test.mjs new file mode 100644 index 0000000..f06b3c6 --- /dev/null +++ b/scripts/post-dates.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + assertValidPostDates, + getPostModifiedDate, + hasDistinctUpdatedDate, +} from "../src/lib/post-dates.ts" + +test("uses the publication date when updated is absent", () => { + assert.equal(getPostModifiedDate({ date: "2026-07-01" }), "2026-07-01") + assert.equal(hasDistinctUpdatedDate({ date: "2026-07-01" }), false) +}) + +test("uses and exposes a distinct updated date", () => { + const dates = { date: "2026-07-01", updated: "2026-07-10" } + + assert.equal(getPostModifiedDate(dates), "2026-07-10") + assert.equal(hasDistinctUpdatedDate(dates), true) +}) + +test("treats an updated date equal to the publication date as unchanged", () => { + const dates = { date: "2026-07-01", updated: "2026-07-01" } + + assert.equal(getPostModifiedDate(dates), "2026-07-01") + assert.equal(hasDistinctUpdatedDate(dates), false) +}) + +test("rejects invalid calendar dates", () => { + assert.throws( + () => assertValidPostDates({ date: "2026-02-30" }, "2026-07-19"), + /publication date.*YYYY-MM-DD/, + ) +}) + +test("rejects an updated date before publication", () => { + assert.throws( + () => assertValidPostDates({ date: "2026-07-10", updated: "2026-07-09" }, "2026-07-19"), + /before publication date/, + ) +}) + +test("rejects a future updated date", () => { + assert.throws( + () => assertValidPostDates({ date: "2026-07-10", updated: "2026-07-20" }, "2026-07-19"), + /future/, + ) +}) diff --git a/scripts/sitemap-lastmod.test.mjs b/scripts/sitemap-lastmod.test.mjs new file mode 100644 index 0000000..258b90a --- /dev/null +++ b/scripts/sitemap-lastmod.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import test from "node:test" +import { fileURLToPath } from "node:url" + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const baseUrl = "https://devy1540.dev" +const today = new Date().toISOString().slice(0, 10) + +function frontmatterValue(raw, key) { + const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? "" + const line = frontmatter.split(/\r?\n/).find((candidate) => candidate.startsWith(`${key}:`)) + return line?.slice(line.indexOf(":") + 1).trim().replace(/^['"]|['"]$/g, "") ?? "" +} + +function readPublishedPosts(language) { + const postsDir = path.join(rootDir, "content/posts", language) + + return fs.readdirSync(postsDir) + .filter((file) => file.endsWith(".md")) + .map((file) => { + const raw = fs.readFileSync(path.join(postsDir, file), "utf8") + return { + slug: file.slice(0, -3), + language, + date: frontmatterValue(raw, "date"), + updated: frontmatterValue(raw, "updated") || undefined, + draft: frontmatterValue(raw, "draft") === "true", + publishDate: frontmatterValue(raw, "publishDate"), + } + }) + .filter((post) => !post.draft && !(post.publishDate && post.publishDate > today)) +} + +function parseSitemapEntries() { + const sitemap = fs.readFileSync(path.join(rootDir, "dist/sitemap.xml"), "utf8") + return [...sitemap.matchAll(/([\s\S]*?)<\/url>/g)].map((match) => ({ + loc: match[1].match(/(.*?)<\/loc>/)?.[1], + lastmod: match[1].match(/(.*?)<\/lastmod>/)?.[1], + })) +} + +function postUrl(post) { + const prefix = post.language === "en" ? "/en" : "" + return `${baseUrl}${prefix}/posts/${post.slug}/` +} + +function readBlogPostingJsonLd(post) { + const prefix = post.language === "en" ? ["en"] : [] + const htmlPath = path.join(rootDir, "dist", ...prefix, "posts", post.slug, "index.html") + const html = fs.readFileSync(htmlPath, "utf8") + const documents = [...html.matchAll(/