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",
+ );
+});