experimenting with making decentralized fanfic archives on atproto. github mirror: https://github.com/haetae-bit/fanfic-atproto

add atproto-ui

+32 -10
astro.config.mjs
··· 4 4 import db from "@astrojs/db"; 5 5 import authproto from "@fujocoded/authproto"; 6 6 import unocss from "unocss/astro"; 7 - 8 7 import preact from "@astrojs/preact"; 9 8 10 9 // https://astro.build/config ··· 13 12 adapter: node({ 14 13 mode: 'standalone', 15 14 }), 16 - integrations: [db(), authproto({ 17 - applicationName: "fan archive", 18 - applicationDomain: "localhost:4321", 19 - driver: { 20 - name: "astro:db", 21 - }, 22 - scopes: { 23 - genericData: true, 15 + integrations: [ 16 + db(), 17 + authproto({ 18 + applicationName: "fan archive", 19 + applicationDomain: "localhost:4321", 20 + driver: { 21 + name: "astro:db", 22 + }, 23 + scopes: { 24 + genericData: true, 25 + }, 26 + }), 27 + unocss(), 28 + preact({ compat: true }) 29 + ], 30 + vite: { 31 + ssr: { 32 + noExternal: ["atproto-ui"], 24 33 }, 25 - }), unocss(), preact({ compat: true })], 34 + }, 35 + // session: { // could this work for slices oauth? 36 + // driver: process.env.PROD ? "db0" : "memory", 37 + // options: { 38 + // database: db(), 39 + // tableName: "oauth", 40 + // }, 41 + // cookie: { 42 + // name: "fics.fan-session", 43 + // secure: process.env.PROD ? true : false, 44 + // sameSite: "lax", 45 + // path: "/", 46 + // } 47 + // }, 26 48 experimental: { 27 49 fonts: [ 28 50 {
+3 -2
package.json
··· 3 3 "version": "0.0.1", 4 4 "dependencies": { 5 5 "@astrojs/db": "^0.18.0", 6 - "@astrojs/node": "^9.4.4", 6 + "@astrojs/node": "^9.4.6", 7 7 "@astrojs/preact": "^4.1.1", 8 8 "@atproto/api": "^0.16.9", 9 9 "@atproto/common-web": "^0.4.3", ··· 27 27 "@tiptap/pm": "^3.6.5", 28 28 "@tiptap/starter-kit": "^3.6.5", 29 29 "@yaireo/tagify": "^4.35.4", 30 - "astro": "^5.14.1", 30 + "astro": "^5.14.4", 31 + "atproto-ui": "^0.3.1", 31 32 "nanoid": "^5.1.5", 32 33 "preact": "^10.27.2" 33 34 },
+145 -92
pnpm-lock.yaml
··· 12 12 specifier: ^0.18.0 13 13 version: 0.18.0(pg@8.16.3) 14 14 '@astrojs/node': 15 - specifier: ^9.4.4 16 - version: 9.4.4(astro@5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2)) 15 + specifier: ^9.4.6 16 + version: 9.4.6(astro@5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2)) 17 17 '@astrojs/preact': 18 18 specifier: ^4.1.1 19 19 version: 4.1.1(@babel/core@7.28.4)(@types/node@24.6.0)(jiti@2.6.0)(preact@10.27.2)(sass-embedded@1.93.2)(sass@1.93.2) ··· 31 31 version: 1.7.4 32 32 '@fujocoded/authproto': 33 33 specifier: ^0.1.1 34 - version: 0.1.1(astro@5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2))(pg@8.16.3) 34 + version: 0.1.1(astro@5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2))(pg@8.16.3) 35 35 '@preact/signals': 36 36 specifier: ^2.3.2 37 37 version: 2.3.2(preact@10.27.2) ··· 84 84 specifier: ^4.35.4 85 85 version: 4.35.4(prop-types@15.8.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) 86 86 astro: 87 - specifier: ^5.14.1 88 - version: 5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 87 + specifier: ^5.14.4 88 + version: 5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 89 + atproto-ui: 90 + specifier: ^0.3.1 91 + version: 0.3.1(@atcute/identity@1.1.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) 89 92 nanoid: 90 93 specifier: ^5.1.5 91 94 version: 5.1.6 ··· 138 141 '@astrojs/db@0.18.0': 139 142 resolution: {integrity: sha512-a91Bl2+SEUhxLF/KxGO9yD9YI6BMMvnBRzRvN7v3Vtpd15UnR9bLz5aQa4pkgzlZPTUILysCKyLl0E10Z2tzGg==} 140 143 141 - '@astrojs/internal-helpers@0.7.3': 142 - resolution: {integrity: sha512-6Pl0bQEIChuW5wqN7jdKrzWfCscW2rG/Cz+fzt4PhSQX2ivBpnhXgFUCs0M3DCYvjYHnPVG2W36X5rmFjZ62sw==} 144 + '@astrojs/internal-helpers@0.7.4': 145 + resolution: {integrity: sha512-lDA9MqE8WGi7T/t2BMi+EAXhs4Vcvr94Gqx3q15cFEz8oFZMO4/SFBqYr/UcmNlvW+35alowkVj+w9VhLvs5Cw==} 143 146 144 - '@astrojs/markdown-remark@6.3.7': 145 - resolution: {integrity: sha512-KXGdq6/BC18doBCYXp08alHlWChH0hdD2B1qv9wIyOHbvwI5K6I7FhSta8dq1hBQNdun8YkKPR013D/Hm8xd0g==} 147 + '@astrojs/markdown-remark@6.3.8': 148 + resolution: {integrity: sha512-uFNyFWadnULWK2cOw4n0hLKeu+xaVWeuECdP10cQ3K2fkybtTlhb7J7TcScdjmS8Yps7oje9S/ehYMfZrhrgCg==} 146 149 147 - '@astrojs/node@9.4.4': 148 - resolution: {integrity: sha512-zQelZmeejnpw3Y5cj2gCyAZ6HT7tjgsWLZH8k40s3bTaT6lqJXlPtKJeIsuEcod21vZLODqBEQeu0CWrWm01EQ==} 150 + '@astrojs/node@9.4.6': 151 + resolution: {integrity: sha512-vyl+GaT20CjullFghaO5/g3ygpzfjQdxjRJev8r33Vi831nCe3yyy4G/V1z6wpq4FHDEduMtT2jdXfJVx1th+A==} 149 152 peerDependencies: 150 - astro: ^5.7.0 153 + astro: ^5.14.3 151 154 152 155 '@astrojs/preact@4.1.1': 153 156 resolution: {integrity: sha512-UyUHtZ6uZEghqR5K6ri6YdczYTRjXDw3n9xzBXXtsl2xZ8dj2uVN4P6qrLo5nlON5lEkRCGsn4mO4utuyAB/KA==} ··· 163 166 resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} 164 167 engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} 165 168 169 + '@atcute/atproto@3.1.7': 170 + resolution: {integrity: sha512-3Ym8qaVZg2vf8qw0KO1aue39z/5oik5J+UDoSes1vr8ddw40UVLA5sV4bXSKmLnhzQHiLLgoVZXe4zaKfozPoQ==} 171 + 172 + '@atcute/bluesky@3.2.6': 173 + resolution: {integrity: sha512-jUSSTW5Th1vy0bWBazVHuhGQ3Xz4cX648WvLNpYDv7WPzlFzIWm6cnQCbUToQ+uK3K4WyVuuqYtZqqI0f4wWUQ==} 174 + 175 + '@atcute/client@4.0.4': 176 + resolution: {integrity: sha512-0vkYe6HcGAef8FS4dlGMqCCPG4I4Lve1R8Amk8UEviUVofiqlv1WGoeez9CJFL8G/7vhcgVV9rPTHLJEjZ4RdQ==} 177 + 178 + '@atcute/identity-resolver@1.1.4': 179 + resolution: {integrity: sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==} 180 + peerDependencies: 181 + '@atcute/identity': ^1.0.0 182 + 183 + '@atcute/identity@1.1.1': 184 + resolution: {integrity: sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==} 185 + 186 + '@atcute/lexicons@1.2.2': 187 + resolution: {integrity: sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA==} 188 + 189 + '@atcute/tangled@1.0.8': 190 + resolution: {integrity: sha512-0E5GjyUa7rBN8qq/Z89ViH2FrInQqJCH/Ymhx4r75DzHHDQtAz9hVAM2J3iUx5Xp3/j9uRkAhYyPNGHmO6R/+A==} 191 + 192 + '@atcute/util-fetch@1.0.3': 193 + resolution: {integrity: sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ==} 194 + 166 195 '@atproto-labs/did-resolver@0.2.1': 167 196 resolution: {integrity: sha512-zSoHyqwwRYUtMNLW+RrWsImt1U5S47nJv5FfmAXTmon6wVKjxKD/PFrD1pg/4G6THqJmQHTs1Hj+54XVupYnvQ==} 168 197 ··· 344 373 resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} 345 374 engines: {node: '>=6.9.0'} 346 375 376 + '@badrap/valita@0.4.6': 377 + resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} 378 + engines: {node: '>= 18'} 379 + 347 380 '@bufbuild/protobuf@2.9.0': 348 381 resolution: {integrity: sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==} 349 382 350 - '@capsizecss/unpack@2.4.0': 351 - resolution: {integrity: sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q==} 383 + '@capsizecss/unpack@3.0.0': 384 + resolution: {integrity: sha512-+ntATQe1AlL7nTOYjwjj6w3299CgRot48wL761TUGYpYgAou3AaONZazp0PKZyCyWhudWsjhq1nvRHOvbMzhTA==} 385 + engines: {node: '>=18'} 352 386 353 387 '@emnapi/runtime@1.5.0': 354 388 resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} ··· 1035 1069 resolution: {integrity: sha512-lRx63oCHxeJ90DqIgmbxH1PQmiBDY1wVaLzB4hK0d/xS5BrG1iZO3HdCJS/DQJk6GJ8xHDev8OMI7iGxvE1ZUA==} 1036 1070 engines: {node: '>=20'} 1037 1071 1072 + '@standard-schema/spec@1.0.0': 1073 + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} 1074 + 1038 1075 '@swc/helpers@0.5.17': 1039 1076 resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} 1040 1077 ··· 1389 1426 peerDependencies: 1390 1427 astro: ^4.14.0 || ^5.0.0 1391 1428 1392 - astro@5.14.1: 1393 - resolution: {integrity: sha512-gPa8NY7/lP8j8g81iy8UwANF3+aukKRWS68IlthZQNgykpg80ne6lbHOp6FErYycxQ1TUhgEfkXVDQZAoJx8Bg==} 1429 + astro@5.14.4: 1430 + resolution: {integrity: sha512-yqgMAO2Whi9GmZkByyiPcG7CiiPr0Me0iBSorMa6M0g+wQk/ewnIqUyr7T/uFCPTQndoKwucnYFTrf0yfb0urw==} 1394 1431 engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} 1395 1432 hasBin: true 1396 1433 1434 + atproto-ui@0.3.1: 1435 + resolution: {integrity: sha512-kEaMrvxJBut1n9kSbVUA5mV8+Gj2/LUSfuKngGTpDuYprh+TAyNnZSmKDs24VBl0+termH4XIIpfOw1qD5z7ng==} 1436 + peerDependencies: 1437 + react: ^18.2.0 || ^19.0.0 1438 + react-dom: ^18.2.0 || ^19.0.0 1439 + peerDependenciesMeta: 1440 + react-dom: 1441 + optional: true 1442 + 1397 1443 await-lock@2.2.2: 1398 1444 resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 1399 1445 ··· 1425 1471 binary-extensions@2.3.0: 1426 1472 resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} 1427 1473 engines: {node: '>=8'} 1428 - 1429 - blob-to-buffer@1.2.9: 1430 - resolution: {integrity: sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==} 1431 1474 1432 1475 boolbase@1.0.0: 1433 1476 resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} ··· 1557 1600 1558 1601 crelt@1.0.6: 1559 1602 resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} 1560 - 1561 - cross-fetch@3.2.0: 1562 - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} 1563 1603 1564 1604 crossws@0.3.5: 1565 1605 resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} ··· 1807 1847 escape-string-regexp@5.0.0: 1808 1848 resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} 1809 1849 engines: {node: '>=12'} 1850 + 1851 + esm-env@1.2.2: 1852 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 1810 1853 1811 1854 estree-walker@2.0.2: 1812 1855 resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} ··· 2276 2319 node-fetch-native@1.6.7: 2277 2320 resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} 2278 2321 2279 - node-fetch@2.7.0: 2280 - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} 2281 - engines: {node: 4.x || >=6.0.0} 2282 - peerDependencies: 2283 - encoding: ^0.1.0 2284 - peerDependenciesMeta: 2285 - encoding: 2286 - optional: true 2287 - 2288 2322 node-fetch@3.3.2: 2289 2323 resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} 2290 2324 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} ··· 2859 2893 tiny-inflate@1.0.3: 2860 2894 resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} 2861 2895 2862 - tinyexec@0.3.2: 2863 - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} 2864 - 2865 2896 tinyexec@1.0.1: 2866 2897 resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} 2867 2898 ··· 2884 2915 totalist@3.0.1: 2885 2916 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 2886 2917 engines: {node: '>=6'} 2887 - 2888 - tr46@0.0.3: 2889 - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} 2890 2918 2891 2919 trim-lines@3.0.1: 2892 2920 resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} ··· 2953 2981 unified@11.0.5: 2954 2982 resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} 2955 2983 2956 - unifont@0.5.2: 2957 - resolution: {integrity: sha512-LzR4WUqzH9ILFvjLAUU7dK3Lnou/qd5kD+IakBtBK4S15/+x2y9VX+DcWQv6s551R6W+vzwgVS6tFg3XggGBgg==} 2984 + unifont@0.6.0: 2985 + resolution: {integrity: sha512-5Fx50fFQMQL5aeHyWnZX9122sSLckcDvcfFiBf3QYeHa7a1MKJooUy52b67moi2MJYkrfo/TWY+CoLdr/w0tTA==} 2958 2986 2959 2987 unist-util-find-after@5.0.0: 2960 2988 resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} ··· 3148 3176 resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} 3149 3177 engines: {node: '>= 8'} 3150 3178 3151 - webidl-conversions@3.0.1: 3152 - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} 3153 - 3154 - whatwg-url@5.0.0: 3155 - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} 3156 - 3157 3179 which-pm-runs@1.1.0: 3158 3180 resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} 3159 3181 engines: {node: '>=4'} ··· 3324 3346 - sqlite3 3325 3347 - utf-8-validate 3326 3348 3327 - '@astrojs/internal-helpers@0.7.3': {} 3349 + '@astrojs/internal-helpers@0.7.4': {} 3328 3350 3329 - '@astrojs/markdown-remark@6.3.7': 3351 + '@astrojs/markdown-remark@6.3.8': 3330 3352 dependencies: 3331 - '@astrojs/internal-helpers': 0.7.3 3353 + '@astrojs/internal-helpers': 0.7.4 3332 3354 '@astrojs/prism': 3.3.0 3333 3355 github-slugger: 2.0.0 3334 3356 hast-util-from-html: 2.0.3 ··· 3352 3374 transitivePeerDependencies: 3353 3375 - supports-color 3354 3376 3355 - '@astrojs/node@9.4.4(astro@5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2))': 3377 + '@astrojs/node@9.4.6(astro@5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2))': 3356 3378 dependencies: 3357 - '@astrojs/internal-helpers': 0.7.3 3358 - astro: 5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 3379 + '@astrojs/internal-helpers': 0.7.4 3380 + astro: 5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 3359 3381 send: 1.2.0 3360 3382 server-destroy: 1.0.1 3361 3383 transitivePeerDependencies: ··· 3398 3420 which-pm-runs: 1.1.0 3399 3421 transitivePeerDependencies: 3400 3422 - supports-color 3423 + 3424 + '@atcute/atproto@3.1.7': 3425 + dependencies: 3426 + '@atcute/lexicons': 1.2.2 3427 + 3428 + '@atcute/bluesky@3.2.6': 3429 + dependencies: 3430 + '@atcute/atproto': 3.1.7 3431 + '@atcute/lexicons': 1.2.2 3432 + 3433 + '@atcute/client@4.0.4': 3434 + dependencies: 3435 + '@atcute/identity': 1.1.1 3436 + '@atcute/lexicons': 1.2.2 3437 + 3438 + '@atcute/identity-resolver@1.1.4(@atcute/identity@1.1.1)': 3439 + dependencies: 3440 + '@atcute/identity': 1.1.1 3441 + '@atcute/lexicons': 1.2.2 3442 + '@atcute/util-fetch': 1.0.3 3443 + '@badrap/valita': 0.4.6 3444 + 3445 + '@atcute/identity@1.1.1': 3446 + dependencies: 3447 + '@atcute/lexicons': 1.2.2 3448 + '@badrap/valita': 0.4.6 3449 + 3450 + '@atcute/lexicons@1.2.2': 3451 + dependencies: 3452 + '@standard-schema/spec': 1.0.0 3453 + esm-env: 1.2.2 3454 + 3455 + '@atcute/tangled@1.0.8': 3456 + dependencies: 3457 + '@atcute/atproto': 3.1.7 3458 + '@atcute/lexicons': 1.2.2 3459 + 3460 + '@atcute/util-fetch@1.0.3': 3461 + dependencies: 3462 + '@badrap/valita': 0.4.6 3401 3463 3402 3464 '@atproto-labs/did-resolver@0.2.1': 3403 3465 dependencies: ··· 3698 3760 '@babel/helper-string-parser': 7.27.1 3699 3761 '@babel/helper-validator-identifier': 7.27.1 3700 3762 3763 + '@badrap/valita@0.4.6': {} 3764 + 3701 3765 '@bufbuild/protobuf@2.9.0': 3702 3766 optional: true 3703 3767 3704 - '@capsizecss/unpack@2.4.0': 3768 + '@capsizecss/unpack@3.0.0': 3705 3769 dependencies: 3706 - blob-to-buffer: 1.2.9 3707 - cross-fetch: 3.2.0 3708 3770 fontkit: 2.0.4 3709 - transitivePeerDependencies: 3710 - - encoding 3711 3771 3712 3772 '@emnapi/runtime@1.5.0': 3713 3773 dependencies: ··· 3803 3863 3804 3864 '@floating-ui/utils@0.2.10': {} 3805 3865 3806 - '@fujocoded/authproto@0.1.1(astro@5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2))(pg@8.16.3)': 3866 + '@fujocoded/authproto@0.1.1(astro@5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2))(pg@8.16.3)': 3807 3867 dependencies: 3808 3868 '@astrojs/db': 0.17.2(pg@8.16.3) 3809 3869 '@atproto/identity': 0.4.9 3810 3870 '@atproto/oauth-client-node': 0.3.8 3811 - astro: 5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 3812 - astro-integration-kit: 0.19.0(astro@5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2)) 3871 + astro: 5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 3872 + astro-integration-kit: 0.19.0(astro@5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2)) 3813 3873 unstorage: 1.17.1 3814 3874 transitivePeerDependencies: 3815 3875 - '@aws-sdk/client-rds-data' ··· 4307 4367 4308 4368 '@sindresorhus/transliterate@2.0.0': {} 4309 4369 4370 + '@standard-schema/spec@1.0.0': {} 4371 + 4310 4372 '@swc/helpers@0.5.17': 4311 4373 dependencies: 4312 4374 tslib: 2.8.1 ··· 4729 4791 4730 4792 array-iterate@2.0.1: {} 4731 4793 4732 - astro-integration-kit@0.19.0(astro@5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2)): 4794 + astro-integration-kit@0.19.0(astro@5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2)): 4733 4795 dependencies: 4734 - astro: 5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 4796 + astro: 5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2) 4735 4797 pathe: 1.1.2 4736 4798 4737 - astro@5.14.1(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2): 4799 + astro@5.14.4(@types/node@24.6.0)(jiti@2.6.0)(rollup@4.52.3)(sass-embedded@1.93.2)(sass@1.93.2)(typescript@5.9.2): 4738 4800 dependencies: 4739 4801 '@astrojs/compiler': 2.13.0 4740 - '@astrojs/internal-helpers': 0.7.3 4741 - '@astrojs/markdown-remark': 6.3.7 4802 + '@astrojs/internal-helpers': 0.7.4 4803 + '@astrojs/markdown-remark': 6.3.8 4742 4804 '@astrojs/telemetry': 3.3.0 4743 - '@capsizecss/unpack': 2.4.0 4805 + '@capsizecss/unpack': 3.0.0 4744 4806 '@oslojs/encoding': 1.1.0 4745 4807 '@rollup/pluginutils': 5.3.0(rollup@4.52.3) 4746 4808 acorn: 8.15.0 ··· 4782 4844 semver: 7.7.2 4783 4845 shiki: 3.13.0 4784 4846 smol-toml: 1.4.2 4785 - tinyexec: 0.3.2 4847 + tinyexec: 1.0.1 4786 4848 tinyglobby: 0.2.15 4787 4849 tsconfck: 3.1.6(typescript@5.9.2) 4788 4850 ultrahtml: 1.6.0 4789 - unifont: 0.5.2 4851 + unifont: 0.6.0 4790 4852 unist-util-visit: 5.0.0 4791 4853 unstorage: 1.17.1 4792 4854 vfile: 6.0.3 ··· 4818 4880 - '@vercel/kv' 4819 4881 - aws4fetch 4820 4882 - db0 4821 - - encoding 4822 4883 - idb-keyval 4823 4884 - ioredis 4824 4885 - jiti ··· 4836 4897 - uploadthing 4837 4898 - yaml 4838 4899 4900 + atproto-ui@0.3.1(@atcute/identity@1.1.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): 4901 + dependencies: 4902 + '@atcute/atproto': 3.1.7 4903 + '@atcute/bluesky': 3.2.6 4904 + '@atcute/client': 4.0.4 4905 + '@atcute/identity-resolver': 1.1.4(@atcute/identity@1.1.1) 4906 + '@atcute/tangled': 1.0.8 4907 + react: 19.2.0 4908 + optionalDependencies: 4909 + react-dom: 19.2.0(react@19.2.0) 4910 + transitivePeerDependencies: 4911 + - '@atcute/identity' 4912 + 4839 4913 await-lock@2.2.2: {} 4840 4914 4841 4915 axobject-query@4.1.0: {} ··· 4855 4929 baseline-browser-mapping@2.8.12: {} 4856 4930 4857 4931 binary-extensions@2.3.0: {} 4858 - 4859 - blob-to-buffer@1.2.9: {} 4860 4932 4861 4933 boolbase@1.0.0: {} 4862 4934 ··· 4971 5043 cookie@1.0.2: {} 4972 5044 4973 5045 crelt@1.0.6: {} 4974 - 4975 - cross-fetch@3.2.0: 4976 - dependencies: 4977 - node-fetch: 2.7.0 4978 - transitivePeerDependencies: 4979 - - encoding 4980 5046 4981 5047 crossws@0.3.5: 4982 5048 dependencies: ··· 5126 5192 escape-string-regexp@4.0.0: {} 5127 5193 5128 5194 escape-string-regexp@5.0.0: {} 5195 + 5196 + esm-env@1.2.2: {} 5129 5197 5130 5198 estree-walker@2.0.2: {} 5131 5199 ··· 5803 5871 5804 5872 node-fetch-native@1.6.7: {} 5805 5873 5806 - node-fetch@2.7.0: 5807 - dependencies: 5808 - whatwg-url: 5.0.0 5809 - 5810 5874 node-fetch@3.3.2: 5811 5875 dependencies: 5812 5876 data-uri-to-buffer: 4.0.1 ··· 6501 6565 6502 6566 tiny-inflate@1.0.3: {} 6503 6567 6504 - tinyexec@0.3.2: {} 6505 - 6506 6568 tinyexec@1.0.1: {} 6507 6569 6508 6570 tinyglobby@0.2.15: ··· 6519 6581 toidentifier@1.0.1: {} 6520 6582 6521 6583 totalist@3.0.1: {} 6522 - 6523 - tr46@0.0.3: {} 6524 6584 6525 6585 trim-lines@3.0.1: {} 6526 6586 ··· 6584 6644 trough: 2.2.0 6585 6645 vfile: 6.0.3 6586 6646 6587 - unifont@0.5.2: 6647 + unifont@0.6.0: 6588 6648 dependencies: 6589 6649 css-tree: 3.1.0 6590 6650 ofetch: 1.4.1 ··· 6737 6797 web-namespaces@2.0.1: {} 6738 6798 6739 6799 web-streams-polyfill@3.3.3: {} 6740 - 6741 - webidl-conversions@3.0.1: {} 6742 - 6743 - whatwg-url@5.0.0: 6744 - dependencies: 6745 - tr46: 0.0.3 6746 - webidl-conversions: 3.0.1 6747 6800 6748 6801 which-pm-runs@1.1.0: {} 6749 6802
src/components/chapters/Bsky.astro

