diff --git a/web/package.json b/web/package.json index 1ea3aaa..8fe0f0f 100644 --- a/web/package.json +++ b/web/package.json @@ -14,9 +14,19 @@ }, "dependencies": { "@fontsource-variable/figtree": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.0.0", + "@fontsource/google-sans-code": "^5.0.0", + "@fontsource/monaspace-argon": "^5.0.0", + "@fontsource/monaspace-krypton": "^5.0.0", + "@fontsource/monaspace-neon": "^5.0.0", + "@fontsource/monaspace-radon": "^5.0.0", + "@fontsource/monaspace-xenon": "^5.0.0", + "@shikijs/rehype": "^3.0.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.4", "@tailwindcss/vite": "^4.1.18", + "@textcomplete/core": "^0.1.0", + "@textcomplete/textarea": "^0.1.0", "clsx": "^2.1.1", "motion": "^12.23.26", "rehype-external-links": "^3.0.0", @@ -24,6 +34,7 @@ "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", + "shiki": "^3.0.0", "solid-js": "^1.9.10", "solid-motionone": "^1.0.4", "tailwind-merge": "^3.4.0", @@ -37,6 +48,7 @@ "@iconify-json/ri": "^1.2.7", "@resvg/resvg-js": "^2.6.2", "@solidjs/testing-library": "^0.8.10", + "@tailwindcss/forms": "^0.5.11", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", @@ -53,5 +65,9 @@ "vite-plugin-solid": "^2.11.10", "vitest": "^4.0.16" }, - "pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.2.5" } } + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } + } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a4ccb56..7129af2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,30 @@ importers: '@fontsource-variable/figtree': specifier: ^5.2.8 version: 5.2.10 + '@fontsource-variable/jetbrains-mono': + specifier: ^5.0.0 + version: 5.2.8 + '@fontsource/google-sans-code': + specifier: ^5.0.0 + version: 5.2.3 + '@fontsource/monaspace-argon': + specifier: ^5.0.0 + version: 5.2.5 + '@fontsource/monaspace-krypton': + specifier: ^5.0.0 + version: 5.2.5 + '@fontsource/monaspace-neon': + specifier: ^5.0.0 + version: 5.2.5 + '@fontsource/monaspace-radon': + specifier: ^5.0.0 + version: 5.2.5 + '@fontsource/monaspace-xenon': + specifier: ^5.0.0 + version: 5.2.5 + '@shikijs/rehype': + specifier: ^3.0.0 + version: 3.20.0 '@solidjs/meta': specifier: ^0.29.4 version: 0.29.4(solid-js@1.9.10) @@ -23,6 +47,12 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(rolldown-vite@7.2.5(@types/node@24.10.4)(jiti@2.6.1)(tsx@4.21.0)) + '@textcomplete/core': + specifier: ^0.1.0 + version: 0.1.13 + '@textcomplete/textarea': + specifier: ^0.1.0 + version: 0.1.13(@textcomplete/core@0.1.13) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -44,6 +74,9 @@ importers: remark-rehype: specifier: ^11.1.2 version: 11.1.2 + shiki: + specifier: ^3.0.0 + version: 3.20.0 solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -78,6 +111,9 @@ importers: '@solidjs/testing-library': specifier: ^0.8.10 version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.10))(solid-js@1.9.10) + '@tailwindcss/forms': + specifier: ^0.5.11 + version: 0.5.11(tailwindcss@4.1.18) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -484,6 +520,27 @@ packages: '@fontsource-variable/figtree@5.2.10': resolution: {integrity: sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==} + '@fontsource-variable/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} + + '@fontsource/google-sans-code@5.2.3': + resolution: {integrity: sha512-bCbCXFWtGIVNEe8O/mE7YpIuVjc3E4CV6pToJ0IZc9XcXKfa74yntZa3UgyzBRgabnx5bm6ciQVQvWGnwi4Rqw==} + + '@fontsource/monaspace-argon@5.2.5': + resolution: {integrity: sha512-EJ+jq1Smm3BB+8RK/gwB1uzjrSKdycZkKm8OZGCYvqJiTChKcGe7b2lmj0PmQbb/+lAEdCBIAcFLlPaWhtRANA==} + + '@fontsource/monaspace-krypton@5.2.5': + resolution: {integrity: sha512-dOWSb0DtSMfjbptckxTme2FKNfRkFtmTuNRQ86sBki/CanR4ajSrE+GuJoCm9Kl3352CvafoRt2fDo8FU+Gz4A==} + + '@fontsource/monaspace-neon@5.2.5': + resolution: {integrity: sha512-TjSSuHC37DroyhP5YCKRCAxdUb3In/uqHzqqW/8Ul+JWilz37d8lr8jFeapnbD3QdYXgiQoU2Rf/CmTdyl06DA==} + + '@fontsource/monaspace-radon@5.2.5': + resolution: {integrity: sha512-mG6ltrFW5BYmcoRvVIKboCORZu8BeGg6h5zV5rnwEmFcjv8Bltua3KOj6aOxWbrR4PYnKyR17lNqSsjR8kx4bQ==} + + '@fontsource/monaspace-xenon@5.2.5': + resolution: {integrity: sha512-k10RCF5nek9ee/rKSALaeYgOcJjjvzxxGkIlPwkSAoasjrsEeVKOSJ6ii1dMXOZwL9ZtW3p56Dxhgaw0Gtwvaw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -718,6 +775,30 @@ packages: '@rolldown/pluginutils@1.0.0-beta.50': resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + '@shikijs/core@3.20.0': + resolution: {integrity: sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==} + + '@shikijs/engine-javascript@3.20.0': + resolution: {integrity: sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg==} + + '@shikijs/engine-oniguruma@3.20.0': + resolution: {integrity: sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==} + + '@shikijs/langs@3.20.0': + resolution: {integrity: sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==} + + '@shikijs/rehype@3.20.0': + resolution: {integrity: sha512-/sqob3V/lJK0m2mZ64nkcWPN88im0D9atkI3S3PUBvtJZTHnJXVwZhHQFRDyObgEIa37IpHYHR3CuFtXB5bT2g==} + + '@shikijs/themes@3.20.0': + resolution: {integrity: sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==} + + '@shikijs/types@3.20.0': + resolution: {integrity: sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shuding/opentype.js@1.4.0-beta.0': resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} engines: {node: '>= 8.0.0'} @@ -766,6 +847,11 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/forms@0.5.11': + resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -870,6 +956,17 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@textcomplete/core@0.1.13': + resolution: {integrity: sha512-C4S+ihQU5HsKQ/TbsmS0e7hfPZtLZbEXj5NDUgRnhu/1Nezpu892bjNZGeErZm+R8iyDIT6wDu6EgIhng4M8eQ==} + + '@textcomplete/textarea@0.1.13': + resolution: {integrity: sha512-GNathnXpV361YuZrBVXvVqFYZ5NQZsjGC7Bt2sCUA/RTWlIgxHxC0ruDChYyRDx4siQZiZZOO5pWz+z1x8pZFQ==} + peerDependencies: + '@textcomplete/core': ^0.1.12 + + '@textcomplete/utils@0.1.13': + resolution: {integrity: sha512-5UW9Ee0WEX1s9K8MFffo5sfUjYm3YVhtqRhAor/ih7p0tnnpaMB7AwMRDKwhSIQL6O+g1fmEkxCeO8WqjPzjUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1310,6 +1407,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1414,6 +1514,9 @@ packages: hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -1735,6 +1838,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1782,6 +1889,12 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.4: + resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1869,6 +1982,15 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + rehype-external-links@3.0.0: resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} @@ -1975,6 +2097,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@3.20.0: + resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2038,6 +2163,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + textarea-caret@3.1.0: + resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -2110,6 +2238,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + undate@0.3.0: + resolution: {integrity: sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2580,6 +2711,20 @@ snapshots: '@fontsource-variable/figtree@5.2.10': {} + '@fontsource-variable/jetbrains-mono@5.2.8': {} + + '@fontsource/google-sans-code@5.2.3': {} + + '@fontsource/monaspace-argon@5.2.5': {} + + '@fontsource/monaspace-krypton@5.2.5': {} + + '@fontsource/monaspace-neon@5.2.5': {} + + '@fontsource/monaspace-radon@5.2.5': {} + + '@fontsource/monaspace-xenon@5.2.5': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2776,6 +2921,48 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.50': {} + '@shikijs/core@3.20.0': + dependencies: + '@shikijs/types': 3.20.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.20.0': + dependencies: + '@shikijs/types': 3.20.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + + '@shikijs/engine-oniguruma@3.20.0': + dependencies: + '@shikijs/types': 3.20.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.20.0': + dependencies: + '@shikijs/types': 3.20.0 + + '@shikijs/rehype@3.20.0': + dependencies: + '@shikijs/types': 3.20.0 + '@types/hast': 3.0.4 + hast-util-to-string: 3.0.1 + shiki: 3.20.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + + '@shikijs/themes@3.20.0': + dependencies: + '@shikijs/types': 3.20.0 + + '@shikijs/types@3.20.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@shuding/opentype.js@1.4.0-beta.0': dependencies: fflate: 0.7.4 @@ -2816,6 +3003,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/forms@0.5.11(tailwindcss@4.1.18)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.1.18 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2908,6 +3100,19 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@textcomplete/core@0.1.13': + dependencies: + eventemitter3: 5.0.1 + + '@textcomplete/textarea@0.1.13(@textcomplete/core@0.1.13)': + dependencies: + '@textcomplete/core': 0.1.13 + '@textcomplete/utils': 0.1.13 + textarea-caret: 3.1.0 + undate: 0.3.0 + + '@textcomplete/utils@0.1.13': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3413,6 +3618,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.1: {} + expect-type@1.3.0: {} exsolve@1.0.8: {} @@ -3500,6 +3707,10 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -3882,6 +4093,8 @@ snapshots: min-indent@1.0.1: {} + mini-svg-data-uri@1.4.4: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -3918,6 +4131,14 @@ snapshots: obug@2.1.1: {} + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.4: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4007,6 +4228,16 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + rehype-external-links@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -4119,6 +4350,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@3.20.0: + dependencies: + '@shikijs/core': 3.20.0 + '@shikijs/engine-javascript': 3.20.0 + '@shikijs/engine-oniguruma': 3.20.0 + '@shikijs/langs': 3.20.0 + '@shikijs/themes': 3.20.0 + '@shikijs/types': 3.20.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + siginfo@2.0.0: {} solid-js@1.9.10: @@ -4183,6 +4425,8 @@ snapshots: tapable@2.3.0: {} + textarea-caret@3.1.0: {} + tiny-inflate@1.0.3: {} tinybench@2.9.0: {} @@ -4246,6 +4490,8 @@ snapshots: ufo@1.6.1: {} + undate@0.3.0: {} + undici-types@7.16.0: {} unicode-trie@2.0.0: diff --git a/web/src/App.tsx b/web/src/App.tsx index a4fdd29..614d26b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import LectureImport from "$pages/LectureImport"; import Library from "$pages/Library"; import Login from "$pages/Login"; import LoginSuccess from "$pages/LoginSuccess"; +import NoteEdit from "$pages/NoteEdit"; import NoteNew from "$pages/NoteNew"; import Notes from "$pages/Notes"; import NoteView from "$pages/NoteView"; @@ -97,6 +98,7 @@ const App: Component = () => { + diff --git a/web/src/components/NoteEditor.tsx b/web/src/components/NoteEditor.tsx index 28c1e39..5fef9ba 100644 --- a/web/src/components/NoteEditor.tsx +++ b/web/src/components/NoteEditor.tsx @@ -1,17 +1,30 @@ /* eslint-disable solid/no-innerhtml */ +import { EditorToolbar } from "$components/notes/EditorToolbar"; import { api } from "$lib/api"; +import type { Note } from "$lib/model"; import { toast } from "$lib/toast"; import { Button } from "$ui/Button"; +import rehypeShiki from "@shikijs/rehype"; +import { Textcomplete } from "@textcomplete/core"; +import { TextareaEditor } from "@textcomplete/textarea"; import rehypeExternalLinks from "rehype-external-links"; -import rehypeSanitize from "rehype-sanitize"; import rehypeStringify from "rehype-stringify"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; -import { createEffect, createSignal, Show } from "solid-js"; +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { unified } from "unified"; +export type EditorFont = "neon" | "argon" | "krypton" | "radon" | "xenon" | "jetbrains" | "google"; + type NoteEditorProps = { noteId?: string; initialTitle?: string; initialContent?: string }; +type EditorTab = "write" | "preview"; + +const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use( + rehypeExternalLinks, + { target: "_blank", rel: ["nofollow"] }, +).use(rehypeStringify); + export function NoteEditor(props: NoteEditorProps) { const [title, setTitle] = createSignal(props.initialTitle || ""); const [content, setContent] = createSignal(props.initialContent || ""); @@ -19,22 +32,121 @@ export function NoteEditor(props: NoteEditorProps) { const [tags, setTags] = createSignal(""); const [visibilityType, setVisibilityType] = createSignal("Private"); const [sharedWith, setSharedWith] = createSignal(""); + const [showLineNumbers, setShowLineNumbers] = createSignal(true); + const [editorFont, setEditorFont] = createSignal("jetbrains"); + const [activeTab, setActiveTab] = createSignal("write"); + + let textareaRef: HTMLTextAreaElement | undefined; + let textcomplete: Textcomplete | undefined; - const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeExternalLinks, { - target: "_blank", - rel: ["nofollow"], - }).use(rehypeStringify); + const [allNotes] = createResource(async (): Promise => { + const res = await api.getNotes(); + if (!res.ok) return []; + return res.json(); + }); + + const updatePreviewContent = async () => { + const file = await processor.process(content()); + setPreview(String(file)); + }; createEffect(() => { - processor.process(content()).then((file) => setPreview(String(file))).catch((e) => console.error(e)); + updatePreviewContent().catch(e => console.error(`Preview error: ${e instanceof Error ? e.message : e}`)); + }); + + onMount(() => { + if (!textareaRef) return; + + const editor = new TextareaEditor(textareaRef); + textcomplete = new Textcomplete(editor, [{ + match: /\[\[([^\]]*)/, + search: (term: string, callback: (results: string[]) => void) => { + const notes = allNotes() ?? []; + const filtered = notes.filter((n) => n.title.toLowerCase().includes(term.toLowerCase())).slice(0, 10).map((n) => + n.title + ); + callback(filtered); + }, + replace: (title: string) => `[[${title}]]`, + template: (title: string) => title, + }]); + }); + + onCleanup(() => { + textcomplete?.destroy(); + }); + + const fontValue = createMemo(() => { + switch (editorFont()) { + case "neon": + return "Monaspace Neon"; + case "argon": + return "Monaspace Argon"; + case "krypton": + return "Monaspace Krypton"; + case "radon": + return "Monaspace Radon"; + case "xenon": + return "Monaspace Xenon"; + case "google": + return "Google Sans Code"; + default: + return "JetBrains Mono"; + } }); + const insertAtCursor = (before: string, after: string = "") => { + if (!textareaRef) return; + const start = textareaRef.selectionStart; + const end = textareaRef.selectionEnd; + const text = content(); + const selectedText = text.substring(start, end); + const newText = text.substring(0, start) + before + selectedText + after + text.substring(end); + setContent(newText); + setTimeout(() => { + textareaRef!.focus(); + textareaRef!.setSelectionRange(start + before.length, start + before.length + selectedText.length); + }, 0); + }; + + const handleBold = () => insertAtCursor("**", "**"); + const handleItalic = () => insertAtCursor("*", "*"); + const handleLink = () => insertAtCursor("[", "](url)"); + const handleCode = () => insertAtCursor("`", "`"); + const handleCodeBlock = () => insertAtCursor("```\n", "\n```"); + const handleWikilink = () => insertAtCursor("[[", "]]"); + const handleList = () => insertAtCursor("- "); + + const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { + const prefix = "#".repeat(level) + " "; + insertAtCursor(prefix); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey) { + switch (e.key) { + case "b": + e.preventDefault(); + handleBold(); + break; + case "i": + e.preventDefault(); + handleItalic(); + break; + case "k": + e.preventDefault(); + handleLink(); + break; + } + } + }; + const handleSubmit = async (e: Event) => { e.preventDefault(); try { let visibility; if (visibilityType() === "SharedWith") { - visibility = { type: "SharedWith", content: sharedWith().split(",").map(s => s.trim()).filter(s => s) }; + visibility = { type: "SharedWith", content: sharedWith().split(",").map((s) => s.trim()).filter((s) => s) }; } else { visibility = { type: visibilityType() }; } @@ -42,11 +154,12 @@ export function NoteEditor(props: NoteEditorProps) { const payload = { title: title(), body: content(), - tags: tags().split(",").map(t => t.trim()).filter(t => t), + tags: tags().split(",").map((t) => t.trim()).filter((t) => t), visibility, }; - const res = await api.post("/notes", payload); + const res = props.noteId ? await api.updateNote(props.noteId, payload) : await api.post("/notes", payload); + if (res.ok) { toast.success("Note saved!"); if (!props.noteId) { @@ -65,79 +178,156 @@ export function NoteEditor(props: NoteEditorProps) { } }; + const lineNumbers = () => Array.from({ length: content().split("\n").length }, (_, i) => i + 1); + return ( - - - Note Editor + + + {props.noteId ? "Edit Note" : "New Note"} - + + + setShowLineNumbers(e.target.checked)} + class="rounded bg-slate-700 border-slate-600" /> + Line numbers + + setEditorFont(e.target.value as EditorFont)} + class="bg-slate-800 border-slate-700 text-white text-sm rounded px-2 py-1"> + JetBrains Mono + Monaspace Neon + Monaspace Argon + Monaspace Krypton + Monaspace Radon + Monaspace Xenon + Google Sans Code + + + + + + + Title + setTitle(e.target.value)} + 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" + placeholder="Note Title" + required /> + + + + + setActiveTab("write")} + class={`px-4 py-2 text-sm font-medium transition-colors ${ + activeTab() === "write" + ? "text-white border-b-2 border-blue-500 -mb-px" + : "text-slate-400 hover:text-white" + }`}> + + Write + + setActiveTab("preview")} + class={`px-4 py-2 text-sm font-medium transition-colors ${ + activeTab() === "preview" + ? "text-white border-b-2 border-blue-500 -mb-px" + : "text-slate-400 hover:text-white" + }`}> + + Preview + + + + + + + + + + {(num) => {num}} + + + setContent(e.target.value)} + style={{ "font-family": fontValue() }} + onKeyDown={handleKeyDown} + class={`flex-1 bg-slate-800 text-white p-3 text-sm leading-relaxed resize-none focus:outline-none min-h-[400px]`} + placeholder="# Heading + +Write your thoughts... + +Link to other notes with [[Title]]" /> + + + + + + + + + + - Title + Tags setTitle(e.target.value)} - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2" - placeholder="Note Title" - required /> + value={tags()} + onInput={(e) => setTags(e.target.value)} + class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2" + placeholder="rust, learning, ..." /> - - Content (Markdown + [[WikiLinks]]) - setContent(e.target.value)} - class="w-full h-96 bg-gray-800 border-gray-700 text-white rounded p-2 font-mono text-sm leading-relaxed" - placeholder="# Heading Write your thoughts... Link to other notes with [[Title]]" /> + Visibility + setVisibilityType(e.target.value)} + class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2"> + Private + Unlisted + Public + Shared With... + + - - - Tags - setTags(e.target.value)} - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2" - placeholder="rust, learning, ..." /> - - - Visibility - setVisibilityType(e.target.value)} - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2"> - Private - Unlisted - Public - Shared With... - - + + + Share with DIDs (comma separated) + setSharedWith(e.target.value)} + class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2" + placeholder="did:plc:..., did:plc:..." /> + - - - Share with DIDs (comma separated) - setSharedWith(e.target.value)} - class="w-full bg-gray-800 border-gray-700 text-white rounded p-2" - placeholder="did:plc:..., did:plc:..." /> - - - + Save Note - - - - - Preview - {} - - + + ); } diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 1e7f61e..3df53b1 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -16,8 +16,9 @@ export const Header: Component = () => { Malfestio - Decks - Review + Decks + Notes + Review Discovery Library Feed diff --git a/web/src/components/notes/BacklinksPanel.tsx b/web/src/components/notes/BacklinksPanel.tsx new file mode 100644 index 0000000..0e83bf2 --- /dev/null +++ b/web/src/components/notes/BacklinksPanel.tsx @@ -0,0 +1,40 @@ +import type { Note } from "$lib/model"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { For, Show } from "solid-js"; + +type BacklinksPanelProps = { backlinks: Note[] }; + +/** + * Panel showing notes that link TO the current note (incoming references) + */ +export const BacklinksPanel: Component = (props) => { + return ( + + + Backlinks + 0}> + ({props.backlinks.length}) + + + 0} fallback={No incoming links}> + + + {(note) => ( + + + + {note.title || "Untitled"} + + + )} + + + + + ); +}; + +export default BacklinksPanel; diff --git a/web/src/components/notes/EditorToolbar.tsx b/web/src/components/notes/EditorToolbar.tsx new file mode 100644 index 0000000..b8b1e1c --- /dev/null +++ b/web/src/components/notes/EditorToolbar.tsx @@ -0,0 +1,93 @@ +import { type Component, For } from "solid-js"; + +type ToolbarButton = { id: string; icon: string; label: string; shortcut?: string; action: () => void }; + +type EditorToolbarProps = { + onBold: () => void; + onItalic: () => void; + onHeading: (level: 1 | 2 | 3 | 4 | 5 | 6) => void; + onLink: () => void; + onCode: () => void; + onCodeBlock: () => void; + onWikilink: () => void; + onList: () => void; + class?: string; +}; + +const ToolbarButtonComponent: Component<{ btn: ToolbarButton }> = (props) => ( + props.btn.action()} + title={props.btn.shortcut ? `${props.btn.label} (${props.btn.shortcut})` : props.btn.label} + class="p-2 rounded hover:bg-slate-700 text-slate-400 hover:text-white transition-colors"> + + +); + +export const EditorToolbar: Component = (props) => { + const buttons: ToolbarButton[] = [ + { + id: "bold", + icon: "i-ri-bold", + label: "Bold", + shortcut: "⌘B", + action: () => props.onBold(), + }, + { id: "italic", icon: "i-ri-italic", label: "Italic", shortcut: "⌘I", action: () => props.onItalic() }, + { + id: "h1", + icon: "i-ri-h-1", + label: "Heading 1", + action: () => props.onHeading(1), + }, + { id: "h2", icon: "i-ri-h-2", label: "Heading 2", action: () => props.onHeading(2) }, + { id: "h3", icon: "i-ri-h-3", label: "Heading 3", action: () => props.onHeading(3) }, + { id: "h4", icon: "i-ri-h-4", label: "Heading 4", action: () => props.onHeading(4) }, + { + id: "h5", + icon: "i-ri-h-5", + label: "Heading 5", + action: () => props.onHeading(5), + }, + { id: "h6", icon: "i-ri-h-6", label: "Heading 6", action: () => props.onHeading(6) }, + { + id: "link", + icon: "i-ri-link", + label: "Link", + shortcut: "⌘K", + action: () => props.onLink(), + }, + { id: "code", icon: "i-ri-code-line", label: "Inline Code", action: () => props.onCode() }, + { id: "codeblock", icon: "i-ri-code-box-line", label: "Code Block", action: () => props.onCodeBlock() }, + { id: "wikilink", icon: "i-ri-links-line", label: "Wikilink", action: () => props.onWikilink() }, + { + id: "list", + icon: "i-ri-list-unordered", + label: "Bullet List", + action: () => props.onList(), + }, + ]; + + return ( + + + + + + + + {(btn) => } + + + + {(btn) => } + + + + + + + ); +}; + +export default EditorToolbar; diff --git a/web/src/components/notes/OutlinePanel.tsx b/web/src/components/notes/OutlinePanel.tsx new file mode 100644 index 0000000..c71c6ce --- /dev/null +++ b/web/src/components/notes/OutlinePanel.tsx @@ -0,0 +1,43 @@ +import type { Heading } from "$lib/wikilink"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { For, Show } from "solid-js"; + +type OutlinePanelProps = { headings: Heading[]; onHeadingClick?: (id: string) => void }; + +/** + * Table of contents panel showing document outline from markdown headings + */ +export const OutlinePanel: Component = (props) => { + const handleClick = (id: string, e: MouseEvent) => { + e.preventDefault(); + props.onHeadingClick?.(id); + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }; + + return ( + + Outline + 0} fallback={No headings found}> + + + {(heading) => ( + handleClick(heading.id, e)} + class="block text-sm text-slate-300 hover:text-blue-400 transition-colors truncate" + style={{ "padding-left": `${(heading.level - 1) * 12}px` }}> + {heading.text} + + )} + + + + + ); +}; + +export default OutlinePanel; diff --git a/web/src/components/notes/WikilinksPanel.tsx b/web/src/components/notes/WikilinksPanel.tsx new file mode 100644 index 0000000..2fa4143 --- /dev/null +++ b/web/src/components/notes/WikilinksPanel.tsx @@ -0,0 +1,62 @@ +import type { Note } from "$lib/model"; +import type { WikiLink } from "$lib/wikilink"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { For, Show } from "solid-js"; + +type WikilinksPanelProps = { links: WikiLink[]; notes: Note[]; resolveNote: (title: string) => Note | null }; + +/** + * Panel showing outgoing wikilinks from the current note + * + * Displays links with status (resolved/unresolved) + */ +export const WikilinksPanel: Component = (props) => { + const uniqueTitles = () => { + const seen = new Set(); + return props.links.filter((link) => { + const normalized = link.title.toLowerCase(); + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); + }; + + return ( + + Wikilinks + 0} fallback={No outgoing links}> + + + {(link) => { + const resolved = () => props.resolveNote(link.title); + return ( + + + + {link.title} + + }> + {(note) => ( + + + {link.alias || link.title} + + )} + + + ); + }} + + + + + ); +}; + +export default WikilinksPanel; diff --git a/web/src/components/notes/tests/BacklinksPanel.test.tsx b/web/src/components/notes/tests/BacklinksPanel.test.tsx new file mode 100644 index 0000000..bf4ce64 --- /dev/null +++ b/web/src/components/notes/tests/BacklinksPanel.test.tsx @@ -0,0 +1,64 @@ +import type { Note } from "$lib/model"; +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BacklinksPanel } from "../BacklinksPanel"; + +vi.mock( + "@solidjs/router", + () => ({ A: (props: { href: string; children: JSX.Element }) => {props.children} }), +); + +describe("BacklinksPanel", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockBacklinks: Note[] = [{ + id: "note-1", + owner_did: "did:plc:test", + title: "First Backlink", + body: "Content", + tags: [], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, { + id: "note-2", + owner_did: "did:plc:test", + title: "Second Backlink", + body: "Content", + tags: [], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }]; + + it("renders heading title", () => { + render(() => ); + expect(screen.getByText("Backlinks")).toBeInTheDocument(); + }); + + it("shows empty state when no backlinks", () => { + render(() => ); + expect(screen.getByText("No incoming links")).toBeInTheDocument(); + }); + + it("renders all backlinks", () => { + render(() => ); + expect(screen.getByText("First Backlink")).toBeInTheDocument(); + expect(screen.getByText("Second Backlink")).toBeInTheDocument(); + }); + + it("displays backlink count", () => { + render(() => ); + expect(screen.getByText("(2)")).toBeInTheDocument(); + }); + + it("renders backlinks as navigation links", () => { + render(() => ); + const firstLink = screen.getByRole("link", { name: /First Backlink/ }); + expect(firstLink).toHaveAttribute("href", "/notes/note-1"); + }); +}); diff --git a/web/src/components/notes/tests/EditorToolbar.test.tsx b/web/src/components/notes/tests/EditorToolbar.test.tsx new file mode 100644 index 0000000..2c078b4 --- /dev/null +++ b/web/src/components/notes/tests/EditorToolbar.test.tsx @@ -0,0 +1,81 @@ +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorToolbar } from "../EditorToolbar"; + +describe("EditorToolbar", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockHandlers = { + onBold: vi.fn(), + onItalic: vi.fn(), + onHeading: vi.fn(), + onLink: vi.fn(), + onCode: vi.fn(), + onCodeBlock: vi.fn(), + onWikilink: vi.fn(), + onList: vi.fn(), + }; + + it("renders all toolbar buttons", () => { + render(() => ); + + expect(screen.getByTitle(/Bold/)).toBeInTheDocument(); + expect(screen.getByTitle(/Italic/)).toBeInTheDocument(); + expect(screen.getByTitle(/Heading 1/)).toBeInTheDocument(); + expect(screen.getByTitle(/Heading 2/)).toBeInTheDocument(); + expect(screen.getByTitle(/Heading 3/)).toBeInTheDocument(); + expect(screen.getByTitle(/Link/)).toBeInTheDocument(); + expect(screen.getByTitle(/Inline Code/)).toBeInTheDocument(); + expect(screen.getByTitle(/Code Block/)).toBeInTheDocument(); + expect(screen.getByTitle(/Wikilink/)).toBeInTheDocument(); + expect(screen.getByTitle(/Bullet List/)).toBeInTheDocument(); + }); + + it("calls onBold when bold button clicked", async () => { + render(() => ); + + const boldBtn = screen.getByTitle(/Bold/); + boldBtn.click(); + + expect(mockHandlers.onBold).toHaveBeenCalledTimes(1); + }); + + it("calls onItalic when italic button clicked", async () => { + render(() => ); + + const italicBtn = screen.getByTitle(/Italic/); + italicBtn.click(); + + expect(mockHandlers.onItalic).toHaveBeenCalledTimes(1); + }); + + it("calls onHeading with level when heading button clicked", async () => { + render(() => ); + + screen.getByTitle(/Heading 1/).click(); + expect(mockHandlers.onHeading).toHaveBeenCalledWith(1); + + screen.getByTitle(/Heading 2/).click(); + expect(mockHandlers.onHeading).toHaveBeenCalledWith(2); + + screen.getByTitle(/Heading 3/).click(); + expect(mockHandlers.onHeading).toHaveBeenCalledWith(3); + }); + + it("calls onLink when link button clicked", async () => { + render(() => ); + + screen.getByTitle(/Link/).click(); + expect(mockHandlers.onLink).toHaveBeenCalledTimes(1); + }); + + it("calls onWikilink when wikilink button clicked", async () => { + render(() => ); + + screen.getByTitle(/Wikilink/).click(); + expect(mockHandlers.onWikilink).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/components/notes/tests/OutlinePanel.test.tsx b/web/src/components/notes/tests/OutlinePanel.test.tsx new file mode 100644 index 0000000..0633afa --- /dev/null +++ b/web/src/components/notes/tests/OutlinePanel.test.tsx @@ -0,0 +1,66 @@ +import type { Heading } from "$lib/wikilink"; +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OutlinePanel } from "../OutlinePanel"; + +vi.mock( + "@solidjs/router", + () => ({ + A: (props: { href: string; children: JSX.Element; onClick?: (e: MouseEvent) => void }) => ( + props.onClick?.(e)}> + {props.children} + + ), + }), +); + +describe("OutlinePanel", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockHeadings: Heading[] = [ + { level: 1, text: "Introduction", id: "introduction" }, + { level: 2, text: "Background", id: "background" }, + { level: 2, text: "Methods", id: "methods" }, + { level: 3, text: "Sub Methods", id: "sub-methods" }, + { level: 1, text: "Conclusion", id: "conclusion" }, + ]; + + it("renders heading title", () => { + render(() => ); + expect(screen.getByText("Outline")).toBeInTheDocument(); + }); + + it("shows empty state when no headings", () => { + render(() => ); + expect(screen.getByText("No headings found")).toBeInTheDocument(); + }); + + it("renders all headings", () => { + render(() => ); + expect(screen.getByText("Introduction")).toBeInTheDocument(); + expect(screen.getByText("Background")).toBeInTheDocument(); + expect(screen.getByText("Methods")).toBeInTheDocument(); + expect(screen.getByText("Sub Methods")).toBeInTheDocument(); + expect(screen.getByText("Conclusion")).toBeInTheDocument(); + }); + + it("renders headings as links", () => { + render(() => ); + const introLink = screen.getByRole("link", { name: "Introduction" }); + expect(introLink).toHaveAttribute("href", "#introduction"); + }); + + it("calls onHeadingClick when heading clicked", () => { + const onClick = vi.fn(); + render(() => ); + const introLink = screen.getByRole("link", { name: "Introduction" }); + introLink.click(); + expect(onClick).toHaveBeenCalledWith("introduction"); + }); +}); diff --git a/web/src/components/notes/tests/WikilinksPanel.test.tsx b/web/src/components/notes/tests/WikilinksPanel.test.tsx new file mode 100644 index 0000000..7fe20cd --- /dev/null +++ b/web/src/components/notes/tests/WikilinksPanel.test.tsx @@ -0,0 +1,73 @@ +import type { Note } from "$lib/model"; +import type { WikiLink } from "$lib/wikilink"; +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WikilinksPanel } from "../WikilinksPanel"; + +vi.mock( + "@solidjs/router", + () => ({ A: (props: { href: string; children: JSX.Element }) => {props.children} }), +); + +describe("WikilinksPanel", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockNotes: Note[] = [{ + id: "note-1", + owner_did: "did:plc:test", + title: "Existing Note", + body: "Content", + tags: [], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }]; + + const mockLinks: WikiLink[] = [{ raw: "[[Existing Note]]", title: "Existing Note", start: 0, end: 17 }, { + raw: "[[Missing Note]]", + title: "Missing Note", + start: 20, + end: 36, + }, { raw: "[[Aliased|Display]]", title: "Aliased", alias: "Display", start: 40, end: 59 }]; + + const resolveNote = (title: string) => mockNotes.find((n) => n.title === title) ?? null; + + it("renders heading title", () => { + render(() => null} />); + expect(screen.getByText("Wikilinks")).toBeInTheDocument(); + }); + + it("shows empty state when no links", () => { + render(() => null} />); + expect(screen.getByText("No outgoing links")).toBeInTheDocument(); + }); + + it("renders resolved links as navigation links", () => { + render(() => ); + const existingLink = screen.getByRole("link", { name: /Existing Note/ }); + expect(existingLink).toHaveAttribute("href", "/notes/note-1"); + }); + + it("renders unresolved links with strikethrough", () => { + render(() => ); + const missingLink = screen.getByText("Missing Note"); + expect(missingLink).toHaveClass("line-through"); + }); + + it("deduplicates links by title", () => { + const duplicateLinks: WikiLink[] = [{ raw: "[[Same]]", title: "Same", start: 0, end: 8 }, { + raw: "[[Same]]", + title: "Same", + start: 10, + end: 18, + }]; + + render(() => null} />); + const sameLinks = screen.getAllByText("Same"); + expect(sameLinks).toHaveLength(1); + }); +}); diff --git a/web/src/fonts.d.ts b/web/src/fonts.d.ts index eed6d67..95e3466 100644 --- a/web/src/fonts.d.ts +++ b/web/src/fonts.d.ts @@ -1,2 +1,9 @@ // CSS-only packages without TypeScript declarations declare module "@fontsource-variable/figtree"; +declare module "@fontsource-variable/jetbrains-mono"; +declare module "@fontsource/google-sans-code"; +declare module "@fontsource/monaspace-argon"; +declare module "@fontsource/monaspace-krypton"; +declare module "@fontsource/monaspace-neon"; +declare module "@fontsource/monaspace-radon"; +declare module "@fontsource/monaspace-xenon"; diff --git a/web/src/index.css b/web/src/index.css index 9a290ea..22dcb5b 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "@tailwindcss/forms"; @plugin "@egoist/tailwindcss-icons"; @font-face { @@ -14,6 +15,16 @@ --font-display: "Mattern", serif; --font-body: "Figtree Variable", sans-serif; + /* Editor Fonts */ + --font-code-neon: "Monaspace Neon", monospace; + --font-code-argon: "Monaspace Argon", monospace; + --font-code-krypton: "Monaspace Krypton", monospace; + --font-code-radon: "Monaspace Radon", monospace; + --font-code-xenon: "Monaspace Xenon", monospace; + --font-code-jetbrains: "JetBrains Mono Variable", monospace; + --font-code-google: "Google Sans Code", monospace; + --font-code: var(--font-code-jetbrains); + /* Spacing Tokens - 16px grid */ /* FIXME: These conflict with, and break existing usage of classes like max-w-4xl @@ -187,3 +198,42 @@ h6 { button { @apply cursor-pointer disabled:cursor-not-allowed disabled:opacity-50; } + +/* Textcomplete dropdown styling */ +.textcomplete-dropdown { + background-color: var(--layer-02); + border: 1px solid var(--layer-03); + border-radius: 0.5rem; + box-shadow: var(--shadow-04); + overflow: hidden; + z-index: 50; +} + +.textcomplete-dropdown .textcomplete-item { + padding: 0.5rem 0.75rem; + cursor: pointer; + color: #e2e8f0; +} + +.textcomplete-dropdown .textcomplete-item:hover, +.textcomplete-dropdown .textcomplete-item.active { + background-color: var(--layer-03); +} + +/* Wikilink styling in rendered content */ +.wikilink { + color: #60a5fa; + text-decoration: underline; + text-decoration-color: rgba(96, 165, 250, 0.3); + transition: color var(--duration-fast) var(--easing-standard); +} + +.wikilink:hover { + color: #93c5fd; + text-decoration-color: rgba(147, 197, 253, 0.5); +} + +.wikilink-broken { + color: #fbbf24; + text-decoration: line-through; +} diff --git a/web/src/index.tsx b/web/src/index.tsx index 567d96f..2e01298 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,5 +1,12 @@ /* @refresh reload */ import "@fontsource-variable/figtree"; +import "@fontsource-variable/jetbrains-mono"; +import "@fontsource/google-sans-code"; +import "@fontsource/monaspace-argon"; +import "@fontsource/monaspace-krypton"; +import "@fontsource/monaspace-neon"; +import "@fontsource/monaspace-radon"; +import "@fontsource/monaspace-xenon"; import { render } from "solid-js/web"; import "./index.css"; import App from "./App.tsx"; diff --git a/web/src/lib/tests/wikilink.test.ts b/web/src/lib/tests/wikilink.test.ts new file mode 100644 index 0000000..116c758 --- /dev/null +++ b/web/src/lib/tests/wikilink.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; +import type { Note } from "../model"; +import { + extractHeadings, + extractWikilinkTitles, + findBacklinks, + parseWikilinks, + renderWikilinks, + resolveWikilink, + slugify, +} from "../wikilink"; + +const mockNotes: Note[] = [{ + id: "note-1", + owner_did: "did:plc:test", + title: "First Note", + body: "Content with [[Second Note]] link", + tags: ["test"], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}, { + id: "note-2", + owner_did: "did:plc:test", + title: "Second Note", + body: "This links to [[First Note]] and [[Third Note]]", + tags: ["test"], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}, { + id: "note-3", + owner_did: "did:plc:test", + title: "Third Note", + body: "No wikilinks here", + tags: [], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}]; + +describe("parseWikilinks", () => { + it("extracts simple wikilinks", () => { + const text = "Check out [[My Note]] for more info"; + const links = parseWikilinks(text); + expect(links).toHaveLength(1); + expect(links[0].title).toBe("My Note"); + expect(links[0].alias).toBeUndefined(); + }); + + it("extracts wikilinks with aliases", () => { + const text = "See [[Long Note Title|short alias]] here"; + const links = parseWikilinks(text); + expect(links).toHaveLength(1); + expect(links[0].title).toBe("Long Note Title"); + expect(links[0].alias).toBe("short alias"); + }); + + it("extracts multiple wikilinks", () => { + const text = "Both [[Note A]] and [[Note B|B]] are relevant"; + const links = parseWikilinks(text); + expect(links).toHaveLength(2); + expect(links[0].title).toBe("Note A"); + expect(links[1].title).toBe("Note B"); + expect(links[1].alias).toBe("B"); + }); + + it("returns empty array for no wikilinks", () => { + const text = "Just regular text without links"; + const links = parseWikilinks(text); + expect(links).toHaveLength(0); + }); + + it("includes position information", () => { + const text = "Start [[Link]] end"; + const links = parseWikilinks(text); + expect(links[0].start).toBe(6); + expect(links[0].end).toBe(14); + expect(links[0].raw).toBe("[[Link]]"); + }); +}); + +describe("extractWikilinkTitles", () => { + it("returns unique titles", () => { + const text = "[[Note A]] and [[Note B]] and [[Note A]] again"; + const titles = extractWikilinkTitles(text); + expect(titles).toHaveLength(2); + expect(titles).toContain("Note A"); + expect(titles).toContain("Note B"); + }); +}); + +describe("resolveWikilink", () => { + it("resolves exact match", () => { + const note = resolveWikilink("First Note", mockNotes); + expect(note?.id).toBe("note-1"); + }); + + it("resolves case-insensitively", () => { + const note = resolveWikilink("FIRST NOTE", mockNotes); + expect(note?.id).toBe("note-1"); + }); + + it("returns null for unresolved links", () => { + const note = resolveWikilink("Nonexistent Note", mockNotes); + expect(note).toBeNull(); + }); + + it("trims whitespace", () => { + const note = resolveWikilink(" First Note ", mockNotes); + expect(note?.id).toBe("note-1"); + }); +}); + +describe("findBacklinks", () => { + it("finds notes linking to target", () => { + const backlinks = findBacklinks("Second Note", mockNotes); + expect(backlinks).toHaveLength(1); + expect(backlinks[0].id).toBe("note-1"); + }); + + it("finds multiple backlinks", () => { + const backlinks = findBacklinks("First Note", mockNotes); + expect(backlinks).toHaveLength(1); + expect(backlinks[0].id).toBe("note-2"); + }); + + it("returns empty for notes with no backlinks", () => { + const backlinks = findBacklinks("Third Note", mockNotes); + expect(backlinks).toHaveLength(1); + expect(backlinks[0].id).toBe("note-2"); + }); + + it("is case-insensitive", () => { + const backlinks = findBacklinks("SECOND NOTE", mockNotes); + expect(backlinks).toHaveLength(1); + }); +}); + +describe("slugify", () => { + it("converts to lowercase", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("removes special characters", () => { + expect(slugify("Hello, World!")).toBe("hello-world"); + }); + + it("collapses multiple dashes", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("handles empty string", () => { + expect(slugify("")).toBe(""); + }); +}); + +describe("extractHeadings", () => { + it("extracts H1-H6 headings", () => { + const markdown = `# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6`; + const headings = extractHeadings(markdown); + expect(headings).toHaveLength(6); + expect(headings[0]).toEqual({ level: 1, text: "Heading 1", id: "heading-1" }); + expect(headings[5]).toEqual({ level: 6, text: "Heading 6", id: "heading-6" }); + }); + + it("handles special characters in headings", () => { + const markdown = "## Hello, World!"; + const headings = extractHeadings(markdown); + expect(headings[0].id).toBe("hello-world"); + }); + + it("returns empty array for no headings", () => { + const markdown = "Just regular text"; + expect(extractHeadings(markdown)).toHaveLength(0); + }); +}); + +describe("renderWikilinks", () => { + it("renders resolved links as anchors", () => { + const text = "See [[My Note]] here"; + const result = renderWikilinks(text, () => "/notes/123"); + expect(result).toBe("See My Note here"); + }); + + it("renders unresolved links as spans", () => { + const text = "See [[Missing]] here"; + const result = renderWikilinks(text, () => null); + expect(result).toBe("See Missing here"); + }); + + it("uses alias for display text when provided", () => { + const text = "See [[Long Title|alias]] here"; + const result = renderWikilinks(text, () => "/notes/123"); + expect(result).toBe("See alias here"); + }); +}); diff --git a/web/src/lib/wikilink.ts b/web/src/lib/wikilink.ts new file mode 100644 index 0000000..8e4b011 --- /dev/null +++ b/web/src/lib/wikilink.ts @@ -0,0 +1,154 @@ +/** + * Wikilink parsing, resolution, and heading extraction utilities + * Supports [[Note Title]] syntax for linking between notes + */ + +import type { Note } from "./model"; + +/** Regex pattern to match wikilinks: [[Title]] or [[Title|Alias]] */ +const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +/** Regex pattern to match markdown headings (H1-H6) */ +const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/gm; + +/** + * Represents a parsed wikilink + */ +export type WikiLink = { + /** The full match including brackets */ + raw: string; + /** The title of the linked note */ + title: string; + /** Optional display alias */ + alias?: string; + /** Start index in the source text */ + start: number; + /** End index in the source text */ + end: number; +}; + +/** + * Represents a heading extracted from markdown + */ +export type Heading = { + /** Heading level (1-6) */ + level: number; + /** Heading text content */ + text: string; + /** Slugified ID for anchor links */ + id: string; +}; + +/** + * Parse all wikilinks from markdown text + * + * @param text - Markdown text containing wikilinks + * @returns Array of parsed wikilinks with position info + */ +export function parseWikilinks(text: string): WikiLink[] { + const links: WikiLink[] = []; + let match: RegExpExecArray | null; + + // Reset regex state + WIKILINK_PATTERN.lastIndex = 0; + + while ((match = WIKILINK_PATTERN.exec(text)) !== null) { + links.push({ + raw: match[0], + title: match[1].trim(), + alias: match[2]?.trim(), + start: match.index, + end: match.index + match[0].length, + }); + } + + return links; +} + +/** + * Extract unique wikilink titles from text + * + * @param text - Markdown text containing wikilinks + * @returns Array of unique linked note titles + */ +export function extractWikilinkTitles(text: string): string[] { + const links = parseWikilinks(text); + const titles = new Set(links.map((l) => l.title)); + return Array.from(titles); +} + +/** + * Resolve a wikilink title to a note (case-insensitive) + * + * @param title - The wikilink title to resolve + * @param notes - Array of notes to search + * @returns The matching note or null if not found + */ +export function resolveWikilink(title: string, notes: Note[]): Note | null { + const normalizedTitle = title.toLowerCase().trim(); + return notes.find((n) => n.title.toLowerCase().trim() === normalizedTitle) ?? null; +} + +/** + * Find all notes that contain wikilinks to a target note + * + * @param noteTitle - Title of the target note + * @param allNotes - Array of all notes to search + * @returns Array of notes that link to the target + */ +export function findBacklinks(noteTitle: string, allNotes: Note[]): Note[] { + const normalizedTitle = noteTitle.toLowerCase().trim(); + return allNotes.filter((note) => { + const links = extractWikilinkTitles(note.body); + return links.some((link) => link.toLowerCase().trim() === normalizedTitle); + }); +} + +/** + * Convert heading text to a URL-safe slug + * + * @param text - Heading text to slugify + * @returns Slugified string suitable for anchor IDs + */ +export function slugify(text: string): string { + return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-"); +} + +/** + * Extract all headings from markdown text + * + * @param markdown - Markdown text to parse + * @returns Array of headings with level, text, and id + */ +export function extractHeadings(markdown: string): Heading[] { + const headings: Heading[] = []; + let match: RegExpExecArray | null; + + HEADING_PATTERN.lastIndex = 0; + + while ((match = HEADING_PATTERN.exec(markdown)) !== null) { + const level = match[1].length as 1 | 2 | 3 | 4 | 5 | 6; + const text = match[2].trim(); + headings.push({ level, text, id: slugify(text) }); + } + + return headings; +} + +/** + * Render wikilinks as HTML anchor tags + * + * @param text - Text containing wikilinks + * @param resolveHref - Function to resolve a title to an href + * @returns Text with wikilinks replaced by anchor tags + */ +export function renderWikilinks(text: string, resolveHref: (title: string) => string | null): string { + const href = resolveHref(text.trim()); + return text.replace( + WIKILINK_PATTERN, + (_, title: string, alias?: string) => + href + ? `${(alias || title).trim()}` + : `${(alias || title).trim()}`, + ); +} diff --git a/web/src/pages/NoteEdit.tsx b/web/src/pages/NoteEdit.tsx new file mode 100644 index 0000000..c1e4af1 --- /dev/null +++ b/web/src/pages/NoteEdit.tsx @@ -0,0 +1,41 @@ +import { NoteEditor } from "$components/NoteEditor"; +import { api } from "$lib/api"; +import type { Note } from "$lib/model"; +import { A, useParams } from "@solidjs/router"; +import { createResource, Show } from "solid-js"; + +const NoteEdit = () => { + const params = useParams<{ id: string }>(); + + const [note] = createResource(() => params.id, async (id: string): Promise => { + const res = await api.getNote(id); + if (!res.ok) return null; + return res.json(); + }); + + return ( + + + + + }> + + Note not found + This note may have been deleted. + ← Back to Notes + + }> + {(n) => } + + + + ); +}; + +export default NoteEdit; diff --git a/web/src/pages/NoteView.tsx b/web/src/pages/NoteView.tsx index 3f8edd0..202e3f8 100644 --- a/web/src/pages/NoteView.tsx +++ b/web/src/pages/NoteView.tsx @@ -1,16 +1,27 @@ /* eslint-disable solid/no-innerhtml */ +import { BacklinksPanel } from "$components/notes/BacklinksPanel"; +import { OutlinePanel } from "$components/notes/OutlinePanel"; +import { WikilinksPanel } from "$components/notes/WikilinksPanel"; import { Button } from "$components/ui/Button"; import { api } from "$lib/api"; import type { Note } from "$lib/model"; +import { + extractHeadings, + findBacklinks, + type Heading, + parseWikilinks, + resolveWikilink, + type WikiLink, +} from "$lib/wikilink"; import { Tag } from "$ui/Tag"; +import rehypeShiki from "@shikijs/rehype"; import { A, useParams } from "@solidjs/router"; import rehypeExternalLinks from "rehype-external-links"; -import rehypeSanitize from "rehype-sanitize"; import rehypeStringify from "rehype-stringify"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import type { Component } from "solid-js"; -import { createEffect, createResource, createSignal, For, Show } from "solid-js"; +import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; import { unified } from "unified"; const NoteView: Component = () => { @@ -20,14 +31,25 @@ const NoteView: Component = () => { if (!res.ok) return null; return res.json(); }); + + const [allNotes] = createResource(async (): Promise => { + const res = await api.getNotes(); + if (!res.ok) return []; + return res.json(); + }); + const [renderedContent, setRenderedContent] = createSignal(""); + const [headings, setHeadings] = createSignal([]); + const [wikilinks, setWikilinks] = createSignal([]); - const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeExternalLinks, { - target: "_blank", - rel: ["nofollow"], - }).use(rehypeStringify); + const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { + theme: "vitesse-dark", + defaultLanguage: "text", + }).use(rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }).use(rehypeStringify); const updateRenderedContent = async (n: Note) => { + setHeadings(extractHeadings(n.body)); + setWikilinks(parseWikilinks(n.body)); const file = await processor.process(n.body); setRenderedContent(String(file)); }; @@ -39,8 +61,17 @@ const NoteView: Component = () => { } }); + const backlinks = createMemo(() => { + const n = note(); + const all = allNotes() ?? []; + if (!n) return []; + return findBacklinks(n.title, all); + }); + + const resolveNote = (title: string) => resolveWikilink(title, allNotes() ?? []); + return ( - + { - - - + + + + + + + )} diff --git a/web/src/pages/tests/NoteView.test.tsx b/web/src/pages/tests/NoteView.test.tsx index 6a3e568..28341d3 100644 --- a/web/src/pages/tests/NoteView.test.tsx +++ b/web/src/pages/tests/NoteView.test.tsx @@ -5,7 +5,7 @@ import { JSX } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; import NoteView from "../NoteView"; -vi.mock("$lib/api", () => ({ api: { getNote: vi.fn() } })); +vi.mock("$lib/api", () => ({ api: { getNote: vi.fn(), getNotes: vi.fn() } })); vi.mock( "@solidjs/router", @@ -19,13 +19,24 @@ const mockNote: Note = { id: "note-1", owner_did: "did:plc:test123", title: "Test Note Title", - body: "# Heading\n\nSome **markdown** content.", + body: "# Heading\n\nSome **markdown** content with [[Other Note]].", tags: ["rust", "learning"], visibility: { type: "Public" }, created_at: "2026-01-01T10:00:00Z", updated_at: "2026-01-01T12:00:00Z", }; +const mockAllNotes: Note[] = [mockNote, { + id: "note-2", + owner_did: "did:plc:test123", + title: "Other Note", + body: "Links back to [[Test Note Title]]", + tags: [], + visibility: { type: "Private" }, + created_at: "2026-01-01T10:00:00Z", + updated_at: "2026-01-01T12:00:00Z", +}]; + describe("NoteView", () => { afterEach(() => { cleanup(); @@ -36,6 +47,9 @@ describe("NoteView", () => { vi.mocked(api.getNote).mockResolvedValue( { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, ); + vi.mocked(api.getNotes).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response, + ); render(() => ); @@ -48,12 +62,15 @@ describe("NoteView", () => { vi.mocked(api.getNote).mockResolvedValue( { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, ); + vi.mocked(api.getNotes).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response, + ); render(() => ); await waitFor(() => { - expect(screen.getByText("rust")).toBeInTheDocument(); - expect(screen.getByText("learning")).toBeInTheDocument(); + expect(screen.getAllByText("rust").length).toBeGreaterThan(0); + expect(screen.getAllByText("learning").length).toBeGreaterThan(0); }); }); @@ -61,6 +78,9 @@ describe("NoteView", () => { vi.mocked(api.getNote).mockResolvedValue( { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, ); + vi.mocked(api.getNotes).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response, + ); render(() => ); @@ -71,6 +91,7 @@ describe("NoteView", () => { it("renders not found state when note returns error", async () => { vi.mocked(api.getNote).mockResolvedValue({ ok: false } as unknown as Response); + vi.mocked(api.getNotes).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); render(() => ); @@ -78,4 +99,49 @@ describe("NoteView", () => { expect(screen.getByText("Note not found")).toBeInTheDocument(); }); }); + + it("renders outline panel heading", async () => { + vi.mocked(api.getNote).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, + ); + vi.mocked(api.getNotes).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText("Outline")).toBeInTheDocument(); + }); + }); + + it("renders wikilinks panel heading", async () => { + vi.mocked(api.getNote).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, + ); + vi.mocked(api.getNotes).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText("Wikilinks")).toBeInTheDocument(); + }); + }); + + it("renders backlinks panel heading", async () => { + vi.mocked(api.getNote).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockNote) } as unknown as Response, + ); + vi.mocked(api.getNotes).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockAllNotes) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText("Backlinks")).toBeInTheDocument(); + }); + }); });
No incoming links
No headings found
No outgoing links
This note may have been deleted.