This repository has no description
0

Configure Feed

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

web: render markdown readmes

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 25, 2026, 9:13 PM +0300) commit be0f84fa parent 2e73f096 change-id mlulwxxz
+1097 -6
+10
web/package.json
··· 38 38 "@sveltejs/kit": "^2.69.3", 39 39 "@sveltejs/vite-plugin-svelte": "^7.2.0", 40 40 "@tailwindcss/vite": "^4.3.2", 41 + "@types/markdown-it": "^14.1.2", 42 + "@types/markdown-it-emoji": "^3.0.1", 41 43 "@types/node": "^24.13.3", 44 + "@types/sanitize-html": "^2.16.1", 42 45 "@vitest/browser-playwright": "4.1.10", 43 46 "@vitest/coverage-v8": "4.1.10", 44 47 "eslint": "^10.7.0", ··· 67 70 "@atcute/lexicons": "^2.0.2", 68 71 "@atcute/oauth-browser-client": "^4.0.1", 69 72 "@atcute/tid": "^1.1.4", 73 + "@mdit/plugin-alert": "^1.0.1", 74 + "@mdit/plugin-footnote": "^1.0.1", 75 + "@mdit/plugin-tasklist": "^1.0.1", 76 + "markdown-it": "^14.3.0", 77 + "markdown-it-anchor": "^9.2.1", 78 + "markdown-it-emoji": "^3.1.0", 79 + "sanitize-html": "^2.17.6", 70 80 "tailwind-merge": "^3.6.0", 71 81 "tailwind-variants": "^3.2.2" 72 82 }
+301
web/pnpm-lock.yaml
··· 26 26 '@atcute/tid': 27 27 specifier: ^1.1.4 28 28 version: 1.1.4 29 + '@mdit/plugin-alert': 30 + specifier: ^1.0.1 31 + version: 1.0.1(markdown-it@14.3.0) 32 + '@mdit/plugin-footnote': 33 + specifier: ^1.0.1 34 + version: 1.0.1(markdown-it@14.3.0) 35 + '@mdit/plugin-tasklist': 36 + specifier: ^1.0.1 37 + version: 1.0.1(markdown-it@14.3.0) 38 + markdown-it: 39 + specifier: ^14.3.0 40 + version: 14.3.0 41 + markdown-it-anchor: 42 + specifier: ^9.2.1 43 + version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0) 44 + markdown-it-emoji: 45 + specifier: ^3.1.0 46 + version: 3.1.0 47 + sanitize-html: 48 + specifier: ^2.17.6 49 + version: 2.17.6 29 50 tailwind-merge: 30 51 specifier: ^3.6.0 31 52 version: 3.6.0 ··· 75 96 '@tailwindcss/vite': 76 97 specifier: ^4.3.2 77 98 version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) 99 + '@types/markdown-it': 100 + specifier: ^14.1.2 101 + version: 14.1.2 102 + '@types/markdown-it-emoji': 103 + specifier: ^3.0.1 104 + version: 3.0.1 78 105 '@types/node': 79 106 specifier: ^24.13.3 80 107 version: 24.13.3 108 + '@types/sanitize-html': 109 + specifier: ^2.16.1 110 + version: 2.16.1 81 111 '@vitest/browser-playwright': 82 112 specifier: 4.1.10 83 113 version: 4.1.10(playwright@1.61.1)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) ··· 561 591 '@jridgewell/trace-mapping@0.3.31': 562 592 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 563 593 594 + '@mdit/plugin-alert@1.0.1': 595 + resolution: {integrity: sha512-gEZgbwlK2JSNIW3izqQyPoYJ8NXAEU0aON7XYpUHBzyD9km+ny5zFkAmoogMAzR8ird8iCQiK5pSv3nOtPBlLw==} 596 + engines: {node: '>=22'} 597 + peerDependencies: 598 + markdown-it: ^14.2.0 599 + peerDependenciesMeta: 600 + markdown-it: 601 + optional: true 602 + 603 + '@mdit/plugin-footnote@1.0.1': 604 + resolution: {integrity: sha512-PrH02dlVQT8/vPvfGrLHpcHq8N0+gOaAVNOfuhwlPqWCL4He3o+buUCxm1Uem02+UR8/TRBW50M/XD/dVSe2jw==} 605 + engines: {node: '>=22'} 606 + peerDependencies: 607 + markdown-it: ^14.2.0 608 + 609 + '@mdit/plugin-tasklist@1.0.1': 610 + resolution: {integrity: sha512-Ks/Tihw5ibbkP2qOZzltkbiTk8RfpxDRvqdn4hl1wIs3VmeKITIQ36gN4DsuotSauA9SBe/mL0QJIfuSCrXqrw==} 611 + engines: {node: '>=22'} 612 + peerDependencies: 613 + markdown-it: ^14.2.0 614 + peerDependenciesMeta: 615 + markdown-it: 616 + optional: true 617 + 564 618 '@mdx-js/react@3.1.1': 565 619 resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} 566 620 peerDependencies: ··· 1398 1452 '@types/json-schema@7.0.15': 1399 1453 resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 1400 1454 1455 + '@types/linkify-it@5.0.0': 1456 + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} 1457 + 1458 + '@types/markdown-it-emoji@3.0.1': 1459 + resolution: {integrity: sha512-cz1j8R35XivBqq9mwnsrP2fsz2yicLhB8+PDtuVkKOExwEdsVBNI+ROL3sbhtR5occRZ66vT0QnwFZCqdjf3pA==} 1460 + 1461 + '@types/markdown-it@14.1.2': 1462 + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} 1463 + 1464 + '@types/mdurl@2.0.0': 1465 + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} 1466 + 1401 1467 '@types/mdx@2.0.14': 1402 1468 resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} 1403 1469 ··· 1409 1475 1410 1476 '@types/resolve@1.20.2': 1411 1477 resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} 1478 + 1479 + '@types/sanitize-html@2.16.1': 1480 + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} 1412 1481 1413 1482 '@types/trusted-types@2.0.7': 1414 1483 resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} ··· 1561 1630 resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} 1562 1631 engines: {node: '>=10'} 1563 1632 1633 + argparse@2.0.1: 1634 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 1635 + 1564 1636 aria-query@5.3.0: 1565 1637 resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} 1566 1638 ··· 1669 1741 csstype@3.2.3: 1670 1742 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 1671 1743 1744 + dayjs@1.11.21: 1745 + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} 1746 + 1672 1747 debug@4.4.3: 1673 1748 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} 1674 1749 engines: {node: '>=6.0'} ··· 1729 1804 dom-accessibility-api@0.6.3: 1730 1805 resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} 1731 1806 1807 + dom-serializer@2.0.0: 1808 + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} 1809 + 1810 + dom-serializer@3.1.1: 1811 + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} 1812 + engines: {node: '>=20.19.0'} 1813 + 1814 + domelementtype@2.3.0: 1815 + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} 1816 + 1817 + domelementtype@3.0.0: 1818 + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} 1819 + engines: {node: '>=20.19.0'} 1820 + 1821 + domhandler@5.0.3: 1822 + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} 1823 + engines: {node: '>= 4'} 1824 + 1825 + domhandler@6.0.1: 1826 + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} 1827 + engines: {node: '>=20.19.0'} 1828 + 1829 + domutils@3.2.2: 1830 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 1831 + 1832 + domutils@4.0.2: 1833 + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} 1834 + engines: {node: '>=20.19.0'} 1835 + 1732 1836 enhanced-resolve@5.21.6: 1733 1837 resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} 1734 1838 engines: {node: '>=10.13.0'} 1735 1839 1840 + entities@4.5.0: 1841 + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} 1842 + engines: {node: '>=0.12'} 1843 + 1844 + entities@7.0.1: 1845 + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} 1846 + engines: {node: '>=0.12'} 1847 + 1848 + entities@8.0.0: 1849 + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} 1850 + engines: {node: '>=20.19.0'} 1851 + 1736 1852 es-errors@1.3.0: 1737 1853 resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} 1738 1854 engines: {node: '>= 0.4'} ··· 1935 2051 html-escaper@2.0.2: 1936 2052 resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} 1937 2053 2054 + htmlparser2@10.1.0: 2055 + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} 2056 + 2057 + htmlparser2@12.0.0: 2058 + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} 2059 + engines: {node: '>=20.19.0'} 2060 + 1938 2061 ignore@5.3.2: 1939 2062 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} 1940 2063 engines: {node: '>= 4'} ··· 1979 2102 is-module@1.0.0: 1980 2103 resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} 1981 2104 2105 + is-plain-object@5.0.0: 2106 + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} 2107 + engines: {node: '>=0.10.0'} 2108 + 1982 2109 is-reference@1.2.1: 1983 2110 resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} 1984 2111 ··· 2038 2165 2039 2166 known-css-properties@0.37.0: 2040 2167 resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} 2168 + 2169 + launder@1.7.1: 2170 + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} 2041 2171 2042 2172 levn@0.4.1: 2043 2173 resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} ··· 2120 2250 lilconfig@2.1.0: 2121 2251 resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} 2122 2252 engines: {node: '>=10'} 2253 + 2254 + linkify-it@5.0.2: 2255 + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} 2123 2256 2124 2257 local-pkg@1.2.1: 2125 2258 resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} ··· 2149 2282 resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} 2150 2283 engines: {node: '>=10'} 2151 2284 2285 + markdown-it-anchor@9.2.1: 2286 + resolution: {integrity: sha512-p6APiLJDFAW2GEvaavDvhIBn7jrX2jLv77NkBGgNacFTurbORYc4pyYySg/mI6mpR6cHQuAtzKtmqgQr4K8dsQ==} 2287 + peerDependencies: 2288 + '@types/markdown-it': '*' 2289 + markdown-it: '*' 2290 + 2291 + markdown-it-emoji@3.1.0: 2292 + resolution: {integrity: sha512-NhmMEH2ywduD4Nty1E8uB5NqfLhAT1VR0dyvoJyStKOqCzbZmVdn/+8wj7zpDsb/fLBikpCPsWwxqKlvMmbz4g==} 2293 + 2294 + markdown-it@14.3.0: 2295 + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} 2296 + hasBin: true 2297 + 2298 + mdurl@2.1.0: 2299 + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} 2300 + 2152 2301 min-indent@1.0.1: 2153 2302 resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} 2154 2303 engines: {node: '>=4'} ··· 2214 2363 package-manager-detector@1.7.0: 2215 2364 resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} 2216 2365 2366 + parse-srcset@1.0.2: 2367 + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} 2368 + 2217 2369 path-exists@4.0.0: 2218 2370 resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 2219 2371 engines: {node: '>=8'} ··· 2366 2518 resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} 2367 2519 engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} 2368 2520 2521 + punycode.js@2.3.1: 2522 + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} 2523 + engines: {node: '>=6'} 2524 + 2369 2525 punycode@2.3.1: 2370 2526 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 2371 2527 engines: {node: '>=6'} ··· 2420 2576 resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 2421 2577 engines: {node: '>=6'} 2422 2578 2579 + sanitize-html@2.17.6: 2580 + resolution: {integrity: sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==} 2581 + engines: {node: '>=22.12.0'} 2582 + 2423 2583 scheduler@0.27.0: 2424 2584 resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} 2425 2585 ··· 2622 2782 resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} 2623 2783 engines: {node: '>=14.17'} 2624 2784 hasBin: true 2785 + 2786 + uc.micro@2.1.0: 2787 + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} 2625 2788 2626 2789 ufo@1.6.4: 2627 2790 resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} ··· 3246 3409 '@jridgewell/resolve-uri': 3.1.2 3247 3410 '@jridgewell/sourcemap-codec': 1.5.5 3248 3411 3412 + '@mdit/plugin-alert@1.0.1(markdown-it@14.3.0)': 3413 + dependencies: 3414 + '@types/markdown-it': 14.1.2 3415 + optionalDependencies: 3416 + markdown-it: 14.3.0 3417 + 3418 + '@mdit/plugin-footnote@1.0.1(markdown-it@14.3.0)': 3419 + dependencies: 3420 + '@types/markdown-it': 14.1.2 3421 + markdown-it: 14.3.0 3422 + 3423 + '@mdit/plugin-tasklist@1.0.1(markdown-it@14.3.0)': 3424 + dependencies: 3425 + '@types/markdown-it': 14.1.2 3426 + optionalDependencies: 3427 + markdown-it: 14.3.0 3428 + 3249 3429 '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7)': 3250 3430 dependencies: 3251 3431 '@types/mdx': 2.0.14 ··· 3879 4059 3880 4060 '@types/json-schema@7.0.15': {} 3881 4061 4062 + '@types/linkify-it@5.0.0': {} 4063 + 4064 + '@types/markdown-it-emoji@3.0.1': 4065 + dependencies: 4066 + '@types/markdown-it': 14.1.2 4067 + 4068 + '@types/markdown-it@14.1.2': 4069 + dependencies: 4070 + '@types/linkify-it': 5.0.0 4071 + '@types/mdurl': 2.0.0 4072 + 4073 + '@types/mdurl@2.0.0': {} 4074 + 3882 4075 '@types/mdx@2.0.14': {} 3883 4076 3884 4077 '@types/node@24.13.3': ··· 3890 4083 csstype: 3.2.3 3891 4084 3892 4085 '@types/resolve@1.20.2': {} 4086 + 4087 + '@types/sanitize-html@2.16.1': 4088 + dependencies: 4089 + htmlparser2: 10.1.0 3893 4090 3894 4091 '@types/trusted-types@2.0.7': {} 3895 4092 ··· 4114 4311 4115 4312 ansi-styles@5.2.0: {} 4116 4313 4314 + argparse@2.0.1: {} 4315 + 4117 4316 aria-query@5.3.0: 4118 4317 dependencies: 4119 4318 dequal: 2.0.3 ··· 4192 4391 4193 4392 csstype@3.2.3: {} 4194 4393 4394 + dayjs@1.11.21: {} 4395 + 4195 4396 debug@4.4.3: 4196 4397 dependencies: 4197 4398 ms: 2.1.3 ··· 4225 4426 4226 4427 dom-accessibility-api@0.6.3: {} 4227 4428 4429 + dom-serializer@2.0.0: 4430 + dependencies: 4431 + domelementtype: 2.3.0 4432 + domhandler: 5.0.3 4433 + entities: 4.5.0 4434 + 4435 + dom-serializer@3.1.1: 4436 + dependencies: 4437 + domelementtype: 3.0.0 4438 + domhandler: 6.0.1 4439 + entities: 8.0.0 4440 + 4441 + domelementtype@2.3.0: {} 4442 + 4443 + domelementtype@3.0.0: {} 4444 + 4445 + domhandler@5.0.3: 4446 + dependencies: 4447 + domelementtype: 2.3.0 4448 + 4449 + domhandler@6.0.1: 4450 + dependencies: 4451 + domelementtype: 3.0.0 4452 + 4453 + domutils@3.2.2: 4454 + dependencies: 4455 + dom-serializer: 2.0.0 4456 + domelementtype: 2.3.0 4457 + domhandler: 5.0.3 4458 + 4459 + domutils@4.0.2: 4460 + dependencies: 4461 + dom-serializer: 3.1.1 4462 + domelementtype: 3.0.0 4463 + domhandler: 6.0.1 4464 + 4228 4465 enhanced-resolve@5.21.6: 4229 4466 dependencies: 4230 4467 graceful-fs: 4.2.11 4231 4468 tapable: 2.3.3 4469 + 4470 + entities@4.5.0: {} 4471 + 4472 + entities@7.0.1: {} 4473 + 4474 + entities@8.0.0: {} 4232 4475 4233 4476 es-errors@1.3.0: {} 4234 4477 ··· 4459 4702 4460 4703 html-escaper@2.0.2: {} 4461 4704 4705 + htmlparser2@10.1.0: 4706 + dependencies: 4707 + domelementtype: 2.3.0 4708 + domhandler: 5.0.3 4709 + domutils: 3.2.2 4710 + entities: 7.0.1 4711 + 4712 + htmlparser2@12.0.0: 4713 + dependencies: 4714 + domelementtype: 3.0.0 4715 + domhandler: 6.0.1 4716 + domutils: 4.0.2 4717 + entities: 8.0.0 4718 + 4462 4719 ignore@5.3.2: {} 4463 4720 4464 4721 ignore@7.0.6: {} ··· 4486 4743 is-docker: 3.0.0 4487 4744 4488 4745 is-module@1.0.0: {} 4746 + 4747 + is-plain-object@5.0.0: {} 4489 4748 4490 4749 is-reference@1.2.1: 4491 4750 dependencies: ··· 4542 4801 4543 4802 known-css-properties@0.37.0: {} 4544 4803 4804 + launder@1.7.1: 4805 + dependencies: 4806 + dayjs: 1.11.21 4807 + 4545 4808 levn@0.4.1: 4546 4809 dependencies: 4547 4810 prelude-ls: 1.2.1 ··· 4598 4861 4599 4862 lilconfig@2.1.0: {} 4600 4863 4864 + linkify-it@5.0.2: 4865 + dependencies: 4866 + uc.micro: 2.1.0 4867 + 4601 4868 local-pkg@1.2.1: 4602 4869 dependencies: 4603 4870 mlly: 1.8.2 ··· 4628 4895 dependencies: 4629 4896 semver: 7.8.5 4630 4897 4898 + markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0): 4899 + dependencies: 4900 + '@types/markdown-it': 14.1.2 4901 + markdown-it: 14.3.0 4902 + 4903 + markdown-it-emoji@3.1.0: {} 4904 + 4905 + markdown-it@14.3.0: 4906 + dependencies: 4907 + argparse: 2.0.1 4908 + entities: 4.5.0 4909 + linkify-it: 5.0.2 4910 + mdurl: 2.1.0 4911 + punycode.js: 2.3.1 4912 + uc.micro: 2.1.0 4913 + 4914 + mdurl@2.1.0: {} 4915 + 4631 4916 min-indent@1.0.1: {} 4632 4917 4633 4918 minimatch@10.2.5: ··· 4728 5013 4729 5014 package-manager-detector@1.7.0: {} 4730 5015 5016 + parse-srcset@1.0.2: {} 5017 + 4731 5018 path-exists@4.0.0: {} 4732 5019 4733 5020 path-key@3.1.1: {} ··· 4811 5098 ansi-styles: 5.2.0 4812 5099 react-is: 17.0.2 4813 5100 5101 + punycode.js@2.3.1: {} 5102 + 4814 5103 punycode@2.3.1: {} 4815 5104 4816 5105 quansync@0.2.11: {} ··· 4904 5193 dependencies: 4905 5194 mri: 1.2.0 4906 5195 5196 + sanitize-html@2.17.6: 5197 + dependencies: 5198 + deepmerge: 4.3.1 5199 + escape-string-regexp: 4.0.0 5200 + htmlparser2: 12.0.0 5201 + is-plain-object: 5.0.0 5202 + launder: 1.7.1 5203 + parse-srcset: 1.0.2 5204 + postcss: 8.5.19 5205 + 4907 5206 scheduler@0.27.0: {} 4908 5207 4909 5208 scule@1.3.0: {} ··· 5099 5398 typescript@5.9.3: {} 5100 5399 5101 5400 typescript@6.0.3: {} 5401 + 5402 + uc.micro@2.1.0: {} 5102 5403 5103 5404 ufo@1.6.4: {} 5104 5405
+1
web/src/app.css
··· 1 1 @import "tailwindcss"; 2 + @import "./markup.css"; 2 3 3 4 @theme { 4 5 --font-sans: "InterVariable", "system-ui", sans-serif, ui-sans-serif;
+10 -4
web/src/lib/components/repo/Readme.svelte
··· 4 4 interface Props { 5 5 filename: string; 6 6 contents: string; 7 + html?: string | null; 7 8 } 8 9 9 - let { filename, contents }: Props = $props(); 10 + let { filename, contents, html = null }: Props = $props(); 10 11 </script> 11 12 12 13 <div class="mt-4 w-full overflow-hidden rounded bg-background-default shadow-sm"> ··· 16 17 <span class="font-mono text-sm text-foreground-muted">{filename}</span> 17 18 </span> 18 19 </div> 19 - <div class="overflow-x-auto px-6 py-4"> 20 - <pre class="font-mono text-sm whitespace-pre-wrap text-foreground-default">{contents}</pre> 21 - </div> 20 + {#if html} 21 + <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 22 + <div class="markup overflow-x-auto px-6 py-4">{@html html}</div> 23 + {:else} 24 + <div class="overflow-x-auto px-6 py-4"> 25 + <pre class="font-mono text-sm whitespace-pre-wrap text-foreground-default">{contents}</pre> 26 + </div> 27 + {/if} 22 28 </div>
+4
web/src/lib/markup/format.ts
··· 1 + // the extensions appview/pages/markup/format.go treats as markdown 2 + const MARKDOWN = /\.(md|markdown|mdown|mkdn|mkd)$/i; 3 + 4 + export const isMarkdownFile = (filename: string): boolean => MARKDOWN.test(filename);
+3
web/src/lib/markup/index.ts
··· 1 + export * from "./format"; 2 + export * from "./paths"; 3 + export * from "./render";
+167
web/src/lib/markup/markdown.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { isMarkdownFile } from "./format"; 3 + import { renderMarkdown } from "./markdown"; 4 + import type { MarkupContext } from "./paths"; 5 + 6 + const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" }; 7 + const render = (source: string, overrides: Partial<MarkupContext> = {}) => 8 + renderMarkdown(source, { ...ctx, ...overrides }); 9 + 10 + describe("isMarkdownFile", () => { 11 + it("knows the markdown extensions from the plain text ones", () => { 12 + expect(isMarkdownFile("README.md")).toBe(true); 13 + expect(isMarkdownFile("readme.MARKDOWN")).toBe(true); 14 + expect(isMarkdownFile("readme.mkd")).toBe(true); 15 + expect(isMarkdownFile("README")).toBe(false); 16 + expect(isMarkdownFile("README.rst")).toBe(false); 17 + }); 18 + }); 19 + 20 + describe("renderMarkdown", () => { 21 + it("renders gfm tables and strikethrough", () => { 22 + const html = render("| a | b |\n| - | - |\n| 1 | 2 |\n\n~~gone~~"); 23 + expect(html).toContain("<table>"); 24 + expect(html).toContain("<s>gone</s>"); 25 + }); 26 + 27 + it("gives headings github's slugs and an anchor link", () => { 28 + const html = render("## Hello, World!"); 29 + expect(html).toContain('id="hello-world"'); 30 + expect(html).toContain('<a class="anchor" href="#hello-world">#</a>'); 31 + }); 32 + 33 + it("points relative links at the file browser", () => { 34 + expect(render("[docs](./docs/setup.md)")).toContain( 35 + 'href="/ada.test/infra/tree/main/docs/setup.md"' 36 + ); 37 + }); 38 + 39 + it("resolves a link against the directory the document is in", () => { 40 + const html = render("[sibling](../other.md)", { dir: "docs/guide" }); 41 + expect(html).toContain('href="/ada.test/infra/tree/main/docs/other.md"'); 42 + }); 43 + 44 + it("treats an absolute path as repo relative, not host relative", () => { 45 + expect(render("[root](/LICENSE)", { dir: "docs" })).toContain( 46 + 'href="/ada.test/infra/tree/main/LICENSE"' 47 + ); 48 + }); 49 + 50 + it("keeps a fragment on a rewritten link", () => { 51 + expect(render("[part](./setup.md#install)")).toContain( 52 + 'href="/ada.test/infra/tree/main/setup.md#install"' 53 + ); 54 + }); 55 + 56 + it("leaves fragments, mail and absolute urls alone", () => { 57 + const html = render("[a](#top) [b](mailto:ada@test) [c](https://example.com)"); 58 + expect(html).toContain('href="#top"'); 59 + expect(html).toContain('href="mailto:ada@test"'); 60 + expect(html).toContain('href="https://example.com"'); 61 + }); 62 + 63 + it("marks off-site links as untrusted", () => { 64 + expect(render("[c](https://example.com)")).toContain('rel="nofollow noopener noreferrer"'); 65 + expect(render("[a](./x.md)")).not.toContain("nofollow"); 66 + }); 67 + 68 + it("points relative images at the raw file, including raw html ones", () => { 69 + expect(render("![logo](assets/logo.png)")).toContain( 70 + 'src="/ada.test/infra/raw/main/assets/logo.png"' 71 + ); 72 + expect(render('<img src="assets/logo.png" alt="logo">')).toContain( 73 + 'src="/ada.test/infra/raw/main/assets/logo.png"' 74 + ); 75 + }); 76 + 77 + it("rewrites every candidate in a srcset", () => { 78 + const html = render('<picture><source srcset="a.png 1x, b.png 2x"></picture>'); 79 + expect(html).toContain( 80 + 'srcset="/ada.test/infra/raw/main/a.png 1x, /ada.test/infra/raw/main/b.png 2x"' 81 + ); 82 + }); 83 + 84 + it("links a bare handle to its profile", () => { 85 + const html = render("thanks @ada.test for the fix"); 86 + expect(html).toContain('<a href="/ada.test" class="mention">@ada.test</a>'); 87 + }); 88 + 89 + it("leaves a handle inside a link label as text", () => { 90 + expect(render("[ask @ada.test](https://example.com)")).not.toContain("mention"); 91 + }); 92 + 93 + it("does not read an email address as a mention", () => { 94 + expect(render("mail ada@ada.test now")).not.toContain("mention"); 95 + }); 96 + 97 + it("shortens our own commit urls to a sha", () => { 98 + const url = 99 + "https://tangled.org/ada.test/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8"; 100 + expect(render(url)).toContain("<code>0c4d0e9b</code>"); 101 + }); 102 + 103 + it("leaves a commit url that has its own label", () => { 104 + const html = render( 105 + "[the fix](https://tangled.org/ada.test/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8)" 106 + ); 107 + expect(html).toContain("the fix"); 108 + expect(html).not.toContain("<code>"); 109 + }); 110 + 111 + it("leaves another host's commit urls as urls", () => { 112 + const url = "https://github.com/ada/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8"; 113 + expect(render(url)).not.toContain("<code>"); 114 + }); 115 + 116 + it("renders task lists as disabled checkboxes", () => { 117 + const html = render("- [x] done\n- [ ] not"); 118 + expect(html).toContain('type="checkbox"'); 119 + expect(html).toContain('checked="checked"'); 120 + expect(html).toContain('disabled="disabled"'); 121 + }); 122 + 123 + it("renders footnotes with their backlinks", () => { 124 + const html = render("a claim[^1]\n\n[^1]: the source"); 125 + expect(html).toContain('class="footnote-ref"'); 126 + expect(html).toContain('class="footnote-backref"'); 127 + expect(html).toContain("the source"); 128 + }); 129 + 130 + it("renders github style alerts", () => { 131 + const html = render("> [!WARNING]\n> careful"); 132 + expect(html).toContain('class="markdown-alert markdown-alert-warning"'); 133 + expect(html).toContain("careful"); 134 + }); 135 + 136 + it("renders emoji shortcodes", () => { 137 + expect(render("ship it :tada:")).toContain("🎉"); 138 + }); 139 + 140 + it("keeps the language on a fenced block", () => { 141 + expect(render("```rust\nfn main() {}\n```")).toContain('class="language-rust"'); 142 + }); 143 + 144 + it("strips scripts, event handlers and javascript urls", () => { 145 + expect(render("<script>alert(1)</script>")).not.toContain("alert"); 146 + expect(render('<img src="x" onerror="alert(1)">')).not.toContain("onerror"); 147 + // markdown-it refuses the destination outright, so this stays plain text 148 + expect(render("[x](javascript:alert(1))")).not.toContain("<a"); 149 + expect(render('<a href="data:text/html,hi">x</a>')).not.toContain("data:"); 150 + }); 151 + 152 + it("drops classes it does not recognise", () => { 153 + expect(render('<div class="fixed inset-0 bg-black">boo</div>')).not.toContain("inset-0"); 154 + }); 155 + 156 + it("drops inputs that are not task list checkboxes", () => { 157 + expect(render('<input type="password" name="p">')).not.toContain("<input"); 158 + }); 159 + 160 + it("keeps the html a readme actually uses for layout", () => { 161 + const html = render( 162 + '<div align="center"><h1>infra</h1></div>\n\n<details><summary>more</summary>\n\nhidden\n\n</details>' 163 + ); 164 + expect(html).toContain('<div align="center">'); 165 + expect(html).toContain("<details><summary>more</summary>"); 166 + }); 167 + });
+124
web/src/lib/markup/markdown.ts
··· 1 + import { alert } from "@mdit/plugin-alert"; 2 + import { footnote } from "@mdit/plugin-footnote"; 3 + import { tasklist } from "@mdit/plugin-tasklist"; 4 + import MarkdownIt from "markdown-it"; 5 + import anchor from "markdown-it-anchor"; 6 + import { full as emoji } from "markdown-it-emoji"; 7 + import { sanitizeMarkup } from "./sanitize"; 8 + import type { MarkupContext } from "./paths"; 9 + 10 + // past this a document renders as plain text, a readme this big is pathological 11 + export const SOURCE_LIMIT = 512 * 1024; 12 + 13 + // github's heading slugs, that is what fragment links in a readme are written 14 + // against 15 + const slugify = (text: string): string => 16 + text 17 + .trim() 18 + .toLowerCase() 19 + .replace(/[^\p{L}\p{N}\p{M}\s_-]+/gu, "") 20 + .replace(/\s+/g, "-"); 21 + 22 + // the dotted dns handle appview/pages/markup/extension/atlink.go matches 23 + const MENTION = /^@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z][a-zA-Z0-9-]*\b/; 24 + 25 + const mentions = (md: MarkdownIt): void => { 26 + md.inline.ruler.before("link", "mention", (state, silent) => { 27 + // inside a link label a mention would nest an anchor in an anchor. markdown-it 28 + // tracks this but its published types leave the field out 29 + if ((state as unknown as { linkLevel: number }).linkLevel > 0) return false; 30 + if (state.src.charCodeAt(state.pos) !== 0x40) return false; 31 + const before = state.pos === 0 ? " " : state.src[state.pos - 1]; 32 + if (before !== " " && before !== "\n" && before !== "(") return false; 33 + const match = MENTION.exec(state.src.slice(state.pos, state.posMax)); 34 + if (!match) return false; 35 + 36 + if (!silent) { 37 + const open = state.push("link_open", "a", 1); 38 + open.attrs = [ 39 + ["href", `/${match[0].slice(1)}`], 40 + ["class", "mention"] 41 + ]; 42 + state.push("text", "", 0).content = match[0]; 43 + state.push("link_close", "a", -1); 44 + } 45 + state.pos += match[0].length; 46 + return true; 47 + }); 48 + }; 49 + 50 + const SHA = /^[0-9a-f]{7,40}$/; 51 + 52 + // `https://host/owner/repo/commit/<sha>` is a mouthful to read inline 53 + const shortSha = (href: string, host: string): string | null => { 54 + let url: URL; 55 + try { 56 + url = new URL(href); 57 + } catch { 58 + return null; 59 + } 60 + if (url.host !== host) return null; 61 + const parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/"); 62 + if (parts.length !== 4 || parts[2] !== "commit" || !SHA.test(parts[3])) return null; 63 + return parts[3].slice(0, 8); 64 + }; 65 + 66 + const commitLinks = 67 + (host: string) => 68 + (md: MarkdownIt): void => { 69 + md.core.ruler.push("commit_link", (state) => { 70 + for (const token of state.tokens) { 71 + if (token.type !== "inline" || !token.children) continue; 72 + for (let i = 0; i < token.children.length; i++) { 73 + const open = token.children[i]; 74 + // only bare urls, a link someone gave a label to keeps it 75 + if (open.type !== "link_open" || open.info !== "auto") continue; 76 + const text = token.children[i + 1]; 77 + if (text?.type !== "text" || token.children[i + 2]?.type !== "link_close") continue; 78 + const sha = shortSha(open.attrGet("href") ?? "", host); 79 + if (!sha) continue; 80 + const code = new state.Token("code_inline", "code", 0); 81 + code.content = sha; 82 + token.children[i + 1] = code; 83 + } 84 + } 85 + }); 86 + }; 87 + 88 + // todo: no syntax highlighting, mermaid or math yet. the first two want a client 89 + // side renderer, math wants mathjax loaded on demand the way the appview does it 90 + const build = (host: string): MarkdownIt => { 91 + const md = new MarkdownIt({ 92 + html: true, 93 + linkify: true, 94 + // the appview only rewrites dashes in the blog, readmes keep their text 95 + typographer: false 96 + }) 97 + .use(anchor, { 98 + slugify, 99 + permalink: anchor.permalink.linkInsideHeader({ class: "anchor", symbol: "#" }) 100 + }) 101 + .use(footnote) 102 + .use(tasklist, { disabled: true, label: true }) 103 + .use(alert) 104 + .use(emoji) 105 + .use(mentions); 106 + 107 + if (host) md.use(commitLinks(host)); 108 + return md; 109 + }; 110 + 111 + const renderers = new Map<string, MarkdownIt>(); 112 + 113 + // building an instance means running every plugin, so keep one per host 114 + const rendererFor = (host: string): MarkdownIt => { 115 + let md = renderers.get(host); 116 + if (!md) { 117 + md = build(host); 118 + renderers.set(host, md); 119 + } 120 + return md; 121 + }; 122 + 123 + export const renderMarkdown = (source: string, ctx: MarkupContext): string => 124 + sanitizeMarkup(rendererFor(ctx.host ?? "").render(source), ctx);
+56
web/src/lib/markup/paths.ts
··· 1 + export interface MarkupContext { 2 + /** `owner/repo` */ 3 + repo: string; 4 + ref: string; 5 + dir?: string; 6 + host?: string; 7 + } 8 + 9 + const ABSOLUTE = /^[a-z][a-z0-9+.-]*:|^\/\//i; 10 + 11 + export const isAbsoluteUrl = (url: string): boolean => ABSOLUTE.test(url); 12 + 13 + export const isRepoRelative = (url: string): boolean => 14 + url !== "" && !isAbsoluteUrl(url) && !url.startsWith("#"); 15 + 16 + // `.` and `..` have to collapse here instead of reaching the url 17 + const normalize = (path: string): string => { 18 + const parts: string[] = []; 19 + for (const part of path.split("/")) { 20 + if (part === "" || part === ".") continue; 21 + if (part === "..") parts.pop(); 22 + else parts.push(part); 23 + } 24 + return parts.join("/"); 25 + }; 26 + 27 + const withinRepo = (path: string, ctx: MarkupContext): string => 28 + normalize(path.startsWith("/") ? path : `${ctx.dir ?? ""}/${path}`); 29 + 30 + const splitSuffix = (url: string): [string, string] => { 31 + const at = url.search(/[?#]/); 32 + return at === -1 ? [url, ""] : [url.slice(0, at), url.slice(at)]; 33 + }; 34 + 35 + // markdown-it percent-encodes destinations before we ever see them, so only the 36 + // ref still needs encoding 37 + const repoUrl = (kind: string, url: string, ctx: MarkupContext): string => { 38 + const [path, suffix] = splitSuffix(url); 39 + if (path === "") return url; 40 + const ref = encodeURIComponent(ctx.ref); 41 + return `/${ctx.repo}/${kind}/${ref}/${withinRepo(path, ctx)}${suffix}`; 42 + }; 43 + 44 + export const treeUrl = (url: string, ctx: MarkupContext): string => repoUrl("tree", url, ctx); 45 + 46 + export const rawUrl = (url: string, ctx: MarkupContext): string => repoUrl("raw", url, ctx); 47 + 48 + export const rawSrcset = (srcset: string, ctx: MarkupContext): string => 49 + srcset 50 + .split(",") 51 + .map((candidate) => { 52 + const [url, ...descriptors] = candidate.trim().split(/\s+/); 53 + if (!isRepoRelative(url)) return candidate.trim(); 54 + return [rawUrl(url, ctx), ...descriptors].join(" "); 55 + }) 56 + .join(", ");
+13
web/src/lib/markup/render.ts
··· 1 + import { isMarkdownFile } from "./format"; 2 + import type { MarkupContext } from "./paths"; 3 + 4 + export const renderDocument = async ( 5 + filename: string, 6 + contents: string, 7 + ctx: MarkupContext 8 + ): Promise<string | null> => { 9 + if (!isMarkdownFile(filename)) return null; 10 + const { SOURCE_LIMIT, renderMarkdown } = await import("./markdown"); 11 + if (contents.length > SOURCE_LIMIT) return null; 12 + return renderMarkdown(contents, ctx); 13 + };
+160
web/src/lib/markup/sanitize.ts
··· 1 + import sanitizeHtml from "sanitize-html"; 2 + import { isRepoRelative, rawSrcset, rawUrl, treeUrl } from "./paths"; 3 + import type { MarkupContext } from "./paths"; 4 + 5 + const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]; 6 + 7 + // mirrors appview/pages/markup/sanitizer, which is bluemonday's UGC policy plus 8 + // the elements our own extensions emit. markdown renders with raw html enabled, 9 + // so anything hand written in a readme lands here too 10 + const ALLOWED_TAGS = [ 11 + ...HEADINGS, 12 + "p", 13 + "br", 14 + "hr", 15 + "div", 16 + "span", 17 + "section", 18 + "blockquote", 19 + "pre", 20 + "code", 21 + "kbd", 22 + "samp", 23 + "var", 24 + "tt", 25 + "b", 26 + "strong", 27 + "i", 28 + "em", 29 + "u", 30 + "s", 31 + "strike", 32 + "del", 33 + "ins", 34 + "sub", 35 + "sup", 36 + "small", 37 + "mark", 38 + "a", 39 + "img", 40 + "picture", 41 + "source", 42 + "video", 43 + "ul", 44 + "ol", 45 + "li", 46 + "dl", 47 + "dt", 48 + "dd", 49 + "table", 50 + "thead", 51 + "tbody", 52 + "tfoot", 53 + "tr", 54 + "th", 55 + "td", 56 + "caption", 57 + "colgroup", 58 + "col", 59 + "details", 60 + "summary", 61 + "figure", 62 + "figcaption", 63 + "abbr", 64 + "bdo", 65 + "cite", 66 + "dfn", 67 + "q", 68 + "ruby", 69 + "rt", 70 + "rp", 71 + "time", 72 + "wbr", 73 + "center", 74 + "input", 75 + "label" 76 + ]; 77 + 78 + // bluemonday's standard attributes 79 + const GLOBAL_ATTRIBUTES = ["id", "title", "dir", "lang", "align"]; 80 + 81 + const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = { 82 + "*": GLOBAL_ATTRIBUTES, 83 + a: ["href", "name", "rel", "aria-hidden"], 84 + img: ["src", "srcset", "alt", "width", "height", "loading"], 85 + source: ["src", "srcset", "type", "media"], 86 + video: ["src", "poster", "controls", "autoplay", "loop", "muted", "width", "height"], 87 + // the tasklist plugin renders disabled checkboxes tied to their labels 88 + input: ["type", "checked", "disabled"], 89 + label: ["for"], 90 + th: ["colspan", "rowspan", "scope"], 91 + td: ["colspan", "rowspan"], 92 + col: ["span", "width"], 93 + colgroup: ["span"], 94 + ol: ["start", "type", "reversed"], 95 + details: ["open"], 96 + time: ["datetime"], 97 + abbr: ["title"] 98 + }; 99 + 100 + // classes are allowlisted per tag, so a readme cannot reach the app's own styles 101 + const ALLOWED_CLASSES: sanitizeHtml.IOptions["allowedClasses"] = { 102 + a: ["anchor", "mention", "footnote-ref", "footnote-backref", "footnote-anchor"], 103 + sup: ["footnote-ref"], 104 + hr: ["footnotes-sep"], 105 + section: ["footnotes"], 106 + ol: ["footnotes-list", "task-list-container"], 107 + ul: ["task-list-container"], 108 + li: ["footnote-item", "task-list-item"], 109 + input: ["task-list-item-checkbox"], 110 + label: ["task-list-item-label"], 111 + div: ["markdown-alert", "markdown-alert-*"], 112 + p: ["markdown-alert-title"], 113 + code: ["language-*"] 114 + }; 115 + 116 + const externalRel = (href: string): string | undefined => 117 + isRepoRelative(href) || href.startsWith("#") ? undefined : "nofollow noopener noreferrer"; 118 + 119 + const optionsFor = (ctx: MarkupContext): sanitizeHtml.IOptions => ({ 120 + allowedTags: ALLOWED_TAGS, 121 + allowedAttributes: ALLOWED_ATTRIBUTES, 122 + allowedClasses: ALLOWED_CLASSES, 123 + allowedSchemes: ["http", "https", "mailto"], 124 + allowedSchemesAppliedToAttributes: ["href", "src", "srcset", "poster"], 125 + // resolving urls here rather than in a renderer rule catches the ones 126 + // written as raw html too 127 + transformTags: { 128 + a: (tagName, attribs) => { 129 + const href = attribs.href ?? ""; 130 + // a mention already points at a profile 131 + const rewritten = 132 + isRepoRelative(href) && attribs.class !== "mention" ? treeUrl(href, ctx) : href; 133 + const rel = externalRel(rewritten); 134 + return { tagName, attribs: { ...attribs, href: rewritten, ...(rel ? { rel } : {}) } }; 135 + }, 136 + img: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), 137 + source: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), 138 + video: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }) 139 + }, 140 + // the tasklist checkboxes are the only inputs we render 141 + exclusiveFilter: (frame) => frame.tag === "input" && frame.attribs.type !== "checkbox" 142 + }); 143 + 144 + // todo: external images should go through camo like the appview does, which needs 145 + // the shared secret in web's config and moves rendering server side 146 + const resolveMedia = ( 147 + attribs: Record<string, string>, 148 + ctx: MarkupContext 149 + ): Record<string, string> => { 150 + const resolved = { ...attribs }; 151 + for (const key of ["src", "poster"]) { 152 + const value = resolved[key]; 153 + if (value && isRepoRelative(value)) resolved[key] = rawUrl(value, ctx); 154 + } 155 + if (resolved.srcset) resolved.srcset = rawSrcset(resolved.srcset, ctx); 156 + return resolved; 157 + }; 158 + 159 + export const sanitizeMarkup = (html: string, ctx: MarkupContext): string => 160 + sanitizeHtml(html, optionsFor(ctx));
+235
web/src/markup.css
··· 1 + /* styles for rendered markdown, see $lib/markup. the html is sanitised to a known 2 + set of tags and classes, so everything here is addressed by element */ 3 + @layer components { 4 + .markup { 5 + @apply text-paragraph-regular text-foreground-default; 6 + } 7 + 8 + .markup > :first-child { 9 + @apply mt-0; 10 + } 11 + 12 + .markup > :last-child { 13 + @apply mb-0; 14 + } 15 + 16 + .markup h1, 17 + .markup h2, 18 + .markup h3, 19 + .markup h4, 20 + .markup h5, 21 + .markup h6 { 22 + @apply mt-6 mb-3 font-medium text-foreground-default; 23 + } 24 + 25 + .markup h1 { 26 + @apply border-b border-border-default pb-2 text-heading-3; 27 + } 28 + 29 + .markup h2 { 30 + @apply border-b border-border-default pb-2 text-heading-4; 31 + } 32 + 33 + .markup h3 { 34 + @apply text-paragraph-large font-semibold; 35 + } 36 + 37 + .markup h4, 38 + .markup h5, 39 + .markup h6 { 40 + @apply text-paragraph-regular font-semibold; 41 + } 42 + 43 + .markup a.anchor { 44 + @apply ml-2 text-foreground-placeholder no-underline opacity-0 transition-opacity; 45 + } 46 + 47 + .markup :hover > a.anchor, 48 + .markup a.anchor:focus-visible { 49 + @apply opacity-100; 50 + } 51 + 52 + .markup p, 53 + .markup blockquote, 54 + .markup ul, 55 + .markup ol, 56 + .markup pre, 57 + .markup table, 58 + .markup details { 59 + @apply my-3; 60 + } 61 + 62 + .markup a { 63 + @apply text-foreground-link-default underline hover:text-foreground-link-hover; 64 + } 65 + 66 + .markup a.mention { 67 + @apply font-medium no-underline hover:underline; 68 + } 69 + 70 + .markup strong { 71 + @apply font-semibold; 72 + } 73 + 74 + .markup ul, 75 + .markup ol { 76 + @apply pl-6; 77 + } 78 + 79 + .markup ul { 80 + @apply list-disc; 81 + } 82 + 83 + .markup ol { 84 + @apply list-decimal; 85 + } 86 + 87 + .markup li + li { 88 + @apply mt-1; 89 + } 90 + 91 + .markup li > ul, 92 + .markup li > ol { 93 + @apply my-1; 94 + } 95 + 96 + .markup dt { 97 + @apply mt-3 font-semibold; 98 + } 99 + 100 + .markup dd { 101 + @apply pl-6; 102 + } 103 + 104 + .markup blockquote { 105 + @apply border-l-2 border-border-strong pl-4 text-foreground-muted; 106 + } 107 + 108 + .markup code { 109 + @apply rounded bg-background-inset px-1 py-0.5 font-mono text-monospace-small; 110 + } 111 + 112 + .markup pre { 113 + @apply overflow-x-auto rounded border border-border-default bg-background-inset p-3; 114 + } 115 + 116 + /* pre above already has the padding and background, inline code would double it */ 117 + .markup pre code { 118 + @apply bg-transparent p-0 font-mono text-monospace-small; 119 + } 120 + 121 + .markup kbd { 122 + @apply rounded border border-border-default bg-background-subtle px-1.5 py-0.5 font-mono text-monospace-small; 123 + } 124 + 125 + .markup hr { 126 + @apply my-6 border-t border-border-default; 127 + } 128 + 129 + .markup img, 130 + .markup video { 131 + @apply inline-block max-w-full; 132 + } 133 + 134 + .markup table { 135 + @apply block w-max max-w-full border-collapse overflow-x-auto; 136 + } 137 + 138 + .markup th, 139 + .markup td { 140 + @apply border border-border-default px-3 py-1.5 text-left; 141 + } 142 + 143 + .markup th { 144 + @apply bg-background-subtle font-semibold; 145 + } 146 + 147 + .markup summary { 148 + @apply cursor-pointer font-medium; 149 + } 150 + 151 + .markup ul.task-list-container { 152 + @apply list-none pl-0; 153 + } 154 + 155 + /* only the outermost list gives up its indent, a nested one still steps in */ 156 + .markup li > ul.task-list-container { 157 + @apply pl-6; 158 + } 159 + 160 + /* the checkbox and its label stay in the inline flow, so a nested list still 161 + drops to its own line */ 162 + .markup input[type="checkbox"] { 163 + @apply mr-1 translate-y-0.5; 164 + } 165 + 166 + /* a footnote you jump to lands mid screen rather than up against the top */ 167 + .markup :is(a.footnote-anchor, li.footnote-item)[id] { 168 + scroll-margin-top: 48vh; 169 + } 170 + 171 + .markup sup.footnote-ref a { 172 + @apply no-underline hover:underline; 173 + } 174 + 175 + .markup hr.footnotes-sep { 176 + @apply mt-8; 177 + } 178 + 179 + .markup section.footnotes { 180 + @apply text-paragraph-small text-foreground-muted; 181 + } 182 + 183 + /* github style alerts, `> [!NOTE]` and friends */ 184 + .markup .markdown-alert { 185 + @apply my-3 border-l-2 border-border-default pl-4; 186 + } 187 + 188 + .markup .markdown-alert-title { 189 + @apply font-semibold; 190 + } 191 + 192 + .markup .markdown-alert > :last-child { 193 + @apply mb-0; 194 + } 195 + 196 + .markup .markdown-alert-note { 197 + @apply border-background-info-emphasis; 198 + } 199 + 200 + .markup .markdown-alert-note .markdown-alert-title { 201 + @apply text-foreground-info; 202 + } 203 + 204 + .markup .markdown-alert-tip { 205 + @apply border-background-success-emphasis; 206 + } 207 + 208 + .markup .markdown-alert-tip .markdown-alert-title { 209 + @apply text-foreground-success; 210 + } 211 + 212 + .markup .markdown-alert-important { 213 + @apply border-background-info-emphasis; 214 + } 215 + 216 + .markup .markdown-alert-important .markdown-alert-title { 217 + @apply text-foreground-info; 218 + } 219 + 220 + .markup .markdown-alert-warning { 221 + @apply border-background-warning-emphasis; 222 + } 223 + 224 + .markup .markdown-alert-warning .markdown-alert-title { 225 + @apply text-foreground-warning; 226 + } 227 + 228 + .markup .markdown-alert-caution { 229 + @apply border-background-danger-emphasis; 230 + } 231 + 232 + .markup .markdown-alert-caution .markdown-alert-title { 233 + @apply text-foreground-danger; 234 + } 235 + }
+1 -1
web/src/routes/[handle]/[repo]/+page.svelte
··· 159 159 </section> 160 160 161 161 {#if data.readme} 162 - <Readme filename={data.readme.filename} contents={data.readme.contents} /> 162 + <Readme filename={data.readme.filename} contents={data.readme.contents} html={data.readmeHtml} /> 163 163 {/if}
+12 -1
web/src/routes/[handle]/[repo]/+page.ts
··· 12 12 toTagSummary, 13 13 toTreeEntrySummary 14 14 } from "$lib/api/repo"; 15 + import { renderDocument } from "$lib/markup"; 15 16 import type { LanguageSlice } from "$lib/components/repo/types"; 16 17 import type { PageLoad } from "./$types"; 17 18 ··· 66 67 67 68 const languages = toLanguageSlices(results.languages?.languages ?? []); 68 69 70 + const readme = results.tree?.readme ?? null; 71 + const readmeHtml = readme 72 + ? await renderDocument(readme.filename, readme.contents, { 73 + repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 74 + ref, 75 + host: event.url.host 76 + }) 77 + : null; 78 + 69 79 // nothing answered, so the knot is down or doesn't know this repo 70 80 const knotUnreachable = 71 81 results.tree === null && results.log === null && results.branches === null; ··· 77 87 isEmpty, 78 88 knotUnreachable, 79 89 files, 80 - readme: results.tree?.readme ?? null, 90 + readme, 91 + readmeHtml, 81 92 commits, 82 93 tagsByCommit: tagsByCommitHash(commits, tags), 83 94 totalCommits: results.log?.total ?? commits.length,