learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: complete markdown note editor

* syntax highlighting

* backlinks

* toolbar

+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
··· 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
··· 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
··· 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&#10;&#10;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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 });