+17
-1
web/package.json
+17
-1
web/package.json
···
14
14
},
15
15
"dependencies": {
16
16
"@fontsource-variable/figtree": "^5.2.8",
17
+
"@fontsource-variable/jetbrains-mono": "^5.0.0",
18
+
"@fontsource/google-sans-code": "^5.0.0",
19
+
"@fontsource/monaspace-argon": "^5.0.0",
20
+
"@fontsource/monaspace-krypton": "^5.0.0",
21
+
"@fontsource/monaspace-neon": "^5.0.0",
22
+
"@fontsource/monaspace-radon": "^5.0.0",
23
+
"@fontsource/monaspace-xenon": "^5.0.0",
24
+
"@shikijs/rehype": "^3.0.0",
17
25
"@solidjs/meta": "^0.29.4",
18
26
"@solidjs/router": "^0.15.4",
19
27
"@tailwindcss/vite": "^4.1.18",
28
+
"@textcomplete/core": "^0.1.0",
29
+
"@textcomplete/textarea": "^0.1.0",
20
30
"clsx": "^2.1.1",
21
31
"motion": "^12.23.26",
22
32
"rehype-external-links": "^3.0.0",
···
24
34
"rehype-stringify": "^10.0.1",
25
35
"remark-parse": "^11.0.0",
26
36
"remark-rehype": "^11.1.2",
37
+
"shiki": "^3.0.0",
27
38
"solid-js": "^1.9.10",
28
39
"solid-motionone": "^1.0.4",
29
40
"tailwind-merge": "^3.4.0",
···
37
48
"@iconify-json/ri": "^1.2.7",
38
49
"@resvg/resvg-js": "^2.6.2",
39
50
"@solidjs/testing-library": "^0.8.10",
51
+
"@tailwindcss/forms": "^0.5.11",
40
52
"@testing-library/jest-dom": "^6.9.1",
41
53
"@testing-library/user-event": "^14.6.1",
42
54
"@types/node": "^24.10.1",
···
53
65
"vite-plugin-solid": "^2.11.10",
54
66
"vitest": "^4.0.16"
55
67
},
56
-
"pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.2.5" } }
68
+
"pnpm": {
69
+
"overrides": {
70
+
"vite": "npm:rolldown-vite@7.2.5"
71
+
}
72
+
}
57
73
}
+246
web/pnpm-lock.yaml
+246
web/pnpm-lock.yaml
···
14
14
'@fontsource-variable/figtree':
15
15
specifier: ^5.2.8
16
16
version: 5.2.10
17
+
'@fontsource-variable/jetbrains-mono':
18
+
specifier: ^5.0.0
19
+
version: 5.2.8
20
+
'@fontsource/google-sans-code':
21
+
specifier: ^5.0.0
22
+
version: 5.2.3
23
+
'@fontsource/monaspace-argon':
24
+
specifier: ^5.0.0
25
+
version: 5.2.5
26
+
'@fontsource/monaspace-krypton':
27
+
specifier: ^5.0.0
28
+
version: 5.2.5
29
+
'@fontsource/monaspace-neon':
30
+
specifier: ^5.0.0
31
+
version: 5.2.5
32
+
'@fontsource/monaspace-radon':
33
+
specifier: ^5.0.0
34
+
version: 5.2.5
35
+
'@fontsource/monaspace-xenon':
36
+
specifier: ^5.0.0
37
+
version: 5.2.5
38
+
'@shikijs/rehype':
39
+
specifier: ^3.0.0
40
+
version: 3.20.0
17
41
'@solidjs/meta':
18
42
specifier: ^0.29.4
19
43
version: 0.29.4(solid-js@1.9.10)
···
23
47
'@tailwindcss/vite':
24
48
specifier: ^4.1.18
25
49
version: 4.1.18(rolldown-vite@7.2.5(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0))
50
+
'@textcomplete/core':
51
+
specifier: ^0.1.0
52
+
version: 0.1.13
53
+
'@textcomplete/textarea':
54
+
specifier: ^0.1.0
55
+
version: 0.1.13(@textcomplete/core@0.1.13)
26
56
clsx:
27
57
specifier: ^2.1.1
28
58
version: 2.1.1
···
44
74
remark-rehype:
45
75
specifier: ^11.1.2
46
76
version: 11.1.2
77
+
shiki:
78
+
specifier: ^3.0.0
79
+
version: 3.20.0
47
80
solid-js:
48
81
specifier: ^1.9.10
49
82
version: 1.9.10
···
78
111
'@solidjs/testing-library':
79
112
specifier: ^0.8.10
80
113
version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.10))(solid-js@1.9.10)
114
+
'@tailwindcss/forms':
115
+
specifier: ^0.5.11
116
+
version: 0.5.11(tailwindcss@4.1.18)
81
117
'@testing-library/jest-dom':
82
118
specifier: ^6.9.1
83
119
version: 6.9.1
···
484
520
'@fontsource-variable/figtree@5.2.10':
485
521
resolution: {integrity: sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==}
486
522
523
+
'@fontsource-variable/jetbrains-mono@5.2.8':
524
+
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
525
+
526
+
'@fontsource/google-sans-code@5.2.3':
527
+
resolution: {integrity: sha512-bCbCXFWtGIVNEe8O/mE7YpIuVjc3E4CV6pToJ0IZc9XcXKfa74yntZa3UgyzBRgabnx5bm6ciQVQvWGnwi4Rqw==}
528
+
529
+
'@fontsource/monaspace-argon@5.2.5':
530
+
resolution: {integrity: sha512-EJ+jq1Smm3BB+8RK/gwB1uzjrSKdycZkKm8OZGCYvqJiTChKcGe7b2lmj0PmQbb/+lAEdCBIAcFLlPaWhtRANA==}
531
+
532
+
'@fontsource/monaspace-krypton@5.2.5':
533
+
resolution: {integrity: sha512-dOWSb0DtSMfjbptckxTme2FKNfRkFtmTuNRQ86sBki/CanR4ajSrE+GuJoCm9Kl3352CvafoRt2fDo8FU+Gz4A==}
534
+
535
+
'@fontsource/monaspace-neon@5.2.5':
536
+
resolution: {integrity: sha512-TjSSuHC37DroyhP5YCKRCAxdUb3In/uqHzqqW/8Ul+JWilz37d8lr8jFeapnbD3QdYXgiQoU2Rf/CmTdyl06DA==}
537
+
538
+
'@fontsource/monaspace-radon@5.2.5':
539
+
resolution: {integrity: sha512-mG6ltrFW5BYmcoRvVIKboCORZu8BeGg6h5zV5rnwEmFcjv8Bltua3KOj6aOxWbrR4PYnKyR17lNqSsjR8kx4bQ==}
540
+
541
+
'@fontsource/monaspace-xenon@5.2.5':
542
+
resolution: {integrity: sha512-k10RCF5nek9ee/rKSALaeYgOcJjjvzxxGkIlPwkSAoasjrsEeVKOSJ6ii1dMXOZwL9ZtW3p56Dxhgaw0Gtwvaw==}
543
+
487
544
'@humanfs/core@0.19.1':
488
545
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
489
546
engines: {node: '>=18.18.0'}
···
718
775
'@rolldown/pluginutils@1.0.0-beta.50':
719
776
resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==}
720
777
778
+
'@shikijs/core@3.20.0':
779
+
resolution: {integrity: sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==}
780
+
781
+
'@shikijs/engine-javascript@3.20.0':
782
+
resolution: {integrity: sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg==}
783
+
784
+
'@shikijs/engine-oniguruma@3.20.0':
785
+
resolution: {integrity: sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==}
786
+
787
+
'@shikijs/langs@3.20.0':
788
+
resolution: {integrity: sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==}
789
+
790
+
'@shikijs/rehype@3.20.0':
791
+
resolution: {integrity: sha512-/sqob3V/lJK0m2mZ64nkcWPN88im0D9atkI3S3PUBvtJZTHnJXVwZhHQFRDyObgEIa37IpHYHR3CuFtXB5bT2g==}
792
+
793
+
'@shikijs/themes@3.20.0':
794
+
resolution: {integrity: sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==}
795
+
796
+
'@shikijs/types@3.20.0':
797
+
resolution: {integrity: sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==}
798
+
799
+
'@shikijs/vscode-textmate@10.0.2':
800
+
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
801
+
721
802
'@shuding/opentype.js@1.4.0-beta.0':
722
803
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
723
804
engines: {node: '>= 8.0.0'}
···
765
846
766
847
'@standard-schema/spec@1.1.0':
767
848
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
849
+
850
+
'@tailwindcss/forms@0.5.11':
851
+
resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==}
852
+
peerDependencies:
853
+
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
768
854
769
855
'@tailwindcss/node@4.1.18':
770
856
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
···
870
956
peerDependencies:
871
957
'@testing-library/dom': '>=7.21.4'
872
958
959
+
'@textcomplete/core@0.1.13':
960
+
resolution: {integrity: sha512-C4S+ihQU5HsKQ/TbsmS0e7hfPZtLZbEXj5NDUgRnhu/1Nezpu892bjNZGeErZm+R8iyDIT6wDu6EgIhng4M8eQ==}
961
+
962
+
'@textcomplete/textarea@0.1.13':
963
+
resolution: {integrity: sha512-GNathnXpV361YuZrBVXvVqFYZ5NQZsjGC7Bt2sCUA/RTWlIgxHxC0ruDChYyRDx4siQZiZZOO5pWz+z1x8pZFQ==}
964
+
peerDependencies:
965
+
'@textcomplete/core': ^0.1.12
966
+
967
+
'@textcomplete/utils@0.1.13':
968
+
resolution: {integrity: sha512-5UW9Ee0WEX1s9K8MFffo5sfUjYm3YVhtqRhAor/ih7p0tnnpaMB7AwMRDKwhSIQL6O+g1fmEkxCeO8WqjPzjUA==}
969
+
873
970
'@tybys/wasm-util@0.10.1':
874
971
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
875
972
···
1310
1407
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1311
1408
engines: {node: '>=0.10.0'}
1312
1409
1410
+
eventemitter3@5.0.1:
1411
+
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
1412
+
1313
1413
expect-type@1.3.0:
1314
1414
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
1315
1415
engines: {node: '>=12.0.0'}
···
1414
1514
hast-util-to-html@9.0.5:
1415
1515
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
1416
1516
1517
+
hast-util-to-string@3.0.1:
1518
+
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
1519
+
1417
1520
hast-util-whitespace@3.0.0:
1418
1521
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
1419
1522
···
1735
1838
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
1736
1839
engines: {node: '>=4'}
1737
1840
1841
+
mini-svg-data-uri@1.4.4:
1842
+
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
1843
+
hasBin: true
1844
+
1738
1845
minimatch@3.1.2:
1739
1846
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
1740
1847
···
1782
1889
obug@2.1.1:
1783
1890
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
1784
1891
1892
+
oniguruma-parser@0.12.1:
1893
+
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
1894
+
1895
+
oniguruma-to-es@4.3.4:
1896
+
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
1897
+
1785
1898
optionator@0.9.4:
1786
1899
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
1787
1900
engines: {node: '>= 0.8.0'}
···
1868
1981
redent@3.0.0:
1869
1982
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
1870
1983
engines: {node: '>=8'}
1984
+
1985
+
regex-recursion@6.0.2:
1986
+
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
1987
+
1988
+
regex-utilities@2.3.0:
1989
+
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
1990
+
1991
+
regex@6.1.0:
1992
+
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
1871
1993
1872
1994
rehype-external-links@3.0.0:
1873
1995
resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==}
···
1975
2097
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
1976
2098
engines: {node: '>=8'}
1977
2099
2100
+
shiki@3.20.0:
2101
+
resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==}
2102
+
1978
2103
siginfo@2.0.0:
1979
2104
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
1980
2105
···
2037
2162
tapable@2.3.0:
2038
2163
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
2039
2164
engines: {node: '>=6'}
2165
+
2166
+
textarea-caret@3.1.0:
2167
+
resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==}
2040
2168
2041
2169
tiny-inflate@1.0.3:
2042
2170
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
···
2109
2237
2110
2238
ufo@1.6.1:
2111
2239
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
2240
+
2241
+
undate@0.3.0:
2242
+
resolution: {integrity: sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q==}
2112
2243
2113
2244
undici-types@7.16.0:
2114
2245
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
···
2580
2711
2581
2712
'@fontsource-variable/figtree@5.2.10': {}
2582
2713
2714
+
'@fontsource-variable/jetbrains-mono@5.2.8': {}
2715
+
2716
+
'@fontsource/google-sans-code@5.2.3': {}
2717
+
2718
+
'@fontsource/monaspace-argon@5.2.5': {}
2719
+
2720
+
'@fontsource/monaspace-krypton@5.2.5': {}
2721
+
2722
+
'@fontsource/monaspace-neon@5.2.5': {}
2723
+
2724
+
'@fontsource/monaspace-radon@5.2.5': {}
2725
+
2726
+
'@fontsource/monaspace-xenon@5.2.5': {}
2727
+
2583
2728
'@humanfs/core@0.19.1': {}
2584
2729
2585
2730
'@humanfs/node@0.16.7':
···
2776
2921
2777
2922
'@rolldown/pluginutils@1.0.0-beta.50': {}
2778
2923
2924
+
'@shikijs/core@3.20.0':
2925
+
dependencies:
2926
+
'@shikijs/types': 3.20.0
2927
+
'@shikijs/vscode-textmate': 10.0.2
2928
+
'@types/hast': 3.0.4
2929
+
hast-util-to-html: 9.0.5
2930
+
2931
+
'@shikijs/engine-javascript@3.20.0':
2932
+
dependencies:
2933
+
'@shikijs/types': 3.20.0
2934
+
'@shikijs/vscode-textmate': 10.0.2
2935
+
oniguruma-to-es: 4.3.4
2936
+
2937
+
'@shikijs/engine-oniguruma@3.20.0':
2938
+
dependencies:
2939
+
'@shikijs/types': 3.20.0
2940
+
'@shikijs/vscode-textmate': 10.0.2
2941
+
2942
+
'@shikijs/langs@3.20.0':
2943
+
dependencies:
2944
+
'@shikijs/types': 3.20.0
2945
+
2946
+
'@shikijs/rehype@3.20.0':
2947
+
dependencies:
2948
+
'@shikijs/types': 3.20.0
2949
+
'@types/hast': 3.0.4
2950
+
hast-util-to-string: 3.0.1
2951
+
shiki: 3.20.0
2952
+
unified: 11.0.5
2953
+
unist-util-visit: 5.0.0
2954
+
2955
+
'@shikijs/themes@3.20.0':
2956
+
dependencies:
2957
+
'@shikijs/types': 3.20.0
2958
+
2959
+
'@shikijs/types@3.20.0':
2960
+
dependencies:
2961
+
'@shikijs/vscode-textmate': 10.0.2
2962
+
'@types/hast': 3.0.4
2963
+
2964
+
'@shikijs/vscode-textmate@10.0.2': {}
2965
+
2779
2966
'@shuding/opentype.js@1.4.0-beta.0':
2780
2967
dependencies:
2781
2968
fflate: 0.7.4
···
2815
3002
'@solidjs/router': 0.15.4(solid-js@1.9.10)
2816
3003
2817
3004
'@standard-schema/spec@1.1.0': {}
3005
+
3006
+
'@tailwindcss/forms@0.5.11(tailwindcss@4.1.18)':
3007
+
dependencies:
3008
+
mini-svg-data-uri: 1.4.4
3009
+
tailwindcss: 4.1.18
2818
3010
2819
3011
'@tailwindcss/node@4.1.18':
2820
3012
dependencies:
···
2908
3100
dependencies:
2909
3101
'@testing-library/dom': 10.4.1
2910
3102
3103
+
'@textcomplete/core@0.1.13':
3104
+
dependencies:
3105
+
eventemitter3: 5.0.1
3106
+
3107
+
'@textcomplete/textarea@0.1.13(@textcomplete/core@0.1.13)':
3108
+
dependencies:
3109
+
'@textcomplete/core': 0.1.13
3110
+
'@textcomplete/utils': 0.1.13
3111
+
textarea-caret: 3.1.0
3112
+
undate: 0.3.0
3113
+
3114
+
'@textcomplete/utils@0.1.13': {}
3115
+
2911
3116
'@tybys/wasm-util@0.10.1':
2912
3117
dependencies:
2913
3118
tslib: 2.8.1
···
3413
3618
3414
3619
esutils@2.0.3: {}
3415
3620
3621
+
eventemitter3@5.0.1: {}
3622
+
3416
3623
expect-type@1.3.0: {}
3417
3624
3418
3625
exsolve@1.0.8: {}
···
3500
3707
stringify-entities: 4.0.4
3501
3708
zwitch: 2.0.4
3502
3709
3710
+
hast-util-to-string@3.0.1:
3711
+
dependencies:
3712
+
'@types/hast': 3.0.4
3713
+
3503
3714
hast-util-whitespace@3.0.0:
3504
3715
dependencies:
3505
3716
'@types/hast': 3.0.4
···
3882
4093
3883
4094
min-indent@1.0.1: {}
3884
4095
4096
+
mini-svg-data-uri@1.4.4: {}
4097
+
3885
4098
minimatch@3.1.2:
3886
4099
dependencies:
3887
4100
brace-expansion: 1.1.12
···
3918
4131
3919
4132
obug@2.1.1: {}
3920
4133
4134
+
oniguruma-parser@0.12.1: {}
4135
+
4136
+
oniguruma-to-es@4.3.4:
4137
+
dependencies:
4138
+
oniguruma-parser: 0.12.1
4139
+
regex: 6.1.0
4140
+
regex-recursion: 6.0.2
4141
+
3921
4142
optionator@0.9.4:
3922
4143
dependencies:
3923
4144
deep-is: 0.1.4
···
4006
4227
dependencies:
4007
4228
indent-string: 4.0.0
4008
4229
strip-indent: 3.0.0
4230
+
4231
+
regex-recursion@6.0.2:
4232
+
dependencies:
4233
+
regex-utilities: 2.3.0
4234
+
4235
+
regex-utilities@2.3.0: {}
4236
+
4237
+
regex@6.1.0:
4238
+
dependencies:
4239
+
regex-utilities: 2.3.0
4009
4240
4010
4241
rehype-external-links@3.0.0:
4011
4242
dependencies:
···
4119
4350
4120
4351
shebang-regex@3.0.0: {}
4121
4352
4353
+
shiki@3.20.0:
4354
+
dependencies:
4355
+
'@shikijs/core': 3.20.0
4356
+
'@shikijs/engine-javascript': 3.20.0
4357
+
'@shikijs/engine-oniguruma': 3.20.0
4358
+
'@shikijs/langs': 3.20.0
4359
+
'@shikijs/themes': 3.20.0
4360
+
'@shikijs/types': 3.20.0
4361
+
'@shikijs/vscode-textmate': 10.0.2
4362
+
'@types/hast': 3.0.4
4363
+
4122
4364
siginfo@2.0.0: {}
4123
4365
4124
4366
solid-js@1.9.10:
···
4183
4425
4184
4426
tapable@2.3.0: {}
4185
4427
4428
+
textarea-caret@3.1.0: {}
4429
+
4186
4430
tiny-inflate@1.0.3: {}
4187
4431
4188
4432
tinybench@2.9.0: {}
···
4245
4489
typescript@5.9.3: {}
4246
4490
4247
4491
ufo@1.6.1: {}
4492
+
4493
+
undate@0.3.0: {}
4248
4494
4249
4495
undici-types@7.16.0: {}
4250
4496
+2
web/src/App.tsx
+2
web/src/App.tsx
···
16
16
import Library from "$pages/Library";
17
17
import Login from "$pages/Login";
18
18
import LoginSuccess from "$pages/LoginSuccess";
19
+
import NoteEdit from "$pages/NoteEdit";
19
20
import NoteNew from "$pages/NoteNew";
20
21
import Notes from "$pages/Notes";
21
22
import NoteView from "$pages/NoteView";
···
97
98
<Route path="/decks" component={Home} />
98
99
<Route path="/decks/new" component={DeckNew} />
99
100
<Route path="/notes/new" component={NoteNew} />
101
+
<Route path="/notes/edit/:id" component={NoteEdit} />
100
102
<Route path="/notes/:id" component={NoteView} />
101
103
<Route path="/notes" component={Notes} />
102
104
<Route path="/decks/:id" component={DeckView} />
+261
-71
web/src/components/NoteEditor.tsx
+261
-71
web/src/components/NoteEditor.tsx
···
1
1
/* eslint-disable solid/no-innerhtml */
2
+
import { EditorToolbar } from "$components/notes/EditorToolbar";
2
3
import { api } from "$lib/api";
4
+
import type { Note } from "$lib/model";
3
5
import { toast } from "$lib/toast";
4
6
import { Button } from "$ui/Button";
7
+
import rehypeShiki from "@shikijs/rehype";
8
+
import { Textcomplete } from "@textcomplete/core";
9
+
import { TextareaEditor } from "@textcomplete/textarea";
5
10
import rehypeExternalLinks from "rehype-external-links";
6
-
import rehypeSanitize from "rehype-sanitize";
7
11
import rehypeStringify from "rehype-stringify";
8
12
import remarkParse from "remark-parse";
9
13
import remarkRehype from "remark-rehype";
10
-
import { createEffect, createSignal, Show } from "solid-js";
14
+
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
11
15
import { unified } from "unified";
12
16
17
+
export type EditorFont = "neon" | "argon" | "krypton" | "radon" | "xenon" | "jetbrains" | "google";
18
+
13
19
type NoteEditorProps = { noteId?: string; initialTitle?: string; initialContent?: string };
14
20
21
+
type EditorTab = "write" | "preview";
22
+
23
+
const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use(
24
+
rehypeExternalLinks,
25
+
{ target: "_blank", rel: ["nofollow"] },
26
+
).use(rehypeStringify);
27
+
15
28
export function NoteEditor(props: NoteEditorProps) {
16
29
const [title, setTitle] = createSignal(props.initialTitle || "");
17
30
const [content, setContent] = createSignal(props.initialContent || "");
···
19
32
const [tags, setTags] = createSignal("");
20
33
const [visibilityType, setVisibilityType] = createSignal<string>("Private");
21
34
const [sharedWith, setSharedWith] = createSignal("");
35
+
const [showLineNumbers, setShowLineNumbers] = createSignal(true);
36
+
const [editorFont, setEditorFont] = createSignal<EditorFont>("jetbrains");
37
+
const [activeTab, setActiveTab] = createSignal<EditorTab>("write");
22
38
23
-
const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeExternalLinks, {
24
-
target: "_blank",
25
-
rel: ["nofollow"],
26
-
}).use(rehypeStringify);
39
+
let textareaRef: HTMLTextAreaElement | undefined;
40
+
let textcomplete: Textcomplete | undefined;
41
+
42
+
const [allNotes] = createResource(async (): Promise<Note[]> => {
43
+
const res = await api.getNotes();
44
+
if (!res.ok) return [];
45
+
return res.json();
46
+
});
47
+
48
+
const updatePreviewContent = async () => {
49
+
const file = await processor.process(content());
50
+
setPreview(String(file));
51
+
};
27
52
28
53
createEffect(() => {
29
-
processor.process(content()).then((file) => setPreview(String(file))).catch((e) => console.error(e));
54
+
updatePreviewContent().catch(e => console.error(`Preview error: ${e instanceof Error ? e.message : e}`));
55
+
});
56
+
57
+
onMount(() => {
58
+
if (!textareaRef) return;
59
+
60
+
const editor = new TextareaEditor(textareaRef);
61
+
textcomplete = new Textcomplete(editor, [{
62
+
match: /\[\[([^\]]*)/,
63
+
search: (term: string, callback: (results: string[]) => void) => {
64
+
const notes = allNotes() ?? [];
65
+
const filtered = notes.filter((n) => n.title.toLowerCase().includes(term.toLowerCase())).slice(0, 10).map((n) =>
66
+
n.title
67
+
);
68
+
callback(filtered);
69
+
},
70
+
replace: (title: string) => `[[${title}]]`,
71
+
template: (title: string) => title,
72
+
}]);
73
+
});
74
+
75
+
onCleanup(() => {
76
+
textcomplete?.destroy();
77
+
});
78
+
79
+
const fontValue = createMemo(() => {
80
+
switch (editorFont()) {
81
+
case "neon":
82
+
return "Monaspace Neon";
83
+
case "argon":
84
+
return "Monaspace Argon";
85
+
case "krypton":
86
+
return "Monaspace Krypton";
87
+
case "radon":
88
+
return "Monaspace Radon";
89
+
case "xenon":
90
+
return "Monaspace Xenon";
91
+
case "google":
92
+
return "Google Sans Code";
93
+
default:
94
+
return "JetBrains Mono";
95
+
}
30
96
});
31
97
98
+
const insertAtCursor = (before: string, after: string = "") => {
99
+
if (!textareaRef) return;
100
+
const start = textareaRef.selectionStart;
101
+
const end = textareaRef.selectionEnd;
102
+
const text = content();
103
+
const selectedText = text.substring(start, end);
104
+
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
105
+
setContent(newText);
106
+
setTimeout(() => {
107
+
textareaRef!.focus();
108
+
textareaRef!.setSelectionRange(start + before.length, start + before.length + selectedText.length);
109
+
}, 0);
110
+
};
111
+
112
+
const handleBold = () => insertAtCursor("**", "**");
113
+
const handleItalic = () => insertAtCursor("*", "*");
114
+
const handleLink = () => insertAtCursor("[", "](url)");
115
+
const handleCode = () => insertAtCursor("`", "`");
116
+
const handleCodeBlock = () => insertAtCursor("```\n", "\n```");
117
+
const handleWikilink = () => insertAtCursor("[[", "]]");
118
+
const handleList = () => insertAtCursor("- ");
119
+
120
+
const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
121
+
const prefix = "#".repeat(level) + " ";
122
+
insertAtCursor(prefix);
123
+
};
124
+
125
+
const handleKeyDown = (e: KeyboardEvent) => {
126
+
if (e.metaKey || e.ctrlKey) {
127
+
switch (e.key) {
128
+
case "b":
129
+
e.preventDefault();
130
+
handleBold();
131
+
break;
132
+
case "i":
133
+
e.preventDefault();
134
+
handleItalic();
135
+
break;
136
+
case "k":
137
+
e.preventDefault();
138
+
handleLink();
139
+
break;
140
+
}
141
+
}
142
+
};
143
+
32
144
const handleSubmit = async (e: Event) => {
33
145
e.preventDefault();
34
146
try {
35
147
let visibility;
36
148
if (visibilityType() === "SharedWith") {
37
-
visibility = { type: "SharedWith", content: sharedWith().split(",").map(s => s.trim()).filter(s => s) };
149
+
visibility = { type: "SharedWith", content: sharedWith().split(",").map((s) => s.trim()).filter((s) => s) };
38
150
} else {
39
151
visibility = { type: visibilityType() };
40
152
}
···
42
154
const payload = {
43
155
title: title(),
44
156
body: content(),
45
-
tags: tags().split(",").map(t => t.trim()).filter(t => t),
157
+
tags: tags().split(",").map((t) => t.trim()).filter((t) => t),
46
158
visibility,
47
159
};
48
160
49
-
const res = await api.post("/notes", payload);
161
+
const res = props.noteId ? await api.updateNote(props.noteId, payload) : await api.post("/notes", payload);
162
+
50
163
if (res.ok) {
51
164
toast.success("Note saved!");
52
165
if (!props.noteId) {
···
65
178
}
66
179
};
67
180
181
+
const lineNumbers = () => Array.from({ length: content().split("\n").length }, (_, i) => i + 1);
182
+
68
183
return (
69
-
<div class="max-w-4xl mx-auto p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
70
-
<div class="space-y-4">
71
-
<h1 class="text-2xl font-bold text-white">Note Editor</h1>
184
+
<div class="max-w-5xl mx-auto p-6">
185
+
<div class="flex items-center justify-between mb-6">
186
+
<h1 class="text-2xl font-bold text-white">{props.noteId ? "Edit Note" : "New Note"}</h1>
187
+
188
+
<div class="flex items-center gap-4">
189
+
<label class="flex items-center gap-2 text-sm text-slate-400">
190
+
<input
191
+
type="checkbox"
192
+
checked={showLineNumbers()}
193
+
onChange={(e) => setShowLineNumbers(e.target.checked)}
194
+
class="rounded bg-slate-700 border-slate-600" />
195
+
Line numbers
196
+
</label>
197
+
<select
198
+
value={editorFont()}
199
+
onChange={(e) => setEditorFont(e.target.value as EditorFont)}
200
+
class="bg-slate-800 border-slate-700 text-white text-sm rounded px-2 py-1">
201
+
<option value="jetbrains">JetBrains Mono</option>
202
+
<option value="neon">Monaspace Neon</option>
203
+
<option value="argon">Monaspace Argon</option>
204
+
<option value="krypton">Monaspace Krypton</option>
205
+
<option value="radon">Monaspace Radon</option>
206
+
<option value="xenon">Monaspace Xenon</option>
207
+
<option value="google">Google Sans Code</option>
208
+
</select>
209
+
</div>
210
+
</div>
72
211
73
-
<form onSubmit={handleSubmit} class="space-y-4">
212
+
<form onSubmit={handleSubmit} class="space-y-4">
213
+
<div>
214
+
<label class="block text-sm font-medium text-slate-400 mb-1">Title</label>
215
+
<input
216
+
type="text"
217
+
value={title()}
218
+
onInput={(e) => setTitle(e.target.value)}
219
+
class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
220
+
placeholder="Note Title"
221
+
required />
222
+
</div>
223
+
224
+
<div>
225
+
<div class="flex border-b border-slate-700 mb-0">
226
+
<button
227
+
type="button"
228
+
onClick={() => setActiveTab("write")}
229
+
class={`px-4 py-2 text-sm font-medium transition-colors ${
230
+
activeTab() === "write"
231
+
? "text-white border-b-2 border-blue-500 -mb-px"
232
+
: "text-slate-400 hover:text-white"
233
+
}`}>
234
+
<span class="i-ri-edit-line mr-1" />
235
+
Write
236
+
</button>
237
+
<button
238
+
type="button"
239
+
onClick={() => setActiveTab("preview")}
240
+
class={`px-4 py-2 text-sm font-medium transition-colors ${
241
+
activeTab() === "preview"
242
+
? "text-white border-b-2 border-blue-500 -mb-px"
243
+
: "text-slate-400 hover:text-white"
244
+
}`}>
245
+
<span class="i-ri-eye-line mr-1" />
246
+
Preview
247
+
</button>
248
+
</div>
249
+
250
+
<Show when={activeTab() === "write"}>
251
+
<div class="border border-slate-700 border-t-0 rounded-b-lg overflow-hidden">
252
+
<EditorToolbar
253
+
onBold={handleBold}
254
+
onItalic={handleItalic}
255
+
onHeading={handleHeading}
256
+
onLink={handleLink}
257
+
onCode={handleCode}
258
+
onCodeBlock={handleCodeBlock}
259
+
onWikilink={handleWikilink}
260
+
onList={handleList} />
261
+
<div class="flex">
262
+
<Show when={showLineNumbers()}>
263
+
<div
264
+
class={`bg-slate-900 border-r border-slate-700 text-slate-600 text-right px-2 py-3 select-none text-sm leading-relaxed`}>
265
+
<For each={lineNumbers()}>{(num) => <div>{num}</div>}</For>
266
+
</div>
267
+
</Show>
268
+
<textarea
269
+
ref={textareaRef}
270
+
value={content()}
271
+
onInput={(e) => setContent(e.target.value)}
272
+
style={{ "font-family": fontValue() }}
273
+
onKeyDown={handleKeyDown}
274
+
class={`flex-1 bg-slate-800 text-white p-3 text-sm leading-relaxed resize-none focus:outline-none min-h-[400px]`}
275
+
placeholder="# Heading
276
+
277
+
Write your thoughts...
278
+
279
+
Link to other notes with [[Title]]" />
280
+
</div>
281
+
</div>
282
+
</Show>
283
+
284
+
<Show when={activeTab() === "preview"}>
285
+
<div
286
+
class="prose prose-invert max-w-none bg-slate-800/50 p-6 rounded-b-lg border border-slate-700 border-t-0 min-h-[460px] overflow-auto"
287
+
innerHTML={preview()} />
288
+
</Show>
289
+
</div>
290
+
291
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
74
292
<div>
75
-
<label class="block text-sm font-medium text-gray-400 mb-1">Title</label>
293
+
<label class="block text-sm font-medium text-slate-400 mb-1">Tags</label>
76
294
<input
77
295
type="text"
78
-
value={title()}
79
-
onInput={e => setTitle(e.target.value)}
80
-
class="w-full bg-gray-800 border-gray-700 text-white rounded p-2"
81
-
placeholder="Note Title"
82
-
required />
296
+
value={tags()}
297
+
onInput={(e) => setTags(e.target.value)}
298
+
class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2"
299
+
placeholder="rust, learning, ..." />
83
300
</div>
84
-
85
301
<div>
86
-
<label class="block text-sm font-medium text-gray-400 mb-1">Content (Markdown + [[WikiLinks]])</label>
87
-
<textarea
88
-
value={content()}
89
-
onInput={e => setContent(e.target.value)}
90
-
class="w-full h-96 bg-gray-800 border-gray-700 text-white rounded p-2 font-mono text-sm leading-relaxed"
91
-
placeholder="# Heading Write your thoughts... Link to other notes with [[Title]]" />
302
+
<label class="block text-sm font-medium text-slate-400 mb-1">Visibility</label>
303
+
<select
304
+
value={visibilityType()}
305
+
onChange={(e) => setVisibilityType(e.target.value)}
306
+
class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2">
307
+
<option value="Private">Private</option>
308
+
<option value="Unlisted">Unlisted</option>
309
+
<option value="Public">Public</option>
310
+
<option value="SharedWith">Shared With...</option>
311
+
</select>
92
312
</div>
313
+
</div>
93
314
94
-
<div class="grid grid-cols-2 gap-4">
95
-
<div>
96
-
<label class="block text-sm font-medium text-gray-400 mb-1">Tags</label>
97
-
<input
98
-
type="text"
99
-
value={tags()}
100
-
onInput={e => setTags(e.target.value)}
101
-
class="w-full bg-gray-800 border-gray-700 text-white rounded p-2"
102
-
placeholder="rust, learning, ..." />
103
-
</div>
104
-
<div>
105
-
<label class="block text-sm font-medium text-gray-400 mb-1">Visibility</label>
106
-
<select
107
-
value={visibilityType()}
108
-
onChange={e => setVisibilityType(e.target.value)}
109
-
class="w-full bg-gray-800 border-gray-700 text-white rounded p-2">
110
-
<option value="Private">Private</option>
111
-
<option value="Unlisted">Unlisted</option>
112
-
<option value="Public">Public</option>
113
-
<option value="SharedWith">Shared With...</option>
114
-
</select>
115
-
</div>
315
+
<Show when={visibilityType() === "SharedWith"}>
316
+
<div>
317
+
<label class="block text-sm font-medium text-slate-400 mb-1">Share with DIDs (comma separated)</label>
318
+
<input
319
+
type="text"
320
+
value={sharedWith()}
321
+
onInput={(e) => setSharedWith(e.target.value)}
322
+
class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2"
323
+
placeholder="did:plc:..., did:plc:..." />
116
324
</div>
325
+
</Show>
117
326
118
-
<Show when={visibilityType() === "SharedWith"}>
119
-
<div>
120
-
<label class="block text-sm font-medium text-gray-400 mb-1">Share with DIDs (comma separated)</label>
121
-
<input
122
-
type="text"
123
-
value={sharedWith()}
124
-
onInput={(e) => setSharedWith(e.target.value)}
125
-
class="w-full bg-gray-800 border-gray-700 text-white rounded p-2"
126
-
placeholder="did:plc:..., did:plc:..." />
127
-
</div>
128
-
</Show>
129
-
327
+
<div class="flex justify-end">
130
328
<Button type="submit">Save Note</Button>
131
-
</form>
132
-
</div>
133
-
134
-
<div class="space-y-4">
135
-
<h2 class="text-xl font-semibold text-gray-300">Preview</h2>
136
-
{}
137
-
<div
138
-
class="prose prose-invert max-w-none bg-gray-900/50 p-6 rounded border border-gray-800 min-h-[500px]"
139
-
innerHTML={preview()} />
140
-
</div>
329
+
</div>
330
+
</form>
141
331
</div>
142
332
);
143
333
}
+3
-2
web/src/components/layout/Header.tsx
+3
-2
web/src/components/layout/Header.tsx
···
16
16
<A href="/" class="text-xl font-bold text-white tracking-tight">Malfestio</A>
17
17
<Show when={authStore.isAuthenticated()}>
18
18
<nav class="hidden md:flex items-center gap-4 text-sm font-medium text-gray-400">
19
-
<A href="/home" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A>
20
-
<A href="/study" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A>
19
+
<A href="/decks" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A>
20
+
<A href="/notes" activeClass="text-blue-500" class="hover:text-white transition-colors">Notes</A>
21
+
<A href="/review" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A>
21
22
<A href="/discovery" activeClass="text-blue-500" class="hover:text-white transition-colors">Discovery</A>
22
23
<A href="/library" activeClass="text-blue-500" class="hover:text-white transition-colors">Library</A>
23
24
<A href="/feed" activeClass="text-blue-500" class="hover:text-white transition-colors">Feed</A>
+40
web/src/components/notes/BacklinksPanel.tsx
+40
web/src/components/notes/BacklinksPanel.tsx
···
1
+
import type { Note } from "$lib/model";
2
+
import { A } from "@solidjs/router";
3
+
import type { Component } from "solid-js";
4
+
import { For, Show } from "solid-js";
5
+
6
+
type BacklinksPanelProps = { backlinks: Note[] };
7
+
8
+
/**
9
+
* Panel showing notes that link TO the current note (incoming references)
10
+
*/
11
+
export const BacklinksPanel: Component<BacklinksPanelProps> = (props) => {
12
+
return (
13
+
<div class="space-y-2">
14
+
<h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wide">
15
+
Backlinks
16
+
<Show when={props.backlinks.length > 0}>
17
+
<span class="ml-1 text-slate-500">({props.backlinks.length})</span>
18
+
</Show>
19
+
</h3>
20
+
<Show when={props.backlinks.length > 0} fallback={<p class="text-sm text-slate-500 italic">No incoming links</p>}>
21
+
<ul class="space-y-1">
22
+
<For each={props.backlinks}>
23
+
{(note) => (
24
+
<li>
25
+
<A
26
+
href={`/notes/${note.id}`}
27
+
class="text-sm text-slate-300 hover:text-blue-400 flex items-center gap-1 transition-colors">
28
+
<span class="i-ri-arrow-left-up-line text-emerald-500" />
29
+
{note.title || "Untitled"}
30
+
</A>
31
+
</li>
32
+
)}
33
+
</For>
34
+
</ul>
35
+
</Show>
36
+
</div>
37
+
);
38
+
};
39
+
40
+
export default BacklinksPanel;
+93
web/src/components/notes/EditorToolbar.tsx
+93
web/src/components/notes/EditorToolbar.tsx
···
1
+
import { type Component, For } from "solid-js";
2
+
3
+
type ToolbarButton = { id: string; icon: string; label: string; shortcut?: string; action: () => void };
4
+
5
+
type EditorToolbarProps = {
6
+
onBold: () => void;
7
+
onItalic: () => void;
8
+
onHeading: (level: 1 | 2 | 3 | 4 | 5 | 6) => void;
9
+
onLink: () => void;
10
+
onCode: () => void;
11
+
onCodeBlock: () => void;
12
+
onWikilink: () => void;
13
+
onList: () => void;
14
+
class?: string;
15
+
};
16
+
17
+
const ToolbarButtonComponent: Component<{ btn: ToolbarButton }> = (props) => (
18
+
<button
19
+
type="button"
20
+
onClick={() => props.btn.action()}
21
+
title={props.btn.shortcut ? `${props.btn.label} (${props.btn.shortcut})` : props.btn.label}
22
+
class="p-2 rounded hover:bg-slate-700 text-slate-400 hover:text-white transition-colors">
23
+
<span class={`${props.btn.icon} text-lg`} />
24
+
</button>
25
+
);
26
+
27
+
export const EditorToolbar: Component<EditorToolbarProps> = (props) => {
28
+
const buttons: ToolbarButton[] = [
29
+
{
30
+
id: "bold",
31
+
icon: "i-ri-bold",
32
+
label: "Bold",
33
+
shortcut: "⌘B",
34
+
action: () => props.onBold(),
35
+
},
36
+
{ id: "italic", icon: "i-ri-italic", label: "Italic", shortcut: "⌘I", action: () => props.onItalic() },
37
+
{
38
+
id: "h1",
39
+
icon: "i-ri-h-1",
40
+
label: "Heading 1",
41
+
action: () => props.onHeading(1),
42
+
},
43
+
{ id: "h2", icon: "i-ri-h-2", label: "Heading 2", action: () => props.onHeading(2) },
44
+
{ id: "h3", icon: "i-ri-h-3", label: "Heading 3", action: () => props.onHeading(3) },
45
+
{ id: "h4", icon: "i-ri-h-4", label: "Heading 4", action: () => props.onHeading(4) },
46
+
{
47
+
id: "h5",
48
+
icon: "i-ri-h-5",
49
+
label: "Heading 5",
50
+
action: () => props.onHeading(5),
51
+
},
52
+
{ id: "h6", icon: "i-ri-h-6", label: "Heading 6", action: () => props.onHeading(6) },
53
+
{
54
+
id: "link",
55
+
icon: "i-ri-link",
56
+
label: "Link",
57
+
shortcut: "⌘K",
58
+
action: () => props.onLink(),
59
+
},
60
+
{ id: "code", icon: "i-ri-code-line", label: "Inline Code", action: () => props.onCode() },
61
+
{ id: "codeblock", icon: "i-ri-code-box-line", label: "Code Block", action: () => props.onCodeBlock() },
62
+
{ id: "wikilink", icon: "i-ri-links-line", label: "Wikilink", action: () => props.onWikilink() },
63
+
{
64
+
id: "list",
65
+
icon: "i-ri-list-unordered",
66
+
label: "Bullet List",
67
+
action: () => props.onList(),
68
+
},
69
+
];
70
+
71
+
return (
72
+
<div class={`flex items-center gap-1 p-2 bg-slate-800 border-b border-slate-700 rounded-t-lg ${props.class ?? ""}`}>
73
+
<div class="flex items-center">
74
+
<ToolbarButtonComponent btn={buttons[0]} />
75
+
<ToolbarButtonComponent btn={buttons[1]} />
76
+
</div>
77
+
<div class="w-px h-6 bg-slate-600 mx-1" />
78
+
<div class="flex items-center">
79
+
<For each={buttons.slice(2, 8)}>{(btn) => <ToolbarButtonComponent btn={btn} />}</For>
80
+
</div>
81
+
<div class="w-px h-6 bg-slate-600 mx-1" />
82
+
<div class="flex items-center">
83
+
<For each={buttons.slice(8, 12)}>{(btn) => <ToolbarButtonComponent btn={btn} />}</For>
84
+
</div>
85
+
<div class="w-px h-6 bg-slate-600 mx-1" />
86
+
<div class="flex items-center">
87
+
<ToolbarButtonComponent btn={buttons[12]} />
88
+
</div>
89
+
</div>
90
+
);
91
+
};
92
+
93
+
export default EditorToolbar;
+43
web/src/components/notes/OutlinePanel.tsx
+43
web/src/components/notes/OutlinePanel.tsx
···
1
+
import type { Heading } from "$lib/wikilink";
2
+
import { A } from "@solidjs/router";
3
+
import type { Component } from "solid-js";
4
+
import { For, Show } from "solid-js";
5
+
6
+
type OutlinePanelProps = { headings: Heading[]; onHeadingClick?: (id: string) => void };
7
+
8
+
/**
9
+
* Table of contents panel showing document outline from markdown headings
10
+
*/
11
+
export const OutlinePanel: Component<OutlinePanelProps> = (props) => {
12
+
const handleClick = (id: string, e: MouseEvent) => {
13
+
e.preventDefault();
14
+
props.onHeadingClick?.(id);
15
+
const element = document.getElementById(id);
16
+
if (element) {
17
+
element.scrollIntoView({ behavior: "smooth", block: "start" });
18
+
}
19
+
};
20
+
21
+
return (
22
+
<div class="space-y-2">
23
+
<h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wide">Outline</h3>
24
+
<Show when={props.headings.length > 0} fallback={<p class="text-sm text-slate-500 italic">No headings found</p>}>
25
+
<nav class="space-y-1">
26
+
<For each={props.headings}>
27
+
{(heading) => (
28
+
<A
29
+
href={`#${heading.id}`}
30
+
onClick={(e) => handleClick(heading.id, e)}
31
+
class="block text-sm text-slate-300 hover:text-blue-400 transition-colors truncate"
32
+
style={{ "padding-left": `${(heading.level - 1) * 12}px` }}>
33
+
{heading.text}
34
+
</A>
35
+
)}
36
+
</For>
37
+
</nav>
38
+
</Show>
39
+
</div>
40
+
);
41
+
};
42
+
43
+
export default OutlinePanel;
+62
web/src/components/notes/WikilinksPanel.tsx
+62
web/src/components/notes/WikilinksPanel.tsx
···
1
+
import type { Note } from "$lib/model";
2
+
import type { WikiLink } from "$lib/wikilink";
3
+
import { A } from "@solidjs/router";
4
+
import type { Component } from "solid-js";
5
+
import { For, Show } from "solid-js";
6
+
7
+
type WikilinksPanelProps = { links: WikiLink[]; notes: Note[]; resolveNote: (title: string) => Note | null };
8
+
9
+
/**
10
+
* Panel showing outgoing wikilinks from the current note
11
+
*
12
+
* Displays links with status (resolved/unresolved)
13
+
*/
14
+
export const WikilinksPanel: Component<WikilinksPanelProps> = (props) => {
15
+
const uniqueTitles = () => {
16
+
const seen = new Set<string>();
17
+
return props.links.filter((link) => {
18
+
const normalized = link.title.toLowerCase();
19
+
if (seen.has(normalized)) return false;
20
+
seen.add(normalized);
21
+
return true;
22
+
});
23
+
};
24
+
25
+
return (
26
+
<div class="space-y-2">
27
+
<h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wide">Wikilinks</h3>
28
+
<Show when={props.links.length > 0} fallback={<p class="text-sm text-slate-500 italic">No outgoing links</p>}>
29
+
<ul class="space-y-1">
30
+
<For each={uniqueTitles()}>
31
+
{(link) => {
32
+
const resolved = () => props.resolveNote(link.title);
33
+
return (
34
+
<li class="text-sm">
35
+
<Show
36
+
when={resolved()}
37
+
fallback={
38
+
<span class="text-slate-500 flex items-center gap-1">
39
+
<span class="i-ri-link-unlink text-amber-500" />
40
+
<span class="line-through">{link.title}</span>
41
+
</span>
42
+
}>
43
+
{(note) => (
44
+
<A
45
+
href={`/notes/${note().id}`}
46
+
class="text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
47
+
<span class="i-ri-link" />
48
+
{link.alias || link.title}
49
+
</A>
50
+
)}
51
+
</Show>
52
+
</li>
53
+
);
54
+
}}
55
+
</For>
56
+
</ul>
57
+
</Show>
58
+
</div>
59
+
);
60
+
};
61
+
62
+
export default WikilinksPanel;
+64
web/src/components/notes/tests/BacklinksPanel.test.tsx
+64
web/src/components/notes/tests/BacklinksPanel.test.tsx
···
1
+
import type { Note } from "$lib/model";
2
+
import { cleanup, render, screen } from "@solidjs/testing-library";
3
+
import { JSX } from "solid-js";
4
+
import { afterEach, describe, expect, it, vi } from "vitest";
5
+
import { BacklinksPanel } from "../BacklinksPanel";
6
+
7
+
vi.mock(
8
+
"@solidjs/router",
9
+
() => ({ A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a> }),
10
+
);
11
+
12
+
describe("BacklinksPanel", () => {
13
+
afterEach(() => {
14
+
cleanup();
15
+
vi.clearAllMocks();
16
+
});
17
+
18
+
const mockBacklinks: Note[] = [{
19
+
id: "note-1",
20
+
owner_did: "did:plc:test",
21
+
title: "First Backlink",
22
+
body: "Content",
23
+
tags: [],
24
+
visibility: { type: "Private" },
25
+
created_at: "2026-01-01T00:00:00Z",
26
+
updated_at: "2026-01-01T00:00:00Z",
27
+
}, {
28
+
id: "note-2",
29
+
owner_did: "did:plc:test",
30
+
title: "Second Backlink",
31
+
body: "Content",
32
+
tags: [],
33
+
visibility: { type: "Private" },
34
+
created_at: "2026-01-01T00:00:00Z",
35
+
updated_at: "2026-01-01T00:00:00Z",
36
+
}];
37
+
38
+
it("renders heading title", () => {
39
+
render(() => <BacklinksPanel backlinks={[]} />);
40
+
expect(screen.getByText("Backlinks")).toBeInTheDocument();
41
+
});
42
+
43
+
it("shows empty state when no backlinks", () => {
44
+
render(() => <BacklinksPanel backlinks={[]} />);
45
+
expect(screen.getByText("No incoming links")).toBeInTheDocument();
46
+
});
47
+
48
+
it("renders all backlinks", () => {
49
+
render(() => <BacklinksPanel backlinks={mockBacklinks} />);
50
+
expect(screen.getByText("First Backlink")).toBeInTheDocument();
51
+
expect(screen.getByText("Second Backlink")).toBeInTheDocument();
52
+
});
53
+
54
+
it("displays backlink count", () => {
55
+
render(() => <BacklinksPanel backlinks={mockBacklinks} />);
56
+
expect(screen.getByText("(2)")).toBeInTheDocument();
57
+
});
58
+
59
+
it("renders backlinks as navigation links", () => {
60
+
render(() => <BacklinksPanel backlinks={mockBacklinks} />);
61
+
const firstLink = screen.getByRole("link", { name: /First Backlink/ });
62
+
expect(firstLink).toHaveAttribute("href", "/notes/note-1");
63
+
});
64
+
});
+81
web/src/components/notes/tests/EditorToolbar.test.tsx
+81
web/src/components/notes/tests/EditorToolbar.test.tsx
···
1
+
import { cleanup, render, screen } from "@solidjs/testing-library";
2
+
import { afterEach, describe, expect, it, vi } from "vitest";
3
+
import { EditorToolbar } from "../EditorToolbar";
4
+
5
+
describe("EditorToolbar", () => {
6
+
afterEach(() => {
7
+
cleanup();
8
+
vi.clearAllMocks();
9
+
});
10
+
11
+
const mockHandlers = {
12
+
onBold: vi.fn(),
13
+
onItalic: vi.fn(),
14
+
onHeading: vi.fn(),
15
+
onLink: vi.fn(),
16
+
onCode: vi.fn(),
17
+
onCodeBlock: vi.fn(),
18
+
onWikilink: vi.fn(),
19
+
onList: vi.fn(),
20
+
};
21
+
22
+
it("renders all toolbar buttons", () => {
23
+
render(() => <EditorToolbar {...mockHandlers} />);
24
+
25
+
expect(screen.getByTitle(/Bold/)).toBeInTheDocument();
26
+
expect(screen.getByTitle(/Italic/)).toBeInTheDocument();
27
+
expect(screen.getByTitle(/Heading 1/)).toBeInTheDocument();
28
+
expect(screen.getByTitle(/Heading 2/)).toBeInTheDocument();
29
+
expect(screen.getByTitle(/Heading 3/)).toBeInTheDocument();
30
+
expect(screen.getByTitle(/Link/)).toBeInTheDocument();
31
+
expect(screen.getByTitle(/Inline Code/)).toBeInTheDocument();
32
+
expect(screen.getByTitle(/Code Block/)).toBeInTheDocument();
33
+
expect(screen.getByTitle(/Wikilink/)).toBeInTheDocument();
34
+
expect(screen.getByTitle(/Bullet List/)).toBeInTheDocument();
35
+
});
36
+
37
+
it("calls onBold when bold button clicked", async () => {
38
+
render(() => <EditorToolbar {...mockHandlers} />);
39
+
40
+
const boldBtn = screen.getByTitle(/Bold/);
41
+
boldBtn.click();
42
+
43
+
expect(mockHandlers.onBold).toHaveBeenCalledTimes(1);
44
+
});
45
+
46
+
it("calls onItalic when italic button clicked", async () => {
47
+
render(() => <EditorToolbar {...mockHandlers} />);
48
+
49
+
const italicBtn = screen.getByTitle(/Italic/);
50
+
italicBtn.click();
51
+
52
+
expect(mockHandlers.onItalic).toHaveBeenCalledTimes(1);
53
+
});
54
+
55
+
it("calls onHeading with level when heading button clicked", async () => {
56
+
render(() => <EditorToolbar {...mockHandlers} />);
57
+
58
+
screen.getByTitle(/Heading 1/).click();
59
+
expect(mockHandlers.onHeading).toHaveBeenCalledWith(1);
60
+
61
+
screen.getByTitle(/Heading 2/).click();
62
+
expect(mockHandlers.onHeading).toHaveBeenCalledWith(2);
63
+
64
+
screen.getByTitle(/Heading 3/).click();
65
+
expect(mockHandlers.onHeading).toHaveBeenCalledWith(3);
66
+
});
67
+
68
+
it("calls onLink when link button clicked", async () => {
69
+
render(() => <EditorToolbar {...mockHandlers} />);
70
+
71
+
screen.getByTitle(/Link/).click();
72
+
expect(mockHandlers.onLink).toHaveBeenCalledTimes(1);
73
+
});
74
+
75
+
it("calls onWikilink when wikilink button clicked", async () => {
76
+
render(() => <EditorToolbar {...mockHandlers} />);
77
+
78
+
screen.getByTitle(/Wikilink/).click();
79
+
expect(mockHandlers.onWikilink).toHaveBeenCalledTimes(1);
80
+
});
81
+
});
+66
web/src/components/notes/tests/OutlinePanel.test.tsx
+66
web/src/components/notes/tests/OutlinePanel.test.tsx
···
1
+
import type { Heading } from "$lib/wikilink";
2
+
import { cleanup, render, screen } from "@solidjs/testing-library";
3
+
import { JSX } from "solid-js";
4
+
import { afterEach, describe, expect, it, vi } from "vitest";
5
+
import { OutlinePanel } from "../OutlinePanel";
6
+
7
+
vi.mock(
8
+
"@solidjs/router",
9
+
() => ({
10
+
A: (props: { href: string; children: JSX.Element; onClick?: (e: MouseEvent) => void }) => (
11
+
<a
12
+
href={props.href}
13
+
onClick={(e) => props.onClick?.(e)}>
14
+
{props.children}
15
+
</a>
16
+
),
17
+
}),
18
+
);
19
+
20
+
describe("OutlinePanel", () => {
21
+
afterEach(() => {
22
+
cleanup();
23
+
vi.clearAllMocks();
24
+
});
25
+
26
+
const mockHeadings: Heading[] = [
27
+
{ level: 1, text: "Introduction", id: "introduction" },
28
+
{ level: 2, text: "Background", id: "background" },
29
+
{ level: 2, text: "Methods", id: "methods" },
30
+
{ level: 3, text: "Sub Methods", id: "sub-methods" },
31
+
{ level: 1, text: "Conclusion", id: "conclusion" },
32
+
];
33
+
34
+
it("renders heading title", () => {
35
+
render(() => <OutlinePanel headings={[]} />);
36
+
expect(screen.getByText("Outline")).toBeInTheDocument();
37
+
});
38
+
39
+
it("shows empty state when no headings", () => {
40
+
render(() => <OutlinePanel headings={[]} />);
41
+
expect(screen.getByText("No headings found")).toBeInTheDocument();
42
+
});
43
+
44
+
it("renders all headings", () => {
45
+
render(() => <OutlinePanel headings={mockHeadings} />);
46
+
expect(screen.getByText("Introduction")).toBeInTheDocument();
47
+
expect(screen.getByText("Background")).toBeInTheDocument();
48
+
expect(screen.getByText("Methods")).toBeInTheDocument();
49
+
expect(screen.getByText("Sub Methods")).toBeInTheDocument();
50
+
expect(screen.getByText("Conclusion")).toBeInTheDocument();
51
+
});
52
+
53
+
it("renders headings as links", () => {
54
+
render(() => <OutlinePanel headings={mockHeadings} />);
55
+
const introLink = screen.getByRole("link", { name: "Introduction" });
56
+
expect(introLink).toHaveAttribute("href", "#introduction");
57
+
});
58
+
59
+
it("calls onHeadingClick when heading clicked", () => {
60
+
const onClick = vi.fn();
61
+
render(() => <OutlinePanel headings={mockHeadings} onHeadingClick={onClick} />);
62
+
const introLink = screen.getByRole("link", { name: "Introduction" });
63
+
introLink.click();
64
+
expect(onClick).toHaveBeenCalledWith("introduction");
65
+
});
66
+
});
+73
web/src/components/notes/tests/WikilinksPanel.test.tsx
+73
web/src/components/notes/tests/WikilinksPanel.test.tsx
···
1
+
import type { Note } from "$lib/model";
2
+
import type { WikiLink } from "$lib/wikilink";
3
+
import { cleanup, render, screen } from "@solidjs/testing-library";
4
+
import { JSX } from "solid-js";
5
+
import { afterEach, describe, expect, it, vi } from "vitest";
6
+
import { WikilinksPanel } from "../WikilinksPanel";
7
+
8
+
vi.mock(
9
+
"@solidjs/router",
10
+
() => ({ A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a> }),
11
+
);
12
+
13
+
describe("WikilinksPanel", () => {
14
+
afterEach(() => {
15
+
cleanup();
16
+
vi.clearAllMocks();
17
+
});
18
+
19
+
const mockNotes: Note[] = [{
20
+
id: "note-1",
21
+
owner_did: "did:plc:test",
22
+
title: "Existing Note",
23
+
body: "Content",
24
+
tags: [],
25
+
visibility: { type: "Private" },
26
+
created_at: "2026-01-01T00:00:00Z",
27
+
updated_at: "2026-01-01T00:00:00Z",
28
+
}];
29
+
30
+
const mockLinks: WikiLink[] = [{ raw: "[[Existing Note]]", title: "Existing Note", start: 0, end: 17 }, {
31
+
raw: "[[Missing Note]]",
32
+
title: "Missing Note",
33
+
start: 20,
34
+
end: 36,
35
+
}, { raw: "[[Aliased|Display]]", title: "Aliased", alias: "Display", start: 40, end: 59 }];
36
+
37
+
const resolveNote = (title: string) => mockNotes.find((n) => n.title === title) ?? null;
38
+
39
+
it("renders heading title", () => {
40
+
render(() => <WikilinksPanel links={[]} notes={[]} resolveNote={() => null} />);
41
+
expect(screen.getByText("Wikilinks")).toBeInTheDocument();
42
+
});
43
+
44
+
it("shows empty state when no links", () => {
45
+
render(() => <WikilinksPanel links={[]} notes={[]} resolveNote={() => null} />);
46
+
expect(screen.getByText("No outgoing links")).toBeInTheDocument();
47
+
});
48
+
49
+
it("renders resolved links as navigation links", () => {
50
+
render(() => <WikilinksPanel links={mockLinks} notes={mockNotes} resolveNote={resolveNote} />);
51
+
const existingLink = screen.getByRole("link", { name: /Existing Note/ });
52
+
expect(existingLink).toHaveAttribute("href", "/notes/note-1");
53
+
});
54
+
55
+
it("renders unresolved links with strikethrough", () => {
56
+
render(() => <WikilinksPanel links={mockLinks} notes={mockNotes} resolveNote={resolveNote} />);
57
+
const missingLink = screen.getByText("Missing Note");
58
+
expect(missingLink).toHaveClass("line-through");
59
+
});
60
+
61
+
it("deduplicates links by title", () => {
62
+
const duplicateLinks: WikiLink[] = [{ raw: "[[Same]]", title: "Same", start: 0, end: 8 }, {
63
+
raw: "[[Same]]",
64
+
title: "Same",
65
+
start: 10,
66
+
end: 18,
67
+
}];
68
+
69
+
render(() => <WikilinksPanel links={duplicateLinks} notes={[]} resolveNote={() => null} />);
70
+
const sameLinks = screen.getAllByText("Same");
71
+
expect(sameLinks).toHaveLength(1);
72
+
});
73
+
});
+7
web/src/fonts.d.ts
+7
web/src/fonts.d.ts
···
1
1
// CSS-only packages without TypeScript declarations
2
2
declare module "@fontsource-variable/figtree";
3
+
declare module "@fontsource-variable/jetbrains-mono";
4
+
declare module "@fontsource/google-sans-code";
5
+
declare module "@fontsource/monaspace-argon";
6
+
declare module "@fontsource/monaspace-krypton";
7
+
declare module "@fontsource/monaspace-neon";
8
+
declare module "@fontsource/monaspace-radon";
9
+
declare module "@fontsource/monaspace-xenon";
+50
web/src/index.css
+50
web/src/index.css
···
1
1
@import "tailwindcss";
2
+
@plugin "@tailwindcss/forms";
2
3
@plugin "@egoist/tailwindcss-icons";
3
4
4
5
@font-face {
···
13
14
@theme {
14
15
--font-display: "Mattern", serif;
15
16
--font-body: "Figtree Variable", sans-serif;
17
+
18
+
/* Editor Fonts */
19
+
--font-code-neon: "Monaspace Neon", monospace;
20
+
--font-code-argon: "Monaspace Argon", monospace;
21
+
--font-code-krypton: "Monaspace Krypton", monospace;
22
+
--font-code-radon: "Monaspace Radon", monospace;
23
+
--font-code-xenon: "Monaspace Xenon", monospace;
24
+
--font-code-jetbrains: "JetBrains Mono Variable", monospace;
25
+
--font-code-google: "Google Sans Code", monospace;
26
+
--font-code: var(--font-code-jetbrains);
16
27
17
28
/* Spacing Tokens - 16px grid */
18
29
/*
···
187
198
button {
188
199
@apply cursor-pointer disabled:cursor-not-allowed disabled:opacity-50;
189
200
}
201
+
202
+
/* Textcomplete dropdown styling */
203
+
.textcomplete-dropdown {
204
+
background-color: var(--layer-02);
205
+
border: 1px solid var(--layer-03);
206
+
border-radius: 0.5rem;
207
+
box-shadow: var(--shadow-04);
208
+
overflow: hidden;
209
+
z-index: 50;
210
+
}
211
+
212
+
.textcomplete-dropdown .textcomplete-item {
213
+
padding: 0.5rem 0.75rem;
214
+
cursor: pointer;
215
+
color: #e2e8f0;
216
+
}
217
+
218
+
.textcomplete-dropdown .textcomplete-item:hover,
219
+
.textcomplete-dropdown .textcomplete-item.active {
220
+
background-color: var(--layer-03);
221
+
}
222
+
223
+
/* Wikilink styling in rendered content */
224
+
.wikilink {
225
+
color: #60a5fa;
226
+
text-decoration: underline;
227
+
text-decoration-color: rgba(96, 165, 250, 0.3);
228
+
transition: color var(--duration-fast) var(--easing-standard);
229
+
}
230
+
231
+
.wikilink:hover {
232
+
color: #93c5fd;
233
+
text-decoration-color: rgba(147, 197, 253, 0.5);
234
+
}
235
+
236
+
.wikilink-broken {
237
+
color: #fbbf24;
238
+
text-decoration: line-through;
239
+
}
+7
web/src/index.tsx
+7
web/src/index.tsx
···
1
1
/* @refresh reload */
2
2
import "@fontsource-variable/figtree";
3
+
import "@fontsource-variable/jetbrains-mono";
4
+
import "@fontsource/google-sans-code";
5
+
import "@fontsource/monaspace-argon";
6
+
import "@fontsource/monaspace-krypton";
7
+
import "@fontsource/monaspace-neon";
8
+
import "@fontsource/monaspace-radon";
9
+
import "@fontsource/monaspace-xenon";
3
10
import { render } from "solid-js/web";
4
11
import "./index.css";
5
12
import App from "./App.tsx";
+202
web/src/lib/tests/wikilink.test.ts
+202
web/src/lib/tests/wikilink.test.ts
···
1
+
import { describe, expect, it } from "vitest";
2
+
import type { Note } from "../model";
3
+
import {
4
+
extractHeadings,
5
+
extractWikilinkTitles,
6
+
findBacklinks,
7
+
parseWikilinks,
8
+
renderWikilinks,
9
+
resolveWikilink,
10
+
slugify,
11
+
} from "../wikilink";
12
+
13
+
const mockNotes: Note[] = [{
14
+
id: "note-1",
15
+
owner_did: "did:plc:test",
16
+
title: "First Note",
17
+
body: "Content with [[Second Note]] link",
18
+
tags: ["test"],
19
+
visibility: { type: "Private" },
20
+
created_at: "2026-01-01T00:00:00Z",
21
+
updated_at: "2026-01-01T00:00:00Z",
22
+
}, {
23
+
id: "note-2",
24
+
owner_did: "did:plc:test",
25
+
title: "Second Note",
26
+
body: "This links to [[First Note]] and [[Third Note]]",
27
+
tags: ["test"],
28
+
visibility: { type: "Private" },
29
+
created_at: "2026-01-01T00:00:00Z",
30
+
updated_at: "2026-01-01T00:00:00Z",
31
+
}, {
32
+
id: "note-3",
33
+
owner_did: "did:plc:test",
34
+
title: "Third Note",
35
+
body: "No wikilinks here",
36
+
tags: [],
37
+
visibility: { type: "Private" },
38
+
created_at: "2026-01-01T00:00:00Z",
39
+
updated_at: "2026-01-01T00:00:00Z",
40
+
}];
41
+
42
+
describe("parseWikilinks", () => {
43
+
it("extracts simple wikilinks", () => {
44
+
const text = "Check out [[My Note]] for more info";
45
+
const links = parseWikilinks(text);
46
+
expect(links).toHaveLength(1);
47
+
expect(links[0].title).toBe("My Note");
48
+
expect(links[0].alias).toBeUndefined();
49
+
});
50
+
51
+
it("extracts wikilinks with aliases", () => {
52
+
const text = "See [[Long Note Title|short alias]] here";
53
+
const links = parseWikilinks(text);
54
+
expect(links).toHaveLength(1);
55
+
expect(links[0].title).toBe("Long Note Title");
56
+
expect(links[0].alias).toBe("short alias");
57
+
});
58
+
59
+
it("extracts multiple wikilinks", () => {
60
+
const text = "Both [[Note A]] and [[Note B|B]] are relevant";
61
+
const links = parseWikilinks(text);
62
+
expect(links).toHaveLength(2);
63
+
expect(links[0].title).toBe("Note A");
64
+
expect(links[1].title).toBe("Note B");
65
+
expect(links[1].alias).toBe("B");
66
+
});
67
+
68
+
it("returns empty array for no wikilinks", () => {
69
+
const text = "Just regular text without links";
70
+
const links = parseWikilinks(text);
71
+
expect(links).toHaveLength(0);
72
+
});
73
+
74
+
it("includes position information", () => {
75
+
const text = "Start [[Link]] end";
76
+
const links = parseWikilinks(text);
77
+
expect(links[0].start).toBe(6);
78
+
expect(links[0].end).toBe(14);
79
+
expect(links[0].raw).toBe("[[Link]]");
80
+
});
81
+
});
82
+
83
+
describe("extractWikilinkTitles", () => {
84
+
it("returns unique titles", () => {
85
+
const text = "[[Note A]] and [[Note B]] and [[Note A]] again";
86
+
const titles = extractWikilinkTitles(text);
87
+
expect(titles).toHaveLength(2);
88
+
expect(titles).toContain("Note A");
89
+
expect(titles).toContain("Note B");
90
+
});
91
+
});
92
+
93
+
describe("resolveWikilink", () => {
94
+
it("resolves exact match", () => {
95
+
const note = resolveWikilink("First Note", mockNotes);
96
+
expect(note?.id).toBe("note-1");
97
+
});
98
+
99
+
it("resolves case-insensitively", () => {
100
+
const note = resolveWikilink("FIRST NOTE", mockNotes);
101
+
expect(note?.id).toBe("note-1");
102
+
});
103
+
104
+
it("returns null for unresolved links", () => {
105
+
const note = resolveWikilink("Nonexistent Note", mockNotes);
106
+
expect(note).toBeNull();
107
+
});
108
+
109
+
it("trims whitespace", () => {
110
+
const note = resolveWikilink(" First Note ", mockNotes);
111
+
expect(note?.id).toBe("note-1");
112
+
});
113
+
});
114
+
115
+
describe("findBacklinks", () => {
116
+
it("finds notes linking to target", () => {
117
+
const backlinks = findBacklinks("Second Note", mockNotes);
118
+
expect(backlinks).toHaveLength(1);
119
+
expect(backlinks[0].id).toBe("note-1");
120
+
});
121
+
122
+
it("finds multiple backlinks", () => {
123
+
const backlinks = findBacklinks("First Note", mockNotes);
124
+
expect(backlinks).toHaveLength(1);
125
+
expect(backlinks[0].id).toBe("note-2");
126
+
});
127
+
128
+
it("returns empty for notes with no backlinks", () => {
129
+
const backlinks = findBacklinks("Third Note", mockNotes);
130
+
expect(backlinks).toHaveLength(1);
131
+
expect(backlinks[0].id).toBe("note-2");
132
+
});
133
+
134
+
it("is case-insensitive", () => {
135
+
const backlinks = findBacklinks("SECOND NOTE", mockNotes);
136
+
expect(backlinks).toHaveLength(1);
137
+
});
138
+
});
139
+
140
+
describe("slugify", () => {
141
+
it("converts to lowercase", () => {
142
+
expect(slugify("Hello World")).toBe("hello-world");
143
+
});
144
+
145
+
it("removes special characters", () => {
146
+
expect(slugify("Hello, World!")).toBe("hello-world");
147
+
});
148
+
149
+
it("collapses multiple dashes", () => {
150
+
expect(slugify("Hello World")).toBe("hello-world");
151
+
});
152
+
153
+
it("handles empty string", () => {
154
+
expect(slugify("")).toBe("");
155
+
});
156
+
});
157
+
158
+
describe("extractHeadings", () => {
159
+
it("extracts H1-H6 headings", () => {
160
+
const markdown = `# Heading 1
161
+
## Heading 2
162
+
### Heading 3
163
+
#### Heading 4
164
+
##### Heading 5
165
+
###### Heading 6`;
166
+
const headings = extractHeadings(markdown);
167
+
expect(headings).toHaveLength(6);
168
+
expect(headings[0]).toEqual({ level: 1, text: "Heading 1", id: "heading-1" });
169
+
expect(headings[5]).toEqual({ level: 6, text: "Heading 6", id: "heading-6" });
170
+
});
171
+
172
+
it("handles special characters in headings", () => {
173
+
const markdown = "## Hello, World!";
174
+
const headings = extractHeadings(markdown);
175
+
expect(headings[0].id).toBe("hello-world");
176
+
});
177
+
178
+
it("returns empty array for no headings", () => {
179
+
const markdown = "Just regular text";
180
+
expect(extractHeadings(markdown)).toHaveLength(0);
181
+
});
182
+
});
183
+
184
+
describe("renderWikilinks", () => {
185
+
it("renders resolved links as anchors", () => {
186
+
const text = "See [[My Note]] here";
187
+
const result = renderWikilinks(text, () => "/notes/123");
188
+
expect(result).toBe("See <a href=\"/notes/123\" class=\"wikilink\">My Note</a> here");
189
+
});
190
+
191
+
it("renders unresolved links as spans", () => {
192
+
const text = "See [[Missing]] here";
193
+
const result = renderWikilinks(text, () => null);
194
+
expect(result).toBe("See <span class=\"wikilink wikilink-broken\">Missing</span> here");
195
+
});
196
+
197
+
it("uses alias for display text when provided", () => {
198
+
const text = "See [[Long Title|alias]] here";
199
+
const result = renderWikilinks(text, () => "/notes/123");
200
+
expect(result).toBe("See <a href=\"/notes/123\" class=\"wikilink\">alias</a> here");
201
+
});
202
+
});
+154
web/src/lib/wikilink.ts
+154
web/src/lib/wikilink.ts
···
1
+
/**
2
+
* Wikilink parsing, resolution, and heading extraction utilities
3
+
* Supports [[Note Title]] syntax for linking between notes
4
+
*/
5
+
6
+
import type { Note } from "./model";
7
+
8
+
/** Regex pattern to match wikilinks: [[Title]] or [[Title|Alias]] */
9
+
const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
10
+
11
+
/** Regex pattern to match markdown headings (H1-H6) */
12
+
const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/gm;
13
+
14
+
/**
15
+
* Represents a parsed wikilink
16
+
*/
17
+
export type WikiLink = {
18
+
/** The full match including brackets */
19
+
raw: string;
20
+
/** The title of the linked note */
21
+
title: string;
22
+
/** Optional display alias */
23
+
alias?: string;
24
+
/** Start index in the source text */
25
+
start: number;
26
+
/** End index in the source text */
27
+
end: number;
28
+
};
29
+
30
+
/**
31
+
* Represents a heading extracted from markdown
32
+
*/
33
+
export type Heading = {
34
+
/** Heading level (1-6) */
35
+
level: number;
36
+
/** Heading text content */
37
+
text: string;
38
+
/** Slugified ID for anchor links */
39
+
id: string;
40
+
};
41
+
42
+
/**
43
+
* Parse all wikilinks from markdown text
44
+
*
45
+
* @param text - Markdown text containing wikilinks
46
+
* @returns Array of parsed wikilinks with position info
47
+
*/
48
+
export function parseWikilinks(text: string): WikiLink[] {
49
+
const links: WikiLink[] = [];
50
+
let match: RegExpExecArray | null;
51
+
52
+
// Reset regex state
53
+
WIKILINK_PATTERN.lastIndex = 0;
54
+
55
+
while ((match = WIKILINK_PATTERN.exec(text)) !== null) {
56
+
links.push({
57
+
raw: match[0],
58
+
title: match[1].trim(),
59
+
alias: match[2]?.trim(),
60
+
start: match.index,
61
+
end: match.index + match[0].length,
62
+
});
63
+
}
64
+
65
+
return links;
66
+
}
67
+
68
+
/**
69
+
* Extract unique wikilink titles from text
70
+
*
71
+
* @param text - Markdown text containing wikilinks
72
+
* @returns Array of unique linked note titles
73
+
*/
74
+
export function extractWikilinkTitles(text: string): string[] {
75
+
const links = parseWikilinks(text);
76
+
const titles = new Set(links.map((l) => l.title));
77
+
return Array.from(titles);
78
+
}
79
+
80
+
/**
81
+
* Resolve a wikilink title to a note (case-insensitive)
82
+
*
83
+
* @param title - The wikilink title to resolve
84
+
* @param notes - Array of notes to search
85
+
* @returns The matching note or null if not found
86
+
*/
87
+
export function resolveWikilink(title: string, notes: Note[]): Note | null {
88
+
const normalizedTitle = title.toLowerCase().trim();
89
+
return notes.find((n) => n.title.toLowerCase().trim() === normalizedTitle) ?? null;
90
+
}
91
+
92
+
/**
93
+
* Find all notes that contain wikilinks to a target note
94
+
*
95
+
* @param noteTitle - Title of the target note
96
+
* @param allNotes - Array of all notes to search
97
+
* @returns Array of notes that link to the target
98
+
*/
99
+
export function findBacklinks(noteTitle: string, allNotes: Note[]): Note[] {
100
+
const normalizedTitle = noteTitle.toLowerCase().trim();
101
+
return allNotes.filter((note) => {
102
+
const links = extractWikilinkTitles(note.body);
103
+
return links.some((link) => link.toLowerCase().trim() === normalizedTitle);
104
+
});
105
+
}
106
+
107
+
/**
108
+
* Convert heading text to a URL-safe slug
109
+
*
110
+
* @param text - Heading text to slugify
111
+
* @returns Slugified string suitable for anchor IDs
112
+
*/
113
+
export function slugify(text: string): string {
114
+
return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
115
+
}
116
+
117
+
/**
118
+
* Extract all headings from markdown text
119
+
*
120
+
* @param markdown - Markdown text to parse
121
+
* @returns Array of headings with level, text, and id
122
+
*/
123
+
export function extractHeadings(markdown: string): Heading[] {
124
+
const headings: Heading[] = [];
125
+
let match: RegExpExecArray | null;
126
+
127
+
HEADING_PATTERN.lastIndex = 0;
128
+
129
+
while ((match = HEADING_PATTERN.exec(markdown)) !== null) {
130
+
const level = match[1].length as 1 | 2 | 3 | 4 | 5 | 6;
131
+
const text = match[2].trim();
132
+
headings.push({ level, text, id: slugify(text) });
133
+
}
134
+
135
+
return headings;
136
+
}
137
+
138
+
/**
139
+
* Render wikilinks as HTML anchor tags
140
+
*
141
+
* @param text - Text containing wikilinks
142
+
* @param resolveHref - Function to resolve a title to an href
143
+
* @returns Text with wikilinks replaced by anchor tags
144
+
*/
145
+
export function renderWikilinks(text: string, resolveHref: (title: string) => string | null): string {
146
+
const href = resolveHref(text.trim());
147
+
return text.replace(
148
+
WIKILINK_PATTERN,
149
+
(_, title: string, alias?: string) =>
150
+
href
151
+
? `<a href="${href}" class="wikilink">${(alias || title).trim()}</a>`
152
+
: `<span class="wikilink wikilink-broken">${(alias || title).trim()}</span>`,
153
+
);
154
+
}
+41
web/src/pages/NoteEdit.tsx
+41
web/src/pages/NoteEdit.tsx
···
1
+
import { NoteEditor } from "$components/NoteEditor";
2
+
import { api } from "$lib/api";
3
+
import type { Note } from "$lib/model";
4
+
import { A, useParams } from "@solidjs/router";
5
+
import { createResource, Show } from "solid-js";
6
+
7
+
const NoteEdit = () => {
8
+
const params = useParams<{ id: string }>();
9
+
10
+
const [note] = createResource(() => params.id, async (id: string): Promise<Note | null> => {
11
+
const res = await api.getNote(id);
12
+
if (!res.ok) return null;
13
+
return res.json();
14
+
});
15
+
16
+
return (
17
+
<div class="max-w-6xl mx-auto">
18
+
<Show
19
+
when={!note.loading}
20
+
fallback={
21
+
<div class="flex justify-center p-12">
22
+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
23
+
</div>
24
+
}>
25
+
<Show
26
+
when={note()}
27
+
fallback={
28
+
<div class="text-center py-12">
29
+
<h2 class="text-xl font-semibold text-white">Note not found</h2>
30
+
<p class="text-slate-400 mt-2">This note may have been deleted.</p>
31
+
<A href="/notes" class="text-blue-500 hover:text-blue-400 mt-4 inline-block">← Back to Notes</A>
32
+
</div>
33
+
}>
34
+
{(n) => <NoteEditor noteId={n().id} initialTitle={n().title} initialContent={n().body} />}
35
+
</Show>
36
+
</Show>
37
+
</div>
38
+
);
39
+
};
40
+
41
+
export default NoteEdit;
+63
-10
web/src/pages/NoteView.tsx
+63
-10
web/src/pages/NoteView.tsx
···
1
1
/* eslint-disable solid/no-innerhtml */
2
+
import { BacklinksPanel } from "$components/notes/BacklinksPanel";
3
+
import { OutlinePanel } from "$components/notes/OutlinePanel";
4
+
import { WikilinksPanel } from "$components/notes/WikilinksPanel";
2
5
import { Button } from "$components/ui/Button";
3
6
import { api } from "$lib/api";
4
7
import type { Note } from "$lib/model";
8
+
import {
9
+
extractHeadings,
10
+
findBacklinks,
11
+
type Heading,
12
+
parseWikilinks,
13
+
resolveWikilink,
14
+
type WikiLink,
15
+
} from "$lib/wikilink";
5
16
import { Tag } from "$ui/Tag";
17
+
import rehypeShiki from "@shikijs/rehype";
6
18
import { A, useParams } from "@solidjs/router";
7
19
import rehypeExternalLinks from "rehype-external-links";
8
-
import rehypeSanitize from "rehype-sanitize";
9
20
import rehypeStringify from "rehype-stringify";
10
21
import remarkParse from "remark-parse";
11
22
import remarkRehype from "remark-rehype";
12
23
import type { Component } from "solid-js";
13
-
import { createEffect, createResource, createSignal, For, Show } from "solid-js";
24
+
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
14
25
import { unified } from "unified";
15
26
16
27
const NoteView: Component = () => {
···
20
31
if (!res.ok) return null;
21
32
return res.json();
22
33
});
34
+
35
+
const [allNotes] = createResource(async (): Promise<Note[]> => {
36
+
const res = await api.getNotes();
37
+
if (!res.ok) return [];
38
+
return res.json();
39
+
});
40
+
23
41
const [renderedContent, setRenderedContent] = createSignal("");
42
+
const [headings, setHeadings] = createSignal<Heading[]>([]);
43
+
const [wikilinks, setWikilinks] = createSignal<WikiLink[]>([]);
24
44
25
-
const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeExternalLinks, {
26
-
target: "_blank",
27
-
rel: ["nofollow"],
28
-
}).use(rehypeStringify);
45
+
const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, {
46
+
theme: "vitesse-dark",
47
+
defaultLanguage: "text",
48
+
}).use(rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }).use(rehypeStringify);
29
49
30
50
const updateRenderedContent = async (n: Note) => {
51
+
setHeadings(extractHeadings(n.body));
52
+
setWikilinks(parseWikilinks(n.body));
31
53
const file = await processor.process(n.body);
32
54
setRenderedContent(String(file));
33
55
};
···
39
61
}
40
62
});
41
63
64
+
const backlinks = createMemo(() => {
65
+
const n = note();
66
+
const all = allNotes() ?? [];
67
+
if (!n) return [];
68
+
return findBacklinks(n.title, all);
69
+
});
70
+
71
+
const resolveNote = (title: string) => resolveWikilink(title, allNotes() ?? []);
72
+
42
73
return (
43
-
<div class="max-w-5xl mx-auto p-6">
74
+
<div class="max-w-7xl mx-auto p-6">
44
75
<Show
45
76
when={!note.loading}
46
77
fallback={
···
94
125
</div>
95
126
</Show>
96
127
97
-
<article class="prose prose-slate dark:prose-invert max-w-none bg-white dark:bg-slate-800/50 rounded-xl p-8 border border-slate-200 dark:border-slate-700">
98
-
<div innerHTML={renderedContent()} />
99
-
</article>
128
+
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6">
129
+
<article class="prose prose-slate dark:prose-invert max-w-none bg-white dark:bg-slate-800/50 rounded-xl p-8 border border-slate-200 dark:border-slate-700 overflow-hidden">
130
+
<div class="shiki-content" innerHTML={renderedContent()} />
131
+
</article>
132
+
133
+
<aside class="space-y-6">
134
+
<div class="surface-01 rounded-lg p-4">
135
+
<OutlinePanel headings={headings()} />
136
+
</div>
137
+
<div class="surface-01 rounded-lg p-4">
138
+
<WikilinksPanel links={wikilinks()} notes={allNotes() ?? []} resolveNote={resolveNote} />
139
+
</div>
140
+
<div class="surface-01 rounded-lg p-4">
141
+
<BacklinksPanel backlinks={backlinks()} />
142
+
</div>
143
+
<Show when={n().tags.length > 0}>
144
+
<div class="surface-01 rounded-lg p-4 hidden lg:block">
145
+
<h3 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-2">Tags</h3>
146
+
<div class="flex flex-wrap gap-2">
147
+
<For each={n().tags}>{(tag) => <Tag label={tag} density="compact" />}</For>
148
+
</div>
149
+
</div>
150
+
</Show>
151
+
</aside>
152
+
</div>
100
153
</div>
101
154
)}
102
155
</Show>
+70
-4
web/src/pages/tests/NoteView.test.tsx
+70
-4
web/src/pages/tests/NoteView.test.tsx
···
5
5
import { afterEach, describe, expect, it, vi } from "vitest";
6
6
import NoteView from "../NoteView";
7
7
8
-
vi.mock("$lib/api", () => ({ api: { getNote: vi.fn() } }));
8
+
vi.mock("$lib/api", () => ({ api: { getNote: vi.fn(), getNotes: vi.fn() } }));
9
9
10
10
vi.mock(
11
11
"@solidjs/router",
···
19
19
id: "note-1",
20
20
owner_did: "did:plc:test123",
21
21
title: "Test Note Title",
22
-
body: "# Heading\n\nSome **markdown** content.",
22
+
body: "# Heading\n\nSome **markdown** content with [[Other Note]].",
23
23
tags: ["rust", "learning"],
24
24
visibility: { type: "Public" },
25
25
created_at: "2026-01-01T10:00:00Z",
26
26
updated_at: "2026-01-01T12:00:00Z",
27
27
};
28
28
29
+
const mockAllNotes: Note[] = [mockNote, {
30
+
id: "note-2",
31
+
owner_did: "did:plc:test123",
32
+
title: "Other Note",
33
+
body: "Links back to [[Test Note Title]]",
34
+
tags: [],
35
+
visibility: { type: "Private" },
36
+
created_at: "2026-01-01T10:00:00Z",
37
+
updated_at: "2026-01-01T12:00:00Z",
38
+
}];
39
+
29
40
describe("NoteView", () => {
30
41
afterEach(() => {
31
42
cleanup();
···
36
47
vi.mocked(api.getNote).mockResolvedValue(
37
48
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
38
49
);
50
+
vi.mocked(api.getNotes).mockResolvedValue(
51
+
{ ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response,
52
+
);
39
53
40
54
render(() => <NoteView />);
41
55
···
48
62
vi.mocked(api.getNote).mockResolvedValue(
49
63
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
50
64
);
65
+
vi.mocked(api.getNotes).mockResolvedValue(
66
+
{ ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response,
67
+
);
51
68
52
69
render(() => <NoteView />);
53
70
54
71
await waitFor(() => {
55
-
expect(screen.getByText("rust")).toBeInTheDocument();
56
-
expect(screen.getByText("learning")).toBeInTheDocument();
72
+
expect(screen.getAllByText("rust").length).toBeGreaterThan(0);
73
+
expect(screen.getAllByText("learning").length).toBeGreaterThan(0);
57
74
});
58
75
});
59
76
60
77
it("has back to notes link", async () => {
61
78
vi.mocked(api.getNote).mockResolvedValue(
62
79
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
80
+
);
81
+
vi.mocked(api.getNotes).mockResolvedValue(
82
+
{ ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response,
63
83
);
64
84
65
85
render(() => <NoteView />);
···
71
91
72
92
it("renders not found state when note returns error", async () => {
73
93
vi.mocked(api.getNote).mockResolvedValue({ ok: false } as unknown as Response);
94
+
vi.mocked(api.getNotes).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
74
95
75
96
render(() => <NoteView />);
76
97
77
98
await waitFor(() => {
78
99
expect(screen.getByText("Note not found")).toBeInTheDocument();
100
+
});
101
+
});
102
+
103
+
it("renders outline panel heading", async () => {
104
+
vi.mocked(api.getNote).mockResolvedValue(
105
+
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
106
+
);
107
+
vi.mocked(api.getNotes).mockResolvedValue(
108
+
{ ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response,
109
+
);
110
+
111
+
render(() => <NoteView />);
112
+
113
+
await waitFor(() => {
114
+
expect(screen.getByText("Outline")).toBeInTheDocument();
115
+
});
116
+
});
117
+
118
+
it("renders wikilinks panel heading", async () => {
119
+
vi.mocked(api.getNote).mockResolvedValue(
120
+
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
121
+
);
122
+
vi.mocked(api.getNotes).mockResolvedValue(
123
+
{ ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response,
124
+
);
125
+
126
+
render(() => <NoteView />);
127
+
128
+
await waitFor(() => {
129
+
expect(screen.getByText("Wikilinks")).toBeInTheDocument();
130
+
});
131
+
});
132
+
133
+
it("renders backlinks panel heading", async () => {
134
+
vi.mocked(api.getNote).mockResolvedValue(
135
+
{ ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response,
136
+
);
137
+
vi.mocked(api.getNotes).mockResolvedValue(
138
+
{ ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response,
139
+
);
140
+
141
+
render(() => <NoteView />);
142
+
143
+
await waitFor(() => {
144
+
expect(screen.getByText("Backlinks")).toBeInTheDocument();
79
145
});
80
146
});
81
147
});