From 2af8fe0e1cb95d8abdb261bbc50dc3612832618c Mon Sep 17 00:00:00 2001 From: dawn Date: Sat, 25 Jul 2026 02:12:44 +0300 Subject: [PATCH] web: render markdown readmes Signed-off-by: dawn --- web/package.json | 10 + web/pnpm-lock.yaml | 301 ++++++++++++++++++ web/src/app.css | 1 + .../lib/components/repo/Readme.stories.svelte | 27 ++ web/src/lib/components/repo/Readme.svelte | 14 +- web/src/lib/markup/format.ts | 4 + web/src/lib/markup/index.ts | 3 + web/src/lib/markup/markdown.test.ts | 167 ++++++++++ web/src/lib/markup/markdown.ts | 124 ++++++++ web/src/lib/markup/paths.ts | 56 ++++ web/src/lib/markup/render.ts | 13 + web/src/lib/markup/sanitize.ts | 160 ++++++++++ web/src/markup.css | 235 ++++++++++++++ web/src/routes/[handle]/[repo]/+page.svelte | 2 +- web/src/routes/[handle]/[repo]/+page.ts | 13 +- 15 files changed, 1124 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/components/repo/Readme.stories.svelte create mode 100644 web/src/lib/markup/format.ts create mode 100644 web/src/lib/markup/index.ts create mode 100644 web/src/lib/markup/markdown.test.ts create mode 100644 web/src/lib/markup/markdown.ts create mode 100644 web/src/lib/markup/paths.ts create mode 100644 web/src/lib/markup/render.ts create mode 100644 web/src/lib/markup/sanitize.ts create mode 100644 web/src/markup.css diff --git a/web/package.json b/web/package.json index af40a5da..ea68e83f 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,10 @@ "@sveltejs/kit": "^2.69.3", "@sveltejs/vite-plugin-svelte": "^7.2.0", "@tailwindcss/vite": "^4.3.2", + "@types/markdown-it": "^14.1.2", + "@types/markdown-it-emoji": "^3.0.1", "@types/node": "^24.13.3", + "@types/sanitize-html": "^2.16.1", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", "eslint": "^10.7.0", @@ -67,6 +70,13 @@ "@atcute/lexicons": "^2.0.2", "@atcute/oauth-browser-client": "^4.0.1", "@atcute/tid": "^1.1.4", + "@mdit/plugin-alert": "^1.0.1", + "@mdit/plugin-footnote": "^1.0.1", + "@mdit/plugin-tasklist": "^1.0.1", + "markdown-it": "^14.3.0", + "markdown-it-anchor": "^9.2.1", + "markdown-it-emoji": "^3.1.0", + "sanitize-html": "^2.17.6", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2" } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7cbffb45..26b5db48 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -26,6 +26,27 @@ importers: '@atcute/tid': specifier: ^1.1.4 version: 1.1.4 + '@mdit/plugin-alert': + specifier: ^1.0.1 + version: 1.0.1(markdown-it@14.3.0) + '@mdit/plugin-footnote': + specifier: ^1.0.1 + version: 1.0.1(markdown-it@14.3.0) + '@mdit/plugin-tasklist': + specifier: ^1.0.1 + version: 1.0.1(markdown-it@14.3.0) + markdown-it: + specifier: ^14.3.0 + version: 14.3.0 + markdown-it-anchor: + specifier: ^9.2.1 + version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0) + markdown-it-emoji: + specifier: ^3.1.0 + version: 3.1.0 + sanitize-html: + specifier: ^2.17.6 + version: 2.17.6 tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -75,9 +96,18 @@ importers: '@tailwindcss/vite': specifier: ^4.3.2 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)) + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + '@types/markdown-it-emoji': + specifier: ^3.0.1 + version: 3.0.1 '@types/node': specifier: ^24.13.3 version: 24.13.3 + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 '@vitest/browser-playwright': specifier: 4.1.10 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,6 +591,30 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdit/plugin-alert@1.0.1': + resolution: {integrity: sha512-gEZgbwlK2JSNIW3izqQyPoYJ8NXAEU0aON7XYpUHBzyD9km+ny5zFkAmoogMAzR8ird8iCQiK5pSv3nOtPBlLw==} + engines: {node: '>=22'} + peerDependencies: + markdown-it: ^14.2.0 + peerDependenciesMeta: + markdown-it: + optional: true + + '@mdit/plugin-footnote@1.0.1': + resolution: {integrity: sha512-PrH02dlVQT8/vPvfGrLHpcHq8N0+gOaAVNOfuhwlPqWCL4He3o+buUCxm1Uem02+UR8/TRBW50M/XD/dVSe2jw==} + engines: {node: '>=22'} + peerDependencies: + markdown-it: ^14.2.0 + + '@mdit/plugin-tasklist@1.0.1': + resolution: {integrity: sha512-Ks/Tihw5ibbkP2qOZzltkbiTk8RfpxDRvqdn4hl1wIs3VmeKITIQ36gN4DsuotSauA9SBe/mL0QJIfuSCrXqrw==} + engines: {node: '>=22'} + peerDependencies: + markdown-it: ^14.2.0 + peerDependenciesMeta: + markdown-it: + optional: true + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -1398,6 +1452,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it-emoji@3.0.1': + resolution: {integrity: sha512-cz1j8R35XivBqq9mwnsrP2fsz2yicLhB8+PDtuVkKOExwEdsVBNI+ROL3sbhtR5occRZ66vT0QnwFZCqdjf3pA==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mdx@2.0.14': resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} @@ -1410,6 +1476,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1561,6 +1630,9 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1669,6 +1741,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1729,10 +1804,51 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -1935,6 +2051,13 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1979,6 +2102,10 @@ packages: is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -2039,6 +2166,9 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2121,6 +2251,9 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + local-pkg@1.2.1: resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} @@ -2149,6 +2282,22 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it-anchor@9.2.1: + resolution: {integrity: sha512-p6APiLJDFAW2GEvaavDvhIBn7jrX2jLv77NkBGgNacFTurbORYc4pyYySg/mI6mpR6cHQuAtzKtmqgQr4K8dsQ==} + peerDependencies: + '@types/markdown-it': '*' + markdown-it: '*' + + markdown-it-emoji@3.1.0: + resolution: {integrity: sha512-NhmMEH2ywduD4Nty1E8uB5NqfLhAT1VR0dyvoJyStKOqCzbZmVdn/+8wj7zpDsb/fLBikpCPsWwxqKlvMmbz4g==} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2214,6 +2363,9 @@ packages: package-manager-detector@1.7.0: resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2366,6 +2518,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2420,6 +2576,10 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + sanitize-html@2.17.6: + resolution: {integrity: sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==} + engines: {node: '>=22.12.0'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2623,6 +2783,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -3246,6 +3409,23 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mdit/plugin-alert@1.0.1(markdown-it@14.3.0)': + dependencies: + '@types/markdown-it': 14.1.2 + optionalDependencies: + markdown-it: 14.3.0 + + '@mdit/plugin-footnote@1.0.1(markdown-it@14.3.0)': + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.3.0 + + '@mdit/plugin-tasklist@1.0.1(markdown-it@14.3.0)': + dependencies: + '@types/markdown-it': 14.1.2 + optionalDependencies: + markdown-it: 14.3.0 + '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7)': dependencies: '@types/mdx': 2.0.14 @@ -3879,6 +4059,19 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it-emoji@3.0.1': + dependencies: + '@types/markdown-it': 14.1.2 + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/mdx@2.0.14': {} '@types/node@24.13.3': @@ -3891,6 +4084,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/trusted-types@2.0.7': {} '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': @@ -4114,6 +4311,8 @@ snapshots: ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -4192,6 +4391,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4225,11 +4426,53 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@4.5.0: {} + + entities@7.0.1: {} + + entities@8.0.0: {} + es-errors@1.3.0: {} es-module-lexer@2.3.1: {} @@ -4459,6 +4702,20 @@ snapshots: html-escaper@2.0.2: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + ignore@5.3.2: {} ignore@7.0.6: {} @@ -4487,6 +4744,8 @@ snapshots: is-module@1.0.0: {} + is-plain-object@5.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.9 @@ -4542,6 +4801,10 @@ snapshots: known-css-properties@0.37.0: {} + launder@1.7.1: + dependencies: + dayjs: 1.11.21 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4598,6 +4861,10 @@ snapshots: lilconfig@2.1.0: {} + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + local-pkg@1.2.1: dependencies: mlly: 1.8.2 @@ -4628,6 +4895,24 @@ snapshots: dependencies: semver: 7.8.5 + markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0): + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.3.0 + + markdown-it-emoji@3.1.0: {} + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + mdurl@2.1.0: {} + min-indent@1.0.1: {} minimatch@10.2.5: @@ -4728,6 +5013,8 @@ snapshots: package-manager-detector@1.7.0: {} + parse-srcset@1.0.2: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4811,6 +5098,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + punycode.js@2.3.1: {} + punycode@2.3.1: {} quansync@0.2.11: {} @@ -4904,6 +5193,16 @@ snapshots: dependencies: mri: 1.2.0 + sanitize-html@2.17.6: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 12.0.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.19 + scheduler@0.27.0: {} scule@1.3.0: {} @@ -5100,6 +5399,8 @@ snapshots: typescript@6.0.3: {} + uc.micro@2.1.0: {} + ufo@1.6.4: {} undici-types@7.18.2: {} diff --git a/web/src/app.css b/web/src/app.css index 99187a84..dd2874fc 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./markup.css"; @theme { --font-sans: "InterVariable", "system-ui", sans-serif, ui-sans-serif; diff --git a/web/src/lib/components/repo/Readme.stories.svelte b/web/src/lib/components/repo/Readme.stories.svelte new file mode 100644 index 00000000..ad941d8b --- /dev/null +++ b/web/src/lib/components/repo/Readme.stories.svelte @@ -0,0 +1,27 @@ + + + +tangled

social code collaboration for the at protocol.

getting started

" + }} +/> diff --git a/web/src/lib/components/repo/Readme.svelte b/web/src/lib/components/repo/Readme.svelte index 284f400a..eccffa91 100644 --- a/web/src/lib/components/repo/Readme.svelte +++ b/web/src/lib/components/repo/Readme.svelte @@ -4,9 +4,10 @@ interface Props { filename: string; contents: string; + html?: string | null; } - let { filename, contents }: Props = $props(); + let { filename, contents, html = null }: Props = $props();
@@ -16,7 +17,12 @@ {filename}
-
-
{contents}
-
+ {#if html} + +
{@html html}
+ {:else} +
+
{contents}
+
+ {/if} diff --git a/web/src/lib/markup/format.ts b/web/src/lib/markup/format.ts new file mode 100644 index 00000000..94a62542 --- /dev/null +++ b/web/src/lib/markup/format.ts @@ -0,0 +1,4 @@ +// the extensions appview/pages/markup/format.go treats as markdown +const MARKDOWN = /\.(md|markdown|mdown|mkdn|mkd)$/i; + +export const isMarkdownFile = (filename: string): boolean => MARKDOWN.test(filename); diff --git a/web/src/lib/markup/index.ts b/web/src/lib/markup/index.ts new file mode 100644 index 00000000..d576e70e --- /dev/null +++ b/web/src/lib/markup/index.ts @@ -0,0 +1,3 @@ +export * from "./format"; +export * from "./paths"; +export * from "./render"; diff --git a/web/src/lib/markup/markdown.test.ts b/web/src/lib/markup/markdown.test.ts new file mode 100644 index 00000000..7e3f6198 --- /dev/null +++ b/web/src/lib/markup/markdown.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { isMarkdownFile } from "./format"; +import { renderMarkdown } from "./markdown"; +import type { MarkupContext } from "./paths"; + +const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" }; +const render = (source: string, overrides: Partial = {}) => + renderMarkdown(source, { ...ctx, ...overrides }); + +describe("isMarkdownFile", () => { + it("knows the markdown extensions from the plain text ones", () => { + expect(isMarkdownFile("README.md")).toBe(true); + expect(isMarkdownFile("readme.MARKDOWN")).toBe(true); + expect(isMarkdownFile("readme.mkd")).toBe(true); + expect(isMarkdownFile("README")).toBe(false); + expect(isMarkdownFile("README.rst")).toBe(false); + }); +}); + +describe("renderMarkdown", () => { + it("renders gfm tables and strikethrough", () => { + const html = render("| a | b |\n| - | - |\n| 1 | 2 |\n\n~~gone~~"); + expect(html).toContain(""); + expect(html).toContain("gone"); + }); + + it("gives headings github's slugs and an anchor link", () => { + const html = render("## Hello, World!"); + expect(html).toContain('id="hello-world"'); + expect(html).toContain('#'); + }); + + it("points relative links at the file browser", () => { + expect(render("[docs](./docs/setup.md)")).toContain( + 'href="/ada.test/infra/tree/main/docs/setup.md"' + ); + }); + + it("resolves a link against the directory the document is in", () => { + const html = render("[sibling](../other.md)", { dir: "docs/guide" }); + expect(html).toContain('href="/ada.test/infra/tree/main/docs/other.md"'); + }); + + it("treats an absolute path as repo relative, not host relative", () => { + expect(render("[root](/LICENSE)", { dir: "docs" })).toContain( + 'href="/ada.test/infra/tree/main/LICENSE"' + ); + }); + + it("keeps a fragment on a rewritten link", () => { + expect(render("[part](./setup.md#install)")).toContain( + 'href="/ada.test/infra/tree/main/setup.md#install"' + ); + }); + + it("leaves fragments, mail and absolute urls alone", () => { + const html = render("[a](#top) [b](mailto:ada@test) [c](https://example.com)"); + expect(html).toContain('href="#top"'); + expect(html).toContain('href="mailto:ada@test"'); + expect(html).toContain('href="https://example.com"'); + }); + + it("marks off-site links as untrusted", () => { + expect(render("[c](https://example.com)")).toContain('rel="nofollow noopener noreferrer"'); + expect(render("[a](./x.md)")).not.toContain("nofollow"); + }); + + it("points relative images at the raw file, including raw html ones", () => { + expect(render("![logo](assets/logo.png)")).toContain( + 'src="/ada.test/infra/raw/main/assets/logo.png"' + ); + expect(render('logo')).toContain( + 'src="/ada.test/infra/raw/main/assets/logo.png"' + ); + }); + + it("rewrites every candidate in a srcset", () => { + const html = render(''); + expect(html).toContain( + 'srcset="/ada.test/infra/raw/main/a.png 1x, /ada.test/infra/raw/main/b.png 2x"' + ); + }); + + it("links a bare handle to its profile", () => { + const html = render("thanks @ada.test for the fix"); + expect(html).toContain('@ada.test'); + }); + + it("leaves a handle inside a link label as text", () => { + expect(render("[ask @ada.test](https://example.com)")).not.toContain("mention"); + }); + + it("does not read an email address as a mention", () => { + expect(render("mail ada@ada.test now")).not.toContain("mention"); + }); + + it("shortens our own commit urls to a sha", () => { + const url = + "https://tangled.org/ada.test/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8"; + expect(render(url)).toContain("0c4d0e9b"); + }); + + it("leaves a commit url that has its own label", () => { + const html = render( + "[the fix](https://tangled.org/ada.test/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8)" + ); + expect(html).toContain("the fix"); + expect(html).not.toContain(""); + }); + + it("leaves another host's commit urls as urls", () => { + const url = "https://github.com/ada/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8"; + expect(render(url)).not.toContain(""); + }); + + it("renders task lists as disabled checkboxes", () => { + const html = render("- [x] done\n- [ ] not"); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('checked="checked"'); + expect(html).toContain('disabled="disabled"'); + }); + + it("renders footnotes with their backlinks", () => { + const html = render("a claim[^1]\n\n[^1]: the source"); + expect(html).toContain('class="footnote-ref"'); + expect(html).toContain('class="footnote-backref"'); + expect(html).toContain("the source"); + }); + + it("renders github style alerts", () => { + const html = render("> [!WARNING]\n> careful"); + expect(html).toContain('class="markdown-alert markdown-alert-warning"'); + expect(html).toContain("careful"); + }); + + it("renders emoji shortcodes", () => { + expect(render("ship it :tada:")).toContain("🎉"); + }); + + it("keeps the language on a fenced block", () => { + expect(render("```rust\nfn main() {}\n```")).toContain('class="language-rust"'); + }); + + it("strips scripts, event handlers and javascript urls", () => { + expect(render("")).not.toContain("alert"); + expect(render('')).not.toContain("onerror"); + // markdown-it refuses the destination outright, so this stays plain text + expect(render("[x](javascript:alert(1))")).not.toContain("x')).not.toContain("data:"); + }); + + it("drops classes it does not recognise", () => { + expect(render('
boo
')).not.toContain("inset-0"); + }); + + it("drops inputs that are not task list checkboxes", () => { + expect(render('')).not.toContain(" { + const html = render( + '

infra

\n\n
more\n\nhidden\n\n
' + ); + expect(html).toContain('
'); + expect(html).toContain("
more"); + }); +}); diff --git a/web/src/lib/markup/markdown.ts b/web/src/lib/markup/markdown.ts new file mode 100644 index 00000000..2c5a692d --- /dev/null +++ b/web/src/lib/markup/markdown.ts @@ -0,0 +1,124 @@ +import { alert } from "@mdit/plugin-alert"; +import { footnote } from "@mdit/plugin-footnote"; +import { tasklist } from "@mdit/plugin-tasklist"; +import MarkdownIt from "markdown-it"; +import anchor from "markdown-it-anchor"; +import { full as emoji } from "markdown-it-emoji"; +import { sanitizeMarkup } from "./sanitize"; +import type { MarkupContext } from "./paths"; + +// past this a document renders as plain text, a readme this big is pathological +export const SOURCE_LIMIT = 512 * 1024; + +// github's heading slugs, that is what fragment links in a readme are written +// against +const slugify = (text: string): string => + text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}\p{M}\s_-]+/gu, "") + .replace(/\s+/g, "-"); + +// the dotted dns handle appview/pages/markup/extension/atlink.go matches +const MENTION = /^@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z][a-zA-Z0-9-]*\b/; + +const mentions = (md: MarkdownIt): void => { + md.inline.ruler.before("link", "mention", (state, silent) => { + // inside a link label a mention would nest an anchor in an anchor. markdown-it + // tracks this but its published types leave the field out + if ((state as unknown as { linkLevel: number }).linkLevel > 0) return false; + if (state.src.charCodeAt(state.pos) !== 0x40) return false; + const before = state.pos === 0 ? " " : state.src[state.pos - 1]; + if (before !== " " && before !== "\n" && before !== "(") return false; + const match = MENTION.exec(state.src.slice(state.pos, state.posMax)); + if (!match) return false; + + if (!silent) { + const open = state.push("link_open", "a", 1); + open.attrs = [ + ["href", `/${match[0].slice(1)}`], + ["class", "mention"] + ]; + state.push("text", "", 0).content = match[0]; + state.push("link_close", "a", -1); + } + state.pos += match[0].length; + return true; + }); +}; + +const SHA = /^[0-9a-f]{7,40}$/; + +// `https://host/owner/repo/commit/` is a mouthful to read inline +const shortSha = (href: string, host: string): string | null => { + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (url.host !== host) return null; + const parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/"); + if (parts.length !== 4 || parts[2] !== "commit" || !SHA.test(parts[3])) return null; + return parts[3].slice(0, 8); +}; + +const commitLinks = + (host: string) => + (md: MarkdownIt): void => { + md.core.ruler.push("commit_link", (state) => { + for (const token of state.tokens) { + if (token.type !== "inline" || !token.children) continue; + for (let i = 0; i < token.children.length; i++) { + const open = token.children[i]; + // only bare urls, a link someone gave a label to keeps it + if (open.type !== "link_open" || open.info !== "auto") continue; + const text = token.children[i + 1]; + if (text?.type !== "text" || token.children[i + 2]?.type !== "link_close") continue; + const sha = shortSha(open.attrGet("href") ?? "", host); + if (!sha) continue; + const code = new state.Token("code_inline", "code", 0); + code.content = sha; + token.children[i + 1] = code; + } + } + }); + }; + +// todo: no syntax highlighting, mermaid or math yet. the first two want a client +// side renderer, math wants mathjax loaded on demand the way the appview does it +const build = (host: string): MarkdownIt => { + const md = new MarkdownIt({ + html: true, + linkify: true, + // the appview only rewrites dashes in the blog, readmes keep their text + typographer: false + }) + .use(anchor, { + slugify, + permalink: anchor.permalink.linkInsideHeader({ class: "anchor", symbol: "#" }) + }) + .use(footnote) + .use(tasklist, { disabled: true, label: true }) + .use(alert) + .use(emoji) + .use(mentions); + + if (host) md.use(commitLinks(host)); + return md; +}; + +const renderers = new Map(); + +// building an instance means running every plugin, so keep one per host +const rendererFor = (host: string): MarkdownIt => { + let md = renderers.get(host); + if (!md) { + md = build(host); + renderers.set(host, md); + } + return md; +}; + +export const renderMarkdown = (source: string, ctx: MarkupContext): string => + sanitizeMarkup(rendererFor(ctx.host ?? "").render(source), ctx); diff --git a/web/src/lib/markup/paths.ts b/web/src/lib/markup/paths.ts new file mode 100644 index 00000000..c19c69d2 --- /dev/null +++ b/web/src/lib/markup/paths.ts @@ -0,0 +1,56 @@ +export interface MarkupContext { + /** `owner/repo` */ + repo: string; + ref: string; + dir?: string; + host?: string; +} + +const ABSOLUTE = /^[a-z][a-z0-9+.-]*:|^\/\//i; + +export const isAbsoluteUrl = (url: string): boolean => ABSOLUTE.test(url); + +export const isRepoRelative = (url: string): boolean => + url !== "" && !isAbsoluteUrl(url) && !url.startsWith("#"); + +// `.` and `..` have to collapse here instead of reaching the url +const normalize = (path: string): string => { + const parts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") parts.pop(); + else parts.push(part); + } + return parts.join("/"); +}; + +const withinRepo = (path: string, ctx: MarkupContext): string => + normalize(path.startsWith("/") ? path : `${ctx.dir ?? ""}/${path}`); + +const splitSuffix = (url: string): [string, string] => { + const at = url.search(/[?#]/); + return at === -1 ? [url, ""] : [url.slice(0, at), url.slice(at)]; +}; + +// markdown-it percent-encodes destinations before we ever see them, so only the +// ref still needs encoding +const repoUrl = (kind: string, url: string, ctx: MarkupContext): string => { + const [path, suffix] = splitSuffix(url); + if (path === "") return url; + const ref = encodeURIComponent(ctx.ref); + return `/${ctx.repo}/${kind}/${ref}/${withinRepo(path, ctx)}${suffix}`; +}; + +export const treeUrl = (url: string, ctx: MarkupContext): string => repoUrl("tree", url, ctx); + +export const rawUrl = (url: string, ctx: MarkupContext): string => repoUrl("raw", url, ctx); + +export const rawSrcset = (srcset: string, ctx: MarkupContext): string => + srcset + .split(",") + .map((candidate) => { + const [url, ...descriptors] = candidate.trim().split(/\s+/); + if (!isRepoRelative(url)) return candidate.trim(); + return [rawUrl(url, ctx), ...descriptors].join(" "); + }) + .join(", "); diff --git a/web/src/lib/markup/render.ts b/web/src/lib/markup/render.ts new file mode 100644 index 00000000..89e5e95b --- /dev/null +++ b/web/src/lib/markup/render.ts @@ -0,0 +1,13 @@ +import { isMarkdownFile } from "./format"; +import type { MarkupContext } from "./paths"; + +export const renderDocument = async ( + filename: string, + contents: string, + ctx: MarkupContext +): Promise => { + if (!isMarkdownFile(filename)) return null; + const { SOURCE_LIMIT, renderMarkdown } = await import("./markdown"); + if (contents.length > SOURCE_LIMIT) return null; + return renderMarkdown(contents, ctx); +}; diff --git a/web/src/lib/markup/sanitize.ts b/web/src/lib/markup/sanitize.ts new file mode 100644 index 00000000..28b255e0 --- /dev/null +++ b/web/src/lib/markup/sanitize.ts @@ -0,0 +1,160 @@ +import sanitizeHtml from "sanitize-html"; +import { isRepoRelative, rawSrcset, rawUrl, treeUrl } from "./paths"; +import type { MarkupContext } from "./paths"; + +const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]; + +// mirrors appview/pages/markup/sanitizer, which is bluemonday's UGC policy plus +// the elements our own extensions emit. markdown renders with raw html enabled, +// so anything hand written in a readme lands here too +const ALLOWED_TAGS = [ + ...HEADINGS, + "p", + "br", + "hr", + "div", + "span", + "section", + "blockquote", + "pre", + "code", + "kbd", + "samp", + "var", + "tt", + "b", + "strong", + "i", + "em", + "u", + "s", + "strike", + "del", + "ins", + "sub", + "sup", + "small", + "mark", + "a", + "img", + "picture", + "source", + "video", + "ul", + "ol", + "li", + "dl", + "dt", + "dd", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "th", + "td", + "caption", + "colgroup", + "col", + "details", + "summary", + "figure", + "figcaption", + "abbr", + "bdo", + "cite", + "dfn", + "q", + "ruby", + "rt", + "rp", + "time", + "wbr", + "center", + "input", + "label" +]; + +// bluemonday's standard attributes +const GLOBAL_ATTRIBUTES = ["id", "title", "dir", "lang", "align"]; + +const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = { + "*": GLOBAL_ATTRIBUTES, + a: ["href", "name", "rel", "aria-hidden"], + img: ["src", "srcset", "alt", "width", "height", "loading"], + source: ["src", "srcset", "type", "media"], + video: ["src", "poster", "controls", "autoplay", "loop", "muted", "width", "height"], + // the tasklist plugin renders disabled checkboxes tied to their labels + input: ["type", "checked", "disabled"], + label: ["for"], + th: ["colspan", "rowspan", "scope"], + td: ["colspan", "rowspan"], + col: ["span", "width"], + colgroup: ["span"], + ol: ["start", "type", "reversed"], + details: ["open"], + time: ["datetime"], + abbr: ["title"] +}; + +// classes are allowlisted per tag, so a readme cannot reach the app's own styles +const ALLOWED_CLASSES: sanitizeHtml.IOptions["allowedClasses"] = { + a: ["anchor", "mention", "footnote-ref", "footnote-backref", "footnote-anchor"], + sup: ["footnote-ref"], + hr: ["footnotes-sep"], + section: ["footnotes"], + ol: ["footnotes-list", "task-list-container"], + ul: ["task-list-container"], + li: ["footnote-item", "task-list-item"], + input: ["task-list-item-checkbox"], + label: ["task-list-item-label"], + div: ["markdown-alert", "markdown-alert-*"], + p: ["markdown-alert-title"], + code: ["language-*"] +}; + +const externalRel = (href: string): string | undefined => + isRepoRelative(href) || href.startsWith("#") ? undefined : "nofollow noopener noreferrer"; + +const optionsFor = (ctx: MarkupContext): sanitizeHtml.IOptions => ({ + allowedTags: ALLOWED_TAGS, + allowedAttributes: ALLOWED_ATTRIBUTES, + allowedClasses: ALLOWED_CLASSES, + allowedSchemes: ["http", "https", "mailto"], + allowedSchemesAppliedToAttributes: ["href", "src", "srcset", "poster"], + // resolving urls here rather than in a renderer rule catches the ones + // written as raw html too + transformTags: { + a: (tagName, attribs) => { + const href = attribs.href ?? ""; + // a mention already points at a profile + const rewritten = + isRepoRelative(href) && attribs.class !== "mention" ? treeUrl(href, ctx) : href; + const rel = externalRel(rewritten); + return { tagName, attribs: { ...attribs, href: rewritten, ...(rel ? { rel } : {}) } }; + }, + img: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), + source: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), + video: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }) + }, + // the tasklist checkboxes are the only inputs we render + exclusiveFilter: (frame) => frame.tag === "input" && frame.attribs.type !== "checkbox" +}); + +// todo: external images should go through camo like the appview does, which needs +// the shared secret in web's config and moves rendering server side +const resolveMedia = ( + attribs: Record, + ctx: MarkupContext +): Record => { + const resolved = { ...attribs }; + for (const key of ["src", "poster"]) { + const value = resolved[key]; + if (value && isRepoRelative(value)) resolved[key] = rawUrl(value, ctx); + } + if (resolved.srcset) resolved.srcset = rawSrcset(resolved.srcset, ctx); + return resolved; +}; + +export const sanitizeMarkup = (html: string, ctx: MarkupContext): string => + sanitizeHtml(html, optionsFor(ctx)); diff --git a/web/src/markup.css b/web/src/markup.css new file mode 100644 index 00000000..13f3551f --- /dev/null +++ b/web/src/markup.css @@ -0,0 +1,235 @@ +/* styles for rendered markdown, see $lib/markup. the html is sanitised to a known + set of tags and classes, so everything here is addressed by element */ +@layer components { + .markup { + @apply text-paragraph-regular text-foreground-default; + } + + .markup > :first-child { + @apply mt-0; + } + + .markup > :last-child { + @apply mb-0; + } + + .markup h1, + .markup h2, + .markup h3, + .markup h4, + .markup h5, + .markup h6 { + @apply mt-6 mb-3 font-medium text-foreground-default; + } + + .markup h1 { + @apply border-b border-border-default pb-2 text-heading-3; + } + + .markup h2 { + @apply border-b border-border-default pb-2 text-heading-4; + } + + .markup h3 { + @apply text-paragraph-large font-semibold; + } + + .markup h4, + .markup h5, + .markup h6 { + @apply text-paragraph-regular font-semibold; + } + + .markup a.anchor { + @apply ml-2 text-foreground-placeholder no-underline opacity-0 transition-opacity; + } + + .markup :hover > a.anchor, + .markup a.anchor:focus-visible { + @apply opacity-100; + } + + .markup p, + .markup blockquote, + .markup ul, + .markup ol, + .markup pre, + .markup table, + .markup details { + @apply my-3; + } + + .markup a { + @apply text-foreground-link-default underline hover:text-foreground-link-hover; + } + + .markup a.mention { + @apply font-medium no-underline hover:underline; + } + + .markup strong { + @apply font-semibold; + } + + .markup ul, + .markup ol { + @apply pl-6; + } + + .markup ul { + @apply list-disc; + } + + .markup ol { + @apply list-decimal; + } + + .markup li + li { + @apply mt-1; + } + + .markup li > ul, + .markup li > ol { + @apply my-1; + } + + .markup dt { + @apply mt-3 font-semibold; + } + + .markup dd { + @apply pl-6; + } + + .markup blockquote { + @apply border-l-2 border-border-strong pl-4 text-foreground-muted; + } + + .markup code { + @apply rounded bg-background-inset px-1 py-0.5 font-mono text-monospace-small; + } + + .markup pre { + @apply overflow-x-auto rounded border border-border-default bg-background-inset p-3; + } + + /* pre above already has the padding and background, inline code would double it */ + .markup pre code { + @apply bg-transparent p-0 font-mono text-monospace-small; + } + + .markup kbd { + @apply rounded border border-border-default bg-background-subtle px-1.5 py-0.5 font-mono text-monospace-small; + } + + .markup hr { + @apply my-6 border-t border-border-default; + } + + .markup img, + .markup video { + @apply inline-block max-w-full; + } + + .markup table { + @apply block w-max max-w-full border-collapse overflow-x-auto; + } + + .markup th, + .markup td { + @apply border border-border-default px-3 py-1.5 text-left; + } + + .markup th { + @apply bg-background-subtle font-semibold; + } + + .markup summary { + @apply cursor-pointer font-medium; + } + + .markup ul.task-list-container { + @apply list-none pl-0; + } + + /* only the outermost list gives up its indent, a nested one still steps in */ + .markup li > ul.task-list-container { + @apply pl-6; + } + + /* the checkbox and its label stay in the inline flow, so a nested list still + drops to its own line */ + .markup input[type="checkbox"] { + @apply mr-1 translate-y-0.5; + } + + /* a footnote you jump to lands mid screen rather than up against the top */ + .markup :is(a.footnote-anchor, li.footnote-item)[id] { + scroll-margin-top: 48vh; + } + + .markup sup.footnote-ref a { + @apply no-underline hover:underline; + } + + .markup hr.footnotes-sep { + @apply mt-8; + } + + .markup section.footnotes { + @apply text-paragraph-small text-foreground-muted; + } + + /* github style alerts, `> [!NOTE]` and friends */ + .markup .markdown-alert { + @apply my-3 border-l-2 border-border-default pl-4; + } + + .markup .markdown-alert-title { + @apply font-semibold; + } + + .markup .markdown-alert > :last-child { + @apply mb-0; + } + + .markup .markdown-alert-note { + @apply border-background-info-emphasis; + } + + .markup .markdown-alert-note .markdown-alert-title { + @apply text-foreground-info; + } + + .markup .markdown-alert-tip { + @apply border-background-success-emphasis; + } + + .markup .markdown-alert-tip .markdown-alert-title { + @apply text-foreground-success; + } + + .markup .markdown-alert-important { + @apply border-background-info-emphasis; + } + + .markup .markdown-alert-important .markdown-alert-title { + @apply text-foreground-info; + } + + .markup .markdown-alert-warning { + @apply border-background-warning-emphasis; + } + + .markup .markdown-alert-warning .markdown-alert-title { + @apply text-foreground-warning; + } + + .markup .markdown-alert-caution { + @apply border-background-danger-emphasis; + } + + .markup .markdown-alert-caution .markdown-alert-title { + @apply text-foreground-danger; + } +} diff --git a/web/src/routes/[handle]/[repo]/+page.svelte b/web/src/routes/[handle]/[repo]/+page.svelte index 271b13b2..5af7401c 100644 --- a/web/src/routes/[handle]/[repo]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/+page.svelte @@ -159,5 +159,5 @@ {#if data.readme} - + {/if} diff --git a/web/src/routes/[handle]/[repo]/+page.ts b/web/src/routes/[handle]/[repo]/+page.ts index ad4d5121..6a16bb99 100644 --- a/web/src/routes/[handle]/[repo]/+page.ts +++ b/web/src/routes/[handle]/[repo]/+page.ts @@ -12,6 +12,7 @@ import { toTagSummary, toTreeEntrySummary } from "$lib/api/repo"; +import { renderDocument } from "$lib/markup"; import type { LanguageSlice } from "$lib/components/repo/types"; import type { PageLoad } from "./$types"; @@ -66,6 +67,15 @@ export const load: PageLoad = async (event) => { const languages = toLanguageSlices(results.languages?.languages ?? []); + const readme = results.tree?.readme ?? null; + const readmeHtml = readme + ? await renderDocument(readme.filename, readme.contents, { + repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, + ref, + host: event.url.host + }) + : null; + // nothing answered, so the knot is down or doesn't know this repo const knotUnreachable = results.tree === null && results.log === null && results.branches === null; @@ -77,7 +87,8 @@ export const load: PageLoad = async (event) => { isEmpty, knotUnreachable, files, - readme: results.tree?.readme ?? null, + readme, + readmeHtml, commits, tagsByCommit: tagsByCommitHash(commits, tags), totalCommits: results.log?.total ?? commits.length, -- 2.51.2