feat: improve SEO feature

This commit is contained in:
2026-08-31 23:12:38 +07:00
parent 025a1d66f7
commit c525e8e3a2
28 changed files with 715 additions and 29 deletions
+93 -27
View File
@@ -35,7 +35,9 @@
"husky": "^9.1.7",
"lint-staged": "^17.4.1",
"oxlint": "^1.80.0",
"png-to-ico": "^3.0.2",
"prettier": "^3.9.6",
"sharp": "^0.35.4",
"tailwindcss": "4",
"typescript": "^5.5.2",
"vite": "^8.2.2",
@@ -201,57 +203,57 @@
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -1047,6 +1049,10 @@
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"png-to-ico": ["png-to-ico@3.0.2", "", { "dependencies": { "@types/node": "^25.5.0", "minimist": "^1.2.8", "pngjs": "^7.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-36vvp3/YF7LPYkUEOj08/WgB1wI63qW391YfOhOWckgOtGkarr9+EhPBimcCmRP7fJaG4EaXQhTTaQ8qqdj8aA=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
@@ -1127,7 +1133,7 @@
"shadcn": ["shadcn@4.19.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "socks": "^2.8.8", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-EQF6R+CUXTsEP2BpyhxrUEAFesrtFD1POvVOf5jM+wkgtA4kG1EW1+1Wlmi9LqiprSL681JJXBcS0u8WkVVVyQ=="],
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1365,6 +1371,8 @@
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"miniflare/sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
"negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -1373,6 +1381,8 @@
"parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
@@ -1633,10 +1643,62 @@
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
"miniflare/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
"miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
"miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
"miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
"miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
"miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
"miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
"miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
"miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
"miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
"miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
"miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
"miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
"miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
"miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
"miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
"miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
"miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
"miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
"miniflare/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
"miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
"miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
"parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"png-to-ico/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"shadcn/@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"shadcn/@babel/core/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
@@ -1687,6 +1749,10 @@
"@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
"miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
"shadcn/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"shadcn/@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+16
View File
@@ -4,7 +4,23 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Upwatch — fast, dependable uptime monitoring from Cloudflare's edge." />
<meta name="robots" content="noindex,nofollow" />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Upwatch" />
<meta property="og:title" content="Upwatch — Uptime monitoring" />
<meta property="og:description" content="Fast, dependable uptime monitoring from Cloudflare's edge." />
<meta property="og:image" content="/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Upwatch — Uptime monitoring" />
<meta name="twitter:description" content="Fast, dependable uptime monitoring from Cloudflare's edge." />
<meta name="twitter:image" content="/og.png" />
<title>Upwatch — Uptime monitoring</title>
</head>
<body>
+3
View File
@@ -12,6 +12,7 @@
"db:local": "wrangler d1 migrations apply uptime --local",
"db:remote": "wrangler d1 migrations apply uptime --remote",
"admin:create": "bun scripts/create-admin.ts",
"gen:icons": "bun scripts/generate-icons.ts",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"format": "prettier --write .",
@@ -34,7 +35,9 @@
"husky": "^9.1.7",
"lint-staged": "^17.4.1",
"oxlint": "^1.80.0",
"png-to-ico": "^3.0.2",
"prettier": "^3.9.6",
"sharp": "^0.35.4",
"tailwindcss": "4",
"typescript": "^5.5.2",
"vite": "^8.2.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="brand" x1="72" y1="48" x2="440" y2="464" gradientUnits="userSpaceOnUse">
<stop stop-color="#3ecf8e"/>
<stop offset="1" stop-color="#24b47e"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#brand)"/>
<path d="M285 70 132 283h118l-23 159 153-229H262L285 70Z" fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="18"/>
</svg>

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"name": "Upwatch",
"short_name": "upwatch",
"description": "Fast, dependable uptime monitoring from Cloudflare's edge.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="brand" x1="72" y1="48" x2="440" y2="464" gradientUnits="userSpaceOnUse">
<stop stop-color="#3ecf8e"/>
<stop offset="1" stop-color="#24b47e"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#brand)"/>
<path d="M285 70 132 283h118l-23 159 153-229H262L285 70Z" fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="18"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="background" x1="70" y1="10" x2="1120" y2="630" gradientUnits="userSpaceOnUse">
<stop stop-color="#f7fffb"/>
<stop offset="1" stop-color="#eefaf5"/>
</linearGradient>
<linearGradient id="brand" x1="76" y1="72" x2="240" y2="250" gradientUnits="userSpaceOnUse">
<stop stop-color="#3ecf8e"/>
<stop offset="1" stop-color="#24b47e"/>
</linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="18" stdDeviation="24" flood-color="#0f5132" flood-opacity=".14"/>
</filter>
</defs>
<rect width="1200" height="630" fill="url(#background)"/>
<circle cx="1085" cy="-5" r="280" fill="#3ecf8e" opacity=".08"/>
<circle cx="80" cy="650" r="230" fill="#24b47e" opacity=".07"/>
<g transform="translate(80 74)" filter="url(#shadow)">
<rect width="150" height="150" rx="36" fill="url(#brand)"/>
<path d="m84 21-47 65h36l-7 45 47-68H77l7-42Z" fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="6"/>
</g>
<text x="260" y="176" fill="#102a20" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="84" font-weight="750" letter-spacing="-4">upwatch</text>
<text x="80" y="330" fill="#173c2e" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="48" font-weight="650" letter-spacing="-1.4">Fast, dependable uptime monitoring</text>
<text x="80" y="390" fill="#557066" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="34">from Cloudflares edge.</text>
<g transform="translate(80 474)" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="24" font-weight="600">
<g><rect width="252" height="68" rx="34" fill="#fff" stroke="#dceae4"/><circle cx="34" cy="34" r="9" fill="#24b47e"/><text x="55" y="42" fill="#254a3b">API operational</text></g>
<g transform="translate(272)"><rect width="268" height="68" rx="34" fill="#fff" stroke="#dceae4"/><circle cx="34" cy="34" r="9" fill="#24b47e"/><text x="55" y="42" fill="#254a3b">Website operational</text></g>
<g transform="translate(560)"><rect width="246" height="68" rx="34" fill="#fff" stroke="#dceae4"/><circle cx="34" cy="34" r="9" fill="#24b47e"/><text x="55" y="42" fill="#254a3b">Edge operational</text></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+38
View File
@@ -0,0 +1,38 @@
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import pngToIco from 'png-to-ico';
import sharp from 'sharp';
const root = path.resolve(import.meta.dirname, '..');
const assets = path.join(import.meta.dirname, 'assets');
const output = path.join(root, 'public');
const iconSource = path.join(assets, 'icon.svg');
const ogSource = path.join(assets, 'og.svg');
await mkdir(output, { recursive: true });
await copyFile(iconSource, path.join(output, 'favicon.svg'));
async function renderIcon(name: string, size: number) {
await sharp(iconSource).resize(size, size).png().toFile(path.join(output, name));
}
async function renderMaskableIcon() {
const icon = await sharp(iconSource).resize(512, 512).png().toBuffer();
await sharp({ create: { width: 512, height: 512, channels: 4, background: '#24b47e' } })
.composite([{ input: icon }])
.png()
.toFile(path.join(output, 'icon-512-maskable.png'));
}
await Promise.all([
renderIcon('apple-touch-icon.png', 180),
renderIcon('icon-192.png', 192),
renderIcon('icon-512.png', 512),
renderMaskableIcon(),
sharp(ogSource).resize(1200, 630).png().toFile(path.join(output, 'og.png')),
]);
const faviconPngs = await Promise.all([16, 32, 48].map((size) => sharp(iconSource).resize(size, size).png().toBuffer()));
await writeFile(path.join(output, 'favicon.ico'), await pngToIco(faviconPngs));
console.log(`Generated Upwatch icons in ${output}`);
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
type SeoOptions = {
title: string;
description?: string;
noindex?: boolean;
canonicalPath?: string;
};
const managedAttribute = 'data-upwatch-seo';
const defaultDescription = "Fast, dependable uptime monitoring from Cloudflare's edge.";
function upsertMeta(selector: string, attributes: Record<string, string>, content: string) {
let element = document.head.querySelector<HTMLMetaElement>(selector);
if (!element) {
element = document.createElement('meta');
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
document.head.append(element);
}
element.setAttribute('content', content);
element.setAttribute(managedAttribute, 'true');
}
function removeManaged(selector: string) {
document.head.querySelector(`${selector}[${managedAttribute}="true"]`)?.remove();
}
function removeNoindex() {
const robots = document.head.querySelector<HTMLMetaElement>('meta[name="robots"]');
if (robots?.content.includes('noindex')) robots.remove();
}
function absoluteUrl(pathname: string) {
const configuredBase = import.meta.env.VITE_PUBLIC_BASE_URL?.replace(/\/$/, '');
return new URL(pathname, configuredBase || window.location.origin).toString();
}
export function useSeo({ title, description, noindex = false, canonicalPath }: SeoOptions) {
useEffect(() => {
const resolvedDescription = description ?? defaultDescription;
document.title = title;
upsertMeta('meta[property="og:title"]', { property: 'og:title' }, title);
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title' }, title);
upsertMeta('meta[name="description"]', { name: 'description' }, resolvedDescription);
upsertMeta('meta[property="og:description"]', { property: 'og:description' }, resolvedDescription);
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description' }, resolvedDescription);
if (noindex) upsertMeta('meta[name="robots"]', { name: 'robots' }, 'noindex,nofollow');
else removeNoindex();
if (canonicalPath) {
let canonical = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!canonical) {
canonical = document.createElement('link');
canonical.rel = 'canonical';
document.head.append(canonical);
}
canonical.href = absoluteUrl(canonicalPath);
canonical.setAttribute(managedAttribute, 'true');
} else removeManaged('link[rel="canonical"]');
}, [canonicalPath, description, noindex, title]);
}
+2
View File
@@ -6,10 +6,12 @@ import { DashboardOverview } from '../components/dashboard/DashboardOverview';
import { IncidentsPanel } from '../components/dashboard/IncidentsPanel';
import { MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
import { MonitorListPanel } from '../components/dashboard/MonitorListPanel';
import { useSeo } from '../lib/seo';
export function DashboardPage() {
const [editing, setEditing] = useState<Monitor | null>(null);
const [formOpen, setFormOpen] = useState(false);
useSeo({ title: 'Dashboard — upwatch', noindex: true });
function openCreateForm() {
setEditing(null);
+16
View File
@@ -3,12 +3,28 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { IncidentTimeline } from '../components/IncidentTimeline';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { usePublicIncidentQuery } from '../queries/status';
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' });
export function IncidentDetailPage({ id }: { id: number }) {
const query = usePublicIncidentQuery(id);
const incident = query.data?.incident;
const latestUpdate = incident?.updates?.at(-1)?.body;
const fallbackDescription = incident
? `${incident.services.map((service) => service.name).join(', ') || 'General service incident'} · Started ${dateTime.format(new Date(incident.startedAt))}`
: 'Public incident report and service restoration updates.';
useSeo({
title: incident
? `${incident.title}${incident.status} — upwatch status`
: query.isError
? 'Incident not found — upwatch'
: 'Incident report — upwatch',
description: latestUpdate ?? fallbackDescription,
noindex: query.isError,
canonicalPath: `/incidents/${id}`,
});
return (
<div className="status-page-shell">
<header className="dashboard-header status-header">
+2
View File
@@ -4,12 +4,14 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useLoginMutation, useSessionQuery } from '../queries/auth';
export function LoginPage() {
const [password, setPassword] = useState('');
const sessionQuery = useSessionQuery();
const loginMutation = useLoginMutation();
useSeo({ title: 'Sign in — upwatch', noindex: true });
useEffect(() => {
if (sessionQuery.data?.authenticated) navigate('/dashboard', { replace: true });
+2
View File
@@ -13,6 +13,7 @@ import { INTERVAL_OPTIONS, MonitorFormDialog } from '../components/dashboard/Mon
import { formatDate, formatDuration } from '../lib/format';
import { monitorState } from '../lib/monitor-status';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import {
useDeleteMonitorMutation,
useMonitorChecksQuery,
@@ -40,6 +41,7 @@ export function MonitorDetailPage({ id }: { id: number }) {
const checks = checksQuery.data?.checks ?? [];
const incidents = incidentsQuery.data?.incidents ?? [];
const openIncident = incidents.find((incident) => incident.resolvedAt === null);
useSeo({ title: monitor ? `${monitor.name} — upwatch` : 'Monitor — upwatch', noindex: true });
if (monitorQuery.isPending)
return (
+2
View File
@@ -11,6 +11,7 @@ import { AiActivityPanel } from '../components/settings/AiActivityPanel';
import { MaintenanceWindowsPanel } from '../components/settings/MaintenanceWindowsPanel';
import { NotificationChannelsPanel } from '../components/settings/NotificationChannelsPanel';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useAiSettingsQuery, useTestAiSettingsMutation, useUpdateAiSettingsMutation } from '../queries/settings';
function AiSettingsForm({ settings }: { settings: AiSettings }) {
@@ -149,6 +150,7 @@ function AiSettingsForm({ settings }: { settings: AiSettings }) {
export function SettingsPage() {
const aiSettingsQuery = useAiSettingsQuery();
useSeo({ title: 'Settings — upwatch', noindex: true });
return (
<div className="dashboard-shell">
<AppHeader context="Settings" />
+11
View File
@@ -8,6 +8,7 @@ import { SiteIcon } from '../components/SiteIcon';
import { StatusHistoryBar } from '../components/StatusHistoryBar';
import { formatDate, formatDuration } from '../lib/format';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useSessionQuery } from '../queries/auth';
import { useIncidentHistoryQuery, useStatusQuery } from '../queries/status';
@@ -71,6 +72,16 @@ export function StatusPage() {
const activeIncidents = status?.activeIncidents ?? [];
const maintenanceServices = status?.services.filter((service) => service.maintenance) ?? [];
const pastIncidents = historyQuery.data?.incidents ?? [];
const operationalServices = status?.services.filter((service) => service.status === 'up').length ?? 0;
const affectedServices = (status?.services.length ?? 0) - operationalServices;
const description = status
? `${status.services.length} ${status.services.length === 1 ? 'service' : 'services'} · ${operationalServices} operational, ${affectedServices} affected · ${activeIncidents.length} active ${activeIncidents.length === 1 ? 'incident' : 'incidents'}`
: 'Live operational health and 90-day availability for every public service.';
useSeo({
title: status ? `${OVERALL_COPY[status.overall].title} — upwatch status` : 'Service status — upwatch',
description,
canonicalPath: '/',
});
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 5_000);
+6
View File
@@ -7,6 +7,7 @@ import channelRoutes from './routes/channels';
import incidentRoutes from './routes/incidents';
import maintenanceRoutes from './routes/maintenance';
import monitorRoutes from './routes/monitors';
import { createPageRoutes } from './routes/pages';
import settingsRoutes from './routes/settings';
import statusRoutes from './routes/status';
import { cleanupExpiredAuthRecords, cleanupStaleData } from './scheduled/cleanup';
@@ -16,6 +17,11 @@ const app = new Hono<{ Bindings: Env }>();
app.use('/api/*', csrf());
app.route(
'/',
createPageRoutes((request, env, executionCtx) => app.fetch(request, env, executionCtx)),
);
app.get('/api/health', async (context) => {
const db = await context.env.DB.prepare('SELECT 1 AS ok').first<{
ok: number;
+132
View File
@@ -0,0 +1,132 @@
import { Hono, type Context } from 'hono';
import type { PublicIncident, PublicStatus } from '../../client/api/status';
import { resolveStatusCacheSeconds } from '../lib/runtime-config';
import { rewriteHead } from '../seo/html';
import { absoluteBase, escapeHtml, incidentHead, statusHead } from '../seo/meta';
type AppFetch = (request: Request, env: Env, executionCtx: ExecutionContext) => Response | Promise<Response>;
type EdgeCache = {
match(request: RequestInfo | URL): Promise<Response | undefined>;
put(request: RequestInfo | URL, response: Response): Promise<void>;
};
function edgeCache(): EdgeCache | null {
try {
return (caches as CacheStorage & { readonly default: EdgeCache }).default;
} catch {
return null;
}
}
function cacheKey(context: Context<{ Bindings: Env }>): Request {
const url = new URL(context.req.url);
return new Request(`${url.origin}${url.pathname}`);
}
async function cachedPage(context: Context<{ Bindings: Env }>): Promise<Response | undefined> {
if (resolveStatusCacheSeconds(context.env) <= 0) return undefined;
try {
return await edgeCache()?.match(cacheKey(context));
} catch {
return undefined;
}
}
function cacheResponse(context: Context<{ Bindings: Env }>, response: Response, seconds: number): Response {
const headers = new Headers(response.headers);
headers.set('Cache-Control', `public, max-age=${seconds}`);
const cacheable = new Response(response.body, { status: response.status, statusText: response.statusText, headers });
const cache = edgeCache();
if (cache && seconds > 0) {
try {
context.executionCtx.waitUntil(cache.put(cacheKey(context), cacheable.clone()).catch(() => undefined));
} catch {
// Unit tests may not expose a request execution context.
}
}
return cacheable;
}
async function assetShell(context: Context<{ Bindings: Env }>): Promise<Response> {
const requestUrl = new URL('/index.html', context.req.url);
return context.env.ASSETS.fetch(new Request(requestUrl, { headers: context.req.raw.headers }));
}
async function internalJson<T>(context: Context<{ Bindings: Env }>, fetchApp: AppFetch, pathname: string): Promise<T | null> {
try {
const request = new Request(new URL(pathname, context.req.url), { headers: { Accept: 'application/json' } });
const response = await fetchApp(request, context.env, context.executionCtx);
return response.ok ? await response.json<T>() : null;
} catch {
return null;
}
}
export function createPageRoutes(fetchApp: AppFetch) {
const routes = new Hono<{ Bindings: Env }>();
routes.get('/', async (context) => {
const cached = await cachedPage(context);
if (cached) return cached;
const payload = await internalJson<PublicStatus>(context, fetchApp, '/api/status');
const transformed = rewriteHead(await assetShell(context), statusHead(payload, absoluteBase(context.env, context.req.raw)));
return cacheResponse(context, transformed, resolveStatusCacheSeconds(context.env));
});
routes.get('/incidents/:id', async (context) => {
const cached = await cachedPage(context);
if (cached) return cached;
const id = context.req.param('id');
const payload = await internalJson<{ incident: PublicIncident }>(context, fetchApp, `/api/status/incidents/${encodeURIComponent(id)}`);
const transformed = rewriteHead(
await assetShell(context),
incidentHead(payload?.incident ?? null, absoluteBase(context.env, context.req.raw), id),
);
return cacheResponse(context, transformed, resolveStatusCacheSeconds(context.env));
});
routes.get('/robots.txt', (context) => {
const base = absoluteBase(context.env, context.req.raw);
return context.text(
[
'User-agent: *',
'Allow: /',
'Allow: /incidents/',
'Disallow: /api/',
'Disallow: /dashboard',
'Disallow: /settings',
'Disallow: /monitors/',
'Disallow: /login',
`Sitemap: ${base}/sitemap.xml`,
'',
].join('\n'),
200,
{ 'Cache-Control': 'public, max-age=3600' },
);
});
routes.get('/sitemap.xml', async (context) => {
const base = absoluteBase(context.env, context.req.raw);
const payload = await internalJson<{ incidents: PublicIncident[] }>(context, fetchApp, '/api/status/incidents');
const urls = [
`<url><loc>${escapeHtml(`${base}/`)}</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>`,
...(payload?.incidents ?? []).map((incident) => {
const lastModified = incident.resolvedAt ?? incident.startedAt;
return `<url><loc>${escapeHtml(`${base}/incidents/${incident.id}`)}</loc><lastmod>${escapeHtml(new Date(lastModified).toISOString())}</lastmod><priority>0.6</priority></url>`;
}),
];
return context.body(
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>`,
200,
{
'Content-Type': 'application/xml; charset=UTF-8',
'Cache-Control': 'public, max-age=10800',
},
);
});
routes.all('/', (context) => context.env.ASSETS.fetch(context.req.raw));
routes.all('/incidents/*', (context) => context.env.ASSETS.fetch(context.req.raw));
return routes;
}
+36
View File
@@ -0,0 +1,36 @@
import { renderMetaTags, type SeoHead } from './meta';
class RemoveElementHandler implements HTMLRewriterElementContentHandlers {
element(element: Element) {
element.remove();
}
}
class TitleHandler implements HTMLRewriterElementContentHandlers {
constructor(private readonly title: string) {}
element(element: Element) {
element.setInnerContent(this.title);
}
}
class HeadHandler implements HTMLRewriterElementContentHandlers {
constructor(private readonly head: SeoHead) {}
element(element: Element) {
element.append(renderMetaTags(this.head), { html: true });
}
}
export function rewriteHead(assetResponse: Response, head: SeoHead): Response {
const remove = new RemoveElementHandler();
return new HTMLRewriter()
.on('title', new TitleHandler(head.title))
.on('meta[name="description"]', remove)
.on('meta[name="robots"]', remove)
.on('meta[property^="og:"]', remove)
.on('meta[name^="twitter:"]', remove)
.on('link[rel="canonical"]', remove)
.on('head', new HeadHandler(head))
.transform(assetResponse);
}
+91
View File
@@ -0,0 +1,91 @@
import type { PublicIncident, PublicStatus } from '../../client/api/status';
export type SeoHead = {
title: string;
description: string;
canonical: string;
image: string;
robots: string;
};
const STATUS_TITLES: Record<PublicStatus['overall'], string> = {
operational: 'All systems operational',
degraded: 'Some systems are degraded',
down: 'Major service disruption',
};
export function absoluteBase(env: Env, request: Request): string {
const configured = (env as unknown as { PUBLIC_BASE_URL?: string }).PUBLIC_BASE_URL?.trim().replace(/\/$/, '');
return configured || new URL(request.url).origin;
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => {
if (character === '&') return '&amp;';
if (character === '<') return '&lt;';
if (character === '>') return '&gt;';
if (character === '"') return '&quot;';
return '&#39;';
});
}
function cleanDescription(value: string, fallback: string): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
return normalized.length > 200 ? `${normalized.slice(0, 197).trimEnd()}` : normalized;
}
export function statusHead(status: PublicStatus | null, base: string): SeoHead {
const title = status ? `${STATUS_TITLES[status.overall]} — upwatch status` : 'Service status — upwatch';
const description = status
? `${status.services.length} ${status.services.length === 1 ? 'service' : 'services'} · ${status.services.filter((service) => service.status === 'up').length} operational · ${status.activeIncidents.length} active ${status.activeIncidents.length === 1 ? 'incident' : 'incidents'}`
: 'Live operational health and 90-day availability for every public service.';
return { title, description, canonical: `${base}/`, image: `${base}/og.png`, robots: 'index,follow' };
}
export function incidentHead(incident: PublicIncident | null, base: string, id: string): SeoHead {
if (!incident) {
return {
title: 'Incident not found — upwatch status',
description: 'The requested public incident report could not be found.',
canonical: `${base}/incidents/${encodeURIComponent(id)}`,
image: `${base}/og.png`,
robots: 'noindex,follow',
};
}
const latestUpdate = incident.updates?.at(-1)?.body;
const services = incident.services.map((service) => service.name).join(', ');
const fallback = `${services || 'General service incident'} · Started ${new Date(incident.startedAt).toISOString()}`;
return {
title: `${incident.title}${incident.status} — upwatch status`,
description: cleanDescription(latestUpdate ?? '', fallback),
canonical: `${base}/incidents/${incident.id}`,
image: `${base}/og.png`,
robots: 'index,follow',
};
}
export function renderMetaTags(head: SeoHead): string {
const title = escapeHtml(head.title);
const description = escapeHtml(head.description);
const canonical = escapeHtml(head.canonical);
const image = escapeHtml(head.image);
const robots = escapeHtml(head.robots);
return [
`<meta name="description" content="${description}">`,
`<meta name="robots" content="${robots}">`,
`<link rel="canonical" href="${canonical}">`,
'<meta property="og:type" content="website">',
'<meta property="og:site_name" content="Upwatch">',
`<meta property="og:title" content="${title}">`,
`<meta property="og:description" content="${description}">`,
`<meta property="og:url" content="${canonical}">`,
`<meta property="og:image" content="${image}">`,
'<meta property="og:image:width" content="1200">',
'<meta property="og:image:height" content="630">',
'<meta name="twitter:card" content="summary_large_image">',
`<meta name="twitter:title" content="${title}">`,
`<meta name="twitter:description" content="${description}">`,
`<meta name="twitter:image" content="${image}">`,
].join('');
}
+117
View File
@@ -0,0 +1,117 @@
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
import { env, exports as worker } from 'cloudflare:workers';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { escapeHtml, incidentHead, renderMetaTags, statusHead } from '../src/worker/seo/meta';
async function resetDatabase() {
await env.DB.batch([
env.DB.prepare('DELETE FROM incident_updates'),
env.DB.prepare('DELETE FROM incident_monitors'),
env.DB.prepare('DELETE FROM incidents'),
env.DB.prepare('DELETE FROM checks'),
env.DB.prepare('DELETE FROM monitors'),
]);
}
async function seedIncident() {
const now = Date.now();
const monitor = await env.DB.prepare(
"INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, failure_threshold, consecutive_failures, last_ok, last_degraded, created_at, updated_at) VALUES ('Public API', 'https://api.example.com', 'GET', 200, 300, 10000, 1, 1, 2, 0, 0, 0, ?, ?)",
)
.bind(now, now)
.run();
const incident = await env.DB.prepare(
"INSERT INTO incidents (title, status, impact, source, kind, started_at, created_at, updated_at) VALUES ('API connectivity issue', 'investigating', 'major', 'manual', 'down', ?, ?, ?)",
)
.bind(now, now, now)
.run();
const incidentId = Number(incident.meta.last_row_id);
await env.DB.batch([
env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (?, ?)').bind(incidentId, monitor.meta.last_row_id),
env.DB.prepare(
"INSERT INTO incident_updates (incident_id, status, body, source, created_at) VALUES (?, 'investigating', 'We are investigating elevated connection failures.', 'manual', ?)",
).bind(incidentId, now),
]);
return incidentId;
}
describe('SEO metadata helpers', () => {
it('escapes untrusted metadata and emits absolute social metadata', () => {
const head = incidentHead(
{
id: 4,
title: 'API <outage>',
status: 'resolved',
impact: 'major',
source: 'manual',
startedAt: '2026-08-30T10:00:00.000Z',
services: [{ id: 1, name: 'API' }],
updates: [{ status: 'resolved', body: 'Service restored & stable.', createdAt: '2026-08-30T11:00:00.000Z' }],
},
'https://status.example.com',
'4',
);
const tags = renderMetaTags(head);
expect(tags).toContain('API &lt;outage&gt;');
expect(tags).toContain('https://status.example.com/og.png');
expect(tags).toContain('summary_large_image');
expect(escapeHtml('"<&')).toBe('&quot;&lt;&amp;');
});
it('builds a neutral fallback status head', () => {
expect(statusHead(null, 'https://example.com')).toMatchObject({
title: 'Service status — upwatch',
canonical: 'https://example.com/',
robots: 'index,follow',
});
});
});
describe('crawler-facing routes', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(resetDatabase);
it('renders status metadata into the SPA shell', async () => {
const response = await worker.default.fetch('https://status.example.com/');
const html = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/html');
expect(html).toContain('<title>All systems operational — upwatch status</title>');
expect(html).toContain('content="https://status.example.com/og.png"');
expect(html).toContain('name="twitter:card" content="summary_large_image"');
});
it('renders incident metadata and a safe missing-incident fallback', async () => {
const incidentId = await seedIncident();
const response = await worker.default.fetch(`https://status.example.com/incidents/${incidentId}`);
const html = await response.text();
expect(html).toContain('API connectivity issue — investigating — upwatch status');
expect(html).toContain('We are investigating elevated connection failures.');
const missingHtml = await (await worker.default.fetch('https://status.example.com/incidents/999999')).text();
expect(missingHtml).toContain('Incident not found — upwatch status');
expect(missingHtml).toContain('content="noindex,follow"');
});
it('publishes crawler policy and recent resolved incidents', async () => {
const incidentId = await seedIncident();
const now = Date.now();
await env.DB.prepare("UPDATE incidents SET status = 'resolved', resolved_at = ?, duration_ms = 1000, updated_at = ? WHERE id = ?")
.bind(now, now, incidentId)
.run();
const robots = await (await worker.default.fetch('https://status.example.com/robots.txt')).text();
expect(robots).toContain('Allow: /incidents/');
expect(robots).toContain('Disallow: /dashboard');
expect(robots).toContain('Sitemap: https://status.example.com/sitemap.xml');
const sitemapResponse = await worker.default.fetch('https://status.example.com/sitemap.xml');
const sitemap = await sitemapResponse.text();
expect(sitemapResponse.headers.get('content-type')).toContain('application/xml');
expect(sitemap).toContain('<loc>https://status.example.com/</loc>');
expect(sitemap).toContain(`<loc>https://status.example.com/incidents/${incidentId}</loc>`);
});
});
+2 -1
View File
@@ -1,8 +1,9 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 152a6ca04cfcd2b62d7c9cf752496777)
// Generated by Wrangler by running `wrangler types` (hash: 55bcbe53729280e10438582cc07eb78c)
// Runtime types generated with workerd@1.20260826.1 2026-08-27 nodejs_compat
interface __BaseEnv_Env {
DB: D1Database;
ASSETS: Fetcher;
}
declare namespace Cloudflare {
interface GlobalProps {
+7 -1
View File
@@ -5,13 +5,19 @@
"compatibility_date": "2026-08-27",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": "./dist/client",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*"],
"run_worker_first": ["/api/*", "/", "/incidents/*", "/robots.txt", "/sitemap.xml"],
},
"triggers": {
"crons": ["*/5 * * * *", "5 0 * * *"],
},
"keep_vars": true,
"vars": {
"PUBLIC_BASE_URL": "https://status.nezumi.pw",
"VITE_PUBLIC_BASE_URL": "https://status.nezumi.pw",
},
"d1_databases": [
{
"binding": "DB",