From 6693b4b1d3eece220b0d2cb00491bf98ea711de5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:56:32 +0000 Subject: [PATCH 01/16] Initial plan From c60236cbcd73a819a468484dc8c340ad3dc6db2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:10:02 +0000 Subject: [PATCH 02/16] feat: add floating toc to articles Co-authored-by: echoja <73801151+echoja@users.noreply.github.com> --- next.config.mjs | 1 + package.json | 1 + pnpm-lock.yaml | 257 ++++++++++++++++++++++++ src/app/article/layout.tsx | 4 +- src/modules/article/ArticleFadeIn.tsx | 9 +- src/modules/article/ArticleLayout.tsx | 28 +++ src/modules/article/FloatingToc.test.ts | 52 +++++ src/modules/article/FloatingToc.tsx | 222 ++++++++++++++++++++ 8 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 src/modules/article/ArticleLayout.tsx create mode 100644 src/modules/article/FloatingToc.test.ts create mode 100644 src/modules/article/FloatingToc.tsx diff --git a/next.config.mjs b/next.config.mjs index 964fbd3..569bf49 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -159,6 +159,7 @@ const withMDX = mdx({ }, }, ], + "@hbsnow/rehype-sectionize", ], }, }); diff --git a/package.json b/package.json index dde6b22..b74ec0b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@echoja/remark-custom-container": "^1.4.0", + "@hbsnow/rehype-sectionize": "1.0.7", "@heroui/react": "2.8.6", "@heroui/theme": "2.4.24", "@mdx-js/loader": "^3.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d03f825..ce110f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@echoja/remark-custom-container': specifier: ^1.4.0 version: 1.4.0 + '@hbsnow/rehype-sectionize': + specifier: 1.0.7 + version: 1.0.7 '@heroui/react': specifier: 2.8.6 version: 2.8.6(@types/react@19.2.7)(framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18) @@ -485,6 +488,10 @@ packages: '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@hbsnow/rehype-sectionize@1.0.7': + resolution: {integrity: sha512-twbVxCFf4YwgTm6FIdGtHfJ14vvIHedk2fqZTpE3X6+vszEeZlMTy7tOyI9KaP/6S2DN2Jnk7zZGtZANTD+vEg==} + engines: {node: '>=16.0.0'} + '@heroui/accordion@2.2.25': resolution: {integrity: sha512-cukvjTXfSLxjCZJ2PwLYUdkJuzKgKfbYkA+l2yvtYfrAQ8G0uz8a+tAGKGcciVLtYke1KsZ/pKjbpInWgGUV7A==} peerDependencies: @@ -2299,6 +2306,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@2.3.10': + resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2329,6 +2339,9 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/parse5@6.0.3': + resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} + '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} @@ -3436,24 +3449,54 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@7.1.2: + resolution: {integrity: sha512-Nz7FfPBuljzsN3tCQ4kCBKqdNhQE2l0Tn+X1ubgKBPRoiDIu1mL08Cfw4k7q71+Duyaw7DXDN+VTAp4Vh3oCOw==} + + hast-util-heading-rank@2.1.1: + resolution: {integrity: sha512-iAuRp+ESgJoRFJbSyaqsfvJDY6zzmFoEnL1gtz1+U8gKtGGj1p0CVlysuUAUjq95qlZESHINLThwJzNGmgGZxA==} + hast-util-heading-rank@3.0.0: resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + hast-util-heading@2.0.1: + resolution: {integrity: sha512-nwRggTanShzHRYMUX46lm6pbJ2c1+TUQCETahENb6yR6c8ro8MkE0hRJm8G0IqAZl35ONgJiW8RC8+D3484vYg==} + + hast-util-is-element@2.1.3: + resolution: {integrity: sha512-O1bKah6mhgEq2WtVMk+Ta5K7pPMqsBBlmzysLdcwKVrqzZQ0CHqUPiIVspNhAG1rvxpvJjtGee17XfauZYKqVA==} + hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@3.1.1: + resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==} + + hast-util-raw@7.2.3: + resolution: {integrity: sha512-RujVQfVsOrxzPOPSzZFiwofMArbQke6DJjnFfceiEbFh7S05CbPt0cYN+A5YeD3pso0JQk6O1aHBnx9+Pm2uqg==} + hast-util-to-estree@3.1.0: resolution: {integrity: sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==} + hast-util-to-html@8.0.4: + resolution: {integrity: sha512-4tpQTUOr9BMjtYyNlt0P50mH7xj0Ks2xpo8M943Vykljf99HW6EzulIoJP1N3eKOSScEHzyzi9dm7/cn0RfGwA==} + hast-util-to-jsx-runtime@2.3.2: resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==} + hast-util-to-parse5@7.1.0: + resolution: {integrity: sha512-YNRgAJkH2Jky5ySkIqFXTQiaqcAtJyVE+D5lkN6CdtOqrnkLfGYYrEcKuHOJZlp+MwjSwuD3fZuawI+sic/RBw==} + hast-util-to-string@3.0.1: resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-whitespace@2.0.1: + resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@7.2.0: + resolution: {integrity: sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -3467,6 +3510,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@2.0.1: + resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3544,6 +3590,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -4251,6 +4301,9 @@ packages: resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} engines: {node: '>=16'} + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4395,12 +4448,21 @@ packages: rehype-autolink-headings@7.1.0: resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} + rehype-parse@8.0.5: + resolution: {integrity: sha512-Ds3RglaY/+clEX2U2mHflt7NlMA72KspZ0JLUJgBBLpRddBcEw3H8uYZQliQriku22NZpYMfjDdSgHcjxue24A==} + rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} rehype-slug@6.0.0: resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + rehype-stringify@9.0.4: + resolution: {integrity: sha512-Uk5xu1YKdqobe5XpSskwPvo1XeHUUucWEQSl8hTrXt5selvca1e8K1EZ37E6YoZ4BT8BCqCdVfQW7OfHfthtVQ==} + + rehype@12.0.1: + resolution: {integrity: sha512-ey6kAqwLM3X6QnMDILJthGvG1m1ULROS9NT4uG9IDCuv08SFyLlreSuvOa//DgEvbXx62DS6elGVqusWhRUbgw==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4839,27 +4901,45 @@ packages: unified-engine@11.2.2: resolution: {integrity: sha512-15g/gWE7qQl9tQ3nAEbMd5h9HV1EACtFs6N9xaRBZICoCwnNGbal1kOs++ICf4aiTdItZxU2s/kYWhW7htlqJg==} + unified@10.1.2: + resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} unist-util-inspect@8.1.0: resolution: {integrity: sha512-mOlg8Mp33pR0eeFpo5d2902ojqFFOKMMG2hF8bmH7ZlhnmjFgh0NI3/ZDwdaBJNbvrS7LZFVrBVtIE9KZ9s7vQ==} + unist-util-is@5.2.1: + resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} unist-util-position-from-estree@2.0.0: resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position@4.0.4: + resolution: {integrity: sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-stringify-position@3.0.3: + resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-visit-parents@5.1.3: + resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} + unist-util-visit-parents@6.0.1: resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + unist-util-visit@4.1.2: + resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} + unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} @@ -4922,6 +5002,12 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vfile-location@4.1.0: + resolution: {integrity: sha512-YF23YMyASIIJXpktBa4vIGLJ5Gs88UB/XePgqPmTa7cDA+JeO3yclbpheQYCHjVHBn/yePzrXuygIL+xbvRYHw==} + + vfile-message@3.1.4: + resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} + vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} @@ -4934,6 +5020,9 @@ packages: vfile-statistics@3.0.0: resolution: {integrity: sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w==} + vfile@5.3.7: + resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} + vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} @@ -5022,6 +5111,9 @@ packages: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} @@ -5418,6 +5510,12 @@ snapshots: dependencies: tslib: 2.8.1 + '@hbsnow/rehype-sectionize@1.0.7': + dependencies: + hast-util-heading: 2.0.1 + hast-util-heading-rank: 2.1.1 + rehype: 12.0.1 + '@heroui/accordion@2.2.25(@heroui/system@2.4.24(@heroui/theme@2.4.24(tailwindcss@4.1.18))(framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@heroui/theme@2.4.24(tailwindcss@4.1.18))(framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@heroui/aria-utils': 2.2.25(@heroui/theme@2.4.24(tailwindcss@4.1.18))(framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -8032,6 +8130,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/hast@2.3.10': + dependencies: + '@types/unist': 2.0.11 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -8062,6 +8164,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/parse5@6.0.3': {} + '@types/react@19.2.7': dependencies: csstype: 3.2.3 @@ -9472,14 +9576,56 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@7.1.2: + dependencies: + '@types/hast': 2.3.10 + '@types/unist': 2.0.11 + hastscript: 7.2.0 + property-information: 6.5.0 + vfile: 5.3.7 + vfile-location: 4.1.0 + web-namespaces: 2.0.1 + + hast-util-heading-rank@2.1.1: + dependencies: + '@types/hast': 2.3.10 + hast-util-heading-rank@3.0.0: dependencies: '@types/hast': 3.0.4 + hast-util-heading@2.0.1: + dependencies: + '@types/hast': 2.3.10 + hast-util-is-element: 2.1.3 + + hast-util-is-element@2.1.3: + dependencies: + '@types/hast': 2.3.10 + '@types/unist': 2.0.11 + hast-util-is-element@3.0.0: dependencies: '@types/hast': 3.0.4 + hast-util-parse-selector@3.1.1: + dependencies: + '@types/hast': 2.3.10 + + hast-util-raw@7.2.3: + dependencies: + '@types/hast': 2.3.10 + '@types/parse5': 6.0.3 + hast-util-from-parse5: 7.1.2 + hast-util-to-parse5: 7.1.0 + html-void-elements: 2.0.1 + parse5: 6.0.1 + unist-util-position: 4.0.4 + unist-util-visit: 4.1.2 + vfile: 5.3.7 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-estree@3.1.0: dependencies: '@types/estree': 1.0.6 @@ -9501,6 +9647,20 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-html@8.0.4: + dependencies: + '@types/hast': 2.3.10 + '@types/unist': 2.0.11 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-raw: 7.2.3 + hast-util-whitespace: 2.0.1 + html-void-elements: 2.0.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.2: dependencies: '@types/estree': 1.0.6 @@ -9521,14 +9681,33 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@7.1.0: + dependencies: + '@types/hast': 2.3.10 + comma-separated-tokens: 2.0.3 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-string@3.0.1: dependencies: '@types/hast': 3.0.4 + hast-util-whitespace@2.0.1: {} + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@7.2.0: + dependencies: + '@types/hast': 2.3.10 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 3.1.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -9541,6 +9720,8 @@ snapshots: html-escaper@2.0.2: {} + html-void-elements@2.0.1: {} + ignore@5.3.2: {} ignore@6.0.2: {} @@ -9616,6 +9797,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@2.0.5: {} + is-bun-module@2.0.0: dependencies: semver: 7.7.3 @@ -10605,6 +10788,8 @@ snapshots: lines-and-columns: 2.0.4 type-fest: 3.13.1 + parse5@6.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -10766,6 +10951,13 @@ snapshots: unified: 11.0.5 unist-util-visit: 5.0.0 + rehype-parse@8.0.5: + dependencies: + '@types/hast': 2.3.10 + hast-util-from-parse5: 7.1.2 + parse5: 6.0.1 + unified: 10.1.2 + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.6 @@ -10782,6 +10974,19 @@ snapshots: hast-util-to-string: 3.0.1 unist-util-visit: 5.0.0 + rehype-stringify@9.0.4: + dependencies: + '@types/hast': 2.3.10 + hast-util-to-html: 8.0.4 + unified: 10.1.2 + + rehype@12.0.1: + dependencies: + '@types/hast': 2.3.10 + rehype-parse: 8.0.5 + rehype-stringify: 9.0.4 + unified: 10.1.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -11363,6 +11568,16 @@ snapshots: - bluebird - supports-color + unified@10.1.2: + dependencies: + '@types/unist': 2.0.11 + bail: 2.0.2 + extend: 3.0.2 + is-buffer: 2.0.5 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 5.3.7 + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -11377,6 +11592,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-is@5.2.1: + dependencies: + '@types/unist': 2.0.11 + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.3 @@ -11385,19 +11604,38 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-position@4.0.4: + dependencies: + '@types/unist': 2.0.11 + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-stringify-position@3.0.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-visit-parents@5.1.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-visit-parents@6.0.1: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.0 + unist-util-visit@4.1.2: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + unist-util-visit@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -11477,6 +11715,16 @@ snapshots: validate-npm-package-name@5.0.1: {} + vfile-location@4.1.0: + dependencies: + '@types/unist': 2.0.11 + vfile: 5.3.7 + + vfile-message@3.1.4: + dependencies: + '@types/unist': 2.0.11 + unist-util-stringify-position: 3.0.3 + vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -11503,6 +11751,13 @@ snapshots: vfile: 6.0.3 vfile-message: 4.0.2 + vfile@5.3.7: + dependencies: + '@types/unist': 2.0.11 + is-buffer: 2.0.5 + unist-util-stringify-position: 3.0.3 + vfile-message: 3.1.4 + vfile@6.0.3: dependencies: '@types/unist': 3.0.3 @@ -11572,6 +11827,8 @@ snapshots: graceful-fs: 4.2.11 optional: true + web-namespaces@2.0.1: {} + webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 diff --git a/src/app/article/layout.tsx b/src/app/article/layout.tsx index 6cd05dd..957f63f 100644 --- a/src/app/article/layout.tsx +++ b/src/app/article/layout.tsx @@ -1,6 +1,6 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; import Utterances from "@modules/utterances"; -import ArticleFadeIn from "@modules/article/ArticleFadeIn"; +import ArticleLayout from "@modules/article/ArticleLayout"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -13,7 +13,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( <> - {children} + {children} ); diff --git a/src/modules/article/ArticleFadeIn.tsx b/src/modules/article/ArticleFadeIn.tsx index 664512f..d648d88 100644 --- a/src/modules/article/ArticleFadeIn.tsx +++ b/src/modules/article/ArticleFadeIn.tsx @@ -1,10 +1,12 @@ "use client"; +import type { Ref } from "react"; import { motion } from "motion/react"; interface IProps { children: React.ReactNode; className?: string; + articleRef?: Ref; } const variants = { @@ -12,9 +14,14 @@ const variants = { hidden: { opacity: 0 }, }; -export default function ArticleFadeIn({ children, className }: IProps) { +export default function ArticleFadeIn({ + children, + className, + articleRef, +}: IProps) { return ( (null); + const pathname = usePathname(); + + return ( +
+ + {children} + + +
+ ); +} diff --git a/src/modules/article/FloatingToc.test.ts b/src/modules/article/FloatingToc.test.ts new file mode 100644 index 0000000..28f3ad5 --- /dev/null +++ b/src/modules/article/FloatingToc.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { collectHeadingInfo } from "./FloatingToc"; + +describe("collectHeadingInfo", () => { + it("extracts headings and filters out toc heading", () => { + const root = document.createElement("article"); + + const section1 = document.createElement("section"); + const heading1 = document.createElement("h2"); + heading1.id = "intro"; + heading1.textContent = "Introduction"; + section1.appendChild(heading1); + root.appendChild(section1); + + const tocSection = document.createElement("section"); + const tocHeading = document.createElement("h2"); + tocHeading.id = "toc"; + tocHeading.textContent = "목차"; + tocSection.appendChild(tocHeading); + root.appendChild(tocSection); + + const section2 = document.createElement("section"); + const heading2 = document.createElement("h3"); + heading2.id = "details"; + heading2.textContent = "Details"; + section2.appendChild(heading2); + root.appendChild(section2); + + const headings = collectHeadingInfo(root); + + expect(headings.map((heading) => heading.id)).toEqual([ + "intro", + "details", + ]); + expect(headings[0]?.level).toBe(2); + expect(headings[1]?.section).toBe(section2); + }); + + it("falls back to heading element when no section exists", () => { + const root = document.createElement("article"); + const heading = document.createElement("h4"); + heading.id = "standalone"; + heading.textContent = "Standalone"; + root.appendChild(heading); + + const headings = collectHeadingInfo(root); + + expect(headings).toHaveLength(1); + expect(headings[0]?.section).toBe(heading); + }); +}); diff --git a/src/modules/article/FloatingToc.tsx b/src/modules/article/FloatingToc.tsx new file mode 100644 index 0000000..0fd35ff --- /dev/null +++ b/src/modules/article/FloatingToc.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { List, X } from "lucide-react"; +import { useEffect, useMemo, useState, type RefObject } from "react"; + +export interface HeadingInfo { + id: string; + title: string; + level: number; + section: Element; +} + +const headingSelector = "h2, h3, h4, h5, h6"; + +const isTocHeading = (title: string) => + /^(목차|table of contents)$/i.test(title.trim()); + +export function collectHeadingInfo(root: HTMLElement): HeadingInfo[] { + const headings = Array.from( + root.querySelectorAll(headingSelector), + ); + + return headings + .map((heading) => { + const title = heading.textContent?.trim() ?? ""; + const id = heading.id?.trim() ?? ""; + const level = Number.parseInt(heading.tagName.replace(/[^0-9]/g, ""), 10); + const section = + (heading.closest("section") as Element | null) ?? (heading as Element); + + return { id, title, level, section }; + }) + .filter( + (heading) => + heading.id && + heading.title && + Number.isFinite(heading.level) && + !isTocHeading(heading.title), + ); +} + +interface FloatingTocProps { + targetRef: RefObject; + refreshKey?: string | null; +} + +interface TocItem { + id: string; + title: string; + level: number; +} + +export default function FloatingToc({ + targetRef, + refreshKey, +}: FloatingTocProps) { + const [items, setItems] = useState([]); + const [activeId, setActiveId] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const root = targetRef.current; + if (!root) return; + + const headingInfo = collectHeadingInfo(root); + setItems(headingInfo.map(({ id, title, level }) => ({ id, title, level }))); + setActiveId(headingInfo[0]?.id ?? null); + + const order = headingInfo.map((heading) => heading.id); + const visible = new Set(); + const sectionToId = new Map(); + headingInfo.forEach((heading) => + sectionToId.set(heading.section, heading.id), + ); + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const id = sectionToId.get(entry.target); + if (!id) return; + if (entry.isIntersecting) { + visible.add(id); + } else { + visible.delete(id); + } + }); + + if (!order.length) return; + + const firstVisible = order.find((id) => visible.has(id)); + if (firstVisible) { + setActiveId(firstVisible); + return; + } + + const viewportHeight = + typeof window === "undefined" ? 0 : window.innerHeight; + + const lastPassed = [...headingInfo] + .reverse() + .find( + (heading) => + heading.section.getBoundingClientRect().top <= + viewportHeight * 0.45, + ); + + const upcoming = headingInfo.find( + (heading) => heading.section.getBoundingClientRect().top >= 0, + ); + + setActiveId( + lastPassed?.id ?? upcoming?.id ?? order[0] ?? null, + ); + }, + { + rootMargin: "-35% 0px -45% 0px", + threshold: [0, 0.1, 0.25, 0.5], + }, + ); + + headingInfo.forEach((heading) => observer.observe(heading.section)); + + return () => observer.disconnect(); + }, [targetRef, refreshKey]); + + useEffect(() => { + setIsOpen(false); + }, [refreshKey]); + + const tocList = useMemo(() => { + if (!items.length) return null; + + return ( + + ); + }, [activeId, items]); + + if (!items.length) return null; + + return ( + <> +
+
+
+ 목차 +
+ {tocList} +
+
+ +
+ + + {isOpen ? ( +
+ +
+ {tocList} +
+ + ) : null} + + + ); +} From 47855614f0bdc933f0867075fdd1b72ff6806491 Mon Sep 17 00:00:00 2001 From: Kim Tae Hoon Date: Mon, 22 Dec 2025 22:36:22 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EB=B0=98=EC=97=90=20=EA=B1=B8=EC=B3=90=20null=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20ESLint=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=88=98=EC=A0=95,=20floating=20ToC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.mjs | 14 +---------- src/app/en/article/layout.tsx | 4 ++-- src/app/ko/article/layout.tsx | 4 ++-- src/app/layout.tsx | 4 ++-- src/modules/article/ArticleLayout.tsx | 6 +---- src/modules/article/FloatingToc.test.ts | 5 +--- src/modules/article/FloatingToc.tsx | 28 +++++++++++++++------- src/modules/category.tsx | 4 +++- src/modules/color-mode/color-mode.ts | 12 +++++++--- src/modules/portfolio/PortfolioContent.tsx | 4 +++- src/modules/utterances.tsx | 20 ++++++++++++---- src/proxy.ts | 16 +++++++++---- 12 files changed, 70 insertions(+), 51 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f9a7a67..c80ab44 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -56,6 +56,7 @@ export default defineConfig([ "react/prop-types": "off", "no-unused-vars": "off", "no-undef": "off", // Handled by TypeScript + curly: "error", "object-shorthand": "error", "no-useless-rename": "error", @@ -68,19 +69,6 @@ export default defineConfig([ }, ], - "@typescript-eslint/naming-convention": [ - "error", - { - selector: "interface", - format: ["PascalCase"], - - custom: { - regex: "^I[A-Z]", - match: true, - }, - }, - ], - "@typescript-eslint/consistent-type-imports": [ "error", { diff --git a/src/app/en/article/layout.tsx b/src/app/en/article/layout.tsx index 6cd05dd..7e6bf50 100644 --- a/src/app/en/article/layout.tsx +++ b/src/app/en/article/layout.tsx @@ -1,7 +1,7 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; import Utterances from "@modules/utterances"; -import ArticleFadeIn from "@modules/article/ArticleFadeIn"; import type { Metadata } from "next"; +import ArticleLayout from "@modules/article/ArticleLayout"; export const metadata: Metadata = { other: { @@ -13,7 +13,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( <> - {children} + {children} ); diff --git a/src/app/ko/article/layout.tsx b/src/app/ko/article/layout.tsx index 6cd05dd..7e6bf50 100644 --- a/src/app/ko/article/layout.tsx +++ b/src/app/ko/article/layout.tsx @@ -1,7 +1,7 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; import Utterances from "@modules/utterances"; -import ArticleFadeIn from "@modules/article/ArticleFadeIn"; import type { Metadata } from "next"; +import ArticleLayout from "@modules/article/ArticleLayout"; export const metadata: Metadata = { other: { @@ -13,7 +13,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( <> - {children} + {children} ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a59ccf3..51f26ac 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -66,7 +66,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= }} > ) : null} - + > */} {process.env.NEXT_PUBLIC_GTM_ID ? ( diff --git a/src/modules/article/ArticleLayout.tsx b/src/modules/article/ArticleLayout.tsx index e4154be..ef30681 100644 --- a/src/modules/article/ArticleLayout.tsx +++ b/src/modules/article/ArticleLayout.tsx @@ -2,7 +2,6 @@ import { usePathname } from "next/navigation"; import { useRef } from "react"; - import ArticleFadeIn from "./ArticleFadeIn"; import FloatingToc from "./FloatingToc"; @@ -16,10 +15,7 @@ export default function ArticleLayout({ children }: ArticleLayoutProps) { return (
- + {children} diff --git a/src/modules/article/FloatingToc.test.ts b/src/modules/article/FloatingToc.test.ts index 28f3ad5..42ce3eb 100644 --- a/src/modules/article/FloatingToc.test.ts +++ b/src/modules/article/FloatingToc.test.ts @@ -29,10 +29,7 @@ describe("collectHeadingInfo", () => { const headings = collectHeadingInfo(root); - expect(headings.map((heading) => heading.id)).toEqual([ - "intro", - "details", - ]); + expect(headings.map((heading) => heading.id)).toEqual(["intro", "details"]); expect(headings[0]?.level).toBe(2); expect(headings[1]?.section).toBe(section2); }); diff --git a/src/modules/article/FloatingToc.tsx b/src/modules/article/FloatingToc.tsx index 0fd35ff..d1c740b 100644 --- a/src/modules/article/FloatingToc.tsx +++ b/src/modules/article/FloatingToc.tsx @@ -40,7 +40,7 @@ export function collectHeadingInfo(root: HTMLElement): HeadingInfo[] { } interface FloatingTocProps { - targetRef: RefObject; + targetRef: RefObject; refreshKey?: string | null; } @@ -60,7 +60,11 @@ export default function FloatingToc({ useEffect(() => { const root = targetRef.current; - if (!root) return; + if (!root) { + console.log("no root found"); + return; + } + console.log("collecting headings"); const headingInfo = collectHeadingInfo(root); setItems(headingInfo.map(({ id, title, level }) => ({ id, title, level }))); @@ -77,7 +81,9 @@ export default function FloatingToc({ (entries) => { entries.forEach((entry) => { const id = sectionToId.get(entry.target); - if (!id) return; + if (!id) { + return; + } if (entry.isIntersecting) { visible.add(id); } else { @@ -85,7 +91,9 @@ export default function FloatingToc({ } }); - if (!order.length) return; + if (!order.length) { + return; + } const firstVisible = order.find((id) => visible.has(id)); if (firstVisible) { @@ -108,9 +116,7 @@ export default function FloatingToc({ (heading) => heading.section.getBoundingClientRect().top >= 0, ); - setActiveId( - lastPassed?.id ?? upcoming?.id ?? order[0] ?? null, - ); + setActiveId(lastPassed?.id ?? upcoming?.id ?? order[0] ?? null); }, { rootMargin: "-35% 0px -45% 0px", @@ -128,7 +134,9 @@ export default function FloatingToc({ }, [refreshKey]); const tocList = useMemo(() => { - if (!items.length) return null; + if (!items.length) { + return null; + } return (