diff --git a/package-lock.json b/package-lock.json
index a2d21d6f..826485ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -113,6 +113,56 @@
"lru-cache": "^10.4.3"
}
},
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "license": "MIT"
+ },
"node_modules/@atproto-labs/did-resolver": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.13.tgz",
@@ -1616,6 +1666,37 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@bramus/specificity/node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/@bramus/specificity/node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
"node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz",
@@ -1831,6 +1912,17 @@
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
"dev": true
},
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild-kit/core-utils": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
@@ -2872,6 +2964,23 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
+ "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@expo/spawn-async": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz",
@@ -4760,6 +4869,15 @@
"node": ">=12.0.0"
}
},
+ "node_modules/@mozilla/readability": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
+ "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
@@ -5065,6 +5183,7 @@
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -12129,6 +12248,13 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
+ "node_modules/@types/mozilla__readability": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@types/mozilla__readability/-/mozilla__readability-0.4.2.tgz",
+ "integrity": "sha512-mXHoZZ/Knps7GABAG9D512yDLCMQNOvQBqzIZpFJnRJveHc528rltnXh6EZTi1RiB1I2Z8yV35Dzs6DhE2WaYw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -12330,7 +12456,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
- "dev": true
+ "devOptional": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
@@ -14352,6 +14478,15 @@
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -16231,8 +16366,7 @@
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
- "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
- "dev": true
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
@@ -16733,10 +16867,10 @@
}
},
"node_modules/dompurify": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
- "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
- "dev": true,
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
+ "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -20689,8 +20823,7 @@
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
- "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
- "dev": true
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
},
"node_modules/is-promise": {
"version": "4.0.0",
@@ -20905,6 +21038,372 @@
"resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz",
"integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="
},
+ "node_modules/isomorphic-dompurify": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.11.0.tgz",
+ "integrity": "sha512-il0sNhLnfawc6vKoMqnZX1JXzEzw0pP3DZWg7mM7VJNJ8cq6DCxeAjEcfjTazyBF8dkUn+rBsBPS5NQs4ZaI3g==",
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "^3.4.1",
+ "jsdom": "^29.1.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/@csstools/css-calc": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
+ "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/@csstools/css-color-parser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
+ "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
+ "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/lru-cache": {
+ "version": "11.3.5",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
+ "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/isomorphic-dompurify/node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/tldts": {
+ "version": "7.0.29",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.29.tgz",
+ "integrity": "sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.29"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/tldts-core": {
+ "version": "7.0.29",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.29.tgz",
+ "integrity": "sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==",
+ "license": "MIT"
+ },
+ "node_modules/isomorphic-dompurify/node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/undici": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/isomorphic-dompurify/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -21730,6 +22229,7 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
@@ -26425,7 +26925,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -27210,6 +27709,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-in-the-middle": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
@@ -27610,7 +28118,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
- "dev": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
@@ -29284,8 +29791,7 @@
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
- "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
- "dev": true
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
"node_modules/synckit": {
"version": "0.11.11",
@@ -31385,7 +31891,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
- "dev": true,
"dependencies": {
"xml-name-validator": "^5.0.0"
},
@@ -31815,7 +32320,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
- "dev": true,
"engines": {
"node": ">=18"
}
@@ -31848,8 +32352,7 @@
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
- "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
- "dev": true
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"node_modules/xtend": {
"version": "4.0.2",
@@ -32116,6 +32619,7 @@
"@mantine/notifications": "9.0.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
+ "@mozilla/readability": "^0.6.0",
"@next/mdx": "16.2.3",
"@semble/types": "*",
"@tanstack/react-query": "^5.85.5",
@@ -32124,6 +32628,7 @@
"capnweb": "^0.6.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
+ "isomorphic-dompurify": "^3.11.0",
"next": "16.2.3",
"react": "19.2.5",
"react-dom": "19.2.5",
@@ -32146,6 +32651,8 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.332",
+ "@types/jsdom": "^21.1.7",
+ "@types/mozilla__readability": "^0.4.2",
"@types/node": "^20.10.4",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
diff --git a/src/webapp/app/(dashboard)/url/layout.tsx b/src/webapp/app/(dashboard)/url/layout.tsx
index dd802c37..18239d17 100644
--- a/src/webapp/app/(dashboard)/url/layout.tsx
+++ b/src/webapp/app/(dashboard)/url/layout.tsx
@@ -1,6 +1,7 @@
import BackButton from '@/components/navigation/backButton/BackButton';
import Header from '@/components/navigation/header/Header';
-import { Fragment } from 'react';
+import ReaderHeaderButton from '@/features/reader/components/ReaderHeaderButton/ReaderHeaderButton';
+import { Fragment, Suspense } from 'react';
interface Props {
children: React.ReactNode;
@@ -11,6 +12,9 @@ export default async function Layout(props: Props) {
{props.children}
diff --git a/src/webapp/app/api/feature-flags/route.ts b/src/webapp/app/api/feature-flags/route.ts
index 95361cc1..dd6bf8c6 100644
--- a/src/webapp/app/api/feature-flags/route.ts
+++ b/src/webapp/app/api/feature-flags/route.ts
@@ -17,6 +17,7 @@ export async function GET() {
following: false,
connections: false,
graphView: false,
+ readerMode: false,
});
}
}
diff --git a/src/webapp/app/api/reader/route.ts b/src/webapp/app/api/reader/route.ts
new file mode 100644
index 00000000..b836e3ff
--- /dev/null
+++ b/src/webapp/app/api/reader/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { JSDOM } from 'jsdom';
+import { Readability } from '@mozilla/readability';
+import DOMPurify from 'isomorphic-dompurify';
+
+export interface ReaderContent {
+ title: string | null;
+ content: string;
+ byline: string | null;
+ siteName: string | null;
+ excerpt: string | null;
+}
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = request.nextUrl;
+ const url = searchParams.get('url');
+
+ if (!url) {
+ return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
+ }
+
+ let targetUrl: URL;
+ try {
+ targetUrl = new URL(url);
+ } catch {
+ return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
+ }
+
+ let html: string;
+ try {
+ const response = await fetch(targetUrl.toString(), {
+ headers: {
+ // Mimic a real browser to avoid blocks
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ },
+ // 10 second timeout
+ signal: AbortSignal.timeout(10_000),
+ });
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: `Failed to fetch URL: ${response.status} ${response.statusText}` },
+ { status: 502 },
+ );
+ }
+
+ html = await response.text();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ return NextResponse.json({ error: `Failed to fetch URL: ${message}` }, { status: 502 });
+ }
+
+ // Parse with jsdom, passing the URL so Readability can resolve relative links
+ const dom = new JSDOM(html, { url: targetUrl.toString() });
+ const reader = new Readability(dom.window.document);
+ const article = reader.parse();
+
+ if (!article) {
+ return NextResponse.json(
+ { error: 'Could not extract readable content from this page' },
+ { status: 422 },
+ );
+ }
+
+ // Sanitize the extracted HTML to prevent XSS
+ const sanitizedContent = DOMPurify.sanitize(article.content ?? '', {
+ USE_PROFILES: { html: true },
+ // Allow common article elements but strip scripts/iframes/etc.
+ FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input', 'button'],
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
+ });
+
+ const result: ReaderContent = {
+ title: article.title ?? null,
+ content: sanitizedContent,
+ byline: article.byline ?? null,
+ siteName: article.siteName ?? null,
+ excerpt: article.excerpt ?? null,
+ };
+
+ return NextResponse.json(result);
+}
diff --git a/src/webapp/components/navigation/header/Header.tsx b/src/webapp/components/navigation/header/Header.tsx
index a4c43833..7e0342fe 100644
--- a/src/webapp/components/navigation/header/Header.tsx
+++ b/src/webapp/components/navigation/header/Header.tsx
@@ -1,10 +1,10 @@
import { Box, Divider, Group, Paper, Text } from '@mantine/core';
import NavbarToggle from '../NavbarToggle';
-import { ReactElement } from 'react';
+import { ReactNode } from 'react';
interface Props {
title?: string;
- children?: ReactElement;
+ children?: ReactNode;
}
export default function Header(props: Props) {
diff --git a/src/webapp/features/reader/components/ReaderButton/ReaderButton.module.css b/src/webapp/features/reader/components/ReaderButton/ReaderButton.module.css
new file mode 100644
index 00000000..7e37d4e0
--- /dev/null
+++ b/src/webapp/features/reader/components/ReaderButton/ReaderButton.module.css
@@ -0,0 +1,135 @@
+/* ── Bottom bar ── */
+
+.bottomBar {
+ position: relative;
+ flex-shrink: 0;
+ background: color-mix(in srgb, var(--mantine-color-body) 85%, transparent);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border-top: 1px solid var(--mantine-color-gray-2);
+}
+
+/* ── Article body typography ── */
+
+.readerContent :global(p) {
+ margin: 0 0 1.3em;
+}
+
+.readerContent :global(h1),
+.readerContent :global(h2),
+.readerContent :global(h3),
+.readerContent :global(h4),
+.readerContent :global(h5),
+.readerContent :global(h6) {
+ font-weight: 700;
+ line-height: 1.3;
+ margin: 1.75em 0 0.5em;
+}
+
+.readerContent :global(h1) { font-size: 1.6em; }
+.readerContent :global(h2) { font-size: 1.35em; }
+.readerContent :global(h3) { font-size: 1.15em; }
+.readerContent :global(h4) { font-size: 1.0em; }
+
+.readerContent :global(a) {
+ color: var(--mantine-color-blue-6);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+.readerContent :global(a:hover) {
+ color: var(--mantine-color-blue-8);
+}
+
+.readerContent :global(blockquote) {
+ border-left: 3px solid var(--mantine-color-gray-4);
+ margin: 1.5em 0;
+ padding: 0.4em 1.25em;
+ color: var(--mantine-color-dimmed);
+ font-style: italic;
+}
+
+.readerContent :global(pre) {
+ background: var(--mantine-color-gray-0);
+ border: 1px solid var(--mantine-color-gray-2);
+ border-radius: 8px;
+ padding: 1em 1.25em;
+ overflow-x: auto;
+ font-size: 0.875em;
+ line-height: 1.6;
+ margin: 1.5em 0;
+}
+
+.readerContent :global(code) {
+ background: var(--mantine-color-gray-1);
+ border-radius: 4px;
+ padding: 0.15em 0.4em;
+ font-size: 0.875em;
+ font-family: var(--mantine-font-family-monospace, monospace);
+}
+
+.readerContent :global(pre code) {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.readerContent :global(ul),
+.readerContent :global(ol) {
+ padding-left: 1.75em;
+ margin: 0 0 1.3em;
+}
+
+.readerContent :global(li) {
+ margin-bottom: 0.4em;
+}
+
+.readerContent :global(li > ul),
+.readerContent :global(li > ol) {
+ margin-top: 0.3em;
+ margin-bottom: 0;
+}
+
+.readerContent :global(img) {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+ margin: 1.5em 0;
+ display: block;
+}
+
+.readerContent :global(figure) {
+ margin: 1.75em 0;
+}
+
+.readerContent :global(figcaption) {
+ font-size: 0.8125em;
+ color: var(--mantine-color-dimmed);
+ text-align: center;
+ margin-top: 0.6em;
+}
+
+.readerContent :global(hr) {
+ border: none;
+ border-top: 1px solid var(--mantine-color-gray-3);
+ margin: 2em 0;
+}
+
+.readerContent :global(table) {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.5em 0;
+ font-size: 0.9375em;
+}
+
+.readerContent :global(th),
+.readerContent :global(td) {
+ border: 1px solid var(--mantine-color-gray-3);
+ padding: 0.5em 0.75em;
+ text-align: left;
+}
+
+.readerContent :global(th) {
+ background: var(--mantine-color-gray-0);
+ font-weight: 600;
+}
diff --git a/src/webapp/features/reader/components/ReaderButton/ReaderButton.tsx b/src/webapp/features/reader/components/ReaderButton/ReaderButton.tsx
new file mode 100644
index 00000000..501e38e2
--- /dev/null
+++ b/src/webapp/features/reader/components/ReaderButton/ReaderButton.tsx
@@ -0,0 +1,273 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ ActionIcon,
+ Drawer,
+ Tooltip,
+ Stack,
+ Title,
+ Text,
+ Alert,
+ ScrollArea,
+ Box,
+ Group,
+ CloseButton,
+ Skeleton,
+ Button,
+ Anchor,
+ Container,
+} from '@mantine/core';
+import { TbBook2, TbExternalLink, TbLink, TbLinkOff } from 'react-icons/tb';
+import { MdErrorOutline } from 'react-icons/md';
+import { fetchReaderContent, type ReaderState } from '../../lib/fetchReaderContent';
+import styles from './ReaderButton.module.css';
+
+interface Props {
+ url: string;
+}
+
+const FONT_SIZE_MIN = 14;
+const FONT_SIZE_MAX = 24;
+const FONT_SIZE_STEP = 2;
+const FONT_SIZE_DEFAULT = 17;
+
+function ArticleSkeleton() {
+ return (
+
+
+
+
+
+
+ {[100, 95, 88, 100, 92, 78, 100, 96, 83, 60].map((w, i) => (
+
+ ))}
+
+
+ {[100, 91, 100, 85, 100, 72].map((w, i) => (
+
+ ))}
+
+
+ );
+}
+
+export default function ReaderButton({ url }: Props) {
+ const [opened, setOpened] = useState(false);
+ const [state, setState] = useState({ status: 'idle' });
+ const [fontSize, setFontSize] = useState(FONT_SIZE_DEFAULT);
+ const [removeLinks, setRemoveLinks] = useState(false);
+
+ const displayedHtml =
+ state.status === 'success'
+ ? removeLinks
+ ? state.data.content.replace(/]*>([\s\S]*?)<\/a>/g, '$1')
+ : state.data.content
+ : '';
+
+ function openReader() {
+ setOpened(true);
+ if (state.status === 'success' || state.status === 'loading') return;
+ fetchReaderContent(url, setState);
+ }
+
+ function handleClose() {
+ setOpened(false);
+ }
+
+ return (
+ <>
+ }
+ onClick={openReader}
+ aria-label="Open reader mode"
+ >
+ Reader
+
+
+
+ {/* ── Scrollable article area ── */}
+
+
+ {state.status === 'loading' && }
+
+ {state.status === 'error' && (
+
+ }
+ title="Could not load reader"
+ color="red"
+ variant="light"
+ >
+ {state.message}
+
+
+
+ )}
+
+ {state.status === 'success' && (
+
+ {state.data.siteName && (
+
+ {state.data.siteName}
+
+ )}
+
+ {state.data.title && (
+
+ {state.data.title}
+
+ )}
+
+ {state.data.byline && (
+
+ {state.data.byline}
+
+ )}
+
+
+
+ )}
+
+
+
+ {/* ── Sticky bottom bar ── */}
+
+
+ {/* Left: font size + link toggle */}
+
+
+
+ setFontSize((s) =>
+ Math.max(FONT_SIZE_MIN, s - FONT_SIZE_STEP),
+ )
+ }
+ disabled={fontSize <= FONT_SIZE_MIN}
+ aria-label="Decrease font size"
+ >
+
+ A-
+
+
+
+
+
+ setFontSize((s) =>
+ Math.min(FONT_SIZE_MAX, s + FONT_SIZE_STEP),
+ )
+ }
+ disabled={fontSize >= FONT_SIZE_MAX}
+ aria-label="Increase font size"
+ >
+
+ A+
+
+
+
+
+ setRemoveLinks((v) => !v)}
+ aria-label={removeLinks ? 'Show links' : 'Remove links'}
+ >
+ {removeLinks ? : }
+
+
+
+
+ {/* Right: open original + close */}
+
+
+ }
+ >
+ Open original
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/webapp/features/reader/components/ReaderHeaderButton/ReaderHeaderButton.tsx b/src/webapp/features/reader/components/ReaderHeaderButton/ReaderHeaderButton.tsx
new file mode 100644
index 00000000..c1b0a401
--- /dev/null
+++ b/src/webapp/features/reader/components/ReaderHeaderButton/ReaderHeaderButton.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+import { useFeatureFlags } from '@/lib/clientFeatureFlags';
+import ReaderButton from '../ReaderButton/ReaderButton';
+
+export default function ReaderHeaderButton() {
+ const { data: featureFlags } = useFeatureFlags();
+ const searchParams = useSearchParams();
+ const rawId = searchParams.get('id');
+
+ if (!featureFlags?.readerMode || !rawId) return null;
+
+ const url = decodeURIComponent(rawId);
+ return ;
+}
diff --git a/src/webapp/features/reader/lib/fetchReaderContent.ts b/src/webapp/features/reader/lib/fetchReaderContent.ts
new file mode 100644
index 00000000..9a15de7e
--- /dev/null
+++ b/src/webapp/features/reader/lib/fetchReaderContent.ts
@@ -0,0 +1,27 @@
+import type { ReaderContent } from '@/app/api/reader/route';
+
+export type ReaderState =
+ | { status: 'idle' }
+ | { status: 'loading' }
+ | { status: 'error'; message: string }
+ | { status: 'success'; data: ReaderContent };
+
+export async function fetchReaderContent(
+ url: string,
+ setState: (state: ReaderState) => void,
+): Promise {
+ setState({ status: 'loading' });
+ try {
+ const res = await fetch(`/api/reader?url=${encodeURIComponent(url)}`);
+ const json = await res.json();
+ if (!res.ok) {
+ setState({ status: 'error', message: json.error ?? 'Unknown error' });
+ return;
+ }
+ setState({ status: 'success', data: json as ReaderContent });
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : 'Failed to load reader content';
+ setState({ status: 'error', message });
+ }
+}
diff --git a/src/webapp/lib/clientFeatureFlags.ts b/src/webapp/lib/clientFeatureFlags.ts
index 6dbd5177..94f76156 100644
--- a/src/webapp/lib/clientFeatureFlags.ts
+++ b/src/webapp/lib/clientFeatureFlags.ts
@@ -11,6 +11,7 @@ interface FeatureFlags {
following: boolean;
connections: boolean;
graphView: boolean;
+ readerMode: boolean;
}
async function fetchFeatureFlags(): Promise {
diff --git a/src/webapp/lib/serverFeatureFlags.ts b/src/webapp/lib/serverFeatureFlags.ts
index 966ff1ce..81f5c35a 100644
--- a/src/webapp/lib/serverFeatureFlags.ts
+++ b/src/webapp/lib/serverFeatureFlags.ts
@@ -26,5 +26,6 @@ export async function getServerFeatureFlags() {
following: true,
connections: true,
graphView: show,
+ readerMode: showForTeam,
};
}
diff --git a/src/webapp/next-env.d.ts b/src/webapp/next-env.d.ts
index 9edff1c7..c4b7818f 100644
--- a/src/webapp/next-env.d.ts
+++ b/src/webapp/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/src/webapp/package.json b/src/webapp/package.json
index 14aa3a0c..07cc7f20 100644
--- a/src/webapp/package.json
+++ b/src/webapp/package.json
@@ -38,6 +38,7 @@
"@mantine/notifications": "9.0.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
+ "@mozilla/readability": "^0.6.0",
"@next/mdx": "16.2.3",
"@semble/types": "*",
"@tanstack/react-query": "^5.85.5",
@@ -46,6 +47,7 @@
"capnweb": "^0.6.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
+ "isomorphic-dompurify": "^3.11.0",
"next": "16.2.3",
"react": "19.2.5",
"react-dom": "19.2.5",
@@ -57,36 +59,38 @@
"web-haptics": "^0.0.6"
},
"devDependencies": {
+ "@chromatic-com/storybook": "^5.1.2",
"@playwright/test": "^1.59.1",
+ "@storybook/addon-a11y": "^10.3.6",
+ "@storybook/addon-docs": "^10.3.6",
+ "@storybook/addon-onboarding": "^10.3.6",
+ "@storybook/addon-vitest": "^10.3.6",
+ "@storybook/nextjs-vite": "^10.3.6",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.332",
+ "@types/jsdom": "^21.1.7",
+ "@types/mozilla__readability": "^0.4.2",
"@types/node": "^20.10.4",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+ "@vitest/browser-playwright": "^4.1.5",
+ "@vitest/coverage-v8": "^4.1.5",
"autoprefixer": "^10.4.16",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.3",
+ "eslint-plugin-storybook": "^10.3.6",
"jsdom": "^26.1.0",
"plasmo": "^0.90.5",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
- "typescript": "^5.3.3",
- "vitest": "^4.1.5",
"storybook": "^10.3.6",
- "@storybook/nextjs-vite": "^10.3.6",
- "@chromatic-com/storybook": "^5.1.2",
- "@storybook/addon-vitest": "^10.3.6",
- "@storybook/addon-a11y": "^10.3.6",
- "@storybook/addon-docs": "^10.3.6",
- "@storybook/addon-onboarding": "^10.3.6",
+ "typescript": "^5.3.3",
"vite": "^8.0.10",
- "eslint-plugin-storybook": "^10.3.6",
- "@vitest/browser-playwright": "^4.1.5",
- "@vitest/coverage-v8": "^4.1.5"
+ "vitest": "^4.1.5"
},
"overrides": {
"@types/react": "19.2.14",