-
+ {popularPosts.map((post) => (
+
-
+
+
+ ))}
+
+ Pagefind 정적 검색입니다. 한국어 형태소 분석은 제한적이므로 제목·태그 매칭에 가중치가 + 있습니다. +
+ +diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4a92c9b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,62 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ gates:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.4.2
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Astro check
+ run: bun run check
+
+ - name: Format
+ run: bunx oxfmt --check .
+
+ - name: Oxlint
+ run: bunx oxlint .
+
+ - name: Vitest
+ run: bunx vitest run --passWithNoTests
+
+ - name: StyleX ESLint
+ run: bun run lint:stylex
+
+ - name: Content schema
+ run: node scripts/check-schema.mjs
+
+ - name: Leak guard
+ run: node scripts/check-leak.mjs
+
+ - name: Build
+ run: bun run build
+
+ - name: Link check
+ run: bun run check-links
+
+ - name: Search index Top-3
+ run: bun run check-search
+
+ - name: JS budget
+ run: node scripts/check-budget.mjs
+
+ - name: Storybook build
+ run: bun run build-storybook
diff --git a/.gitignore b/.gitignore
index 7b9c8a1..18fb1ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,4 @@ yarn-error.log*
# vercel
.vercel
node_modules
+public/og/*.png
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 23f6e85..6b60364 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -10,7 +10,7 @@
".next/**",
".astro/**",
"storybook-static/**",
- "legacy-next/**",
+ ".tasks/**",
"coverage/**",
"bun.lock",
"yarn.lock"
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 1d92b13..83005da 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -12,7 +12,6 @@
".next/**",
".astro/**",
"storybook-static/**",
- "legacy-next/**",
"coverage/**"
]
}
diff --git a/.tasks/phase-4-hardening-ops/LOG.md b/.tasks/phase-4-hardening-ops/LOG.md
new file mode 100644
index 0000000..c82d02b
--- /dev/null
+++ b/.tasks/phase-4-hardening-ops/LOG.md
@@ -0,0 +1,45 @@
+# Phase 4 LOG
+
+- Harness: cursor
+- Started: 2026-09-10
+- Status: PR-READY #61
+
+## Notes
+
+- 스택: `feat/phase-3-quiz` ← `feat/phase-4-hardening`. 머지는 사람. #56 → #58 → #59 → #60 먼저.
+- `feat/ci-gates`는 만들지 않는다. CI 워크플로는 이 브랜치에 둔다 (이미 콘텐츠 스택 위).
+- `gh-pages` 원격 브랜치 삭제는 하지 않는다. PR 본문에 사람이 Pages=Actions 확인 후 삭제하라고 적는다.
+- `legacy-next/` 삭제는 이 Phase 범위.
+
+## Implementation (2026-09-10)
+
+- CI: `.github/workflows/ci.yml` — bun 1.4.2, Node 22, full gate chain.
+- Pagefind: build 후 `bunx pagefind --site dist`, `/search`, quiz/draft 제외, title/tags 가중.
+- SEO: `@astrojs/sitemap`, `/rss.xml` (articles+til 30), `scripts/generate-og.mjs` + `src/lib/og.ts`, Base canonical/JSON-LD.
+- 404: `NotFoundGuide` — 인기글·태그·검색·구경로 안내.
+- Guards: `check-schema`, `check-leak`, `check-budget`, `check-links` (canonical skip).
+- Removed `legacy-next/`.
+
+## Review fixes (2026-09-10, PR #61)
+
+- `check-leak`: `src/components`, `src/layouts`, `src/lib` 포함 (stories/test 제외). secret 패턴(`sk-`, `ghp_`, `AKIA`, PEM 등) 추가.
+- `check-search`: Pagefind WASM `search()` Top-3 게이트 — CI `build` 후 실행 (fragment substring 아님).
+
+### Search Top-3 checklist (`scripts/check-search.mjs`)
+
+`dist/pagefind/pagefind.js`를 Node에서 로드(file:// fetch shim) → `init()` → `search(query)` → `results.slice(0,3)`의 `data().raw_url`에 expected path 포함 여부.
+
+| # | Query | Expected path (rank ≤3) | Source |
+|---|-------|-------------------------|--------|
+| 1 | `리팩토링` | `/til/refactoring-javascript/01/` | TIL — 리팩토링 시리즈 1편 |
+| 2 | `Personal Design System` | `/articles/design-system/01/` | articles — Design System (Storybook은 tag 페이지가 1위) |
+| 3 | `literal` | `/til/ts/enum-to-template-literal/` | TIL — enum → literal 타입 |
+
+한국어 stemming 없음(Pagefind 한계). WASM 점수 순위 그대로 검증한다.
+
+## 2026-09-10 review
+
+- Sol `gpt-5.6-sol-medium`: leak 범위 → search substring → WASM Top-3. 최종 `REVIEW APPROVE phase-4 61`. usage: not visible.
+- `gh pr ready 61`. 머지는 사람. 스택: #56 → #58 → #59 → #60 → #61.
+- 사람 후속: Pages Source=Actions 확인 후 `gh-pages` 삭제, branch protection에 CI.
+
diff --git a/.tasks/phase-4-hardening-ops/PLAN.md b/.tasks/phase-4-hardening-ops/PLAN.md
index af49861..28fac8e 100644
--- a/.tasks/phase-4-hardening-ops/PLAN.md
+++ b/.tasks/phase-4-hardening-ops/PLAN.md
@@ -24,5 +24,6 @@
## 승인 요청
-- [ ] Pagefind 동의? (Algolia 불필요 확인)
-- [ ] OG 자동생성 vs 수동 — 선호?
+- [x] Pagefind 동의? (Algolia 불필요 확인) — 2026-09-10 스택 진행. Pagefind.
+- [x] OG 자동생성 vs 수동 — 빌드 스크립트 자동 + 폴백 이미지 (PLAN 기본안).
+
diff --git a/astro.config.mjs b/astro.config.mjs
index fd9228e..b6970eb 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -8,6 +8,8 @@ import rehypeSlug from "rehype-slug";
import remarkGfm from "remark-gfm";
import remarkSmartypants from "remark-smartypants";
+import sitemap from "@astrojs/sitemap";
+
const markdownPlugins = {
remarkPlugins: [remarkGfm, remarkSmartypants],
rehypePlugins: [
@@ -37,5 +39,11 @@ export default defineConfig({
],
},
- integrations: [preact(), mdx(markdownPlugins)],
+ integrations: [
+ preact(),
+ mdx(markdownPlugins),
+ sitemap({
+ filter: (page) => !page.includes("/wiki/quiz"),
+ }),
+ ],
});
diff --git a/bun.lock b/bun.lock
index 87c2266..ba636eb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,8 @@
"@astrojs/mdx": "^8.0.1",
"@astrojs/preact": "^6.0.5",
"@astrojs/prism": "^4.0.2",
+ "@astrojs/rss": "^4.0.19",
+ "@astrojs/sitemap": "^3.7.4",
"@fontsource/caveat": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0",
"@fontsource/noto-sans-kr": "^5.3.0",
@@ -37,9 +39,11 @@
"eslint": "^10.10.0",
"oxfmt": "^0.67.0",
"oxlint": "^1.82.0",
+ "pagefind": "^1.5.2",
"storybook": "^10.6.0",
"typescript": "^6.0.0",
"vitest": "^5.0.0",
+ "yaml": "^2.9.0",
},
},
},
@@ -86,6 +90,10 @@
"@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="],
+ "@astrojs/rss": ["@astrojs/rss@4.0.19", "", { "dependencies": { "fast-xml-parser": "^5.5.7", "piccolore": "^0.1.3", "zod": "^4.3.6" } }, "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA=="],
+
+ "@astrojs/sitemap": ["@astrojs/sitemap@3.7.4", "", { "dependencies": { "sitemap": "^9.0.0", "zod": "^4.3.6" } }, "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA=="],
+
"@astrojs/telemetry": ["@astrojs/telemetry@3.3.3", "", { "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", "package-manager-detector": "^1.6.0" } }, "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ=="],
"@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.4", "", { "dependencies": { "yaml": "^2.8.3" } }, "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A=="],
@@ -348,6 +356,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
+ "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
+
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.127.0", "", { "os": "android", "cpu": "arm" }, "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ=="],
@@ -506,6 +516,20 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="],
+ "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ=="],
+
+ "@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw=="],
+
+ "@pagefind/freebsd-x64": ["@pagefind/freebsd-x64@1.5.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA=="],
+
+ "@pagefind/linux-arm64": ["@pagefind/linux-arm64@1.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw=="],
+
+ "@pagefind/linux-x64": ["@pagefind/linux-x64@1.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA=="],
+
+ "@pagefind/windows-arm64": ["@pagefind/windows-arm64@1.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g=="],
+
+ "@pagefind/windows-x64": ["@pagefind/windows-x64@1.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg=="],
+
"@preact/preset-vite": ["@preact/preset-vite@2.10.6", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="],
"@preact/signals": ["@preact/signals@2.11.2", "", { "dependencies": { "@preact/signals-core": "^1.14.4" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-rVTRTt/T0HIRgbugwS5FigbfF/kfdEFYFtiqxa+lbpqTajepqnR0firuAS2iNdHyejWB7Yc9p9QePkVNBtTAwg=="],
@@ -628,6 +652,8 @@
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
+ "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
+
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.185.4", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA=="],
@@ -698,6 +724,10 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+ "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="],
+
+ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
+
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
@@ -904,6 +934,10 @@
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
+ "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="],
+
+ "fast-xml-parser": ["fast-xml-parser@5.11.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw=="],
+
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
@@ -1022,6 +1056,8 @@
"is-plain-object": ["is-plain-object@5.1.0", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="],
+ "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="],
+
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="],
@@ -1266,6 +1302,8 @@
"package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
+ "pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="],
+
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
@@ -1278,6 +1316,8 @@
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+ "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
+
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
@@ -1402,6 +1442,8 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
+ "sitemap": ["sitemap@9.0.1", "", { "dependencies": { "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/esm/cli.js" } }, "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ=="],
+
"smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -1426,6 +1468,8 @@
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+ "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="],
+
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
@@ -1582,13 +1626,15 @@
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
+ "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
+
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
- "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yaml-language-server": ["yaml-language-server@1.23.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-i18n": "^4.2.0", "prettier": "^3.8.1", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A=="],
@@ -1604,6 +1650,8 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@astrojs/yaml2ts/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+
"@babel/core/debug": ["debug@4.3.3", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1642,6 +1690,8 @@
"@types/mdast/@types/unist": ["@types/unist@2.0.6", "", {}, "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ=="],
+ "@types/sax/@types/node": ["@types/node@24.13.4", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw=="],
+
"@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@vitest/mocker/@vitest/spy": ["@vitest/spy@5.0.0", "", {}, "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g=="],
@@ -1684,6 +1734,8 @@
"rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
+ "sitemap/@types/node": ["@types/node@24.13.4", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw=="],
+
"strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -1702,6 +1754,8 @@
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
+ "yaml-language-server/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+
"@babel/core/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"@babel/traverse/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
@@ -1716,6 +1770,8 @@
"@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+ "@types/sax/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+
"ajv-i18n/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"css-select/domhandler/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -1736,6 +1792,8 @@
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+ "sitemap/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+
"unstorage/chokidar/readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"yaml-language-server/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
diff --git a/legacy-next/.eslintrc.js b/legacy-next/.eslintrc.js
deleted file mode 100644
index 5a1eb67..0000000
--- a/legacy-next/.eslintrc.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/* eslint-disable no-undef */
-
-module.exports = {
- env: {
- browser: true,
- es2021: true,
- },
- extends: [
- "eslint:recommended",
- "plugin:react/recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:prettier/recommended",
- ],
- parser: "@typescript-eslint/parser",
- parserOptions: {
- ecmaFeatures: {
- jsx: true,
- },
- ecmaVersion: 12,
- sourceType: "module",
- },
- plugins: ["react", "@typescript-eslint", "prettier"],
- rules: {
- "react/jsx-uses-react": "off",
- "react/react-in-jsx-scope": "off",
- "react/prop-types": "off",
- "react/require-default-props": "off",
- "@typescript-eslint/explicit-module-boundary-types": "off",
- },
-};
diff --git a/legacy-next/.prettierrc b/legacy-next/.prettierrc
deleted file mode 100644
index 941df31..0000000
--- a/legacy-next/.prettierrc
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "tabWidth": 2,
- "useTabs": false,
- "endOfLine": "lf",
- "printWidth": 90,
- "trailingComma": "es5",
- "singleQuote": true
-}
diff --git a/legacy-next/components/atoms/Texts.tsx b/legacy-next/components/atoms/Texts.tsx
deleted file mode 100644
index 991368f..0000000
--- a/legacy-next/components/atoms/Texts.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-interface TextsProps {
- texts: string;
- className: string;
-}
-
-export function Texts({ texts, className }: TextsProps) {
- return {texts};
-}
diff --git a/legacy-next/components/molecules/TitleTexts.module.scss b/legacy-next/components/molecules/TitleTexts.module.scss
deleted file mode 100644
index e2653e6..0000000
--- a/legacy-next/components/molecules/TitleTexts.module.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.headText {
- font-size: 2rem;
-}
diff --git a/legacy-next/components/molecules/TitleTexts.tsx b/legacy-next/components/molecules/TitleTexts.tsx
deleted file mode 100644
index 7a2cc8b..0000000
--- a/legacy-next/components/molecules/TitleTexts.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Texts } from "@/components/atoms/Texts";
-import { CENTER } from "@/styles/GlobalClassNames";
-
-import style from "./TitleTexts.module.scss";
-
-interface TitleTextsProps {
- titleText: string;
-}
-
-export function TitleTexts({ titleText }: TitleTextsProps) {
- return
= Readonly
+ Pagefind 정적 검색입니다. 한국어 형태소 분석은 제한적이므로 제목·태그 매칭에 가중치가
+ 있습니다.
+
-
-### `literal` 타입; `PeriodNames`, `_RestPeriodNames`
-
-단순한 문자열 literal 타입이다
-
-Union이라는 의미에 맞게, **_관심사에 따라 분리된 타입들을 하나로 묶는 것이 쉽다_**
-
-`keyof typeof 객체`를 대신하기 위해 타입을 하나하나 더 써야한다는 것이 단점이기는 하지만,
-
-아래 객체를 생성할 때 자동완성이 되기 때문에 크게 불편함을 느끼진 않을수 있다
-
-오히려 하나의 타입에 너무 많은 속성이 있어서 타이핑 시간이 오래 걸릴 정도라면, 타입을 충분히 분리하지 못한 게 아닐지 검토해봐야 할 것 같다
-
-또 토스 블로그에서 소개한 것처럼, **`template-literal` 타입들을 조합한 새로운 타입들을 선언하기 매우 편리해진다**
-
-```typescript
-export type MarketNames = "domestic" | "overseas";
-export type CategoryNames = "index" | "stock";
-
-export type DetailChartTypeNames = `${MarketNames}-${CategoryNames}`;
-export const DetailChartTypes: ReadonlyRecord
-
-### Readonly 객체를 `class-validator IsEnum`에 활용
-
-```typescript
-// get-chart-dto.ts
-export class GetChartDto {
- @IsNotEmpty()
- @IsEnum(Periods)
- period: PeriodNames;
-}
-```
-
-`literal`을 활용하여 안전한(type-safe) `Readonly` 객체를 만들고, `class-validator`에서 활용한다
-
-`class-validator`가 아니더라도, 기존 `enum` 사용하듯 `Periods.DAILY`와 같이 사용할 수도 있다
-
-JS로 Transpile 되었을때, 단순 객체 리터럴이기 때문에 라인 블로그대로라면 tree-shaking도 가능하다
diff --git a/legacy-next/pages/study/refactoring-javascript/01.mdx b/legacy-next/pages/study/refactoring-javascript/01.mdx
deleted file mode 100644
index 9f8ba7b..0000000
--- a/legacy-next/pages/study/refactoring-javascript/01.mdx
+++ /dev/null
@@ -1,93 +0,0 @@
-# 1. 리팩토링이란?
-
-> 리팩토링은 코드를 변경하는 것이 아니다
-
-- 코드 동작을 바꾸면 안 된다는 것
-- 코드 동작이 변하지 않는다는 것을 어떻게 보장?
-- 코드 동작은 변하지 않으면서 코드를 변경한다는 것은 어떤 의미일까?
-
-## 1.1 코드 동작이 변하지 않는다는 것을 어떻게 보장할 수 있을까?
-
-리팩토링할 때 동작의 모든 유형을 다루는 것이 아닌, 다음 내용을 주로 다룬다
-
-- 구현 세부 사항
-- 불특정하고 검증되지 않은 동작
-- 성능
-
-=> 테스트, 버전 관리를 사용
-
-## 1.2 구현 세부 사항에 관심을 두면 어떨까?
-
-예를 들어, 숫자에 2를 곱하는 간단한 함수를 구현할 때
-
-- `return number * 2`
-- `return number << 1`
-
-이런 부분이 구현 세부 사항
-
-이때 `number` 값이 너무 커지면 두번째 경우는 문제가 발생
-
--> 테스트 케이스는 처음 생각했던 것보다 더 많은 경우를 포함해야 한다는 것을 의미
-
-- 변경한 구현 세부 사항에서 숫자를 두 배로 한 부분이 우리가 신경 써야 할 동작,
-- 세부 사항 테스트는 대부분 불필요
-
-## 1.3 불특정하고 검증되지 않은 동작에 관심을 두면 어떨까?
-
-실행 테스트, 수동 절차, 어떻게 동작하는지 최소한의 설명이 없으면 기본적으로 해당 코드를 검증할 수 없다는 것을 의미
-
-테스트가 부족한 코드 또는 실행시킬 수 있는 최소한의 문서가 없는 코드는 코드를 변경하기 어렵고, 그러면 동작 변경을 검증할 수 없어 리팩토링하기 어렵다
-
-> 테스트를 거치지 않은 코드는 리팩토링할 수 없다
-
-## 1.4 성능에 관심을 두면 어떨까?
-
-사용자는 성능보다는 동작과 기댓값에 더 중점을 둔다
-
-첫번째 구현에서 결과가 충분히 좋다는 것은 적절한 시간 안에 입력으로 기댓값을 산출할 수 있다는 의미; 비기능적 검증 (nonfunctional testing)
-
-## 1.5 동작이 변하지 않는다면 리팩토링의 요점은?
-
-> 리팩토링의 요점은 행동을 유지하면서 품질을 향상하는 것
-
-## 1.6 품질 균형 잡기와 일 끝마치기
-
-기술부채
-
-- 소규모 프로젝트에서 가시적이고 설명 가능한 기술부채는 어느 정도 허용될 수 있지만,
-- 큰 프로젝트에서는 품질을 중요하게 여기며 진행해야
-
-## 1.7 품질이란 무엇이며 리팩토링과 어떤 관련이 있을까?
-
-코드 품질을 좋게 하려는 수많은 노력들
-
-- SOLID; 단일 책임, 개방 폐쇄, 리스코프 치환, 인터페이스 분리, 의존성 역전
-- DRY; 반복 X
-- KISS; 정말 간단해야
-- GRASP; 일반적인 책임 할당 소프트웨어 패턴
-- YAGNI; 나중에 필요 없어질 것을 고려
-
-명확한 기준은 없다, 다만 제대로 작동하고 쉽게 확장될 수 있는 코드를 만드는 것
-
-코드 테스트를 작성하고 쉽게 테스트할 수 있는 코드를 작성하는 것
-
-- 인터페이스 단순화하기 위해 함수와 모듈 추출
-- 테스트를 이용해 코드 동작 확인
-- 가능하면 불순한 기능 피하기
-- 변수 및 함수 이름 잘 짓기
-
-자신만의 `소프트웨어 품질 원칙`을 구성해보라
-
-## 1.8 탐험으로서 리팩토링
-
-리팩토링은 코딩에 자신감을 갖게 하고 작업 중인 내용을 잘 이해하는 데 도움을 준다
-
-## 1.9 어떤 것이 리팩토링이고 어떤 것이 아닐까?
-
-새로운 코드와 기능을 만드는 것은 리팩토링이 아니다
-
-기존 코드에서 인터페이스(동작)에 대한 변경 사항은 테스트를 중단시키거나, 충분하지 않은 테스트를 하게 할 수 있음
-
-반면 구현 세부 사항을 변경할 때는 테스트가 중단되어서는 안된다
-
-## 1.10 마무리
diff --git a/legacy-next/styles/GlobalClassNames.ts b/legacy-next/styles/GlobalClassNames.ts
deleted file mode 100644
index 0076da0..0000000
--- a/legacy-next/styles/GlobalClassNames.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const CENTER = "center";
diff --git a/legacy-next/styles/_variables.scss b/legacy-next/styles/_variables.scss
deleted file mode 100644
index 589f714..0000000
--- a/legacy-next/styles/_variables.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-// --- Colors
-// @see {https://www.w3schools.com/colors/colors_2021.asp}
-$Black-Inkwell: rgb(54, 57, 69);
-$Gray-UltimateGray: rgb(147, 149, 151);
diff --git a/legacy-next/styles/globals.scss b/legacy-next/styles/globals.scss
deleted file mode 100644
index 646df0e..0000000
--- a/legacy-next/styles/globals.scss
+++ /dev/null
@@ -1,42 +0,0 @@
-@import "variables";
-
-.reset {
- padding: 0;
- margin: 0;
- border: 0;
- box-sizing: border-box;
-}
-
-.center {
- width: 100%;
- display: grid;
- place-content: center;
-}
-
-html,
-body {
- @extend .reset;
- @extend .center;
-
- width: 100vw;
- max-width: 900px;
- background-color: $Black-Inkwell;
- color: rgb(250, 250, 250);
- font-family:
- -apple-system,
- BlinkMacSystemFont,
- Segoe UI,
- Roboto,
- Oxygen,
- Ubuntu,
- Cantarell,
- Fira Sans,
- Droid Sans,
- Helvetica Neue,
- sans-serif;
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
diff --git a/package.json b/package.json
index bd20379..1fc9de9 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"dev": "astro dev",
- "build": "astro build",
+ "build": "node scripts/generate-og.mjs && astro build && bunx pagefind --site dist",
"preview": "astro preview",
"check": "astro check",
"format": "oxfmt --check .",
@@ -14,6 +14,10 @@
"lint:stylex": "eslint -c eslint.stylex.config.js src",
"test": "vitest run --passWithNoTests",
"check-links": "node scripts/check-links.mjs",
+ "check-schema": "node scripts/check-schema.mjs",
+ "check-leak": "node scripts/check-leak.mjs",
+ "check-budget": "node scripts/check-budget.mjs",
+ "check-search": "node scripts/check-search.mjs",
"check-code-blocks": "node scripts/check-code-blocks.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
@@ -24,6 +28,8 @@
"@astrojs/mdx": "^8.0.1",
"@astrojs/preact": "^6.0.5",
"@astrojs/prism": "^4.0.2",
+ "@astrojs/rss": "^4.0.19",
+ "@astrojs/sitemap": "^3.7.4",
"@fontsource/caveat": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0",
"@fontsource/noto-sans-kr": "^5.3.0",
@@ -51,9 +57,11 @@
"eslint": "^10.10.0",
"oxfmt": "^0.67.0",
"oxlint": "^1.82.0",
+ "pagefind": "^1.5.2",
"storybook": "^10.6.0",
"typescript": "^6.0.0",
- "vitest": "^5.0.0"
+ "vitest": "^5.0.0",
+ "yaml": "^2.9.0"
},
"engines": {
"node": ">=22.12.0"
diff --git a/scripts/check-budget.mjs b/scripts/check-budget.mjs
new file mode 100644
index 0000000..da1fc9a
--- /dev/null
+++ b/scripts/check-budget.mjs
@@ -0,0 +1,90 @@
+import { readFile, readdir, stat } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const distDir = join(rootDir, "dist");
+const MAX_BYTES = 50 * 1024;
+
+const EXCLUDED_PAGE_PATTERNS = [/\/wiki\/quiz\/?(?:index\.html)?$/, /\/wiki\/quiz\.html$/];
+
+const EXCLUDED_SCRIPT_PATTERNS = [/quiz/i, /three/i, /HeroCanvas/i, /pagefind/i];
+
+const walkHtml = async (dir) => {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const files = [];
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await walkHtml(fullPath)));
+ } else if (entry.name.endsWith(".html")) {
+ files.push(fullPath);
+ }
+ }
+ return files;
+};
+
+const fileSize = async (path) => {
+ try {
+ const info = await stat(path);
+ return info.size;
+ } catch {
+ return 0;
+ }
+};
+
+const resolveAssetPath = (href) => {
+ if (!href.startsWith("/")) return null;
+ const normalized = href.split("?")[0].split("#")[0];
+ return join(distDir, normalized.slice(1));
+};
+
+const isExcludedPage = (relPath) => EXCLUDED_PAGE_PATTERNS.some((pattern) => pattern.test(relPath));
+
+const isExcludedScript = (src) => EXCLUDED_SCRIPT_PATTERNS.some((pattern) => pattern.test(src));
+
+const run = async () => {
+ let htmlFiles;
+ try {
+ htmlFiles = await walkHtml(distDir);
+ } catch {
+ console.error("check-budget: dist/ not found — run `bun run build` first.");
+ process.exit(1);
+ }
+
+ const errors = [];
+
+ for (const file of htmlFiles) {
+ const relPath = file.slice(distDir.length + 1);
+ if (isExcludedPage(relPath)) continue;
+
+ const html = await readFile(file, "utf8");
+ const scripts = [...html.matchAll(/\ssrc="([^"]+\.js[^"]*)"/g)].map((match) => match[1]);
+ let total = 0;
+
+ for (const src of scripts) {
+ if (isExcludedScript(src)) continue;
+ const assetPath = resolveAssetPath(src);
+ if (!assetPath) continue;
+ total += await fileSize(assetPath);
+ }
+
+ if (total > MAX_BYTES) {
+ errors.push(
+ `${relPath}: ${total} bytes JS (limit ${MAX_BYTES}; island/quiz/three/pagefind scripts excluded)`,
+ );
+ }
+ }
+
+ if (errors.length > 0) {
+ console.error(`check-budget: ${errors.length} issue(s)`);
+ for (const message of errors) console.error(` - ${message}`);
+ process.exit(1);
+ }
+
+ console.log(
+ `check-budget: OK (${htmlFiles.length} html files, ≤${MAX_BYTES} bytes non-island JS)`,
+ );
+};
+
+await run();
diff --git a/scripts/check-leak.mjs b/scripts/check-leak.mjs
new file mode 100644
index 0000000..aaa4a58
--- /dev/null
+++ b/scripts/check-leak.mjs
@@ -0,0 +1,142 @@
+import { readFile, readdir } from "node:fs/promises";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { parse as parseYaml } from "yaml";
+
+const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const scanRoots = [
+ join(rootDir, "src/content"),
+ join(rootDir, "src/pages"),
+ join(rootDir, "src/components"),
+ join(rootDir, "src/layouts"),
+ join(rootDir, "src/lib"),
+ join(rootDir, "public"),
+];
+
+const SCANNABLE_EXT = /\.(md|mdx|astro|json|ts|tsx|js|mjs|css)$/i;
+
+const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
+
+const INTERNAL_DOMAIN_PATTERNS = [
+ /\best\.(ai|co\.kr|works)\b/i,
+ /\bworks@est\b/i,
+ /\binternal\.(est|company)\b/i,
+ /\bintra\./i,
+ /\bvpn\./i,
+];
+
+const SSN_PATTERN = /\b(?:\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[-\s]?[1-4]\d{6})\b/;
+
+/** High-signal secret patterns — placeholders like sk-REDACTED are ignored via min length. */
+const SECRET_PATTERNS = [
+ { name: "OpenAI API key", pattern: /\bsk-[a-zA-Z0-9]{20,}\b/ },
+ { name: "GitHub PAT", pattern: /\bghp_[a-zA-Z0-9]{36,}\b/ },
+ { name: "GitHub fine-grained PAT", pattern: /\bgithub_pat_[a-zA-Z0-9_]{50,}\b/ },
+ { name: "AWS access key", pattern: /\bAKIA[0-9A-Z]{16}\b/ },
+ { name: "Google API key", pattern: /\bAIza[0-9A-Za-z\-_]{35}\b/ },
+ { name: "Slack token", pattern: /\bxox[baprs]-[0-9a-zA-Z-]{10,}\b/ },
+ { name: "PEM private key", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
+];
+
+const errors = [];
+
+const shouldSkipFile = (relPath) =>
+ /\.(stories|test)\.(ts|tsx|astro)$/i.test(relPath) ||
+ relPath.includes("/__tests__/") ||
+ relPath.includes("\\__tests__\\");
+
+const walk = async (dir) => {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const files = [];
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === "raw" || fullPath.includes(`${join("src", "content", "raw")}`)) {
+ errors.push(`${relative(rootDir, fullPath)}: raw/ path is not publishable`);
+ }
+ files.push(...(await walk(fullPath)));
+ } else {
+ files.push(fullPath);
+ }
+ }
+ return files;
+};
+
+const scanSecrets = (relPath, text) => {
+ for (const { name, pattern } of SECRET_PATTERNS) {
+ if (pattern.test(text)) {
+ errors.push(`${relPath}: possible ${name}`);
+ }
+ }
+};
+
+const scanText = (relPath, text) => {
+ if (relPath.includes("/raw/") || relPath.includes("\\raw\\")) {
+ errors.push(`${relPath}: raw/ path is not publishable`);
+ }
+
+ for (const pattern of INTERNAL_DOMAIN_PATTERNS) {
+ if (pattern.test(text)) {
+ errors.push(`${relPath}: possible internal company domain`);
+ break;
+ }
+ }
+
+ if (SSN_PATTERN.test(text)) {
+ errors.push(`${relPath}: possible Korean resident registration number pattern`);
+ }
+
+ scanSecrets(relPath, text);
+};
+
+const scanFrontmatter = (relPath, source) => {
+ const match = FRONTMATTER_RE.exec(source);
+ if (!match) return;
+
+ let data;
+ try {
+ data = parseYaml(match[1]);
+ } catch {
+ return;
+ }
+
+ if (data?.visibility && data.visibility !== "public") {
+ errors.push(`${relPath}: visibility=${data.visibility} (must be public for publish)`);
+ }
+};
+
+const run = async () => {
+ const files = [];
+ for (const root of scanRoots) {
+ try {
+ files.push(...(await walk(root)));
+ } catch {
+ continue;
+ }
+ }
+
+ let scanned = 0;
+ for (const filePath of files) {
+ const relPath = relative(rootDir, filePath);
+ if (shouldSkipFile(relPath)) continue;
+ if (!SCANNABLE_EXT.test(filePath)) continue;
+
+ const text = await readFile(filePath, "utf8");
+ scanText(relPath, text);
+ if (/\.(md|mdx)$/i.test(filePath)) {
+ scanFrontmatter(relPath, text);
+ }
+ scanned += 1;
+ }
+
+ if (errors.length > 0) {
+ console.error(`check-leak: ${errors.length} issue(s)`);
+ for (const message of errors) console.error(` - ${message}`);
+ process.exit(1);
+ }
+
+ console.log(`check-leak: OK (${scanned} publishable file(s) scanned)`);
+};
+
+await run();
diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs
index b9556ed..f0d2c33 100644
--- a/scripts/check-links.mjs
+++ b/scripts/check-links.mjs
@@ -35,6 +35,10 @@ const hrefToDistPath = (href) => {
const home = join(distDir, "index.html");
return { direct: home, asIndex: home, anchor: null, pathOnly: normalized };
}
+ if (normalized === "/404") {
+ const notFound = join(distDir, "404.html");
+ return { direct: notFound, asIndex: notFound, anchor: null, pathOnly: normalized };
+ }
const direct = join(distDir, normalized.slice(1));
const asIndex = join(direct, "index.html");
return { direct, asIndex, anchor: href.includes("#") ? href.split("#")[1] : null, pathOnly };
@@ -42,9 +46,13 @@ const hrefToDistPath = (href) => {
const collectFromHtml = (html) => {
const hrefs = [...html.matchAll(/\shref="([^"]+)"/g)].map((match) => match[1]);
+ const canonicalHrefs = new Set(
+ [...html.matchAll(/]*rel="canonical"[^>]*href="([^"]+)"/g)].map((match) => match[1]),
+ );
+ const navigableHrefs = hrefs.filter((href) => !canonicalHrefs.has(href));
const srcs = [...html.matchAll(/\ssrc="([^"]+)"/g)].map((match) => match[1]);
const ids = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]));
- return { hrefs, srcs, ids };
+ return { hrefs: navigableHrefs, srcs, ids };
};
const isInternal = (url) =>
diff --git a/scripts/check-schema.mjs b/scripts/check-schema.mjs
new file mode 100644
index 0000000..af6c579
--- /dev/null
+++ b/scripts/check-schema.mjs
@@ -0,0 +1,76 @@
+import { readFile, readdir } from "node:fs/promises";
+import { spawnSync } from "node:child_process";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { parse as parseYaml } from "yaml";
+
+const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const contentRoot = join(rootDir, "src/content");
+const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
+
+const syncResult = spawnSync("bun", ["run", "astro", "sync"], {
+ cwd: rootDir,
+ stdio: "inherit",
+});
+if (syncResult.status !== 0) {
+ process.exit(syncResult.status ?? 1);
+}
+
+const { articleSchema, tilSchema } = await import("../src/lib/content-schemas.ts");
+
+const walkContent = async (dir) => {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const files = [];
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await walkContent(fullPath)));
+ } else if (/\.(md|mdx)$/i.test(entry.name)) {
+ files.push(fullPath);
+ }
+ }
+ return files;
+};
+
+const errors = [];
+
+const validateFile = async (filePath) => {
+ const rel = relative(contentRoot, filePath);
+ const [collection] = rel.split("/");
+ if (collection !== "articles" && collection !== "til") return;
+
+ const source = await readFile(filePath, "utf8");
+ const match = FRONTMATTER_RE.exec(source);
+ if (!match) {
+ errors.push(`${rel}: missing YAML frontmatter`);
+ return;
+ }
+
+ let data;
+ try {
+ data = parseYaml(match[1]);
+ } catch (error) {
+ errors.push(`${rel}: invalid YAML — ${error instanceof Error ? error.message : String(error)}`);
+ return;
+ }
+
+ const schema = collection === "articles" ? articleSchema : tilSchema;
+ const parsed = schema.safeParse(data);
+ if (!parsed.success) {
+ errors.push(`${rel}: ${parsed.error.message}`);
+ }
+};
+
+const files = await walkContent(contentRoot);
+for (const filePath of files) {
+ await validateFile(filePath);
+}
+
+if (errors.length > 0) {
+ console.error(`check-schema: ${errors.length} issue(s)`);
+ for (const message of errors) console.error(` - ${message}`);
+ process.exit(1);
+}
+
+console.log(`check-schema: OK (${files.length} content file(s), astro sync passed)`);
diff --git a/scripts/check-search.mjs b/scripts/check-search.mjs
new file mode 100644
index 0000000..eee9097
--- /dev/null
+++ b/scripts/check-search.mjs
@@ -0,0 +1,134 @@
+import { readFile } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const pagefindDir = join(rootDir, "dist/pagefind");
+const pagefindModulePath = join(pagefindDir, "pagefind.js");
+const TOP_N = 3;
+
+/**
+ * Top-3 ranked search checklist — see .tasks/phase-4-hardening-ops/LOG.md
+ * Uses Pagefind WASM ranking via dist/pagefind/pagefind.js (same as browser UI).
+ */
+const SEARCH_CASES = [
+ {
+ query: "리팩토링",
+ expectPath: "/til/refactoring-javascript/01",
+ note: "TIL — 리팩토링 시리즈 1편 (title 가중)",
+ },
+ {
+ query: "Personal Design System",
+ expectPath: "/articles/design-system/01",
+ note: "articles — Design System 환경 설정 (distinctive title phrase)",
+ },
+ {
+ query: "literal",
+ expectPath: "/til/ts/enum-to-template-literal",
+ note: "TIL — enum → literal 타입",
+ },
+];
+
+const installFileFetchShim = () => {
+ const nativeFetch = globalThis.fetch.bind(globalThis);
+ globalThis.fetch = async (input, init) => {
+ const url = String(input);
+ if (url.startsWith("file://")) {
+ const filePath = fileURLToPath(url.split("?")[0]);
+ const body = await readFile(filePath);
+ return new Response(body);
+ }
+ return nativeFetch(input, init);
+ };
+ return () => {
+ globalThis.fetch = nativeFetch;
+ };
+};
+
+const normalizePath = (url) => {
+ if (!url) return "";
+ const pathOnly = url
+ .replace(/^\/file:[^/]*/, "")
+ .split("#")[0]
+ .split("?")[0];
+ const withLeading = pathOnly.startsWith("/") ? pathOnly : `/${pathOnly}`;
+ return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
+};
+
+const rankedSearch = async (pagefind, query) => {
+ const response = await pagefind.search(query);
+ const ranked = response.results ?? [];
+ const top = ranked.slice(0, TOP_N);
+ const resolved = await Promise.all(top.map((result) => result.data()));
+ return resolved.map((doc) => ({
+ url: normalizePath(doc.raw_url ?? doc.url),
+ title: doc.meta?.title ?? "",
+ score: ranked.find((entry) => entry.id === doc.id)?.score,
+ }));
+};
+
+const assertIndexPresent = async () => {
+ try {
+ const entry = JSON.parse(await readFile(join(pagefindDir, "pagefind-entry.json"), "utf8"));
+ const pageCount = entry.languages?.ko?.page_count ?? 0;
+ if (pageCount < 10) {
+ console.error(`check-search: suspicious page_count=${pageCount} in pagefind-entry.json`);
+ process.exit(1);
+ }
+ } catch {
+ console.error(
+ "check-search: dist/pagefind/pagefind-entry.json not found — run `bun run build` first.",
+ );
+ process.exit(1);
+ }
+
+ try {
+ await readFile(pagefindModulePath);
+ } catch {
+ console.error("check-search: dist/pagefind/pagefind.js not found — run `bun run build` first.");
+ process.exit(1);
+ }
+};
+
+const run = async () => {
+ await assertIndexPresent();
+
+ const restoreFetch = installFileFetchShim();
+ const pagefind = await import(pathToFileURL(pagefindModulePath).href);
+
+ try {
+ await pagefind.init();
+ const errors = [];
+
+ for (const { query, expectPath, note } of SEARCH_CASES) {
+ const topResults = await rankedSearch(pagefind, query);
+ const expected = normalizePath(expectPath);
+ const urls = topResults.map((hit) => hit.url);
+ const rank = urls.indexOf(expected);
+
+ if (rank === -1) {
+ const summary = topResults
+ .map((hit, index) => `${index + 1}:${hit.url} (${hit.title.slice(0, 40)})`)
+ .join(", ");
+ errors.push(`"${query}" (${note}): expected ${expected} in top ${TOP_N}, got [${summary}]`);
+ }
+ }
+
+ if (errors.length > 0) {
+ console.error(`check-search: ${errors.length} issue(s)`);
+ for (const message of errors) console.error(` - ${message}`);
+ process.exit(1);
+ }
+
+ console.log(
+ `check-search: OK (${SEARCH_CASES.length} queries ranked top-${TOP_N} via Pagefind WASM)`,
+ );
+ } finally {
+ if (typeof pagefind.destroy === "function") {
+ await pagefind.destroy();
+ }
+ restoreFetch();
+ }
+};
+
+await run();
diff --git a/scripts/generate-og.mjs b/scripts/generate-og.mjs
new file mode 100644
index 0000000..055f03f
--- /dev/null
+++ b/scripts/generate-og.mjs
@@ -0,0 +1,104 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import sharp from "sharp";
+import { parse as parseYaml } from "yaml";
+
+const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const contentRoot = join(rootDir, "src/content");
+const ogDir = join(rootDir, "public/og");
+
+const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
+
+const walkContent = async (dir) => {
+ const { readdir } = await import("node:fs/promises");
+ const entries = await readdir(dir, { withFileTypes: true });
+ const files = [];
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await walkContent(fullPath)));
+ } else if (/\.(md|mdx)$/i.test(entry.name)) {
+ files.push(fullPath);
+ }
+ }
+ return files;
+};
+
+const parseFrontmatter = async (filePath) => {
+ const { readFile } = await import("node:fs/promises");
+ const source = await readFile(filePath, "utf8");
+ const match = FRONTMATTER_RE.exec(source);
+ if (!match) return null;
+ return parseYaml(match[1]);
+};
+
+const buildOgSvg = ({ title, subtitle }) => {
+ const escapeXml = (value) =>
+ value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+ const truncate = (value, max) => (value.length <= max ? value : `${value.slice(0, max - 1)}…`);
+ const safeTitle = escapeXml(truncate(title, 72));
+ const safeSubtitle = escapeXml(truncate(subtitle ?? "gitgitWi", 96));
+ return `
+`;
+};
+
+const writePng = async (fileName, svg) => {
+ const outputPath = join(ogDir, fileName);
+ const png = await sharp(Buffer.from(svg)).png().toBuffer();
+ await writeFile(outputPath, png);
+};
+
+const run = async () => {
+ await mkdir(ogDir, { recursive: true });
+
+ await writePng(
+ "default.png",
+ buildOgSvg({ title: "gitgitWi", subtitle: "Technical blog · Astro · TypeScript" }),
+ );
+
+ const files = await walkContent(contentRoot);
+ let generated = 1;
+
+ for (const filePath of files) {
+ const rel = relative(contentRoot, filePath);
+ const [collection] = rel.split("/");
+ if (collection !== "articles" && collection !== "til") continue;
+
+ const data = await parseFrontmatter(filePath);
+ if (!data?.title) continue;
+ if (collection === "articles" && data.draft) continue;
+ if (collection === "til" && data.visibility && data.visibility !== "public") continue;
+
+ const id = rel.replace(/\.(md|mdx)$/i, "");
+ const contentId = id.slice(collection.length + 1);
+ const subtitle =
+ collection === "articles" ? (data.description ?? "Article") : (data.summary ?? "Wiki");
+ const fileName = `${collection}-${contentId.replaceAll("/", "-")}.png`;
+
+ await writePng(fileName, buildOgSvg({ title: data.title, subtitle }));
+ generated += 1;
+ }
+
+ console.log(`generate-og: OK (${generated} image(s) in public/og/)`);
+};
+
+await run();
diff --git a/src/components/layout/ArticleLayout.astro b/src/components/layout/ArticleLayout.astro
index 1727068..ca3b46f 100644
--- a/src/components/layout/ArticleLayout.astro
+++ b/src/components/layout/ArticleLayout.astro
@@ -101,7 +101,7 @@ const accentBarStyles = {
>
)}
-
+ {popularPosts.map((post) => (
+
+