a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

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

feat: images & syntax highlighting

+1116 -26
+5
package.json
··· 25 25 "@capacitor/status-bar": "8.0.1", 26 26 "@ionic/vue": "^8.0.0", 27 27 "@ionic/vue-router": "^8.0.0", 28 + "@shikijs/markdown-it": "^4.0.2", 28 29 "@tanstack/query-persist-client-core": "^5.95.0", 29 30 "@tanstack/vue-query": "^5.94.5", 31 + "dompurify": "^3.3.3", 30 32 "idb-keyval": "^6.2.2", 31 33 "ionicons": "^7.0.0", 34 + "markdown-it": "^14.1.1", 32 35 "pinia": "^3.0.4", 36 + "shiki": "^4.0.2", 33 37 "vue": "^3.3.0", 34 38 "vue-router": "^4.2.0" 35 39 }, 36 40 "devDependencies": { 37 41 "@capacitor/cli": "8.2.0", 38 42 "@eslint/js": "10.0.1", 43 + "@types/dompurify": "^3.2.0", 39 44 "@types/node": "25.5.0", 40 45 "@vitejs/plugin-legacy": "^5.0.0", 41 46 "@vitejs/plugin-vue": "^4.0.0",
+431
pnpm-lock.yaml
··· 44 44 '@ionic/vue-router': 45 45 specifier: ^8.0.0 46 46 version: 8.8.1(@stencil/core@4.43.3)(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) 47 + '@shikijs/markdown-it': 48 + specifier: ^4.0.2 49 + version: 4.0.2 47 50 '@tanstack/query-persist-client-core': 48 51 specifier: ^5.95.0 49 52 version: 5.95.0 50 53 '@tanstack/vue-query': 51 54 specifier: ^5.94.5 52 55 version: 5.94.5(vue@3.5.30(typescript@5.9.3)) 56 + dompurify: 57 + specifier: ^3.3.3 58 + version: 3.3.3 53 59 idb-keyval: 54 60 specifier: ^6.2.2 55 61 version: 6.2.2 56 62 ionicons: 57 63 specifier: ^7.0.0 58 64 version: 7.4.0 65 + markdown-it: 66 + specifier: ^14.1.1 67 + version: 14.1.1 59 68 pinia: 60 69 specifier: ^3.0.4 61 70 version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) 71 + shiki: 72 + specifier: ^4.0.2 73 + version: 4.0.2 62 74 vue: 63 75 specifier: ^3.3.0 64 76 version: 3.5.30(typescript@5.9.3) ··· 72 84 '@eslint/js': 73 85 specifier: 10.0.1 74 86 version: 10.0.1(eslint@10.1.0) 87 + '@types/dompurify': 88 + specifier: ^3.2.0 89 + version: 3.2.0 75 90 '@types/node': 76 91 specifier: 25.5.0 77 92 version: 25.5.0 ··· 1172 1187 cpu: [x64] 1173 1188 os: [win32] 1174 1189 1190 + '@shikijs/core@4.0.2': 1191 + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} 1192 + engines: {node: '>=20'} 1193 + 1194 + '@shikijs/engine-javascript@4.0.2': 1195 + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} 1196 + engines: {node: '>=20'} 1197 + 1198 + '@shikijs/engine-oniguruma@4.0.2': 1199 + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} 1200 + engines: {node: '>=20'} 1201 + 1202 + '@shikijs/langs@4.0.2': 1203 + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} 1204 + engines: {node: '>=20'} 1205 + 1206 + '@shikijs/markdown-it@4.0.2': 1207 + resolution: {integrity: sha512-7DDEhknj/mXTN7ME8CjKWBv5O/4YgOiJBZLgs/NbUFMC7Ik1x/VEhaK+aBjX60bJdok0E2mxEYan/GzJ2xRx+A==} 1208 + engines: {node: '>=20'} 1209 + peerDependencies: 1210 + markdown-it-async: ^2.2.0 1211 + peerDependenciesMeta: 1212 + markdown-it-async: 1213 + optional: true 1214 + 1215 + '@shikijs/primitive@4.0.2': 1216 + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} 1217 + engines: {node: '>=20'} 1218 + 1219 + '@shikijs/themes@4.0.2': 1220 + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} 1221 + engines: {node: '>=20'} 1222 + 1223 + '@shikijs/types@4.0.2': 1224 + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} 1225 + engines: {node: '>=20'} 1226 + 1227 + '@shikijs/vscode-textmate@10.0.2': 1228 + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} 1229 + 1175 1230 '@sinclair/typebox@0.27.10': 1176 1231 resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} 1177 1232 ··· 1234 1289 '@types/chai@4.3.20': 1235 1290 resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} 1236 1291 1292 + '@types/dompurify@3.2.0': 1293 + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} 1294 + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. 1295 + 1237 1296 '@types/esrecurse@4.3.1': 1238 1297 resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} 1239 1298 ··· 1242 1301 1243 1302 '@types/fs-extra@8.1.5': 1244 1303 resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==} 1304 + 1305 + '@types/hast@3.0.4': 1306 + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} 1245 1307 1246 1308 '@types/json-schema@7.0.15': 1247 1309 resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 1248 1310 1311 + '@types/mdast@4.0.4': 1312 + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} 1313 + 1249 1314 '@types/node@25.5.0': 1250 1315 resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} 1251 1316 ··· 1257 1322 1258 1323 '@types/slice-ansi@4.0.0': 1259 1324 resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==} 1325 + 1326 + '@types/trusted-types@2.0.7': 1327 + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 1328 + 1329 + '@types/unist@3.0.3': 1330 + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} 1260 1331 1261 1332 '@types/yauzl@2.10.3': 1262 1333 resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} ··· 1319 1390 '@typescript-eslint/visitor-keys@8.57.1': 1320 1391 resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} 1321 1392 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 1393 + 1394 + '@ungap/structured-clone@1.3.0': 1395 + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} 1322 1396 1323 1397 '@vitejs/plugin-legacy@5.4.3': 1324 1398 resolution: {integrity: sha512-wsyXK9mascyplcqvww1gA1xYiy29iRHfyciw+a0t7qRNdzX6PdfSWmOoCi74epr87DujM+5J+rnnSv+4PazqVg==} ··· 1484 1558 arch@2.2.0: 1485 1559 resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} 1486 1560 1561 + argparse@2.0.1: 1562 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 1563 + 1487 1564 asn1@0.2.6: 1488 1565 resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} 1489 1566 ··· 1617 1694 caseless@0.12.0: 1618 1695 resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} 1619 1696 1697 + ccount@2.0.1: 1698 + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} 1699 + 1620 1700 chai@4.5.0: 1621 1701 resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} 1622 1702 engines: {node: '>=4'} ··· 1625 1705 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1626 1706 engines: {node: '>=10'} 1627 1707 1708 + character-entities-html4@2.1.0: 1709 + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} 1710 + 1711 + character-entities-legacy@3.0.0: 1712 + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} 1713 + 1628 1714 check-error@1.0.3: 1629 1715 resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} 1630 1716 ··· 1669 1755 combined-stream@1.0.8: 1670 1756 resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} 1671 1757 engines: {node: '>= 0.8'} 1758 + 1759 + comma-separated-tokens@2.0.3: 1760 + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} 1672 1761 1673 1762 commander@10.0.1: 1674 1763 resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} ··· 1781 1870 resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} 1782 1871 engines: {node: '>=0.4.0'} 1783 1872 1873 + dequal@2.0.3: 1874 + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} 1875 + engines: {node: '>=6'} 1876 + 1877 + devlop@1.1.0: 1878 + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} 1879 + 1784 1880 diff-sequences@29.6.3: 1785 1881 resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} 1786 1882 engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} ··· 1789 1885 resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} 1790 1886 engines: {node: '>=12'} 1791 1887 deprecated: Use your platform's native DOMException instead 1888 + 1889 + dompurify@3.3.3: 1890 + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} 1792 1891 1793 1892 dunder-proto@1.0.1: 1794 1893 resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} ··· 1824 1923 enquirer@2.4.1: 1825 1924 resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} 1826 1925 engines: {node: '>=8.6'} 1926 + 1927 + entities@4.5.0: 1928 + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} 1929 + engines: {node: '>=0.12'} 1827 1930 1828 1931 entities@6.0.1: 1829 1932 resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} ··· 2091 2194 resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} 2092 2195 engines: {node: '>= 0.4'} 2093 2196 2197 + hast-util-to-html@9.0.5: 2198 + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} 2199 + 2200 + hast-util-whitespace@3.0.0: 2201 + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} 2202 + 2094 2203 he@1.2.0: 2095 2204 resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} 2096 2205 hasBin: true ··· 2101 2210 html-encoding-sniffer@3.0.0: 2102 2211 resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} 2103 2212 engines: {node: '>=12'} 2213 + 2214 + html-void-elements@3.0.0: 2215 + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} 2104 2216 2105 2217 http-proxy-agent@5.0.0: 2106 2218 resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} ··· 2299 2411 resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} 2300 2412 engines: {node: '>= 0.8.0'} 2301 2413 2414 + linkify-it@5.0.0: 2415 + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} 2416 + 2302 2417 listr2@3.14.0: 2303 2418 resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==} 2304 2419 engines: {node: '>=10.0.0'} ··· 2349 2464 magic-string@0.30.21: 2350 2465 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 2351 2466 2467 + markdown-it@14.1.1: 2468 + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} 2469 + hasBin: true 2470 + 2352 2471 math-intrinsics@1.1.0: 2353 2472 resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} 2354 2473 engines: {node: '>= 0.4'} 2474 + 2475 + mdast-util-to-hast@13.2.1: 2476 + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} 2477 + 2478 + mdurl@2.0.0: 2479 + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} 2355 2480 2356 2481 meow@13.2.0: 2357 2482 resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} ··· 2360 2485 merge-stream@2.0.0: 2361 2486 resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} 2362 2487 2488 + micromark-util-character@2.1.1: 2489 + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} 2490 + 2491 + micromark-util-encode@2.0.1: 2492 + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} 2493 + 2494 + micromark-util-sanitize-uri@2.0.1: 2495 + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} 2496 + 2497 + micromark-util-symbol@2.0.1: 2498 + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} 2499 + 2500 + micromark-util-types@2.0.2: 2501 + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} 2502 + 2363 2503 mime-db@1.52.0: 2364 2504 resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} 2365 2505 engines: {node: '>= 0.6'} ··· 2444 2584 onetime@5.1.2: 2445 2585 resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} 2446 2586 engines: {node: '>=6'} 2587 + 2588 + oniguruma-parser@0.12.1: 2589 + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} 2590 + 2591 + oniguruma-to-es@4.3.5: 2592 + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} 2447 2593 2448 2594 open@8.4.2: 2449 2595 resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} ··· 2577 2723 prompts@2.4.2: 2578 2724 resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} 2579 2725 engines: {node: '>= 6'} 2726 + 2727 + property-information@7.1.0: 2728 + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} 2580 2729 2581 2730 proto-list@1.2.4: 2582 2731 resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} ··· 2590 2739 pump@3.0.4: 2591 2740 resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} 2592 2741 2742 + punycode.js@2.3.1: 2743 + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} 2744 + engines: {node: '>=6'} 2745 + 2593 2746 punycode@2.3.1: 2594 2747 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 2595 2748 engines: {node: '>=6'} ··· 2617 2770 2618 2771 regenerator-runtime@0.14.1: 2619 2772 resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} 2773 + 2774 + regex-recursion@6.0.2: 2775 + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} 2776 + 2777 + regex-utilities@2.3.0: 2778 + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} 2779 + 2780 + regex@6.1.0: 2781 + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} 2620 2782 2621 2783 regexpu-core@6.4.0: 2622 2784 resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} ··· 2700 2862 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 2701 2863 engines: {node: '>=8'} 2702 2864 2865 + shiki@4.0.2: 2866 + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} 2867 + engines: {node: '>=20'} 2868 + 2703 2869 side-channel-list@1.0.0: 2704 2870 resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} 2705 2871 engines: {node: '>= 0.4'} ··· 2748 2914 resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 2749 2915 engines: {node: '>=0.10.0'} 2750 2916 2917 + space-separated-tokens@2.0.2: 2918 + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} 2919 + 2751 2920 speakingurl@14.0.1: 2752 2921 resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} 2753 2922 engines: {node: '>=0.10.0'} ··· 2777 2946 2778 2947 string_decoder@1.3.0: 2779 2948 resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 2949 + 2950 + stringify-entities@4.0.4: 2951 + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} 2780 2952 2781 2953 strip-ansi@6.0.1: 2782 2954 resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} ··· 2875 3047 resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} 2876 3048 hasBin: true 2877 3049 3050 + trim-lines@3.0.1: 3051 + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} 3052 + 2878 3053 ts-api-utils@2.5.0: 2879 3054 resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} 2880 3055 engines: {node: '>=18.12'} ··· 2914 3089 engines: {node: '>=14.17'} 2915 3090 hasBin: true 2916 3091 3092 + uc.micro@2.1.0: 3093 + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} 3094 + 2917 3095 ufo@1.6.3: 2918 3096 resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} 2919 3097 ··· 2939 3117 unicode-segmenter@0.14.5: 2940 3118 resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 2941 3119 3120 + unist-util-is@6.0.1: 3121 + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} 3122 + 3123 + unist-util-position@5.0.0: 3124 + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} 3125 + 3126 + unist-util-stringify-position@4.0.0: 3127 + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} 3128 + 3129 + unist-util-visit-parents@6.0.2: 3130 + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} 3131 + 3132 + unist-util-visit@5.1.0: 3133 + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} 3134 + 2942 3135 universalify@0.2.0: 2943 3136 resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} 2944 3137 engines: {node: '>= 4.0.0'} ··· 2973 3166 verror@1.10.0: 2974 3167 resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} 2975 3168 engines: {'0': node >=0.6.0} 3169 + 3170 + vfile-message@4.0.3: 3171 + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} 3172 + 3173 + vfile@6.0.3: 3174 + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} 2976 3175 2977 3176 vite-node@0.34.6: 2978 3177 resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} ··· 3181 3380 yocto-queue@1.2.2: 3182 3381 resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} 3183 3382 engines: {node: '>=12.20'} 3383 + 3384 + zwitch@2.0.4: 3385 + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} 3184 3386 3185 3387 snapshots: 3186 3388 ··· 4339 4541 '@rollup/rollup-win32-x64-msvc@4.60.0': 4340 4542 optional: true 4341 4543 4544 + '@shikijs/core@4.0.2': 4545 + dependencies: 4546 + '@shikijs/primitive': 4.0.2 4547 + '@shikijs/types': 4.0.2 4548 + '@shikijs/vscode-textmate': 10.0.2 4549 + '@types/hast': 3.0.4 4550 + hast-util-to-html: 9.0.5 4551 + 4552 + '@shikijs/engine-javascript@4.0.2': 4553 + dependencies: 4554 + '@shikijs/types': 4.0.2 4555 + '@shikijs/vscode-textmate': 10.0.2 4556 + oniguruma-to-es: 4.3.5 4557 + 4558 + '@shikijs/engine-oniguruma@4.0.2': 4559 + dependencies: 4560 + '@shikijs/types': 4.0.2 4561 + '@shikijs/vscode-textmate': 10.0.2 4562 + 4563 + '@shikijs/langs@4.0.2': 4564 + dependencies: 4565 + '@shikijs/types': 4.0.2 4566 + 4567 + '@shikijs/markdown-it@4.0.2': 4568 + dependencies: 4569 + markdown-it: 14.1.1 4570 + shiki: 4.0.2 4571 + 4572 + '@shikijs/primitive@4.0.2': 4573 + dependencies: 4574 + '@shikijs/types': 4.0.2 4575 + '@shikijs/vscode-textmate': 10.0.2 4576 + '@types/hast': 3.0.4 4577 + 4578 + '@shikijs/themes@4.0.2': 4579 + dependencies: 4580 + '@shikijs/types': 4.0.2 4581 + 4582 + '@shikijs/types@4.0.2': 4583 + dependencies: 4584 + '@shikijs/vscode-textmate': 10.0.2 4585 + '@types/hast': 3.0.4 4586 + 4587 + '@shikijs/vscode-textmate@10.0.2': {} 4588 + 4342 4589 '@sinclair/typebox@0.27.10': {} 4343 4590 4344 4591 '@standard-schema/spec@1.1.0': {} ··· 4400 4647 4401 4648 '@types/chai@4.3.20': {} 4402 4649 4650 + '@types/dompurify@3.2.0': 4651 + dependencies: 4652 + dompurify: 3.3.3 4653 + 4403 4654 '@types/esrecurse@4.3.1': {} 4404 4655 4405 4656 '@types/estree@1.0.8': {} ··· 4408 4659 dependencies: 4409 4660 '@types/node': 25.5.0 4410 4661 4662 + '@types/hast@3.0.4': 4663 + dependencies: 4664 + '@types/unist': 3.0.3 4665 + 4411 4666 '@types/json-schema@7.0.15': {} 4412 4667 4668 + '@types/mdast@4.0.4': 4669 + dependencies: 4670 + '@types/unist': 3.0.3 4671 + 4413 4672 '@types/node@25.5.0': 4414 4673 dependencies: 4415 4674 undici-types: 7.18.2 ··· 4419 4678 '@types/sizzle@2.3.10': {} 4420 4679 4421 4680 '@types/slice-ansi@4.0.0': {} 4681 + 4682 + '@types/trusted-types@2.0.7': 4683 + optional: true 4684 + 4685 + '@types/unist@3.0.3': {} 4422 4686 4423 4687 '@types/yauzl@2.10.3': 4424 4688 dependencies: ··· 4516 4780 '@typescript-eslint/types': 8.57.1 4517 4781 eslint-visitor-keys: 5.0.1 4518 4782 4783 + '@ungap/structured-clone@1.3.0': {} 4784 + 4519 4785 '@vitejs/plugin-legacy@5.4.3(terser@5.46.1)(vite@5.4.21(@types/node@25.5.0)(terser@5.46.1))': 4520 4786 dependencies: 4521 4787 '@babel/core': 7.29.0 ··· 4729 4995 4730 4996 arch@2.2.0: {} 4731 4997 4998 + argparse@2.0.1: {} 4999 + 4732 5000 asn1@0.2.6: 4733 5001 dependencies: 4734 5002 safer-buffer: 2.1.2 ··· 4846 5114 caniuse-lite@1.0.30001780: {} 4847 5115 4848 5116 caseless@0.12.0: {} 5117 + 5118 + ccount@2.0.1: {} 4849 5119 4850 5120 chai@4.5.0: 4851 5121 dependencies: ··· 4862 5132 ansi-styles: 4.3.0 4863 5133 supports-color: 7.2.0 4864 5134 5135 + character-entities-html4@2.1.0: {} 5136 + 5137 + character-entities-legacy@3.0.0: {} 5138 + 4865 5139 check-error@1.0.3: 4866 5140 dependencies: 4867 5141 get-func-name: 2.0.2 ··· 4900 5174 combined-stream@1.0.8: 4901 5175 dependencies: 4902 5176 delayed-stream: 1.0.0 5177 + 5178 + comma-separated-tokens@2.0.3: {} 4903 5179 4904 5180 commander@10.0.1: {} 4905 5181 ··· 5030 5306 5031 5307 delayed-stream@1.0.0: {} 5032 5308 5309 + dequal@2.0.3: {} 5310 + 5311 + devlop@1.1.0: 5312 + dependencies: 5313 + dequal: 2.0.3 5314 + 5033 5315 diff-sequences@29.6.3: {} 5034 5316 5035 5317 domexception@4.0.0: 5036 5318 dependencies: 5037 5319 webidl-conversions: 7.0.0 5320 + 5321 + dompurify@3.3.3: 5322 + optionalDependencies: 5323 + '@types/trusted-types': 2.0.7 5038 5324 5039 5325 dunder-proto@1.0.1: 5040 5326 dependencies: ··· 5074 5360 dependencies: 5075 5361 ansi-colors: 4.1.3 5076 5362 strip-ansi: 6.0.1 5363 + 5364 + entities@4.5.0: {} 5077 5365 5078 5366 entities@6.0.1: {} 5079 5367 ··· 5383 5671 dependencies: 5384 5672 function-bind: 1.1.2 5385 5673 5674 + hast-util-to-html@9.0.5: 5675 + dependencies: 5676 + '@types/hast': 3.0.4 5677 + '@types/unist': 3.0.3 5678 + ccount: 2.0.1 5679 + comma-separated-tokens: 2.0.3 5680 + hast-util-whitespace: 3.0.0 5681 + html-void-elements: 3.0.0 5682 + mdast-util-to-hast: 13.2.1 5683 + property-information: 7.1.0 5684 + space-separated-tokens: 2.0.2 5685 + stringify-entities: 4.0.4 5686 + zwitch: 2.0.4 5687 + 5688 + hast-util-whitespace@3.0.0: 5689 + dependencies: 5690 + '@types/hast': 3.0.4 5691 + 5386 5692 he@1.2.0: {} 5387 5693 5388 5694 hookable@5.5.3: {} ··· 5390 5696 html-encoding-sniffer@3.0.0: 5391 5697 dependencies: 5392 5698 whatwg-encoding: 2.0.0 5699 + 5700 + html-void-elements@3.0.0: {} 5393 5701 5394 5702 http-proxy-agent@5.0.0: 5395 5703 dependencies: ··· 5577 5885 prelude-ls: 1.2.1 5578 5886 type-check: 0.4.0 5579 5887 5888 + linkify-it@5.0.0: 5889 + dependencies: 5890 + uc.micro: 2.1.0 5891 + 5580 5892 listr2@3.14.0(enquirer@2.4.1): 5581 5893 dependencies: 5582 5894 cli-truncate: 2.1.0 ··· 5630 5942 dependencies: 5631 5943 '@jridgewell/sourcemap-codec': 1.5.5 5632 5944 5945 + markdown-it@14.1.1: 5946 + dependencies: 5947 + argparse: 2.0.1 5948 + entities: 4.5.0 5949 + linkify-it: 5.0.0 5950 + mdurl: 2.0.0 5951 + punycode.js: 2.3.1 5952 + uc.micro: 2.1.0 5953 + 5633 5954 math-intrinsics@1.1.0: {} 5634 5955 5956 + mdast-util-to-hast@13.2.1: 5957 + dependencies: 5958 + '@types/hast': 3.0.4 5959 + '@types/mdast': 4.0.4 5960 + '@ungap/structured-clone': 1.3.0 5961 + devlop: 1.1.0 5962 + micromark-util-sanitize-uri: 2.0.1 5963 + trim-lines: 3.0.1 5964 + unist-util-position: 5.0.0 5965 + unist-util-visit: 5.1.0 5966 + vfile: 6.0.3 5967 + 5968 + mdurl@2.0.0: {} 5969 + 5635 5970 meow@13.2.0: {} 5636 5971 5637 5972 merge-stream@2.0.0: {} 5638 5973 5974 + micromark-util-character@2.1.1: 5975 + dependencies: 5976 + micromark-util-symbol: 2.0.1 5977 + micromark-util-types: 2.0.2 5978 + 5979 + micromark-util-encode@2.0.1: {} 5980 + 5981 + micromark-util-sanitize-uri@2.0.1: 5982 + dependencies: 5983 + micromark-util-character: 2.1.1 5984 + micromark-util-encode: 2.0.1 5985 + micromark-util-symbol: 2.0.1 5986 + 5987 + micromark-util-symbol@2.0.1: {} 5988 + 5989 + micromark-util-types@2.0.2: {} 5990 + 5639 5991 mime-db@1.52.0: {} 5640 5992 5641 5993 mime-types@2.1.35: ··· 5719 6071 dependencies: 5720 6072 mimic-fn: 2.1.0 5721 6073 6074 + oniguruma-parser@0.12.1: {} 6075 + 6076 + oniguruma-to-es@4.3.5: 6077 + dependencies: 6078 + oniguruma-parser: 0.12.1 6079 + regex: 6.1.0 6080 + regex-recursion: 6.0.2 6081 + 5722 6082 open@8.4.2: 5723 6083 dependencies: 5724 6084 define-lazy-prop: 2.0.0 ··· 5843 6203 kleur: 3.0.3 5844 6204 sisteransi: 1.0.5 5845 6205 6206 + property-information@7.1.0: {} 6207 + 5846 6208 proto-list@1.2.4: {} 5847 6209 5848 6210 proxy-from-env@1.0.0: {} ··· 5855 6217 dependencies: 5856 6218 end-of-stream: 1.4.5 5857 6219 once: 1.4.0 6220 + 6221 + punycode.js@2.3.1: {} 5858 6222 5859 6223 punycode@2.3.1: {} 5860 6224 ··· 5880 6244 5881 6245 regenerator-runtime@0.14.1: {} 5882 6246 6247 + regex-recursion@6.0.2: 6248 + dependencies: 6249 + regex-utilities: 2.3.0 6250 + 6251 + regex-utilities@2.3.0: {} 6252 + 6253 + regex@6.1.0: 6254 + dependencies: 6255 + regex-utilities: 2.3.0 6256 + 5883 6257 regexpu-core@6.4.0: 5884 6258 dependencies: 5885 6259 regenerate: 1.4.2 ··· 5980 6354 5981 6355 shebang-regex@3.0.0: {} 5982 6356 6357 + shiki@4.0.2: 6358 + dependencies: 6359 + '@shikijs/core': 4.0.2 6360 + '@shikijs/engine-javascript': 4.0.2 6361 + '@shikijs/engine-oniguruma': 4.0.2 6362 + '@shikijs/langs': 4.0.2 6363 + '@shikijs/themes': 4.0.2 6364 + '@shikijs/types': 4.0.2 6365 + '@shikijs/vscode-textmate': 10.0.2 6366 + '@types/hast': 3.0.4 6367 + 5983 6368 side-channel-list@1.0.0: 5984 6369 dependencies: 5985 6370 es-errors: 1.3.0 ··· 6037 6422 6038 6423 source-map@0.6.1: {} 6039 6424 6425 + space-separated-tokens@2.0.2: {} 6426 + 6040 6427 speakingurl@14.0.1: {} 6041 6428 6042 6429 split2@4.2.0: {} ··· 6072 6459 string_decoder@1.3.0: 6073 6460 dependencies: 6074 6461 safe-buffer: 5.2.1 6462 + 6463 + stringify-entities@4.0.4: 6464 + dependencies: 6465 + character-entities-html4: 2.1.0 6466 + character-entities-legacy: 3.0.0 6075 6467 6076 6468 strip-ansi@6.0.1: 6077 6469 dependencies: ··· 6164 6556 6165 6557 tree-kill@1.2.2: {} 6166 6558 6559 + trim-lines@3.0.1: {} 6560 + 6167 6561 ts-api-utils@2.5.0(typescript@5.9.3): 6168 6562 dependencies: 6169 6563 typescript: 5.9.3 ··· 6197 6591 6198 6592 typescript@5.9.3: {} 6199 6593 6594 + uc.micro@2.1.0: {} 6595 + 6200 6596 ufo@1.6.3: {} 6201 6597 6202 6598 undici-types@7.18.2: {} ··· 6214 6610 6215 6611 unicode-segmenter@0.14.5: {} 6216 6612 6613 + unist-util-is@6.0.1: 6614 + dependencies: 6615 + '@types/unist': 3.0.3 6616 + 6617 + unist-util-position@5.0.0: 6618 + dependencies: 6619 + '@types/unist': 3.0.3 6620 + 6621 + unist-util-stringify-position@4.0.0: 6622 + dependencies: 6623 + '@types/unist': 3.0.3 6624 + 6625 + unist-util-visit-parents@6.0.2: 6626 + dependencies: 6627 + '@types/unist': 3.0.3 6628 + unist-util-is: 6.0.1 6629 + 6630 + unist-util-visit@5.1.0: 6631 + dependencies: 6632 + '@types/unist': 3.0.3 6633 + unist-util-is: 6.0.1 6634 + unist-util-visit-parents: 6.0.2 6635 + 6217 6636 universalify@0.2.0: {} 6218 6637 6219 6638 universalify@2.0.1: {} ··· 6244 6663 assert-plus: 1.0.0 6245 6664 core-util-is: 1.0.2 6246 6665 extsprintf: 1.3.0 6666 + 6667 + vfile-message@4.0.3: 6668 + dependencies: 6669 + '@types/unist': 3.0.3 6670 + unist-util-stringify-position: 4.0.0 6671 + 6672 + vfile@6.0.3: 6673 + dependencies: 6674 + '@types/unist': 3.0.3 6675 + vfile-message: 4.0.3 6247 6676 6248 6677 vite-node@0.34.6(@types/node@25.5.0)(terser@5.46.1): 6249 6678 dependencies: ··· 6428 6857 yocto-queue@0.1.0: {} 6429 6858 6430 6859 yocto-queue@1.2.2: {} 6860 + 6861 + zwitch@2.0.4: {}
+223 -13
src/components/repo/MarkdownRenderer.vue
··· 1 - <!-- TODO: markdown → HTML pipeline (e.g. marked + DOMPurify). --> 2 1 <template> 3 - <div class="markdown-body"> 4 - <pre class="markdown-raw">{{ content }}</pre> 5 - </div> 2 + <div class="markdown-body" v-html="renderedHtml" /> 6 3 </template> 7 4 8 5 <script setup lang="ts"> 9 - defineProps<{ content: string }>(); 6 + import { onBeforeUnmount, ref, watch } from "vue"; 7 + import markdownit from "markdown-it"; 8 + import { fromHighlighter } from "@shikijs/markdown-it"; 9 + import { getHighlighter } from "@/lib/syntax.js"; 10 + import { sanitizeRichHtml } from "@/lib/html.js"; 11 + import { resolveRepoImageUrl, type RepoAssetContext } from "@/services/tangled/repo-assets.js"; 12 + 13 + const props = defineProps<{ content: string; repoContext?: RepoAssetContext }>(); 14 + 15 + let mdPromise: Promise<ReturnType<typeof markdownit>> | null = null; 16 + let activeObjectUrls: string[] = []; 17 + 18 + // Languages commonly found in README code blocks — load upfront so aliases (e.g. "sh") resolve. 19 + const PRELOAD_LANGS = [ 20 + "bash", "javascript", "typescript", "python", "json", "yaml", 21 + "html", "css", "rust", "go", "sql", "markdown", "tsx", "jsx", 22 + ] as const; 23 + 24 + async function getMd() { 25 + if (!mdPromise) { 26 + mdPromise = (async () => { 27 + const md = markdownit({ html: true, linkify: true, typographer: true }); 28 + const hl = await getHighlighter(); 29 + await Promise.all(PRELOAD_LANGS.map((l) => hl.loadLanguage(l).catch(() => null))); 30 + md.use(fromHighlighter(hl, { 31 + themes: { light: "github-light", dark: "github-dark" }, 32 + })); 33 + return md; 34 + })(); 35 + } 36 + return mdPromise; 37 + } 38 + 39 + const renderedHtml = ref(""); 40 + 41 + function revokeObjectUrls(urls: string[] = activeObjectUrls) { 42 + for (const url of urls) { 43 + URL.revokeObjectURL(url); 44 + } 45 + 46 + if (urls === activeObjectUrls) { 47 + activeObjectUrls = []; 48 + } 49 + } 50 + 51 + async function resolveImageSources(html: string, repoContext?: RepoAssetContext): Promise<{ html: string; objectUrls: string[] }> { 52 + if (!repoContext) return { html, objectUrls: [] }; 53 + 54 + const parser = new DOMParser(); 55 + const doc = parser.parseFromString(`<body>${html}</body>`, "text/html"); 56 + const objectUrls: string[] = []; 57 + const images = Array.from(doc.body.querySelectorAll("img[src]")); 58 + 59 + await Promise.all(images.map(async (image) => { 60 + const src = image.getAttribute("src")?.trim(); 61 + if (!src) return; 62 + 63 + const resolved = await resolveRepoImageUrl(repoContext, src); 64 + if (!resolved) return; 65 + 66 + image.setAttribute("src", resolved.url); 67 + if (resolved.revoke) objectUrls.push(resolved.url); 68 + })); 69 + 70 + return { html: doc.body.innerHTML, objectUrls }; 71 + } 72 + 73 + watch( 74 + () => [props.content, props.repoContext] as const, 75 + async ([content, repoContext], _, onCleanup) => { 76 + let cancelled = false; 77 + const previousUrls = activeObjectUrls; 78 + const nextUrls: string[] = []; 79 + 80 + onCleanup(() => { 81 + cancelled = true; 82 + revokeObjectUrls(nextUrls); 83 + }); 84 + 85 + const md = await getMd(); 86 + const raw = md.render(content); 87 + const sanitized = sanitizeRichHtml(raw); 88 + const resolved = await resolveImageSources(sanitized, repoContext); 89 + nextUrls.push(...resolved.objectUrls); 90 + 91 + if (cancelled) return; 92 + 93 + activeObjectUrls = nextUrls; 94 + revokeObjectUrls(previousUrls); 95 + renderedHtml.value = resolved.html; 96 + }, 97 + { immediate: true }, 98 + ); 99 + 100 + onBeforeUnmount(() => { 101 + revokeObjectUrls(); 102 + }); 10 103 </script> 11 104 12 105 <style scoped> 13 106 .markdown-body { 14 107 padding: 0 16px 24px; 108 + color: var(--t-text-primary); 109 + font-size: 14px; 110 + line-height: 1.6; 111 + word-break: break-word; 15 112 } 16 113 17 - .markdown-raw { 114 + .markdown-body :deep(h1), 115 + .markdown-body :deep(h2), 116 + .markdown-body :deep(h3), 117 + .markdown-body :deep(h4), 118 + .markdown-body :deep(h5), 119 + .markdown-body :deep(h6) { 120 + font-weight: 600; 121 + line-height: 1.25; 122 + margin: 24px 0 16px; 123 + color: var(--t-text-primary); 124 + } 125 + 126 + .markdown-body :deep(h1) { font-size: 2em; border-bottom: 1px solid var(--t-border); padding-bottom: 0.3em; } 127 + .markdown-body :deep(h2) { font-size: 1.5em; border-bottom: 1px solid var(--t-border); padding-bottom: 0.3em; } 128 + .markdown-body :deep(h3) { font-size: 1.25em; } 129 + 130 + .markdown-body :deep(p) { 131 + margin: 0 0 16px; 132 + } 133 + 134 + .markdown-body :deep(a) { 135 + color: var(--t-accent); 136 + text-decoration: none; 137 + } 138 + 139 + .markdown-body :deep(a:hover) { 140 + text-decoration: underline; 141 + } 142 + 143 + .markdown-body :deep(code) { 18 144 font-family: var(--t-mono); 19 - font-size: 12px; 20 - color: var(--t-text-secondary); 21 - line-height: 1.6; 22 - white-space: pre-wrap; 23 - word-break: break-word; 24 - margin: 0; 145 + font-size: 0.875em; 25 146 background: var(--t-surface-raised); 26 147 border: 1px solid var(--t-border); 148 + border-radius: 4px; 149 + padding: 0.1em 0.4em; 150 + } 151 + 152 + .markdown-body :deep(pre) { 153 + margin: 0 0 16px; 27 154 border-radius: var(--t-radius-md); 28 - padding: 14px 16px; 155 + border: 1px solid var(--t-border); 29 156 overflow-x: auto; 157 + } 158 + 159 + .markdown-body :deep(pre code) { 160 + background: transparent; 161 + border: none; 162 + padding: 0; 163 + font-size: 12px; 164 + } 165 + 166 + .markdown-body :deep(.shiki) { 167 + padding: 16px; 168 + background: var(--t-surface-raised) !important; 169 + font-family: var(--t-mono); 170 + font-size: 12px; 171 + line-height: 1.6; 172 + tab-size: 2; 173 + } 174 + 175 + .markdown-body :deep(.shiki span) { 176 + color: var(--shiki-light); 177 + } 178 + 179 + @media (prefers-color-scheme: dark) { 180 + .markdown-body :deep(.shiki) { 181 + background: var(--t-surface-raised) !important; 182 + } 183 + 184 + .markdown-body :deep(.shiki span) { 185 + color: var(--shiki-dark); 186 + } 187 + } 188 + 189 + .markdown-body :deep(blockquote) { 190 + margin: 0 0 16px; 191 + padding: 0 16px; 192 + border-left: 4px solid var(--t-border); 193 + color: var(--t-text-secondary); 194 + } 195 + 196 + .markdown-body :deep(ul), 197 + .markdown-body :deep(ol) { 198 + margin: 0 0 16px; 199 + padding-left: 2em; 200 + } 201 + 202 + .markdown-body :deep(li) { 203 + margin: 4px 0; 204 + } 205 + 206 + .markdown-body :deep(table) { 207 + width: 100%; 208 + border-collapse: collapse; 209 + margin: 0 0 16px; 210 + font-size: 13px; 211 + overflow-x: auto; 212 + display: block; 213 + } 214 + 215 + .markdown-body :deep(th), 216 + .markdown-body :deep(td) { 217 + padding: 6px 12px; 218 + border: 1px solid var(--t-border); 219 + text-align: left; 220 + } 221 + 222 + .markdown-body :deep(th) { 223 + background: var(--t-surface-raised); 224 + font-weight: 600; 225 + } 226 + 227 + .markdown-body :deep(tr:nth-child(even) td) { 228 + background: var(--t-surface-raised); 229 + } 230 + 231 + .markdown-body :deep(img) { 232 + max-width: 100%; 233 + border-radius: var(--t-radius-md); 234 + } 235 + 236 + .markdown-body :deep(hr) { 237 + border: none; 238 + border-top: 1px solid var(--t-border); 239 + margin: 24px 0; 30 240 } 31 241 </style>
+5 -3
src/core/query/client.ts
··· 1 1 import { QueryClient } from "@tanstack/vue-query"; 2 2 3 + const isDev = import.meta.env.DEV; 4 + 3 5 export const queryClient = new QueryClient({ 4 6 defaultOptions: { 5 7 queries: { 6 - staleTime: 5 * 60 * 1000, // 5 minutes 7 - gcTime: 10 * 60 * 1000, // 10 minutes 8 - retry: 2, 8 + staleTime: isDev ? 0 : 5 * 60 * 1000, 9 + gcTime: isDev ? 0 : 10 * 60 * 1000, 10 + retry: isDev ? 0 : 2, 9 11 }, 10 12 }, 11 13 });
+19 -2
src/features/repo/IssueDetailPage.vue
··· 47 47 <div class="section-head"> 48 48 <h2>Body</h2> 49 49 </div> 50 - <MarkdownRenderer v-if="issue.body" :content="issue.body" /> 50 + <MarkdownRenderer v-if="issue.body" :content="issue.body" :repo-context="markdownContext" /> 51 51 <EmptyState 52 52 v-else 53 53 :icon="documentTextOutline" ··· 92 92 import EmptyState from "@/components/common/EmptyState.vue"; 93 93 import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 94 94 import CommentThread from "@/components/repo/CommentThread.vue"; 95 - import { useIdentity, useRepoRecord, useIssueDetail, useIssueComments } from "@/services/tangled/queries.js"; 95 + import { useIdentity, useRepoRecord, useIssueDetail, useIssueComments, useDefaultBranch } from "@/services/tangled/queries.js"; 96 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 96 97 97 98 const route = useRoute(); 98 99 const owner = computed(() => String(route.params.owner ?? "")); ··· 105 106 const hasIdentity = computed(() => !!identity.data.value); 106 107 107 108 const repoQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 109 + const knotHost = computed(() => repoQuery.data.value?.knot ?? ""); 110 + const knotRepo = computed(() => (did.value && repoName.value ? `${did.value}/${repoName.value}` : "")); 111 + const branchQuery = useDefaultBranch(knotHost, knotRepo, { 112 + enabled: computed(() => !!knotHost.value && !!knotRepo.value), 113 + }); 108 114 const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 115 + const markdownContext = computed<RepoAssetContext | undefined>(() => { 116 + if (!knotHost.value || !knotRepo.value || !branchQuery.data.value?.name) return undefined; 117 + 118 + return { 119 + owner: owner.value, 120 + repo: repoName.value, 121 + branch: branchQuery.data.value.name, 122 + knotHost: knotHost.value, 123 + knotRepo: knotRepo.value, 124 + }; 125 + }); 109 126 110 127 const issueQuery = useIssueDetail(pds, did, owner, issueId, { enabled: hasIdentity }); 111 128 const issueAtUri = computed(() => issueQuery.data.value?.atUri ?? "");
+19 -1
src/features/repo/PullRequestDetailPage.vue
··· 56 56 <div class="section-head"> 57 57 <h2>Body</h2> 58 58 </div> 59 - <MarkdownRenderer v-if="pullRequest.body" :content="pullRequest.body" /> 59 + <MarkdownRenderer v-if="pullRequest.body" :content="pullRequest.body" :repo-context="markdownContext" /> 60 60 <EmptyState 61 61 v-else 62 62 :icon="documentTextOutline" ··· 104 104 import { 105 105 useIdentity, 106 106 useRepoRecord, 107 + useDefaultBranch, 107 108 usePullRequestDetail, 108 109 usePullRequestComments, 109 110 } from "@/services/tangled/queries.js"; 111 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 110 112 111 113 const route = useRoute(); 112 114 const owner = computed(() => String(route.params.owner ?? "")); ··· 119 121 const hasIdentity = computed(() => !!identity.data.value); 120 122 121 123 const repoQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 124 + const knotHost = computed(() => repoQuery.data.value?.knot ?? ""); 125 + const knotRepo = computed(() => (did.value && repoName.value ? `${did.value}/${repoName.value}` : "")); 126 + const branchQuery = useDefaultBranch(knotHost, knotRepo, { 127 + enabled: computed(() => !!knotHost.value && !!knotRepo.value), 128 + }); 122 129 const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 130 + const markdownContext = computed<RepoAssetContext | undefined>(() => { 131 + if (!knotHost.value || !knotRepo.value || !branchQuery.data.value?.name) return undefined; 132 + 133 + return { 134 + owner: owner.value, 135 + repo: repoName.value, 136 + branch: branchQuery.data.value.name, 137 + knotHost: knotHost.value, 138 + knotRepo: knotRepo.value, 139 + }; 140 + }); 123 141 124 142 const pullQuery = usePullRequestDetail(pds, did, owner, pullId, { enabled: hasIdentity }); 125 143 const pullAtUri = computed(() => pullQuery.data.value?.atUri ?? "");
+18 -1
src/features/repo/RepoDetailPage.vue
··· 38 38 39 39 <!-- Content --> 40 40 <template v-else> 41 - <RepoOverview v-if="segment === 'overview'" :repo="repo" :commits="commits" /> 41 + <RepoOverview 42 + v-if="segment === 'overview'" 43 + :repo="repo" 44 + :commits="commits" 45 + :markdown-context="markdownContext" /> 42 46 <RepoFiles 43 47 v-else-if="segment === 'files'" 44 48 :knot-host="knotHost" ··· 91 95 useRepoPRs, 92 96 } from "@/services/tangled/queries.js"; 93 97 import type { RepoDetail } from "@/domain/models/repo.js"; 98 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 94 99 95 100 const route = useRoute(); 96 101 const router = useRouter(); ··· 136 141 const branchQuery = useDefaultBranch(knotHost, knotRepo, { enabled: hasRecord }); 137 142 const defaultBranch = computed(() => branchQuery.data.value?.name ?? ""); 138 143 const hasBranch = computed(() => !!branchQuery.data.value?.name); 144 + const markdownContext = computed<RepoAssetContext | undefined>(() => { 145 + if (!knotHost.value || !knotRepo.value || !defaultBranch.value) return undefined; 146 + 147 + return { 148 + owner: owner.value, 149 + repo: repoName.value, 150 + branch: defaultBranch.value, 151 + knotHost: knotHost.value, 152 + knotRepo: knotRepo.value, 153 + sourcePath: "README.md", 154 + }; 155 + }); 139 156 140 157 const languagesQuery = useRepoLanguages(knotHost, knotRepo, undefined, { enabled: hasBranch }); 141 158 const readmeQuery = useRepoBlob(knotHost, knotRepo, defaultBranch, "README.md", { readme: true, enabled: hasBranch });
+91 -3
src/features/repo/RepoFiles.vue
··· 18 18 title="Could not load file" 19 19 :message="blobQuery.error.value instanceof Error ? blobQuery.error.value.message : 'Unknown error'" /> 20 20 <template v-else-if="blobQuery.data.value"> 21 - <div v-if="blobQuery.data.value.isBinary" class="binary-notice"> 21 + <div v-if="binaryPreviewUrl" class="image-preview-wrap"> 22 + <div class="file-meta"> 23 + <span class="file-size" v-if="blobQuery.data.value.size != null"> 24 + {{ formatSize(blobQuery.data.value.size) }} 25 + </span> 26 + </div> 27 + <img :src="binaryPreviewUrl" :alt="selectedFile?.name ?? 'Repository image'" class="image-preview" /> 28 + </div> 29 + <div v-else-if="blobQuery.data.value.isBinary" class="binary-notice"> 22 30 <ion-icon :icon="documentOutline" class="binary-icon" /> 23 31 Binary file — cannot display. 24 32 </div> ··· 28 36 {{ formatSize(blobQuery.data.value.size) }} 29 37 </span> 30 38 </div> 31 - <pre class="file-content"><code>{{ blobQuery.data.value.content }}</code></pre> 39 + <div 40 + v-if="highlightedHtml" 41 + class="file-content shiki-wrap" 42 + v-html="highlightedHtml" /> 43 + <pre v-else class="file-content"><code>{{ blobQuery.data.value.content }}</code></pre> 32 44 </div> 33 45 </template> 34 46 </template> ··· 55 67 </template> 56 68 57 69 <script setup lang="ts"> 58 - import { ref, computed } from "vue"; 70 + import { ref, computed, watch, onBeforeUnmount } from "vue"; 59 71 import { IonList, IonButton, IonIcon } from "@ionic/vue"; 60 72 import { folderOpenOutline, alertCircleOutline, arrowBackOutline, documentOutline } from "ionicons/icons"; 61 73 import FileTreeItem from "@/components/repo/FileTreeItem.vue"; ··· 63 75 import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 64 76 import { useRepoBlob, useRepoTree } from "@/services/tangled/queries.js"; 65 77 import type { RepoFile } from "@/domain/models/repo.js"; 78 + import { highlightCode } from "@/lib/syntax.js"; 79 + import { createObjectUrlFromBlobContent } from "@/services/tangled/repo-assets.js"; 66 80 67 81 const props = defineProps<{ knotHost: string; knotRepo: string; branch: string }>(); 68 82 ··· 124 138 currentPath.value = segments.join("/"); 125 139 } 126 140 141 + const highlightedHtml = ref<string | null>(null); 142 + const binaryPreviewUrl = ref<string | null>(null); 143 + 144 + watch( 145 + () => [blobQuery.data.value?.content, selectedFile.value?.name] as const, 146 + async ([content, name]) => { 147 + highlightedHtml.value = null; 148 + if (!content || !name) return; 149 + highlightedHtml.value = await highlightCode(content, name); 150 + }, 151 + { immediate: true }, 152 + ); 153 + 154 + watch( 155 + () => blobQuery.data.value, 156 + (blob) => { 157 + if (binaryPreviewUrl.value) { 158 + URL.revokeObjectURL(binaryPreviewUrl.value); 159 + binaryPreviewUrl.value = null; 160 + } 161 + 162 + if (!blob) return; 163 + binaryPreviewUrl.value = createObjectUrlFromBlobContent(blob); 164 + }, 165 + { immediate: true }, 166 + ); 167 + 168 + onBeforeUnmount(() => { 169 + if (binaryPreviewUrl.value) { 170 + URL.revokeObjectURL(binaryPreviewUrl.value); 171 + } 172 + }); 173 + 127 174 function formatSize(bytes: number): string { 128 175 if (bytes < 1024) return `${bytes} B`; 129 176 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; ··· 167 214 overflow: auto; 168 215 } 169 216 217 + .image-preview-wrap { 218 + display: flex; 219 + flex-direction: column; 220 + } 221 + 222 + .image-preview { 223 + display: block; 224 + width: 100%; 225 + height: auto; 226 + object-fit: contain; 227 + background: var(--t-surface-raised); 228 + } 229 + 170 230 .file-meta { 171 231 padding: 6px 16px; 172 232 border-bottom: 1px solid var(--t-border); ··· 203 263 204 264 .binary-icon { 205 265 font-size: 20px; 266 + } 267 + 268 + .shiki-wrap :deep(.shiki) { 269 + margin: 0; 270 + padding: 16px; 271 + font-family: var(--t-mono); 272 + font-size: 12px; 273 + line-height: 1.6; 274 + tab-size: 2; 275 + overflow-x: auto; 276 + background: transparent !important; 277 + } 278 + 279 + .shiki-wrap :deep(.shiki code) { 280 + font-family: inherit; 281 + font-size: inherit; 282 + background: transparent !important; 283 + } 284 + 285 + /* Dual-theme: light tokens visible by default, dark tokens on dark scheme */ 286 + .shiki-wrap :deep(.shiki span) { 287 + color: var(--shiki-light); 288 + } 289 + 290 + @media (prefers-color-scheme: dark) { 291 + .shiki-wrap :deep(.shiki span) { 292 + color: var(--shiki-dark); 293 + } 206 294 } 207 295 </style>
+3 -2
src/features/repo/RepoOverview.vue
··· 43 43 <!-- README --> 44 44 <div class="section"> 45 45 <h3 class="section-label">README</h3> 46 - <MarkdownRenderer v-if="repo.readme" :content="repo.readme" /> 46 + <MarkdownRenderer v-if="repo.readme" :content="repo.readme" :repo-context="markdownContext" /> 47 47 <EmptyState v-else :icon="documentOutline" title="No README" message="This repo doesn't have a README yet." /> 48 48 </div> 49 49 ··· 69 69 import EmptyState from "@/components/common/EmptyState.vue"; 70 70 import type { RepoDetail } from "@/domain/models/repo.js"; 71 71 import type { CommitEntry } from "@/services/tangled/queries.js"; 72 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 72 73 73 - const props = defineProps<{ repo: RepoDetail; commits?: CommitEntry[] }>(); 74 + const props = defineProps<{ repo: RepoDetail; commits?: CommitEntry[]; markdownContext?: RepoAssetContext }>(); 74 75 75 76 function relativeTime(iso: string): string { 76 77 const d = new Date(iso);
+31
src/lib/html.ts
··· 1 + import DOMPurify from "dompurify"; 2 + 3 + const SANITIZE_CONFIG = { 4 + USE_PROFILES: { html: true }, 5 + ADD_ATTR: ["class", "style"], 6 + }; 7 + 8 + export function sanitizeRichHtml(html: string): string { 9 + return stripHtmlComments(DOMPurify.sanitize(html, SANITIZE_CONFIG)); 10 + } 11 + 12 + export function stripHtmlComments(html: string): string { 13 + if (typeof DOMParser === "undefined" || typeof NodeFilter === "undefined") { 14 + return html.replace(/<!--[\s\S]*?-->/g, ""); 15 + } 16 + 17 + const parser = new DOMParser(); 18 + const doc = parser.parseFromString(`<body>${html}</body>`, "text/html"); 19 + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_COMMENT); 20 + const comments: Comment[] = []; 21 + 22 + for (let current = walker.nextNode(); current; current = walker.nextNode()) { 23 + comments.push(current as Comment); 24 + } 25 + 26 + for (const comment of comments) { 27 + comment.parentNode?.removeChild(comment); 28 + } 29 + 30 + return doc.body.innerHTML; 31 + }
+114
src/lib/syntax.ts
··· 1 + import { createHighlighter, type Highlighter } from "shiki"; 2 + import { sanitizeRichHtml } from "./html.js"; 3 + 4 + let promise: Promise<Highlighter> | null = null; 5 + 6 + export function getHighlighter(): Promise<Highlighter> { 7 + if (!promise) { 8 + promise = createHighlighter({ 9 + themes: ["github-light", "github-dark"], 10 + langs: [], 11 + }); 12 + } 13 + return promise; 14 + } 15 + 16 + const LANG_MAP: Record<string, string> = { 17 + js: "javascript", 18 + mjs: "javascript", 19 + cjs: "javascript", 20 + jsx: "jsx", 21 + ts: "typescript", 22 + mts: "typescript", 23 + cts: "typescript", 24 + tsx: "tsx", 25 + vue: "vue", 26 + svelte: "svelte", 27 + astro: "astro", 28 + py: "python", 29 + rb: "ruby", 30 + rs: "rust", 31 + go: "go", 32 + java: "java", 33 + kt: "kotlin", 34 + kts: "kotlin", 35 + swift: "swift", 36 + c: "c", 37 + h: "c", 38 + cpp: "cpp", 39 + cc: "cpp", 40 + cxx: "cpp", 41 + hpp: "cpp", 42 + cs: "csharp", 43 + css: "css", 44 + scss: "scss", 45 + sass: "sass", 46 + less: "less", 47 + html: "html", 48 + htm: "html", 49 + json: "json", 50 + jsonc: "jsonc", 51 + yaml: "yaml", 52 + yml: "yaml", 53 + toml: "toml", 54 + xml: "xml", 55 + svg: "xml", 56 + md: "markdown", 57 + mdx: "mdx", 58 + sh: "bash", 59 + bash: "bash", 60 + zsh: "bash", 61 + fish: "fish", 62 + ps1: "powershell", 63 + sql: "sql", 64 + graphql: "graphql", 65 + gql: "graphql", 66 + tf: "terraform", 67 + r: "r", 68 + lua: "lua", 69 + php: "php", 70 + dart: "dart", 71 + ex: "elixir", 72 + exs: "elixir", 73 + erl: "erlang", 74 + elm: "elm", 75 + clj: "clojure", 76 + cljs: "clojure", 77 + hs: "haskell", 78 + nix: "nix", 79 + proto: "proto", 80 + ini: "ini", 81 + conf: "ini", 82 + }; 83 + 84 + export function detectLang(filename: string): string | null { 85 + const lower = filename.toLowerCase(); 86 + if (lower === "dockerfile" || lower.startsWith("dockerfile.")) return "dockerfile"; 87 + if (lower === "makefile" || lower === "gnumakefile") return "makefile"; 88 + if (lower === "gemfile" || lower === "rakefile") return "ruby"; 89 + if (lower === ".env" || lower.startsWith(".env.")) return "bash"; 90 + 91 + const ext = lower.split(".").pop(); 92 + if (!ext || ext === lower) return null; 93 + return LANG_MAP[ext] ?? null; 94 + } 95 + 96 + export async function highlightCode(code: string, filename: string): Promise<string | null> { 97 + const lang = detectLang(filename); 98 + if (!lang) return null; 99 + 100 + const hl = await getHighlighter(); 101 + const loaded = hl.getLoadedLanguages(); 102 + if (!loaded.includes(lang as never)) { 103 + try { 104 + await hl.loadLanguage(lang as never); 105 + } catch { 106 + return null; 107 + } 108 + } 109 + 110 + return sanitizeRichHtml(hl.codeToHtml(code, { 111 + lang, 112 + themes: { light: "github-light", dark: "github-dark" }, 113 + })); 114 + }
+5 -1
src/main.ts
··· 34 34 /* Theme variables */ 35 35 import "./theme/variables.css"; 36 36 37 - persistQueryClient({ queryClient: queryClient as any, persister: createIdbPersister(), maxAge: 30 * 60 * 1000 }); 37 + if (import.meta.env.DEV) { 38 + void createIdbPersister().removeClient(); 39 + } else { 40 + persistQueryClient({ queryClient: queryClient as any, persister: createIdbPersister(), maxAge: 30 * 60 * 1000 }); 41 + } 38 42 39 43 const app = createApp(App).use(IonicVue).use(router).use(createPinia()).use(VueQueryPlugin, { queryClient }); 40 44
+115
src/services/tangled/repo-assets.ts
··· 1 + import { fetchRepoBlob } from "./endpoints.js"; 2 + import { normalizeBlob, type BlobContent } from "./normalizers.js"; 3 + 4 + export type RepoAssetContext = { 5 + owner: string; 6 + repo: string; 7 + branch: string; 8 + knotHost: string; 9 + knotRepo: string; 10 + sourcePath?: string; 11 + }; 12 + 13 + const EXTERNAL_URL_RE = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i; 14 + const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; 15 + 16 + export function isRenderableImage(blob: Pick<BlobContent, "mimeType">): boolean { 17 + return !!blob.mimeType?.toLowerCase().startsWith("image/"); 18 + } 19 + 20 + export function createObjectUrlFromBlobContent(blob: BlobContent): string | null { 21 + if (!isRenderableImage(blob)) return null; 22 + 23 + const browserBlob = toBrowserBlob(blob); 24 + if (!browserBlob) return null; 25 + 26 + return URL.createObjectURL(browserBlob); 27 + } 28 + 29 + export function buildPublicRawUrl(context: RepoAssetContext, repoPath: string): string { 30 + const owner = encodeURIComponent(context.owner); 31 + const repo = encodeURIComponent(context.repo); 32 + const branch = encodeURIComponent(context.branch); 33 + const path = repoPath 34 + .split("/") 35 + .filter(Boolean) 36 + .map((segment) => encodeURIComponent(segment)) 37 + .join("/"); 38 + 39 + return `https://tangled.org/${owner}/${repo}/raw/${branch}/${path}`; 40 + } 41 + 42 + export function resolveRepoRelativePath(sourcePath: string | undefined, src: string): string | null { 43 + const value = src.trim(); 44 + if (!value || value.startsWith("#") || value.startsWith("data:") || value.startsWith("blob:")) return null; 45 + if (EXTERNAL_URL_RE.test(value) || SCHEME_RE.test(value)) return null; 46 + 47 + const [pathOnly] = value.split(/[?#]/, 1); 48 + if (!pathOnly) return null; 49 + 50 + const baseSegments = value.startsWith("/") 51 + ? [] 52 + : (sourcePath ? dirname(sourcePath).split("/").filter(Boolean) : []); 53 + 54 + const segments = pathOnly.replace(/^\/+/, "").split("/"); 55 + const resolved = [...baseSegments]; 56 + 57 + for (const segment of segments) { 58 + if (!segment || segment === ".") continue; 59 + 60 + if (segment === "..") { 61 + if (!resolved.length) return null; 62 + resolved.pop(); 63 + continue; 64 + } 65 + 66 + resolved.push(segment); 67 + } 68 + 69 + return resolved.join("/"); 70 + } 71 + 72 + export async function resolveRepoImageUrl( 73 + context: RepoAssetContext, 74 + src: string, 75 + ): Promise<{ url: string; revoke: boolean } | null> { 76 + const repoPath = resolveRepoRelativePath(context.sourcePath, src); 77 + if (!repoPath) return null; 78 + 79 + try { 80 + const blob = normalizeBlob(await fetchRepoBlob(context.knotHost, { 81 + repo: context.knotRepo, 82 + ref: context.branch, 83 + path: repoPath, 84 + })); 85 + 86 + const objectUrl = createObjectUrlFromBlobContent(blob); 87 + if (objectUrl) return { url: objectUrl, revoke: true }; 88 + } catch { 89 + // Fall back to the public raw URL if the XRPC lookup fails. 90 + } 91 + 92 + return { url: buildPublicRawUrl(context, repoPath), revoke: false }; 93 + } 94 + 95 + function dirname(path: string): string { 96 + const segments = path.split("/").filter(Boolean); 97 + segments.pop(); 98 + return segments.join("/"); 99 + } 100 + 101 + function toBrowserBlob(blob: BlobContent): Blob | null { 102 + const type = blob.mimeType ?? "application/octet-stream"; 103 + 104 + if (blob.encoding === "base64") { 105 + try { 106 + const binary = atob(blob.content); 107 + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); 108 + return new Blob([bytes], { type }); 109 + } catch { 110 + return null; 111 + } 112 + } 113 + 114 + return new Blob([blob.content], { type }); 115 + }
+16
src/vite-env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 2 /// <reference types="@atcute/bluesky" /> 3 3 /// <reference types="@atcute/tangled" /> 4 + 5 + declare module "markdown-it" { 6 + type MarkdownIt = { 7 + render(content: string): string; 8 + use(plugin: (...args: any[]) => unknown, ...params: any[]): MarkdownIt; 9 + }; 10 + 11 + type MarkdownItOptions = { 12 + html?: boolean; 13 + linkify?: boolean; 14 + typographer?: boolean; 15 + }; 16 + 17 + const markdownit: (options?: MarkdownItOptions) => MarkdownIt; 18 + export default markdownit; 19 + }
+21
tests/unit/tangled-normalizers.spec.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import { buildKnotUrl } from "@/services/tangled/endpoints.js"; 3 3 import { buildIssueCommentThread, normalizeLogText, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; 4 + import { buildPublicRawUrl, resolveRepoRelativePath } from "@/services/tangled/repo-assets.js"; 4 5 import { getAtUriRkey, parseAtUri } from "@/services/tangled/uris.js"; 5 6 import type { IssueComment } from "@/domain/models/comment.js"; 6 7 ··· 116 117 message: "docs: update site", 117 118 when: "2026-03-21T15:08:58Z", 118 119 }); 120 + }); 121 + 122 + it("resolves repo-relative markdown asset paths", () => { 123 + expect(resolveRepoRelativePath("docs/guides/README.md", "../images/sidebar.png")).toBe("docs/images/sidebar.png"); 124 + expect(resolveRepoRelativePath("README.md", "/www/src/static/images/context-menu-in-sidebar.png")).toBe( 125 + "www/src/static/images/context-menu-in-sidebar.png", 126 + ); 127 + expect(resolveRepoRelativePath("README.md", "../../../escape.png")).toBeNull(); 128 + }); 129 + 130 + it("builds public raw URLs for repo assets", () => { 131 + expect(buildPublicRawUrl({ 132 + owner: "desertthunder.dev", 133 + repo: "writer", 134 + branch: "main", 135 + knotHost: "unused", 136 + knotRepo: "unused", 137 + }, "www/src/static/images/context-menu-in-sidebar.png")).toBe( 138 + "https://tangled.org/desertthunder.dev/writer/raw/main/www/src/static/images/context-menu-in-sidebar.png", 139 + ); 119 140 }); 120 141 }); 121 142