From c317f610c6e654a6d6ab46616989856e8cdb7545 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:23:58 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs(phase-4):=20Pagefind=EC=99=80=20OG?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=EC=83=9D=EC=84=B1=EC=9D=84=20PLAN?= =?UTF-8?q?=EC=97=90=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .tasks/phase-4-hardening-ops/LOG.md | 12 ++++++++++++ .tasks/phase-4-hardening-ops/PLAN.md | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .tasks/phase-4-hardening-ops/LOG.md diff --git a/.tasks/phase-4-hardening-ops/LOG.md b/.tasks/phase-4-hardening-ops/LOG.md new file mode 100644 index 0000000..b4abdaf --- /dev/null +++ b/.tasks/phase-4-hardening-ops/LOG.md @@ -0,0 +1,12 @@ +# Phase 4 LOG + +- Harness: cursor +- Started: 2026-09-10 +- Status: PLAN-READY (Pagefind · OG 빌드 생성). base `feat/phase-3-quiz` (#60 ready, 미머지). + +## 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 범위. 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 기본안). + From 8a195b4701cf331d805f401a4ea60d6b042a4deb Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:29:19 +0900 Subject: [PATCH 02/12] =?UTF-8?q?ci(phase-4):=20PR=C2=B7main=20CI=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun 1.4·Node 22에서 check·format·lint·test·schema·leak·build·links·budget·storybook을 순차 실행한다. --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++++++++++++++++ .oxfmtrc.json | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..00e6fdf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +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: JS budget + run: node scripts/check-budget.mjs + + - name: Storybook build + run: bun run build-storybook 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" From 59a716d4eb4183d04c4c0a09c6d2293f99aecf84 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:29:19 +0900 Subject: [PATCH 03/12] =?UTF-8?q?build(phase-4):=20sitemap=C2=B7rss=C2=B7p?= =?UTF-8?q?agefind=20=EB=B9=8C=EB=93=9C=20=EC=B2=B4=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate-og 후 astro build·pagefind 인덱싱까지 한 번에 돌린다. --- astro.config.mjs | 10 +++++++- bun.lock | 60 +++++++++++++++++++++++++++++++++++++++++++++++- package.json | 11 +++++++-- 3 files changed, 77 insertions(+), 4 deletions(-) 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/package.json b/package.json index bd20379..65acb22 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,9 @@ "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-code-blocks": "node scripts/check-code-blocks.mjs", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" @@ -24,6 +27,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 +56,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" From 0f215d83b56cd3be70395019a77c06142cddb2ef Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:29:19 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(phase-4):=20schema=C2=B7leak=C2=B7bu?= =?UTF-8?q?dget=C2=B7OG=20=EC=83=9D=EC=84=B1=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit astro sync+Zod·공개안전 leak·50KB JS 예산·sharp OG PNG 생성을 CI에서 검증한다. --- scripts/check-budget.mjs | 90 +++++++++++++++++++++++++++++++ scripts/check-leak.mjs | 108 +++++++++++++++++++++++++++++++++++++ scripts/check-links.mjs | 10 +++- scripts/check-schema.mjs | 76 ++++++++++++++++++++++++++ scripts/generate-og.mjs | 104 +++++++++++++++++++++++++++++++++++ src/content.config.ts | 35 ++---------- src/lib/content-schemas.ts | 35 ++++++++++++ 7 files changed, 425 insertions(+), 33 deletions(-) create mode 100644 scripts/check-budget.mjs create mode 100644 scripts/check-leak.mjs create mode 100644 scripts/check-schema.mjs create mode 100644 scripts/generate-og.mjs create mode 100644 src/lib/content-schemas.ts 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..f608447 --- /dev/null +++ b/scripts/check-leak.mjs @@ -0,0 +1,108 @@ +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, "public"), +]; + +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/; + +const errors = []; + +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 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`); + } +}; + +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; + } + } + + for (const filePath of files) { + const relPath = relative(rootDir, filePath); + if (!/\.(md|mdx|astro|json|ts|tsx|js|mjs)$/i.test(filePath)) continue; + + const text = await readFile(filePath, "utf8"); + scanText(relPath, text); + if (/\.(md|mdx)$/i.test(filePath)) { + scanFrontmatter(relPath, text); + } + } + + 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 (${files.length} 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/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 ` + + + + + + + + + + ${safeTitle} + ${safeSubtitle} + gitgitwi.github.io +`; +}; + +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/content.config.ts b/src/content.config.ts index 4646406..d768d7f 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,45 +1,16 @@ import { defineCollection } from "astro:content"; import { glob } from "astro/loaders"; -import { z } from "astro/zod"; -const colorwaySchema = z.enum(["canvas", "mint", "clay", "ink"]); - -const articleThemeSchema = z.object({ - colorway: colorwaySchema.default("canvas"), - layout: z.enum(["essay", "gallery", "docs"]).default("essay"), - density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"), -}); +import { articleSchema, tilSchema } from "./lib/content-schemas.ts"; const articles = defineCollection({ loader: glob({ base: "./src/content/articles", pattern: "**/*.{md,mdx}" }), - schema: z.object({ - title: z.string(), - description: z.string(), - pubDate: z.coerce.date(), - updatedDate: z.coerce.date().optional(), - tags: z.array(z.string()).default([]), - draft: z.boolean().default(false), - theme: articleThemeSchema.default({ - colorway: "canvas", - layout: "essay", - density: "comfortable", - }), - cover: z.url().optional(), - }), + schema: articleSchema, }); const til = defineCollection({ loader: glob({ base: "./src/content/til", pattern: "**/*.{md,mdx}" }), - schema: z.object({ - title: z.string(), - summary: z.string(), - pubDate: z.coerce.date(), - updatedDate: z.coerce.date().optional(), - tags: z.array(z.string()).default([]), - domains: z.array(z.string()).optional(), - visibility: z.enum(["public", "private"]).default("public"), - quizRefs: z.array(z.string()).optional(), - }), + schema: tilSchema, }); export const collections = { articles, til }; diff --git a/src/lib/content-schemas.ts b/src/lib/content-schemas.ts new file mode 100644 index 0000000..b8f99c4 --- /dev/null +++ b/src/lib/content-schemas.ts @@ -0,0 +1,35 @@ +import { z } from "astro/zod"; + +const colorwaySchema = z.enum(["canvas", "mint", "clay", "ink"]); + +const articleThemeSchema = z.object({ + colorway: colorwaySchema.default("canvas"), + layout: z.enum(["essay", "gallery", "docs"]).default("essay"), + density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"), +}); + +export const articleSchema = z.object({ + title: z.string(), + description: z.string(), + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + tags: z.array(z.string()).default([]), + draft: z.boolean().default(false), + theme: articleThemeSchema.default({ + colorway: "canvas", + layout: "essay", + density: "comfortable", + }), + cover: z.url().optional(), +}); + +export const tilSchema = z.object({ + title: z.string(), + summary: z.string(), + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + tags: z.array(z.string()).default([]), + domains: z.array(z.string()).optional(), + visibility: z.enum(["public", "private"]).default("public"), + quizRefs: z.array(z.string()).optional(), +}); From 7619e7fb1aee09c3d465bfa5b4a8bcdaf1ee3162 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:29:19 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(phase-4):=20SEO=20canonical=C2=B7JSO?= =?UTF-8?q?N-LD=C2=B7RSS=C2=B7OG=20=EB=A9=94=ED=83=80=EB=A5=BC=20Base?= =?UTF-8?q?=EC=97=90=20=ED=86=B5=ED=95=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit articles·til에 BlogPosting/TechArticle과 빌드타임 OG 경로를 연결한다. --- src/layouts/Base.astro | 43 ++++++++++++++- src/layouts/Page.astro | 46 ++++++++++++++-- src/lib/og.ts | 44 +++++++++++++++ src/lib/seo.ts | 86 ++++++++++++++++++++++++++++++ src/pages/articles/[...slug].astro | 20 ++++++- src/pages/rss.xml.ts | 28 ++++++++++ src/pages/til/[...slug].astro | 20 ++++++- 7 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 src/lib/og.ts create mode 100644 src/lib/seo.ts create mode 100644 src/pages/rss.xml.ts diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index bdb4ce0..0831dce 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -2,11 +2,31 @@ import '../styles/global.css'; import '../styles/prism-vitesse-light.css'; +import { buildCanonicalUrl, resolveOgImageUrl, type JsonLd } from '../lib/seo.ts'; + type Props = { title?: string; + description?: string; + canonicalPath?: string; + ogImagePath?: string; + ogType?: 'website' | 'article'; + jsonLd?: JsonLd | JsonLd[]; + noindex?: boolean; }; -const { title = 'gitgitWi' } = Astro.props; +const { + title = 'gitgitWi', + description = 'Astro, TypeScript, 디자인 시스템에 관한 글과 위키를 모아두는 개인 블로그입니다.', + canonicalPath = Astro.url.pathname, + ogImagePath, + ogType = 'website', + jsonLd, + noindex = false, +} = Astro.props; + +const canonicalUrl = buildCanonicalUrl(canonicalPath); +const ogImageUrl = resolveOgImageUrl(ogImagePath); +const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; --- @@ -15,6 +35,27 @@ const { title = 'gitgitWi' } = Astro.props; {title} + + + {noindex && } + + + + + + + + + + + + + + { + jsonLdEntries.map((entry) => ( + + diff --git a/src/components/search/SearchPanel.stories.ts b/src/components/search/SearchPanel.stories.ts new file mode 100644 index 0000000..70f3046 --- /dev/null +++ b/src/components/search/SearchPanel.stories.ts @@ -0,0 +1,8 @@ +import SearchPanel from "./SearchPanel.astro"; + +export default { + title: "search/SearchPanel", + component: SearchPanel, +}; + +export const Default = {}; diff --git a/src/components/search/SearchPanel.stylex.ts b/src/components/search/SearchPanel.stylex.ts new file mode 100644 index 0000000..f710bae --- /dev/null +++ b/src/components/search/SearchPanel.stylex.ts @@ -0,0 +1,19 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../styles/tokens.stylex.ts"; + +export const searchPanelStyles = stylex.create({ + root: { + display: "flex", + flexDirection: "column", + gap: tokens.space[4], + }, + note: { + color: tokens.color.inkTertiary, + fontSize: tokens.fontSize.sm, + lineHeight: tokens.lineHeight.body, + }, + mount: { + minHeight: "12rem", + }, +}); diff --git a/src/pages/search.astro b/src/pages/search.astro new file mode 100644 index 0000000..6f22b6f --- /dev/null +++ b/src/pages/search.astro @@ -0,0 +1,23 @@ +--- +import SearchPanel from '../components/search/SearchPanel.astro'; +import Heading from '../components/ui/Heading.astro'; +import Stack from '../components/ui/Stack.astro'; +import Text from '../components/ui/Text.astro'; +import Page from '../layouts/Page.astro'; +--- + + + +
+ 검색 + articles·til·태그에서 Pagefind로 검색합니다. +
+ +
+
diff --git a/src/pages/wiki/quiz.astro b/src/pages/wiki/quiz.astro index 30f2fe0..cd6c4f8 100644 --- a/src/pages/wiki/quiz.astro +++ b/src/pages/wiki/quiz.astro @@ -16,7 +16,7 @@ const deckCounts = getDeckItemCounts(); const allItems = getAllQuizItems(); --- - +
Wiki Quiz From edfc90b4736f111a54803fb416f16e4224acb944 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:29:19 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat(phase-4):=20404=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=EA=B8=80=C2=B7=ED=83=9C=EA=B7=B8=C2=B7=EA=B5=AC?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=95=88=EB=82=B4=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redirects.json 미매핑 구 Next 경로는 검색·홈으로 유도한다. --- src/components/patterns/NotFoundGuide.astro | 76 +++++++++++++++++++ .../patterns/NotFoundGuide.stories.ts | 24 ++++++ .../patterns/NotFoundGuide.stylex.ts | 27 +++++++ src/pages/404.astro | 23 ++++++ 4 files changed, 150 insertions(+) create mode 100644 src/components/patterns/NotFoundGuide.astro create mode 100644 src/components/patterns/NotFoundGuide.stories.ts create mode 100644 src/components/patterns/NotFoundGuide.stylex.ts create mode 100644 src/pages/404.astro diff --git a/src/components/patterns/NotFoundGuide.astro b/src/components/patterns/NotFoundGuide.astro new file mode 100644 index 0000000..9aff419 --- /dev/null +++ b/src/components/patterns/NotFoundGuide.astro @@ -0,0 +1,76 @@ +--- +import PostCard from '../content/PostCard.astro'; +import TagList from './TagList.astro'; +import Button from '../ui/Button.astro'; +import Heading from '../ui/Heading.astro'; +import Stack from '../ui/Stack.astro'; +import Text from '../ui/Text.astro'; +import * as stylex from '@stylexjs/stylex'; + +import { notFoundGuideStyles } from './NotFoundGuide.stylex.ts'; +import type { ContentListItem, TagIndexEntry } from '../../lib/content.ts'; +import { formatDisplayDate } from '../../lib/content.ts'; + +type Props = { + popularPosts: ReadonlyArray; + tags: ReadonlyArray; + requestedPath?: string; +}; + +const { popularPosts, tags, requestedPath } = Astro.props; +--- + +
+ + 페이지를 찾을 수 없습니다 + + { + requestedPath + ? `요청한 경로(${requestedPath})에 해당하는 페이지가 없습니다.` + : '요청한 페이지가 없거나 이전 주소로 접근했을 수 있습니다.' + } + + + +
+ + 예전 Next.js 경로(`/posts/*`, `/study/*`)는 `public/redirects.json`에 등록된 항목만 자동 + 이동합니다. 매핑이 없으면 검색이나 아래 링크를 이용해 주세요. + +
+ + + + + + + { + popularPosts.length > 0 && ( + + 인기 글 +
    + {popularPosts.map((post) => ( +
  • + +
  • + ))} +
+
+ ) + } + + { + tags.length > 0 && ( + + 태그 + ({ slug, label }))} /> + + ) + } +
diff --git a/src/components/patterns/NotFoundGuide.stories.ts b/src/components/patterns/NotFoundGuide.stories.ts new file mode 100644 index 0000000..c547729 --- /dev/null +++ b/src/components/patterns/NotFoundGuide.stories.ts @@ -0,0 +1,24 @@ +import NotFoundGuide from "./NotFoundGuide.astro"; + +export default { + title: "patterns/NotFoundGuide", + component: NotFoundGuide, +}; + +export const Default = { + args: { + requestedPath: "/posts/hello", + popularPosts: [ + { + id: "hello", + title: "Hello Astro", + summary: "블로그 이전 첫 글", + pubDate: new Date("2024-01-01"), + tags: ["astro"], + href: "/articles/hello", + kind: "article", + }, + ], + tags: [{ slug: "astro", label: "Astro", count: 2 }], + }, +}; diff --git a/src/components/patterns/NotFoundGuide.stylex.ts b/src/components/patterns/NotFoundGuide.stylex.ts new file mode 100644 index 0000000..6653079 --- /dev/null +++ b/src/components/patterns/NotFoundGuide.stylex.ts @@ -0,0 +1,27 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../styles/tokens.stylex.ts"; + +export const notFoundGuideStyles = stylex.create({ + section: { + display: "flex", + flexDirection: "column", + gap: tokens.space[8], + }, + list: { + display: "flex", + flexDirection: "column", + gap: tokens.space[3], + margin: 0, + padding: 0, + listStyle: "none", + }, + legacyNote: { + padding: tokens.space[4], + borderRadius: tokens.radius.md, + backgroundColor: tokens.color.surfaceMuted, + color: tokens.color.inkTertiary, + fontSize: tokens.fontSize.sm, + lineHeight: tokens.lineHeight.body, + }, +}); diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..6935aa6 --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,23 @@ +--- +import NotFoundGuide from '../components/patterns/NotFoundGuide.astro'; +import { + buildTagIndex, + getAllTaggedItems, + getRecentPosts, +} from '../lib/content.ts'; +import Page from '../layouts/Page.astro'; + +const popularPosts = await getRecentPosts(5); +const tags = buildTagIndex(await getAllTaggedItems()).slice(0, 12); +const requestedPath = Astro.url.pathname; +--- + + + + From cb37e2724dafd4bd0b522143743449c1f4044b1d Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:29:19 +0900 Subject: [PATCH 08/12] =?UTF-8?q?chore(phase-4):=20legacy-next=20=EC=9E=94?= =?UTF-8?q?=EC=9E=AC=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 이후 Next 스냅샷은 더 이상 유지하지 않는다. --- .gitignore | 1 + .oxlintrc.json | 1 - legacy-next/.eslintrc.js | 30 --- legacy-next/.prettierrc | 8 - legacy-next/components/atoms/Texts.tsx | 8 - .../molecules/TitleTexts.module.scss | 3 - .../components/molecules/TitleTexts.tsx | 12 - legacy-next/components/molecules/index.ts | 1 - legacy-next/constants/index.ts | 1 - legacy-next/constants/texts.ts | 2 - legacy-next/next-env.d.ts | 5 - legacy-next/next.config.js | 16 -- legacy-next/package.json | 38 --- legacy-next/pages/_app.tsx | 6 - legacy-next/pages/index.tsx | 19 -- legacy-next/pages/posts/design-system/01.md | 236 ------------------ .../posts/from-jekyll-to-nextjs-mdx/00.mdx | 83 ------ legacy-next/pages/posts/hello.mdx | 7 - .../posts/til/tdd/fullstack-tdd-by-newbie.mdx | 98 -------- .../posts/til/ts/enum-to-template-literal.mdx | 230 ----------------- .../pages/study/refactoring-javascript/01.mdx | 93 ------- legacy-next/styles/GlobalClassNames.ts | 1 - legacy-next/styles/_variables.scss | 4 - legacy-next/styles/globals.scss | 42 ---- tsconfig.json | 2 +- 25 files changed, 2 insertions(+), 945 deletions(-) delete mode 100644 legacy-next/.eslintrc.js delete mode 100644 legacy-next/.prettierrc delete mode 100644 legacy-next/components/atoms/Texts.tsx delete mode 100644 legacy-next/components/molecules/TitleTexts.module.scss delete mode 100644 legacy-next/components/molecules/TitleTexts.tsx delete mode 100644 legacy-next/components/molecules/index.ts delete mode 100644 legacy-next/constants/index.ts delete mode 100644 legacy-next/constants/texts.ts delete mode 100644 legacy-next/next-env.d.ts delete mode 100644 legacy-next/next.config.js delete mode 100644 legacy-next/package.json delete mode 100644 legacy-next/pages/_app.tsx delete mode 100644 legacy-next/pages/index.tsx delete mode 100644 legacy-next/pages/posts/design-system/01.md delete mode 100644 legacy-next/pages/posts/from-jekyll-to-nextjs-mdx/00.mdx delete mode 100644 legacy-next/pages/posts/hello.mdx delete mode 100644 legacy-next/pages/posts/til/tdd/fullstack-tdd-by-newbie.mdx delete mode 100644 legacy-next/pages/posts/til/ts/enum-to-template-literal.mdx delete mode 100644 legacy-next/pages/study/refactoring-javascript/01.mdx delete mode 100644 legacy-next/styles/GlobalClassNames.ts delete mode 100644 legacy-next/styles/_variables.scss delete mode 100644 legacy-next/styles/globals.scss 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/.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/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 ; -} diff --git a/legacy-next/components/molecules/index.ts b/legacy-next/components/molecules/index.ts deleted file mode 100644 index 13890f7..0000000 --- a/legacy-next/components/molecules/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TitleTexts } from "./TitleTexts"; diff --git a/legacy-next/constants/index.ts b/legacy-next/constants/index.ts deleted file mode 100644 index 7c7471e..0000000 --- a/legacy-next/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./texts"; diff --git a/legacy-next/constants/texts.ts b/legacy-next/constants/texts.ts deleted file mode 100644 index df16ce8..0000000 --- a/legacy-next/constants/texts.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const MAIN_TITLE = "gitgitWi's space"; -export const MAIN_META_DESC = "gitgitWi's space on GitHub Pages"; diff --git a/legacy-next/next-env.d.ts b/legacy-next/next-env.d.ts deleted file mode 100644 index 4f11a03..0000000 --- a/legacy-next/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/legacy-next/next.config.js b/legacy-next/next.config.js deleted file mode 100644 index 0c05e90..0000000 --- a/legacy-next/next.config.js +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-check -/* eslint-disable no-undef */ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const withMdx = require("@next/mdx")({ - extension: /\.mdx$/, -}); - -module.exports = withMdx({ - reactStrictMode: true, - eslint: { - ignoreDuringBuilds: true, - }, - basePath: process.env.NEXT_PUBLIC_BASE_PATH, - assetPrefix: process.env.NEXT_PUBLIC_BASE_PATH, - pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"], -}); diff --git a/legacy-next/package.json b/legacy-next/package.json deleted file mode 100644 index 4b0ee63..0000000 --- a/legacy-next/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "gh-blog", - "version": "0.1.0", - "private": true, - "author": "gitgitWi", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "export": "next export", - "lint": "next lint" - }, - "dependencies": { - "@mdx-js/loader": "^1.6.22", - "@next/mdx": "^11.1.0", - "next": "12", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "sass": "^1.38.0" - }, - "devDependencies": { - "@types/node": "^16.6.1", - "@types/react": "^17.0.17", - "@typescript-eslint/eslint-plugin": "^4.28.5", - "@typescript-eslint/parser": "^4.28.5", - "eslint": "7.32.0", - "eslint-config-next": "11.0.1", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-prettier": "^3.4.0", - "eslint-plugin-react": "^7.24.0", - "oxfmt": "^0.67.0", - "oxlint": "^1.82.0", - "prettier": "^2.3.2", - "typed-scss-modules": "^4.1.1", - "typescript": "^4.3.5" - }, - "packageManager": "bun@1.4.2" -} diff --git a/legacy-next/pages/_app.tsx b/legacy-next/pages/_app.tsx deleted file mode 100644 index b036d6d..0000000 --- a/legacy-next/pages/_app.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { AppProps } from "next/app"; -import "@/styles/globals.scss"; - -export default function MyApp({ Component, pageProps }: AppProps) { - return ; -} diff --git a/legacy-next/pages/index.tsx b/legacy-next/pages/index.tsx deleted file mode 100644 index 5b1d98e..0000000 --- a/legacy-next/pages/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Head from "next/head"; -import { MAIN_META_DESC, MAIN_TITLE } from "@/constants/index"; -import { TitleTexts } from "@/components/molecules"; - -export default function Home() { - return ( -
- - {MAIN_TITLE} - - - - -
- -
-
- ); -} diff --git a/legacy-next/pages/posts/design-system/01.md b/legacy-next/pages/posts/design-system/01.md deleted file mode 100644 index 39c427f..0000000 --- a/legacy-next/pages/posts/design-system/01.md +++ /dev/null @@ -1,236 +0,0 @@ -# [FE쥬니어의 Design System] 01. Personal Design System, `Storybook`, `Vite`로 환경 설정하기 - -> _`Storybook` + `React`로 DesignSystem/ComponentLibrary 만드는 튜토리얼은 이미 블로그, Youtube에 널려있다_ -> -> _Airbnb, GitHub 등 개발팀에서 만든 `Storybook`은 물론이고, GitHub가 공개된 경우도 있다 *(아래 references 참고)*_ - -[👉  구현 중인 `GetIt-UI` GitHub 보기](https://github.com/gitgitWi/getit-ui) - -## Motivation - -- 요즘 차트 컴포넌트 구현을 `Storybook` 기반으로 진행중([zum-stock-line-stories](http://zum-stock-line-stories.surge.sh/))인데, 생각보다 엄청난 생산성 향상을 맛보며 재미를 느낌 - - - 별도의 페이지를 구성하지 않아도 컴포넌트 구현 결과를 HMR로 볼 수 있음 (_이건 당연한 거!_) - - **_개발자 도구 들어가서 ➡️ 메뉴를 클릭하고 ➡️ 뭔가를 조정해야 하는 수고들을 덜어줌!_** - - [Measure & Outline](https://storybook.js.org/docs/react/essentials/measure-and-outline): 컴포넌트 사이즈, 아웃라인 등 확인 가능 - - [Viewport](https://storybook.js.org/docs/react/essentials/viewport): mobile viewport preset 가능 - - Docs로 한번에 여러 렌더링 상태 확인 가능 - - addon 생태계가 활성화되어있음 - - Jest, Cypress 등 다양한 툴과의 통합 - - `Storybook`을 따로 빌드, 배포 가능 - - - 현재까지 개발 진행상황 공유 가능 - - 색상, 크기, 데이터값 등 **_속성들을 [`controls`](https://storybook.js.org/docs/react/essentials/controls)로 바꿔서 바로 확인 가능_** 👉 디자이너/기획자와의 보다 빠른 소통 가능할 듯?! - - ![zum-stock-line-stori_2022-2-2](https://user-images.githubusercontent.com/57997672/156254919-f4795507-1ecf-4e88-b8ef-a037ab071169.gif) - -
- -- 거의 1년째 방치 중인 개인 블로그는 물론이고, **_셋팅만 열심히 했던 개인 프로젝트들_** 빠르게 완성도를 높이기 위해 디자인 시스템을 만들어보자 - - - 단기적으로 1년 정도는 **컴포넌트 라이브러리** 구축 위주 방향이 될 듯, 아무래도 디자인/UX에 대한 지식이 없기 때문 - - 장기 프로젝트로 디자인 시스템으로 발전시켜 나가자 - -
- -- 회사 업무에 적용하기 전 다양한 테스트를 위한 도구로 사용할 수 있음 - - 하고 싶은 거 다하자!! - - 얼마전 _`당근마켓 프론트엔드 개발자 라이브 QnA 세션`_ 에서 얻은 인사이트 - - _회사 코드를 포트폴리오로 사용할 수는 없으니 유사한 구현 내용을 개인 GitHub에 올려놓는 것도 방법이다!_ - ---- - -## Design Patterns & Techs I Used - -- **Atomic Design Pattern & Monorepo** - - - 기본적으로 컴포넌트 라이브러리이기 때문에, atoms, molecules, organisms 정도까지만 하면 될 듯 - - 이 단계에 따라 각각의 패키지 구성; `atoms/button`, `atoms/text` 등 - - 디자인 시스템 중 단일 Repo로 구성하는 경우도 많지만, 추후 `Svelte`, `Solid` 등 다양한 프레임워크/라이브러리 및 `rollup`, `esbuild` 등 다양한 빌드 툴 사용해볼 수 있도록 Monorepo로 구성 - - `Yarn berry` [PnP 모드](https://yarnpkg.com/features/pnp)에서 [workspace 플러그인 사용](https://github.com/yarnpkg/berry/tree/master/packages/plugin-workspace-tools) - - ```bash - getit-ui/ - ┣ atoms/ # atom packages - ┃ ┣ button/ - ┃ ┃ ┣ src/ - ┃ ┃ ┣ package.json - ┃ ┃ ┣ tsconfig.json - ┃ ┃ ┣ tsconfig.node.json - ┃ ┃ ┗ vite.config.ts - ┃ ┗ text/ - ┃ ┣ src/ - ┃ ┣ package.json - ┃ ┣ tsconfig.json - ┃ ┣ tsconfig.node.json - ┃ ┗ vite.config.ts - ┣ molecules/ # molecules packages - ┣ stories/ # storybook package - ┃ ┣ .storybook/ - ┃ ┃ ┣ main.ts - ┃ ┃ ┗ preview.ts - ┃ ┣ src/ - ┃ ┃ ┣ Introduction.stories.mdx - ┃ ┃ ┣ react-app-env.d.ts - ┃ ┃ ┗ setupTests.ts - ┃ ┣ .gitignore - ┃ ┣ package.json - ┃ ┗ tsconfig.json - ┃ # root configs - ┣ .editorconfig - ┣ .eslintignore - ┣ .eslintrc.js - ┣ .gitignore - ┣ .pnp.cjs - ┣ .pnp.loader.mjs - ┣ .prettierrc - ┣ .yarnrc.yml - ┣ Dockerfile.dev - ┣ README.md - ┣ package.json - ┣ tsconfig.components.json - ┣ tsconfig.json - ┗ yarn.lock - ``` - -
- -- **`Storybook`** - - - 구현된 컴포넌트들 시각 테스트 - - 회귀, E2E 등 다양한 테스트, 문서화 - - 별도의 전용 패키지(`stories`)를 만들어서 실행 - -
- -- **`Vite` + `React`** - - - 일단은 `React` 컴포넌트 위주 구성 - - `Vite` [라이브러리 모드](https://vitejs-kr.github.io/guide/build.html#library-mode)로 빌드 - -
- -- **`CSS(SCSS) Module`** - - - 개인적으로 `CSS`를 활용할 때 가장 선호하는 방식 - - `styled-component`/`emotion` 보다 `CSS` 그 자체를 사용하면서, `JS` 방식으로 `className` 적용을 컨트롤 할 수 있음 - - [Real-world CSS vs. CSS-in-JS performance comparison](https://pustelto.com/blog/css-vs-css-in-js-perf/); 성능 면에서 `CSS-in-JS`가 좋지 않다는 이야기도.. - - 특히 이번 대선 차트 구현할 때 이점을 크게 맛보게 되었는데, IE에서 `grid(-ms-grid)`가 생각대로 컨트롤 하기 어렵다는 점 때문에, IE 전용 클래스를 따로 만들어 `userAgent`에 따라 분기처리해 적용함 - - ```scss - // 모던 브라우저 클래스 - .line { - @extend .line-default; - display: grid; - grid-template-columns: 13px minmax(36px, auto) minmax(46px, auto); - } - - // IE 전용 클래스 - .line-ie { - @extend .line-default; - display: -ms-flexbox; - - div { - margin-right: 5px; - } - - p { - width: 47px; - } - } - ``` - - ```typescript - // line 컴포넌트 (VanillaJS) - classNames: [IS_IE ? styles["line-ie"] : styles["line"], ...classNames]; - // ... - ``` - ---- - -## _Problem & Solutions_ - -### `Vite` 라이브러리 모드 빌드 -> 모노레포에서 사용시 `CSS`를 가져오지 않는 문제? - -**_Problem_** - -- HTML 파일을 생성하는 게 아닌, JS로 컴포넌트 렌더링 로직만 만드는 것 -- 일반적인 페이지를 빌드하는 경우, `Webpack` 등 번들러가 `HTML`에 `link` 태그를 자동삽입하고, 렌더링시 스타일을 반영함 -- 그런데 라이브러리 모드에서 빌드시 JS에 `CSS Module` 클래스명만 가져오고, CSS 파일(`import 'styles.css'`)을 가져오지 않음 -- **_다른 패키지에서 사용시 스타일 적용 안됨_** - - ![image](https://user-images.githubusercontent.com/57997672/156269529-6740dc85-674a-43b1-be44-badcdd9b6d4f.png) - -
- -**_Solution_** - -- [vite-plugin-css-injected-by-js](https://www.npmjs.com/package/vite-plugin-css-injected-by-js?activeTab=readme) 사용 - - - `vite.config.ts`에서 플러그인 추가 - - ```javascript - import cssInject from "vite-plugin-css-injected-by-js"; - - export default defineConfig({ - plugins: [react(), cssInject()], - - /** 기타 설정 */ - }); - ``` - - - 빌드시 아래와 같이 `style` 태그 생성 로직을 생성, 빌드된 파일 상단에 삽입 - - ![image](https://user-images.githubusercontent.com/57997672/156269833-c3533abf-75d6-4d09-923c-b1dd37b78ec8.png) - - - 동일 컴포넌트를 여러개 호출하더라도 하나의 `style` 태그만 생성함 - - ![image](https://user-images.githubusercontent.com/57997672/156270703-4cc60b4e-36c5-4b03-9e56-86c26d0d5347.png) - - - _근데 이렇게 되면 `CSS-in-JS`를 쓰는 방식과 성능면에서 차이가 없어질 수도 있지 않을까하는 의문이.._ - -
- -### Storybook 전용 `stories` 패키지에서 Vite 사용시 빌드 오류 - -**_Problem_** - -- 현재 각각의 컴포넌트 패키지 + `Storybook` 전용 패키지가 있는 상황 -- 컴포넌트 패키지들이 이미 `Vite`를 사용하고 있고, (왠만하면) `Webpack` 보다는 `Vite`가 빌드 타임이 짧기 때문에 [Vite 빌더](https://storybook.js.org/blog/storybook-for-vite/)를 써보려고 시도 함 -- 문제는 `Yarn berry` 환경에서 `Vite` 빌더가 의존성 제대로 못 찾고 빌드가 안됨 - - ; '22.3.2. 현재 아직 해결되지 않은 이슈😭 - -**_Solution_** - -- 아쉽지만 `stories` 패키지만 빌더를 `CRA` + `Webpack5`로 지정해 사용 중 -- 내 생각에 베스트는, `Storybook` 전용 패키지를 별도로 분리하지 않고, 각각의 컴포넌트 패키지에서 `story` 파일를 만들어서 root에서 preview를 실행하도록 하는 것 - - 다만 Monorepo 구조의 root에서는 뭔가 설정하기가 까다로움 - - `Storybook` 자체가 빈 프로젝트에서 실행하는 게 아니기 때문인듯 - > _Storybook needs to be installed into a project that is already set up with a framework. It will not work on an empty project.()_ - - _좀더 연구해봐야 할 듯..?_ - - 한편 패키지를 분리했기 때문에, 다른 패키지에서 `import` 했을 때 잘 되는지 확인 할 수 있다는 장점은 있는 듯 - -
- ---- - -### References - -#### Design System Overview - -- [토스, 에어비앤비로 알아보는 디자인 시스템의 내일](https://dewberry9.github.io/future-of-design-system) -- [Do you think your component library is your design system? Think again](https://uxdesign.cc/do-you-think-your-component-library-is-your-design-system-think-again-7e2c902b5275) - -#### Real-World Cases - -- [awesome-storybook](https://project-awesome.org/lauthieb/awesome-storybook) -- [awesome-design-systems](https://github.com/alexpate/awesome-design-systems) -- 쿠팡: [이커머스에 최적화된 디자인 시스템, RDS](https://youtu.be/g4f7p0YcVnc) - 주로 UX 디자인에 대한 이야기 -- 토스 - - [TDS로 UI 쌓기 : 그 많던 코드는 누가 다 치웠을까?](https://youtu.be/pfKuEuufgdc) - 토스 안드로이드 - - [Do more, with less. - 디자인 시스템, 그다음은?](https://youtu.be/LmLchZ4tCXc) - 토스 FE -- 배민: [[6월 우아한테크세미나] 디자인시스템이 가져온 변화](https://youtu.be/aVHLcQzcRbA) - 배민 FE -- LINE: [개발자와 디자이너의 협업을 위한 LINE 디자인 시스템, LDS 소개](https://engineering.linecorp.com/ko/blog/line-design-system/) -- SOCAR: [[SOCAR FRAME 만들기 #1] 쏘카의 디자인 시스템 맛보기1~5](https://tech.socarcorp.kr/design/2020/06/23/socar-design-system-01.html) -- 카카오: [FE개발자의 성장 스토리 03 : 카카오 어드민 UI 컴포넌트를 모노레포로 개발하여 얻은 것들](https://tech.kakao.com/2020/12/03/frontend-growth-03/) -- AirBnB: [Building (And Re-Building) the Airbnb Design System | Maja Wichrowska & Tae Kim](https://youtu.be/fHQ1WSx41CA) - 2016년 이후 AirBnB에서 React 기반 디자인 시스템을 만들게 된 배경 diff --git a/legacy-next/pages/posts/from-jekyll-to-nextjs-mdx/00.mdx b/legacy-next/pages/posts/from-jekyll-to-nextjs-mdx/00.mdx deleted file mode 100644 index 207e592..0000000 --- a/legacy-next/pages/posts/from-jekyll-to-nextjs-mdx/00.mdx +++ /dev/null @@ -1,83 +0,0 @@ -import { TitleTexts } from "@/components/molecules"; - -## GitHub Pages, `Jekyll` 기반에서 `Next.js` + `MDX`로 - - - -
- -### intro. - -작년 2월, 본격 개발에 입문한 지 만 2개월 정도 되어서 `GitHub Pages`라는 걸로 블로그를 만들 수 있다는 걸 알게 되었다. - -뭣도 모르고 유튜브 따라, 블로그 따라 헤매면서 어찌어찌 `Jekyll` 기반 블로그를 만들게 되었고.. -처음 한두달은 "이런 게 개발자 블로그지!"라며 교육 받은 거, 멘토링 행사 참여한 거, 생애 첫 코딩 테스트 후기 등등 신나게 올렸다. - -
- -### 문제는 - -- `Ruby` 기반이어서 레퍼런스를 찾기도 어렵고, -- 그래서 뭔가 커스텀을 하고 싶어도 하기 어려워지고, -- 무엇보다 포스팅이 많아질수록 **_컴파일하는 시간이 크게 늘어난다_** - - 단순히 글만 올리는게 아니라, 태그별 검색도 넣고, 페이지네이션도 들어가는데, - - 이걸 전부 `html`로 만들어버리기 때문에 불필요한 중복이 너무 많아지는 느낌 - -그래서 한동안은 그냥 티스토리, velog를 사용하거나, Notion에 정리하거나, _~~아예 안하게 되었..~~_ - -
- -### 블로그 못 잃어.. - -최근까지 거의 강의 수강이나 독서를 하면서 Notion에 정리하고 있었다 - -아무래도 `Markdown` 문법을 지원하고 거기에 이런저런 임베딩도 편하고 공유도 편한 편이라 쓰게 되는 것 같다 - -한편으로는 구글링으로 뭔가를 검색할 때 나오는 블로그 글들의 수준이.. - -나까지 굳이 소음을 더 만들어야 하나하는 생각에 블로그를 잘 안하게 되기도 했다 - -그런데 최근 텔레그램에서 재테크 네임드들이 블로그 좀 하라고 강력 권장하는 글을 많이 보게 되기도 했고, - -무엇보다 사내 블로그 스터디를 하게 되면서 좀더 반강제적인(!) 동기가 생기게 되었다 - -
- -### 왜 `Next.js` + `MDX` 인가 - -`Next.js`를 선택하게 된 데는 특별한 이유는 없었고, - -요즘 FE 개발한다면 십중팔구는 `React`를 하고, `React`를 하다보면 `Next.js`를 하게 되는, 대유행(?) 시대이기 때문에, 나도 언젠가 다음 개인 프로젝트는 `Next.js`로 해야겠다는 막연한 생각이 있었다 - -지금 회사에서는 `Vue.js`를 쓰기 때문에 `Nuxt.js`를 쓸 수도 있긴 하겠지만, `Vue.js` 레퍼런스도 그리 많지 않은데 `Nuxt.js` 관련해선 더 없을 것 같기도 하고, `VSCode`에서 타입추론이나 자동완성이 잘 안되는게 답답한 맛도 있기도 하고, Udemy에 결제해 논 `React`, `Next.js` 강좌도 살살 들어야해서 `Next.js`로 가기로 했다 - -그렇다면 `Jekyll`에서 했던 것처럼 `Markdown` 으로 글을 작성할 수 있는게 `Next.js`에선 뭐가 있을까 검색해보니, `Markdown` 관련 플러그인을 설치하기도 하고, MDX를 사용하기도 하는 것 같았다. - -`MDX`는 `Markdown`에 `React` 컴포넌트를 그냥 `import`로 가져와서 붙여넣기만 하면 되는 식이어서 글 쓰기도 편하고, 컴포넌트로 뭔가 커스텀하기도 좋아보였다. 다만 코드(?)가 너무 변태 같다는 것이 조금 어색하긴 하다.. - -```mdx -import Text from "../component/Text.tsx"; - -## 이건 마크다운 헤더 - - -``` - -
- -### 마지막으로, 앞으로의 로드맵 - -할게 많구만.. - -- [ ] 아름다운 컴포넌트 구현 (⭐️⭐️) -- [ ] custom code syntax hightlight (⭐️) -- [ ] GitHub 댓글 컴포넌트 붙이기 -- [ ] 사이트 맵 -> 구글 검색 다시 등록하기 -- [ ] RSS 생성 -- [ ] 다른 사이트 임베딩; 노션 북마크, 임베딩 효과 참고.. - - [ ] Gist나 Codepen, Stackblitz 등 웹 IDE - - [ ] Youtube -- [ ] post 마다 OpenGraph 태그 생성 - - [ ] 썸네일 자동 생성기..? -- [ ] 사이트 내 검색 -> 서버리스 SQL 사용?? -- [ ] 개인 포트폴리오 연동 diff --git a/legacy-next/pages/posts/hello.mdx b/legacy-next/pages/posts/hello.mdx deleted file mode 100644 index 0d2e9b9..0000000 --- a/legacy-next/pages/posts/hello.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import { TitleTexts } from "@/components/molecules"; - - - -```typescript -const hello = () => `World!`; -``` diff --git a/legacy-next/pages/posts/til/tdd/fullstack-tdd-by-newbie.mdx b/legacy-next/pages/posts/til/tdd/fullstack-tdd-by-newbie.mdx deleted file mode 100644 index 371b992..0000000 --- a/legacy-next/pages/posts/til/tdd/fullstack-tdd-by-newbie.mdx +++ /dev/null @@ -1,98 +0,0 @@ -# TDD 뉴비가 느낀 러닝 커브와 재미 - -- 주간회고 02. TDD 뉴비의 Full-stack(?) TDD 1편 - -#### 3줄 요약 - -> - 테스트 코드 뉴비로서 어디서부터 뭘 해야될 지 몰라 막막함을 느끼고, 파일럿 프로젝트 때의 안좋은 추억이 되살아났다 -> - 단순히 툴 사용법이 아니라, 테스트 코드를 작성하는 원칙이 뭔지 좀더 공부하고 정리해보고 싶다 -> - 많은 장점들이 있지만, 무엇보다 테스트 패스할 때의 소소한 즐거움이 제일 큰 장점인 듯 - -
- ---- - -## 테스트 코드 앞에만 서면~ 나는 왜 작아지는가~ - -![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1643041008094/t76_KRs7T.png) - -> _지금 보니, 약간 망했다는 얘기를 굉장히 구구절절 쓴 것 같다..._ - -작년 수습기간 파일럿 프로젝트(`investing.com` 클론, [👉 파일럿 프로젝트 회고](https://zuminternet.github.io/zum-front-investing-clone/)) 초반에 여러가지로 시간을 많이 날렸지만, 그중에서도 가장 허탕을 쳤던 것 중 하나는 TDD로 개발을 해본답시고 1주일을 날린 것이다 - -1년 반이라는 예상보다 길고 힘들었던 커리어 전환 기간을 거치고 취업한 상태여서 너무 신이 났었는지, **_이것도 해보겠다 저것도 해보겠다 너무 욕심이 많았던_** 것 같기도 하다 - -`Vue.js`도 처음해보는데 `TDD`를 해보겠다하고, 데이터 업데이트를 실시간으로 해보겠다고 `SSE`를 도입하고, 몇번 써보지도 않은 `MongoDB`에 잘 맞지도 않는 `TypeORM`을 억지로 끼워넣느라 많지도 않은 시간을 삽질하는데 써버렸고, 결국 중간발표까지 요구사항 절반도 못하게 되는 대참사가 벌어졌다 - -그나마 `SSE`, `TypeORM`은 어떻게든 됐고, `아 이렇게 하는거 아니구나` 하는 걸 깨닫기도 했지만, **_`Frontend TDD`는 결국 시간만 쓰고 혼란스러움만 남긴 채 접게 됐다_** - -
- -그렇게 약 5개월의 시간이 지나고, 차트 서버를 담당하게 되면서 모놀리식 구조에서 모노레포 구조로 바꾸고, 프레임워크를 `Koa.js`에서 `Nest.js`로 변경하는 대공사를 벌이게 된다 - -내가 바꾼 코드들이 제대로 동작하는지에 대해 수작업으로 확인하는 과정들이 반복됐고, 이 **단순 반복 작업들이 너무 귀찮아지면서 자연스럽게 TDD에 대한 관심으로 흘러갔다** - -프로젝트 구조를 모노레포로 바꿀 당시에는 줌투자 2차 오픈 일정이 빡빡하게 돌아가던 중이라, 구조 변경하랴 맵차트 개발하랴 테스트 코드를 넣을 시간이 없었지만, 오픈 이후 조금 여유로워진 상황에서는 TDD 방식으로 프레임워크를 `Nest.js`로 변경할 수 있는 시간이 충분했다 - -리팩토링 관련 책들을 읽으면서 **테스트 없는 리팩토링은 반쪽짜리 리팩토링이라는 걸 깨달았기 때문에**, 이번에는 TDD를 제대로 해보면서 리팩토링을 하고싶다는 마음이 들었다 - -문제는, 다시 한번 마주한 ***테스트 코드를 어떻게 짜야하는가에 대한 막막함***이었다 - -
- -## 지금 당장 TDD 하고 싶은데, 뭘 어떻게 해야 할까..??? - -TDD는 `요즘 개발자(?)`라면 당연히(?) 하는 거기도 하고, 워낙 많은 테스트 관련 책과 강의, 블로그, 아티클들이 있기 때문에 공부를 한다면 충분히 할 수 있는 분야인 것 같다 - -하지만 **당장 프로젝트에 적용해보려고 구글링해보면, 생각보다 볼만한 것들이 많지 않았던 것 같다** - -물론 JS 테스트 코드를 짜려고 하면 언제나 나오는 `Jest` 사용법 정도는, 정말 너무 많다 싶을 정도로 자료가 많다 - -> _이런 라이브러리나 툴 사용법은 필요할 때마다 그때그때 공식문서 위주로 찾아보고, StackOverflow 검색해보고, 그래도 뭔가 부족하다 싶으면 `GitHub`이나 [SourceGraph](https://sourcegraph.com/search), [tabnine](https://www.tabnine.com/code) 오픈 소스 코드 검색으로 예제를 찾아보면 꽤 괜찮은 내용들이 많다_ - -그러나 당장 프로젝트에 적용하기 위해 찾아보고 싶었던 주제는 이런 툴 사용법이 아닌, **_단위 테스트에선 무엇을 어떻게 테스트해야 하는가, Controller를 테스트하는데 MockService는 왜 만들어야하나 등 원리원칙적인 내용들을 찾고 싶었다_** - -파일럿 프로젝트 때 TDD에 실패했던 이유 중 하나는, 컴포넌트 단위 테스트에서 이벤트, api 요청 등 단위 테스트 범위를 벗어나는, e2e 테스트에서 해야 될 것들을 자꾸 하려고 했기 때문이다 - -다른 하나는 테스트를 할 때 테스트 대상이 아닌 나머지 로직과 데이터를 Mock으로 만들어 제공하는 의미를 알지 못했고, 모든 걸 다 Mock으로 만들어서 제공하면 당연히 성공할 수 밖에 없는거 아닌가 하는 의문 때문에, 테스트 코드를 구현하는 게 의미 없다는 생각이 들었기 때문이다 - -부족한 구글링 실력 탓도 있겠지만 원하는 결과는 찾기 어려웠고, 차라리 내가 좀더 공부하고 정리해서 공유하는게 낫겠다는 생각이 들었다 - -올해 안으로 가이드라인을 정리해서, GitBook 이나 간단한 웹으로 만들어 공유해 볼 예정이다 - -
- -![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1643045178911/CJd3G_5f3.png) - -![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1643045247027/uefVjTMZ9.png) - -하지만 기왕 쓰는 김에.. 회사 팀원들, 블로그, 유튜브, 강의 등 여기저기서 주워들은 내용들, 그리고 직접 테스트 코드를 구현하며 깨닫게 된 **아주 간단한 원칙**은 이렇다 - -- 모든 테스트는 주어진 상황/컨텍스트(`given`)에 대해 성공 조건(`when`)-성공 결과(`then`), 실패 조건(`when`)-실패 결과(`then`)를 테스트한다 - -- 단위 테스트는 함수/클래스 내부 동작이 잘 동작하는지를 테스트하기 때문에, 당연히 나머지 외부에 대해서는 정상 동작한다고 가정한다 - - Mock 함수, 데이터를 잘 만들어야 하는 이유 👉 이 때문에 그다지 효율적이진 않을 수 있다 - - 새로운 기능 개발하는 경우엔, Mock 함수/데이터를 만들면서 인터페이스나 추상클래스를 만들수 있기 때문에 설계 측면에선 괜찮을 듯 - -![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1643046228793/CjJWMTHL1.png) - -- e2e/통합 테스트는 내부 동작보다는 함수/클래스 등 전반적인 기능을 테스트할 수 있다 - - 말그대로 end-to-end, 사용자 input에 따른 output이 어떻게 나오는지를 테스트 - - 실제 데이터, api를 가지고 테스트할 수 있음; mock 데이터/함수도 당연히 사용 가능 - - 리팩토링 등 이미 구현된 기능에 대해 코드 변경이 있는 경우 - -
- -## 10년 만에 느껴보는 수학 문제집 푸는 맛(?) - -![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1643101897718/aeW-AlUHM.png) - -> _VSCode, IntelliJ 등 에디터에서 테스트 관련 설정을 하면, 굳이 테스트 관련 명령을 하지 않아도 알아서 코드 수정을 반영해 테스트를 실행해준다_ - -테스트 코드의 장점은 누구나 알다시피, 코드 변경을 하더라도 기능이 잘 작동하는지를 빠르게 확인할 수 있다는 점이다 - -그것말고도 개인적으로 꽤 괜찮은 side-effect를 느낀 부분은 ***개발자로서 자기 효능감을 느낄 수 있다***는 점이다 - -학창 시절 수학 문제집을 풀고 채점할 때, 동그라미가 하나씩 늘어날때마다 기분이 좋았던 기억이 있는데, 깔끔하게 통과한 테스트 결과를 보면 왠지 상쾌하고 기분이 좋아지는 듯 하다 - -개발은 공부하면 할수록 스스로 부족하다고 느낄 때도 많지만, 이렇게 사소하게나마 *`내가 만든 코드가 테스트를 통과했구나`*하는 즐거움을 주는 게 가장 큰 장점이 아닐까 생각이 든다 diff --git a/legacy-next/pages/posts/til/ts/enum-to-template-literal.mdx b/legacy-next/pages/posts/til/ts/enum-to-template-literal.mdx deleted file mode 100644 index a3d8a8a..0000000 --- a/legacy-next/pages/posts/til/ts/enum-to-template-literal.mdx +++ /dev/null @@ -1,230 +0,0 @@ -# [TypeScript] `enum` -> `literal` 타입 갈아타기(+`class-validator`, `template literal` 타입 활용) - -### 3줄 요약 - -> - `enum`은 tree-shaking, memory-leak 문제가 있고, 여러 enum을 하나의 enum으로 합칠 수 없다 -> - `class-validator`에서 `IsEnum`을 쓰려면, 타입이 아닌 객체가 필요하다 -> - `literal`과 `Readonly>` 유틸 타입을 활용해 문제를 해결했다 - -[👉 hashnode에서 보기](https://wiii.hashnode.dev/enum-to-template-literal) - ---- - -## `enum`은 비싸고 불편하다! - -다른 언어들과 마찬가지로 `TypeScript`도 열거형 타입으로 [`enum`](https://www.typescriptlang.org/ko/docs/handbook/enums.html)을 제공한다 - -함수 인자로 특정 string만 들어와야 하는 경우 등 유용하게 쓸 수 있다 - -
- -### Tree-shaking, 메모리 문제 - -그러나 Line 기술 블로그(['TypeScript enum을 사용하지 않는 게 좋은 이유를 Tree-shaking 관점에서 소개합니다.'](https://engineering.linecorp.com/ko/blog/typescript-enum-tree-shaking/))에서 잘 설명하는 것처럼, 순진한 enum 타입은 tree-shaking이 안되고 메모리 낭비까지 이어질 수 있다. - -
- -### 예상과는 다른 Type Union - -무엇보다 개인적으로 가장 불편하게 느껴지는 것은 다른 타입들과는 다르게, **_enum은 `union`으로 여러 enum을 하나로 합칠 수 없다는 점이다_** - -아래 예시 코드는 실무에서 구현 중인 API 타입 일부를 조금 수정해서 가져온 것이고, 전형적인 enum을 가져왔다 - -```typescript -enum PeriodA { - DAILY = "DAILY", - MONTHLY = "MONTHLY", -} - -enum PeriodCommon { - WEEKLY = "WEEKLY", - QUARTERLY = "QUARTERLY", - YEARLY = "YEARLY", -} -``` - -[Union Type](https://www.typescriptlang.org/ko/docs/handbook/2/everyday-types.html#%EC%9C%A0%EB%8B%88%EC%96%B8-%ED%83%80%EC%9E%85)을 쓰는 대부분은 아래처럼 A 타입 일수도 있고, B 타입일 수도 있을 때이다 - -```typescript -type StringOrNumber = string | number; - -const strOrNumberLogger = (what: StringOrNumber) => console.log(what); -strOrNumberLogger("asdf"); -strOrNumberLogger(123123); -``` - -
- -**_그러나 이 넘의 enum은 그렇지 않다_** - -`PeriodA`와 `PeriodCommon`를 합친 `Periods`라는 enum을 만들고 싶지만 그 과정은 쉽지 않다 - -아래처럼 타입 Union을 하면 당장은 에러가 나진 않지만, 실질적으로는 사용할 수 없는 타입이 된다 - -```typescript -type Periods = PeriodA | PeriodCommon; - -const periodLogger = (period: Periods) => console.log(period); -periodLogger("DAILY"); // error -``` - -
- -Intersection은 더더욱 안된다 - -둘 사이에 공통점이 없기 때문에 **`never`** 타입이 된다 - -```typescript -type Periods = PeriodA & PeriodCommon; // -> never - -const periodLogger = (period: Periods) => console.log(period); -periodLogger("DAILY"); // error -``` - -
- -타입 assertion으로 에러를 안낼 수는 있는데..***아무 의미 없는 타입 선언***이 되어 버린다 - -![](https://images.velog.io/images/johnwi/post/669de5ad-34fb-4a23-b86e-a8cb8ea5326a/image.png) - -
- ---- - -## `class-validator`의 `IsEnum`을 쓰려면 객체가 필요하다! - -가장 속 편한건 단순히 `literal`'만' 쓰는 거다 - -토스 기술 블로그 [Template Literal Types로 타입 안전하게 코딩하기](https://toss.tech/article/template-literal-types)에서 소개하는 것처럼, - -TypeScript에는 문자열을 가지고 다양한 타입을 만들어 낼 수 있는 재미있는(?) 기능이 있다 - -
- -다만 위에서 예시로 가져온 `Periods`는 단순히 함수 인자의 타입만 추론하는데 쓰는 것이 아니라, - -아래와 같이 **[`class-validator`](https://github.com/typestack/class-validator)를 활용해 API 요청 유효성 검사**에도 사용될 수 있어야했다 - -```typescript -export class GetChartDto { - @IsNotEmpty() - @IsEnum(Periods) - period: PeriodNames; -} -``` - -
- -당연한 거지만, 아래와 같이 type을 함수 인자로 넣을 수는 없다 - -![](https://images.velog.io/images/johnwi/post/515a911a-181d-4730-8f15-610f6dc508a3/image.png) - -
- -위 라인 블로그에서 가장 추천하는 방법은 객체를 가지고 Union 타입을 만드는 것이다 - -enum으로 union 만들 방법을 구글링(`'typescript enum union'`)하면 나에게 가장 먼저 뜨는 글인 - -정규현님의 [enum type 대신 union type으로 변경하기](https://ajdkfl6445.gitbook.io/study/typescript/enum-type-union-type)에서도 그 방식을 개선하여 사용한 경험을 보여주고 있다 - -여기서 핵심은 아래와 같다 - -```typescript -const READONLY_객체 = 객체 as const; -type enumLike = keyof READONLY_객체[keyof typeof READONLY_객체]; -``` - -짧고 유용한 코드지만, 부족한 나에게는 이게 뭘 의미하는지 한번에 파악하기가 조금 어렵다고 느껴졌다 - -다만 `Readonly` 타입을 사용한다는 점에서 힌트를 얻을 수 있었다 - -_(이게 꼭 필요한 건지는 아직 테스트를 안해봤다...)_ - -
- -## _(적어도 나에게는)_ 좀더 편한 방법! - -결론은 아래와 같다 - -```typescript -// periods.ts -type ReadonlyRecord = Readonly>; - -export type PeriodANames = "DAILY" | "MONTHLY"; -export const PeriodA: ReadonlyRecord = { - DAILY: "DAILY", - MONTHLY: "MONTHLY", -}; - -type PeriodCommonNames = "WEEKLY" | "QUARTERLY" | "YEARLY"; -const PeriodCommon: ReadonlyRecord = {/** */}; - -export type PeriodNames = PeriodANames | PeriodCommonNames; -export const Periods = { ...PeriodA, ...PeriodCommon }; -``` - -
- -### 커스텀 유틸 타입 `ReadonlyRecord` - -이 부분은 반드시 필요한 부분은 아니고, 개인적으로 객체를 `Reaonly`로 만드는 경우가 많아 따로 선언했다 - -실무 코드에서는 좀더 편한 코딩을 위해 끼를 부려서 아래와 같이 선언했다 - -객체 `key`, `value`가 모두 `string`인 경우에는 기본값 덕분에 제네릭을 생략할 수 있고, - -`string`은 아니지만 `key`, `value`가 동일한 타입인 경우는 하나만 적어주면 된다 - -```typescript -export type ReadonlyRecord

= Readonly>; - -export const PeriodA: ReadonlyRecord = {/** */}; -``` - -
- -### `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 = { - "domestic-index": "domestic-index", - "domestic-stock": "domestic-stock", - "overseas-index": "overseas-index", - "overseas-stock": "overseas-stock", -}; -``` - -
- -### 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/tsconfig.json b/tsconfig.json index ef1fd8f..6ee99e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist", "legacy-next", "storybook-static"], + "exclude": ["dist", "storybook-static"], "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" From 14a29092612809478c2f6e6e74e0ac9356cd8ba2 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:29:54 +0900 Subject: [PATCH 09/12] =?UTF-8?q?docs(phase-4):=20Phase=204=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20LOG=EB=A5=BC=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-DRAFT 전 구현 범위와 게이트 요약을 남긴다. --- .tasks/phase-4-hardening-ops/LOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.tasks/phase-4-hardening-ops/LOG.md b/.tasks/phase-4-hardening-ops/LOG.md index b4abdaf..d000f61 100644 --- a/.tasks/phase-4-hardening-ops/LOG.md +++ b/.tasks/phase-4-hardening-ops/LOG.md @@ -2,7 +2,7 @@ - Harness: cursor - Started: 2026-09-10 -- Status: PLAN-READY (Pagefind · OG 빌드 생성). base `feat/phase-3-quiz` (#60 ready, 미머지). +- Status: PR-DRAFT (Pagefind · OG 빌드 생성). base `feat/phase-3-quiz` (#60 ready, 미머지). ## Notes @@ -10,3 +10,12 @@ - `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/`. From 5b61bd9f297e5bb3b63f930270678eca62a7ba93 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:46:33 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix(phase-4):=20leak=C2=B7search=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=ED=95=B4=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/components·layouts·lib 스캔, secret 패턴, Pagefind Top-3 fragment 검증을 CI에 연결한다. --- .github/workflows/ci.yml | 3 + .tasks/phase-4-hardening-ops/LOG.md | 15 ++++ package.json | 1 + scripts/check-leak.mjs | 38 ++++++++- scripts/check-search.mjs | 116 ++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 scripts/check-search.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00e6fdf..4a92c9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - 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 diff --git a/.tasks/phase-4-hardening-ops/LOG.md b/.tasks/phase-4-hardening-ops/LOG.md index d000f61..368f00c 100644 --- a/.tasks/phase-4-hardening-ops/LOG.md +++ b/.tasks/phase-4-hardening-ops/LOG.md @@ -19,3 +19,18 @@ - 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 fragment(gzip JSON) Top-3 게이트 — CI `build` 후 실행. + +### Search Top-3 checklist (`scripts/check-search.mjs`) + +| # | Query | Expected path | Source | +|---|-------|---------------|--------| +| 1 | `리팩토링` | `/til/refactoring-javascript/01` | TIL — 리팩토링 시리즈 1편 | +| 2 | `Storybook` | `/articles/design-system/01` | articles — Design System 환경 설정 | +| 3 | `literal` | `/til/ts/enum-to-template-literal` | TIL — enum → literal 타입 | + +한국어 stemming 없음(Pagefind 한계). 제목·태그·본문 substring 매칭으로 fragment URL을 검증한다. diff --git a/package.json b/package.json index 65acb22..1fc9de9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "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" diff --git a/scripts/check-leak.mjs b/scripts/check-leak.mjs index f608447..aaa4a58 100644 --- a/scripts/check-leak.mjs +++ b/scripts/check-leak.mjs @@ -8,9 +8,14 @@ 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 = [ @@ -23,8 +28,24 @@ const INTERNAL_DOMAIN_PATTERNS = [ 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 = []; @@ -42,6 +63,14 @@ const walk = async (dir) => { 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`); @@ -57,6 +86,8 @@ const scanText = (relPath, text) => { if (SSN_PATTERN.test(text)) { errors.push(`${relPath}: possible Korean resident registration number pattern`); } + + scanSecrets(relPath, text); }; const scanFrontmatter = (relPath, source) => { @@ -85,15 +116,18 @@ const run = async () => { } } + let scanned = 0; for (const filePath of files) { const relPath = relative(rootDir, filePath); - if (!/\.(md|mdx|astro|json|ts|tsx|js|mjs)$/i.test(filePath)) continue; + 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) { @@ -102,7 +136,7 @@ const run = async () => { process.exit(1); } - console.log(`check-leak: OK (${files.length} file(s) scanned)`); + console.log(`check-leak: OK (${scanned} publishable file(s) scanned)`); }; await run(); diff --git a/scripts/check-search.mjs b/scripts/check-search.mjs new file mode 100644 index 0000000..d6bd07c --- /dev/null +++ b/scripts/check-search.mjs @@ -0,0 +1,116 @@ +import { readFile, readdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pagefindDir = join(rootDir, "dist/pagefind"); +const fragmentDir = join(pagefindDir, "fragment"); +const FRAGMENT_PREFIX = "pagefind_dcd"; + +/** Top-3 manual checklist — see .tasks/phase-4-hardening-ops/LOG.md */ +const SEARCH_CASES = [ + { + query: "리팩토링", + expectPath: "/til/refactoring-javascript/01", + note: "TIL 위키 — 리팩토링 시리즈 1편", + }, + { + query: "Storybook", + expectPath: "/articles/design-system/01", + note: "articles — Design System 환경 설정 글", + }, + { + query: "literal", + expectPath: "/til/ts/enum-to-template-literal", + note: "TIL — enum → literal 타입 글", + }, +]; + +const loadFragments = async () => { + let names; + try { + names = await readdir(fragmentDir); + } catch { + console.error("check-search: dist/pagefind/fragment not found — run `bun run build` first."); + process.exit(1); + } + + const docs = []; + for (const name of names) { + if (!name.endsWith(".pf_fragment")) continue; + const compressed = await readFile(join(fragmentDir, name)); + const text = gunzipSync(compressed).toString("utf8"); + if (!text.startsWith(FRAGMENT_PREFIX)) { + console.error(`check-search: unexpected fragment prefix in ${name}`); + process.exit(1); + } + docs.push(JSON.parse(text.slice(FRAGMENT_PREFIX.length))); + } + + if (docs.length === 0) { + console.error("check-search: no Pagefind fragments found"); + process.exit(1); + } + + return docs; +}; + +const matchesQuery = (doc, query) => { + const q = query.toLowerCase(); + const haystacks = [doc.content ?? "", doc.meta?.title ?? "", doc.meta?.tags ?? ""].map((value) => + String(value).toLowerCase(), + ); + return haystacks.some((text) => text.includes(q)); +}; + +const normalizePath = (url) => { + if (!url) return ""; + const pathOnly = url.split("#")[0].split("?")[0]; + return pathOnly.endsWith("/") ? pathOnly : `${pathOnly}/`; +}; + +const run = async () => { + const entryPath = join(pagefindDir, "pagefind-entry.json"); + try { + const entry = JSON.parse(await readFile(entryPath, "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); + } + + const fragments = await loadFragments(); + const errors = []; + + for (const { query, expectPath, note } of SEARCH_CASES) { + const hits = fragments.filter((doc) => matchesQuery(doc, query)); + const urls = hits.map((doc) => normalizePath(doc.url)); + const expected = normalizePath(expectPath); + const matched = urls.includes(expected); + + if (!matched) { + errors.push( + `"${query}" (${note}): expected ${expected} in index hits, got [${urls.slice(0, 5).join(", ")}]`, + ); + } + } + + 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, ${fragments.length} fragments, ko index)`, + ); +}; + +await run(); From bc4f0b90329c39ceec8d1d287472fe3918dd1765 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:57:01 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix(phase-4):=20check-search=EA=B0=80=20P?= =?UTF-8?q?agefind=20WASM=20Top-3=20=EC=88=9C=EC=9C=84=EB=A5=BC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fragment substring 대신 pagefind.js search() 결과 slice(0,3) raw_url을 assert한다. --- .tasks/phase-4-hardening-ops/LOG.md | 16 +-- scripts/check-search.mjs | 156 ++++++++++++++++------------ 2 files changed, 96 insertions(+), 76 deletions(-) diff --git a/.tasks/phase-4-hardening-ops/LOG.md b/.tasks/phase-4-hardening-ops/LOG.md index 368f00c..c1492f4 100644 --- a/.tasks/phase-4-hardening-ops/LOG.md +++ b/.tasks/phase-4-hardening-ops/LOG.md @@ -23,14 +23,16 @@ ## 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 fragment(gzip JSON) Top-3 게이트 — CI `build` 후 실행. +- `check-search`: Pagefind WASM `search()` Top-3 게이트 — CI `build` 후 실행 (fragment substring 아님). ### Search Top-3 checklist (`scripts/check-search.mjs`) -| # | Query | Expected path | Source | -|---|-------|---------------|--------| -| 1 | `리팩토링` | `/til/refactoring-javascript/01` | TIL — 리팩토링 시리즈 1편 | -| 2 | `Storybook` | `/articles/design-system/01` | articles — Design System 환경 설정 | -| 3 | `literal` | `/til/ts/enum-to-template-literal` | TIL — enum → literal 타입 | +`dist/pagefind/pagefind.js`를 Node에서 로드(file:// fetch shim) → `init()` → `search(query)` → `results.slice(0,3)`의 `data().raw_url`에 expected path 포함 여부. -한국어 stemming 없음(Pagefind 한계). 제목·태그·본문 substring 매칭으로 fragment URL을 검증한다. +| # | 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 점수 순위 그대로 검증한다. diff --git a/scripts/check-search.mjs b/scripts/check-search.mjs index d6bd07c..eee9097 100644 --- a/scripts/check-search.mjs +++ b/scripts/check-search.mjs @@ -1,79 +1,75 @@ -import { readFile, readdir } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { gunzipSync } from "node:zlib"; +import { fileURLToPath, pathToFileURL } from "node:url"; const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const pagefindDir = join(rootDir, "dist/pagefind"); -const fragmentDir = join(pagefindDir, "fragment"); -const FRAGMENT_PREFIX = "pagefind_dcd"; +const pagefindModulePath = join(pagefindDir, "pagefind.js"); +const TOP_N = 3; -/** Top-3 manual checklist — see .tasks/phase-4-hardening-ops/LOG.md */ +/** + * 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편", + note: "TIL — 리팩토링 시리즈 1편 (title 가중)", }, { - query: "Storybook", + query: "Personal Design System", expectPath: "/articles/design-system/01", - note: "articles — Design System 환경 설정 글", + note: "articles — Design System 환경 설정 (distinctive title phrase)", }, { query: "literal", expectPath: "/til/ts/enum-to-template-literal", - note: "TIL — enum → literal 타입 글", + note: "TIL — enum → literal 타입", }, ]; -const loadFragments = async () => { - let names; - try { - names = await readdir(fragmentDir); - } catch { - console.error("check-search: dist/pagefind/fragment not found — run `bun run build` first."); - process.exit(1); - } - - const docs = []; - for (const name of names) { - if (!name.endsWith(".pf_fragment")) continue; - const compressed = await readFile(join(fragmentDir, name)); - const text = gunzipSync(compressed).toString("utf8"); - if (!text.startsWith(FRAGMENT_PREFIX)) { - console.error(`check-search: unexpected fragment prefix in ${name}`); - process.exit(1); +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); } - docs.push(JSON.parse(text.slice(FRAGMENT_PREFIX.length))); - } - - if (docs.length === 0) { - console.error("check-search: no Pagefind fragments found"); - process.exit(1); - } - - return docs; -}; - -const matchesQuery = (doc, query) => { - const q = query.toLowerCase(); - const haystacks = [doc.content ?? "", doc.meta?.title ?? "", doc.meta?.tags ?? ""].map((value) => - String(value).toLowerCase(), - ); - return haystacks.some((text) => text.includes(q)); + return nativeFetch(input, init); + }; + return () => { + globalThis.fetch = nativeFetch; + }; }; const normalizePath = (url) => { if (!url) return ""; - const pathOnly = url.split("#")[0].split("?")[0]; - return pathOnly.endsWith("/") ? pathOnly : `${pathOnly}/`; + const pathOnly = url + .replace(/^\/file:[^/]*/, "") + .split("#")[0] + .split("?")[0]; + const withLeading = pathOnly.startsWith("/") ? pathOnly : `/${pathOnly}`; + return withLeading.endsWith("/") ? withLeading : `${withLeading}/`; }; -const run = async () => { - const entryPath = join(pagefindDir, "pagefind-entry.json"); +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(entryPath, "utf8")); + 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`); @@ -86,31 +82,53 @@ const run = async () => { process.exit(1); } - const fragments = await loadFragments(); - const errors = []; + 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 hits = fragments.filter((doc) => matchesQuery(doc, query)); - const urls = hits.map((doc) => normalizePath(doc.url)); - const expected = normalizePath(expectPath); - const matched = urls.includes(expected); + 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 (!matched) { - errors.push( - `"${query}" (${note}): expected ${expected} in index hits, got [${urls.slice(0, 5).join(", ")}]`, - ); + 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); - } + 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, ${fragments.length} fragments, ko index)`, - ); + 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(); From f364a5c75596d047d8fb6f4c94864300e82bced8 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 04:03:34 +0900 Subject: [PATCH 12/12] =?UTF-8?q?docs(phase-4):=20Sol=20APPROVE=EC=99=80?= =?UTF-8?q?=20pr=20ready=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .tasks/phase-4-hardening-ops/LOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.tasks/phase-4-hardening-ops/LOG.md b/.tasks/phase-4-hardening-ops/LOG.md index c1492f4..c82d02b 100644 --- a/.tasks/phase-4-hardening-ops/LOG.md +++ b/.tasks/phase-4-hardening-ops/LOG.md @@ -2,7 +2,7 @@ - Harness: cursor - Started: 2026-09-10 -- Status: PR-DRAFT (Pagefind · OG 빌드 생성). base `feat/phase-3-quiz` (#60 ready, 미머지). +- Status: PR-READY #61 ## Notes @@ -36,3 +36,10 @@ | 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. +