+32
-10
astro.config.mjs
+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
-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
+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
src/components/chapters/Bsky.astro
This is a binary file and will not be displayed.
+82
src/components/chapters/Bsky.tsx
+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
+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
-5
src/components/chapters/Leaflet.astro
+30
src/components/chapters/Leaflet.tsx
+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
+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
+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
+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
+