This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: reader mode

+1086 -32
+524 -17
package-lock.json
··· 113 113 "lru-cache": "^10.4.3" 114 114 } 115 115 }, 116 + "node_modules/@asamuzakjp/dom-selector": { 117 + "version": "7.1.1", 118 + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", 119 + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", 120 + "license": "MIT", 121 + "dependencies": { 122 + "@asamuzakjp/generational-cache": "^1.0.1", 123 + "@asamuzakjp/nwsapi": "^2.3.9", 124 + "bidi-js": "^1.0.3", 125 + "css-tree": "^3.2.1", 126 + "is-potential-custom-element-name": "^1.0.1" 127 + }, 128 + "engines": { 129 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 130 + } 131 + }, 132 + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { 133 + "version": "3.2.1", 134 + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", 135 + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", 136 + "license": "MIT", 137 + "dependencies": { 138 + "mdn-data": "2.27.1", 139 + "source-map-js": "^1.2.1" 140 + }, 141 + "engines": { 142 + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" 143 + } 144 + }, 145 + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { 146 + "version": "2.27.1", 147 + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", 148 + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", 149 + "license": "CC0-1.0" 150 + }, 151 + "node_modules/@asamuzakjp/generational-cache": { 152 + "version": "1.0.1", 153 + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", 154 + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", 155 + "license": "MIT", 156 + "engines": { 157 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 158 + } 159 + }, 160 + "node_modules/@asamuzakjp/nwsapi": { 161 + "version": "2.3.9", 162 + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", 163 + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", 164 + "license": "MIT" 165 + }, 116 166 "node_modules/@atproto-labs/did-resolver": { 117 167 "version": "0.1.13", 118 168 "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.13.tgz", ··· 1616 1666 "dev": true, 1617 1667 "license": "MIT" 1618 1668 }, 1669 + "node_modules/@bramus/specificity": { 1670 + "version": "2.4.2", 1671 + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", 1672 + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", 1673 + "license": "MIT", 1674 + "dependencies": { 1675 + "css-tree": "^3.0.0" 1676 + }, 1677 + "bin": { 1678 + "specificity": "bin/cli.js" 1679 + } 1680 + }, 1681 + "node_modules/@bramus/specificity/node_modules/css-tree": { 1682 + "version": "3.2.1", 1683 + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", 1684 + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", 1685 + "license": "MIT", 1686 + "dependencies": { 1687 + "mdn-data": "2.27.1", 1688 + "source-map-js": "^1.2.1" 1689 + }, 1690 + "engines": { 1691 + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" 1692 + } 1693 + }, 1694 + "node_modules/@bramus/specificity/node_modules/mdn-data": { 1695 + "version": "2.27.1", 1696 + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", 1697 + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", 1698 + "license": "CC0-1.0" 1699 + }, 1619 1700 "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { 1620 1701 "version": "2.2.0", 1621 1702 "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", ··· 1830 1911 "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", 1831 1912 "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", 1832 1913 "dev": true 1914 + }, 1915 + "node_modules/@emnapi/wasi-threads": { 1916 + "version": "1.2.1", 1917 + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", 1918 + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", 1919 + "dev": true, 1920 + "license": "MIT", 1921 + "optional": true, 1922 + "dependencies": { 1923 + "tslib": "^2.4.0" 1924 + } 1833 1925 }, 1834 1926 "node_modules/@esbuild-kit/core-utils": { 1835 1927 "version": "3.3.2", ··· 2870 2962 }, 2871 2963 "engines": { 2872 2964 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 2965 + } 2966 + }, 2967 + "node_modules/@exodus/bytes": { 2968 + "version": "1.15.0", 2969 + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", 2970 + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", 2971 + "license": "MIT", 2972 + "engines": { 2973 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 2974 + }, 2975 + "peerDependencies": { 2976 + "@noble/hashes": "^1.8.0 || ^2.0.0" 2977 + }, 2978 + "peerDependenciesMeta": { 2979 + "@noble/hashes": { 2980 + "optional": true 2981 + } 2873 2982 } 2874 2983 }, 2875 2984 "node_modules/@expo/spawn-async": { ··· 4760 4869 "node": ">=12.0.0" 4761 4870 } 4762 4871 }, 4872 + "node_modules/@mozilla/readability": { 4873 + "version": "0.6.0", 4874 + "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", 4875 + "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==", 4876 + "license": "Apache-2.0", 4877 + "engines": { 4878 + "node": ">=14.0.0" 4879 + } 4880 + }, 4763 4881 "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { 4764 4882 "version": "3.0.3", 4765 4883 "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", ··· 5065 5183 "version": "1.8.0", 5066 5184 "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", 5067 5185 "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", 5186 + "peer": true, 5068 5187 "engines": { 5069 5188 "node": "^14.21.3 || >=16" 5070 5189 }, ··· 12129 12248 "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", 12130 12249 "dev": true 12131 12250 }, 12251 + "node_modules/@types/mozilla__readability": { 12252 + "version": "0.4.2", 12253 + "resolved": "https://registry.npmjs.org/@types/mozilla__readability/-/mozilla__readability-0.4.2.tgz", 12254 + "integrity": "sha512-mXHoZZ/Knps7GABAG9D512yDLCMQNOvQBqzIZpFJnRJveHc528rltnXh6EZTi1RiB1I2Z8yV35Dzs6DhE2WaYw==", 12255 + "dev": true, 12256 + "license": "MIT" 12257 + }, 12132 12258 "node_modules/@types/ms": { 12133 12259 "version": "2.1.0", 12134 12260 "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", ··· 12330 12456 "version": "2.0.7", 12331 12457 "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", 12332 12458 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 12333 - "dev": true 12459 + "devOptional": true 12334 12460 }, 12335 12461 "node_modules/@types/unist": { 12336 12462 "version": "3.0.3", ··· 14352 14478 "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" 14353 14479 } 14354 14480 }, 14481 + "node_modules/bidi-js": { 14482 + "version": "1.0.3", 14483 + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", 14484 + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", 14485 + "license": "MIT", 14486 + "dependencies": { 14487 + "require-from-string": "^2.0.2" 14488 + } 14489 + }, 14355 14490 "node_modules/binary-extensions": { 14356 14491 "version": "2.3.0", 14357 14492 "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", ··· 16231 16366 "node_modules/decimal.js": { 16232 16367 "version": "10.6.0", 16233 16368 "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", 16234 - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", 16235 - "dev": true 16369 + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" 16236 16370 }, 16237 16371 "node_modules/decode-named-character-reference": { 16238 16372 "version": "1.2.0", ··· 16733 16867 } 16734 16868 }, 16735 16869 "node_modules/dompurify": { 16736 - "version": "3.3.1", 16737 - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", 16738 - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", 16739 - "dev": true, 16870 + "version": "3.4.2", 16871 + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", 16872 + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", 16873 + "license": "(MPL-2.0 OR Apache-2.0)", 16740 16874 "optionalDependencies": { 16741 16875 "@types/trusted-types": "^2.0.7" 16742 16876 } ··· 20689 20823 "node_modules/is-potential-custom-element-name": { 20690 20824 "version": "1.0.1", 20691 20825 "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", 20692 - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", 20693 - "dev": true 20826 + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" 20694 20827 }, 20695 20828 "node_modules/is-promise": { 20696 20829 "version": "4.0.0", ··· 20905 21038 "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 20906 21039 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 20907 21040 }, 21041 + "node_modules/isomorphic-dompurify": { 21042 + "version": "3.11.0", 21043 + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.11.0.tgz", 21044 + "integrity": "sha512-il0sNhLnfawc6vKoMqnZX1JXzEzw0pP3DZWg7mM7VJNJ8cq6DCxeAjEcfjTazyBF8dkUn+rBsBPS5NQs4ZaI3g==", 21045 + "license": "MIT", 21046 + "dependencies": { 21047 + "dompurify": "^3.4.1", 21048 + "jsdom": "^29.1.0" 21049 + }, 21050 + "engines": { 21051 + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" 21052 + } 21053 + }, 21054 + "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/css-color": { 21055 + "version": "5.1.11", 21056 + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", 21057 + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", 21058 + "license": "MIT", 21059 + "dependencies": { 21060 + "@asamuzakjp/generational-cache": "^1.0.1", 21061 + "@csstools/css-calc": "^3.2.0", 21062 + "@csstools/css-color-parser": "^4.1.0", 21063 + "@csstools/css-parser-algorithms": "^4.0.0", 21064 + "@csstools/css-tokenizer": "^4.0.0" 21065 + }, 21066 + "engines": { 21067 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 21068 + } 21069 + }, 21070 + "node_modules/isomorphic-dompurify/node_modules/@csstools/color-helpers": { 21071 + "version": "6.0.2", 21072 + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", 21073 + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", 21074 + "funding": [ 21075 + { 21076 + "type": "github", 21077 + "url": "https://github.com/sponsors/csstools" 21078 + }, 21079 + { 21080 + "type": "opencollective", 21081 + "url": "https://opencollective.com/csstools" 21082 + } 21083 + ], 21084 + "license": "MIT-0", 21085 + "engines": { 21086 + "node": ">=20.19.0" 21087 + } 21088 + }, 21089 + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-calc": { 21090 + "version": "3.2.0", 21091 + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", 21092 + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", 21093 + "funding": [ 21094 + { 21095 + "type": "github", 21096 + "url": "https://github.com/sponsors/csstools" 21097 + }, 21098 + { 21099 + "type": "opencollective", 21100 + "url": "https://opencollective.com/csstools" 21101 + } 21102 + ], 21103 + "license": "MIT", 21104 + "engines": { 21105 + "node": ">=20.19.0" 21106 + }, 21107 + "peerDependencies": { 21108 + "@csstools/css-parser-algorithms": "^4.0.0", 21109 + "@csstools/css-tokenizer": "^4.0.0" 21110 + } 21111 + }, 21112 + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-color-parser": { 21113 + "version": "4.1.0", 21114 + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", 21115 + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", 21116 + "funding": [ 21117 + { 21118 + "type": "github", 21119 + "url": "https://github.com/sponsors/csstools" 21120 + }, 21121 + { 21122 + "type": "opencollective", 21123 + "url": "https://opencollective.com/csstools" 21124 + } 21125 + ], 21126 + "license": "MIT", 21127 + "dependencies": { 21128 + "@csstools/color-helpers": "^6.0.2", 21129 + "@csstools/css-calc": "^3.2.0" 21130 + }, 21131 + "engines": { 21132 + "node": ">=20.19.0" 21133 + }, 21134 + "peerDependencies": { 21135 + "@csstools/css-parser-algorithms": "^4.0.0", 21136 + "@csstools/css-tokenizer": "^4.0.0" 21137 + } 21138 + }, 21139 + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-parser-algorithms": { 21140 + "version": "4.0.0", 21141 + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", 21142 + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", 21143 + "funding": [ 21144 + { 21145 + "type": "github", 21146 + "url": "https://github.com/sponsors/csstools" 21147 + }, 21148 + { 21149 + "type": "opencollective", 21150 + "url": "https://opencollective.com/csstools" 21151 + } 21152 + ], 21153 + "license": "MIT", 21154 + "peer": true, 21155 + "engines": { 21156 + "node": ">=20.19.0" 21157 + }, 21158 + "peerDependencies": { 21159 + "@csstools/css-tokenizer": "^4.0.0" 21160 + } 21161 + }, 21162 + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-syntax-patches-for-csstree": { 21163 + "version": "1.1.3", 21164 + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", 21165 + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", 21166 + "funding": [ 21167 + { 21168 + "type": "github", 21169 + "url": "https://github.com/sponsors/csstools" 21170 + }, 21171 + { 21172 + "type": "opencollective", 21173 + "url": "https://opencollective.com/csstools" 21174 + } 21175 + ], 21176 + "license": "MIT-0", 21177 + "peerDependencies": { 21178 + "css-tree": "^3.2.1" 21179 + }, 21180 + "peerDependenciesMeta": { 21181 + "css-tree": { 21182 + "optional": true 21183 + } 21184 + } 21185 + }, 21186 + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-tokenizer": { 21187 + "version": "4.0.0", 21188 + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", 21189 + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", 21190 + "funding": [ 21191 + { 21192 + "type": "github", 21193 + "url": "https://github.com/sponsors/csstools" 21194 + }, 21195 + { 21196 + "type": "opencollective", 21197 + "url": "https://opencollective.com/csstools" 21198 + } 21199 + ], 21200 + "license": "MIT", 21201 + "peer": true, 21202 + "engines": { 21203 + "node": ">=20.19.0" 21204 + } 21205 + }, 21206 + "node_modules/isomorphic-dompurify/node_modules/css-tree": { 21207 + "version": "3.2.1", 21208 + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", 21209 + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", 21210 + "license": "MIT", 21211 + "peer": true, 21212 + "dependencies": { 21213 + "mdn-data": "2.27.1", 21214 + "source-map-js": "^1.2.1" 21215 + }, 21216 + "engines": { 21217 + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" 21218 + } 21219 + }, 21220 + "node_modules/isomorphic-dompurify/node_modules/data-urls": { 21221 + "version": "7.0.0", 21222 + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", 21223 + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", 21224 + "license": "MIT", 21225 + "dependencies": { 21226 + "whatwg-mimetype": "^5.0.0", 21227 + "whatwg-url": "^16.0.0" 21228 + }, 21229 + "engines": { 21230 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 21231 + } 21232 + }, 21233 + "node_modules/isomorphic-dompurify/node_modules/entities": { 21234 + "version": "8.0.0", 21235 + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", 21236 + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", 21237 + "license": "BSD-2-Clause", 21238 + "engines": { 21239 + "node": ">=20.19.0" 21240 + }, 21241 + "funding": { 21242 + "url": "https://github.com/fb55/entities?sponsor=1" 21243 + } 21244 + }, 21245 + "node_modules/isomorphic-dompurify/node_modules/html-encoding-sniffer": { 21246 + "version": "6.0.0", 21247 + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", 21248 + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", 21249 + "license": "MIT", 21250 + "dependencies": { 21251 + "@exodus/bytes": "^1.6.0" 21252 + }, 21253 + "engines": { 21254 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 21255 + } 21256 + }, 21257 + "node_modules/isomorphic-dompurify/node_modules/jsdom": { 21258 + "version": "29.1.1", 21259 + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", 21260 + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", 21261 + "license": "MIT", 21262 + "dependencies": { 21263 + "@asamuzakjp/css-color": "^5.1.11", 21264 + "@asamuzakjp/dom-selector": "^7.1.1", 21265 + "@bramus/specificity": "^2.4.2", 21266 + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", 21267 + "@exodus/bytes": "^1.15.0", 21268 + "css-tree": "^3.2.1", 21269 + "data-urls": "^7.0.0", 21270 + "decimal.js": "^10.6.0", 21271 + "html-encoding-sniffer": "^6.0.0", 21272 + "is-potential-custom-element-name": "^1.0.1", 21273 + "lru-cache": "^11.3.5", 21274 + "parse5": "^8.0.1", 21275 + "saxes": "^6.0.0", 21276 + "symbol-tree": "^3.2.4", 21277 + "tough-cookie": "^6.0.1", 21278 + "undici": "^7.25.0", 21279 + "w3c-xmlserializer": "^5.0.0", 21280 + "webidl-conversions": "^8.0.1", 21281 + "whatwg-mimetype": "^5.0.0", 21282 + "whatwg-url": "^16.0.1", 21283 + "xml-name-validator": "^5.0.0" 21284 + }, 21285 + "engines": { 21286 + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" 21287 + }, 21288 + "peerDependencies": { 21289 + "canvas": "^3.0.0" 21290 + }, 21291 + "peerDependenciesMeta": { 21292 + "canvas": { 21293 + "optional": true 21294 + } 21295 + } 21296 + }, 21297 + "node_modules/isomorphic-dompurify/node_modules/lru-cache": { 21298 + "version": "11.3.5", 21299 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", 21300 + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", 21301 + "license": "BlueOak-1.0.0", 21302 + "engines": { 21303 + "node": "20 || >=22" 21304 + } 21305 + }, 21306 + "node_modules/isomorphic-dompurify/node_modules/mdn-data": { 21307 + "version": "2.27.1", 21308 + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", 21309 + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", 21310 + "license": "CC0-1.0" 21311 + }, 21312 + "node_modules/isomorphic-dompurify/node_modules/parse5": { 21313 + "version": "8.0.1", 21314 + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", 21315 + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", 21316 + "license": "MIT", 21317 + "dependencies": { 21318 + "entities": "^8.0.0" 21319 + }, 21320 + "funding": { 21321 + "url": "https://github.com/inikulin/parse5?sponsor=1" 21322 + } 21323 + }, 21324 + "node_modules/isomorphic-dompurify/node_modules/tldts": { 21325 + "version": "7.0.29", 21326 + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.29.tgz", 21327 + "integrity": "sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==", 21328 + "license": "MIT", 21329 + "dependencies": { 21330 + "tldts-core": "^7.0.29" 21331 + }, 21332 + "bin": { 21333 + "tldts": "bin/cli.js" 21334 + } 21335 + }, 21336 + "node_modules/isomorphic-dompurify/node_modules/tldts-core": { 21337 + "version": "7.0.29", 21338 + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.29.tgz", 21339 + "integrity": "sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==", 21340 + "license": "MIT" 21341 + }, 21342 + "node_modules/isomorphic-dompurify/node_modules/tough-cookie": { 21343 + "version": "6.0.1", 21344 + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", 21345 + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", 21346 + "license": "BSD-3-Clause", 21347 + "dependencies": { 21348 + "tldts": "^7.0.5" 21349 + }, 21350 + "engines": { 21351 + "node": ">=16" 21352 + } 21353 + }, 21354 + "node_modules/isomorphic-dompurify/node_modules/tr46": { 21355 + "version": "6.0.0", 21356 + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", 21357 + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", 21358 + "license": "MIT", 21359 + "dependencies": { 21360 + "punycode": "^2.3.1" 21361 + }, 21362 + "engines": { 21363 + "node": ">=20" 21364 + } 21365 + }, 21366 + "node_modules/isomorphic-dompurify/node_modules/undici": { 21367 + "version": "7.25.0", 21368 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", 21369 + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", 21370 + "license": "MIT", 21371 + "engines": { 21372 + "node": ">=20.18.1" 21373 + } 21374 + }, 21375 + "node_modules/isomorphic-dompurify/node_modules/webidl-conversions": { 21376 + "version": "8.0.1", 21377 + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", 21378 + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", 21379 + "license": "BSD-2-Clause", 21380 + "engines": { 21381 + "node": ">=20" 21382 + } 21383 + }, 21384 + "node_modules/isomorphic-dompurify/node_modules/whatwg-mimetype": { 21385 + "version": "5.0.0", 21386 + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", 21387 + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", 21388 + "license": "MIT", 21389 + "engines": { 21390 + "node": ">=20" 21391 + } 21392 + }, 21393 + "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { 21394 + "version": "16.0.1", 21395 + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", 21396 + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", 21397 + "license": "MIT", 21398 + "dependencies": { 21399 + "@exodus/bytes": "^1.11.0", 21400 + "tr46": "^6.0.0", 21401 + "webidl-conversions": "^8.0.1" 21402 + }, 21403 + "engines": { 21404 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 21405 + } 21406 + }, 20908 21407 "node_modules/istanbul-lib-coverage": { 20909 21408 "version": "3.2.2", 20910 21409 "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", ··· 21730 22229 "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", 21731 22230 "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", 21732 22231 "dev": true, 22232 + "license": "MIT", 21733 22233 "peer": true, 21734 22234 "dependencies": { 21735 22235 "cssstyle": "^4.2.1", ··· 26425 26925 "version": "2.3.1", 26426 26926 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 26427 26927 "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 26428 - "dev": true, 26429 26928 "engines": { 26430 26929 "node": ">=6" 26431 26930 } ··· 27210 27709 "node": ">=0.10.0" 27211 27710 } 27212 27711 }, 27712 + "node_modules/require-from-string": { 27713 + "version": "2.0.2", 27714 + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 27715 + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", 27716 + "license": "MIT", 27717 + "engines": { 27718 + "node": ">=0.10.0" 27719 + } 27720 + }, 27213 27721 "node_modules/require-in-the-middle": { 27214 27722 "version": "8.0.1", 27215 27723 "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", ··· 27610 28118 "version": "6.0.0", 27611 28119 "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", 27612 28120 "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", 27613 - "dev": true, 27614 28121 "dependencies": { 27615 28122 "xmlchars": "^2.2.0" 27616 28123 }, ··· 29284 29791 "node_modules/symbol-tree": { 29285 29792 "version": "3.2.4", 29286 29793 "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", 29287 - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", 29288 - "dev": true 29794 + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" 29289 29795 }, 29290 29796 "node_modules/synckit": { 29291 29797 "version": "0.11.11", ··· 31385 31891 "version": "5.0.0", 31386 31892 "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", 31387 31893 "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", 31388 - "dev": true, 31389 31894 "dependencies": { 31390 31895 "xml-name-validator": "^5.0.0" 31391 31896 }, ··· 31815 32320 "version": "5.0.0", 31816 32321 "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", 31817 32322 "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", 31818 - "dev": true, 31819 32323 "engines": { 31820 32324 "node": ">=18" 31821 32325 } ··· 31848 32352 "node_modules/xmlchars": { 31849 32353 "version": "2.2.0", 31850 32354 "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", 31851 - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", 31852 - "dev": true 32355 + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" 31853 32356 }, 31854 32357 "node_modules/xtend": { 31855 32358 "version": "4.0.2", ··· 32116 32619 "@mantine/notifications": "9.0.0", 32117 32620 "@mdx-js/loader": "^3.1.1", 32118 32621 "@mdx-js/react": "^3.1.1", 32622 + "@mozilla/readability": "^0.6.0", 32119 32623 "@next/mdx": "16.2.3", 32120 32624 "@semble/types": "*", 32121 32625 "@tanstack/react-query": "^5.85.5", ··· 32124 32628 "capnweb": "^0.6.1", 32125 32629 "date-fns": "^4.1.0", 32126 32630 "dayjs": "^1.11.13", 32631 + "isomorphic-dompurify": "^3.11.0", 32127 32632 "next": "16.2.3", 32128 32633 "react": "19.2.5", 32129 32634 "react-dom": "19.2.5", ··· 32146 32651 "@testing-library/react": "^16.3.2", 32147 32652 "@testing-library/user-event": "^14.6.1", 32148 32653 "@types/chrome": "^0.0.332", 32654 + "@types/jsdom": "^21.1.7", 32655 + "@types/mozilla__readability": "^0.4.2", 32149 32656 "@types/node": "^20.10.4", 32150 32657 "@types/react": "19.2.14", 32151 32658 "@types/react-dom": "19.2.3",
+5 -1
src/webapp/app/(dashboard)/url/layout.tsx
··· 1 1 import BackButton from '@/components/navigation/backButton/BackButton'; 2 2 import Header from '@/components/navigation/header/Header'; 3 - import { Fragment } from 'react'; 3 + import ReaderHeaderButton from '@/features/reader/components/ReaderHeaderButton/ReaderHeaderButton'; 4 + import { Fragment, Suspense } from 'react'; 4 5 5 6 interface Props { 6 7 children: React.ReactNode; ··· 11 12 <Fragment> 12 13 <Header> 13 14 <BackButton /> 15 + <Suspense fallback={null}> 16 + <ReaderHeaderButton /> 17 + </Suspense> 14 18 </Header> 15 19 {props.children} 16 20 </Fragment>
+1
src/webapp/app/api/feature-flags/route.ts
··· 17 17 following: false, 18 18 connections: false, 19 19 graphView: false, 20 + readerMode: false, 20 21 }); 21 22 } 22 23 }
+85
src/webapp/app/api/reader/route.ts
··· 1 + import { NextRequest, NextResponse } from 'next/server'; 2 + import { JSDOM } from 'jsdom'; 3 + import { Readability } from '@mozilla/readability'; 4 + import DOMPurify from 'isomorphic-dompurify'; 5 + 6 + export interface ReaderContent { 7 + title: string | null; 8 + content: string; 9 + byline: string | null; 10 + siteName: string | null; 11 + excerpt: string | null; 12 + } 13 + 14 + export async function GET(request: NextRequest) { 15 + const { searchParams } = request.nextUrl; 16 + const url = searchParams.get('url'); 17 + 18 + if (!url) { 19 + return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); 20 + } 21 + 22 + let targetUrl: URL; 23 + try { 24 + targetUrl = new URL(url); 25 + } catch { 26 + return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }); 27 + } 28 + 29 + let html: string; 30 + try { 31 + const response = await fetch(targetUrl.toString(), { 32 + headers: { 33 + // Mimic a real browser to avoid blocks 34 + 'User-Agent': 35 + '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', 36 + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 37 + 'Accept-Language': 'en-US,en;q=0.5', 38 + }, 39 + // 10 second timeout 40 + signal: AbortSignal.timeout(10_000), 41 + }); 42 + 43 + if (!response.ok) { 44 + return NextResponse.json( 45 + { error: `Failed to fetch URL: ${response.status} ${response.statusText}` }, 46 + { status: 502 }, 47 + ); 48 + } 49 + 50 + html = await response.text(); 51 + } catch (err) { 52 + const message = err instanceof Error ? err.message : 'Unknown error'; 53 + return NextResponse.json({ error: `Failed to fetch URL: ${message}` }, { status: 502 }); 54 + } 55 + 56 + // Parse with jsdom, passing the URL so Readability can resolve relative links 57 + const dom = new JSDOM(html, { url: targetUrl.toString() }); 58 + const reader = new Readability(dom.window.document); 59 + const article = reader.parse(); 60 + 61 + if (!article) { 62 + return NextResponse.json( 63 + { error: 'Could not extract readable content from this page' }, 64 + { status: 422 }, 65 + ); 66 + } 67 + 68 + // Sanitize the extracted HTML to prevent XSS 69 + const sanitizedContent = DOMPurify.sanitize(article.content ?? '', { 70 + USE_PROFILES: { html: true }, 71 + // Allow common article elements but strip scripts/iframes/etc. 72 + FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input', 'button'], 73 + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'], 74 + }); 75 + 76 + const result: ReaderContent = { 77 + title: article.title ?? null, 78 + content: sanitizedContent, 79 + byline: article.byline ?? null, 80 + siteName: article.siteName ?? null, 81 + excerpt: article.excerpt ?? null, 82 + }; 83 + 84 + return NextResponse.json(result); 85 + }
+2 -2
src/webapp/components/navigation/header/Header.tsx
··· 1 1 import { Box, Divider, Group, Paper, Text } from '@mantine/core'; 2 2 import NavbarToggle from '../NavbarToggle'; 3 - import { ReactElement } from 'react'; 3 + import { ReactNode } from 'react'; 4 4 5 5 interface Props { 6 6 title?: string; 7 - children?: ReactElement; 7 + children?: ReactNode; 8 8 } 9 9 10 10 export default function Header(props: Props) {
+135
src/webapp/features/reader/components/ReaderButton/ReaderButton.module.css
··· 1 + /* ── Bottom bar ── */ 2 + 3 + .bottomBar { 4 + position: relative; 5 + flex-shrink: 0; 6 + background: color-mix(in srgb, var(--mantine-color-body) 85%, transparent); 7 + backdrop-filter: blur(10px); 8 + -webkit-backdrop-filter: blur(10px); 9 + border-top: 1px solid var(--mantine-color-gray-2); 10 + } 11 + 12 + /* ── Article body typography ── */ 13 + 14 + .readerContent :global(p) { 15 + margin: 0 0 1.3em; 16 + } 17 + 18 + .readerContent :global(h1), 19 + .readerContent :global(h2), 20 + .readerContent :global(h3), 21 + .readerContent :global(h4), 22 + .readerContent :global(h5), 23 + .readerContent :global(h6) { 24 + font-weight: 700; 25 + line-height: 1.3; 26 + margin: 1.75em 0 0.5em; 27 + } 28 + 29 + .readerContent :global(h1) { font-size: 1.6em; } 30 + .readerContent :global(h2) { font-size: 1.35em; } 31 + .readerContent :global(h3) { font-size: 1.15em; } 32 + .readerContent :global(h4) { font-size: 1.0em; } 33 + 34 + .readerContent :global(a) { 35 + color: var(--mantine-color-blue-6); 36 + text-decoration: underline; 37 + text-underline-offset: 3px; 38 + } 39 + 40 + .readerContent :global(a:hover) { 41 + color: var(--mantine-color-blue-8); 42 + } 43 + 44 + .readerContent :global(blockquote) { 45 + border-left: 3px solid var(--mantine-color-gray-4); 46 + margin: 1.5em 0; 47 + padding: 0.4em 1.25em; 48 + color: var(--mantine-color-dimmed); 49 + font-style: italic; 50 + } 51 + 52 + .readerContent :global(pre) { 53 + background: var(--mantine-color-gray-0); 54 + border: 1px solid var(--mantine-color-gray-2); 55 + border-radius: 8px; 56 + padding: 1em 1.25em; 57 + overflow-x: auto; 58 + font-size: 0.875em; 59 + line-height: 1.6; 60 + margin: 1.5em 0; 61 + } 62 + 63 + .readerContent :global(code) { 64 + background: var(--mantine-color-gray-1); 65 + border-radius: 4px; 66 + padding: 0.15em 0.4em; 67 + font-size: 0.875em; 68 + font-family: var(--mantine-font-family-monospace, monospace); 69 + } 70 + 71 + .readerContent :global(pre code) { 72 + background: none; 73 + padding: 0; 74 + font-size: inherit; 75 + } 76 + 77 + .readerContent :global(ul), 78 + .readerContent :global(ol) { 79 + padding-left: 1.75em; 80 + margin: 0 0 1.3em; 81 + } 82 + 83 + .readerContent :global(li) { 84 + margin-bottom: 0.4em; 85 + } 86 + 87 + .readerContent :global(li > ul), 88 + .readerContent :global(li > ol) { 89 + margin-top: 0.3em; 90 + margin-bottom: 0; 91 + } 92 + 93 + .readerContent :global(img) { 94 + max-width: 100%; 95 + height: auto; 96 + border-radius: 8px; 97 + margin: 1.5em 0; 98 + display: block; 99 + } 100 + 101 + .readerContent :global(figure) { 102 + margin: 1.75em 0; 103 + } 104 + 105 + .readerContent :global(figcaption) { 106 + font-size: 0.8125em; 107 + color: var(--mantine-color-dimmed); 108 + text-align: center; 109 + margin-top: 0.6em; 110 + } 111 + 112 + .readerContent :global(hr) { 113 + border: none; 114 + border-top: 1px solid var(--mantine-color-gray-3); 115 + margin: 2em 0; 116 + } 117 + 118 + .readerContent :global(table) { 119 + width: 100%; 120 + border-collapse: collapse; 121 + margin: 1.5em 0; 122 + font-size: 0.9375em; 123 + } 124 + 125 + .readerContent :global(th), 126 + .readerContent :global(td) { 127 + border: 1px solid var(--mantine-color-gray-3); 128 + padding: 0.5em 0.75em; 129 + text-align: left; 130 + } 131 + 132 + .readerContent :global(th) { 133 + background: var(--mantine-color-gray-0); 134 + font-weight: 600; 135 + }
+273
src/webapp/features/reader/components/ReaderButton/ReaderButton.tsx
··· 1 + 'use client'; 2 + 3 + import { useState } from 'react'; 4 + import { 5 + ActionIcon, 6 + Drawer, 7 + Tooltip, 8 + Stack, 9 + Title, 10 + Text, 11 + Alert, 12 + ScrollArea, 13 + Box, 14 + Group, 15 + CloseButton, 16 + Skeleton, 17 + Button, 18 + Anchor, 19 + Container, 20 + } from '@mantine/core'; 21 + import { TbBook2, TbExternalLink, TbLink, TbLinkOff } from 'react-icons/tb'; 22 + import { MdErrorOutline } from 'react-icons/md'; 23 + import { fetchReaderContent, type ReaderState } from '../../lib/fetchReaderContent'; 24 + import styles from './ReaderButton.module.css'; 25 + 26 + interface Props { 27 + url: string; 28 + } 29 + 30 + const FONT_SIZE_MIN = 14; 31 + const FONT_SIZE_MAX = 24; 32 + const FONT_SIZE_STEP = 2; 33 + const FONT_SIZE_DEFAULT = 17; 34 + 35 + function ArticleSkeleton() { 36 + return ( 37 + <Stack gap="sm"> 38 + <Skeleton height={12} width="15%" radius="sm" /> 39 + <Skeleton height={36} width="90%" radius="sm" /> 40 + <Skeleton height={36} width="70%" radius="sm" /> 41 + <Skeleton height={12} width="25%" radius="sm" mt={4} /> 42 + <Box mt="xl"> 43 + {[100, 95, 88, 100, 92, 78, 100, 96, 83, 60].map((w, i) => ( 44 + <Skeleton key={i} height={14} width={`${w}%`} radius="sm" mb={10} /> 45 + ))} 46 + </Box> 47 + <Box mt="xs"> 48 + {[100, 91, 100, 85, 100, 72].map((w, i) => ( 49 + <Skeleton key={i} height={14} width={`${w}%`} radius="sm" mb={10} /> 50 + ))} 51 + </Box> 52 + </Stack> 53 + ); 54 + } 55 + 56 + export default function ReaderButton({ url }: Props) { 57 + const [opened, setOpened] = useState(false); 58 + const [state, setState] = useState<ReaderState>({ status: 'idle' }); 59 + const [fontSize, setFontSize] = useState(FONT_SIZE_DEFAULT); 60 + const [removeLinks, setRemoveLinks] = useState(false); 61 + 62 + const displayedHtml = 63 + state.status === 'success' 64 + ? removeLinks 65 + ? state.data.content.replace(/<a[^>]*>([\s\S]*?)<\/a>/g, '$1') 66 + : state.data.content 67 + : ''; 68 + 69 + function openReader() { 70 + setOpened(true); 71 + if (state.status === 'success' || state.status === 'loading') return; 72 + fetchReaderContent(url, setState); 73 + } 74 + 75 + function handleClose() { 76 + setOpened(false); 77 + } 78 + 79 + return ( 80 + <> 81 + <Button 82 + variant="light" 83 + color="gray" 84 + radius="xl" 85 + leftSection={<TbBook2 size={18} />} 86 + onClick={openReader} 87 + aria-label="Open reader mode" 88 + > 89 + Reader 90 + </Button> 91 + 92 + <Drawer 93 + opened={opened} 94 + onClose={handleClose} 95 + position="bottom" 96 + size="full" 97 + padding={0} 98 + withCloseButton={false} 99 + styles={{ 100 + content: { display: 'flex', flexDirection: 'column' }, 101 + body: { 102 + flex: 1, 103 + display: 'flex', 104 + flexDirection: 'column', 105 + overflow: 'hidden', 106 + }, 107 + }} 108 + > 109 + {/* ── Scrollable article area ── */} 110 + <ScrollArea style={{ flex: 1 }}> 111 + <Container 112 + size={'sm'} 113 + px="xl" 114 + style={{ 115 + paddingTop: '2.5rem', 116 + paddingBottom: '3rem', 117 + }} 118 + > 119 + {state.status === 'loading' && <ArticleSkeleton />} 120 + 121 + {state.status === 'error' && ( 122 + <Stack gap="md"> 123 + <Alert 124 + icon={<MdErrorOutline size={20} />} 125 + title="Could not load reader" 126 + color="red" 127 + variant="light" 128 + > 129 + {state.message} 130 + </Alert> 131 + <Button 132 + variant="default" 133 + size="sm" 134 + radius="xl" 135 + onClick={() => fetchReaderContent(url, setState)} 136 + > 137 + Try again 138 + </Button> 139 + </Stack> 140 + )} 141 + 142 + {state.status === 'success' && ( 143 + <Stack gap="xs"> 144 + {state.data.siteName && ( 145 + <Text size="xs" c="dimmed" tt="uppercase" fw={600}> 146 + {state.data.siteName} 147 + </Text> 148 + )} 149 + 150 + {state.data.title && ( 151 + <Title 152 + order={1} 153 + style={{ lineHeight: 1.2, fontSize: '1.75rem' }} 154 + > 155 + {state.data.title} 156 + </Title> 157 + )} 158 + 159 + {state.data.byline && ( 160 + <Text size="sm" c="dimmed" mt={2}> 161 + {state.data.byline} 162 + </Text> 163 + )} 164 + 165 + <Box 166 + component="article" 167 + dangerouslySetInnerHTML={{ __html: displayedHtml }} 168 + className={styles.readerContent} 169 + style={{ 170 + fontSize: `${fontSize}px`, 171 + lineHeight: 1.8, 172 + color: 'var(--mantine-color-text)', 173 + marginTop: '1.5rem', 174 + }} 175 + /> 176 + </Stack> 177 + )} 178 + </Container> 179 + </ScrollArea> 180 + 181 + {/* ── Sticky bottom bar ── */} 182 + <Box className={styles.bottomBar}> 183 + <Group px="lg" py="xs" justify="space-between" align="center"> 184 + {/* Left: font size + link toggle */} 185 + <Group gap="xs"> 186 + <Tooltip label="Decrease font size" withArrow position="top"> 187 + <ActionIcon 188 + variant="light" 189 + color="gray" 190 + size="md" 191 + radius="xl" 192 + onClick={() => 193 + setFontSize((s) => 194 + Math.max(FONT_SIZE_MIN, s - FONT_SIZE_STEP), 195 + ) 196 + } 197 + disabled={fontSize <= FONT_SIZE_MIN} 198 + aria-label="Decrease font size" 199 + > 200 + <Text size="xs" fw={700}> 201 + A- 202 + </Text> 203 + </ActionIcon> 204 + </Tooltip> 205 + <Tooltip label="Increase font size" withArrow position="top"> 206 + <ActionIcon 207 + variant="light" 208 + color="gray" 209 + size="md" 210 + radius="xl" 211 + onClick={() => 212 + setFontSize((s) => 213 + Math.min(FONT_SIZE_MAX, s + FONT_SIZE_STEP), 214 + ) 215 + } 216 + disabled={fontSize >= FONT_SIZE_MAX} 217 + aria-label="Increase font size" 218 + > 219 + <Text size="xs" fw={700}> 220 + A+ 221 + </Text> 222 + </ActionIcon> 223 + </Tooltip> 224 + <Tooltip 225 + label={removeLinks ? 'Show links' : 'Remove links'} 226 + withArrow 227 + position="top" 228 + > 229 + <ActionIcon 230 + variant={removeLinks ? 'filled' : 'light'} 231 + color={'gray'} 232 + size="md" 233 + radius="xl" 234 + onClick={() => setRemoveLinks((v) => !v)} 235 + aria-label={removeLinks ? 'Show links' : 'Remove links'} 236 + > 237 + {removeLinks ? <TbLinkOff size={16} /> : <TbLink size={16} />} 238 + </ActionIcon> 239 + </Tooltip> 240 + </Group> 241 + 242 + {/* Right: open original + close */} 243 + <Group gap="xs"> 244 + <Anchor 245 + href={url} 246 + target="_blank" 247 + rel="noopener noreferrer" 248 + underline="never" 249 + > 250 + <Button 251 + variant="light" 252 + color="gray" 253 + size="xs" 254 + radius="xl" 255 + rightSection={<TbExternalLink size={14} />} 256 + > 257 + Open original 258 + </Button> 259 + </Anchor> 260 + 261 + <CloseButton 262 + size="lg" 263 + radius={'xl'} 264 + onClick={handleClose} 265 + aria-label="Close reader mode" 266 + /> 267 + </Group> 268 + </Group> 269 + </Box> 270 + </Drawer> 271 + </> 272 + ); 273 + }
+16
src/webapp/features/reader/components/ReaderHeaderButton/ReaderHeaderButton.tsx
··· 1 + 'use client'; 2 + 3 + import { useSearchParams } from 'next/navigation'; 4 + import { useFeatureFlags } from '@/lib/clientFeatureFlags'; 5 + import ReaderButton from '../ReaderButton/ReaderButton'; 6 + 7 + export default function ReaderHeaderButton() { 8 + const { data: featureFlags } = useFeatureFlags(); 9 + const searchParams = useSearchParams(); 10 + const rawId = searchParams.get('id'); 11 + 12 + if (!featureFlags?.readerMode || !rawId) return null; 13 + 14 + const url = decodeURIComponent(rawId); 15 + return <ReaderButton url={url} />; 16 + }
+27
src/webapp/features/reader/lib/fetchReaderContent.ts
··· 1 + import type { ReaderContent } from '@/app/api/reader/route'; 2 + 3 + export type ReaderState = 4 + | { status: 'idle' } 5 + | { status: 'loading' } 6 + | { status: 'error'; message: string } 7 + | { status: 'success'; data: ReaderContent }; 8 + 9 + export async function fetchReaderContent( 10 + url: string, 11 + setState: (state: ReaderState) => void, 12 + ): Promise<void> { 13 + setState({ status: 'loading' }); 14 + try { 15 + const res = await fetch(`/api/reader?url=${encodeURIComponent(url)}`); 16 + const json = await res.json(); 17 + if (!res.ok) { 18 + setState({ status: 'error', message: json.error ?? 'Unknown error' }); 19 + return; 20 + } 21 + setState({ status: 'success', data: json as ReaderContent }); 22 + } catch (err) { 23 + const message = 24 + err instanceof Error ? err.message : 'Failed to load reader content'; 25 + setState({ status: 'error', message }); 26 + } 27 + }
+1
src/webapp/lib/clientFeatureFlags.ts
··· 11 11 following: boolean; 12 12 connections: boolean; 13 13 graphView: boolean; 14 + readerMode: boolean; 14 15 } 15 16 16 17 async function fetchFeatureFlags(): Promise<FeatureFlags> {
+1
src/webapp/lib/serverFeatureFlags.ts
··· 26 26 following: true, 27 27 connections: true, 28 28 graphView: show, 29 + readerMode: showForTeam, 29 30 }; 30 31 }
+1 -1
src/webapp/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - import "./.next/types/routes.d.ts"; 3 + import "./.next/dev/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+15 -11
src/webapp/package.json
··· 38 38 "@mantine/notifications": "9.0.0", 39 39 "@mdx-js/loader": "^3.1.1", 40 40 "@mdx-js/react": "^3.1.1", 41 + "@mozilla/readability": "^0.6.0", 41 42 "@next/mdx": "16.2.3", 42 43 "@semble/types": "*", 43 44 "@tanstack/react-query": "^5.85.5", ··· 46 47 "capnweb": "^0.6.1", 47 48 "date-fns": "^4.1.0", 48 49 "dayjs": "^1.11.13", 50 + "isomorphic-dompurify": "^3.11.0", 49 51 "next": "16.2.3", 50 52 "react": "19.2.5", 51 53 "react-dom": "19.2.5", ··· 57 59 "web-haptics": "^0.0.6" 58 60 }, 59 61 "devDependencies": { 62 + "@chromatic-com/storybook": "^5.1.2", 60 63 "@playwright/test": "^1.59.1", 64 + "@storybook/addon-a11y": "^10.3.6", 65 + "@storybook/addon-docs": "^10.3.6", 66 + "@storybook/addon-onboarding": "^10.3.6", 67 + "@storybook/addon-vitest": "^10.3.6", 68 + "@storybook/nextjs-vite": "^10.3.6", 61 69 "@testing-library/dom": "^10.4.1", 62 70 "@testing-library/react": "^16.3.2", 63 71 "@testing-library/user-event": "^14.6.1", 64 72 "@types/chrome": "^0.0.332", 73 + "@types/jsdom": "^21.1.7", 74 + "@types/mozilla__readability": "^0.4.2", 65 75 "@types/node": "^20.10.4", 66 76 "@types/react": "19.2.14", 67 77 "@types/react-dom": "19.2.3", 68 78 "@vitejs/plugin-react": "^6.0.1", 79 + "@vitest/browser-playwright": "^4.1.5", 80 + "@vitest/coverage-v8": "^4.1.5", 69 81 "autoprefixer": "^10.4.16", 70 82 "eslint": "^9.0.0", 71 83 "eslint-config-next": "16.2.3", 84 + "eslint-plugin-storybook": "^10.3.6", 72 85 "jsdom": "^26.1.0", 73 86 "plasmo": "^0.90.5", 74 87 "postcss": "^8.5.6", 75 88 "postcss-preset-mantine": "^1.18.0", 76 89 "postcss-simple-vars": "^7.0.1", 77 - "typescript": "^5.3.3", 78 - "vitest": "^4.1.5", 79 90 "storybook": "^10.3.6", 80 - "@storybook/nextjs-vite": "^10.3.6", 81 - "@chromatic-com/storybook": "^5.1.2", 82 - "@storybook/addon-vitest": "^10.3.6", 83 - "@storybook/addon-a11y": "^10.3.6", 84 - "@storybook/addon-docs": "^10.3.6", 85 - "@storybook/addon-onboarding": "^10.3.6", 91 + "typescript": "^5.3.3", 86 92 "vite": "^8.0.10", 87 - "eslint-plugin-storybook": "^10.3.6", 88 - "@vitest/browser-playwright": "^4.1.5", 89 - "@vitest/coverage-v8": "^4.1.5" 93 + "vitest": "^4.1.5" 90 94 }, 91 95 "overrides": { 92 96 "@types/react": "19.2.14",