This is a binary file and will not be displayed.

+82
src/components/chapters/Bsky.tsx
··· 1 + /** @jsxImportSource react */ 2 + import { AtProtoProvider, BlueskyPost, formatDidForLabel, parseAtUri, toBlueskyPostUrl, useDidResolution, type BlueskyPostRendererProps, type FeedPostRecord, type ParsedAtUri } from "atproto-ui"; 3 + import { createAutoEmbed } from "./utils/BskyEmbed"; 4 + import { createFacetedSegments } from "./utils/Facet"; 5 + 6 + type props = { 7 + did: string; 8 + rkey: string; 9 + embed?: boolean; 10 + } 11 + 12 + export function Bsky({ did, rkey, embed = false }: props) { 13 + // You can embed Bluesky posts inside other documents, 14 + // and there's no need to wrap it in another <AtProtoProvider> unless it's standalone 15 + return embed ? ( 16 + <BlueskyPost 17 + did={did} 18 + rkey={rkey} 19 + // renderer={bskyRenderer} 20 + /> 21 + ) : ( 22 + <AtProtoProvider> 23 + <BlueskyPost 24 + did={did} 25 + rkey={rkey} 26 + // renderer={bskyRenderer} 27 + /> 28 + </AtProtoProvider> 29 + ) 30 + } 31 + 32 + function bskyRenderer({ record, error, loading, embed, authorDid, atUri }: BlueskyPostRendererProps) { 33 + const replyParentUri = record.reply?.parent?.uri; 34 + const replyTarget = replyParentUri ? parseAtUri(replyParentUri) : undefined; 35 + const { handle: parentHandle, loading: parentHandleLoading } = useDidResolution(replyTarget?.did); 36 + 37 + if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load post.</div>; 38 + if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>; 39 + 40 + const createdDate = new Date(record.createdAt); 41 + const created = createdDate.toLocaleString(undefined, { 42 + dateStyle: 'medium', 43 + timeStyle: 'short' 44 + }); 45 + const replyHref = replyTarget ? toBlueskyPostUrl(replyTarget) : undefined; 46 + const replyLabel = replyTarget ? formatReplyLabel(replyTarget, parentHandle, parentHandleLoading) : undefined; 47 + 48 + const resolvedEmbed = embed ?? createAutoEmbed(record, authorDid); 49 + const parsedSelf = atUri ? parseAtUri(atUri) : undefined; 50 + const postUrl = parsedSelf ? toBlueskyPostUrl(parsedSelf) : undefined; 51 + 52 + return ( 53 + <article className="bsky" aria-busy={loading}> 54 + {replyHref && replyLabel && ( 55 + <div> 56 + Replying to{' '} 57 + <a href={replyHref} target="_blank" rel="noopener noreferrer"> 58 + {replyLabel} 59 + </a> 60 + </div> 61 + )} 62 + {postUrl && ( 63 + <span> 64 + <a href={postUrl} target="_blank" rel="noopener noreferrer"> 65 + View on Bluesky 66 + </a> 67 + </span> 68 + )} 69 + {record.text.split(/\n/g).map(text => <p>{text}</p>)} 70 + {record.facets && record.facets.length > 0 && ( 71 + <>{createFacetedSegments(record.text, record.facets)}</> 72 + )} 73 + {resolvedEmbed && <>{resolvedEmbed}</>} 74 + </article> 75 + ) 76 + } 77 + 78 + function formatReplyLabel(target: ParsedAtUri, resolvedHandle?: string, loading?: boolean): string { 79 + if (resolvedHandle) return `@${resolvedHandle}`; 80 + if (loading) return '…'; 81 + return `@${formatDidForLabel(target.did)}`; 82 + }
+12 -3
src/components/chapters/Chapter.astro
··· 9 9 --- 10 10 <section> 11 11 <header> 12 - <h2>{chapter.title}</h2> 13 - {chapter.notes && ( 12 + <h1>{chapter.title}</h1> 13 + {chapter.authorsNotes && ( 14 14 <details> 15 15 <summary>Author's Notes</summary> 16 - <Fragment set:html={chapter.notes} /> 16 + <Fragment set:html={chapter.authorsNotes} /> 17 17 </details> 18 18 )} 19 19 <time datetime={chapter.createdAt.toISOString()}> ··· 24 24 <div class="prose lg:prose-xl"> 25 25 <Fragment set:html={chapter.content} /> 26 26 </div> 27 + 28 + <footer> 29 + {chapter.endNotes && ( 30 + <aside> 31 + <p>End notes</p> 32 + <Fragment set:html={chapter.endNotes} /> 33 + </aside> 34 + )} 35 + </footer> 27 36 </section>
-5
src/components/chapters/Leaflet.astro
··· 1 - --- 2 - import { ComAtprotoRepoStrongRef } from "@atproto/api"; 3 - 4 - 5 - ---
+30
src/components/chapters/Leaflet.tsx
··· 1 + /** @jsxImportSource react */ 2 + import { AtProtoProvider, LeafletDocument } from "atproto-ui"; 3 + 4 + type props = { 5 + did: string; 6 + rkey: string; 7 + } 8 + 9 + export function Leaflet({ did, rkey }: props) { 10 + return ( 11 + <AtProtoProvider> 12 + <LeafletDocument 13 + did={did} 14 + rkey={rkey} 15 + // renderer={({ record }) => ( 16 + // <article> 17 + // {record.pages.map(page => ( 18 + // <> 19 + // <pre>{JSON.stringify(page, null, 2)}</pre> 20 + // {page.blocks?.map(({ block }) => ( 21 + // <p>{block.$type}</p> 22 + // ))} 23 + // </> 24 + // ))} 25 + // </article> 26 + // )} 27 + /> 28 + </AtProtoProvider> 29 + ) 30 + }
+137
src/components/chapters/utils/BskyEmbed.tsx
··· 1 + import { useBlob, type FeedPostRecord } from "atproto-ui"; 2 + 3 + export function createAutoEmbed(record: FeedPostRecord, authorDid: string | undefined) { 4 + const embed = record.embed as { $type?: string } | undefined; 5 + if (!embed) return null; 6 + if (embed.$type === 'app.bsky.embed.external') { 7 + return <ExternalEmbed embed={embed as ExternalEmbedType} did={authorDid} />; 8 + } 9 + if (embed.$type === 'app.bsky.embed.images') { 10 + return <ImagesEmbed embed={embed as ImagesEmbedType} did={authorDid} />; 11 + } 12 + if (embed.$type === 'app.bsky.embed.recordWithMedia') { 13 + const media = (embed as RecordWithMediaEmbed).media; 14 + if (media?.$type === 'app.bsky.embed.images') { 15 + return <ImagesEmbed embed={media as ImagesEmbedType} did={authorDid} />; 16 + } 17 + } 18 + return null; 19 + } 20 + 21 + type ImagesEmbedType = { 22 + $type: 'app.bsky.embed.images'; 23 + images: Array<{ 24 + alt?: string; 25 + mime?: string; 26 + size?: number; 27 + image?: { 28 + $type?: string; 29 + ref?: { $link?: string }; 30 + cid?: string; 31 + }; 32 + aspectRatio?: { 33 + width: number; 34 + height: number; 35 + }; 36 + }>; 37 + }; 38 + 39 + type RecordWithMediaEmbed = { 40 + $type: 'app.bsky.embed.recordWithMedia'; 41 + record?: unknown; 42 + media?: { $type?: string }; 43 + }; 44 + 45 + interface ImagesEmbedProps { 46 + embed: ImagesEmbedType; 47 + did?: string; 48 + } 49 + 50 + function ImagesEmbed({ embed, did }: ImagesEmbedProps) { 51 + if (!embed.images || embed.images.length === 0) return null; 52 + const columns = embed.images.length > 1 ? 'repeat(auto-fit, minmax(160px, 1fr))' : '1fr'; 53 + return ( 54 + <div style={{ gridTemplateColumns: columns }}> 55 + {embed.images.map((image, idx) => ( 56 + <PostImage key={idx} image={image} did={did} /> 57 + ))} 58 + </div> 59 + ); 60 + }; 61 + 62 + interface PostImageProps { 63 + image: ImagesEmbedType['images'][number]; 64 + did?: string; 65 + } 66 + 67 + function PostImage({ image, did }: PostImageProps) { 68 + const cid = image.image?.ref?.$link ?? image.image?.cid; 69 + const { url, loading, error } = useBlob(did, cid); 70 + const alt = image.alt?.trim() || 'Bluesky attachment'; 71 + const aspect = image.aspectRatio && image.aspectRatio.height > 0 72 + ? `${image.aspectRatio.width} / ${image.aspectRatio.height}` 73 + : undefined; 74 + 75 + return ( 76 + <figure aria-busy={loading}> 77 + <div style={{ aspectRatio: aspect }}> 78 + {url ? ( 79 + <img src={url} alt={alt} /> 80 + ) : ( 81 + <div> 82 + {loading ? 'Loading image…' : error ? 'Image failed to load' : 'Image unavailable'} 83 + </div> 84 + )} 85 + </div> 86 + {image.alt && image.alt.trim().length > 0 && ( 87 + <figcaption>{image.alt}</figcaption> 88 + )} 89 + </figure> 90 + ); 91 + }; 92 + 93 + type ExternalEmbedType = { 94 + $type: "app.bsky.embed.external"; 95 + external: { 96 + description?: string; 97 + thumb?: { 98 + $type?: string; 99 + mimeType?: string; 100 + ref?: { $link?: string; }; 101 + size?: number; 102 + }; 103 + title?: string; 104 + uri?: string; 105 + }; 106 + } 107 + 108 + interface ExternalEmbedProps { 109 + embed: ExternalEmbedType; 110 + did?: string; 111 + } 112 + 113 + function ExternalEmbed({ embed, did }: ExternalEmbedProps) { 114 + const cid = embed.external.thumb?.ref?.$link; 115 + const image = useBlob(did, cid); 116 + const { url, loading, error } = image; 117 + 118 + return ( 119 + <figure aria-busy={loading}> 120 + <div className="max-w-full"> 121 + <a href={embed.external.uri} target="_blank" rel="noopener noreferrer"> 122 + <span aria-hidden="true">{embed.external.title}</span> 123 + </a> 124 + {url ? ( 125 + <img src={url} alt={embed.external.description} /> 126 + ) : ( 127 + <div className={error ? "text-error" : "text-neutral"}> 128 + {loading ? 'Loading image…' : error ? 'Image failed to load' : 'Image unavailable'} 129 + </div> 130 + )} 131 + </div> 132 + {embed.external.description && embed.external.description.trim().length > 0 && ( 133 + <figcaption className="font-style-italic">{embed.external.description}</figcaption> 134 + )} 135 + </figure> 136 + ); 137 + }
+163
src/components/chapters/utils/Facet.tsx
··· 1 + import type { LeafletRichTextFacet, LeafletRichTextFeature } from "atproto-ui"; 2 + import React from "preact/compat"; 3 + 4 + interface BskyPostTextFacet { 5 + $type?: "app.bsky.richtext.facet"; 6 + features?: BskyPostFeature[]; 7 + index: { 8 + byteEnd: number; 9 + byteStart: number; 10 + $type?: "app.bsky.richtext.facet#byteSlice"; 11 + }; 12 + } 13 + 14 + type BskyPostFeature = BskyPostLinkFeature | BskyPostTagFeature | BskyPostMentionFeature; 15 + 16 + type BskyPostTagFeature = { 17 + $type: "app.bsky.richtext.facet#tag"; 18 + tag?: string; 19 + }; 20 + 21 + type BskyPostLinkFeature = { 22 + $type: "app.bsky.richtext.facet#link"; 23 + uri?: string; 24 + } 25 + 26 + type BskyPostMentionFeature = { 27 + $type: "app.bsky.richtext.facet#mention"; 28 + did?: string; 29 + } 30 + 31 + type Features = LeafletRichTextFeature | BskyPostFeature; 32 + 33 + interface Segment { 34 + text: string; 35 + features: Features[]; 36 + } 37 + 38 + export function createFacetedSegments(plaintext: string, facets?: LeafletRichTextFacet[] | BskyPostTextFacet[]): Segment[] { 39 + if (!facets?.length) { 40 + return [{ text: plaintext, features: [] }]; 41 + } 42 + 43 + const prefix = buildBytePrefix(plaintext); 44 + const startEvents = new Map<number, Features[]>(); 45 + const endEvents = new Map<number, Features[]>(); 46 + const boundaries = new Set<number>([0, prefix.length - 1]); 47 + for (const facet of facets) { 48 + const { byteStart, byteEnd } = facet.index ?? {}; 49 + if (typeof byteStart !== 'number' || typeof byteEnd !== 'number' || byteStart >= byteEnd) continue; 50 + const start = byteOffsetToCharIndex(prefix, byteStart); 51 + const end = byteOffsetToCharIndex(prefix, byteEnd); 52 + if (start >= end) continue; 53 + boundaries.add(start); 54 + boundaries.add(end); 55 + if (facet.features?.length) { 56 + startEvents.set(start, [...(startEvents.get(start) ?? []), ...facet.features]); 57 + endEvents.set(end, [...(endEvents.get(end) ?? []), ...facet.features]); 58 + } 59 + } 60 + const sortedBounds = [...boundaries].sort((a, b) => a - b); 61 + const segments: Segment[] = []; 62 + let active: Features[] = []; 63 + for (let i = 0; i < sortedBounds.length - 1; i++) { 64 + const boundary = sortedBounds[i]; 65 + const next = sortedBounds[i + 1]; 66 + const endFeatures = endEvents.get(boundary); 67 + if (endFeatures?.length) { 68 + active = active.filter((feature) => !endFeatures.includes(feature)); 69 + } 70 + const startFeatures = startEvents.get(boundary); 71 + if (startFeatures?.length) { 72 + active = [...active, ...startFeatures]; 73 + } 74 + if (boundary === next) continue; 75 + const text = sliceByCharRange(plaintext, boundary, next); 76 + segments.push({ text, features: active.slice() }); 77 + } 78 + return segments; 79 + } 80 + 81 + function buildBytePrefix(text: string): number[] { 82 + const encoder = new TextEncoder(); 83 + const prefix: number[] = [0]; 84 + let byteCount = 0; 85 + for (let i = 0; i < text.length;) { 86 + const codePoint = text.codePointAt(i)!; 87 + const char = String.fromCodePoint(codePoint); 88 + const encoded = encoder.encode(char); 89 + byteCount += encoded.length; 90 + prefix.push(byteCount); 91 + i += codePoint > 0xffff ? 2 : 1; 92 + } 93 + return prefix; 94 + } 95 + 96 + function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number { 97 + for (let i = 0; i < prefix.length; i++) { 98 + if (prefix[i] === byteOffset) return i; 99 + if (prefix[i] > byteOffset) return Math.max(0, i - 1); 100 + } 101 + return prefix.length - 1; 102 + } 103 + 104 + function sliceByCharRange(text: string, start: number, end: number): string { 105 + if (start <= 0 && end >= text.length) return text; 106 + let result = ''; 107 + let charIndex = 0; 108 + for (let i = 0; i < text.length && charIndex < end;) { 109 + const codePoint = text.codePointAt(i)!; 110 + const char = String.fromCodePoint(codePoint); 111 + if (charIndex >= start && charIndex < end) result += char; 112 + i += codePoint > 0xffff ? 2 : 1; 113 + charIndex++; 114 + } 115 + return result; 116 + } 117 + 118 + export function renderSegment(segment: Segment): React.ReactNode { 119 + const parts = segment.text.split('\n'); 120 + return parts.flatMap((part, idx) => { 121 + const key = `${segment.text}-${idx}-${part.length}`; 122 + const wrapped = applyFeatures(part.length ? part : '\u00a0', segment.features, key); 123 + if (idx === parts.length - 1) return wrapped; 124 + return [wrapped, <br key={`${key}-br`} />]; 125 + }); 126 + } 127 + 128 + export function applyFeatures(content: React.ReactNode, features: Features[], key: string): React.ReactNode { 129 + if (!features?.length) return <React.Fragment key={key}>{content}</React.Fragment>; 130 + return ( 131 + <React.Fragment key={key}> 132 + {features.reduce<React.ReactNode>((child, feature, idx) => wrapFeature(child, feature, `${key}-feature-${idx}`), content)} 133 + </React.Fragment> 134 + ); 135 + } 136 + 137 + export function wrapFeature(child: React.ReactNode, feature: Features, key: string): React.ReactNode { 138 + switch (feature.$type) { 139 + case 'app.bsky.richtext.facet#link': 140 + case 'pub.leaflet.richtext.facet#link': 141 + return <a key={key} href={feature.uri} target="_blank" rel="noopener noreferrer">{child}</a>; 142 + case 'pub.leaflet.richtext.facet#code': 143 + return <code key={key}>{child}</code>; 144 + case 'pub.leaflet.richtext.facet#highlight': 145 + return <mark key={key}>{child}</mark>; 146 + case 'pub.leaflet.richtext.facet#underline': 147 + return <span key={key} style={{ textDecoration: 'underline' }}>{child}</span>; 148 + case 'pub.leaflet.richtext.facet#strikethrough': 149 + return <span key={key} style={{ textDecoration: 'line-through' }}>{child}</span>; 150 + case 'pub.leaflet.richtext.facet#bold': 151 + return <strong key={key}>{child}</strong>; 152 + case 'pub.leaflet.richtext.facet#italic': 153 + return <em key={key}>{child}</em>; 154 + case 'pub.leaflet.richtext.facet#id': 155 + return <span key={key} id={feature.id}>{child}</span>; 156 + case "app.bsky.richtext.facet#mention": 157 + return <a key={key} href={`https://bsky.app/profile/${feature.did}`} target="_blank" rel="noopener noreferrer">@{child}</a> 158 + case "app.bsky.richtext.facet#tag": 159 + return <a key={key} href={`https://bsky.app/hashtag/${feature.tag}`} target="_blank" rel="noopener noreferrer">{feature.tag}</a> 160 + default: 161 + return <span key={key}>{child}</span>; 162 + } 163 + }
+284
src/components/chapters/utils/LeafletBlocks.tsx
··· 1 + import { BlueskyPost, formatDidForLabel, parseAtUri, useBlob, useDidResolution, type LeafletAlignmentValue, type LeafletBlock, type LeafletBlockquoteBlock, type LeafletBskyPostBlock, type LeafletCodeBlock, type LeafletHeaderBlock, type LeafletIFrameBlock, type LeafletImageBlock, type LeafletLinearDocumentBlock, type LeafletLinearDocumentPage, type LeafletListItem, type LeafletMathBlock, type LeafletRichTextFacet, type LeafletRichTextFeature, type LeafletTextBlock, type LeafletUnorderedListBlock, type LeafletWebsiteBlock } from "atproto-ui"; 2 + import React from "preact/compat"; 3 + import { useMemo, useRef } from "preact/hooks"; 4 + import { createFacetedSegments, renderSegment } from "./Facet"; 5 + 6 + export const LeafletRenderer: React.FC<{ page: LeafletLinearDocumentPage; documentDid: string; }> = ({ page, documentDid }) => { 7 + if (!page.blocks?.length) return null; 8 + return ( 9 + <div> 10 + {page.blocks.map((blockWrapper, idx) => ( 11 + <LeafletBlockRenderer 12 + key={`block-${idx}`} 13 + wrapper={blockWrapper} 14 + documentDid={documentDid} 15 + isFirst={idx === 0} 16 + /> 17 + ))} 18 + </div> 19 + ); 20 + }; 21 + 22 + interface LeafletBlockRendererProps { 23 + wrapper: LeafletLinearDocumentBlock; 24 + documentDid: string; 25 + isFirst?: boolean; 26 + } 27 + 28 + const LeafletBlockRenderer: React.FC<LeafletBlockRendererProps> = ({ wrapper, documentDid, isFirst }) => { 29 + const block = wrapper.block; 30 + if (!block || !('$type' in block) || !block.$type) { 31 + return null; 32 + } 33 + const alignment = alignmentValue(wrapper.alignment); 34 + 35 + switch (block.$type) { 36 + case 'pub.leaflet.blocks.header': 37 + return <LeafletHeaderBlockView block={block} alignment={alignment} isFirst={isFirst} />; 38 + case 'pub.leaflet.blocks.blockquote': 39 + return <LeafletBlockquoteBlockView block={block} alignment={alignment} isFirst={isFirst} />; 40 + case 'pub.leaflet.blocks.image': 41 + return <LeafletImageBlockView block={block} alignment={alignment} documentDid={documentDid} />; 42 + case 'pub.leaflet.blocks.unorderedList': 43 + return <LeafletListBlockView block={block} alignment={alignment} documentDid={documentDid} />; 44 + case 'pub.leaflet.blocks.website': 45 + return <LeafletWebsiteBlockView block={block} alignment={alignment} documentDid={documentDid} />; 46 + case 'pub.leaflet.blocks.iframe': 47 + return <LeafletIframeBlockView block={block} alignment={alignment} />; 48 + case 'pub.leaflet.blocks.math': 49 + return <LeafletMathBlockView block={block} alignment={alignment} />; 50 + case 'pub.leaflet.blocks.code': 51 + return <LeafletCodeBlockView block={block} alignment={alignment} />; 52 + case 'pub.leaflet.blocks.horizontalRule': 53 + return <LeafletHorizontalRuleBlockView alignment={alignment} />; 54 + case 'pub.leaflet.blocks.bskyPost': 55 + return <LeafletBskyPostBlockView block={block} />; 56 + case 'pub.leaflet.blocks.text': 57 + default: 58 + return <LeafletTextBlockView block={block as LeafletTextBlock} alignment={alignment} isFirst={isFirst} />; 59 + } 60 + }; 61 + 62 + const LeafletTextBlockView: React.FC<{ block: LeafletTextBlock; alignment?: React.CSSProperties['textAlign']; isFirst?: boolean }> = ({ block, alignment, isFirst }) => { 63 + const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]); 64 + const textContent = block.plaintext ?? ''; 65 + if (!textContent.trim() && segments.length === 0) { 66 + return null; 67 + } 68 + const style: React.CSSProperties = { 69 + ...(alignment ? { textAlign: alignment } : undefined), 70 + ...(isFirst ? { marginTop: 0 } : undefined) 71 + }; 72 + return ( 73 + <p style={style}> 74 + {segments.map((segment, idx) => ( 75 + <React.Fragment key={`text-${idx}`}> 76 + {renderSegment(segment)} 77 + </React.Fragment> 78 + ))} 79 + </p> 80 + ); 81 + }; 82 + 83 + const LeafletHeaderBlockView: React.FC<{ block: LeafletHeaderBlock; alignment?: React.CSSProperties['textAlign']; isFirst?: boolean }> = ({ block, alignment, isFirst }) => { 84 + const level = block.level && block.level >= 1 && block.level <= 6 ? block.level : 2; 85 + const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]); 86 + const normalizedLevel = Math.min(Math.max(level, 1), 6) as 1 | 2 | 3 | 4 | 5 | 6; 87 + const headingTag = (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const)[normalizedLevel - 1]; 88 + const style: React.CSSProperties = { 89 + ...(alignment ? { textAlign: alignment } : undefined), 90 + ...(isFirst ? { marginTop: 0 } : undefined) 91 + }; 92 + return React.createElement( 93 + headingTag, 94 + { style }, 95 + segments.map((segment, idx) => ( 96 + <React.Fragment key={`header-${idx}`}> 97 + {renderSegment(segment)} 98 + </React.Fragment> 99 + )) 100 + ); 101 + }; 102 + 103 + const LeafletBlockquoteBlockView: React.FC<{ block: LeafletBlockquoteBlock; alignment?: React.CSSProperties['textAlign']; isFirst?: boolean }> = ({ block, alignment, isFirst }) => { 104 + const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]); 105 + const textContent = block.plaintext ?? ''; 106 + if (!textContent.trim() && segments.length === 0) { 107 + return null; 108 + } 109 + return ( 110 + <blockquote style={{ ...(alignment ? { textAlign: alignment } : undefined), ...(isFirst ? { marginTop: 0 } : undefined) }}> 111 + {segments.map((segment, idx) => ( 112 + <React.Fragment key={`quote-${idx}`}> 113 + {renderSegment(segment)} 114 + </React.Fragment> 115 + ))} 116 + </blockquote> 117 + ); 118 + }; 119 + 120 + const LeafletImageBlockView: React.FC<{ block: LeafletImageBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; }> = ({ block, alignment, documentDid }) => { 121 + const cid = block.image?.ref?.$link ?? block.image?.cid; 122 + const { url, loading, error } = useBlob(documentDid, cid); 123 + const aspectRatio = block.aspectRatio?.height && block.aspectRatio?.width 124 + ? `${block.aspectRatio.width} / ${block.aspectRatio.height}` 125 + : undefined; 126 + 127 + return ( 128 + <figure style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 129 + <div style={{ ...(aspectRatio ? { aspectRatio } : {}) }}> 130 + {url && !error ? ( 131 + <img src={url} alt={block.alt ?? ''} /> 132 + ) : ( 133 + <div> 134 + {loading ? 'Loading image…' : error ? 'Image unavailable' : 'No image'} 135 + </div> 136 + )} 137 + </div> 138 + {block.alt && block.alt.trim().length > 0 && ( 139 + <figcaption>{block.alt}</figcaption> 140 + )} 141 + </figure> 142 + ); 143 + }; 144 + 145 + const LeafletListBlockView: React.FC<{ block: LeafletUnorderedListBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; }> = ({ block, alignment, documentDid }) => { 146 + return ( 147 + <ul style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 148 + {block.children?.map((child, idx) => ( 149 + <LeafletListItemRenderer 150 + key={`list-item-${idx}`} 151 + item={child} 152 + documentDid={documentDid} 153 + alignment={alignment} 154 + /> 155 + ))} 156 + </ul> 157 + ); 158 + }; 159 + 160 + const LeafletListItemRenderer: React.FC<{ item: LeafletListItem; documentDid: string; alignment?: React.CSSProperties['textAlign'] }> = ({ item, documentDid, alignment }) => { 161 + return ( 162 + <li style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 163 + <div> 164 + <LeafletInlineBlock block={item.content} documentDid={documentDid} alignment={alignment} /> 165 + </div> 166 + {item.children && item.children.length > 0 && ( 167 + <ul style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 168 + {item.children.map((child, idx) => ( 169 + <LeafletListItemRenderer key={`nested-${idx}`} item={child} documentDid={documentDid} alignment={alignment} /> 170 + ))} 171 + </ul> 172 + )} 173 + </li> 174 + ); 175 + }; 176 + 177 + const LeafletInlineBlock: React.FC<{ block: LeafletBlock; documentDid: string; alignment?: React.CSSProperties['textAlign'] }> = ({ block, documentDid, alignment }) => { 178 + switch (block.$type) { 179 + case 'pub.leaflet.blocks.header': 180 + return <LeafletHeaderBlockView block={block as LeafletHeaderBlock} alignment={alignment} />; 181 + case 'pub.leaflet.blocks.blockquote': 182 + return <LeafletBlockquoteBlockView block={block as LeafletBlockquoteBlock} alignment={alignment} />; 183 + case 'pub.leaflet.blocks.image': 184 + return <LeafletImageBlockView block={block as LeafletImageBlock} documentDid={documentDid} alignment={alignment} />; 185 + default: 186 + return <LeafletTextBlockView block={block as LeafletTextBlock} alignment={alignment} />; 187 + } 188 + }; 189 + 190 + const LeafletWebsiteBlockView: React.FC<{ block: LeafletWebsiteBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string }> = ({ block, alignment, documentDid }) => { 191 + const previewCid = block.previewImage?.ref?.$link ?? block.previewImage?.cid; 192 + const { url, loading, error } = useBlob(documentDid, previewCid); 193 + 194 + return ( 195 + <a href={block.src} target="_blank" rel="noopener noreferrer" style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 196 + {url && !error ? ( 197 + <img src={url} alt={block.title ?? 'Website preview'} /> 198 + ) : ( 199 + <div> 200 + {loading ? 'Loading preview…' : 'Open link'} 201 + </div> 202 + )} 203 + <div> 204 + {block.title && <strong>{block.title}</strong>} 205 + {block.description && <p>{block.description}</p>} 206 + <span>{block.src}</span> 207 + </div> 208 + </a> 209 + ); 210 + }; 211 + 212 + const LeafletIframeBlockView: React.FC<{ block: LeafletIFrameBlock; alignment?: React.CSSProperties['textAlign'] }> = ({ block, alignment }) => { 213 + return ( 214 + <div style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 215 + <iframe 216 + src={block.url} 217 + title={block.url} 218 + style={{ ...(block.height ? { height: Math.min(Math.max(block.height, 120), 800) } : {}) }} 219 + loading="lazy" 220 + allowFullScreen 221 + /> 222 + </div> 223 + ); 224 + }; 225 + 226 + const LeafletMathBlockView: React.FC<{ block: LeafletMathBlock; alignment?: React.CSSProperties['textAlign'] }> = ({ block, alignment }) => { 227 + return ( 228 + <pre style={{ ...(alignment ? { textAlign: alignment }: undefined) }}>{block.tex}</pre> 229 + ); 230 + }; 231 + 232 + const LeafletCodeBlockView: React.FC<{ block: LeafletCodeBlock; alignment?: React.CSSProperties['textAlign']; }> = ({ block, alignment }) => { 233 + const codeRef = useRef<HTMLElement | null>(null); 234 + const langClass = block.language ? `language-${block.language.toLowerCase()}` : undefined; 235 + return ( 236 + <pre style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 237 + <code ref={codeRef} className={langClass}>{block.plaintext}</code> 238 + </pre> 239 + ); 240 + }; 241 + 242 + const LeafletHorizontalRuleBlockView: React.FC<{ alignment?: React.CSSProperties['textAlign']; }> = ({ alignment }) => { 243 + return <hr style={{ marginLeft: alignment ? 'auto' : undefined, marginRight: alignment ? 'auto' : undefined }} />; 244 + }; 245 + 246 + const LeafletBskyPostBlockView: React.FC<{ block: LeafletBskyPostBlock }> = ({ block }) => { 247 + const parsed = parseAtUri(block.postRef?.uri); 248 + if (!parsed) { 249 + return <div>Referenced post unavailable.</div>; 250 + } 251 + return <BlueskyPost did={parsed.did} rkey={parsed.rkey} iconPlacement="linkInline" />; 252 + }; 253 + 254 + function alignmentValue(value?: LeafletAlignmentValue): React.CSSProperties['textAlign'] | undefined { 255 + if (!value) return undefined; 256 + let normalized = value.startsWith('#') ? value.slice(1) : value; 257 + if (normalized.includes('#')) { 258 + normalized = normalized.split('#').pop() ?? normalized; 259 + } 260 + if (normalized.startsWith('lex:')) { 261 + normalized = normalized.split(':').pop() ?? normalized; 262 + } 263 + switch (normalized) { 264 + case 'textAlignLeft': 265 + return 'left'; 266 + case 'textAlignCenter': 267 + return 'center'; 268 + case 'textAlignRight': 269 + return 'right'; 270 + case 'textAlignJustify': 271 + return 'justify'; 272 + default: 273 + return undefined; 274 + } 275 + } 276 + 277 + function useAuthorLabel(author: string | undefined, authorDid: string | undefined): string | undefined { 278 + const { handle } = useDidResolution(authorDid); 279 + if (!author) return undefined; 280 + if (handle) return `@${handle}`; 281 + if (authorDid) return formatDidForLabel(authorDid); 282 + return author; 283 + } 284 +