feat: improve SEO feature
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 76 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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 Cloudflare’s 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 |
@@ -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}`);
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 '&';
|
||||
if (character === '<') return '<';
|
||||
if (character === '>') return '>';
|
||||
if (character === '"') return '"';
|
||||
return ''';
|
||||
});
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
@@ -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 <outage>');
|
||||
expect(tags).toContain('https://status.example.com/og.png');
|
||||
expect(tags).toContain('summary_large_image');
|
||||
expect(escapeHtml('"<&')).toBe('"<&');
|
||||
});
|
||||
|
||||
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>`);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||