diff --git a/next.config.ts b/next.config.ts index 2440bbe..1c5468f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,23 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + async headers() { + return [ + { + source: "/:path*", + headers: [ + // Signals AI crawlers that training, search indexing, and agentic + // use are all permitted (https://contentsignals.org). Set here + // rather than public/_headers because the Docker deploy doesn't + // read that file. + { + key: "Content-Signal", + value: "ai-train=yes, search=yes, ai-input=yes", + }, + ], + }, + ]; + }, async redirects() { return [ { diff --git a/package.json b/package.json index 56d259a..6a8bcef 100644 --- a/package.json +++ b/package.json @@ -45,16 +45,21 @@ "react-dom": "19.2.3", "react-hook-form": "^7.62.0", "react-markdown": "^10.1.0", + "rehype-parse": "^9.0.1", + "rehype-remark": "^10.0.1", "remark-gfm": "^4.0.1", + "remark-stringify": "^11.0.0", "schema-dts": "^2.0.0", "sonner": "^2.0.7", "swr": "^2.3.8", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "unified": "^11.0.5", "zustand": "^5.0.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/hast": "^3.0.5", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2da350..91bb95a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,9 +101,18 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.3) + rehype-parse: + specifier: ^9.0.1 + version: 9.0.1 + rehype-remark: + specifier: ^10.0.1 + version: 10.0.1 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-stringify: + specifier: ^11.0.0 + version: 11.0.0 schema-dts: specifier: ^2.0.0 version: 2.0.0(typescript@5.9.3) @@ -119,6 +128,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 zustand: specifier: ^5.0.12 version: 5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) @@ -126,6 +138,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.2 + '@types/hast': + specifier: ^3.0.5 + version: 3.0.5 '@types/node': specifier: ^20 version: 20.19.37 @@ -1577,8 +1592,8 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2243,6 +2258,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2588,12 +2607,51 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-mdast@10.1.2: + resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2606,6 +2664,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3354,6 +3415,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3570,6 +3634,15 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-minify-whitespace@6.0.2: + resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-remark@10.0.1: + resolution: {integrity: sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3857,6 +3930,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + trim-trailing-lines@2.1.0: + resolution: {integrity: sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -3925,6 +4001,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -4010,12 +4089,18 @@ packages: resolution: {integrity: sha512-2UJ32VNh+lMUP9RlLoneEdrnupW3eIM9TGdTuaa+HSUqKACt9Vvw+xoGFGdd0YsUnGidTNyuxdhQRhJVpDAvqw==} engines: {node: '>=12'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-vitals@5.2.0: resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} @@ -5436,7 +5521,7 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/hast@3.0.4': + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -6125,6 +6210,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -6640,10 +6727,81 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 @@ -6660,9 +6818,41 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-mdast@10.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + hast-util-phrasing: 3.0.1 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hast-util-whitespace: 3.0.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-hast: 13.2.1 + mdast-util-to-string: 4.0.0 + rehype-minify-whitespace: 6.0.2 + trim-trailing-lines: 2.1.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 hermes-estree@0.25.1: {} @@ -6676,6 +6866,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -7079,7 +7271,7 @@ snapshots: mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 @@ -7090,7 +7282,7 @@ snapshots: mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 @@ -7107,7 +7299,7 @@ snapshots: mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 @@ -7122,7 +7314,7 @@ snapshots: mdast-util-to-hast@13.2.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.3.0 devlop: 1.1.0 @@ -7594,6 +7786,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-expression-matcher@1.5.0: {} @@ -7749,7 +7945,7 @@ snapshots: react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.3): dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/react': 19.2.14 devlop: 1.1.0 @@ -7860,6 +8056,25 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-minify-whitespace@6.0.2: + dependencies: + '@types/hast': 3.0.5 + hast-util-minify-whitespace: 1.0.1 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-remark@10.0.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + hast-util-to-mdast: 10.1.2 + unified: 11.0.5 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -7882,7 +8097,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 @@ -8226,6 +8441,8 @@ snapshots: trim-lines@3.0.1: {} + trim-trailing-lines@2.1.0: {} + trough@2.2.0: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -8321,6 +8538,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -8418,6 +8640,11 @@ snapshots: versor@0.2.0: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -8428,6 +8655,8 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + web-namespaces@2.0.1: {} + web-vitals@5.2.0: {} webidl-conversions@7.0.0: {} diff --git a/public/_headers b/public/_headers index 788ce09..893ac56 100644 --- a/public/_headers +++ b/public/_headers @@ -4,6 +4,7 @@ Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() X-DNS-Prefetch-Control: on + Content-Signal: ai-train=yes, search=yes, ai-input=yes Content-Security-Policy: default-src 'none'; script-src 'self' 'unsafe-inline' https://platform.twitter.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.prod.website-files.com https://p16-sign-sg.tiktokcdn.com https://storage.googleapis.com https://scontent.cdninstagram.com https://*.cdninstagram.com https://pbs.twimg.com https://images.lumacdn.com; font-src 'self'; connect-src 'self' https://public-api.luma.com; frame-src https://luma.com https://platform.twitter.com; media-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; manifest-src 'self' https://:project.pages.dev/* diff --git a/src/app/builders/[slug]/page.tsx b/src/app/builders/[slug]/page.tsx index 58b8152..ca4d2d2 100644 --- a/src/app/builders/[slug]/page.tsx +++ b/src/app/builders/[slug]/page.tsx @@ -15,7 +15,10 @@ export async function generateMetadata({ return { title: `${builder.name}: ${builder.tagline}`, description: builder.quote ?? undefined, - alternates: { canonical: `/builders/${builder.slug}` }, + alternates: { + canonical: `/builders/${builder.slug}`, + types: { "text/markdown": `/builders/${builder.slug}.md` }, + }, openGraph: { title: `${builder.name}: ${builder.tagline} | Build Canada`, description: builder.quote ?? undefined, diff --git a/src/app/llms.txt/route.ts b/src/app/llms.txt/route.ts new file mode 100644 index 0000000..8956006 --- /dev/null +++ b/src/app/llms.txt/route.ts @@ -0,0 +1,77 @@ +import { fetchMemos } from "@/lib/api/memos"; +import { fetchPosts } from "@/lib/api/posts"; +import { fetchBuilders } from "@/lib/api/builders"; +import { getSiteConfig } from "@/lib/api/config"; + +// Curated index for LLM agents (https://llmstxt.org). Links point at the .md +// representations served via src/app/md/[...path]/route.ts. Upstream outages +// degrade to the static sections instead of failing the whole file. +export const revalidate = 3600; + +interface Entry { + title: string; + url: string; + note?: string | null; +} + +function section(heading: string, entries: Entry[]): string | null { + if (!entries.length) return null; + const lines = entries.map( + (e) => `- [${e.title}](${e.url})${e.note ? `: ${e.note}` : ""}`, + ); + return `## ${heading}\n\n${lines.join("\n")}`; +} + +export async function GET() { + const { siteUrl, orgDescription } = getSiteConfig(); + + const [memos, posts, builders] = await Promise.all([ + fetchMemos().catch(() => []), + fetchPosts().catch(() => []), + fetchBuilders().catch(() => []), + ]); + + const parts = [ + "# Build Canada", + `> ${orgDescription}`, + [ + `Build Canada publishes policy memos, posts, and profiles of great Canadian builders at ${siteUrl}.`, + "Every memo, post, and builder profile is available as clean markdown: append `.md` to its URL,", + "or request the HTML URL with an `Accept: text/markdown` header.", + `The homepage (${siteUrl}) and ${siteUrl}/about describe the organization and team.`, + ].join(" "), + section( + "Memos", + memos.map((m) => ({ + title: m.title, + url: `${siteUrl}/memos/${m.slug}.md`, + note: m.keyMessage1, + })), + ), + section( + "Posts", + posts.map((p) => ({ + title: p.title, + url: `${siteUrl}/posts/${p.slug}.md`, + note: p.keyMessage1, + })), + ), + section( + "Builders", + builders.map((b) => ({ + title: b.tagline ? `${b.name}: ${b.tagline}` : b.name, + url: `${siteUrl}/builders/${b.slug}.md`, + note: b.quote, + })), + ), + section("Other (HTML only)", [ + { title: "Government commitment tracker", url: `${siteUrl}/tracker`, note: "Tracks federal government commitments and their status" }, + { title: "Bills explorer", url: `${siteUrl}/bills`, note: "Plain-language summaries of Canadian legislation" }, + { title: "Projects", url: `${siteUrl}/projects`, note: "Tools and projects built by the Build Canada community" }, + ]), + ]; + + return new Response(parts.filter(Boolean).join("\n\n") + "\n", { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/src/app/md/[...path]/route.ts b/src/app/md/[...path]/route.ts new file mode 100644 index 0000000..ad729db --- /dev/null +++ b/src/app/md/[...path]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from "next/server"; +import { markdownBuilders } from "@/lib/markdown/content"; +import { markdownResponse } from "@/lib/markdown/document"; + +// Serves the markdown representation of content pages at /md/{type}/{slug}. +// Not linked directly: src/proxy.ts rewrites the two public shapes here — +// /memos/foo.md and `Accept: text/markdown` on /memos/foo (same for posts +// and builders). Upstream fetches are ISR-cached at the fetch layer +// (revalidate in src/lib/api/*), so this handler is a cheap pure transform. +// Draft content can't leak: apiFetch only sends a bearer token when one is +// explicitly set in the request scope, which never happens here. +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + + if (path.length !== 2) { + return new Response("Not found", { status: 404 }); + } + + const [type, slug] = path; + const build = markdownBuilders[type]; + if (!build) { + return new Response("Not found", { status: 404 }); + } + + let result; + try { + result = await build(decodeURIComponent(slug)); + } catch { + return new Response("Not found", { status: 404 }); + } + + if (result.kind === "redirect") { + return Response.redirect(new URL(result.location, req.url), 308); + } + + return markdownResponse(result.doc, { canonicalUrl: result.canonicalUrl }); +} diff --git a/src/app/memos/[slug]/page.tsx b/src/app/memos/[slug]/page.tsx index 2a02bdb..b3bab46 100644 --- a/src/app/memos/[slug]/page.tsx +++ b/src/app/memos/[slug]/page.tsx @@ -56,7 +56,10 @@ export async function generateMetadata({ return { title, description, - alternates: { canonical: `/memos/${memo.slug}` }, + alternates: { + canonical: `/memos/${memo.slug}`, + types: { "text/markdown": `/memos/${memo.slug}.md` }, + }, openGraph: { title, description, diff --git a/src/app/posts/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx index 1b472a4..ad83e13 100644 --- a/src/app/posts/[slug]/page.tsx +++ b/src/app/posts/[slug]/page.tsx @@ -39,7 +39,10 @@ export async function generateMetadata({ return { title, description, - alternates: { canonical: `/posts/${post.slug}` }, + alternates: { + canonical: `/posts/${post.slug}`, + types: { "text/markdown": `/posts/${post.slug}.md` }, + }, openGraph: { title, description, diff --git a/src/lib/markdown/content.ts b/src/lib/markdown/content.ts new file mode 100644 index 0000000..bf40700 --- /dev/null +++ b/src/lib/markdown/content.ts @@ -0,0 +1,160 @@ +import { fetchMemo } from "@/lib/api/memos"; +import { fetchPost } from "@/lib/api/posts"; +import { fetchBuilder } from "@/lib/api/builders"; +import { getSiteConfig } from "@/lib/api/config"; +import { buildGraph } from "@/lib/schemas/graph"; +import { generateArticleSchema } from "@/lib/schemas/generators/article"; +import { generateBreadcrumbSchema } from "@/lib/schemas/generators/breadcrumb"; +import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; +import { htmlToMarkdown } from "./html-to-markdown"; +import { buildMarkdownDocument, type MarkdownDocument } from "./document"; + +export type MarkdownContentResult = + | { kind: "document"; doc: MarkdownDocument; canonicalUrl: string } + | { kind: "redirect"; location: string }; + +export type MarkdownContentBuilder = (slug: string) => Promise; + +export async function memoMarkdown(slug: string): Promise { + const memo = await fetchMemo(slug); + if (memo.slug !== slug) { + return { kind: "redirect", location: `/memos/${memo.slug}.md` }; + } + + const config = getSiteConfig(); + const canonicalUrl = `${config.siteUrl}/memos/${memo.slug}`; + const convert = (html: string | null | undefined) => + html ? htmlToMarkdown(html, { baseUrl: config.siteUrl }) : Promise.resolve(""); + + const [body, appendix, supporters] = await Promise.all([ + convert(memo.body), + convert(memo.appendix), + convert(memo.supporters), + ]); + + const keyMessages = memo.keyMessages.length + ? "## Key Messages\n\n" + memo.keyMessages.map((m, i) => `${i + 1}. ${m}`).join("\n") + : null; + + const authorImage = + memo.author.name === "Build Canada" + ? "/assets/logos/buildcanada-logo-square.svg" + : memo.author.photo; + + const jsonLd = buildGraph( + generateOrganizationSchema(config), + generateArticleSchema( + { + title: memo.title, + slug: memo.slug, + keyMessage1: memo.keyMessage1, + seoImage: memo.seoImage, + publishedAt: memo.publishedAt ? new Date(memo.publishedAt) : null, + createdAt: new Date(memo.createdAt), + updatedAt: new Date(memo.updatedAt), + }, + { + name: memo.author.name, + title: memo.author.title, + photo: authorImage, + bio: memo.author.bio, + websiteUrl: memo.author.websiteUrl, + xUrl: memo.author.xUrl, + linkedinUrl: memo.author.linkedinUrl, + }, + config, + ), + generateBreadcrumbSchema(`/memos/${memo.slug}`, memo.title, config.siteUrl), + ); + + const doc = buildMarkdownDocument({ + frontmatter: { + title: memo.title, + description: memo.keyMessage1, + image: memo.seoImage, + author: memo.author.title ? `${memo.author.name}, ${memo.author.title}` : memo.author.name, + published: memo.publishedAt, + canonical: canonicalUrl, + }, + sections: [ + `# ${memo.title}`, + keyMessages, + body, + appendix ? `## Appendix\n\n${appendix}` : null, + supporters ? `## Supporters\n\n${supporters}` : null, + ], + jsonLd, + }); + + return { kind: "document", doc, canonicalUrl }; +} + +export async function postMarkdown(slug: string): Promise { + const post = await fetchPost(slug); + if (post.slug !== slug) { + return { kind: "redirect", location: `/posts/${post.slug}.md` }; + } + + const config = getSiteConfig(); + const canonicalUrl = `${config.siteUrl}/posts/${post.slug}`; + const body = post.body ? await htmlToMarkdown(post.body, { baseUrl: config.siteUrl }) : ""; + + const jsonLd = buildGraph( + generateOrganizationSchema(config), + generateBreadcrumbSchema(`/posts/${post.slug}`, post.title, config.siteUrl), + ); + + const doc = buildMarkdownDocument({ + frontmatter: { + title: post.title, + description: post.summary, + image: post.seoImage, + published: post.publishedAt, + canonical: canonicalUrl, + }, + sections: [ + `# ${post.title}`, + post.summary ? `> ${post.summary.replace(/\n/g, "\n> ")}` : null, + body, + ], + jsonLd, + }); + + return { kind: "document", doc, canonicalUrl }; +} + +export async function builderMarkdown(slug: string): Promise { + const builder = await fetchBuilder(slug); + if (builder.slug !== slug) { + return { kind: "redirect", location: `/builders/${builder.slug}.md` }; + } + + const config = getSiteConfig(); + const canonicalUrl = `${config.siteUrl}/builders/${builder.slug}`; + const body = builder.body ? await htmlToMarkdown(builder.body, { baseUrl: config.siteUrl }) : ""; + + const title = builder.tagline ? `${builder.name}: ${builder.tagline}` : builder.name; + + const doc = buildMarkdownDocument({ + frontmatter: { + title, + description: builder.quote, + image: builder.imageUrl, + canonical: canonicalUrl, + }, + sections: [ + `# ${title}`, + builder.quote ? `> "${builder.quote}"` : null, + body, + builder.author ? `_Written by ${builder.author}_` : null, + ], + }); + + return { kind: "document", doc, canonicalUrl }; +} + +export const markdownBuilders: Record = { + memos: memoMarkdown, + posts: postMarkdown, + builders: builderMarkdown, +}; diff --git a/src/lib/markdown/document.ts b/src/lib/markdown/document.ts new file mode 100644 index 0000000..abaf921 --- /dev/null +++ b/src/lib/markdown/document.ts @@ -0,0 +1,69 @@ +// Assembles the markdown documents served to LLM agents, mirroring the +// conventions of Cloudflare's "Markdown for Agents": YAML frontmatter, +// clean body, optional JSON-LD in a fenced block, and an x-markdown-tokens +// estimate header. +// https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/ + +export interface MarkdownFrontmatter { + title: string; + description?: string | null; + image?: string | null; + author?: string | null; + published?: string | null; + canonical: string; +} + +export interface MarkdownDocument { + body: string; + tokens: number; +} + +// JSON.stringify produces valid YAML scalars, so titles containing ":" or +// quotes can't break the frontmatter. +function frontmatterBlock(fm: MarkdownFrontmatter): string { + const lines = ["---"]; + const push = (key: string, value: string | null | undefined) => { + if (value) lines.push(`${key}: ${JSON.stringify(value)}`); + }; + push("title", fm.title); + push("description", fm.description); + push("image", fm.image); + push("author", fm.author); + push("published", fm.published); + push("canonical", fm.canonical); + lines.push("---"); + return lines.join("\n"); +} + +export function buildMarkdownDocument({ + frontmatter, + sections, + jsonLd, +}: { + frontmatter: MarkdownFrontmatter; + sections: Array; + jsonLd?: object; +}): MarkdownDocument { + const parts = [frontmatterBlock(frontmatter), ...sections.filter((s): s is string => Boolean(s?.trim()))]; + if (jsonLd) { + parts.push("---\n\n```json\n" + JSON.stringify(jsonLd, null, 2) + "\n```"); + } + const body = parts.join("\n\n") + "\n"; + return { body, tokens: Math.ceil(body.length / 4) }; +} + +export function markdownResponse( + doc: MarkdownDocument, + { canonicalUrl }: { canonicalUrl: string }, +): Response { + return new Response(doc.body, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "x-markdown-tokens": String(doc.tokens), + Vary: "Accept", + Link: `<${canonicalUrl}>; rel="canonical"`, + // The HTML page is the canonical document for search indexes. + "X-Robots-Tag": "noindex", + }, + }); +} diff --git a/src/lib/markdown/html-to-markdown.ts b/src/lib/markdown/html-to-markdown.ts new file mode 100644 index 0000000..f9c2782 --- /dev/null +++ b/src/lib/markdown/html-to-markdown.ts @@ -0,0 +1,42 @@ +import { unified, type Plugin } from "unified"; +import type { Root, Element } from "hast"; +import rehypeParse from "rehype-parse"; +import rehypeRemark from "rehype-remark"; +import remarkGfm from "remark-gfm"; +import remarkStringify from "remark-stringify"; + +// York Factory bodies reference site-relative assets (/assets/...); markdown +// consumers fetch documents out of browser context, so links must be absolute. +const rehypeAbsoluteUrls: Plugin<[{ baseUrl: string }], Root> = ({ baseUrl }) => { + const absolutize = (value: unknown): unknown => + typeof value === "string" && value.startsWith("/") && !value.startsWith("//") + ? `${baseUrl}${value}` + : value; + + const visit = (node: Root | Element) => { + if (node.type === "element") { + if (node.properties.href) node.properties.href = absolutize(node.properties.href) as string; + if (node.properties.src) node.properties.src = absolutize(node.properties.src) as string; + } + for (const child of node.children) { + if (child.type === "element") visit(child); + } + }; + + return visit; +}; + +export async function htmlToMarkdown( + html: string, + opts: { baseUrl: string }, +): Promise { + if (!html.trim()) return ""; + const file = await unified() + .use(rehypeParse, { fragment: true }) + .use(rehypeAbsoluteUrls, { baseUrl: opts.baseUrl }) + .use(rehypeRemark) + .use(remarkGfm) + .use(remarkStringify, { bullet: "-", emphasis: "_", rule: "-" }) + .process(html); + return String(file).trim(); +} diff --git a/src/proxy.ts b/src/proxy.ts index 7ea3a6c..424582d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -12,13 +12,25 @@ import { const FLAG = "dashboard"; // Next 16 allows a single proxy (formerly "middleware") entrypoint, so this file -// owns two unrelated concerns, dispatched by path: -// - /dashboard/* → gate the section behind the `dashboard` PostHog flag -// - /memos/*, /api/auth/me → silently renew an expired OAuth session +// owns three unrelated concerns, dispatched by path: +// - /dashboard/* → gate the section behind the `dashboard` PostHog flag +// - /memos/*, /posts/*, /builders/* → serve markdown to LLM agents (.md suffix or Accept header) +// - /memos/*, /api/auth/me → silently renew an expired OAuth session export const config = { - matcher: ["/memos/:path*", "/api/auth/me", "/dashboard", "/dashboard/:path*"], + matcher: [ + "/memos/:path*", + "/posts/:path*", + "/builders/:path*", + "/api/auth/me", + "/dashboard", + "/dashboard/:path*", + ], }; +// Detail pages of these content types have a markdown representation for LLM +// agents, served by src/app/md/[...path]/route.ts. Single-segment slugs only. +const MARKDOWN_CONTENT = /^\/(memos|posts|builders)\/([^/]+?)(\.md)?$/; + export async function proxy(req: NextRequest) { const { pathname } = req.nextUrl; @@ -26,7 +38,36 @@ export async function proxy(req: NextRequest) { return gateDashboard(req); } - return renewSession(req); + const markdown = resolveMarkdownRewrite(req); + if (markdown) return markdown; + + if (pathname.startsWith("/memos/") || pathname === "/api/auth/me") { + return renewSession(req); + } + return NextResponse.next(); +} + +// Rewrites both public markdown shapes to the internal /md route: +// /memos/foo.md → /md/memos/foo +// /memos/foo + `Accept: text/markdown` header → /md/memos/foo +// +// Caching note: the markdown responses emit `Vary: Accept`, but Next owns the +// Vary header on HTML page routes and drops any Accept we append (verified on +// `next start`), so the HTML variant can't advertise the negotiation. In +// practice this is safe: spec-compliant caches key the markdown responses on +// Accept, and caches that ignore Vary (e.g. Cloudflare's free tier) don't +// cache text/html by default. The explicit .md URL is the canonical +// mechanism; Accept negotiation is best-effort. +function resolveMarkdownRewrite(req: NextRequest): NextResponse | null { + const match = req.nextUrl.pathname.match(MARKDOWN_CONTENT); + if (!match) return null; + + const [, type, slug, mdSuffix] = match; + const wantsMarkdown = + Boolean(mdSuffix) || (req.headers.get("accept") ?? "").includes("text/markdown"); + if (!wantsMarkdown) return null; + + return NextResponse.rewrite(new URL(`/md/${type}/${slug}`, req.url)); } // Gate the entire /dashboard section behind the `dashboard` PostHog flag.