From 7f4c3991d99acb437e80fa0b35d6c404623f89b9 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sun, 11 Jan 2026 19:31:45 +0700 Subject: [PATCH 01/11] feat(observability): setup posthog --- .env.example | 15 ++ .github/workflows/deployment.yml | 19 +- package.json | 7 + pnpm-lock.yaml | 354 +++++++++++++++++++++++++++++++ src/client.tsx | 4 + src/lib/env/client.ts | 11 +- src/lib/env/utils.ts | 6 + src/lib/posthog/client.ts | 14 ++ src/lib/posthog/plugin.ts | 38 ++++ src/lib/posthog/provider.tsx | 7 + src/lib/posthog/server.ts | 13 ++ src/router.ts | 14 +- src/routes/__root.tsx | 3 +- src/server.ts | 40 +--- vite.config.ts | 10 +- 15 files changed, 518 insertions(+), 37 deletions(-) create mode 100644 src/lib/env/utils.ts create mode 100644 src/lib/posthog/client.ts create mode 100644 src/lib/posthog/plugin.ts create mode 100644 src/lib/posthog/provider.tsx create mode 100644 src/lib/posthog/server.ts diff --git a/.env.example b/.env.example index 8ffcb39..3557091 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,18 @@ HOSTNAME= CLOUDFLARE_API_TOKEN=<***> # Your Cloudflare email CLOUDFLARE_EMAIL=<***> + +# Observability - Posthog +# See https://app.posthog.com/project/settings +VITE_PUBLIC_POSTHOG_HOST= +VITE_PUBLIC_POSTHOG_KEY=<***> +VITE_PUBLIC_POSTHOG_DEBUG= +VITE_PUBLIC_POSTHOG_ENABLED= + +# Observability - Posthog sourcemap upload (CI only) +# See https://app.posthog.com/settings/project#variables +POSTHOG_CLI_HOST= +POSTHOG_CLI_ENV_ID=<***> +# Personal API key with error tracking write and organization read scopes +# See https://app.posthog.com/settings/user-api-keys#variables +POSTHOG_CLI_TOKEN=<***> diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 8202d71..e082841 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -22,6 +22,8 @@ jobs: deploy: if: ${{ github.event.action != 'closed' }} runs-on: ubuntu-latest + environment: + name: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} permissions: contents: read pull-requests: write @@ -39,9 +41,10 @@ jobs: cache: pnpm - name: Install dependencies run: pnpm install - - name: Deploy + - name: Build and deploy run: pnpm alchemy deploy --stage ${{ env.STAGE }} env: + # Alchemy HOSTNAME: ${{ vars.HOSTNAME }} ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }} ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} @@ -50,9 +53,20 @@ jobs: PULL_REQUEST: ${{ github.event.number }} GITHUB_SHA: ${{ github.sha }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Build + VITE_PUBLIC_POSTHOG_HOST: ${{ vars.VITE_PUBLIC_POSTHOG_HOST }} + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_DEBUG: ${{ vars.VITE_PUBLIC_POSTHOG_DEBUG }} + VITE_PUBLIC_POSTHOG_ENABLED: ${{ vars.VITE_PUBLIC_POSTHOG_ENABLED }} + # Sourcemap inject and upload + POSTHOG_CLI_HOST: ${{ vars.POSTHOG_CLI_HOST }} + POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_CLI_ENV_ID }} + POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }} cleanup: - runs-on: ubuntu-latest if: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + runs-on: ubuntu-latest + environment: + name: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} permissions: id-token: write contents: read @@ -80,6 +94,7 @@ jobs: - name: Destroy Preview Environment run: pnpm alchemy destroy --stage ${{ env.STAGE }} env: + # Alchemy HOSTNAME: ${{ vars.HOSTNAME }} ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }} ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} diff --git a/package.json b/package.json index 2ff3e0d..94c93ac 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "alchemy:dev": "alchemy dev --env-file ./.env", "alchemy:deploy": "alchemy deploy --env-file ./.env", "alchemy:destroy": "alchemy destroy --env-file ./.env", + "sourcemap:inject": "posthog-cli sourcemap inject --directory ./dist", + "sourcemap:upload": "posthog-cli sourcemap upload --directory ./dist --delete-after", "commitlint": "commitlint --edit", "format": "oxfmt", "format:check": "oxfmt --check", @@ -40,6 +42,7 @@ "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/plus-jakarta-sans": "^5.2.8", + "@posthog/react": "^1.5.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -78,6 +81,8 @@ "input-otp": "^1.4.2", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", + "posthog-js": "^1.314.0", + "posthog-node": "^5.19.0", "react": "^19.2.3", "react-day-picker": "^9.13.0", "react-dom": "^19.2.3", @@ -95,6 +100,8 @@ "@commitlint/cli": "^20.3.0", "@commitlint/config-conventional": "^20.3.0", "@commitlint/types": "^20.2.0", + "@posthog/cli": "^0.5.22", + "@posthog/rollup-plugin": "^1.1.7", "@release-it/conventional-changelog": "10.0.4", "@storybook/addon-docs": "^10.1.11", "@storybook/react-vite": "^10.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e773d65..6f8be27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@fontsource-variable/plus-jakarta-sans': specifier: ^5.2.8 version: 5.2.8 + '@posthog/react': + specifier: ^1.5.2 + version: 1.5.2(@types/react@19.2.7)(posthog-js@1.314.0)(react@19.2.3) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -132,6 +135,12 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + posthog-js: + specifier: ^1.314.0 + version: 1.314.0 + posthog-node: + specifier: ^5.19.0 + version: 5.19.0 react: specifier: ^19.2.3 version: 19.2.3 @@ -178,6 +187,12 @@ importers: '@commitlint/types': specifier: ^20.2.0 version: 20.2.0 + '@posthog/cli': + specifier: ^0.5.22 + version: 0.5.22 + '@posthog/rollup-plugin': + specifier: ^1.1.7 + version: 1.1.7(rollup@4.55.1) '@release-it/conventional-changelog': specifier: 10.0.4 version: 10.0.4(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1)(release-it@19.2.2(@types/node@25.0.3)) @@ -1662,6 +1677,32 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/cli@0.5.22': + resolution: {integrity: sha512-Hgt/s4rjvO7Rvdzja4ccFu60NdQzZROCFiLYtTBqabG+ovLGZpf3JL0MgsIJP3GCZdwoDDaWDUHkhOXmAFTtcA==} + engines: {node: '>=14', npm: '>=6'} + hasBin: true + + '@posthog/core@1.9.0': + resolution: {integrity: sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw==} + + '@posthog/core@1.9.1': + resolution: {integrity: sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw==} + + '@posthog/react@1.5.2': + resolution: {integrity: sha512-KHdXbV1yba7Y2l8BVmwXlySWxqKVLNQ5ZiVvWOf7r3Eo7GIFxCM4CaNK/z83kKWn8KTskmKy7AGF6Hl6INWK3g==} + peerDependencies: + '@types/react': '>=16.8.0' + posthog-js: '>=1.257.2' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@posthog/rollup-plugin@1.1.7': + resolution: {integrity: sha512-jpi6x4bueP78Psuf+Xx8fn/jglpHf0wUfs+iiCzyUgo+JghFUNoLtRMcu8vfeuo8i9RPibl0BJNlafupQsXkzQ==} + peerDependencies: + rollup: '>= 4.0.0' + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3309,9 +3350,18 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios-proxy-builder@0.1.2: + resolution: {integrity: sha512-6uBVsBZzkB3tCC8iyx59mCjQckhB8+GQrI9Cop8eC7ybIsvs/KtnNgEBfRMSEa7GqK2VBGUzgjNYMdPIfotyPA==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + babel-dead-code-elimination@1.0.11: resolution: {integrity: sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ==} @@ -3375,6 +3425,10 @@ packages: magicast: optional: true + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3446,6 +3500,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3473,6 +3531,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -3495,6 +3557,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console.table@0.10.0: + resolution: {integrity: sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==} + engines: {node: '> 0.10'} + conventional-changelog-angular@7.0.0: resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} engines: {node: '>=16'} @@ -3550,6 +3616,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3677,6 +3746,9 @@ packages: resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==} engines: {node: '>=18'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -3688,6 +3760,10 @@ packages: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3829,9 +3905,16 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + easy-table@1.1.0: + resolution: {integrity: sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -3894,6 +3977,22 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.43.0: resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==} @@ -3997,6 +4096,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4013,10 +4115,23 @@ packages: resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} engines: {node: '>=18'} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4037,6 +4152,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -4045,6 +4164,10 @@ packages: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -4091,6 +4214,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -4103,6 +4230,10 @@ packages: peerDependencies: csstype: ^3.0.10 + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -4124,6 +4255,14 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4537,6 +4676,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -4552,10 +4695,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -4818,6 +4969,16 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.314.0: + resolution: {integrity: sha512-qW1T73UAFpA0g2Ln0blsOUJxRhv0Tn4DrPdhGyTpw+MupW+qvVjzEg/i9jWQ4Al+8AkrNcmZFafJcSWXxWsWqg==} + + posthog-node@5.19.0: + resolution: {integrity: sha512-5Nx+/b1JbAy6TaCENuO+mLFfZIXyYavae+D6FABd52gzkGryRkRTsKi+WLlILI5shSCAMWs5gxzGAZ3nOC0gFA==} + engines: {node: '>=20'} + + preact@10.28.1: + resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==} + prettier@3.7.4: resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} @@ -5018,6 +5179,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@6.1.2: + resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} + engines: {node: 20 || >=22} + hasBin: true + rollup@4.55.1: resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -5324,6 +5490,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -5496,6 +5666,12 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -5531,6 +5707,7 @@ packages: wrangler@4.56.0: resolution: {integrity: sha512-Nqi8duQeRbA+31QrD6QlWHW3IZVnuuRxMy7DEg46deUzywivmaRV/euBN5KKXDPtA24VyhYsK7I0tkb7P5DM2w==} engines: {node: '>=20.0.0'} + deprecated: Version 4.55.0 and 4.56.0 can incorrectly automatically delegate 'wrangler deploy' to 'opennextjs-cloudflare'. Use an older or newer version. hasBin: true peerDependencies: '@cloudflare/workers-types': ^4.20251217.0 @@ -7088,6 +7265,39 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@posthog/cli@0.5.22': + dependencies: + axios: 1.13.2 + axios-proxy-builder: 0.1.2 + console.table: 0.10.0 + detect-libc: 2.1.2 + rimraf: 6.1.2 + transitivePeerDependencies: + - debug + + '@posthog/core@1.9.0': + dependencies: + cross-spawn: 7.0.6 + + '@posthog/core@1.9.1': + dependencies: + cross-spawn: 7.0.6 + + '@posthog/react@1.5.2(@types/react@19.2.7)(posthog-js@1.314.0)(react@19.2.3)': + dependencies: + posthog-js: 1.314.0 + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.7 + + '@posthog/rollup-plugin@1.1.7(rollup@4.55.1)': + dependencies: + '@posthog/cli': 0.5.22 + '@posthog/core': 1.9.1 + rollup: 4.55.1 + transitivePeerDependencies: + - debug + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -8936,8 +9146,22 @@ snapshots: dependencies: retry: 0.13.1 + asynckit@0.4.0: {} + aws4fetch@1.0.20: {} + axios-proxy-builder@0.1.2: + dependencies: + tunnel: 0.0.6 + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-dead-code-elimination@1.0.11: dependencies: '@babel/core': 7.28.5 @@ -9006,6 +9230,11 @@ snapshots: pkg-types: 2.3.0 rc9: 2.1.2 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + callsites@3.1.0: {} caniuse-lite@1.0.30001762: {} @@ -9097,6 +9326,9 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone@1.0.4: + optional: true + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -9129,6 +9361,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@12.1.0: {} commander@14.0.2: {} @@ -9149,6 +9385,10 @@ snapshots: consola@3.4.2: {} + console.table@0.10.0: + dependencies: + easy-table: 1.1.0 + conventional-changelog-angular@7.0.0: dependencies: compare-func: 2.0.0 @@ -9210,6 +9450,8 @@ snapshots: cookie@1.1.1: {} + core-js@3.47.0: {} + core-util-is@1.0.3: {} cosmiconfig-typescript-loader@6.2.0(@types/node@25.0.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): @@ -9314,6 +9556,11 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + define-lazy-prop@3.0.0: {} defu@6.1.4: {} @@ -9324,6 +9571,8 @@ snapshots: escodegen: 2.1.0 esprima: 4.0.1 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -9370,8 +9619,18 @@ snapshots: optionalDependencies: '@cloudflare/workers-types': 4.20260103.0 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + eastasianwidth@0.2.0: {} + easy-table@1.1.0: + optionalDependencies: + wcwidth: 1.0.1 + electron-to-chromium@1.5.267: {} embla-carousel-react@8.6.0(react@19.2.3): @@ -9420,6 +9679,21 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + es-toolkit@1.43.0: {} esbuild@0.25.12: @@ -9592,6 +9866,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -9612,11 +9888,21 @@ snapshots: path-exists: 5.0.0 unicorn-magic: 0.1.0 + follow-redirects@1.15.11: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true @@ -9628,10 +9914,28 @@ snapshots: get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} get-port@7.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@8.0.1: {} get-stream@9.0.1: @@ -9699,6 +10003,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.1 + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -9709,6 +10019,8 @@ snapshots: dependencies: csstype: 3.2.3 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} h3@2.0.1-rc.7(crossws@0.4.1(srvx@0.10.0)): @@ -9729,6 +10041,12 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -10075,6 +10393,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + meow@12.1.1: {} meow@13.2.0: {} @@ -10086,8 +10406,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -10370,6 +10696,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.314.0: + dependencies: + '@posthog/core': 1.9.0 + core-js: 3.47.0 + fflate: 0.4.8 + preact: 10.28.1 + web-vitals: 4.2.4 + + posthog-node@5.19.0: + dependencies: + '@posthog/core': 1.9.0 + + preact@10.28.1: {} + prettier@3.7.4: {} pretty-format@27.5.1: @@ -10612,6 +10952,11 @@ snapshots: rfdc@1.4.1: {} + rimraf@6.1.2: + dependencies: + glob: 13.0.0 + package-json-from-dist: 1.0.1 + rollup@4.55.1: dependencies: '@types/estree': 1.0.8 @@ -10918,6 +11263,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel@0.0.6: {} + tw-animate-css@1.4.0: {} type-fest@2.19.0: {} @@ -11059,6 +11406,13 @@ snapshots: walk-up-path@4.0.0: {} + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true + + web-vitals@4.2.4: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: diff --git a/src/client.tsx b/src/client.tsx index 1cc5fb0..c766152 100644 --- a/src/client.tsx +++ b/src/client.tsx @@ -2,6 +2,10 @@ import { StartClient } from '@tanstack/react-start/client'; import * as React from 'react'; import { hydrateRoot } from 'react-dom/client'; +import { initializePosthogClient } from '~/lib/posthog/client'; + +initializePosthogClient(); + hydrateRoot( document, diff --git a/src/lib/env/client.ts b/src/lib/env/client.ts index f9a76b5..f25f127 100644 --- a/src/lib/env/client.ts +++ b/src/lib/env/client.ts @@ -1,10 +1,17 @@ import { createEnv } from '@t3-oss/env-core'; -import * as _ from 'zod/v4'; +import * as z from 'zod/v4'; + +import { coerceBoolean } from './utils'; /** Env schema for client bundle */ export const clientEnv = createEnv({ clientPrefix: 'VITE_', - client: {}, + client: { + VITE_PUBLIC_POSTHOG_KEY: z.string().nonempty(), + VITE_PUBLIC_POSTHOG_HOST: z.url(), + VITE_PUBLIC_POSTHOG_DEBUG: coerceBoolean().default(false), + VITE_PUBLIC_POSTHOG_ENABLED: coerceBoolean().default(false), + }, runtimeEnv: import.meta.env, emptyStringAsUndefined: true, }); diff --git a/src/lib/env/utils.ts b/src/lib/env/utils.ts new file mode 100644 index 0000000..e186a60 --- /dev/null +++ b/src/lib/env/utils.ts @@ -0,0 +1,6 @@ +import * as z from 'zod/v4'; + +/** Coerce string to boolean. */ +export function coerceBoolean() { + return z.enum(['true', 'false']).transform((val) => val === 'true'); +} diff --git a/src/lib/posthog/client.ts b/src/lib/posthog/client.ts new file mode 100644 index 0000000..5b05a73 --- /dev/null +++ b/src/lib/posthog/client.ts @@ -0,0 +1,14 @@ +import { posthog } from 'posthog-js'; + +import { clientEnv } from '~/lib/env/client'; + +/** Initialize browser Posthog client */ +export function initializePosthogClient() { + if (!clientEnv.VITE_PUBLIC_POSTHOG_ENABLED) return; + + posthog.init(clientEnv.VITE_PUBLIC_POSTHOG_KEY, { + defaults: '2025-11-30', + api_host: clientEnv.VITE_PUBLIC_POSTHOG_HOST, + debug: clientEnv.VITE_PUBLIC_POSTHOG_DEBUG, + }); +} diff --git a/src/lib/posthog/plugin.ts b/src/lib/posthog/plugin.ts new file mode 100644 index 0000000..5b54f29 --- /dev/null +++ b/src/lib/posthog/plugin.ts @@ -0,0 +1,38 @@ +import posthogVitePlugin, { + type PostHogRollupPluginOptions, +} from '@posthog/rollup-plugin'; + +/** + * Create a PostHog Vite/Rollup plugin instance only when the required + * PostHog configuration is present. + * + * This wrapper is useful when you want sourcemap upload + injection in some + * environments (e.g. production CI) but want to avoid running the plugin in + * others (e.g. local dev, preview builds). + * + * If any required option is missing, this function returns `undefined` so it + * can be conditionally included in your Vite/Rollup plugins array without + * additional branching. + * + * When enabled, the underlying PostHog plugin will: + * - inject sourcemap references + * - upload sourcemaps + * - delete sourcemaps after upload (as configured) + */ +export function posthog(options: Partial) { + if (!options.personalApiKey || !options.envId || !options.host) { + return undefined; + } + + return posthogVitePlugin({ + host: options.host, + envId: options.envId, + personalApiKey: options.personalApiKey, + ...options, + sourcemaps: { + enabled: true, + deleteAfterUpload: true, + ...options.sourcemaps, + }, + }); +} diff --git a/src/lib/posthog/provider.tsx b/src/lib/posthog/provider.tsx new file mode 100644 index 0000000..32c9807 --- /dev/null +++ b/src/lib/posthog/provider.tsx @@ -0,0 +1,7 @@ +import { PostHogProvider as BasePosthogProvider } from '@posthog/react'; +import { posthog } from 'posthog-js'; +import * as React from 'react'; + +export function PostHogProvider({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/lib/posthog/server.ts b/src/lib/posthog/server.ts new file mode 100644 index 0000000..11b3670 --- /dev/null +++ b/src/lib/posthog/server.ts @@ -0,0 +1,13 @@ +import { PostHog } from 'posthog-node'; + +import { clientEnv } from '~/lib/env/client'; + +/** Create server Posthog client instance */ +export function createPosthogClient() { + return new PostHog(clientEnv.VITE_PUBLIC_POSTHOG_KEY, { + host: clientEnv.VITE_PUBLIC_POSTHOG_HOST, + flushAt: 1, // Flush after every event + flushInterval: 0, // No batching delays + disabled: !clientEnv.VITE_PUBLIC_POSTHOG_ENABLED, + }); +} diff --git a/src/router.ts b/src/router.ts index 8151b77..f6e10a8 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,11 +1,21 @@ -import { createRouter as createTanstackRouter } from '@tanstack/react-router'; +import { + createRouter as createTanstackRouter, + ErrorComponent, +} from '@tanstack/react-router'; +import { posthog } from 'posthog-js'; import { routeTree } from '~/routeTree.gen'; export function getRouter() { - return createTanstackRouter({ + const router = createTanstackRouter({ routeTree, defaultPreload: 'intent', scrollRestoration: true, + defaultErrorComponent: ErrorComponent, + defaultOnCatch(error) { + posthog.captureException(error); + }, }); + + return router; } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 4c391ba..c21a7f3 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -8,6 +8,7 @@ import * as React from 'react'; import { preload } from 'react-dom'; import { tanstackRouterDevtools } from '~/devtools/router-devtools'; +import { PostHogProvider } from '~/lib/posthog/provider'; import { Toaster } from '~/ui/components/core/sonner'; import appStylesheet from '~/ui/styles/app.css?url'; import fontStylesheet from '~/ui/styles/fonts.css?url'; @@ -40,7 +41,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { - {children} + {children} ) { - if (workerWaitUntil) { - return workerWaitUntil(promise); - } - return promise; -} +const serverHandler = defineHandlerCallback((ctx) => { + return defaultStreamHandler(ctx); +}); -/** Perform tasks before server shutdown */ -async function shutdown() {} +const fetch = createStartHandler(serverHandler); -export default createServerEntry({ - async fetch(request) { - const response = handler.fetch(request); - await waitUntil(shutdown()); - return response; - }, -}); +export default createServerEntry({ fetch }); diff --git a/vite.config.ts b/vite.config.ts index ec05082..a9e759c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,8 @@ import alchemy from 'alchemy/cloudflare/tanstack-start'; import { defineConfig, loadEnv, type ConfigEnv } from 'vite'; import tsConfigPaths from 'vite-tsconfig-paths'; +import { posthog } from './src/lib/posthog/plugin'; + export default async function viteConfig({ mode }: ConfigEnv) { /** * Environment Variables aren't loaded automatically @@ -23,18 +25,24 @@ export default async function viteConfig({ mode }: ConfigEnv) { target: 'esnext', minify: true, cssMinify: true, + sourcemap: true, rollupOptions: { external: ['node:async_hooks', 'cloudflare:workers'], }, }, plugins: [ - alchemy(), + alchemy({ viteEnvironment: { name: 'ssr' } }), devtools(), tailwindcss(), tsConfigPaths({ projects: ['./tsconfig.json'] }), tanstackStart({ srcDirectory: 'src', router: { routeToken: 'layout' } }), // React's vite plugin must come after start's vite plugin viteReact({ babel: { plugins: ['babel-plugin-react-compiler'] } }), + posthog({ + host: process.env.POSTHOG_CLI_HOST, + envId: process.env.POSTHOG_CLI_ENV_ID, + personalApiKey: process.env.POSTHOG_CLI_TOKEN, + }), ], }); } From a0ecfb040041cadfa1d519f86ad828855d6740c0 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sun, 11 Jan 2026 22:59:39 +0700 Subject: [PATCH 02/11] build: manually split posthog-js and @posthog/react from main bundle --- vite.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index a9e759c..54c5e50 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,13 @@ export default async function viteConfig({ mode }: ConfigEnv) { cssMinify: true, sourcemap: true, rollupOptions: { + output: { + manualChunks: (id) => { + if (id.includes('posthog-js') || id.includes('@posthog/react')) { + return 'posthog'; + } + }, + }, external: ['node:async_hooks', 'cloudflare:workers'], }, }, From ea6531995169a001a4b60d62b3c0ace0cea91788 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 13 Jan 2026 10:28:25 +0700 Subject: [PATCH 03/11] chore(linter): turn off capitalized comments rule --- .oxlintrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index f32c073..7e8c218 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -63,7 +63,7 @@ "react/rules-of-hooks": "error", // Style - "capitalized-comments": "warn", + "capitalized-comments": "off", "default-case-last": "error", "default-param-last": "error", "func-names": ["error", "as-needed", { "generators": "as-needed" }], From 1e2753a437378dec73416946e2e23d8fc3b6467a Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 13 Jan 2026 20:08:23 +0700 Subject: [PATCH 04/11] ci(workflow): split deployment and cleanup workflows --- .github/workflows/cleanup-preview.yaml | 48 ++++++++++++++++ ...deployment.yml => deployment-preview.yaml} | 56 +++---------------- .github/workflows/deployment-production.yaml | 54 ++++++++++++++++++ 3 files changed, 110 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/cleanup-preview.yaml rename .github/workflows/{deployment.yml => deployment-preview.yaml} (50%) create mode 100644 .github/workflows/deployment-production.yaml diff --git a/.github/workflows/cleanup-preview.yaml b/.github/workflows/cleanup-preview.yaml new file mode 100644 index 0000000..e42d219 --- /dev/null +++ b/.github/workflows/cleanup-preview.yaml @@ -0,0 +1,48 @@ +# This workflow destroys the preview environment/deployment when a PR is closed. +# It prevents preview resources from leaking/stale after close. + +name: Cleanup Preview +on: + pull_request: + branches: + - main + types: + - closed +concurrency: + group: cleanup-preview-${{ github.ref }} + cancel-in-progress: false +env: + NODE_VERSION: '24.11.1' + PNPM_VERSION: '10.23.0' + STAGE: ${{ format('preview-{0}', github.event.number) }} +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - name: Install dependencies + run: pnpm install + - name: Destroy Preview Environment + run: pnpm alchemy destroy --stage ${{ env.STAGE }} + env: + # Alchemy + HOSTNAME: ${{ vars.HOSTNAME }} + ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }} + ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + PULL_REQUEST: ${{ github.event.number }} diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment-preview.yaml similarity index 50% rename from .github/workflows/deployment.yml rename to .github/workflows/deployment-preview.yaml index e082841..05be620 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment-preview.yaml @@ -1,8 +1,9 @@ -name: Deployment +# Deploys a per-PR preview environment for this repository. +# Triggered when a PR targeting main (commonly from release/* or hotfix/*) is opened, reopened, or updated (synchronize). +# Deploys to the "preview" environment with stage name "preview-" . + +name: Deployment (Preview) on: - push: - branches: - - main pull_request: branches: - main @@ -10,20 +11,18 @@ on: - opened - reopened - synchronize - - closed concurrency: - group: deployment-${{ github.ref }} + group: deployment-preview-${{ github.ref }} cancel-in-progress: false env: NODE_VERSION: '24.11.1' PNPM_VERSION: '10.23.0' - STAGE: ${{ github.event_name == 'pull_request' && format('preview-{0}', github.event.number) || (github.ref == 'refs/heads/main' && 'production' || github.ref_name) }} + STAGE: ${{ format('preview-{0}', github.event.number) }} jobs: deploy: - if: ${{ github.event.action != 'closed' }} runs-on: ubuntu-latest environment: - name: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} + name: preview permissions: contents: read pull-requests: write @@ -62,42 +61,3 @@ jobs: POSTHOG_CLI_HOST: ${{ vars.POSTHOG_CLI_HOST }} POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_CLI_ENV_ID }} POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }} - cleanup: - if: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} - runs-on: ubuntu-latest - environment: - name: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} - permissions: - id-token: write - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - run_install: false - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: pnpm - - name: Install dependencies - run: pnpm install - - name: Safety Check - run: |- - if [ "${{ env.STAGE }}" = "production" ]; then - echo "ERROR: Cannot destroy production environment in cleanup job" - exit 1 - fi - - name: Destroy Preview Environment - run: pnpm alchemy destroy --stage ${{ env.STAGE }} - env: - # Alchemy - HOSTNAME: ${{ vars.HOSTNAME }} - ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }} - ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} - PULL_REQUEST: ${{ github.event.number }} diff --git a/.github/workflows/deployment-production.yaml b/.github/workflows/deployment-production.yaml new file mode 100644 index 0000000..d0492ed --- /dev/null +++ b/.github/workflows/deployment-production.yaml @@ -0,0 +1,54 @@ +# Deploys the main branch to the production environment on every push to main. +# Deploys to the GitHub environment "production" with stage name "production". + +name: Deployment (Production) +on: + push: + branches: + - main +concurrency: + group: deployment-production-${{ github.ref }} + cancel-in-progress: false +env: + NODE_VERSION: '24.11.1' + PNPM_VERSION: '10.23.0' + STAGE: 'production' +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: production + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - name: Install dependencies + run: pnpm install + - name: Build and deploy + run: pnpm alchemy deploy --stage ${{ env.STAGE }} + env: + # Alchemy + HOSTNAME: ${{ vars.HOSTNAME }} + ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }} + ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + # Build + VITE_PUBLIC_POSTHOG_HOST: ${{ vars.VITE_PUBLIC_POSTHOG_HOST }} + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_DEBUG: ${{ vars.VITE_PUBLIC_POSTHOG_DEBUG }} + VITE_PUBLIC_POSTHOG_ENABLED: ${{ vars.VITE_PUBLIC_POSTHOG_ENABLED }} + # Sourcemap inject and upload + POSTHOG_CLI_HOST: ${{ vars.POSTHOG_CLI_HOST }} + POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_CLI_ENV_ID }} + POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }} From c56f2608bb0cf6a0246900fc5126eef9da8ffff9 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Wed, 14 Jan 2026 11:57:46 +0700 Subject: [PATCH 05/11] chore(oxfmt): update and add import and tailwindcss options --- .oxfmtrc.json | 22 +++++++- .storybook/preview.ts | 4 +- .vscode/settings.json | 1 + package.json | 2 +- pnpm-lock.yaml | 74 ++++++++++++------------- src/devtools/router-devtools.tsx | 1 - src/ui/components/core/alert.tsx | 2 +- src/ui/components/core/badge.tsx | 2 +- src/ui/components/core/button.tsx | 6 +- src/ui/components/core/calendar.tsx | 2 +- src/ui/components/core/chart.tsx | 15 +++-- src/ui/components/core/empty.tsx | 2 +- src/ui/components/core/field.tsx | 2 +- src/ui/components/core/input-group.tsx | 8 +-- src/ui/components/core/item.tsx | 8 +-- src/ui/components/core/resizable.tsx | 4 +- src/ui/components/core/sheet.tsx | 8 +-- src/ui/components/core/sidebar.tsx | 2 +- src/ui/components/core/toggle-group.tsx | 3 +- src/ui/components/core/toggle.tsx | 6 +- 20 files changed, 96 insertions(+), 78 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 2b1769a..efb26aa 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -15,5 +15,25 @@ "arrowParens": "always", "ignorePatterns": ["pnpm-lock.yaml", "routeTree.gen.ts", "public/"], "experimentalSortPackageJson": true, - "experimentalSortImports": { "order": "asc" } + "experimentalSortImports": { + "order": "asc", + "internalPattern": ["~/"], + "groups": [ + ["side-effect"], + ["side-effect-style"], + ["builtin"], + ["external", "external-type"], + ["internal", "internal-type"], + ["parent", "parent-type"], + ["sibling", "sibling-type"], + ["index", "index-type"] + ] + }, + "experimentalTailwindcss": { + "stylesheet": "./src/ui/styles/app.css", + "attributes": ["class", "className", "classNames"], + "functions": ["clsx", "twMerge", "cn", "cva"], + "preserveDuplicates": false, + "preserveWhitespace": false + } } diff --git a/.storybook/preview.ts b/.storybook/preview.ts index d679584..20e84e3 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,8 +1,8 @@ +import '../src/ui/styles/app.css'; + import addonDocs from '@storybook/addon-docs'; import { definePreview } from '@storybook/react-vite'; -import '../src/ui/styles/app.css'; - export default definePreview({ addons: [addonDocs()], tags: ['autodocs'], diff --git a/.vscode/settings.json b/.vscode/settings.json index 09ecbb7..d948946 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ // Extension - Oxc "oxc.enable": true, + "oxc.fmt.configPath": ".oxfmtrc.json", // Extension - Tailwind CSS "tailwindCSS.classAttributes": ["class", "className", "classNames"], diff --git a/package.json b/package.json index 94c93ac..ce99448 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "conventional-changelog-conventionalcommits": "9.1.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", - "oxfmt": "^0.22.0", + "oxfmt": "^0.24.0", "oxlint": "^1.37.0", "release-it": "19.2.2", "storybook": "^10.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f8be27..b12e1d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,8 +239,8 @@ importers: specifier: ^16.2.7 version: 16.2.7 oxfmt: - specifier: ^0.22.0 - version: 0.22.0 + specifier: ^0.24.0 + version: 0.24.0 oxlint: specifier: ^1.37.0 version: 1.37.0(oxlint-tsgolint@0.10.0) @@ -1550,43 +1550,43 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} - '@oxfmt/darwin-arm64@0.22.0': - resolution: {integrity: sha512-dhz2m2uLrHT3MwM+LAdvr97EojJZTwaZ6BuMTRftJzqa9dHYDG/MtSBuDD2DpGpZ0SM2iVwni2wCzCYGKTojbA==} + '@oxfmt/darwin-arm64@0.24.0': + resolution: {integrity: sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A==} cpu: [arm64] os: [darwin] - '@oxfmt/darwin-x64@0.22.0': - resolution: {integrity: sha512-VykUbibvqSOG5YIFUMpHtZVrY1YKDl9Il2SvFemUfR5Ac1t1BFZOnazYe98jtZGFY4sEdEORs0ImBARnyMX/hw==} + '@oxfmt/darwin-x64@0.24.0': + resolution: {integrity: sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg==} cpu: [x64] os: [darwin] - '@oxfmt/linux-arm64-gnu@0.22.0': - resolution: {integrity: sha512-y0MBha/K34TztYAZUn6KQE9xLPLNHqRpOdzRp96fhkbrQTeEXo+jF+8+aV8VnqjG0y7p+IQN4ATxNSstSPO9sA==} + '@oxfmt/linux-arm64-gnu@0.24.0': + resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==} cpu: [arm64] os: [linux] - '@oxfmt/linux-arm64-musl@0.22.0': - resolution: {integrity: sha512-8a0p2UEmavB+moQ7ID17i+dE7N2xng6lPU8vrrNnnwKde0YpGHdW6hmuH4mS+rrltvs0fjyGRSvCnD2Qm9IAcA==} + '@oxfmt/linux-arm64-musl@0.24.0': + resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==} cpu: [arm64] os: [linux] - '@oxfmt/linux-x64-gnu@0.22.0': - resolution: {integrity: sha512-ZA1lS6MLvtGfD9AaDylCSTTiOWVQs1eIl9uqsGYs+Zr8p0mI7QRIRA6juWk9FXn1hHfmYBdBgWu2GdIW0YFCFA==} + '@oxfmt/linux-x64-gnu@0.24.0': + resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==} cpu: [x64] os: [linux] - '@oxfmt/linux-x64-musl@0.22.0': - resolution: {integrity: sha512-J5zFB8T5yk6Jx63rdKuXfcPqR1cAp12nO5/NJfGITH00AML2Yj9JM4dRnmssJomYHKa8dNSr40l6OdxRZN88CQ==} + '@oxfmt/linux-x64-musl@0.24.0': + resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==} cpu: [x64] os: [linux] - '@oxfmt/win32-arm64@0.22.0': - resolution: {integrity: sha512-XYxyIiOf3HqlfETLFKqCHYL88mhw+Ka25vDVgmlcghbJv9BPoVzquZW7P4i0T3D5GWp4LHhZHmMo8BuK8PP5BA==} + '@oxfmt/win32-arm64@0.24.0': + resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==} cpu: [arm64] os: [win32] - '@oxfmt/win32-x64@0.22.0': - resolution: {integrity: sha512-/shfU+wwlXcKP2NkZt+kYCSVom2EEu8MwbENlYCak6LtPPrN5xAQhHuOSFByjDzTBApdQugch0j0ZB/4Wyaljg==} + '@oxfmt/win32-x64@0.24.0': + resolution: {integrity: sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw==} cpu: [x64] os: [win32] @@ -4841,8 +4841,8 @@ packages: resolution: {integrity: sha512-zBd1G8HkewNd2A8oQ8c6BN/f/c9EId7rSUueOLGu28govmUctXmM+3765GwsByv9nYUdrLqHphXlYIc86saYsg==} engines: {node: '>=18'} - oxfmt@0.22.0: - resolution: {integrity: sha512-Z7JM5yv4KaDz5kT21MxRAvtYo5Eu9Ti/XY1JYShSOlaH859XSn4UaS3wlZKyz4Mpbo8ISkxhU75UY5yp+OQUyA==} + oxfmt@0.24.0: + resolution: {integrity: sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -7182,28 +7182,28 @@ snapshots: '@oozcitak/util@10.0.0': {} - '@oxfmt/darwin-arm64@0.22.0': + '@oxfmt/darwin-arm64@0.24.0': optional: true - '@oxfmt/darwin-x64@0.22.0': + '@oxfmt/darwin-x64@0.24.0': optional: true - '@oxfmt/linux-arm64-gnu@0.22.0': + '@oxfmt/linux-arm64-gnu@0.24.0': optional: true - '@oxfmt/linux-arm64-musl@0.22.0': + '@oxfmt/linux-arm64-musl@0.24.0': optional: true - '@oxfmt/linux-x64-gnu@0.22.0': + '@oxfmt/linux-x64-gnu@0.24.0': optional: true - '@oxfmt/linux-x64-musl@0.22.0': + '@oxfmt/linux-x64-musl@0.24.0': optional: true - '@oxfmt/win32-arm64@0.22.0': + '@oxfmt/win32-arm64@0.24.0': optional: true - '@oxfmt/win32-x64@0.22.0': + '@oxfmt/win32-x64@0.24.0': optional: true '@oxlint-tsgolint/darwin-arm64@0.10.0': @@ -10550,18 +10550,18 @@ snapshots: macos-release: 3.4.0 windows-release: 6.1.0 - oxfmt@0.22.0: + oxfmt@0.24.0: dependencies: tinypool: 2.0.0 optionalDependencies: - '@oxfmt/darwin-arm64': 0.22.0 - '@oxfmt/darwin-x64': 0.22.0 - '@oxfmt/linux-arm64-gnu': 0.22.0 - '@oxfmt/linux-arm64-musl': 0.22.0 - '@oxfmt/linux-x64-gnu': 0.22.0 - '@oxfmt/linux-x64-musl': 0.22.0 - '@oxfmt/win32-arm64': 0.22.0 - '@oxfmt/win32-x64': 0.22.0 + '@oxfmt/darwin-arm64': 0.24.0 + '@oxfmt/darwin-x64': 0.24.0 + '@oxfmt/linux-arm64-gnu': 0.24.0 + '@oxfmt/linux-arm64-musl': 0.24.0 + '@oxfmt/linux-x64-gnu': 0.24.0 + '@oxfmt/linux-x64-musl': 0.24.0 + '@oxfmt/win32-arm64': 0.24.0 + '@oxfmt/win32-x64': 0.24.0 oxlint-tsgolint@0.10.0: optionalDependencies: diff --git a/src/devtools/router-devtools.tsx b/src/devtools/router-devtools.tsx index 2a5ae71..36092a8 100644 --- a/src/devtools/router-devtools.tsx +++ b/src/devtools/router-devtools.tsx @@ -1,5 +1,4 @@ import type { TanStackDevtoolsReactPlugin } from '@tanstack/react-devtools'; - import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'; export const tanstackRouterDevtools: TanStackDevtoolsReactPlugin = { diff --git a/src/ui/components/core/alert.tsx b/src/ui/components/core/alert.tsx index 0f9439e..d933a42 100644 --- a/src/ui/components/core/alert.tsx +++ b/src/ui/components/core/alert.tsx @@ -10,7 +10,7 @@ const alertVariants = cva( variant: { default: 'bg-card text-card-foreground', destructive: - 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current', }, }, defaultVariants: { diff --git a/src/ui/components/core/badge.tsx b/src/ui/components/core/badge.tsx index 8d5bb1f..645c982 100644 --- a/src/ui/components/core/badge.tsx +++ b/src/ui/components/core/badge.tsx @@ -14,7 +14,7 @@ const badgeVariants = cva( secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', destructive: - 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + 'border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90', outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', }, diff --git a/src/ui/components/core/button.tsx b/src/ui/components/core/button.tsx index e6e9f36..0cbadc8 100644 --- a/src/ui/components/core/button.tsx +++ b/src/ui/components/core/button.tsx @@ -11,9 +11,9 @@ const buttonVariants = cva( variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: - 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: @@ -22,7 +22,7 @@ const buttonVariants = cva( }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', - sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-sm': 'size-8', diff --git a/src/ui/components/core/calendar.tsx b/src/ui/components/core/calendar.tsx index dcae41f..81302cf 100644 --- a/src/ui/components/core/calendar.tsx +++ b/src/ui/components/core/calendar.tsx @@ -83,7 +83,7 @@ function Calendar({ 'font-medium select-none', captionLayout === 'label' ? 'text-sm' - : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5', + : 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground', defaultClassNames.caption_label, ), table: 'w-full border-collapse', diff --git a/src/ui/components/core/chart.tsx b/src/ui/components/core/chart.tsx index cd7cf2f..58bfd81 100644 --- a/src/ui/components/core/chart.tsx +++ b/src/ui/components/core/chart.tsx @@ -1,13 +1,12 @@ -import type { LegendPayload } from 'recharts/types/component/DefaultLegendContent'; -import type { Props as LegendProps } from 'recharts/types/component/Legend'; - import * as React from 'react'; import * as RechartsPrimitive from 'recharts'; +import type { LegendPayload } from 'recharts/types/component/DefaultLegendContent'; import { NameType, Payload, ValueType, } from 'recharts/types/component/DefaultTooltipContent'; +import type { Props as LegendProps } from 'recharts/types/component/Legend'; import { TooltipContentProps } from 'recharts/types/component/Tooltip'; import { cn } from '~/ui/utils'; @@ -94,7 +93,7 @@ function ChartContainer({ data-slot="chart" data-chart={chartId} className={cn( - "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", className, )} {...props} @@ -209,7 +208,7 @@ function ChartTooltipContent({ return (
@@ -224,7 +223,7 @@ function ChartTooltipContent({
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5', + 'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground', indicator === 'dot' && 'items-center', )} > @@ -269,7 +268,7 @@ function ChartTooltipContent({
{item.value && ( - + {item.value.toLocaleString()} )} @@ -315,7 +314,7 @@ function ChartLegendContent({
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3', + 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground', )} > {itemConfig?.icon && !hideIcon ? ( diff --git a/src/ui/components/core/empty.tsx b/src/ui/components/core/empty.tsx index c4a0d89..e687401 100644 --- a/src/ui/components/core/empty.tsx +++ b/src/ui/components/core/empty.tsx @@ -34,7 +34,7 @@ const emptyMediaVariants = cva( variants: { variant: { default: 'bg-transparent', - icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", }, }, defaultVariants: { diff --git a/src/ui/components/core/field.tsx b/src/ui/components/core/field.tsx index 3d46872..490d418 100644 --- a/src/ui/components/core/field.tsx +++ b/src/ui/components/core/field.tsx @@ -64,7 +64,7 @@ const fieldVariants = cva( 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ], responsive: [ - 'flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto', + 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto [&>.sr-only]:w-auto', '@md/field-group:*:data-[slot=field-label]:flex-auto', '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ], diff --git a/src/ui/components/core/input-group.tsx b/src/ui/components/core/input-group.tsx index 8c6d51f..2c38611 100644 --- a/src/ui/components/core/input-group.tsx +++ b/src/ui/components/core/input-group.tsx @@ -44,9 +44,9 @@ const inputGroupAddonVariants = cva( 'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]', 'block-start': - 'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5', + 'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3', 'block-end': - 'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5', + 'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3', }, }, defaultVariants: { @@ -83,8 +83,8 @@ const inputGroupButtonVariants = cva( { variants: { size: { - xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", - sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5', + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5', 'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0', 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', diff --git a/src/ui/components/core/item.tsx b/src/ui/components/core/item.tsx index b5b900c..7dc1db6 100644 --- a/src/ui/components/core/item.tsx +++ b/src/ui/components/core/item.tsx @@ -40,8 +40,8 @@ const itemVariants = cva( muted: 'bg-muted/50', }, size: { - default: 'p-4 gap-4 ', - sm: 'py-3 px-4 gap-2.5', + default: 'gap-4 p-4', + sm: 'gap-2.5 px-4 py-3', }, }, defaultVariants: { @@ -77,9 +77,9 @@ const itemMediaVariants = cva( variants: { variant: { default: 'bg-transparent', - icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4", + icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4", image: - 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover', + 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover', }, }, defaultVariants: { diff --git a/src/ui/components/core/resizable.tsx b/src/ui/components/core/resizable.tsx index 038241a..2238dab 100644 --- a/src/ui/components/core/resizable.tsx +++ b/src/ui/components/core/resizable.tsx @@ -39,7 +39,7 @@ function ResizableSeparator({ className={cn( 'relative flex items-center justify-center bg-border', // When in focus - 'focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden', + 'focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden', // Dom pseudo element :after 'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2', // When the orientation changes @@ -54,7 +54,7 @@ function ResizableSeparator({ {...props} > {withHandle && ( -
+
)} diff --git a/src/ui/components/core/sheet.tsx b/src/ui/components/core/sheet.tsx index 20c2342..467c916 100644 --- a/src/ui/components/core/sheet.tsx +++ b/src/ui/components/core/sheet.tsx @@ -58,13 +58,13 @@ function SheetContent({ className={cn( 'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500', side === 'right' && - 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', side === 'left' && - 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', + 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', side === 'top' && - 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', + 'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', side === 'bottom' && - 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', + 'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', className, )} {...props} diff --git a/src/ui/components/core/sidebar.tsx b/src/ui/components/core/sidebar.tsx index 8f51276..4114e5a 100644 --- a/src/ui/components/core/sidebar.tsx +++ b/src/ui/components/core/sidebar.tsx @@ -565,7 +565,7 @@ function SidebarMenuAction({ 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', showOnHover && - 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', + 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0', className, )} {...props} diff --git a/src/ui/components/core/toggle-group.tsx b/src/ui/components/core/toggle-group.tsx index d35d7aa..bb5e732 100644 --- a/src/ui/components/core/toggle-group.tsx +++ b/src/ui/components/core/toggle-group.tsx @@ -1,6 +1,5 @@ -import type { VariantProps } from 'class-variance-authority'; - import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'; +import type { VariantProps } from 'class-variance-authority'; import * as React from 'react'; import { toggleVariants } from '~/ui/components/core/toggle'; diff --git a/src/ui/components/core/toggle.tsx b/src/ui/components/core/toggle.tsx index 46f81a7..cbc3f7f 100644 --- a/src/ui/components/core/toggle.tsx +++ b/src/ui/components/core/toggle.tsx @@ -14,9 +14,9 @@ const toggleVariants = cva( 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', }, size: { - default: 'h-9 px-2 min-w-9', - sm: 'h-8 px-1.5 min-w-8', - lg: 'h-10 px-2.5 min-w-10', + default: 'h-9 min-w-9 px-2', + sm: 'h-8 min-w-8 px-1.5', + lg: 'h-10 min-w-10 px-2.5', }, }, defaultVariants: { From 3435a647c1600d1264ca4195a9571d69952427e6 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Wed, 14 Jan 2026 21:31:44 +0700 Subject: [PATCH 06/11] feat(i18n): setup paraglide --- .oxfmtrc.json | 7 +- .oxlintrc.json | 7 +- .vscode/extensions.json | 6 +- messages/en.json | 5 + messages/id.json | 5 + messages/zh-CN.json | 5 + package.json | 2 + pnpm-lock.yaml | 184 ++++++++++++++++++++++++++++++++++- project.inlang/settings.json | 12 +++ src/router.ts | 6 ++ src/routes/__root.tsx | 54 +++++++--- src/routes/index.tsx | 6 +- src/server.ts | 19 ++-- tsconfig.json | 2 + vite.config.ts | 28 ++++++ 15 files changed, 315 insertions(+), 33 deletions(-) create mode 100644 messages/en.json create mode 100644 messages/id.json create mode 100644 messages/zh-CN.json create mode 100644 project.inlang/settings.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json index efb26aa..334cd76 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,7 +13,12 @@ "bracketSameLine": false, "singleAttributePerLine": false, "arrowParens": "always", - "ignorePatterns": ["pnpm-lock.yaml", "routeTree.gen.ts", "public/"], + "ignorePatterns": [ + "pnpm-lock.yaml", + "routeTree.gen.ts", + "public/", + "src/lib/i18n/" + ], "experimentalSortPackageJson": true, "experimentalSortImports": { "order": "asc", diff --git a/.oxlintrc.json b/.oxlintrc.json index 7e8c218..715d339 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,11 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": ["pnpm-lock.yaml", "routeTree.gen.ts", "public/"], + "ignorePatterns": [ + "pnpm-lock.yaml", + "routeTree.gen.ts", + "public/", + "src/lib/i18n/" + ], "env": { "browser": true, "node": true diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 4ea727f..fc5a256 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,7 @@ { - "recommendations": ["oxc.oxc-vscode", "bradlc.vscode-tailwindcss"] + "recommendations": [ + "oxc.oxc-vscode", + "bradlc.vscode-tailwindcss", + "inlang.vs-code-extension" + ] } diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..4e2a872 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "Devsantara Kit", + "app_description": "The blueprint for your next big idea" +} diff --git a/messages/id.json b/messages/id.json new file mode 100644 index 0000000..3f79448 --- /dev/null +++ b/messages/id.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "Devsantara Kit", + "app_description": "Rancangan dasar untuk ide besar kamu berikutnya" +} diff --git a/messages/zh-CN.json b/messages/zh-CN.json new file mode 100644 index 0000000..095667c --- /dev/null +++ b/messages/zh-CN.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "Devsantara 工具包", + "app_description": "您下一个伟大构想的基石计划" +} diff --git a/package.json b/package.json index ce99448..4654ff7 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,8 @@ "@commitlint/cli": "^20.3.0", "@commitlint/config-conventional": "^20.3.0", "@commitlint/types": "^20.2.0", + "@inlang/cli": "^3.0.0", + "@inlang/paraglide-js": "^2.8.0", "@posthog/cli": "^0.5.22", "@posthog/rollup-plugin": "^1.1.7", "@release-it/conventional-changelog": "10.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b12e1d3..5b1c80e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,12 @@ importers: '@commitlint/types': specifier: ^20.2.0 version: 20.2.0 + '@inlang/cli': + specifier: ^3.0.0 + version: 3.1.2 + '@inlang/paraglide-js': + specifier: ^2.8.0 + version: 2.8.0 '@posthog/cli': specifier: ^0.5.22 version: 0.5.22 @@ -225,7 +231,7 @@ importers: version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) alchemy: specifier: ^0.83.0 - version: 0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)) + version: 0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(kysely@0.27.6)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -1241,6 +1247,22 @@ packages: cpu: [x64] os: [win32] + '@inlang/cli@3.1.2': + resolution: {integrity: sha512-Dwt9b21N0qVZv//x5DVhNTHWE4rl7XFY/Jvx6dir3/EphyKVxBOHKq0RtYEB48BrE/OHIru6tmNJgmuAPsVETA==} + engines: {node: '>=18.0.0'} + hasBin: true + + '@inlang/paraglide-js@2.8.0': + resolution: {integrity: sha512-ataaSmV53zz+tIr+KJLdC3tTB1uikS79hvtLlZk2ikbGRB/kcyQeg+lsqzjsXCAvy0/O28ucCRjxbHsTzOVQVg==} + hasBin: true + + '@inlang/recommend-sherlock@0.2.1': + resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==} + + '@inlang/sdk@2.6.0': + resolution: {integrity: sha512-f4iVHVXyzOi0CXlXSAT7XPrReLBaVXy/po/qrOPf2OHh+hUwyD1bDx2EYC5KgrZ16z3ylWfqWVuc7o4l7/tuUQ==} + engines: {node: '>=18.0.0'} + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -1415,6 +1437,13 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lix-js/sdk@0.4.7': + resolution: {integrity: sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==} + engines: {node: '>=18'} + + '@lix-js/server-protocol-schema@0.1.1': + resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -2517,6 +2546,9 @@ packages: resolution: {integrity: sha512-6rsHTjodIn/t90lv5snQjRPVtOosM7Vp0AKdrObymq45ojlgVwnpAqdc+0OBBrpEiy31zZ6/TKeIVqV1HwvnuQ==} engines: {node: '>=18'} + '@sinclair/typebox@0.31.28': + resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -2730,6 +2762,10 @@ packages: '@speed-highlight/core@1.2.14': resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@sqlite.org/sqlite-wasm@3.48.0-build4': + resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} + hasBin: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3335,6 +3371,9 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3535,6 +3574,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -3543,6 +3586,10 @@ packages: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} + comment-json@4.5.1: + resolution: {integrity: sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==} + engines: {node: '>= 6'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -3553,6 +3600,10 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -3734,6 +3785,14 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dedent@1.5.1: + resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -3996,6 +4055,11 @@ packages: es-toolkit@1.43.0: resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==} + esbuild-wasm@0.19.12: + resolution: {integrity: sha512-Zmc4hk6FibJZBcTx5/8K/4jT3/oG1vkGTEeKJUQFCUQKimD6Q7+adp/bdVQyYJFolMKaXkQnVZdV4O5ZaTYmyQ==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -4282,6 +4346,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -4457,6 +4525,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-sha256@0.11.1: + resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4491,6 +4562,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} + launch-editor@2.12.0: resolution: {integrity: sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==} @@ -5326,6 +5401,11 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sqlite-wasm-kysely@0.3.0: + resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} + peerDependencies: + kysely: '*' + srvx@0.10.0: resolution: {integrity: sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA==} engines: {node: '>=20.16.0'} @@ -5566,6 +5646,9 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -5594,6 +5677,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -6853,6 +6944,39 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inlang/cli@3.1.2': + dependencies: + '@inlang/sdk': 2.6.0 + esbuild-wasm: 0.19.12 + transitivePeerDependencies: + - babel-plugin-macros + + '@inlang/paraglide-js@2.8.0': + dependencies: + '@inlang/recommend-sherlock': 0.2.1 + '@inlang/sdk': 2.6.0 + commander: 11.1.0 + consola: 3.4.0 + json5: 2.2.3 + unplugin: 2.3.11 + urlpattern-polyfill: 10.1.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@inlang/recommend-sherlock@0.2.1': + dependencies: + comment-json: 4.5.1 + + '@inlang/sdk@2.6.0': + dependencies: + '@lix-js/sdk': 0.4.7 + '@sinclair/typebox': 0.31.28 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 13.0.0 + transitivePeerDependencies: + - babel-plugin-macros + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@25.0.3)': @@ -7025,6 +7149,20 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lix-js/sdk@0.4.7': + dependencies: + '@lix-js/server-protocol-schema': 0.1.1 + dedent: 1.5.1 + human-id: 4.1.3 + js-sha256: 0.11.1 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 10.0.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@lix-js/server-protocol-schema@0.1.1': {} + '@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: '@types/mdx': 2.0.13 @@ -8099,6 +8237,8 @@ snapshots: dependencies: '@types/node': 22.19.3 + '@sinclair/typebox@0.31.28': {} + '@sindresorhus/is@7.2.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -8413,6 +8553,8 @@ snapshots: '@speed-highlight/core@1.2.14': {} + '@sqlite.org/sqlite-wasm@3.48.0-build4': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -9027,7 +9169,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)): + alchemy@0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(kysely@0.27.6)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)): dependencies: '@aws-sdk/credential-providers': 3.962.0 '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20251217.0) @@ -9037,7 +9179,7 @@ snapshots: '@smithy/node-config-provider': 4.3.7 '@smithy/types': 4.11.0 aws4fetch: 1.0.20 - drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0) + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.27.6) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -9132,6 +9274,8 @@ snapshots: array-ify@1.0.0: {} + array-timsort@1.0.3: {} + assertion-error@2.0.1: {} ast-types@0.13.4: @@ -9365,10 +9509,18 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@11.1.0: {} + commander@12.1.0: {} commander@14.0.2: {} + comment-json@4.5.1: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -9383,6 +9535,8 @@ snapshots: confbox@0.2.2: {} + consola@3.4.0: {} + consola@3.4.2: {} console.table@0.10.0: @@ -9547,6 +9701,8 @@ snapshots: decimal.js-light@2.5.1: {} + dedent@1.5.1: {} + deep-eql@5.0.2: {} default-browser-id@5.0.1: {} @@ -9615,9 +9771,10 @@ snapshots: dotenv@17.2.3: {} - drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0): + drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.27.6): optionalDependencies: '@cloudflare/workers-types': 4.20260103.0 + kysely: 0.27.6 dunder-proto@1.0.1: dependencies: @@ -9696,6 +9853,8 @@ snapshots: es-toolkit@1.43.0: {} + esbuild-wasm@0.19.12: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -10076,6 +10235,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-id@4.1.3: {} + human-signals@5.0.0: {} human-signals@8.0.1: {} @@ -10212,6 +10373,8 @@ snapshots: jiti@2.6.1: {} + js-sha256@0.11.1: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -10237,6 +10400,8 @@ snapshots: kleur@4.1.5: {} + kysely@0.27.6: {} + launch-editor@2.12.0: dependencies: picocolors: 1.1.1 @@ -11119,6 +11284,11 @@ snapshots: split2@4.2.0: {} + sqlite-wasm-kysely@0.3.0(kysely@0.27.6): + dependencies: + '@sqlite.org/sqlite-wasm': 3.48.0-build4 + kysely: 0.27.6 + srvx@0.10.0: {} stdin-discarder@0.2.2: {} @@ -11321,6 +11491,8 @@ snapshots: url-join@5.0.0: {} + urlpattern-polyfill@10.1.0: {} + use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 @@ -11342,6 +11514,10 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + + uuid@13.0.0: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/project.inlang/settings.json b/project.inlang/settings.json new file mode 100644 index 0000000..5d336d0 --- /dev/null +++ b/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "id", "zh-CN"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/src/router.ts b/src/router.ts index f6e10a8..d17e02b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -4,6 +4,7 @@ import { } from '@tanstack/react-router'; import { posthog } from 'posthog-js'; +import { deLocalizeUrl, localizeUrl } from '~/lib/i18n/runtime'; import { routeTree } from '~/routeTree.gen'; export function getRouter() { @@ -11,10 +12,15 @@ export function getRouter() { routeTree, defaultPreload: 'intent', scrollRestoration: true, + trailingSlash: 'never', defaultErrorComponent: ErrorComponent, defaultOnCatch(error) { posthog.captureException(error); }, + rewrite: { + input: ({ url }) => deLocalizeUrl(url), + output: ({ url }) => localizeUrl(url), + }, }); return router; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index c21a7f3..9b37b9c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -8,6 +8,13 @@ import * as React from 'react'; import { preload } from 'react-dom'; import { tanstackRouterDevtools } from '~/devtools/router-devtools'; +import { m } from '~/lib/i18n/messages'; +import { + baseLocale, + getLocale, + locales, + localizeHref, +} from '~/lib/i18n/runtime'; import { PostHogProvider } from '~/lib/posthog/provider'; import { Toaster } from '~/ui/components/core/sonner'; import appStylesheet from '~/ui/styles/app.css?url'; @@ -15,17 +22,40 @@ import fontStylesheet from '~/ui/styles/fonts.css?url'; import { ThemeProvider } from '~/ui/styles/theme'; export const Route = createRootRoute({ - head: () => ({ - meta: [ - { charSet: 'utf-8' }, - { name: 'viewport', content: 'width=device-width, initial-scale=1' }, - { title: 'Devsantara Kit' }, - ], - links: [ - { rel: 'stylesheet', href: fontStylesheet }, - { rel: 'stylesheet', href: appStylesheet }, - ], - }), + loader: ({ location }) => { + return { currentHref: location.url.href }; + }, + head: ({ loaderData }) => { + /** @example http://localhost:3000/path/without/locale */ + const currentHref = loaderData?.currentHref ?? ''; + + return { + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: m.app_name() }, + { name: 'description', content: m.app_description() }, + ], + links: [ + { rel: 'stylesheet', href: fontStylesheet }, + { rel: 'stylesheet', href: appStylesheet }, + { + rel: 'canonical', + href: localizeHref(currentHref), + }, + { + rel: 'alternate', + hrefLang: 'x-default', + href: localizeHref(currentHref, { locale: baseLocale }), + }, + ...locales.map((locale) => ({ + rel: 'alternate', + hrefLang: locale, + href: localizeHref(currentHref, { locale }), + })), + ], + }; + }, shellComponent: RootDocument, }); @@ -34,7 +64,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { preload(jetBrainsMonoFont, { as: 'font', type: 'font/woff2' }); return ( - + diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3026d1f..1a13482 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,7 @@ import { createFileRoute } from '@tanstack/react-router'; +import { m } from '~/lib/i18n/messages'; + export const Route = createFileRoute('/')({ component: HomePage, }); @@ -9,10 +11,10 @@ function HomePage() {

- Devsantara Kit + {m.app_name()}

- The blueprint for your next big idea + {m.app_description()}

git@github.com:devsantara/kit.git diff --git a/src/server.ts b/src/server.ts index e00e57b..2194408 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,9 @@ -import { - createStartHandler, - defaultStreamHandler, - defineHandlerCallback, -} from '@tanstack/react-start/server'; -import { createServerEntry } from '@tanstack/react-start/server-entry'; +import handler from '@tanstack/react-start/server-entry'; -const serverHandler = defineHandlerCallback((ctx) => { - return defaultStreamHandler(ctx); -}); +import { paraglideMiddleware } from '~/lib/i18n/server'; -const fetch = createStartHandler(serverHandler); - -export default createServerEntry({ fetch }); +export default { + fetch(req: Request): Promise { + return paraglideMiddleware(req, () => handler.fetch(req)); + }, +}; diff --git a/tsconfig.json b/tsconfig.json index acd4bac..3bcc0e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "include": [ "@types/**/*.ts", + "src/**/*.js", "src/**/*.ts", "src/**/*.tsx", ".storybook/**/*.ts", @@ -17,6 +18,7 @@ "target": "ES2022", "jsx": "react-jsx", "module": "ESNext", + "allowJs": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client", "@cloudflare/workers-types"], diff --git a/vite.config.ts b/vite.config.ts index 54c5e50..8d6776e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import { paraglideVitePlugin as paraglide } from '@inlang/paraglide-js'; import tailwindcss from '@tailwindcss/vite'; import { devtools } from '@tanstack/devtools-vite'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; @@ -45,6 +46,33 @@ export default async function viteConfig({ mode }: ConfigEnv) { tanstackStart({ srcDirectory: 'src', router: { routeToken: 'layout' } }), // React's vite plugin must come after start's vite plugin viteReact({ babel: { plugins: ['babel-plugin-react-compiler'] } }), + paraglide({ + project: './project.inlang', + outdir: './src/lib/i18n', + cookieName: 'LOCALE', + outputStructure: 'message-modules', + strategy: ['url', 'cookie', 'preferredLanguage', 'baseLocale'], + // DisableAsyncLocalStorage should ONLY be used in serverless environments like Cloudflare Workers. + disableAsyncLocalStorage: true, + urlPatterns: [ + { + pattern: '/', + localized: [ + ['en', '/en'], + ['id', '/id'], + ['zh-CN', '/zh-CN'], + ], + }, + { + pattern: '/:path(.*)?', + localized: [ + ['en', '/en/:path(.*)?'], + ['id', '/id/:path(.*)?'], + ['zh-CN', '/zh-CN/:path(.*)?'], + ], + }, + ], + }), posthog({ host: process.env.POSTHOG_CLI_HOST, envId: process.env.POSTHOG_CLI_ENV_ID, From 13ff3ab1d6debda0a39ceeb23125d4038f676487 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 16 Jan 2026 14:55:00 +0700 Subject: [PATCH 07/11] fix(storybook): missing iframe.html on production build --- .storybook/main.ts | 9 ++++++++- .storybook/preview.ts | 3 ++- vite.storybook.ts | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 vite.storybook.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 1ae8f29..e05f4d4 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -3,6 +3,13 @@ import { defineMain } from '@storybook/react-vite/node'; export default defineMain({ stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx|mdx)'], staticDirs: ['../public'], - framework: '@storybook/react-vite', addons: ['@storybook/addon-docs'], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: './vite.storybook.ts', + }, + }, + }, }); diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 20e84e3..d6dc4a6 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,5 @@ -import '../src/ui/styles/app.css'; +import '~/ui/styles/app.css'; +import '~/ui/styles/fonts.css'; import addonDocs from '@storybook/addon-docs'; import { definePreview } from '@storybook/react-vite'; diff --git a/vite.storybook.ts b/vite.storybook.ts new file mode 100644 index 0000000..42f4e65 --- /dev/null +++ b/vite.storybook.ts @@ -0,0 +1,7 @@ +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; +import tsConfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [tsConfigPaths({ projects: ['./tsconfig.json'] }), tailwindcss()], +}); From b3c41ec2378fb83a4d491a548c304f76d73973c5 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 16 Jan 2026 14:56:56 +0700 Subject: [PATCH 08/11] feat(seo): create head metadata builder --- src/lib/seo/head.ts | 814 +++++++++++++++++++++++++++++++++ src/lib/seo/types/alternate.ts | 8 + src/lib/seo/types/metadata.ts | 51 +++ src/lib/seo/types/opengraph.ts | 113 +++++ src/lib/seo/types/twitter.ts | 42 ++ src/routes/__root.tsx | 43 +- 6 files changed, 1044 insertions(+), 27 deletions(-) create mode 100644 src/lib/seo/head.ts create mode 100644 src/lib/seo/types/alternate.ts create mode 100644 src/lib/seo/types/metadata.ts create mode 100644 src/lib/seo/types/opengraph.ts create mode 100644 src/lib/seo/types/twitter.ts diff --git a/src/lib/seo/head.ts b/src/lib/seo/head.ts new file mode 100644 index 0000000..4902982 --- /dev/null +++ b/src/lib/seo/head.ts @@ -0,0 +1,814 @@ +import * as React from 'react'; + +import type { AlternateLocaleOptions } from '~/lib/seo/types/alternate'; +import type { + ColorSchemeOptions, + IconOptions, + RobotOptions, + ViewportOptions, +} from '~/lib/seo/types/metadata'; +import type { OpenGraphOptions } from '~/lib/seo/types/opengraph'; +import type { TwitterOptions } from '~/lib/seo/types/twitter'; + +type Awaitable = T | Promise; +type Meta = React.DetailedHTMLProps< + React.MetaHTMLAttributes, + HTMLMetaElement +>; +type Link = React.DetailedHTMLProps< + React.LinkHTMLAttributes, + HTMLLinkElement +>; +type Script = React.DetailedHTMLProps< + React.ScriptHTMLAttributes, + HTMLScriptElement +>; +type Style = React.DetailedHTMLProps< + React.StyleHTMLAttributes, + HTMLStyleElement +>; + +interface HeadResult { + meta?: Meta[]; + links?: Link[]; + scripts?: Script[]; + styles?: Style[]; +} + +/** + * HeadBuilder - A fluent builder class for constructing SEO-optimized HTML head metadata + * + * This class provides a comprehensive, chainable API for building HTML head elements including + * meta tags, link elements, scripts, and styles. It supports common SEO practices such as + * Open Graph, Twitter Cards, canonical URLs, and robot directives. + * + * The builder follows the Builder design pattern, allowing you to chain multiple method calls + * to construct a complete head configuration before calling `build()` to generate the result. + * + * @example + * const head = new HeadBuilder('https://devsantara.com') + * .addCharSet('utf-8') + * .addTitle('My Awesome Website') + * .addDescription('A comprehensive guide to building great websites') + * .addViewport({ + * width: 'device-width', + * initialScale: 1, + * }) + * .build(); + * + * // Result: + * { + * meta: [ + * { charSet: 'utf-8' }, + * { title: 'My Awesome Website' }, + * { name: 'description', content: 'A comprehensive guide to building great websites' }, + * { name: 'viewport', content: 'width=device-width, initial-scale=1' } + * ], + * links: [], + * scripts: [], + * styles: [] + * } + */ +export class HeadBuilder { + private metadataBase?: URL; + private meta: Meta[] = []; + private links: Link[] = []; + private scripts: Script[] = []; + private styles: Style[] = []; + + /** + * Creates a new HeadBuilder instance with optional metadataBase configuration + * + * The metadataBase serves as the base path and origin for absolute URLs in various + * metadata fields. When relative URLs (for Open Graph images, alternates, etc.) are used, + * they are composed with this base. If not provided, relative URLs will be used as-is. + * + * @param metadataBase - The base URL to use for resolving relative URLs in metadata + * + * @example + * const head = new HeadBuilder('https://devsantara.com') + * .addOpenGraph({ + * title: 'My Article', + * image: { url: '/og-image.jpg' } // Will resolve to https://devsantara.com/og-image.jpg + * }) + * .build(); + */ + constructor(metadataBase?: URL) { + this.metadataBase = metadataBase; + } + + /** + * Resolves a URL by composing relative URLs with the metadataBase + * + * If the URL is already absolute (contains a protocol), it's returned unchanged. + * If the URL is relative and metadataBase is provided, they're composed together. + * If the URL is relative and metadataBase is not provided, the URL is returned as-is. + */ + private resolveUrl(url: string | URL) { + if (url instanceof URL) return url.toString(); + if (!this.metadataBase) return url; + + try { + // Try to parse as absolute URL + // oxlint-disable-next-line no-new + new URL(url); + return url; + } catch { + try { + // It's relative, compose with base + return new URL(url, this.metadataBase).toString(); + } catch { + // If base is invalid, return original URL + return url; + } + } + } + + /** + * Adds a charset meta tag to specify the character encoding of the document + * + * The charset meta tag declares the character encoding used by the document, typically UTF-8. + * This should be one of the first meta tags in the head section to ensure proper character + * rendering across all browsers and devices. + * + * @example + * const head = new HeadBuilder() + * .addCharSet('utf-8') + * .build(); + * + * // Result: + * + */ + addCharSet(charSet: string) { + this.meta.push({ charSet }); + return this; + } + + /** + * Adds a viewport meta tag to control layout and scaling on mobile devices + * + * The viewport meta tag is crucial for responsive web design. It instructs the browser + * how to scale and render the page, particularly on mobile devices. Without this tag, + * mobile browsers will attempt to scale the full desktop page to fit the screen, often + * resulting in zoomed-out, unreadable content. + * + * @example + * const head = new HeadBuilder() + * .addViewport({ + * width: 'device-width', + * initialScale: 1, + * }) + * .build(); + * + * // Result: + * + */ + addViewport(options: ViewportOptions) { + const contentParts: string[] = []; + if (options.width) { + contentParts.push(`width=${options.width}`); + } + if (options.height) { + contentParts.push(`height=${options.height}`); + } + if (options.initialScale) { + contentParts.push(`initial-scale=${options.initialScale}`); + } + if (options.minimumScale) { + contentParts.push(`minimum-scale=${options.minimumScale}`); + } + if (options.maximumScale) { + contentParts.push(`maximum-scale=${options.maximumScale}`); + } + if (options.userScalable !== undefined) { + contentParts.push(`user-scalable=${options.userScalable ? 'yes' : 'no'}`); + } + if (options.viewportFit) { + contentParts.push(`viewport-fit=${options.viewportFit}`); + } + if (options.interactiveWidget) { + contentParts.push(`interactive-widget=${options.interactiveWidget}`); + } + + const content = contentParts.join(', '); + this.meta.push({ name: 'viewport', content }); + return this; + } + + /** + * Adds a canonical link tag to specify the preferred version of a web page + * + * The canonical URL tells search engines which version of a page to prioritize when + * multiple URLs have similar or identical content. This is essential for: + * - Preventing duplicate content issues with search engines + * - Consolidating ranking signals to the preferred URL + * - Managing URL variants (HTTP vs HTTPS, www vs non-www, trailing slashes) + * - Handling pagination and session parameters + * + * Relative URLs are resolved using the metadataBase if provided. + * + * @example + * const head = new HeadBuilder('https://devsantara.com') + * .addCanonical('/blogs/nusantara') + * .build(); + * + * // Result: + * + */ + addCanonical(url: string | URL) { + this.links.push({ rel: 'canonical', href: this.resolveUrl(url) }); + return this; + } + + /** + * Adds alternate locale links to specify the page's translations or variants + * + * Alternate locale links help search engines understand which pages are translations + * or variants of the same content in different languages or for different regions. + * This is crucial for international SEO and helps users find content in their locale. + * + * Relative URLs are resolved using the metadataBase if provided. + * + * @example + * const head = new HeadBuilder() + * .addAlternateLocales({ + * 'x-default': 'https://devsantara.com/en', + * 'en': 'https://devsantara.com/en', + * 'fr': 'https://devsantara.com/fr' + * }) + * .build(); + * + * // Result: + * + * + * + */ + addAlternateLocales( + alternates: AlternateLocaleOptions, + ) { + Object.entries(alternates).map(([locale, url]) => { + this.links.push({ + rel: 'alternate', + hrefLang: locale, + href: this.resolveUrl(url), + }); + }); + return this; + } + + /** + * Adds a manifest link for progressive web app (PWA) configuration + * + * The manifest link points to a JSON file that contains metadata about your web application, + * including the app name, icons, theme colors, and display preferences. This is essential + * for progressive web apps that can be installed on mobile devices and desktops. + * + * @example + * const head = new HeadBuilder() + * .addManifest('/manifest.json') + * .build(); + * + * // Result: + * + */ + addManifest(url: string | URL) { + this.links.push({ rel: 'manifest', href: String(url) }); + return this; + } + + /** + * Adds keywords meta tag for search engine optimization + * + * The keywords meta tag provides a list of keywords relevant to the page content. + * While modern search engines like Google place less emphasis on keywords, they can still + * be useful for other search engines and content discovery. Keep keywords relevant and + * avoid keyword stuffing. + * + * @example + * const head = new HeadBuilder() + * .addKeywords(['web development', 'SEO', 'best practices']) + * .build(); + * + * // Result: + * + */ + addKeywords(keywords: string[]) { + this.meta.push({ name: 'keywords', content: keywords.join(', ') }); + return this; + } + + /** + * Adds a color-scheme meta tag to indicate the color theme preferences + * + * The color-scheme meta tag allows web developers to declare which color schemes + * their page supports (light mode, dark mode, or both). This helps browsers and + * operating systems render the page appropriately and improves user experience + * by respecting user's system preferences. + * + * @example + * const head = new HeadBuilder() + * .addColorScheme('light dark') + * .build(); + * + * // Result: + * + */ + addColorScheme(scheme: ColorSchemeOptions) { + this.meta.push({ name: 'color-scheme', content: scheme }); + return this; + } + + /** + * Adds a title meta tag specifying the page title + * + * The title tag is critical for SEO and user experience. It appears in search engine + * results, browser tabs, and browser history. Keep titles concise, + * descriptive, and unique across your site. Include primary keywords naturally without + * keyword stuffing. + * + * @example + * const head = new HeadBuilder() + * .addTitle('Devsantara') + * .build(); + * + * // Result: + * Devsantara + */ + addTitle(title: string) { + this.meta.push({ title }); + return this; + } + + /** + * Adds a meta description tag summarizing the page content + * + * The meta description provides a concise summary of the page content + * that appears in search engine results. + * + * @example + * // Basic page description + * const head = new HeadBuilder() + * .addDescription('The blueprint for your next big idea') + * .build(); + * + * // HTML Result: + * + */ + addDescription(description: string) { + this.meta.push({ name: 'description', content: description }); + return this; + } + + /** + * Adds Open Graph (OG) meta tags for rich preview on social media + * + * Open Graph meta tags control how your content is previewed when shared on Facebook, + * LinkedIn, Twitter (X), and other social platforms. Proper OG tags increase social + * engagement by providing attractive, accurate previews with titles, descriptions, and images. + * + * Open Graph includes standard properties for all content types plus type-specific properties + * for articles, videos, music, and more. + * + * Relative URLs are resolved using the metadataBase if provided. + * + * @example + * const head = new HeadBuilder('https://devsantara.com') + * .addOpenGraph({ + * title: 'Devsantara', + * description: 'The blueprint for your next big idea', + * url: '/', + * image: { + * url: '/assets/og.jpg', + * } + * }) + * .build(); + * + * // Result: + * + * + * + * + */ + addOpenGraph(options: OpenGraphOptions) { + if (options.title) { + this.meta.push({ property: 'og:title', content: options.title }); + } + if (options.description) { + this.meta.push({ + property: 'og:description', + content: options.description, + }); + } + if (options.url) { + this.meta.push({ + property: 'og:url', + content: this.resolveUrl(options.url), + }); + } + if (options.locale) { + this.meta.push({ property: 'og:locale', content: options.locale }); + } + if (options.image) { + this.meta.push({ + property: 'og:image', + content: this.resolveUrl(options.image.url), + }); + if (options.image.alt) { + this.meta.push({ + property: 'og:image:alt', + content: options.image.alt, + }); + } + if (options.image.type) { + this.meta.push({ + property: 'og:image:type', + content: options.image.type, + }); + } + if (options.image.width) { + this.meta.push({ + property: 'og:image:width', + content: String(options.image.width), + }); + } + if (options.image.height) { + this.meta.push({ + property: 'og:image:height', + content: String(options.image.height), + }); + } + } + if (options.type) { + this.meta.push({ property: 'og:type', content: options.type.name }); + if ('properties' in options.type) { + for (const property of options.type.properties) { + this.meta.push({ + property: property.name, + content: property.content, + }); + } + } + } + return this; + } + + /** + * Adds Twitter Card meta tags for enhanced sharing on Twitter/X + * + * Twitter Cards enable rich media presentations of content when shared on Twitter. + * They control how your content appears with summaries, images, and interactive elements. + * Different card types optimize content differently (summary, summary_large_image, player, etc.). + * + * @example + * const head = new HeadBuilder() + * .addTwitter({ + * card: { name: 'summary' }, + * title: 'Devsantara', + * description: 'The blueprint for your next big idea', + * site: '@devsantara', + * creator: '@edwintantawi' + * }) + * .build(); + * + * // Result: + * + * + * + * + * + */ + addTwitter(options: TwitterOptions) { + if (options.title) { + this.meta.push({ name: 'twitter:title', content: options.title }); + } + if (options.description) { + this.meta.push({ + name: 'twitter:description', + content: options.description, + }); + } + if (options.site) { + this.meta.push({ name: 'twitter:site', content: options.site }); + } + if (options.siteId) { + this.meta.push({ name: 'twitter:site:id', content: options.siteId }); + } + if (options.creator) { + this.meta.push({ name: 'twitter:creator', content: options.creator }); + } + if (options.creatorId) { + this.meta.push({ + name: 'twitter:creator:id', + content: options.creatorId, + }); + } + if (options.image) { + this.meta.push({ + name: 'twitter:image', + content: String(options.image.url), + }); + if (options.image.alt) { + this.meta.push({ + name: 'twitter:image:alt', + content: options.image.alt, + }); + } + } + if (options.card) { + this.meta.push({ name: 'twitter:card', content: options.card.name }); + if ('properties' in options.card) { + for (const property of options.card.properties) { + this.meta.push({ + name: property.name, + content: String(property.content), + }); + } + } + } + return this; + } + + /** + * Adds favicon and other icon link elements + * + * Favicon and icon link elements help users identify your site visually in browser tabs, + * bookmarks, history, and on mobile home screens. Support multiple icon formats and types + * for optimal display across different devices and platforms (desktop, iOS, Android, etc.). + * + * Relative URLs are resolved using the metadataBase if provided. + * + * @example + * const head = new HeadBuilder('https://devsantara.com') + * .addIcons({ + * shortcut: [ + * { url: '/favicon.ico' } + * ] + * }) + * .build(); + * + * // Result: + * + */ + addIcons(options: IconOptions) { + if (options.icon) { + for (const { url, ...iconOptions } of options.icon) { + this.links.push({ + rel: 'icon', + href: this.resolveUrl(url), + ...iconOptions, + }); + } + } + if (options.shortcut) { + for (const { url, ...shortcutOptions } of options.shortcut) { + this.links.push({ + rel: 'shortcut icon', + href: this.resolveUrl(url), + ...shortcutOptions, + }); + } + } + if (options.apple) { + for (const { url, ...appleOptions } of options.apple) { + this.links.push({ + rel: 'apple-touch-icon', + href: this.resolveUrl(url), + ...appleOptions, + }); + } + } + if (options.other) { + for (const { rel, url, ...otherOptions } of options.other) { + this.links.push({ + rel: rel || 'icon', + href: this.resolveUrl(url), + ...otherOptions, + }); + } + } + return this; + } + + /** + * Adds robot directives meta tag controlling search engine crawler behavior + * + * The robots meta tag provides directives to search engine crawlers and other automated + * bots about how they should treat and index your page. It controls indexing, following + * links, snippet generation, image indexing, and other crawler behaviors. + * + * @example + * const head = new HeadBuilder() + * .addRobot({ + * index: true, + * follow: true + * }) + * .build(); + * + * // Result: + * + */ + addRobot(options: RobotOptions) { + const contentParts: string[] = []; + + if (options.index) { + contentParts.push(options.index ? 'index' : 'noindex'); + } + if (options.follow) { + contentParts.push(options.follow ? 'follow' : 'nofollow'); + } + if (options.noarchive) { + contentParts.push('noarchive'); + } + if (options.nosnippet) { + contentParts.push('nosnippet'); + } + if (options.noimageindex) { + contentParts.push('noimageindex'); + } + if (options.nocache) { + contentParts.push('nocache'); + } + if (options.notranslate) { + contentParts.push('notranslate'); + } + if (options.indexifembedded) { + contentParts.push('indexifembedded'); + } + if (options.nositelinkssearchbox) { + contentParts.push('nositelinkssearchbox'); + } + if (options.unavailable_after) { + contentParts.push(`unavailable_after:${options.unavailable_after}`); + } + if (options['max-video-preview']) { + contentParts.push(`max-video-preview:${options['max-video-preview']}`); + } + if (options['max-image-preview']) { + contentParts.push(`max-image-preview:${options['max-image-preview']}`); + } + if (options['max-snippet']) { + contentParts.push(`max-snippet:${options['max-snippet']}`); + } + + if (contentParts.length > 0) { + const content = contentParts.join(', '); + this.meta.push({ name: 'robots', content }); + } + + return this; + } + + /** + * Adds external stylesheet link elements + * + * External stylesheets define the visual styling for your page. This method adds link elements + * with rel="stylesheet" pointing to CSS files. You can specify multiple stylesheets and include + * additional attributes like media queries, integrity hashes for security, and preload hints. + * + * + * @example + * const head = new HeadBuilder() + * .addStylesheets([ + * { href: '/styles.css' } + * ]) + * .build(); + * + * // Result: + * + */ + addStylesheets(stylesheets: Omit[]) { + for (const { href, ...stylesheet } of stylesheets) { + this.links.push({ rel: 'stylesheet', href, ...stylesheet }); + } + return this; + } + + /** + * Adds custom link elements + * + * This method allows adding any link element with custom rel values and attributes. + * Use this for adding links like preconnect, prefetch, dns-prefetch, preload, or custom + * relationships that aren't covered by other specific methods. + * + * @example + * const HEAD = new HeadBuilder() + * .addOtherLinks([ + * { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + * { rel: 'preconnect', href: 'https://cdn.devsantara.com' } + * ]) + * .build(); + * + * // Result: + * + * + */ + addLinks(links: Link[]) { + for (const link of links) { + this.links.push(link); + } + return this; + } + + /** + * Adds custom meta elements + * + * This method allows adding any meta element that isn't covered by the specific + * helper methods. Use this for custom properties, tracking codes, application-specific + * meta tags, or platform-specific directives. + * + * @example + * const head = new HeadBuilder() + * .addMeta([ + * { name: 'apple-mobile-web-app-capable', content: 'yes' }, + * { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' } + * ]) + * .build(); + * + * // Result: + * + * + */ + addMeta(meta: Meta[]) { + for (const m of meta) { + this.meta.push(m); + } + return this; + } + + /** + * Adds custom script elements + * + * This method allows adding script elements to the head section, such as analytics, + * tracking scripts, third-party integrations, or utility scripts that need to execute + * before page content loads. You can specify attributes like async, defer, type, and more. + * + * @example + * // Analytics and tracking scripts + * const head = new HeadBuilder() + * .addScripts([ + * { src: 'https://www.googletagmanager.com/gtag/js?id=GA-123456', async: true }, + * { src: 'https://cdn.devsantara.com/analytics.js', defer: true } + * ]) + * .build(); + * + * // Result: + * + * + */ + addScripts(scripts: Script[]) { + for (const script of scripts) { + this.scripts.push(script); + } + return this; + } + + /** + * Adds custom style elements + * + * This method allows adding inline style elements directly to the head section. + * Use this for critical CSS, utility styles, or dynamic styles that need to be + * applied before stylesheets load. Inline styles have higher priority than external + * stylesheets for loading performance. + * + * @example + * const head = new HeadBuilder() + * .addStyles([ + * { + * children: ` + * .header { background: #333; color: white; padding: 20px; } + * .hero { min-height: 100vh; display: flex; align-items: center; } + * ` + * } + * ]) + * .build(); + * + * // HTML Result: + * + */ + addStyles(styles: Style[]) { + for (const style of styles) { + this.styles.push(style); + } + return this; + } + + /** + * Builds and returns the complete head result object + * + * This method finalizes the construction of all head elements collected through + * the various add* methods and returns them as a structured HeadResult object. + * The result contains separate arrays for meta tags, link elements, scripts, and styles, + * ready to be rendered in the document head. + */ + build(): Awaitable { + return { + links: this.links, + scripts: this.scripts, + meta: this.meta, + styles: this.styles, + }; + } +} diff --git a/src/lib/seo/types/alternate.ts b/src/lib/seo/types/alternate.ts new file mode 100644 index 0000000..889e234 --- /dev/null +++ b/src/lib/seo/types/alternate.ts @@ -0,0 +1,8 @@ +export type AlternateLocaleKey = + | ('x-default' | TLocale) + | (string & {}); + +export type AlternateLocaleOptions = Record< + AlternateLocaleKey, + string | URL +>; diff --git a/src/lib/seo/types/metadata.ts b/src/lib/seo/types/metadata.ts new file mode 100644 index 0000000..7cb2015 --- /dev/null +++ b/src/lib/seo/types/metadata.ts @@ -0,0 +1,51 @@ +export interface ViewportOptions { + width?: string | number; + height?: string | number; + initialScale?: number; + minimumScale?: number; + maximumScale?: number; + userScalable?: boolean; + viewportFit?: 'auto' | 'cover' | 'contain'; + interactiveWidget?: 'resizes-visual' | 'resizes-content' | 'overlays-content'; +} + +export type ColorSchemeOptions = + | 'normal' + | 'light' + | 'dark' + | 'light dark' + | 'dark light' + | 'only light'; + +export interface RobotOptions { + index?: boolean; + follow?: boolean; + noarchive?: boolean; + nosnippet?: boolean; + noimageindex?: boolean; + nocache?: boolean; + notranslate?: boolean; + indexifembedded?: boolean; + nositelinkssearchbox?: boolean; + unavailable_after?: string; + 'max-video-preview'?: number | string; + 'max-image-preview'?: 'none' | 'standard' | 'large'; + 'max-snippet'?: number; +} + +export interface IconOptions { + icon?: Omit[]; + shortcut?: Omit[]; + apple?: Omit[]; + other?: Icon[]; +} + +interface Icon { + url: string | URL; + type?: string; + sizes?: string; + color?: string; + rel?: string; + media?: string; + fetchPriority?: 'high' | 'low' | 'auto'; +} diff --git a/src/lib/seo/types/opengraph.ts b/src/lib/seo/types/opengraph.ts new file mode 100644 index 0000000..1548b74 --- /dev/null +++ b/src/lib/seo/types/opengraph.ts @@ -0,0 +1,113 @@ +export interface OpenGraphOptions { + title?: string; + description?: string; + url?: string; + locale?: string; + image?: { + url: string | URL; + alt?: string; + type?: string; + width?: number; + height?: number; + }; + type?: OpenGraphTypeProperty; +} + +type OpenGraphTypeProperty = + | { name: 'article'; properties: ArticleMetadataProperty[] } + | { name: 'book'; properties: BookMetadataProperty[] } + | { name: 'music.song'; properties: MusicSongMetadataProperty[] } + | { name: 'music.album'; properties: MusicAlbumMetadataProperty[] } + | { name: 'music.playlist'; properties: MusicPlaylistMetadataProperty[] } + | { + name: 'music.radio_station'; + properties: MusicRadioStationMetadataProperty[]; + } + | { name: 'profile'; properties: ProfileMetadataProperty[] } + | { name: 'video.tv_show'; properties: VideoTvShowMetadataProperty[] } + | { name: 'video.other'; properties: VideoOtherMetadataProperty[] } + | { name: 'video.movie'; properties: VideoMovieProperty[] } + | { name: 'video.episode'; properties: VideoEpisodeMetadataProperty[] } + | { name: 'website' }; + +type ArticleMetadataProperty = + | { name: 'article:published_time'; content: string } + | { name: 'article:modified_time'; content: string } + | { name: 'article:expiration_time'; content: string } + | { name: 'article:author'; content: string } + | { name: 'article:section'; content: string } + | { name: 'article:tag'; content: string }; + +type BookMetadataProperty = + | { name: 'book:isbn'; content: string } + | { name: 'book:release_date'; content: string } + | { name: 'book:author'; content: string } + | { name: 'book:tag'; content: string }; + +type ProfileMetadataProperty = + | { name: 'profile:first_name'; content: string } + | { name: 'profile:last_name'; content: string } + | { name: 'profile:username'; content: string } + | { name: 'profile:gender'; content: string }; + +type MusicSongMetadataProperty = + | { name: 'music:duration'; content: string } + | { name: 'music:album'; content: string } + | { name: 'music:album:disc'; content: string } + | { name: 'music:album:track'; content: string } + | { name: 'music:musician'; content: string }; + +type MusicAlbumMetadataProperty = + | { name: 'music:song'; content: string } + | { name: 'music:song:disc'; content: string } + | { name: 'music:song:track'; content: string } + | { name: 'music:musician'; content: string } + | { name: 'music:release_date'; content: string }; + +type MusicPlaylistMetadataProperty = + | { name: 'music:song'; content: string } + | { name: 'music:song:disc'; content: string } + | { name: 'music:song:track'; content: string } + | { name: 'music:creator'; content: string }; + +interface MusicRadioStationMetadataProperty { + name: 'music:creator'; + content: string; +} + +type VideoMovieProperty = + | { name: 'video:actor'; content: string } + | { name: 'video:actor:role'; content: string } + | { name: 'video:director'; content: string } + | { name: 'video:writer'; content: string } + | { name: 'video:duration'; content: string } + | { name: 'video:release_date'; content: string } + | { name: 'video:tag'; content: string }; + +type VideoEpisodeMetadataProperty = + | { name: 'video:actor'; content: string } + | { name: 'video:actor:role'; content: string } + | { name: 'video:director'; content: string } + | { name: 'video:writer'; content: string } + | { name: 'video:duration'; content: string } + | { name: 'video:release_date'; content: string } + | { name: 'video:tag'; content: string } + | { name: 'video:series'; content: string }; + +type VideoTvShowMetadataProperty = + | { name: 'video:actor'; content: string } + | { name: 'video:actor:role'; content: string } + | { name: 'video:director'; content: string } + | { name: 'video:writer'; content: string } + | { name: 'video:duration'; content: string } + | { name: 'video:release_date'; content: string } + | { name: 'video:tag'; content: string }; + +type VideoOtherMetadataProperty = + | { name: 'video:actor'; content: string } + | { name: 'video:actor:role'; content: string } + | { name: 'video:director'; content: string } + | { name: 'video:writer'; content: string } + | { name: 'video:duration'; content: string } + | { name: 'video:release_date'; content: string } + | { name: 'video:tag'; content: string }; diff --git a/src/lib/seo/types/twitter.ts b/src/lib/seo/types/twitter.ts new file mode 100644 index 0000000..fff565b --- /dev/null +++ b/src/lib/seo/types/twitter.ts @@ -0,0 +1,42 @@ +export interface TwitterOptions { + title?: string; + description?: string; + site?: string; + siteId?: string; + creator?: string; + creatorId?: string; + image?: { + url: string | URL; + alt?: string; + }; + card?: TwitterCardProperty; +} + +type TwitterCardProperty = + | { name: 'summary' } + | { name: 'summary_large_image' } + | { + name: 'player'; + properties: TwitterPlayerProperty[]; + } + | { + name: 'app'; + properties: TwitterAppProperty[]; + }; + +type TwitterPlayerProperty = + | { name: 'twitter:player'; content: string | URL } + | { name: 'twitter:player:width'; content: number } + | { name: 'twitter:player:height'; content: number } + | { name: 'twitter:player:stream'; content: string | URL }; + +type TwitterAppProperty = + | { name: 'twitter:app:name:iphone'; content: string } + | { name: 'twitter:app:id:iphone'; content: string | number } + | { name: 'twitter:app:url:iphone'; content: string | URL } + | { name: 'twitter:app:name:ipad'; content: string } + | { name: 'twitter:app:id:ipad'; content: string | number } + | { name: 'twitter:app:url:ipad'; content: string | URL } + | { name: 'twitter:app:name:googleplay'; content: string } + | { name: 'twitter:app:id:googleplay'; content: string } + | { name: 'twitter:app:url:googleplay'; content: string | URL }; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 9b37b9c..3891bcd 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -12,10 +12,11 @@ import { m } from '~/lib/i18n/messages'; import { baseLocale, getLocale, - locales, localizeHref, + type Locale, } from '~/lib/i18n/runtime'; import { PostHogProvider } from '~/lib/posthog/provider'; +import { HeadBuilder } from '~/lib/seo/head'; import { Toaster } from '~/ui/components/core/sonner'; import appStylesheet from '~/ui/styles/app.css?url'; import fontStylesheet from '~/ui/styles/fonts.css?url'; @@ -29,32 +30,20 @@ export const Route = createRootRoute({ /** @example http://localhost:3000/path/without/locale */ const currentHref = loaderData?.currentHref ?? ''; - return { - meta: [ - { charSet: 'utf-8' }, - { name: 'viewport', content: 'width=device-width, initial-scale=1' }, - { title: m.app_name() }, - { name: 'description', content: m.app_description() }, - ], - links: [ - { rel: 'stylesheet', href: fontStylesheet }, - { rel: 'stylesheet', href: appStylesheet }, - { - rel: 'canonical', - href: localizeHref(currentHref), - }, - { - rel: 'alternate', - hrefLang: 'x-default', - href: localizeHref(currentHref, { locale: baseLocale }), - }, - ...locales.map((locale) => ({ - rel: 'alternate', - hrefLang: locale, - href: localizeHref(currentHref, { locale }), - })), - ], - }; + return new HeadBuilder() + .addCharSet('utf-8') + .addViewport({ width: 'device-width', initialScale: 1 }) + .addTitle(m.app_name()) + .addDescription(m.app_description()) + .addStylesheets([{ href: fontStylesheet }, { href: appStylesheet }]) + .addCanonical(localizeHref(currentHref)) + .addAlternateLocales({ + 'x-default': localizeHref(currentHref, { locale: baseLocale }), + en: localizeHref(currentHref, { locale: 'en' }), + id: localizeHref(currentHref, { locale: 'id' }), + 'zh-CN': localizeHref(currentHref, { locale: 'zh-CN' }), + }) + .build(); }, shellComponent: RootDocument, }); From 5c0e864792972ba09239d8408365a48d7bf74f6d Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 16 Jan 2026 19:01:52 +0700 Subject: [PATCH 09/11] feat(seo): add metadata, opengraph, web manifest and icons --- public/apple-touch-icon.png | Bin 0 -> 10743 bytes public/favicon-96x96.png | Bin 0 -> 5314 bytes public/favicon.ico | Bin 0 -> 15086 bytes public/favicon.svg | 17 +++++++++++++ public/site.webmanifest | 23 +++++++++++++++++ public/web-app-manifest-192x192.png | Bin 0 -> 11473 bytes public/web-app-manifest-512x512.png | Bin 0 -> 30483 bytes src/routes/__root.tsx | 37 +++++++++++++++++++++++++--- 8 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-96x96.png create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 public/site.webmanifest create mode 100644 public/web-app-manifest-192x192.png create mode 100644 public/web-app-manifest-512x512.png diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..82f342818e26ae4c4ff928c1080f1dac043da8e1 GIT binary patch literal 10743 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 NWd(KlU$0HR{y(e8I~f1~ literal 0 HcmV?d00001 diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..e70ee8662e53aa6db6d9c9d4789662a0ee5f678c GIT binary patch literal 5314 zcmV;z6g}&SP)}`Y(m`oT;qRjZ< zgQS}o#*$3Wq8n)~rt~Bd8QlqEiIRw2p=_n7n&)@b`Pcowb?mT^?%P*grnwq*RtT|`S zoH;*;a_=a&iSlcG@gaaOeEJ(chOaBb=ljF=zY~LSaB;v;*Dwi~ABs;1_*H1UD~!7^ z;7Ro1>e?m zO+^xZ7%}=~#Oe|;>*9RhK*oZz%er;z{w0L{ZNMG@-(KYiSe&nnnEkWZm043NC1A>M zipA!U5OQ?DqJVF&ZV?cxVJPiS7peZ7BqkU{0fJb0Y7|ZQiscQr8fMydDupF)}hV^zGPhkmfsb_kb7`jzs3X@ zI-&IEF{dpLn8T1L+hkPuSQ}C(tLi=?`zeND`t;LJXTTRBZOCX@scG=Qr)nSkv{+Y^Ta8@5hZ-?uMUTnK^wjwPqqsme*Llx zr8Yb|P=`aypbd`>)Zx&TfDiEKfRB6?V7|lkf>8sv(Z)GZUluZcF1AsharGu(VJJU0 zIF1OIzCQZsqqKJI+Ud56WKaYc`rdo*Rd+nv77rsB81YGb=`o^+DV{p!Kr K^|Ni^wlTSWLF|=BFzv%?N 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 literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..03dc6a2 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..82bffde --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Devsantara Kit", + "short_name": "Kit", + "description": "The blueprint for your next big idea", + "start_url": "/", + "theme_color": "#000000", + "background_color": "#ffffff", + "display": "standalone", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..b5e66c6831511dadcc8a2fdc515749097e21f9f9 GIT binary patch literal 11473 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

6NX%5xOVaE>| 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 W;Z1L;jR0p4L8gY524#A { - return { currentHref: location.url.href }; + return { currentHref: location.url.origin + location.url.pathname }; }, head: ({ loaderData }) => { /** @example http://localhost:3000/path/without/locale */ const currentHref = loaderData?.currentHref ?? ''; - return new HeadBuilder() + return new HeadBuilder(new URL(currentHref)) .addCharSet('utf-8') .addViewport({ width: 'device-width', initialScale: 1 }) + .addStylesheets([{ href: fontStylesheet }, { href: appStylesheet }]) .addTitle(m.app_name()) .addDescription(m.app_description()) - .addStylesheets([{ href: fontStylesheet }, { href: appStylesheet }]) + .addColorScheme('light dark') + .addManifest('/site.webmanifest') .addCanonical(localizeHref(currentHref)) .addAlternateLocales({ 'x-default': localizeHref(currentHref, { locale: baseLocale }), @@ -43,6 +45,35 @@ export const Route = createRootRoute({ id: localizeHref(currentHref, { locale: 'id' }), 'zh-CN': localizeHref(currentHref, { locale: 'zh-CN' }), }) + .addIcons({ + shortcut: [{ url: '/favicon.ico', sizes: '48x48' }], + icon: [ + { type: 'image/svg+xml', url: '/favicon.svg', sizes: 'any' }, + { type: 'image/png', url: '/favicon-96x96.png', sizes: '96x96' }, + ], + apple: [{ url: '/apple-touch-icon.png', sizes: '180x180' }], + }) + .addOpenGraph({ + title: m.app_name(), + description: m.app_description(), + locale: getLocale(), + type: { name: 'website' }, + url: localizeHref(currentHref), + image: { + url: 'https://assets.devsantara.com/kit/thumbnail.jpg', + type: 'image/jpeg', + width: 1280, + height: 640, + }, + }) + .addTwitter({ + card: { name: 'summary' }, + title: m.app_name(), + description: m.app_description(), + site: '@devsantara_hq', + image: { url: 'https://assets.devsantara.com/kit/thumbnail.jpg' }, + }) + .addMeta([{ name: 'apple-mobile-web-app-title', content: m.app_name() }]) .build(); }, shellComponent: RootDocument, From 4d5c59199e0aacc22f7293aa3fd58eebbe122051 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 16 Jan 2026 19:06:49 +0700 Subject: [PATCH 10/11] style: add eof and fix bad indentation --- public/site.webmanifest | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/site.webmanifest b/public/site.webmanifest index 82bffde..59beee9 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -3,7 +3,7 @@ "short_name": "Kit", "description": "The blueprint for your next big idea", "start_url": "/", - "theme_color": "#000000", + "theme_color": "#000000", "background_color": "#ffffff", "display": "standalone", "icons": [ @@ -20,4 +20,4 @@ "purpose": "maskable" } ] -} \ No newline at end of file +} From cbb2552f61abd78683ab079840d4890ac8bb0eff Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 16 Jan 2026 23:21:21 +0700 Subject: [PATCH 11/11] chore(release): v0.3.0 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a3b18..1c67540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [0.3.0](https://github.com/devsantara/kit/compare/0.2.0...0.3.0) (2026-01-16) + +### Features + +- **i18n:** setup paraglide ([3435a64](https://github.com/devsantara/kit/commit/3435a647c1600d1264ca4195a9571d69952427e6)) +- **observability:** setup posthog ([7f4c399](https://github.com/devsantara/kit/commit/7f4c3991d99acb437e80fa0b35d6c404623f89b9)) +- **seo:** add metadata, opengraph, web manifest and icons ([5c0e864](https://github.com/devsantara/kit/commit/5c0e864792972ba09239d8408365a48d7bf74f6d)) +- **seo:** create head metadata builder ([b3c41ec](https://github.com/devsantara/kit/commit/b3c41ec2378fb83a4d491a548c304f76d73973c5)) + +### Bug Fixes + +- **storybook:** missing iframe.html on production build ([13ff3ab](https://github.com/devsantara/kit/commit/13ff3ab1d6debda0a39ceeb23125d4038f676487)) + +### Build System + +- manually split posthog-js and @posthog/react from main bundle ([a0ecfb0](https://github.com/devsantara/kit/commit/a0ecfb040041cadfa1d519f86ad828855d6740c0)) + +### Continuous Integration + +- **workflow:** split deployment and cleanup workflows ([1e2753a](https://github.com/devsantara/kit/commit/1e2753a437378dec73416946e2e23d8fc3b6467a)) + +### Chores + +- **linter:** turn off capitalized comments rule ([ea65319](https://github.com/devsantara/kit/commit/ea6531995169a001a4b60d62b3c0ace0cea91788)) +- **oxfmt:** update and add import and tailwindcss options ([c56f260](https://github.com/devsantara/kit/commit/c56f2608bb0cf6a0246900fc5126eef9da8ffff9)) + ## [0.2.0](https://github.com/devsantara/kit/compare/0.1.0...0.2.0) (2026-01-06) ### Features diff --git a/package.json b/package.json index 4654ff7..cbea9de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kit", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "The blueprint for your next big idea", "keywords": [],