diff --git a/package-lock.json b/package-lock.json index a020c36..10874ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,10 +34,1002 @@ "typescript": "^5" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", + "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", + "license": "MIT", + "peer": true + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", + "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", + "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", + "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", + "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", + "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", + "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", + "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", + "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@sightmap/next": { + "resolved": "packages/next", + "link": true + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", + "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@next/env": "16.3.4", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.4", + "@next/swc-darwin-x64": "16.3.4", + "@next/swc-linux-arm64-gnu": "16.3.4", + "@next/swc-linux-arm64-musl": "16.3.4", + "@next/swc-linux-x64-gnu": "16.3.4", + "@next/swc-linux-x64-musl": "16.3.4", + "@next/swc-win32-arm64-msvc": "16.3.4", + "@next/swc-win32-x64-msvc": "16.3.4", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC", + "peer": true + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "peer": true, + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "peer": true + }, "packages/next": { "name": "@sightmap/next", "version": "0.0.1", - "extraneous": true, "license": "MIT", "bin": { "sightmap-next": "bin/sightmap-next.mjs" diff --git a/package.json b/package.json index 913f653..ee9e657 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "examples/*" ], "scripts": { - "test": "echo \"no packages yet\"" + "test": "npm test -w packages/next" }, "engines": { "node": ">=20" diff --git a/packages/next/README.md b/packages/next/README.md new file mode 100644 index 0000000..6eb5d49 --- /dev/null +++ b/packages/next/README.md @@ -0,0 +1,80 @@ +# @sightmap/next + +The Next.js side of Sightmap and Sightkick. Three commands and one component, no runtime +dependencies beyond `next` and `react`. + +```bash +npm i -D @sightmap/sightmap @sightmap/sightkick agent-browser +npm i @sightmap/next +``` + +## `sightmap-next seed` + +One-shot scaffold of `.sightmap/` from the router. Never overwrites. + +``` +sightmap-next seed [app-root] [--app-dir DIR] [--sightmap-dir DIR] [--base-url URL] [--dry-run] +``` + +- Walks `app/**/page.*` (and `pages/**`). Route groups vanish, `[id]` → `:id`, + `[...slug]` → `**`, `@slots`, `(.)intercepts`, and `_private` folders are skipped. +- One feature file per top-level route (`home.yaml`, `tasks.yaml`, …), per the spec's + authoring conventions. Each view gets `route:`, `source:`, the layout chain as + `dependencies:`, and `stability: stub`. +- Route handlers under `app/api` become `requests:` seeds, with `method:` when the file + exports exactly one HTTP verb. + +Then curate against the running app with the `sightmap-authoring` skill and drop the +`stub` markers. + +## `sightmap-next build` + +``` +sightmap-next build [app-root] [--public-dir DIR] [--no-init-script] +``` + +Writes, using the `sightmap` and `sightkick` CLIs: + +| File | What | +|---|---| +| `public/.well-known/sightmap.json` | the corpus, `sightmap export` | +| `public/.well-known/sightkick.json` | the compiled tool IR, `sightkick build` | +| `public/sightkick-runtime.js` | the runtime that registers the IR on `document.modelContext` | +| `webmcp.init.js` | runtime + IR in one file for `agent-browser --init-script` | + +Wire it as `"prebuild": "sightmap-next build"`. + +## `` + +```tsx +// app/layout.tsx +import { SightkickTools } from "@sightmap/next"; +import ir from "../public/.well-known/sightkick.json"; + + // inline the IR (registers as soon as the runtime loads) + // or fetch /.well-known/sightkick.json after hydration + // gate it +``` + +Registers the tool layer on every page. Tools are view-scoped and re-register on +client-side navigation. If an agent-browser init script already booted the runtime, the +component reuses it. + +## `sightmap-next run-plan` + +``` +sightmap-next run-plan [--base-url URL] [--init-script FILE] [--session NAME] + [--stamp] [--stale-ok] [--dry-run] +``` + +Replays a stored plan — a Gherkin scenario resolved to tool calls and expectations — +through `agent-browser webmcp invoke`. Same plan format and expectation vocabulary as +Sightkick's `scripts/run-plan.mjs` (`ok`, `value.{equals,contains,absent}`, +`list.{length,contains,excludes}`); only the executor differs. `--stamp` records the +feature-file and compiled-IR hashes; a later run refuses to proceed when either moved. + +## Tests + +```bash +npm test +``` diff --git a/packages/next/bin/sightmap-next.mjs b/packages/next/bin/sightmap-next.mjs new file mode 100755 index 0000000..4f8b099 --- /dev/null +++ b/packages/next/bin/sightmap-next.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// sightmap-next — the Next.js side of Sightmap + Sightkick. +// +// seed scaffold .sightmap/ view stubs from the App Router (one-shot, never overwrites) +// build publish the corpus + compiled WebMCP tool layer under public/.well-known/ +// and emit webmcp.init.js for agent-browser --init-script +// run-plan replay a stored plan (a Gherkin scenario resolved to tool calls) +// through `agent-browser webmcp invoke`, with no agent in the loop +import { seedCommand } from "../src/seed.mjs"; +import { buildCommand } from "../src/build.mjs"; +import { runPlanCommand } from "../src/run-plan.mjs"; + +const USAGE = `sightmap-next — Next.js compatibility layer for Sightmap + Sightkick + +Usage: + sightmap-next seed [app-root] [--app-dir DIR] [--sightmap-dir DIR] [--base-url URL] [--dry-run] + sightmap-next build [app-root] [--public-dir DIR] [--no-init-script] [--sightkick-args "..."] + sightmap-next run-plan [--base-url URL] [--init-script FILE] [--session NAME] + [--stamp] [--stale-ok] [--dry-run] + +seed Walk app/**/page.* (and pages/**) and write one stub view per route into + .sightmap/.yaml, following the spec's authoring conventions + (one feature file per top-level route, stability: stub, source: the page + file, dependencies: the layout chain). Route handlers under app/api become + requests: seeds. Existing files are never touched — the corpus is a curated + authority; this is a scaffold, not a generator. +build Run 'sightmap export' and 'sightkick build' / 'sightkick runtime' and write + public/.well-known/sightmap.json the corpus (what the app is) + public/.well-known/sightkick.json the compiled tool IR (what the app can do) + public/sightkick-runtime.js the WebMCP runtime the tools register through + webmcp.init.js runtime + IR in one file, for + 'agent-browser --init-script' +run-plan For each plan: open the app in agent-browser, wait for the tools to + register, invoke each step via 'agent-browser webmcp invoke', check the + expectation. Refuses to run a plan whose scenario or compiled IR has + changed since it was stamped. +`; + +const [cmd, ...rest] = process.argv.slice(2); +try { + switch (cmd) { + case "seed": + process.exitCode = await seedCommand(rest); + break; + case "build": + process.exitCode = await buildCommand(rest); + break; + case "run-plan": + process.exitCode = await runPlanCommand(rest); + break; + case undefined: + case "-h": + case "--help": + case "help": + process.stdout.write(USAGE); + process.exitCode = cmd ? 0 : 2; + break; + default: + process.stderr.write( + `sightmap-next: unknown command "${cmd}"\n\n${USAGE}`, + ); + process.exitCode = 2; + } +} catch (err) { + process.stderr.write(`✗ ${err?.message ?? err}\n`); + process.exitCode = 1; +} diff --git a/packages/next/package.json b/packages/next/package.json new file mode 100644 index 0000000..d1fd776 --- /dev/null +++ b/packages/next/package.json @@ -0,0 +1,37 @@ +{ + "name": "@sightmap/next", + "version": "0.0.1", + "description": "Next.js compatibility layer for Sightmap and Sightkick: seed a .sightmap/ corpus from the App Router, publish the corpus and the compiled WebMCP tool layer at /.well-known/, boot the tools on every page, and replay natural-language plans through agent-browser.", + "license": "MIT", + "type": "module", + "homepage": "https://sightmap.org", + "repository": { + "type": "git", + "url": "git+https://github.com/sightmap/sightmap-next.git", + "directory": "packages/next" + }, + "keywords": ["sightmap", "sightkick", "webmcp", "nextjs", "vercel", "agent-browser", "agent"], + "bin": { + "sightmap-next": "bin/sightmap-next.mjs" + }, + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./seed": "./src/seed.mjs", + "./build": "./src/build.mjs", + "./run-plan": "./src/run-plan.mjs" + }, + "files": ["bin/", "src/", "README.md"], + "scripts": { + "test": "node --test \"test/*.test.mjs\"" + }, + "peerDependencies": { + "next": ">=14", + "react": ">=18" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/next/src/args.mjs b/packages/next/src/args.mjs new file mode 100644 index 0000000..6260b55 --- /dev/null +++ b/packages/next/src/args.mjs @@ -0,0 +1,31 @@ +// Tiny flag parser: --k v, --k=v, --flag, --no-flag. Positionals in `_`. +export function parseArgs(argv, { booleans = [] } = {}) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith("--")) { + out._.push(a); + continue; + } + const eq = a.indexOf("="); + if (eq !== -1) { + out[a.slice(2, eq)] = a.slice(eq + 1); + continue; + } + const key = a.slice(2); + if (key.startsWith("no-")) { + out[key.slice(3)] = false; + continue; + } + if ( + booleans.includes(key) || + i + 1 >= argv.length || + argv[i + 1].startsWith("--") + ) { + out[key] = true; + continue; + } + out[key] = argv[++i]; + } + return out; +} diff --git a/packages/next/src/build.mjs b/packages/next/src/build.mjs new file mode 100644 index 0000000..77f1d77 --- /dev/null +++ b/packages/next/src/build.mjs @@ -0,0 +1,100 @@ +// build — publish the corpus and the compiled tool layer as static assets. +// +// This is the `sitemap.xml` half of the analogy: a deployed app tells machine +// readers what it is (`/.well-known/sightmap.json`) and what it can do +// (`/.well-known/sightkick.json`), served from the CDN like any other public +// file. The runtime that turns the IR into WebMCP tools ships alongside, and +// `webmcp.init.js` bundles runtime + IR into the one file agent-browser's +// `--init-script` wants — the artifact its `webmcp-gen` skill would otherwise +// hand-write. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { parseArgs } from "./args.mjs"; +import { requireBin, run } from "./cli-tools.mjs"; + +export const WELL_KNOWN = { + corpus: ".well-known/sightmap.json", + ir: ".well-known/sightkick.json", + runtime: "sightkick-runtime.js", +}; + +/** runtime bundle + IR → a single self-booting script for `agent-browser --init-script`. */ +export function renderInitScript(runtimeJs, irJson) { + const ir = JSON.stringify(JSON.parse(irJson)); + return ( + `// Generated by sightmap-next build — do not edit. Sightkick runtime + compiled IR.\n` + + `// Load before navigation: agent-browser --init-script ./webmcp.init.js open \n` + + `// Then: agent-browser webmcp list\n` + + `window.__sightkick_ir = ${ir};\n` + + runtimeJs + + `\n;(function(){ if (window.__sightkick && !window.__sightkick.ir) window.__sightkick.load(window.__sightkick_ir); })();\n` + ); +} + +export function build( + root, + { + publicDir = "public", + initScript = "webmcp.init.js", + sightkickArgs = [], + log = console.log, + } = {}, +) { + const sightmap = requireBin("sightmap", root, "npm i -D @sightmap/sightmap"); + const sightkick = requireBin( + "sightkick", + root, + "npm i -D @sightmap/sightkick", + ); + const pub = join(root, publicDir); + mkdirSync(join(pub, ".well-known"), { recursive: true }); + + const corpusOut = join(pub, WELL_KNOWN.corpus); + run(sightmap, ["export", root, "-o", corpusOut], { cwd: root }); + log(`✓ ${relative(root, corpusOut)} (sightmap export)`); + + const irOut = join(pub, WELL_KNOWN.ir); + const b = run(sightkick, ["build", root, "-o", irOut, ...sightkickArgs], { + cwd: root, + }); + log( + `✓ ${relative(root, irOut)} (${(b.stdout || b.stderr).trim().split("\n").pop()})`, + ); + + const rtOut = join(pub, WELL_KNOWN.runtime); + run(sightkick, ["runtime", "-o", rtOut], { cwd: root }); + log(`✓ ${relative(root, rtOut)} (sightkick runtime)`); + + if (initScript) { + const out = join(root, initScript); + writeFileSync( + out, + renderInitScript( + readFileSync(rtOut, "utf8"), + readFileSync(irOut, "utf8"), + ), + ); + log( + `✓ ${relative(root, out)} (runtime + IR, for agent-browser --init-script)`, + ); + } + return { corpusOut, irOut, rtOut }; +} + +export async function buildCommand(argv) { + const args = parseArgs(argv, { booleans: ["init-script"] }); + const root = args._[0] ?? process.cwd(); + build(root, { + publicDir: args["public-dir"], + initScript: + args["init-script"] === false + ? null + : typeof args["init-script"] === "string" + ? args["init-script"] + : undefined, + sightkickArgs: args["sightkick-args"] + ? String(args["sightkick-args"]).split(/\s+/).filter(Boolean) + : [], + }); + return 0; +} diff --git a/packages/next/src/cli-tools.mjs b/packages/next/src/cli-tools.mjs new file mode 100644 index 0000000..888c39b --- /dev/null +++ b/packages/next/src/cli-tools.mjs @@ -0,0 +1,46 @@ +// Resolve the sightmap / sightkick / agent-browser CLIs: the nearest +// node_modules/.bin above the app dir (they're normally devDependencies), then PATH. +import { existsSync } from "node:fs"; +import { join, dirname, delimiter } from "node:path"; +import { spawnSync } from "node:child_process"; + +export function resolveBin(name, root = process.cwd()) { + const file = process.platform === "win32" ? `${name}.cmd` : name; + // Walk up from the app dir: npm workspaces hoist bins to an ancestor node_modules/.bin. + for (let dir = root; ; dir = dirname(dir)) { + const local = join(dir, "node_modules", ".bin", file); + if (existsSync(local)) return local; + if (dirname(dir) === dir) break; + } + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + const p = join(dir, process.platform === "win32" ? `${name}.cmd` : name); + if (dir && existsSync(p)) return p; + } + return null; +} + +export function run(bin, args, { cwd, input, quiet = false } = {}) { + const res = spawnSync(bin, args, { + cwd, + input, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + shell: process.platform === "win32", + }); + if (res.error) throw new Error(`${bin}: ${res.error.message}`); + if (res.status !== 0 && !quiet) { + throw new Error( + `${[bin, ...args].join(" ")} exited ${res.status}\n${res.stderr || res.stdout}`, + ); + } + return res; +} + +export function requireBin(name, root, hint) { + const bin = resolveBin(name, root); + if (!bin) + throw new Error( + `"${name}" not found on PATH or in node_modules/.bin. ${hint}`, + ); + return bin; +} diff --git a/packages/next/src/index.d.ts b/packages/next/src/index.d.ts new file mode 100644 index 0000000..a6b364a --- /dev/null +++ b/packages/next/src/index.d.ts @@ -0,0 +1,17 @@ +import type { ReactElement } from "react"; + +export interface SightkickToolsProps { + /** The compiled IR: a URL to fetch (default "/.well-known/sightkick.json") or the parsed JSON to inline. */ + ir?: string | object; + /** URL of the Sightkick runtime bundle (default "/sightkick-runtime.js"). */ + runtime?: string; + /** Render nothing when false (e.g. gate on an environment variable). */ + enabled?: boolean; + /** next/script strategy; "afterInteractive" by default. */ + strategy?: "afterInteractive" | "lazyOnload" | "beforeInteractive"; +} + +export function SightkickTools( + props?: SightkickToolsProps, +): ReactElement | null; +export default SightkickTools; diff --git a/packages/next/src/index.js b/packages/next/src/index.js new file mode 100644 index 0000000..b11b1b9 --- /dev/null +++ b/packages/next/src/index.js @@ -0,0 +1,53 @@ +// — boot the Sightkick runtime on every page so the compiled +// tool layer registers on the browser's WebMCP surface (document.modelContext). +// +// Drop it in the root layout. Any WebMCP client then sees the app's tools: +// agent-browser (`agent-browser webmcp list`), the ChatGPT desktop browser, +// Chrome's own WebMCP surface. Nothing else in the app changes — the tools are +// compiled from `.sightmap/` + `.sightkick/` by `sightmap-next build`. +// +// Pass `ir` as the JSON object (imported at build time from +// public/.well-known/sightkick.json) to inline it and register synchronously +// on runtime load, or as a URL string to fetch it after hydration. +import React from "react"; +import Script from "next/script"; + +const DEFAULT_IR = "/.well-known/sightkick.json"; +const DEFAULT_RUNTIME = "/sightkick-runtime.js"; + +function bootScript(ir, runtime) { + const irExpr = typeof ir === "string" ? null : JSON.stringify(ir); + const irUrl = typeof ir === "string" ? JSON.stringify(ir) : null; + return ( + "(function(){" + + (irExpr ? `window.__sightkick_ir=${irExpr};` : "") + + `var RT=${JSON.stringify(runtime)};` + + `function load(){` + + (irExpr + ? `window.__sightkick.load(window.__sightkick_ir);` + : `fetch(${irUrl},{cache:"no-store"}).then(function(r){return r.json()}).then(function(ir){window.__sightkick.load(ir)}).catch(function(e){console.warn("[sightkick] could not load IR",e)});`) + + `}` + + // An init script (agent-browser --init-script) may already have booted the runtime. + `if(window.__sightkick){if(!window.__sightkick.ir)load();return}` + + `var s=document.createElement("script");s.src=RT;s.async=true;` + + (irExpr ? "" : `s.onload=load;`) + + `document.head.appendChild(s);` + + "})();" + ); +} + +export function SightkickTools({ + ir = DEFAULT_IR, + runtime = DEFAULT_RUNTIME, + enabled = true, + strategy = "afterInteractive", +} = {}) { + if (!enabled) return null; + return React.createElement(Script, { + id: "sightkick-boot", + strategy, + dangerouslySetInnerHTML: { __html: bootScript(ir, runtime) }, + }); +} + +export default SightkickTools; diff --git a/packages/next/src/run-plan.mjs b/packages/next/src/run-plan.mjs new file mode 100644 index 0000000..646a319 --- /dev/null +++ b/packages/next/src/run-plan.mjs @@ -0,0 +1,317 @@ +// run-plan — replay a stored plan through agent-browser, with no agent in the loop. +// +// A plan is a Gherkin scenario an agent resolved *once* against the tool layer: +// each line becomes a tool name, params, and an expectation. Replay is then a +// sequence of `agent-browser webmcp invoke` calls — no LLM, no tokens — until +// either the scenario text or the compiled IR changes, at which point the plan +// refuses to run and asks to be re-planned. The plan format and the expectation +// vocabulary are Sightkick's (examples/saucedemo/plans/*.plan.json in +// sightmap/sightkick); only the executor differs: agent-browser's native WebMCP +// path instead of `sightkick call`. +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { parseArgs } from "./args.mjs"; +import { requireBin, run } from "./cli-tools.mjs"; +import { WELL_KNOWN } from "./build.mjs"; + +const sha256 = (t) => "sha256:" + createHash("sha256").update(t).digest("hex"); + +/** Same vocabulary as sightkick's run-plan: ok, value.{equals,contains,absent}, list.{length,contains}. */ +export function checkExpect(result, expect) { + if (expect == null) return { pass: true }; + if ("ok" in expect && Boolean(result.ok) !== expect.ok) { + return { + pass: false, + reason: `expected ok:${expect.ok}, got ok:${Boolean(result.ok)}`, + }; + } + if (expect.value) { + const v = result.value; + if (expect.value.absent && v !== undefined && v !== null && v !== "") { + return { + pass: false, + reason: `expected value absent, got ${JSON.stringify(v)}`, + }; + } + if ("equals" in expect.value && v !== expect.value.equals) { + return { + pass: false, + reason: `expected value ${JSON.stringify(expect.value.equals)}, got ${JSON.stringify(v)}`, + }; + } + if ( + expect.value.contains && + !(typeof v === "string" && v.includes(expect.value.contains)) + ) { + return { + pass: false, + reason: `expected value to contain ${JSON.stringify(expect.value.contains)}, got ${JSON.stringify(v)}`, + }; + } + } + if (expect.list) { + const items = result.items ?? []; + if ("length" in expect.list && items.length !== expect.list.length) { + return { + pass: false, + reason: `expected ${expect.list.length} row(s), got ${items.length}`, + }; + } + if (expect.list.contains) { + const found = items.some((row) => + Object.entries(expect.list.contains).every(([k, v]) => row[k] === v), + ); + if (!found) + return { + pass: false, + reason: `expected a row matching ${JSON.stringify(expect.list.contains)}, got ${JSON.stringify(items)}`, + }; + } + if (expect.list.excludes) { + const found = items.some((row) => + Object.entries(expect.list.excludes).every(([k, v]) => row[k] === v), + ); + if (found) + return { + pass: false, + reason: `expected no row matching ${JSON.stringify(expect.list.excludes)}`, + }; + } + } + return { pass: true }; +} + +/** Unwrap agent-browser's `webmcp invoke --json` envelope down to Sightkick's ToolResult. */ +export function unwrapInvoke(stdout) { + let env; + try { + env = JSON.parse(stdout); + } catch { + throw new Error(`agent-browser did not print JSON:\n${stdout}`); + } + if (env.success === false) + return { + ok: false, + message: env.error?.message ?? JSON.stringify(env.error), + }; + const out = env.data?.output ?? env.data; + const text = out?.content?.find?.((c) => c.type === "text")?.text; + if (typeof text !== "string") return { ok: !out?.isError, raw: out }; + try { + return JSON.parse(text); + } catch { + return { ok: !out?.isError, value: text }; + } +} + +class AgentBrowser { + constructor(bin, { session, cwd }) { + this.bin = bin; + this.base = session ? ["--session", session] : []; + this.cwd = cwd; + } + exec(args, opts = {}) { + return run(this.bin, [...this.base, ...args], { + cwd: this.cwd, + quiet: true, + ...opts, + }); + } + open(url, { initScript } = {}) { + const args = ["open", url]; + if (initScript) args.unshift("--init-script", initScript); + // The daemon may still be shutting down from a previous close; retry briefly. + let last; + for (let attempt = 0; attempt < 5; attempt++) { + last = this.exec(args); + if (last.status === 0) return; + Atomics.wait( + new Int32Array(new SharedArrayBuffer(4)), + 0, + 0, + 1000 * (attempt + 1), + ); + } + throw new Error( + `agent-browser open ${url} failed:\n${last.stderr || last.stdout}`, + ); + } + waitForTools(timeoutMs = 15000) { + // The app's (or the init script) registers tools asynchronously. + const r = this.exec([ + "wait", + "--fn", + "!!(window.__sightkick && window.__sightkick.ir)", + "--timeout", + String(timeoutMs), + ]); + if (r.status !== 0) + throw new Error( + `sightkick runtime never loaded an IR on the page (is in the root layout, or --init-script set?)\n${r.stderr || r.stdout}`, + ); + } + invoke(tool, params) { + const args = ["webmcp", "invoke", tool, "--json"]; + if (params && Object.keys(params).length) + args.push("--params", JSON.stringify(params)); + const r = this.exec(args); + return unwrapInvoke(r.stdout || r.stderr); + } + close() { + this.exec(["close"]); + } +} + +export function runPlan(planPath, opts = {}) { + const { + baseUrl = process.env.SIGHTMAP_BASE_URL ?? "http://localhost:3000", + stamp = false, + staleOk = false, + dryRun = false, + initScript, + session, + keepOpen = false, + log = console.log, + } = opts; + const plan = JSON.parse(readFileSync(planPath, "utf8")); + const planDir = dirname(resolve(planPath)); + const root = resolve(planDir, plan.app ?? ".."); + + const featureText = readFileSync( + resolve(root, plan.scenario.feature), + "utf8", + ); + const scenarioHash = sha256(featureText); + const irPath = resolve(root, opts.publicDir ?? "public", WELL_KNOWN.ir); + let irHash = null; + try { + irHash = sha256(readFileSync(irPath, "utf8")); + } catch { + throw new Error( + `no compiled IR at ${irPath} — run 'sightmap-next build' first`, + ); + } + + if (stamp) { + plan.scenario.hash = scenarioHash; + plan.irHash = irHash; + plan.generatedAt = new Date().toISOString(); + writeFileSync(planPath, JSON.stringify(plan, null, 2) + "\n"); + log(`stamped ${planPath}`); + return 0; + } + if (plan.scenario.hash && plan.scenario.hash !== scenarioHash && !staleOk) { + log( + `✗ ${plan.scenario.feature} changed since this plan was stamped — re-plan it (or pass --stale-ok)`, + ); + return 1; + } + if (plan.irHash && plan.irHash !== irHash && !staleOk) { + log( + `✗ the compiled tool layer (${WELL_KNOWN.ir}) changed since this plan was stamped — re-plan it (or pass --stale-ok)`, + ); + return 1; + } + if (!plan.scenario.hash || !plan.irHash) + log( + `⚠ ${planPath} is unstamped — run with --stamp to lock its hashes. Proceeding anyway.`, + ); + + log(`\n${plan.scenario.name}`); + const startUrl = baseUrl.replace(/\/$/, "") + (plan.start ?? "/"); + if (dryRun) { + log(` agent-browser open ${startUrl}`); + for (const step of plan.steps) { + if (!step.tool) { + log(` · ${step.gherkin}`); + continue; + } + const p = step.params ? ` --params '${JSON.stringify(step.params)}'` : ""; + log( + ` ${step.gherkin}\n agent-browser webmcp invoke ${step.tool}${p}`, + ); + } + return 0; + } + + const ab = + opts.browser ?? + new AgentBrowser( + requireBin( + "agent-browser", + root, + "npm i -D agent-browser (then: agent-browser install)", + ), + { session, cwd: root }, + ); + // Every plan starts from a fresh document, so state resets even when the browser is shared. + ab.open(startUrl, { + initScript: initScript ? resolve(initScript) : undefined, + }); + ab.waitForTools(); + let failed = false; + try { + for (const step of plan.steps) { + if (!step.tool) { + log(` · ${step.gherkin}`); + continue; + } + const result = ab.invoke(step.tool, step.params); + const { pass, reason } = checkExpect(result, step.expect); + log(` ${pass ? "✓" : "✗"} ${step.gherkin}`); + if (!pass) { + log(` ${reason}\n result: ${JSON.stringify(result)}`); + failed = true; + break; + } + } + } finally { + if (!keepOpen && !opts.browser) ab.close(); + } + return failed ? 1 : 0; +} + +export function createBrowser(root, { session } = {}) { + return new AgentBrowser( + requireBin( + "agent-browser", + root, + "npm i -D agent-browser (then: agent-browser install)", + ), + { session, cwd: root }, + ); +} + +export async function runPlanCommand(argv) { + const args = parseArgs(argv, { + booleans: ["stamp", "stale-ok", "dry-run", "keep-open"], + }); + if (!args._.length) + throw new Error( + "usage: sightmap-next run-plan [--base-url URL] [--init-script FILE] [--session NAME] [--stamp] [--stale-ok] [--dry-run]", + ); + let code = 0; + const live = !args.stamp && !args["dry-run"]; + const browser = live + ? createBrowser(process.cwd(), { session: args.session }) + : undefined; + try { + for (const p of args._) { + const c = runPlan(p, { + browser, + baseUrl: args["base-url"], + initScript: args["init-script"], + session: args.session, + stamp: !!args.stamp, + staleOk: !!args["stale-ok"], + dryRun: !!args["dry-run"], + keepOpen: !!args["keep-open"], + }); + if (c !== 0) code = c; + } + } finally { + if (browser && !args["keep-open"]) browser.close(); + } + return code; +} diff --git a/packages/next/src/seed.mjs b/packages/next/src/seed.mjs new file mode 100644 index 0000000..4122707 --- /dev/null +++ b/packages/next/src/seed.mjs @@ -0,0 +1,360 @@ +// seed — scaffold .sightmap/ view stubs from a Next.js router tree. +// +// This is deliberately a one-shot scaffold and not a build step. The Sightmap +// spec's authoring conventions make `.sightmap/` a curated authority: nothing +// regenerates it from source, and discovery happens against the running app. +// What a router tree *can* contribute deterministically is exactly what the +// `sightmap-authoring` skill tells an agent to seed by hand — the route table +// (`route:`), the page file (`source:`), and the layout chain +// (`dependencies:`). Everything else (selectors, properties, memory) needs the +// live loop. So: seed once, mark every view `stability: stub`, never overwrite. +import { + readdirSync, + statSync, + existsSync, + mkdirSync, + writeFileSync, + readFileSync, +} from "node:fs"; +import { join, relative, sep, basename } from "node:path"; +import { parseArgs } from "./args.mjs"; + +const PAGE_RE = /^page\.(js|jsx|ts|tsx|md|mdx)$/; +const LAYOUT_RE = /^layout\.(js|jsx|ts|tsx)$/; +const ROUTE_RE = /^route\.(js|jsx|ts|tsx)$/; +const HTTP_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "PATCH", + "OPTIONS", +]; + +/** Convert one App Router directory segment to its URL form, or null to skip the subtree. */ +export function segmentToRoute(seg) { + if (seg.startsWith("_")) return null; // private folder: excluded from routing + if (seg.startsWith("@")) return null; // parallel route slot: rendered inside its parent + if (/^\(\.{1,3}\)/.test(seg)) return null; // intercepting route: a view of another route + if (/^\(.*\)$/.test(seg)) return ""; // route group: no URL segment + if (/^\[\[\.\.\..+\]\]$/.test(seg)) return "**"; // optional catch-all + if (/^\[\.\.\..+\]$/.test(seg)) return "**"; // catch-all + const m = /^\[(.+)\]$/.exec(seg); + if (m) return `:${m[1]}`; // dynamic segment — the spec's Express-style param form + return seg; +} + +function pascal(s) { + return s + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean) + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(""); +} + +/** A readable, deterministic view name from a route: `/` → Home, `/tasks/:id` → TasksById. */ +export function routeToName(route) { + if (route === "/") return "Home"; + const parts = route.split("/").filter(Boolean); + const out = []; + for (const p of parts) { + if (p === "**") out.push("CatchAll"); + else if (p.startsWith(":")) out.push("By" + pascal(p.slice(1))); + else out.push(pascal(p)); + } + return out.join("") || "Home"; +} + +/** The feature file a route belongs to, per the spec's "one feature file per top-level route". */ +export function routeToFeature(route) { + const first = route.split("/").filter(Boolean)[0]; + if (!first || first.startsWith(":") || first === "**") return "home"; + return first.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); +} + +function walk(dir, segs, layouts, acc, root) { + let entries; + try { + entries = readdirSync(dir).sort(); + } catch { + return; + } + const here = [...layouts]; + const layout = entries.find((e) => LAYOUT_RE.test(e)); + if (layout) here.push(relative(root, join(dir, layout)).split(sep).join("/")); + + for (const e of entries) { + const full = join(dir, e); + if (PAGE_RE.test(e)) { + acc.views.push({ + route: "/" + segs.filter(Boolean).join("/"), + source: relative(root, full).split(sep).join("/"), + dependencies: here, + }); + } else if (ROUTE_RE.test(e)) { + acc.requests.push({ + route: "/" + segs.filter(Boolean).join("/"), + source: relative(root, full).split(sep).join("/"), + methods: detectMethods(full), + }); + } + } + for (const e of entries) { + const full = join(dir, e); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (!st.isDirectory()) continue; + const seg = segmentToRoute(e); + if (seg === null) continue; + walk(full, seg === "" ? segs : [...segs, seg], here, acc, root); + } +} + +function detectMethods(file) { + let src = ""; + try { + src = readFileSync(file, "utf8"); + } catch { + return []; + } + const found = new Set(); + for (const m of HTTP_METHODS) { + if (new RegExp(`export\\s+(async\\s+)?function\\s+${m}\\b`).test(src)) + found.add(m); + if (new RegExp(`export\\s+(const|let)\\s+${m}\\b`).test(src)) found.add(m); + if (new RegExp(`export\\s*\\{[^}]*\\b${m}\\b[^}]*\\}`).test(src)) + found.add(m); + } + return [...found]; +} + +/** Also honor the Pages Router, minus _app/_document/_error and api/ (the latter become requests). */ +function walkPages(dir, segs, acc, root) { + let entries; + try { + entries = readdirSync(dir).sort(); + } catch { + return; + } + for (const e of entries) { + const full = join(dir, e); + const st = statSync(full); + if (st.isDirectory()) { + if (segs.length === 0 && e === "api") { + walkPagesApi(full, ["api"], acc, root); + continue; + } + walkPages(full, [...segs, segmentToRoute(e) ?? e], acc, root); + continue; + } + const m = /^(.+)\.(js|jsx|ts|tsx|md|mdx)$/.exec(e); + if (!m) continue; + const name = m[1]; + if ( + segs.length === 0 && + ["_app", "_document", "_error", "404", "500"].includes(name) + ) + continue; + const seg = name === "index" ? "" : (segmentToRoute(name) ?? name); + acc.views.push({ + route: "/" + [...segs, seg].filter(Boolean).join("/"), + source: relative(root, full).split(sep).join("/"), + dependencies: [], + }); + } +} + +function walkPagesApi(dir, segs, acc, root) { + for (const e of readdirSync(dir).sort()) { + const full = join(dir, e); + if (statSync(full).isDirectory()) { + walkPagesApi(full, [...segs, segmentToRoute(e) ?? e], acc, root); + continue; + } + const m = /^(.+)\.(js|jsx|ts|tsx)$/.exec(e); + if (!m) continue; + const seg = m[1] === "index" ? "" : (segmentToRoute(m[1]) ?? m[1]); + acc.requests.push({ + route: "/" + [...segs, seg].filter(Boolean).join("/"), + source: relative(root, full).split(sep).join("/"), + methods: [], + }); + } +} + +/** + * Scan a Next.js project and return `{ views, requests }` — each view carries + * `route`, `source`, `dependencies`; each request carries `route`, `source`, + * `methods`. Pure: reads the tree, writes nothing. + */ +export function scanNextProject(root, { appDir } = {}) { + const acc = { views: [], requests: [] }; + const candidates = appDir ? [appDir] : ["app", "src/app"]; + for (const c of candidates) { + const dir = join(root, c); + if (existsSync(dir)) { + walk(dir, [], [], acc, root); + break; + } + } + for (const c of ["pages", "src/pages"]) { + const dir = join(root, c); + if (existsSync(dir)) { + walkPages(dir, [], acc, root); + break; + } + } + // Deterministic order, unique names. + acc.views.sort((a, b) => a.route.localeCompare(b.route)); + const seen = new Map(); + for (const v of acc.views) { + let name = routeToName(v.route); + if (seen.has(name)) { + seen.set(name, seen.get(name) + 1); + name = `${name}${seen.get(name)}`; + } else seen.set(name, 1); + v.name = name; + } + acc.requests.sort((a, b) => a.route.localeCompare(b.route)); + for (const r of acc.requests) + r.name = routeToName(r.route.replace(/^\/api\b/, "") || "/") + "Api"; + return acc; +} + +function y(s) { + // Quote only when YAML would otherwise mis-parse (leading punctuation, colons, etc.). + if (/^[A-Za-z0-9_./-]+$/.test(s) && !/^(true|false|null|yes|no|~)$/i.test(s)) + return s; + return "'" + s.replace(/'/g, "''") + "'"; +} + +/** Render one feature file's YAML (views + any requests under the same top-level segment). */ +export function renderFeatureYaml(feature, views, requests, { baseUrl } = {}) { + const lines = ["version: 1", ""]; + if (views.length) { + lines.push("views:"); + for (const v of views) { + lines.push(` - name: ${v.name}`); + lines.push(` route: ${y(v.route)}`); + const concrete = !/[:*]/.test(v.route); + if (baseUrl && concrete) + lines.push(` url: ${y(baseUrl.replace(/\/$/, "") + v.route)}`); + lines.push(` stability: stub`); + lines.push(` source: ${y(v.source)}`); + if (v.dependencies?.length) { + lines.push(` dependencies:`); + for (const d of v.dependencies) lines.push(` - ${y(d)}`); + } + lines.push( + ` description: ${y("Seeded from the Next.js router by sightmap-next. Curate against the running app, then drop stability: stub.")}`, + ); + lines.push(` components: []`); + } + } + if (requests.length) { + if (views.length) lines.push(""); + lines.push("requests:"); + for (const r of requests) { + lines.push(` - name: ${r.name}`); + lines.push(` route: ${y(r.route)}`); + if (r.methods?.length === 1) lines.push(` method: ${r.methods[0]}`); + lines.push(` source: ${y(r.source)}`); + if (r.methods?.length > 1) + lines.push( + ` description: Route handler exporting ${r.methods.join(", ")}. Split into one request per method if the payloads differ.`, + ); + } + } + lines.push(""); + return lines.join("\n"); +} + +/** Group scan results into feature files and write the ones that don't exist yet. */ +export function seed( + root, + { + appDir, + sightmapDir = ".sightmap", + baseUrl = "http://localhost:3000", + dryRun = false, + log = console.log, + } = {}, +) { + const { views, requests } = scanNextProject(root, { appDir }); + if (!views.length && !requests.length) { + log(`nothing to seed: no app/ or pages/ router found under ${root}`); + return { written: [], skipped: [], views, requests }; + } + const byFeature = new Map(); + for (const v of views) { + const f = routeToFeature(v.route); + if (!byFeature.has(f)) byFeature.set(f, { views: [], requests: [] }); + byFeature.get(f).views.push(v); + } + for (const r of requests) { + const f = routeToFeature(r.route.replace(/^\/api\b/, "") || "/"); + if (!byFeature.has(f)) byFeature.set(f, { views: [], requests: [] }); + byFeature.get(f).requests.push(r); + } + const outDir = join(root, sightmapDir); + const written = []; + const skipped = []; + if (!dryRun) mkdirSync(outDir, { recursive: true }); + const cfg = join(outDir, "config.yaml"); + if (!existsSync(cfg) && !dryRun) { + writeFileSync(cfg, "version: 1\n"); + written.push(relative(root, cfg)); + } + for (const [feature, group] of [...byFeature.entries()].sort()) { + const file = join(outDir, `${feature}.yaml`); + const rel = relative(root, file); + const yaml = renderFeatureYaml(feature, group.views, group.requests, { + baseUrl, + }); + if (existsSync(file)) { + skipped.push(rel); + continue; + } + if (dryRun) { + log(`--- ${rel}\n${yaml}`); + } else { + writeFileSync(file, yaml); + } + written.push(rel); + } + return { written, skipped, views, requests }; +} + +export async function seedCommand(argv) { + const args = parseArgs(argv, { booleans: ["dry-run"] }); + const root = args._[0] ?? process.cwd(); + const res = seed(root, { + appDir: args["app-dir"], + sightmapDir: args["sightmap-dir"], + baseUrl: args["base-url"], + dryRun: !!args["dry-run"], + }); + const n = res.views.length; + console.log( + `${args["dry-run"] ? "would seed" : "seeded"} ${n} view${n === 1 ? "" : "s"}, ${res.requests.length} request${res.requests.length === 1 ? "" : "s"} from the Next.js router`, + ); + for (const f of res.written) + console.log(` ${args["dry-run"] ? "would write" : "wrote "} ${f}`); + for (const f of res.skipped) + console.log( + ` skipped ${f} (exists — the corpus is curated; edit it by hand or with the sightmap-authoring skill)`, + ); + if (res.written.length && !args["dry-run"]) { + console.log( + `\nNext:\n 1. sightmap validate\n 2. next dev, then curate each stub against the live app (sightmap-authoring skill)\n 3. write .sightkick/tools.yaml (sightkick-authoring skill), then: sightmap-next build`, + ); + } + return 0; +} diff --git a/packages/next/test/fixtures/app-router/app/(.)intercept/page.tsx b/packages/next/test/fixtures/app-router/app/(.)intercept/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/(marketing)/about/page.tsx b/packages/next/test/fixtures/app-router/app/(marketing)/about/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/@modal/photo/page.tsx b/packages/next/test/fixtures/app-router/app/@modal/photo/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/_lib/page.tsx b/packages/next/test/fixtures/app-router/app/_lib/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/api/tasks/[id]/route.ts b/packages/next/test/fixtures/app-router/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..68b5245 --- /dev/null +++ b/packages/next/test/fixtures/app-router/app/api/tasks/[id]/route.ts @@ -0,0 +1 @@ +export { DELETE } from "./impl"; diff --git a/packages/next/test/fixtures/app-router/app/api/tasks/route.ts b/packages/next/test/fixtures/app-router/app/api/tasks/route.ts new file mode 100644 index 0000000..a722578 --- /dev/null +++ b/packages/next/test/fixtures/app-router/app/api/tasks/route.ts @@ -0,0 +1,2 @@ +export async function GET() {} +export const POST = handler; diff --git a/packages/next/test/fixtures/app-router/app/docs/[...slug]/page.mdx b/packages/next/test/fixtures/app-router/app/docs/[...slug]/page.mdx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/layout.tsx b/packages/next/test/fixtures/app-router/app/layout.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/page.tsx b/packages/next/test/fixtures/app-router/app/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/tasks/[id]/page.tsx b/packages/next/test/fixtures/app-router/app/tasks/[id]/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/fixtures/app-router/app/tasks/layout.tsx b/packages/next/test/fixtures/app-router/app/tasks/layout.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/test/seed.test.mjs b/packages/next/test/seed.test.mjs new file mode 100644 index 0000000..cdee2a1 --- /dev/null +++ b/packages/next/test/seed.test.mjs @@ -0,0 +1,118 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + existsSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + scanNextProject, + segmentToRoute, + routeToName, + routeToFeature, + renderFeatureYaml, + seed, +} from "../src/seed.mjs"; + +const FIXTURE = new URL("./fixtures/app-router", import.meta.url).pathname; + +test("segments map to spec route syntax", () => { + assert.equal(segmentToRoute("[id]"), ":id"); + assert.equal(segmentToRoute("[...slug]"), "**"); + assert.equal(segmentToRoute("[[...slug]]"), "**"); + assert.equal(segmentToRoute("(marketing)"), ""); + assert.equal(segmentToRoute("@modal"), null); + assert.equal(segmentToRoute("(.)photo"), null); + assert.equal(segmentToRoute("_lib"), null); + assert.equal(segmentToRoute("tasks"), "tasks"); +}); + +test("names and feature files are deterministic", () => { + assert.equal(routeToName("/"), "Home"); + assert.equal(routeToName("/tasks/:id"), "TasksById"); + assert.equal(routeToName("/docs/**"), "DocsCatchAll"); + assert.equal(routeToFeature("/"), "home"); + assert.equal(routeToFeature("/tasks/:id"), "tasks"); + assert.equal(routeToFeature("/about"), "about"); +}); + +test("scan walks the App Router and skips slots, private, and intercepting routes", () => { + const { views, requests } = scanNextProject(FIXTURE); + assert.deepEqual( + views.map((v) => [v.name, v.route, v.source]), + [ + ["Home", "/", "app/page.tsx"], + ["About", "/about", "app/(marketing)/about/page.tsx"], + ["DocsCatchAll", "/docs/**", "app/docs/[...slug]/page.mdx"], + ["TasksById", "/tasks/:id", "app/tasks/[id]/page.tsx"], + ], + ); + const detail = views.find((v) => v.name === "TasksById"); + assert.deepEqual(detail.dependencies, [ + "app/layout.tsx", + "app/tasks/layout.tsx", + ]); + assert.deepEqual( + requests.map((r) => [r.route, r.methods.sort()]), + [ + ["/api/tasks", ["GET", "POST"]], + ["/api/tasks/:id", ["DELETE"]], + ], + ); +}); + +test("rendered YAML is valid corpus shape", () => { + const { views, requests } = scanNextProject(FIXTURE); + const yaml = renderFeatureYaml( + "tasks", + views.filter((v) => v.route.startsWith("/tasks")), + requests, + { baseUrl: "http://localhost:3000" }, + ); + assert.match(yaml, /^version: 1\n/); + assert.match(yaml, /route: .\/tasks\/:id.\n/); + assert.match(yaml, /stability: stub/); + assert.doesNotMatch( + yaml, + /url: http:\/\/localhost:3000\/tasks\/:id/, + "parameterized routes get no url", + ); + assert.match( + yaml, + /requests:\n - name: TasksApi\n route: \/api\/tasks\n source: app\/api\/tasks\/route.ts\n description: Route handler exporting GET, POST/, + ); +}); + +test("seed writes once and never overwrites", () => { + const root = mkdtempSync(join(tmpdir(), "sightmap-next-")); + writeFileSync(join(root, "package.json"), "{}"); + const app = join(root, "app"); + mkdirSync(join(app, "about"), { recursive: true }); + writeFileSync(join(app, "page.tsx"), ""); + writeFileSync(join(app, "about", "page.tsx"), ""); + const first = seed(root, { log: () => {} }); + assert.deepEqual(first.written.sort(), [ + ".sightmap/about.yaml", + ".sightmap/config.yaml", + ".sightmap/home.yaml", + ]); + assert.ok(existsSync(join(root, ".sightmap", "home.yaml"))); + writeFileSync( + join(root, ".sightmap", "home.yaml"), + "version: 1\n# curated\n", + ); + const second = seed(root, { log: () => {} }); + assert.deepEqual(second.written, []); + assert.deepEqual(second.skipped.sort(), [ + ".sightmap/about.yaml", + ".sightmap/home.yaml", + ]); + assert.equal( + readFileSync(join(root, ".sightmap", "home.yaml"), "utf8"), + "version: 1\n# curated\n", + ); +});