diff --git a/.gitignore b/.gitignore index 9d68f8e8..53e74d92 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ coverage/ .coverage/ .jest/ __tests__/coverage/ +site/src/content/generated-api-reference/ # ------------------------- # Environment diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04982f3e..65e9b524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,6 +360,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + es-toolkit: + specifier: ^1.32.0 + version: 1.44.0 github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -378,6 +381,9 @@ importers: lucide-react: specifier: ^0.546.0 version: 0.546.0(react@18.3.1) + marked: + specifier: ^17.0.1 + version: 17.0.1 mdast-util-to-string: specifier: ^4.0.0 version: 4.0.0 @@ -415,6 +421,9 @@ importers: specifier: ^5.0.0 version: 5.0.0 devDependencies: + '@astrojs/check': + specifier: ^0.9.6 + version: 0.9.6(prettier@3.8.1)(typescript@5.9.3) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -451,9 +460,18 @@ importers: sirv: specifier: ^3.0.2 version: 3.0.2 + tsx: + specifier: ^4.20.3 + version: 4.21.0 turndown: specifier: ^7.2.2 version: 7.2.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-api-extractor: + specifier: ^1.0.0-alpha.13 + version: 1.0.0-alpha.13(typescript@5.9.3) vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) @@ -488,12 +506,30 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astrojs/check@0.9.6': + resolution: {integrity: sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + '@astrojs/compiler@2.13.0': resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==} '@astrojs/internal-helpers@0.7.5': resolution: {integrity: sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==} + '@astrojs/language-server@2.16.3': + resolution: {integrity: sha512-yO5K7RYCMXUfeDlnU6UnmtnoXzpuQc0yhlaCNZ67k1C/MiwwwvMZz+LGa+H35c49w5QBfvtr4w4Zcf5PcH8uYA==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + '@astrojs/markdown-remark@6.3.10': resolution: {integrity: sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==} @@ -534,6 +570,9 @@ packages: '@astrojs/underscore-redirects@1.0.0': resolution: {integrity: sha512-qZxHwVnmb5FXuvRsaIGaqWgnftjCuMY+GSbaVZdBmE4j8AfgPqKPxYp8SUERyJcjpKCEmO4wD6ybuGH8A2kVRQ==} + '@astrojs/yaml2ts@0.2.2': + resolution: {integrity: sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -823,6 +862,27 @@ packages: resolution: {integrity: sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==} engines: {node: '>=18'} + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -2731,6 +2791,32 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@vue/compiler-core@3.5.27': resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==} @@ -2805,6 +2891,14 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-errors@3.0.0: resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} peerDependencies: @@ -3529,6 +3623,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3598,6 +3695,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -4284,6 +4384,12 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonparse@1.3.1: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} @@ -4320,6 +4426,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} @@ -4479,6 +4589,9 @@ packages: lodash.upperfirst@4.3.1: resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -4554,6 +4667,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4814,6 +4932,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nano-spawn@2.0.0: resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} @@ -5037,6 +5158,9 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5158,6 +5282,11 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -5343,6 +5472,12 @@ packages: remove-trailing-separator@1.1.0: resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5906,6 +6041,18 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-api-extractor@1.0.0-alpha.13: + resolution: {integrity: sha512-vMn26RR+SisBu3t0E0n7odikrw/+C+5al52q6JwmiMEhUhLVgkO747BiwGhSTmedwjC6ACFFFHa9bytoQQhZ4w==} + engines: {node: '>=22'} + peerDependencies: + typescript: ^5.8 + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -6223,6 +6370,98 @@ packages: jsdom: optional: true + volar-service-css@0.0.68: + resolution: {integrity: sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.68: + resolution: {integrity: sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.68: + resolution: {integrity: sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.68: + resolution: {integrity: sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.68: + resolution: {integrity: sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.68: + resolution: {integrity: sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.68: + resolution: {integrity: sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.9: + resolution: {integrity: sha512-1tLWfp+TDM5ZuVWht3jmaY5y7O6aZmpeXLoHl5bv1QtRsRKt4xYGRMmdJa5Pqx/FTkgRbsna9R+Gn2xE+evVuA==} + + vscode-html-languageservice@5.6.1: + resolution: {integrity: sha512-5Mrqy5CLfFZUgkyhNZLA1Ye5g12Cb/v6VM7SxUzZUaRKWMDz4md+y26PrfRTSU0/eQAl3XpO9m2og+GGtDMuaA==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -6370,6 +6609,15 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-language-server@1.19.2: + resolution: {integrity: sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg==} + hasBin: true + + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -6473,10 +6721,46 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@astrojs/check@0.9.6(prettier@3.8.1)(typescript@5.9.3)': + dependencies: + '@astrojs/language-server': 2.16.3(prettier@3.8.1)(typescript@5.9.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 5.9.3 + yargs: 17.7.2 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + '@astrojs/compiler@2.13.0': {} '@astrojs/internal-helpers@0.7.5': {} + '@astrojs/language-server@2.16.3(prettier@3.8.1)(typescript@5.9.3)': + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/yaml2ts': 0.2.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.28(typescript@5.9.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.15 + volar-service-css: 0.0.68(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.68(@volar/language-service@2.4.28) + volar-service-html: 0.0.68(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.68(@volar/language-service@2.4.28)(prettier@3.8.1) + volar-service-typescript: 0.0.68(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.68(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.68(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.1 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.8.1 + transitivePeerDependencies: + - typescript + '@astrojs/markdown-remark@6.3.10': dependencies: '@astrojs/internal-helpers': 0.7.5 @@ -6624,6 +6908,10 @@ snapshots: '@astrojs/underscore-redirects@1.0.0': {} + '@astrojs/yaml2ts@0.2.2': + dependencies: + yaml: 2.8.2 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6951,6 +7239,29 @@ snapshots: gonzales-pe: 4.3.0 node-source-walk: 7.0.1 + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -9010,6 +9321,56 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@volar/kit@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 5.9.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + '@vue/compiler-core@3.5.27': dependencies: '@babel/parser': 7.28.5 @@ -9104,6 +9465,10 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ajv-draft-04@1.0.0(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-errors@3.0.0(ajv@8.17.1): dependencies: ajv: 8.17.1 @@ -9861,6 +10226,11 @@ snapshots: electron-to-chromium@1.5.267: {} + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -9913,6 +10283,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.44.0: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -10790,6 +11162,10 @@ snapshots: json5@2.2.3: {} + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + jsonparse@1.3.1: {} jsonpointer@5.0.1: {} @@ -10828,6 +11204,8 @@ snapshots: kleur@3.0.3: {} + kleur@4.1.5: {} + kuler@2.0.0: {} lambda-local@2.2.0: @@ -10971,6 +11349,8 @@ snapshots: lodash.upperfirst@4.3.1: {} + lodash@4.17.21: {} + lodash@4.17.23: {} log-update@6.1.0: @@ -11048,6 +11428,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdast-util-definitions@6.0.0: @@ -11562,6 +11944,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + nano-spawn@2.0.0: {} nanoid@3.3.11: {} @@ -11777,6 +12161,8 @@ snapshots: dependencies: entities: 6.0.1 + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-exists@5.0.0: {} @@ -11883,6 +12269,8 @@ snapshots: prettier@2.8.8: {} + prettier@3.8.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -12133,6 +12521,10 @@ snapshots: remove-trailing-separator@1.1.0: {} + request-light@0.5.8: {} + + request-light@0.7.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -12743,6 +13135,17 @@ snapshots: type-fest@4.41.0: {} + typesafe-path@0.2.2: {} + + typescript-api-extractor@1.0.0-alpha.13(typescript@5.9.3): + dependencies: + es-toolkit: 1.44.0 + typescript: 5.9.3 + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.7.3 + typescript@5.9.3: {} ufo@1.6.1: {} @@ -13066,6 +13469,103 @@ snapshots: - tsx - yaml + volar-service-css@0.0.68(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.9 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.68(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.68(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.68(@volar/language-service@2.4.28)(prettier@3.8.1): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.8.1 + + volar-service-typescript-twoslash-queries@0.0.68(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.68(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.7.3 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.68(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.19.2 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.9: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.1: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -13197,6 +13697,23 @@ snapshots: yallist@5.0.0: {} + yaml-language-server@1.19.2: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.17.1 + ajv-draft-04: 1.0.0(ajv@8.17.1) + lodash: 4.17.21 + prettier: 3.8.1 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + yaml: 2.7.1 + + yaml@2.7.1: {} + yaml@2.8.2: {} yargs-parser@21.1.1: {} diff --git a/site/CLAUDE.md b/site/CLAUDE.md index 0d4b73b8..e64cf88c 100644 --- a/site/CLAUDE.md +++ b/site/CLAUDE.md @@ -17,6 +17,7 @@ From `site/` directory: | `pnpm dev` | Start dev server at `localhost:4321` | | `pnpm build` | Build production site to `./dist/` | | `pnpm preview` | Preview production build locally | +| `pnpm api-docs` | Regenerate API reference JSON files | | `pnpm test` | Run all tests once | | `pnpm test:watch` | Run tests in watch mode | | `pnpm test:ui` | Run Vitest with web UI | @@ -140,9 +141,11 @@ This limits the classnames Tailwind must generate. site/ ├── src/ │ ├── components/ # Astro + React components +│ │ └── docs/api-reference/ # API reference Astro components │ ├── content/ # Content collections (blog/, docs/, authors.json) │ ├── layouts/ # Page layouts (Base, Blog, Docs, Markdown) │ ├── pages/ # Route pages (file-based routing) +│ ├── content/generated-api-reference/ # Generated API reference JSON (gitignored) │ ├── stores/ # Nanostores for cross-island state │ ├── styles/ # Global CSS, Tailwind imports │ ├── types/ # TypeScript type definitions @@ -155,6 +158,8 @@ site/ │ ├── content.config.ts # Content collection schemas │ ├── docs.config.ts # Documentation sidebar structure │ └── test-setup.ts # Vitest setup file +├── scripts/ +│ └── api-docs-builder/ # Generates API reference from TypeScript ├── public/ # Static assets (served untransformed) ├── integrations/ # Custom Astro integrations │ └── pagefind.ts # Pagefind search integration @@ -430,6 +435,58 @@ vi.mock('@/types/docs', async () => { - **[Vitest 3.2.4](https://vitest.dev)**: Testing framework - **[clsx](https://github.com/lukeed/clsx)**: Class name concatenation utility +## API Reference Generation + +The API docs builder extracts type information from TypeScript sources and generates JSON files used by Astro components. + +### How It Works + +``` +packages/core/src/core/ui/{component}/ → JSON → → tables +``` + +1. **Builder script** (`scripts/api-docs-builder/`) parses TypeScript using `typescript-api-extractor` +2. **Extracts** from core files: Props interface, State interface, defaultProps +3. **Extracts** from data-attrs files: data attributes with JSDoc descriptions +4. **Extracts** from HTML element files: Lit `tagName` +5. **Outputs** JSON to `src/content/generated-api-reference/{component}.json` +6. **Astro components** (`src/components/docs/api-reference/`) render the JSON as tables + +### Generated Files Are Gitignored + +The `src/content/generated-api-reference/` directory is **gitignored**. JSON files are regenerated: +- Automatically on `pnpm dev` (via `predev` hook) +- Automatically on `pnpm build` (via `prebuild` hook) +- Manually via `pnpm api-docs` + +### Usage in MDX + +```mdx +import ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; + +## API Reference + +### Props + + + +### State + + + +### Data Attributes + + +``` + +### Adding a New Component + +When a new component is added to `packages/core/src/core/ui/`: +1. Run `pnpm api-docs` to generate its JSON +2. Add `` to the MDX reference page as described above + +See `scripts/api-docs-builder/README.md` for full documentation. + ## Custom Astro Integration: Pagefind **Location:** `integrations/pagefind.ts` diff --git a/site/package.json b/site/package.json index 66b35e48..fbf25b6a 100644 --- a/site/package.json +++ b/site/package.json @@ -3,7 +3,10 @@ "type": "module", "version": "0.0.1", "scripts": { - "dev": " astro dev", + "api-docs": "tsx scripts/api-docs-builder/src/index.ts", + "predev": "pnpm api-docs", + "dev": "astro dev", + "prebuild": "pnpm api-docs", "build": "astro build", "astro": "astro", "test": "vitest run", @@ -28,12 +31,14 @@ "@videojs/react-preview": "workspace:*", "astro": "^5.14.4", "clsx": "^2.1.1", + "es-toolkit": "^1.32.0", "github-slugger": "^2.0.0", "iron-session": "^8.0.4", "jose": "^6.1.3", "just-debounce-it": "^3.2.0", "just-throttle": "^4.2.0", "lucide-react": "^0.546.0", + "marked": "^17.0.1", "mdast-util-to-string": "^4.0.0", "nanostores": "^1.0.1", "react": "^18.0.0", @@ -48,6 +53,7 @@ "unist-util-visit": "^5.0.0" }, "devDependencies": { + "@astrojs/check": "^0.9.6", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -60,7 +66,10 @@ "babel-plugin-react-compiler": "1.0.0", "jsdom": "^27.0.0", "sirv": "^3.0.2", + "tsx": "^4.20.3", "turndown": "^7.2.2", + "typescript": "^5.9.3", + "typescript-api-extractor": "^1.0.0-alpha.13", "vitest": "^3.2.4" } } diff --git a/site/scripts/api-docs-builder/README.md b/site/scripts/api-docs-builder/README.md new file mode 100644 index 00000000..756bfef8 --- /dev/null +++ b/site/scripts/api-docs-builder/README.md @@ -0,0 +1,170 @@ +# API Docs Builder + +Generates interactive API documentation from TypeScript sources for Video.js 10 components. + +## Architecture + +``` +TypeScript Sources (core/html packages) + ↓ + api-docs-builder (typescript-api-extractor) + ↓ + JSON files (site/src/content/generated-api-reference/) + ↓ + Astro component + ↓ + Interactive tables in MDX pages +``` + +## How It Works + +### 1. Source Discovery + +The builder scans `packages/core/src/core/ui/` for component directories. For each component (e.g., `play-button`), it looks for: + +- **Core file**: `play-button-core.ts` → Extracts `PlayButtonProps`, `PlayButtonState`, and `defaultProps` +- **Data attrs file**: `play-button-data-attrs.ts` → Extracts data attributes with JSDoc descriptions +- **HTML element file**: `packages/html/src/ui/play-button/play-button-element.ts` → Extracts `tagName` + +### 2. TypeScript Extraction + +Uses `typescript-api-extractor` to parse TypeScript AST and extract: + +- Interface properties with types and JSDoc descriptions +- Default values from `static defaultProps = { ... }` +- Data attributes from `const PlayButtonDataAttrs = { ... } as const` +- Lit element tag names from `static tagName = 'media-play-button'` + +### 3. JSON Output + +Generates one JSON file per component at `site/src/content/generated-api-reference/{kebab-case-name}.json`: + +```json +{ + "name": "PlayButton", + "props": { + "label": { + "type": "string | ((state: PlayButtonState) => string)", + "description": "Custom label for the button.", + "default": "''" + } + }, + "state": { + "paused": { + "type": "boolean", + "description": "Whether playback is paused." + } + }, + "dataAttributes": { + "data-paused": { + "description": "Present when the media is paused." + } + }, + "platforms": { + "html": { + "tagName": "media-play-button" + } + } +} +``` + +### 4. Astro Components + +The `` component: + +1. Loads the JSON via Astro Content Collections (`getEntry('apiReference', 'play-button')`) +2. Filters props based on current framework (hides React-only props on HTML pages) +3. Renders interactive tables with expandable prop details + +## Usage + +### In MDX + +```mdx +import ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; + +## API Reference + +### Props + + + +### State + + + +### Data Attributes + + +``` + +### Building + +The builder runs automatically before dev/build via npm scripts: + +```bash +# Run manually +pnpm api-docs + +# Runs automatically on: +pnpm dev # via predev hook +pnpm build # via prebuild hook +``` + +## File Structure + +``` +site/scripts/api-docs-builder/ +├── README.md # This file +└── src/ + ├── index.ts # Main entry point, orchestrates handlers + ├── types.ts # TypeScript interfaces + ├── formatter.ts # Type formatting utilities + ├── core-handler.ts # Extracts Props/State from core packages + ├── data-attrs-handler.ts # Extracts data attributes + ├── html-handler.ts # Extracts Lit element info + └── tests/ + ├── test-utils.ts + ├── core-handler.test.ts + ├── data-attrs-handler.test.ts + ├── formatter.test.ts + └── html-handler.test.ts + +site/src/ +├── content/generated-api-reference/ # Generated JSON files (gitignored) +│ ├── play-button.json +│ └── mute-button.json +└── components/docs/api-reference/ + ├── ApiRefSection.astro # Main wrapper, loads JSON + ├── ApiPropsTable.astro # Props table + ├── ApiStateTable.astro # State interface table + ├── ApiDataAttrsTable.astro # Data attributes table + └── PropRow.astro # Expandable prop row +``` + +## Adding a New Component + +1. Create the component in `packages/core/src/core/ui/{name}/` +2. Export `{Name}Props` interface and `{Name}State` interface +3. Optionally create `{name}-data-attrs.ts` with data attribute definitions +4. Create the HTML element in `packages/html/src/ui/{name}/` with `static tagName` +5. Run `pnpm api-docs` to generate JSON +6. Use `` in MDX as described above + +## Differences from base-ui + +This implementation is adapted from MUI base-ui's api-docs-builder with key differences: + +1. **Multi-platform**: One JSON per component containing all platform variants (React/HTML) +2. **Core-first**: Props come from core package, not platform-specific components +3. **Data attributes**: Extracted from dedicated `*-data-attrs.ts` files +4. **HTML elements**: Extracts Lit element `static tagName` +5. **No prettier**: Uses biome for formatting (removed prettier dependency) + +## Dependencies + +- `typescript-api-extractor`: AST parsing for TypeScript types +- `es-toolkit`: Utility functions (kebabCase, etc.) +- `tsx`: TypeScript execution + +All dependencies are in `site/package.json` devDependencies. diff --git a/site/scripts/api-docs-builder/src/core-handler.ts b/site/scripts/api-docs-builder/src/core-handler.ts new file mode 100644 index 00000000..b9f2727b --- /dev/null +++ b/site/scripts/api-docs-builder/src/core-handler.ts @@ -0,0 +1,147 @@ +import * as ts from 'typescript'; +import * as tae from 'typescript-api-extractor'; +import { formatProperties } from './formatter.js'; +import type { CoreExtraction, ExtractedProp } from './types.js'; + +/** + * Extract Props, State, and defaultProps from a core component file. + * + * Looks for patterns like: + * - interface PlayButtonProps { ... } + * - interface PlayButtonState { ... } + * - class PlayButtonCore { static defaultProps = { ... } } + */ +export function extractCore(filePath: string, program: ts.Program, componentName: string): CoreExtraction | null { + const ast = tae.parseFromProgram(filePath, program); + + // Find the Props interface (e.g., PlayButtonProps) + const propsExport = ast.exports.find((exp) => exp.name === `${componentName}Props`); + + // Find the State interface (e.g., PlayButtonState) + const stateExport = ast.exports.find((exp) => exp.name === `${componentName}State`); + + if (!propsExport && !stateExport) { + return null; + } + + // Extract props + let props: ExtractedProp[] = []; + let description: string | undefined; + + if (propsExport?.type instanceof tae.ObjectNode) { + const formatted = formatProperties(propsExport.type.properties); + props = Object.entries(formatted).map(([name, def]) => ({ + name, + ...def, + })); + description = propsExport.documentation?.description; + } + + // Extract state + let state: ExtractedProp[] = []; + if (stateExport?.type instanceof tae.ObjectNode) { + const formatted = formatProperties(stateExport.type.properties); + state = Object.entries(formatted).map(([name, def]) => ({ + name, + ...def, + })); + } + + // Extract defaultProps from the Core class + const defaultProps = extractDefaultProps(filePath, program, componentName); + + return { + description, + props, + state, + defaultProps, + }; +} + +/** + * Extract defaultProps from the Core class static property. + * + * Looks for: static readonly defaultProps = { label: '', disabled: false } + */ +export function extractDefaultProps( + filePath: string, + program: ts.Program, + componentName: string +): Record { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return {}; + + const defaultProps: Record = {}; + + function visit(node: ts.Node) { + // Look for class declaration + if (ts.isClassDeclaration(node) && node.name?.text === `${componentName}Core`) { + for (const member of node.members) { + // Look for static property named defaultProps + if ( + ts.isPropertyDeclaration(member) && + member.name && + ts.isIdentifier(member.name) && + member.name.text === 'defaultProps' && + member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) && + member.initializer + ) { + // Parse the object literal + if (ts.isObjectLiteralExpression(member.initializer)) { + for (const prop of member.initializer.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + const propName = prop.name.text; + const propValue = getPropertyValue(prop.initializer, sourceFile); + if (propValue !== undefined) { + defaultProps[propName] = propValue; + } + } + } + } + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + return defaultProps; +} + +/** + * Get a string representation of a property value. + */ +export function getPropertyValue(node: ts.Expression, sourceFile: ts.SourceFile): string | undefined { + if (ts.isStringLiteral(node)) { + return `'${node.text}'`; + } + + if (ts.isNumericLiteral(node)) { + return node.text; + } + + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return 'true'; + } + + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return 'false'; + } + + if (node.kind === ts.SyntaxKind.NullKeyword) { + return 'null'; + } + + if (ts.isArrayLiteralExpression(node) && node.elements.length === 0) { + return '[]'; + } + + if (ts.isObjectLiteralExpression(node) && node.properties.length === 0) { + return '{}'; + } + + // For more complex expressions, get the source text + return node.getText(sourceFile); +} diff --git a/site/scripts/api-docs-builder/src/data-attrs-handler.ts b/site/scripts/api-docs-builder/src/data-attrs-handler.ts new file mode 100644 index 00000000..8de8ec46 --- /dev/null +++ b/site/scripts/api-docs-builder/src/data-attrs-handler.ts @@ -0,0 +1,118 @@ +import * as ts from 'typescript'; +import type { DataAttrsExtraction } from './types.js'; + +/** + * Extract data attributes from a data-attrs file. + * + * Looks for patterns like: + * ```ts + * export const PlayButtonDataAttrs = { + * /** Present when the media is paused. *\/ + * paused: 'data-paused', + * /** Present when the media has ended. *\/ + * ended: 'data-ended', + * } as const; + * ``` + */ +export function extractDataAttrs( + filePath: string, + program: ts.Program, + componentName: string +): DataAttrsExtraction | null { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) { + return null; + } + + const attrs: Array<{ name: string; description: string }> = []; + + // Common naming patterns for data attributes exports + const possibleNames = [`${componentName}DataAttrs`, `${componentName}DataAttributes`]; + + function visit(node: ts.Node) { + // Look for variable declaration like: export const PlayButtonDataAttrs = { ... } + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !possibleNames.includes(decl.name.text) || !decl.initializer) { + continue; + } + + // Handle `as const` assertions + let objLiteral: ts.ObjectLiteralExpression | undefined; + + if (ts.isObjectLiteralExpression(decl.initializer)) { + objLiteral = decl.initializer; + } else if (ts.isAsExpression(decl.initializer) && ts.isObjectLiteralExpression(decl.initializer.expression)) { + objLiteral = decl.initializer.expression; + } + + if (!objLiteral) continue; + + // Extract properties with their JSDoc comments + for (const prop of objLiteral.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + const propName = prop.name.text; + let dataAttrValue = ''; + + // Get the value (e.g., 'data-paused') + if (ts.isStringLiteral(prop.initializer)) { + dataAttrValue = prop.initializer.text; + } + + // Get JSDoc comment for this property + const jsDocComment = getJsDocComment(prop, sourceFile); + + attrs.push({ + name: dataAttrValue || `data-${propName}`, + description: jsDocComment || '', + }); + } + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + if (attrs.length === 0) { + return null; + } + + return { attrs }; +} + +/** + * Extract JSDoc comment from a property assignment. + */ +export function getJsDocComment(node: ts.PropertyAssignment, sourceFile: ts.SourceFile): string { + // Get leading comment ranges + const fullText = sourceFile.getFullText(); + const nodeStart = node.getFullStart(); + const ranges = ts.getLeadingCommentRanges(fullText, nodeStart); + + if (!ranges || ranges.length === 0) return ''; + + // Get the last comment (closest to the property) + const lastRange = ranges[ranges.length - 1]; + if (!lastRange) return ''; + + const commentText = fullText.substring(lastRange.pos, lastRange.end); + + // Parse JSDoc comment + if (commentText.startsWith('/**')) { + return commentText + .replace(/^\/\*\*\s*/, '') + .replace(/\s*\*\/$/, '') + .replace(/^\s*\*\s?/gm, '') + .trim(); + } + + // Single-line comment + if (commentText.startsWith('//')) { + return commentText.replace(/^\/\/\s*/, '').trim(); + } + + return ''; +} diff --git a/site/scripts/api-docs-builder/src/formatter.ts b/site/scripts/api-docs-builder/src/formatter.ts new file mode 100644 index 00000000..0e196958 --- /dev/null +++ b/site/scripts/api-docs-builder/src/formatter.ts @@ -0,0 +1,240 @@ +import { uniq } from 'es-toolkit/array'; +import * as tae from 'typescript-api-extractor'; +import type { PropDef } from './types.js'; + +/** + * Get abbreviated type for display in collapsed rows. + * + * Returns `shortType` when abbreviation adds value, `undefined` otherwise. + */ +export function getShortPropType(name: string, type: string): string | undefined { + // Callbacks → "function" + if (/^(on|get)[A-Z]/.test(name) && type.includes('=>')) { + return 'function'; + } + + // className/style/render → simplified + if (name === 'className' && type.includes('=>')) { + return 'string | function'; + } + if (name === 'style' && type.includes('=>')) { + return 'CSSProperties | function'; + } + if (name === 'render' && type.includes('=>')) { + return 'ReactElement | function'; + } + + // Simple types → no abbreviation needed + if (['boolean', 'string', 'number'].includes(type)) { + return undefined; + } + + // Short unions (less than 3 members and under 40 chars) → no abbreviation + if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40)) { + return undefined; + } + + // Function in union → "type | function" + if (type.includes('=>')) { + const parts = type.split(' | '); + const nonFunctionParts = parts.filter((p) => !p.includes('=>')); + if (nonFunctionParts.length > 0) { + return `${nonFunctionParts.join(' | ')} | function`; + } + return 'function'; + } + + // Complex unions → no abbreviation needed (show full type) + return undefined; +} + +/** + * Format a list of properties into API reference format. + */ +export function formatProperties(props: tae.PropertyNode[]): Record { + const result: Record = {}; + + for (const prop of props) { + // Skip ref for components + if (prop.name === 'ref') continue; + // Skip props marked with @ignore + if (prop.documentation?.hasTag('ignore')) continue; + + const formattedType = formatType(prop.type, prop.optional); + const shortType = getShortPropType(prop.name, formattedType); + + const entry: PropDef = { type: formattedType }; + if (shortType !== undefined) entry.shortType = shortType; + if (prop.documentation?.defaultValue !== undefined) entry.default = prop.documentation.defaultValue; + if (!prop.optional) entry.required = true; + if (prop.documentation?.description !== undefined) entry.description = prop.documentation.description; + + result[prop.name] = entry; + } + + return result; +} + +/** + * Format a type into a human-readable string. + */ +export function formatType(type: tae.AnyType, removeUndefined: boolean): string { + if (type instanceof tae.ExternalTypeNode) { + if (/^ReactElement(<.*>)?/.test(type.typeName.name || '')) { + return 'ReactElement'; + } + + if (type.typeName.namespaces?.length === 1 && type.typeName.namespaces[0] === 'React') { + return createNameWithTypeArguments(type.typeName); + } + + return getFullyQualifiedName(type.typeName); + } + + if (type instanceof tae.IntrinsicNode) { + return type.typeName ? getFullyQualifiedName(type.typeName) : type.intrinsic; + } + + if (type instanceof tae.UnionNode) { + if (type.typeName) { + return getFullyQualifiedName(type.typeName); + } + + let memberTypes = type.types; + + if (removeUndefined) { + memberTypes = memberTypes.filter((t) => !(t instanceof tae.IntrinsicNode && t.intrinsic === 'undefined')); + } + + const flattenedMemberTypes = memberTypes.flatMap((t) => { + if (t instanceof tae.UnionNode) { + return t.typeName ? t : t.types; + } + if (t instanceof tae.TypeParameterNode && t.constraint instanceof tae.UnionNode) { + return t.constraint.types; + } + return t; + }); + + const formattedMemberTypes = uniq(orderMembers(flattenedMemberTypes).map((t) => formatType(t, removeUndefined))); + + return formattedMemberTypes.join(' | '); + } + + if (type instanceof tae.IntersectionNode) { + if (type.typeName) { + return getFullyQualifiedName(type.typeName); + } + + return orderMembers(type.types) + .map((t) => formatType(t, false)) + .join(' & '); + } + + if (type instanceof tae.ObjectNode) { + if (type.typeName) { + return getFullyQualifiedName(type.typeName); + } + + if (type.properties.length === 0) { + return '{}'; + } + + return `{ ${type.properties.map((m) => `${m.name}${m.optional ? '?' : ''}: ${formatType(m.type, m.optional)}`).join('; ')} }`; + } + + if (type instanceof tae.LiteralNode) { + return normalizeQuotes(type.value as string); + } + + if (type instanceof tae.ArrayNode) { + const formattedMemberType = formatType(type.elementType, false); + if (formattedMemberType.includes(' ')) { + return `(${formattedMemberType})[]`; + } + return `${formattedMemberType}[]`; + } + + if (type instanceof tae.FunctionNode) { + if (type.typeName) { + return getFullyQualifiedName(type.typeName); + } + + const functionSignature = type.callSignatures + .map((s) => { + const params = s.parameters.map((p) => `${p.name}: ${formatType(p.type, false)}`).join(', '); + const returnType = formatType(s.returnValueType, false); + return `(${params}) => ${returnType}`; + }) + .join(' | '); + return `(${functionSignature})`; + } + + if (type instanceof tae.TupleNode) { + if (type.typeName) { + return getFullyQualifiedName(type.typeName); + } + return `[${type.types.map((member: tae.AnyType) => formatType(member, false)).join(', ')}]`; + } + + if (type instanceof tae.TypeParameterNode) { + return type.constraint !== undefined ? formatType(type.constraint, removeUndefined) : type.name; + } + + return 'unknown'; +} + +function getFullyQualifiedName(typeName: tae.TypeName): string { + const nameWithTypeArgs = createNameWithTypeArguments(typeName); + + if (!typeName.namespaces || typeName.namespaces.length === 0) { + return nameWithTypeArgs; + } + + return `${typeName.namespaces.join('.')}.${nameWithTypeArgs}`; +} + +function createNameWithTypeArguments(typeName: tae.TypeName): string { + if ( + typeName.typeArguments && + typeName.typeArguments.length > 0 && + typeName.typeArguments.some((ta) => ta.equalToDefault === false) + ) { + return `${typeName.name}<${typeName.typeArguments.map((ta) => formatType(ta.type, false)).join(', ')}>`; + } + + return typeName.name; +} + +/** + * Order members so null, undefined, and any come last. + */ +function orderMembers(members: readonly tae.AnyType[]): readonly tae.AnyType[] { + let ordered = pushToEnd(members, 'any'); + ordered = pushToEnd(ordered, 'null'); + ordered = pushToEnd(ordered, 'undefined'); + return ordered; +} + +function pushToEnd(members: readonly tae.AnyType[], name: string): readonly tae.AnyType[] { + const index = members.findIndex( + (member: tae.AnyType) => member instanceof tae.IntrinsicNode && member.intrinsic === name + ); + + if (index !== -1) { + const member = members[index]; + return [...members.slice(0, index), ...members.slice(index + 1), member!]; + } + + return members; +} + +function normalizeQuotes(str: string): string { + if (str.startsWith('"') && str.endsWith('"')) { + return str + .replaceAll("'", "\\'") + .replaceAll('\\"', '"') + .replace(/^"(.*)"$/, "'$1'"); + } + return str; +} diff --git a/site/scripts/api-docs-builder/src/html-handler.ts b/site/scripts/api-docs-builder/src/html-handler.ts new file mode 100644 index 00000000..4cec4421 --- /dev/null +++ b/site/scripts/api-docs-builder/src/html-handler.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import type { HtmlExtraction } from './types.js'; + +/** Extract tagName from a Lit element file. */ +export function extractHtml(filePath: string, program: ts.Program, componentName: string): HtmlExtraction | null { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return null; + + let tagName = ''; + + function visit(node: ts.Node) { + if (ts.isClassDeclaration(node) && node.name?.text === `${componentName}Element`) { + for (const member of node.members) { + if ( + ts.isPropertyDeclaration(member) && + member.name && + ts.isIdentifier(member.name) && + member.name.text === 'tagName' && + member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) && + member.initializer && + ts.isStringLiteral(member.initializer) + ) { + tagName = member.initializer.text; + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + return tagName ? { tagName } : null; +} diff --git a/site/scripts/api-docs-builder/src/index.ts b/site/scripts/api-docs-builder/src/index.ts new file mode 100644 index 00000000..006c2894 --- /dev/null +++ b/site/scripts/api-docs-builder/src/index.ts @@ -0,0 +1,252 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { kebabCase } from 'es-toolkit/string'; +import * as ts from 'typescript'; +import * as tae from 'typescript-api-extractor'; +import { extractCore } from './core-handler.js'; +import { extractDataAttrs } from './data-attrs-handler.js'; +import { extractHtml } from './html-handler.js'; +import { + type ComponentApiReference, + ComponentApiReferenceSchema, + type ComponentSource, + type DataAttrDef, + type PropDef, + type StateDef, +} from './types.js'; +import { kebabToPascal, sortProps } from './utils.js'; + +// Magenta prefix - visible on both light and dark terminals +const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m'; + +const log = { + info: (...args: unknown[]) => console.log(PREFIX, ...args), + warn: (...args: unknown[]) => console.warn(PREFIX, '\x1b[33mwarn:\x1b[0m', ...args), + error: (...args: unknown[]) => console.error(PREFIX, '\x1b[31merror:\x1b[0m', ...args), + success: (...args: unknown[]) => console.log(PREFIX, ...args), +}; + +// Paths relative to the monorepo root +const MONOREPO_ROOT = path.resolve(import.meta.dirname, '../../../../'); +const CORE_UI_PATH = path.join(MONOREPO_ROOT, 'packages/core/src/core/ui'); +const HTML_UI_PATH = path.join(MONOREPO_ROOT, 'packages/html/src/ui'); +const OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-api-reference'); + +/** + * Discover all components by scanning the core/ui directory. + */ +function discoverComponents(): ComponentSource[] { + const components: ComponentSource[] = []; + + if (!fs.existsSync(CORE_UI_PATH)) { + log.error(`Core UI path not found: ${CORE_UI_PATH}`); + return components; + } + + const dirs = fs.readdirSync(CORE_UI_PATH, { withFileTypes: true }); + + for (const dir of dirs) { + if (!dir.isDirectory()) continue; + + const componentName = kebabToPascal(dir.name); + const componentDir = path.join(CORE_UI_PATH, dir.name); + + // Look for core file + const coreFile = path.join(componentDir, `${dir.name}-core.ts`); + const dataAttrsFile = path.join(componentDir, `${dir.name}-data-attrs.ts`); + + // Look for HTML element file + const htmlFile = path.join(HTML_UI_PATH, dir.name, `${dir.name}-element.ts`); + + const source: ComponentSource = { + name: componentName, + }; + + if (fs.existsSync(coreFile)) { + source.corePath = coreFile; + } + + if (fs.existsSync(dataAttrsFile)) { + source.dataAttrsPath = dataAttrsFile; + } + + if (fs.existsSync(htmlFile)) { + source.htmlPath = htmlFile; + } + + // Only include if we have at least a core file + if (source.corePath) { + components.push(source); + } + } + + return components; +} + +/** + * Create a TypeScript program for all relevant files. + */ +function createProgram(sources: ComponentSource[]): ts.Program { + const files: string[] = []; + + for (const source of sources) { + if (source.corePath) files.push(source.corePath); + if (source.dataAttrsPath) files.push(source.dataAttrsPath); + if (source.htmlPath) files.push(source.htmlPath); + } + + // Load base tsconfig - works for all packages since we only need type resolution + const tsconfigPath = path.join(MONOREPO_ROOT, 'tsconfig.base.json'); + const config = tae.loadConfig(tsconfigPath); + + config.options.rootDir = MONOREPO_ROOT; + + return ts.createProgram(files, config.options); +} + +/** + * Build the API reference for a single component. + */ +function buildComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null { + // Extract from core + const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; + + if (!coreData) { + log.warn(`No core data found for ${source.name}`); + return null; + } + + // Extract data attributes + const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null; + + // Extract HTML element info + const htmlData = source.htmlPath ? extractHtml(source.htmlPath, program, source.name) : null; + + // Build props record + const props: Record = {}; + for (const prop of coreData.props) { + props[prop.name] = { + type: prop.type, + shortType: prop.shortType, + description: prop.description, + default: coreData.defaultProps[prop.name] ?? prop.default, + required: prop.required, + }; + + // Clean up undefined values + if (props[prop.name]!.shortType === undefined) delete props[prop.name]!.shortType; + if (props[prop.name]!.description === undefined) delete props[prop.name]!.description; + if (props[prop.name]!.default === undefined) delete props[prop.name]!.default; + if (!props[prop.name]!.required) delete props[prop.name]!.required; + } + + // Build state record + const state: Record = {}; + for (const s of coreData.state) { + state[s.name] = { + type: s.type, + description: s.description, + }; + if (state[s.name]!.description === undefined) delete state[s.name]!.description; + } + + // Build data attributes record + const dataAttributes: Record = {}; + if (dataAttrsData) { + for (const attr of dataAttrsData.attrs) { + dataAttributes[attr.name] = { + description: attr.description, + }; + } + } + + // Build result + const result: ComponentApiReference = { + name: source.name, + description: coreData.description, + props, + state, + dataAttributes, + platforms: {}, + }; + + // Add HTML platform info if available + if (htmlData) { + result.platforms.html = { + tagName: htmlData.tagName, + }; + } + + // Clean up undefined description + if (result.description === undefined) delete result.description; + + return result; +} + +/** + * Main entry point. + */ +function main() { + // Ensure output directory exists + if (!fs.existsSync(OUTPUT_PATH)) { + fs.mkdirSync(OUTPUT_PATH, { recursive: true }); + } + + // Discover components + const components = discoverComponents(); + + if (components.length === 0) { + log.info('No components found.'); + return; + } + log.info(`Found ${components.length} components. Processing...`); + + // Create TypeScript program + const program = createProgram(components); + + // Process each component + let successCount = 0; + let errorCount = 0; + + for (const source of components) { + try { + const apiRef = buildComponentApiReference(source, program); + + if (apiRef) { + // Sort props + apiRef.props = sortProps(apiRef.props); + + // Validate against schema before writing + const validated = ComponentApiReferenceSchema.safeParse(apiRef); + if (!validated.success) { + log.error(`Schema validation failed for ${source.name}:`); + for (const issue of validated.error.issues) { + log.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + errorCount++; + continue; + } + + // Write JSON file + const outputFile = path.join(OUTPUT_PATH, `${kebabCase(source.name)}.json`); + const json = `${JSON.stringify(validated.data, null, 2)}\n`; + fs.writeFileSync(outputFile, json); + + log.success(`✅ Generated ${path.basename(outputFile)}`); + successCount++; + } + } catch (error) { + log.error(`⚠️ Error processing ${source.name}:`, (error as Error).message); + errorCount++; + } + } + + log.info(`Done! Generated ${successCount} files.`); + + if (errorCount > 0) { + log.error(`${errorCount} errors occurred.`); + process.exit(1); + } +} + +main(); diff --git a/site/scripts/api-docs-builder/src/tests/core-handler.test.ts b/site/scripts/api-docs-builder/src/tests/core-handler.test.ts new file mode 100644 index 00000000..090f17cc --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/core-handler.test.ts @@ -0,0 +1,336 @@ +import * as tae from 'typescript-api-extractor'; +import { describe, expect, it, type MockInstance, vi } from 'vitest'; +import { extractCore, extractDefaultProps } from '../core-handler.js'; +import { createTestProgram } from './test-utils.js'; + +vi.mock('typescript-api-extractor', async () => { + const actual = await vi.importActual('typescript-api-extractor'); + return { + ...actual, + parseFromProgram: vi.fn(), + }; +}); + +const mockParseFromProgram = tae.parseFromProgram as unknown as MockInstance; + +describe('extractDefaultProps', () => { + it("extracts string literals with quotes ('label' → \"''\")", () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + label: '', + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.label).toBe("''"); + }); + + it("extracts non-empty string literals ('Play' → \"'Play'\")", () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + label: 'Play', + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.label).toBe("'Play'"); + }); + + it('extracts booleans (false → "false")', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + disabled: false, + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.disabled).toBe('false'); + }); + + it('extracts booleans (true → "true")', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + enabled: true, + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.enabled).toBe('true'); + }); + + it('extracts null values (null → "null")', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + value: null, + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.value).toBe('null'); + }); + + it('extracts empty arrays ([] → "[]")', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + items: [], + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.items).toBe('[]'); + }); + + it('extracts empty objects ({} → "{}")', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + config: {}, + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.config).toBe('{}'); + }); + + it('extracts numeric literals', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + count: 42, + ratio: 1.5, + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result.count).toBe('42'); + expect(result.ratio).toBe('1.5'); + }); + + it('returns empty object when class not found', () => { + const code = ` + export class OtherClass { + static readonly defaultProps = { + label: 'test', + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result).toEqual({}); + }); + + it('returns empty object when no defaultProps property', () => { + const code = ` + export class MockComponentCore { + static readonly otherProperty = { + label: 'test', + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result).toEqual({}); + }); + + it('ignores non-static defaultProps', () => { + const code = ` + export class MockComponentCore { + readonly defaultProps = { + label: 'test', + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + expect(result).toEqual({}); + }); +}); + +describe('getPropertyValue', () => { + it('falls back to getText for complex expressions', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + label: \`hello \${world}\`, + }; + } + `; + const program = createTestProgram(code); + const result = extractDefaultProps('test.ts', program, 'MockComponent'); + + // biome-ignore lint/suspicious/noTemplateCurlyInString: testing template literal extraction + expect(result.label).toBe('`hello ${world}`'); + }); +}); + +describe('extractCore', () => { + function createMockAst(exports: Array<{ name: string; type: unknown; documentation?: unknown }>) { + return { exports }; + } + + function createMockObjectNode(properties: tae.PropertyNode[]): tae.ObjectNode { + const node = Object.create(tae.ObjectNode.prototype); + node.properties = properties; + return node; + } + + function createMockIntrinsicNode(intrinsic: string): tae.IntrinsicNode { + const node = Object.create(tae.IntrinsicNode.prototype); + node.intrinsic = intrinsic; + node.typeName = undefined; + return node; + } + + function createMockPropertyNode( + name: string, + typeName: string, + options: { optional?: boolean; description?: string; defaultValue?: string } = {} + ): tae.PropertyNode { + const type = createMockIntrinsicNode(typeName); + const documentation = + options.description !== undefined || options.defaultValue !== undefined + ? ({ + description: options.description, + defaultValue: options.defaultValue, + hasTag: () => false, + } as unknown as tae.Documentation) + : undefined; + + return { name, type, optional: options.optional ?? false, documentation } as tae.PropertyNode; + } + + it('returns null when neither Props nor State export is found', () => { + const code = 'export const x = 1;'; + const program = createTestProgram(code); + + mockParseFromProgram.mockReturnValueOnce( + createMockAst([{ name: 'SomethingElse', type: createMockIntrinsicNode('string') }]) + ); + + const result = extractCore('test.ts', program, 'MockComponent'); + + expect(result).toBeNull(); + }); + + it('extracts props when propsExport.type is an ObjectNode', () => { + const code = 'export const x = 1;'; + const program = createTestProgram(code); + + const propsType = createMockObjectNode([ + createMockPropertyNode('label', 'string', { optional: true }), + createMockPropertyNode('disabled', 'boolean', { optional: true }), + ]); + + mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }])); + + const result = extractCore('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.props).toHaveLength(2); + expect(result!.props[0]!.name).toBe('label'); + expect(result!.props[0]!.type).toBe('string'); + expect(result!.props[1]!.name).toBe('disabled'); + expect(result!.props[1]!.type).toBe('boolean'); + }); + + it('extracts state when stateExport.type is an ObjectNode', () => { + const code = 'export const x = 1;'; + const program = createTestProgram(code); + + const stateType = createMockObjectNode([createMockPropertyNode('paused', 'boolean', { optional: false })]); + + mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentState', type: stateType }])); + + const result = extractCore('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.state).toHaveLength(1); + expect(result!.state[0]!.name).toBe('paused'); + expect(result!.state[0]!.type).toBe('boolean'); + }); + + it('extracts description from propsExport documentation', () => { + const code = 'export const x = 1;'; + const program = createTestProgram(code); + + const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]); + + mockParseFromProgram.mockReturnValueOnce( + createMockAst([ + { + name: 'MockComponentProps', + type: propsType, + documentation: { description: 'Props for the play button.' }, + }, + ]) + ); + + const result = extractCore('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.description).toBe('Props for the play button.'); + }); + + it('skips props when propsExport.type is not an ObjectNode', () => { + const code = 'export const x = 1;'; + const program = createTestProgram(code); + + mockParseFromProgram.mockReturnValueOnce( + createMockAst([ + { name: 'MockComponentProps', type: createMockIntrinsicNode('string') }, + { name: 'MockComponentState', type: createMockObjectNode([createMockPropertyNode('paused', 'boolean')]) }, + ]) + ); + + const result = extractCore('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.props).toHaveLength(0); + expect(result!.state).toHaveLength(1); + }); + + it('merges defaultProps from extractDefaultProps into result', () => { + const code = ` + export class MockComponentCore { + static readonly defaultProps = { + label: 'Play', + }; + } + `; + const program = createTestProgram(code); + + const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]); + + mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }])); + + const result = extractCore('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.defaultProps).toEqual({ label: "'Play'" }); + }); +}); diff --git a/site/scripts/api-docs-builder/src/tests/data-attrs-handler.test.ts b/site/scripts/api-docs-builder/src/tests/data-attrs-handler.test.ts new file mode 100644 index 00000000..cce9e08a --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/data-attrs-handler.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { extractDataAttrs } from '../data-attrs-handler.js'; +import { createTestProgram } from './test-utils.js'; + +describe('extractDataAttrs', () => { + it('extracts from {Name}DataAttrs constant', () => { + const code = ` + export const MockComponentDataAttrs = { + active: 'data-active', + disabled: 'data-disabled', + } as const; + `; + const program = createTestProgram(code); + const result = extractDataAttrs('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.attrs).toHaveLength(2); + expect(result!.attrs[0]!.name).toBe('data-active'); + expect(result!.attrs[1]!.name).toBe('data-disabled'); + }); + + it('extracts from {Name}DataAttributes constant (alternate naming)', () => { + const code = ` + export const MockComponentDataAttributes = { + paused: 'data-paused', + } as const; + `; + const program = createTestProgram(code); + const result = extractDataAttrs('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.attrs).toHaveLength(1); + expect(result!.attrs[0]!.name).toBe('data-paused'); + }); + + it('extracts JSDoc comments for each property', () => { + const code = ` + export const MockComponentDataAttrs = { + /** Present when the component is active. */ + active: 'data-active', + /** Present when the component is disabled. */ + disabled: 'data-disabled', + } as const; + `; + const program = createTestProgram(code); + const result = extractDataAttrs('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.attrs[0]!.description).toBe('Present when the component is active.'); + expect(result!.attrs[1]!.description).toBe('Present when the component is disabled.'); + }); + + it('handles object without as const', () => { + const code = ` + export const MockComponentDataAttrs = { + value: 'data-value', + }; + `; + const program = createTestProgram(code); + const result = extractDataAttrs('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.attrs).toHaveLength(1); + }); + + it('returns null when constant not found', () => { + const code = ` + export const OtherConstant = { + value: 'data-value', + }; + `; + const program = createTestProgram(code); + const result = extractDataAttrs('test.ts', program, 'MockComponent'); + + expect(result).toBeNull(); + }); + + it('extracts single-line // comments for properties', () => { + const code = ` + export const MockComponentDataAttrs = { + // Present when the component is focused. + focused: 'data-focused', + } as const; + `; + const program = createTestProgram(code); + const result = extractDataAttrs('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.attrs[0]!.description).toBe('Present when the component is focused.'); + }); + + it('falls back to data-{key} when value is not a string literal', () => { + const code = ` + const PREFIX = 'data-'; + export const MockComponentDataAttrs = { + active: PREFIX + 'active', + }; + `; + const program = createTestProgram(code); + const result = extractDataAttrs('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.attrs[0]!.name).toBe('data-active'); + }); +}); diff --git a/site/scripts/api-docs-builder/src/tests/formatter.test.ts b/site/scripts/api-docs-builder/src/tests/formatter.test.ts new file mode 100644 index 00000000..c83963af --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/formatter.test.ts @@ -0,0 +1,490 @@ +import * as tae from 'typescript-api-extractor'; +import { describe, expect, it } from 'vitest'; +import { formatProperties, formatType, getShortPropType } from '../formatter'; + +describe('getShortPropType', () => { + it("returns 'function' for callback props (onX with =>)", () => { + expect(getShortPropType('onClick', '(event: Event) => void')).toBe('function'); + expect(getShortPropType('onChange', '(value: string) => void')).toBe('function'); + }); + + it("returns 'function' for getter props (getX with =>)", () => { + expect(getShortPropType('getValue', '() => string')).toBe('function'); + expect(getShortPropType('getState', '() => State')).toBe('function'); + }); + + it("returns 'string | function' for className with =>", () => { + expect(getShortPropType('className', 'string | ((state: State) => string)')).toBe('string | function'); + }); + + it("returns 'CSSProperties | function' for style with =>", () => { + expect(getShortPropType('style', 'CSSProperties | ((state: State) => CSSProperties)')).toBe( + 'CSSProperties | function' + ); + }); + + it("returns 'ReactElement | function' for render with =>", () => { + expect(getShortPropType('render', 'ReactElement | ((state: State) => ReactElement)')).toBe( + 'ReactElement | function' + ); + }); + + it('returns undefined for simple types (boolean, string, number)', () => { + expect(getShortPropType('disabled', 'boolean')).toBeUndefined(); + expect(getShortPropType('label', 'string')).toBeUndefined(); + expect(getShortPropType('count', 'number')).toBeUndefined(); + }); + + it('returns undefined for short unions (< 3 members and < 40 chars)', () => { + expect(getShortPropType('size', "'small' | 'large'")).toBeUndefined(); + expect(getShortPropType('value', 'string | number')).toBeUndefined(); + }); + + it("returns 'type | function' for unions containing functions", () => { + const type = "string | ((state: State) => string) | 'auto'"; + expect(getShortPropType('label', type)).toBe("string | 'auto' | function"); + }); + + it('returns undefined for complex unions (NOT "Union")', () => { + // Complex union with 3+ members, no function + const complexUnion = "'small' | 'medium' | 'large' | 'xlarge'"; + expect(getShortPropType('size', complexUnion)).toBeUndefined(); + }); +}); + +describe('formatProperties', () => { + it('skips ref prop', () => { + const props: tae.PropertyNode[] = [ + createPropertyNode('label', 'string', { optional: true }), + createPropertyNode('ref', 'any', { optional: true }), + ]; + + const result = formatProperties(props); + + expect(result).toHaveProperty('label'); + expect(result).not.toHaveProperty('ref'); + }); + + it('skips props with @ignore JSDoc tag', () => { + const props: tae.PropertyNode[] = [ + createPropertyNode('label', 'string', { optional: true }), + createPropertyNode('ignoredProp', 'string', { optional: true, hasIgnoreTag: true }), + ]; + + const result = formatProperties(props); + + expect(result).toHaveProperty('label'); + expect(result).not.toHaveProperty('ignoredProp'); + }); + + it('sets required: true for non-optional props', () => { + const props: tae.PropertyNode[] = [ + createPropertyNode('required', 'string', { optional: false }), + createPropertyNode('optional', 'string', { optional: true }), + ]; + + const result = formatProperties(props); + + expect(result.required?.required).toBe(true); + expect(result.optional?.required).toBeUndefined(); + }); + + it('cleans up undefined values from result', () => { + const props: tae.PropertyNode[] = [createPropertyNode('simple', 'boolean', { optional: true })]; + + const result = formatProperties(props); + + expect(result.simple).toEqual({ type: 'boolean' }); + expect(Object.keys(result.simple!)).not.toContain('shortType'); + expect(Object.keys(result.simple!)).not.toContain('default'); + expect(Object.keys(result.simple!)).not.toContain('required'); + }); + + it('passes through description from documentation', () => { + const props: tae.PropertyNode[] = [ + createPropertyNode('label', 'string', { optional: true, description: 'The button label.' }), + ]; + + const result = formatProperties(props); + + expect(result.label?.description).toBe('The button label.'); + }); + + it('passes through default from documentation.defaultValue', () => { + const props: tae.PropertyNode[] = [ + createPropertyNode('disabled', 'boolean', { optional: true, defaultValue: 'false' }), + ]; + + const result = formatProperties(props); + + expect(result.disabled?.default).toBe('false'); + }); + + it('sets shortType for callback props', () => { + const fnType = createFunctionNode([ + { + parameters: [ + { + name: 'event', + type: createIntrinsicNode('Event'), + optional: false, + documentation: undefined, + defaultValue: undefined, + } as tae.Parameter, + ], + returnValueType: createIntrinsicNode('void'), + } as tae.CallSignature, + ]); + + const prop = { + name: 'onClick', + type: fnType, + optional: true, + documentation: undefined, + } as tae.PropertyNode; + + const result = formatProperties([prop]); + + expect(result.onClick?.shortType).toBe('function'); + }); +}); + +describe('formatType', () => { + it('formats IntrinsicNode (boolean, string, number)', () => { + const boolNode = createIntrinsicNode('boolean'); + const strNode = createIntrinsicNode('string'); + const numNode = createIntrinsicNode('number'); + + expect(formatType(boolNode, false)).toBe('boolean'); + expect(formatType(strNode, false)).toBe('string'); + expect(formatType(numNode, false)).toBe('number'); + }); + + it('formats UnionNode and removes undefined when optional', () => { + const unionNode = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('undefined')]); + + expect(formatType(unionNode, true)).toBe('string'); + expect(formatType(unionNode, false)).toBe('string | undefined'); + }); + + it('flattens nested unions', () => { + const innerUnion = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); + const outerUnion = createUnionNode([innerUnion, createIntrinsicNode('boolean')]); + + expect(formatType(outerUnion, false)).toBe('string | number | boolean'); + }); + + it('formats ObjectNode with properties', () => { + const objNode = createObjectNode([ + { name: 'x', type: createIntrinsicNode('number'), optional: false }, + { name: 'y', type: createIntrinsicNode('number'), optional: true }, + ]); + + expect(formatType(objNode, false)).toBe('{ x: number; y?: number }'); + }); + + it('formats ArrayNode with parentheses for complex element types', () => { + const simpleArray = createArrayNode(createIntrinsicNode('string')); + const complexArray = createArrayNode( + createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]) + ); + + expect(formatType(simpleArray, false)).toBe('string[]'); + expect(formatType(complexArray, false)).toBe('(string | number)[]'); + }); + + it('orders members with null/undefined/any last', () => { + const unionNode = createUnionNode([ + createIntrinsicNode('null'), + createIntrinsicNode('string'), + createIntrinsicNode('undefined'), + createIntrinsicNode('number'), + ]); + + expect(formatType(unionNode, false)).toBe('string | number | null | undefined'); + }); + + it('normalizes quotes (double to single)', () => { + const literalNode = createLiteralNode('"hello"'); + + expect(formatType(literalNode, false)).toBe("'hello'"); + }); + + // --- ExternalTypeNode --- + + it('formats ExternalTypeNode ReactElement to just ReactElement', () => { + const node = createExternalTypeNode('ReactElement', undefined, [ + { type: createIntrinsicNode('Props'), equalToDefault: false }, + ]); + + expect(formatType(node, false)).toBe('ReactElement'); + }); + + it('formats ExternalTypeNode with React namespace by stripping namespace', () => { + const node = createExternalTypeNode('CSSProperties', ['React']); + + expect(formatType(node, false)).toBe('CSSProperties'); + }); + + it('formats ExternalTypeNode with fully qualified name', () => { + const node = createExternalTypeNode('Baz', ['Foo', 'Bar']); + + expect(formatType(node, false)).toBe('Foo.Bar.Baz'); + }); + + it('formats ExternalTypeNode with non-default type arguments', () => { + const node = createExternalTypeNode('Map', undefined, [ + { type: createIntrinsicNode('string'), equalToDefault: false }, + { type: createIntrinsicNode('number'), equalToDefault: false }, + ]); + + expect(formatType(node, false)).toBe('Map'); + }); + + // --- IntersectionNode --- + + it('formats IntersectionNode without typeName', () => { + const node = createIntersectionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); + + expect(formatType(node, false)).toBe('string & number'); + }); + + it('formats IntersectionNode with typeName as fully qualified name', () => { + const typeName = createTypeName('Combined'); + const node = createIntersectionNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName); + + expect(formatType(node, false)).toBe('Combined'); + }); + + // --- FunctionNode --- + + it('formats FunctionNode without typeName', () => { + const node = createFunctionNode([ + { + parameters: [ + { + name: 'x', + type: createIntrinsicNode('string'), + optional: false, + documentation: undefined, + defaultValue: undefined, + } as tae.Parameter, + ], + returnValueType: createIntrinsicNode('void'), + } as tae.CallSignature, + ]); + + expect(formatType(node, false)).toBe('((x: string) => void)'); + }); + + it('formats FunctionNode with typeName as fully qualified name', () => { + const typeName = createTypeName('MyHandler'); + const node = createFunctionNode( + [ + { + parameters: [], + returnValueType: createIntrinsicNode('void'), + } as tae.CallSignature, + ], + typeName + ); + + expect(formatType(node, false)).toBe('MyHandler'); + }); + + // --- TupleNode --- + + it('formats TupleNode without typeName', () => { + const node = createTupleNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); + + expect(formatType(node, false)).toBe('[string, number]'); + }); + + it('formats TupleNode with typeName as fully qualified name', () => { + const typeName = createTypeName('Pair'); + const node = createTupleNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName); + + expect(formatType(node, false)).toBe('Pair'); + }); + + // --- TypeParameterNode --- + + it('formats TypeParameterNode with constraint', () => { + const node = createTypeParameterNode('T', createIntrinsicNode('string')); + + expect(formatType(node, false)).toBe('string'); + }); + + it('formats TypeParameterNode without constraint returns the name', () => { + const node = createTypeParameterNode('T'); + + expect(formatType(node, false)).toBe('T'); + }); + + // --- UnionNode with typeName --- + + it('formats UnionNode with typeName as fully qualified name', () => { + const typeName = createTypeName('Status'); + const node = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName); + + expect(formatType(node, false)).toBe('Status'); + }); + + // --- ObjectNode edge cases --- + + it('formats empty ObjectNode as {}', () => { + const node = createObjectNode([]); + + expect(formatType(node, false)).toBe('{}'); + }); + + // --- Unknown node --- + + it('returns unknown for unrecognized node type', () => { + const node = {} as tae.AnyType; + + expect(formatType(node, false)).toBe('unknown'); + }); + + // --- Union dedup --- + + it('deduplicates union members via uniq', () => { + const node = createUnionNode([ + createIntrinsicNode('string'), + createIntrinsicNode('string'), + createIntrinsicNode('number'), + ]); + + expect(formatType(node, false)).toBe('string | number'); + }); + + // --- TypeParameterNode constraint flattening in union --- + + it('flattens TypeParameterNode constraint in union', () => { + const constraintUnion = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); + const typeParam = createTypeParameterNode('T', constraintUnion); + const union = createUnionNode([typeParam, createIntrinsicNode('boolean')]); + + expect(formatType(union, false)).toBe('string | number | boolean'); + }); +}); + +// --- Helper factories --- + +function createPropertyNode( + name: string, + typeName: string, + options: { optional?: boolean; hasIgnoreTag?: boolean; description?: string; defaultValue?: string } = {} +): tae.PropertyNode { + const type = createIntrinsicNode(typeName); + const documentation = + options.hasIgnoreTag || options.description !== undefined || options.defaultValue !== undefined + ? createDocumentation(options) + : undefined; + + return { + name, + type, + optional: options.optional ?? false, + documentation, + } as tae.PropertyNode; +} + +function createDocumentation(options: { + hasIgnoreTag?: boolean; + description?: string; + defaultValue?: string; +}): tae.Documentation { + return { + description: options.description, + defaultValue: options.defaultValue, + hasTag: (tag: string) => (tag === 'ignore' ? (options.hasIgnoreTag ?? false) : false), + } as unknown as tae.Documentation; +} + +function createIntrinsicNode(intrinsic: string): tae.IntrinsicNode { + const node = Object.create(tae.IntrinsicNode.prototype); + node.intrinsic = intrinsic; + node.typeName = undefined; + return node; +} + +function createUnionNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.UnionNode { + const node = Object.create(tae.UnionNode.prototype); + node.types = types; + node.typeName = typeName; + return node; +} + +function createObjectNode( + properties: Array<{ name: string; type: tae.AnyType; optional: boolean }>, + typeName?: tae.TypeName +): tae.ObjectNode { + const node = Object.create(tae.ObjectNode.prototype); + node.properties = properties.map((p) => ({ + name: p.name, + type: p.type, + optional: p.optional, + })); + node.typeName = typeName; + return node; +} + +function createArrayNode(elementType: tae.AnyType): tae.ArrayNode { + const node = Object.create(tae.ArrayNode.prototype); + node.elementType = elementType; + return node; +} + +function createLiteralNode(value: string): tae.LiteralNode { + const node = Object.create(tae.LiteralNode.prototype); + node.value = value; + return node; +} + +function createExternalTypeNode( + name: string, + namespaces?: string[], + typeArguments?: Array<{ type: tae.AnyType; equalToDefault: boolean }> +): tae.ExternalTypeNode { + const node = Object.create(tae.ExternalTypeNode.prototype); + node.typeName = createTypeName(name, namespaces, typeArguments); + return node; +} + +function createIntersectionNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.IntersectionNode { + const node = Object.create(tae.IntersectionNode.prototype); + node.types = types; + node.typeName = typeName; + node.properties = []; + return node; +} + +function createFunctionNode(callSignatures: tae.CallSignature[], typeName?: tae.TypeName): tae.FunctionNode { + const node = Object.create(tae.FunctionNode.prototype); + node.callSignatures = callSignatures; + node.typeName = typeName; + return node; +} + +function createTupleNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.TupleNode { + const node = Object.create(tae.TupleNode.prototype); + node.types = types; + node.typeName = typeName; + return node; +} + +function createTypeParameterNode(name: string, constraint?: tae.AnyType): tae.TypeParameterNode { + const node = Object.create(tae.TypeParameterNode.prototype); + node.name = name; + node.constraint = constraint; + return node; +} + +function createTypeName( + name: string, + namespaces?: string[], + typeArguments?: Array<{ type: tae.AnyType; equalToDefault: boolean }> +): tae.TypeName { + return new tae.TypeName(name, namespaces, typeArguments); +} diff --git a/site/scripts/api-docs-builder/src/tests/html-handler.test.ts b/site/scripts/api-docs-builder/src/tests/html-handler.test.ts new file mode 100644 index 00000000..14688487 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/html-handler.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { extractHtml } from '../html-handler.js'; +import { createTestProgram } from './test-utils.js'; + +describe('extractHtml', () => { + it('extracts tagName from {Name}Element class', () => { + const code = ` + export class MockComponentElement { + static readonly tagName = 'media-mock-component'; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.tagName).toBe('media-mock-component'); + }); + + it('extracts tagName without readonly modifier', () => { + const code = ` + export class MockComponentElement { + static tagName = 'media-mock-component'; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.tagName).toBe('media-mock-component'); + }); + + it('returns null when Element class not found', () => { + const code = ` + export class OtherClass { + static readonly tagName = 'media-other'; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'MockComponent'); + + expect(result).toBeNull(); + }); + + it('returns null when tagName not static', () => { + const code = ` + export class MockComponentElement { + readonly tagName = 'media-mock-component'; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'MockComponent'); + + expect(result).toBeNull(); + }); + + it('returns null when tagName is not a string literal', () => { + const code = ` + const TAG = 'media-mock-component'; + export class MockComponentElement { + static readonly tagName = TAG; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'MockComponent'); + + expect(result).toBeNull(); + }); + + it('returns null when no tagName property exists', () => { + const code = ` + export class MockComponentElement { + static readonly otherProperty = 'value'; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'MockComponent'); + + expect(result).toBeNull(); + }); +}); diff --git a/site/scripts/api-docs-builder/src/tests/test-utils.ts b/site/scripts/api-docs-builder/src/tests/test-utils.ts new file mode 100644 index 00000000..3f4471f8 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/test-utils.ts @@ -0,0 +1,13 @@ +import * as ts from 'typescript'; + +/** Only suitable for AST-walking tests — no type resolution. */ +export function createTestProgram(code: string, fileName = 'test.ts'): ts.Program { + const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); + const compilerHost = ts.createCompilerHost({}); + const originalGetSourceFile = compilerHost.getSourceFile; + compilerHost.getSourceFile = (name, ...args) => { + return name === fileName ? sourceFile : originalGetSourceFile.call(compilerHost, name, ...args); + }; + compilerHost.fileExists = (name) => name === fileName; + return ts.createProgram([fileName], {}, compilerHost); +} diff --git a/site/scripts/api-docs-builder/src/tests/utils.test.ts b/site/scripts/api-docs-builder/src/tests/utils.test.ts new file mode 100644 index 00000000..81e3e395 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/utils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { kebabToPascal, sortProps } from '../utils.js'; + +describe('kebabToPascal', () => { + it("converts 'play-button' to 'PlayButton'", () => { + expect(kebabToPascal('play-button')).toBe('PlayButton'); + }); + + it("converts 'slider' to 'Slider'", () => { + expect(kebabToPascal('slider')).toBe('Slider'); + }); + + it("converts 'time-display-current' to 'TimeDisplayCurrent'", () => { + expect(kebabToPascal('time-display-current')).toBe('TimeDisplayCurrent'); + }); +}); + +describe('sortProps', () => { + it('sorts required props before optional props', () => { + const props = { + optional: { type: 'string' }, + required: { type: 'string', required: true as const }, + }; + + const result = sortProps(props); + const keys = Object.keys(result); + + expect(keys).toEqual(['required', 'optional']); + }); + + it('sorts alphabetically within each group', () => { + const props = { + zebra: { type: 'string', required: true as const }, + apple: { type: 'string', required: true as const }, + mango: { type: 'string' }, + banana: { type: 'string' }, + }; + + const result = sortProps(props); + const keys = Object.keys(result); + + expect(keys).toEqual(['apple', 'zebra', 'banana', 'mango']); + }); + + it('keeps all-optional props alphabetical', () => { + const props = { + charlie: { type: 'string' }, + alpha: { type: 'string' }, + bravo: { type: 'string' }, + }; + + const result = sortProps(props); + const keys = Object.keys(result); + + expect(keys).toEqual(['alpha', 'bravo', 'charlie']); + }); + + it('keeps all-required props alphabetical', () => { + const props = { + charlie: { type: 'string', required: true as const }, + alpha: { type: 'string', required: true as const }, + bravo: { type: 'string', required: true as const }, + }; + + const result = sortProps(props); + const keys = Object.keys(result); + + expect(keys).toEqual(['alpha', 'bravo', 'charlie']); + }); +}); diff --git a/site/scripts/api-docs-builder/src/types.ts b/site/scripts/api-docs-builder/src/types.ts new file mode 100644 index 00000000..793331ac --- /dev/null +++ b/site/scripts/api-docs-builder/src/types.ts @@ -0,0 +1,59 @@ +/** + * Re-export types from the shared schema. + * The shared schema in src/types/api-reference.ts is the single source of truth. + */ +export type { + ComponentApiReference, + DataAttrDef, + PropDef, + StateDef, +} from '../../../src/types/api-reference.js'; + +export { ComponentApiReferenceSchema } from '../../../src/types/api-reference.js'; + +/** + * Source file locations for a component across packages. + */ +export interface ComponentSource { + /** PascalCase component name (e.g., PlayButton) */ + name: string; + /** Path to core file (e.g., packages/core/src/core/ui/play-button/play-button-core.ts) */ + corePath?: string; + /** Path to data attrs file */ + dataAttrsPath?: string; + /** Path to HTML element file */ + htmlPath?: string; +} + +/** + * Extracted property from TypeScript analysis. + */ +export interface ExtractedProp { + name: string; + type: string; + shortType?: string; + description?: string; + default?: string; + required?: boolean; +} + +/** + * Extraction result from core package. + */ +export interface CoreExtraction { + description?: string; + props: ExtractedProp[]; + state: ExtractedProp[]; + defaultProps: Record; +} + +/** + * Extraction result from data attributes file. + */ +export interface DataAttrsExtraction { + attrs: Array<{ name: string; description: string }>; +} + +export interface HtmlExtraction { + tagName: string; +} diff --git a/site/scripts/api-docs-builder/src/utils.ts b/site/scripts/api-docs-builder/src/utils.ts new file mode 100644 index 00000000..2e036940 --- /dev/null +++ b/site/scripts/api-docs-builder/src/utils.ts @@ -0,0 +1,26 @@ +import type { PropDef } from './types.js'; + +export function kebabToPascal(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +export function sortProps(props: Record): Record { + const entries = Object.entries(props); + + entries.sort((a, b) => { + // Required first + const aRequired = a[1].required ?? false; + const bRequired = b[1].required ?? false; + + if (aRequired && !bRequired) return -1; + if (!aRequired && bRequired) return 1; + + // Then alphabetical + return a[0].localeCompare(b[0]); + }); + + return Object.fromEntries(entries); +} diff --git a/site/src/components/docs/api-reference/ApiDataAttrsTable.astro b/site/src/components/docs/api-reference/ApiDataAttrsTable.astro new file mode 100644 index 00000000..7f2d7a0f --- /dev/null +++ b/site/src/components/docs/api-reference/ApiDataAttrsTable.astro @@ -0,0 +1,43 @@ +--- +/** + * Renders the data attributes table for API reference. + */ +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Td from '@/components/typography/Td.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { DataAttrDef } from '@/types/api-reference'; +import InlineMarkdown from './InlineMarkdown.astro'; + +interface Props { + dataAttributes: Record; +} + +const { dataAttributes } = Astro.props; + +const attrs = Object.entries(dataAttributes); +--- + + + + + + + + + + { + attrs.map(([name, def]) => ( + + + + + )) + } + +
AttributeDescription
+ {name} +
diff --git a/site/src/components/docs/api-reference/ApiPropsTable.astro b/site/src/components/docs/api-reference/ApiPropsTable.astro new file mode 100644 index 00000000..447ac77a --- /dev/null +++ b/site/src/components/docs/api-reference/ApiPropsTable.astro @@ -0,0 +1,47 @@ +--- +/** + * Renders the props table for API reference. + * + * Props are sorted: required first, then alphabetical. + */ +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { PropDef } from '@/types/api-reference'; +import PropRow from './PropRow.astro'; + +interface Props { + props: Record; + componentName: string; +} + +const { props, componentName } = Astro.props; +--- + + + + + + + + + + + { + Object.entries(props).map(([name, prop]) => ( + + )) + } + +
PropTypeDefault +
diff --git a/site/src/components/docs/api-reference/ApiRefSection.astro b/site/src/components/docs/api-reference/ApiRefSection.astro new file mode 100644 index 00000000..fbb9ff11 --- /dev/null +++ b/site/src/components/docs/api-reference/ApiRefSection.astro @@ -0,0 +1,63 @@ +--- +import { getEntry } from 'astro:content'; +import { kebabCase } from 'es-toolkit/string'; +import ContentWidth from '@/components/frames/ContentWidth.astro'; +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import P from '@/components/typography/P.astro'; +import type { ComponentApiReference } from '@/types/api-reference'; +import { isValidFramework } from '@/types/docs'; +import FrameworkCase from '../FrameworkCase.astro'; +import ApiDataAttrsTable from './ApiDataAttrsTable.astro'; +import ApiPropsTable from './ApiPropsTable.astro'; +import ApiStateTable from './ApiStateTable.astro'; + +interface Props { + component: string; + section: 'props' | 'state' | 'dataAttributes'; +} + +const { component, section } = Astro.props; + +const { framework } = Astro.params; +if (!framework || !isValidFramework(framework)) { + throw new Error(`Invalid or missing framework param "${framework ?? 'undefined'}".`); +} + +const entry = await getEntry('apiReference', kebabCase(component)); +const apiRef: ComponentApiReference | null = entry?.data ?? null; + +const hasData = apiRef && Object.keys(apiRef[section]).length > 0; +--- + + + { + hasData && section === "props" && ( + + ) + } + + { + hasData && section === "state" && ( + <> +

+ + State is accessible via the{" "} + render,{" "} + className, and{" "} + style props. + + + State is reflected as data attributes for CSS styling. + +

+ + + ) + } + + { + hasData && section === "dataAttributes" && ( + + ) + } +
diff --git a/site/src/components/docs/api-reference/ApiStateTable.astro b/site/src/components/docs/api-reference/ApiStateTable.astro new file mode 100644 index 00000000..8122b93d --- /dev/null +++ b/site/src/components/docs/api-reference/ApiStateTable.astro @@ -0,0 +1,47 @@ +--- +/** + * Renders the state interface table for API reference. + */ +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Td from '@/components/typography/Td.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { StateDef } from '@/types/api-reference'; +import InlineMarkdown from './InlineMarkdown.astro'; + +interface Props { + state: Record; +} + +const { state } = Astro.props; + +const stateEntries = Object.entries(state); +--- + + + + + + + + + + + { + stateEntries.map(([name, def]) => ( + + + + + + )) + } + +
PropertyTypeDescription
+ {name} + + {def.type} +
diff --git a/site/src/components/docs/api-reference/InlineMarkdown.astro b/site/src/components/docs/api-reference/InlineMarkdown.astro new file mode 100644 index 00000000..27049c68 --- /dev/null +++ b/site/src/components/docs/api-reference/InlineMarkdown.astro @@ -0,0 +1,14 @@ +--- +import { renderInlineMarkdown } from '@/utils/docs/renderInlineMarkdown'; + +interface Props { + content?: string; + fallback?: string; +} + +const { content, fallback = '-' } = Astro.props; + +const html = content ? renderInlineMarkdown(content) : fallback; +--- + + diff --git a/site/src/components/docs/api-reference/PropRow.astro b/site/src/components/docs/api-reference/PropRow.astro new file mode 100644 index 00000000..19c9d361 --- /dev/null +++ b/site/src/components/docs/api-reference/PropRow.astro @@ -0,0 +1,140 @@ +--- +/** + * Single row in the props table with expandable details. + * + * Uses button disclosure pattern: the summary row has real `` cells, + * the detail row is a separate `` toggled via `aria-controls` + `hidden`. + * The entire summary row is clickable, delegating to the toggle button. + */ +import clsx from 'clsx'; +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import Td from '@/components/typography/Td.astro'; +import Tr from '@/components/typography/Tr.astro'; +import InlineMarkdown from './InlineMarkdown.astro'; + +interface Props { + name: string; + type: string; + shortType?: string; + description?: string; + defaultValue?: string; + required?: boolean; + componentName: string; +} + +const { name, type, shortType, description, defaultValue, required, componentName } = Astro.props; + +const displayType = shortType ?? type; +const hasDetail = Boolean(shortType || description); +const id = `${componentName}-${name}`; +--- + + + + + {name} + {required && *} + + + + {displayType} + + + + {defaultValue ?? "—"} + + + { + hasDetail && ( + + + + ) + } + + +{ + hasDetail && ( + + +
+
+ {description && ( + <> +
+ Description +
+
+ + )} + + {(type || shortType) && ( + <> +
+ Type +
+
+ + {type || shortType} + +
+ + )} +
+
+ + + ) +} + +{/* Astro deduplicates script tags, so this runs once per page. */} + diff --git a/site/src/components/typography/A.astro b/site/src/components/typography/A.astro index dcc3bd69..c542af4d 100644 --- a/site/src/components/typography/A.astro +++ b/site/src/components/typography/A.astro @@ -1,8 +1,9 @@ --- - import type { HTMLTag, Polymorphic } from 'astro/types'; import { twMerge } from 'tailwind-merge'; +import { shared } from './styles'; + type Props = Polymorphic<{ as: Tag }> & { class?: string; }; @@ -10,6 +11,6 @@ type Props = Polymorphic<{ as: Tag }> & { const { as: Tag = 'a', class: className, ...props } = Astro.props; --- - - + + diff --git a/site/src/components/typography/Em.astro b/site/src/components/typography/Em.astro index edf1f246..4c3653f5 100644 --- a/site/src/components/typography/Em.astro +++ b/site/src/components/typography/Em.astro @@ -1,8 +1,9 @@ --- - import type { HTMLTag, Polymorphic } from 'astro/types'; import { twMerge } from 'tailwind-merge'; +import { shared } from './styles'; + type Props = Polymorphic<{ as: Tag }> & { class?: string; }; @@ -10,6 +11,6 @@ type Props = Polymorphic<{ as: Tag }> & { const { as: Tag = 'em', class: className, ...props } = Astro.props; --- - - + + diff --git a/site/src/components/typography/Li.astro b/site/src/components/typography/Li.astro index 0eb9477c..b450341c 100644 --- a/site/src/components/typography/Li.astro +++ b/site/src/components/typography/Li.astro @@ -1,8 +1,9 @@ --- - import type { HTMLTag, Polymorphic } from 'astro/types'; import { twMerge } from 'tailwind-merge'; +import { shared } from './styles'; + type Props = Polymorphic<{ as: Tag }> & { class?: string; }; @@ -10,6 +11,6 @@ type Props = Polymorphic<{ as: Tag }> & { const { as: Tag = 'li', class: className, ...props } = Astro.props; --- - - + + diff --git a/site/src/components/typography/MarkdownCode.astro b/site/src/components/typography/MarkdownCode.astro index 193194e3..60f6e70f 100644 --- a/site/src/components/typography/MarkdownCode.astro +++ b/site/src/components/typography/MarkdownCode.astro @@ -1,8 +1,9 @@ --- - import type { HTMLTag, Polymorphic } from 'astro/types'; import { twMerge } from 'tailwind-merge'; +import { shared } from './styles'; + type Props = Polymorphic<{ as: Tag }> & { class?: string; codeBlock?: string; @@ -16,19 +17,13 @@ const isCodeBlock = codeBlock === 'true'; --- { - isCodeBlock ? ( - - - - ) : ( - - - - ) + isCodeBlock ? ( + + + + ) : ( + + + + ) } diff --git a/site/src/components/typography/Ol.astro b/site/src/components/typography/Ol.astro index dec1b7f6..de7b82e0 100644 --- a/site/src/components/typography/Ol.astro +++ b/site/src/components/typography/Ol.astro @@ -1,9 +1,10 @@ --- - import type { HTMLTag, Polymorphic } from 'astro/types'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import { shared } from './styles'; + type Props = Polymorphic<{ as: Tag }> & { maxWidth?: boolean; class?: string; @@ -13,8 +14,12 @@ const { as: Tag = 'ol', maxWidth = true, class: className, ...props } = Astro.pr --- - + diff --git a/site/src/components/typography/Strong.astro b/site/src/components/typography/Strong.astro index 358edc36..715fa394 100644 --- a/site/src/components/typography/Strong.astro +++ b/site/src/components/typography/Strong.astro @@ -1,8 +1,9 @@ --- - import type { HTMLTag, Polymorphic } from 'astro/types'; import { twMerge } from 'tailwind-merge'; +import { shared } from './styles'; + type Props = Polymorphic<{ as: Tag }> & { class?: string; }; @@ -10,6 +11,6 @@ type Props = Polymorphic<{ as: Tag }> & { const { as: Tag = 'strong', class: className, ...props } = Astro.props; --- - - + + diff --git a/site/src/components/typography/Table.astro b/site/src/components/typography/Table.astro index af5f0a13..d3ddd7a0 100644 --- a/site/src/components/typography/Table.astro +++ b/site/src/components/typography/Table.astro @@ -5,13 +5,14 @@ import { twMerge } from 'tailwind-merge'; type Props = Polymorphic<{ as: Tag }> & { maxWidth?: boolean; + outerClass?: string; class?: string; }; -const { as: Tag = 'table', maxWidth = true, class: className, ...props } = Astro.props; +const { as: Tag = 'table', maxWidth = true, outerClass, class: className, ...props } = Astro.props; --- -
+
= Polymorphic<{ as: Tag }> & { const { as: Tag = 'thead', class: className, ...props } = Astro.props; --- - + diff --git a/site/src/components/typography/Tr.astro b/site/src/components/typography/Tr.astro index 03fd311e..ea0bfc42 100644 --- a/site/src/components/typography/Tr.astro +++ b/site/src/components/typography/Tr.astro @@ -10,6 +10,6 @@ type Props = Polymorphic<{ as: Tag }> & { const { as: Tag = 'tr', class: className, ...props } = Astro.props; --- - + diff --git a/site/src/components/typography/Ul.astro b/site/src/components/typography/Ul.astro index 4547527f..087bb4bd 100644 --- a/site/src/components/typography/Ul.astro +++ b/site/src/components/typography/Ul.astro @@ -1,9 +1,10 @@ --- - import type { HTMLTag, Polymorphic } from 'astro/types'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import { shared } from './styles'; + type Props = Polymorphic<{ as: Tag }> & { maxWidth?: boolean; class?: string; @@ -13,8 +14,12 @@ const { as: Tag = 'ul', maxWidth = true, class: className, ...props } = Astro.pr --- - + diff --git a/site/src/components/typography/styles.ts b/site/src/components/typography/styles.ts new file mode 100644 index 00000000..76330137 --- /dev/null +++ b/site/src/components/typography/styles.ts @@ -0,0 +1,17 @@ +/** + * Shared Tailwind class strings for typography elements. + * + * Used by both the Astro typography components and `renderInlineMarkdown` + * so styling stays in sync across server-rendered MDX and programmatic + * HTML generation. + */ +export const shared = { + a: 'underline intent:no-underline', + code: 'bg-light-100 dark:bg-dark-110 dark:text-light-100 border border-light-40 dark:border-dark-80 px-1 rounded font-mono text-code', + codeBlock: 'font-mono text-code', + em: 'font-medium', + li: 'text-base', + ol: 'list-decimal list-outside pl-6 space-y-1', + strong: 'font-semibold', + ul: 'list-disc list-outside pl-6 space-y-1', +} as const; diff --git a/site/src/content.config.ts b/site/src/content.config.ts index a093e3ef..357d855e 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -1,5 +1,6 @@ import { defineCollection, reference, z } from 'astro:content'; -import { file } from 'astro/loaders'; +import { file, glob } from 'astro/loaders'; +import { ComponentApiReferenceSchema } from './types/api-reference'; import { SUPPORTED_FRAMEWORKS } from './types/docs'; import { defaultGitService } from './utils/gitService'; import { globWithParser } from './utils/globWithParser'; @@ -106,4 +107,12 @@ const authors = defineCollection({ }), }); -export const collections = { blog, docs, authors }; +const apiReference = defineCollection({ + loader: glob({ + pattern: '*.json', + base: './src/content/generated-api-reference', + }), + schema: ComponentApiReferenceSchema, +}); + +export const collections = { blog, docs, authors, apiReference }; diff --git a/site/src/content/docs/concepts/ui-components.mdx b/site/src/content/docs/concepts/ui-components.mdx index 897c673b..8dff5324 100644 --- a/site/src/content/docs/concepts/ui-components.mdx +++ b/site/src/content/docs/concepts/ui-components.mdx @@ -86,16 +86,7 @@ Single-element components that handle one piece of UI: - **PlayButton** - Play/pause toggle - **MuteButton** - Audio mute toggle -- **FullscreenButton** - Fullscreen toggle -- **CurrentTimeDisplay** - Current playback time -- **DurationDisplay** - Total media duration -- **PreviewTimeDisplay** - Time at hover position ### Compound components -Multi-element components for complex interactions: - -- **TimeSlider** - Seekable timeline with progress -- **VolumeSlider** - Volume control slider -- **Tooltip** - Hover tooltips with positioning -- **Popover** - Click/hover popovers with collision detection +Multi-element components for complex interactions diff --git a/site/src/content/docs/reference/fullscreen-button.mdx b/site/src/content/docs/reference/fullscreen-button.mdx deleted file mode 100644 index 5f035602..00000000 --- a/site/src/content/docs/reference/fullscreen-button.mdx +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: FullscreenButton -frameworkTitle: - html: fullscreen-button -description: A button component for toggling fullscreen mode ---- - -import { FullscreenButtonDemo } from '@/examples/react/FullscreenButton/FullscreenButtonDemo'; -import componentModuleStr from '@/examples/react/FullscreenButton/BasicFullscreenButton.tsx?raw'; -import cssModuleStr from '@/examples/react/FullscreenButton/FullscreenButton.module.css?raw'; -import htmlStr from '@/examples/html/fullscreen-button/snippet.html?raw'; -import htmlCssStr from '@/examples/html/fullscreen-button/fullscreen-button.css?raw'; -import htmlJsStr from '@/examples/html/fullscreen-button/fullscreen-button.js?raw'; -import FrameworkCase from '@/components/docs/FrameworkCase.astro'; -import ServerCode from '@/components/Code/ServerCode.astro'; -import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs'; - -## Features - -- Automatically switches icons based on fullscreen state -- Works with browser Fullscreen API -- Falls back gracefully when fullscreen not supported -- Accessible keyboard navigation - -## Example - - - - - Component - CSS Module - - - - - - - - - - - - - - - HTML - CSS - JS - - - - - - - - - - - - - - -## Data Attributes - -The FullscreenButton automatically sets data attributes based on fullscreen state: - -- `data-fullscreen` - Present when in fullscreen, absent when not - -Use these attributes for state-based styling in your CSS. - - - -## Props - -All standard button props are supported, plus: - -| Prop | Type | Description | -|------|------|-------------| -| `children` | `ReactNode` | Button content (typically icons) | -| `className` | `string` | CSS class name | - - - -## Accessibility - -- Automatically includes proper ARIA labels -- Keyboard accessible (Space/Enter) -- Announces fullscreen state changes to screen readers - -## Browser support - -The FullscreenButton uses the standard Fullscreen API, which is supported in all modern browsers. diff --git a/site/src/content/docs/reference/mute-button.mdx b/site/src/content/docs/reference/mute-button.mdx index 00a7e1c2..d491e450 100644 --- a/site/src/content/docs/reference/mute-button.mdx +++ b/site/src/content/docs/reference/mute-button.mdx @@ -2,92 +2,21 @@ title: MuteButton frameworkTitle: html: mute-button -description: A button component for toggling audio mute state +description: A button component for muting and unmuting audio playback --- -import { MuteButtonDemo } from '@/examples/react/MuteButton/MuteButtonDemo'; -import componentModuleStr from '@/examples/react/MuteButton/BasicMuteButton.tsx?raw'; -import cssModuleStr from '@/examples/react/MuteButton/MuteButton.module.css?raw'; -import htmlStr from '@/examples/html/mute-button/snippet.html?raw'; -import htmlCssStr from '@/examples/html/mute-button/mute-button.css?raw'; -import htmlJsStr from '@/examples/html/mute-button/mute-button.js?raw'; -import FrameworkCase from '@/components/docs/FrameworkCase.astro'; -import ServerCode from '@/components/Code/ServerCode.astro'; -import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs'; +import ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; -## Features +## API Reference -- Multi-state icon display (high, low, off) -- Automatically reflects volume level changes -- Toggles mute/unmute on click -- Accessible keyboard navigation +### Props -## Example + - - - - Component - CSS Module - - - - - - - - - - +### State - - - - HTML - CSS - JS - - - - - - - - - - - - - + +### Data Attributes - -## Data Attributes - -The MuteButton automatically sets data attributes based on volume level: - -- `data-volume-level="high"` - Volume > 50% -- `data-volume-level="medium"` - Volume 25-50% -- `data-volume-level="low"` - Volume 1-24% -- `data-volume-level="off"` - Volume 0% (muted) - -Use these attributes for state-based styling in your CSS. - - - -## Props - -All standard button props are supported, plus: - -| Prop | Type | Description | -|------|------|-------------| -| `children` | `ReactNode` | Button content (typically icons) | -| `className` | `string` | CSS class name | - - - -## Accessibility - -- Automatically includes proper ARIA labels -- Keyboard accessible (Space/Enter) -- Announces volume state changes to screen readers + diff --git a/site/src/content/docs/reference/play-button.mdx b/site/src/content/docs/reference/play-button.mdx index c2efab2f..5835317e 100644 --- a/site/src/content/docs/reference/play-button.mdx +++ b/site/src/content/docs/reference/play-button.mdx @@ -5,84 +5,18 @@ frameworkTitle: description: A button component for playing and pausing media playback --- -import { PlayButtonDemo } from '@/examples/react/PlayButton/PlayButtonDemo'; -import componentModuleStr from '@/examples/react/PlayButton/BasicPlayButton.tsx?raw'; -import cssModuleStr from '@/examples/react/PlayButton/PlayButton.module.css?raw'; -import htmlStr from '@/examples/html/play-button/snippet.html?raw'; -import htmlCssStr from '@/examples/html/play-button/play-button.css?raw'; -import htmlJsStr from '@/examples/html/play-button/play-button.js?raw'; -import FrameworkCase from '@/components/docs/FrameworkCase.astro'; -import ServerCode from '@/components/Code/ServerCode.astro'; -import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs'; +import ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; -## Features +## API Reference -- Automatically switches icons based on playback state -- Uses data attributes for state-based styling -- Accessible keyboard navigation -- Works with any media element +### Props -## Example + - - - - Component - CSS Module - - - - - - - - - - +### State - - - - HTML - CSS - JS - - - - - - - - - - - - - + -## Data Attributes +### Data Attributes -The PlayButton automatically sets data attributes based on media state: - -- `data-paused` - Present when media is paused, absent when playing - -Use these attributes for state-based styling in your CSS. - - - -## Props - -All standard button props are supported, plus: - -| Prop | Type | Description | -|------|------|-------------| -| `children` | `ReactNode` | Button content (typically icons) | -| `className` | `string` | CSS class name | - - - -## Accessibility - -- Automatically includes proper ARIA labels -- Keyboard accessible (Space/Enter) -- Announces state changes to screen readers + diff --git a/site/src/content/docs/reference/time-slider.mdx b/site/src/content/docs/reference/time-slider.mdx deleted file mode 100644 index 471154ec..00000000 --- a/site/src/content/docs/reference/time-slider.mdx +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: TimeSlider -frameworkTitle: - html: time-slider -description: A slider component for seeking through media content ---- - -import { TimeSliderDemo } from '@/examples/react/TimeSlider/TimeSliderDemo'; -import componentModuleStr from '@/examples/react/TimeSlider/BasicTimeSlider.tsx?raw'; -import cssModuleStr from '@/examples/react/TimeSlider/TimeSlider.module.css?raw'; -import htmlHorizontalStr from '@/examples/html/time-slider/snippet-horizontal.html?raw'; -import htmlVerticalStr from '@/examples/html/time-slider/snippet-vertical.html?raw'; -import htmlCssStr from '@/examples/html/time-slider/time-slider.css?raw'; -import FrameworkCase from '@/components/docs/FrameworkCase.astro'; -import ServerCode from '@/components/Code/ServerCode.astro'; -import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs'; - -## Features - -- Supports both horizontal and vertical orientations -- Displays current playback position -- Shows preview position on hover -- Keyboard accessible (Arrow keys for seeking) -- Touch-friendly drag interaction - -## Example - - - - - Component - CSS Module - - - - - - - - - - - - - - - HTML (Horizontal) - HTML (Vertical) - CSS - - - - - - - - - - - - - - - - -## Compound Components - -TimeSlider is composed of multiple sub-components: - -### TimeSlider.Root -The container component that manages state and interactions. - -**Props:** -- `orientation?: 'horizontal' | 'vertical'` - Slider orientation (default: 'horizontal') -- All standard div props - -### TimeSlider.Track -The background track element that contains progress and pointer indicators. - -### TimeSlider.Progress -Visual indicator showing how much of the media has been played. - -### TimeSlider.Pointer -Shows the hover/preview position when user moves cursor over the slider. - -### TimeSlider.Thumb -The draggable handle that indicates and controls the current playback position. - - - -## Data Attributes - -The TimeSlider automatically sets data attributes: - -- `data-orientation` - Current orientation ('horizontal' or 'vertical') -- `data-current-time` - Current playback time in seconds -- `data-duration` - Total media duration in seconds - -Use these attributes for state-based styling in your CSS. - -## CSS Variables - -The component exposes CSS variables for positioning: - -- `--slider-fill` - Percentage of progress (0-100%) -- `--slider-pointer` - Percentage of pointer position (0-100%) - -## Accessibility - -- Includes proper ARIA role (`slider`) -- Keyboard accessible (Arrow keys, Home, End) -- Screen reader announcements for time values -- Proper aria-valuemin, aria-valuemax, aria-valuenow attributes diff --git a/site/src/content/docs/reference/volume-slider.mdx b/site/src/content/docs/reference/volume-slider.mdx deleted file mode 100644 index 77edbf1a..00000000 --- a/site/src/content/docs/reference/volume-slider.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: VolumeSlider -frameworkTitle: - html: volume-slider -description: A slider component for controlling media volume ---- - -import { VolumeSliderDemo } from '@/examples/react/VolumeSlider/VolumeSliderDemo'; -import componentModuleStr from '@/examples/react/VolumeSlider/BasicVolumeSlider.tsx?raw'; -import cssModuleStr from '@/examples/react/VolumeSlider/VolumeSlider.module.css?raw'; -import htmlHorizontalStr from '@/examples/html/volume-slider/snippet-horizontal.html?raw'; -import htmlVerticalStr from '@/examples/html/volume-slider/snippet-vertical.html?raw'; -import htmlCssStr from '@/examples/html/volume-slider/volume-slider.css?raw'; -import FrameworkCase from '@/components/docs/FrameworkCase.astro'; -import ServerCode from '@/components/Code/ServerCode.astro'; -import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs'; - -## Features - -- Supports both horizontal and vertical orientations -- Displays current volume level -- Reflects muted state -- Keyboard accessible (Arrow keys for volume adjustment) -- Touch-friendly drag interaction - -## Example - - - - - Component - CSS Module - - - - - - - - - - - - - - - HTML (Horizontal) - HTML (Vertical) - CSS - - - - - - - - - - - - - - - - -## Compound Components - -VolumeSlider is composed of multiple sub-components: - -### VolumeSlider.Root -The container component that manages state and interactions. - -**Props:** -- `orientation?: 'horizontal' | 'vertical'` - Slider orientation (default: 'horizontal') -- All standard div props - -### VolumeSlider.Track -The background track element that contains the progress indicator. - -### VolumeSlider.Progress -Visual indicator showing the current volume level. - -### VolumeSlider.Thumb -The draggable handle that indicates and controls the current volume level. - - - -## Data Attributes - -The VolumeSlider automatically sets data attributes: - -- `data-orientation` - Current orientation ('horizontal' or 'vertical') -- `data-muted` - Present when volume is muted -- `data-volume-level` - Volume level category: 'high' (>50%), 'medium' (25-50%), 'low' (1-24%), or 'off' (0%) - -Use these attributes for state-based styling in your CSS. - -## CSS Variables - -The component exposes CSS variables for positioning: - -- `--slider-fill` - Percentage of volume level (0-100%) -- `--slider-pointer` - Percentage of pointer position (0-100%) - -## Accessibility - -- Includes proper ARIA role (`slider`) -- Keyboard accessible (Arrow keys, Home, End) -- Screen reader announcements for volume values -- Proper aria-valuemin, aria-valuemax, aria-valuenow attributes diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index dce43801..48ddf452 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -4,7 +4,11 @@ export const sidebar: Sidebar = [ { sidebarLabel: 'Getting started', contents: [ - { slug: 'how-to/write-guides', sidebarLabel: 'Writing guides', devOnly: true }, + { + slug: 'how-to/write-guides', + sidebarLabel: 'Writing guides', + devOnly: true, + }, { slug: 'how-to/installation' }, { slug: 'concepts/v10-roadmap', sidebarLabel: 'Roadmap' }, ], @@ -19,12 +23,6 @@ export const sidebar: Sidebar = [ }, { sidebarLabel: 'Components', - contents: [ - { slug: 'reference/play-button' }, - { slug: 'reference/mute-button' }, - { slug: 'reference/fullscreen-button' }, - { slug: 'reference/time-slider' }, - { slug: 'reference/volume-slider' }, - ], + contents: [{ slug: 'reference/play-button' }, { slug: 'reference/mute-button' }], }, ]; diff --git a/site/src/examples/html/fullscreen-button/basic.html b/site/src/examples/html/fullscreen-button/basic.html deleted file mode 100644 index 63ed8ee6..00000000 --- a/site/src/examples/html/fullscreen-button/basic.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - Basic Fullscreen Button - Video.js - - - - -
- - - -
- - - - - - - - -
-
-
- - diff --git a/site/src/examples/html/fullscreen-button/fullscreen-button.css b/site/src/examples/html/fullscreen-button/fullscreen-button.css deleted file mode 100644 index f85a8559..00000000 --- a/site/src/examples/html/fullscreen-button/fullscreen-button.css +++ /dev/null @@ -1,85 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 2rem; - font-family: - system-ui, - -apple-system, - sans-serif; - background: #0a0a0a; - color: white; -} - -.demo-container { - max-width: 800px; - margin: 0 auto; -} - -.media-container { - position: relative; - width: 100%; - max-width: 640px; -} - -video { - width: 100%; - height: auto; - display: block; -} - -.controls { - position: absolute; - bottom: 1rem; - right: 1rem; - z-index: 10; -} - -media-fullscreen-button { - position: relative; - display: grid; - padding: 0.625rem; - border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); - border: none; - cursor: pointer; - color: white; - transition: background 150ms ease; -} - -media-fullscreen-button:hover { - background: rgba(255, 255, 255, 0.15); -} - -media-fullscreen-button:active { - background: rgba(255, 255, 255, 0.2); -} - -/* Icon positioning - both occupy same grid cell */ -media-fullscreen-enter-icon, -media-fullscreen-exit-icon { - grid-area: 1/1; - transition: opacity 200ms ease; - width: 18px; - height: 18px; -} - -/* Show/hide icons based on fullscreen state using data attributes */ -media-fullscreen-button:not([data-fullscreen]) media-fullscreen-enter-icon { - opacity: 1; -} - -media-fullscreen-button:not([data-fullscreen]) media-fullscreen-exit-icon { - opacity: 0; -} - -media-fullscreen-button[data-fullscreen] media-fullscreen-enter-icon { - opacity: 0; -} - -media-fullscreen-button[data-fullscreen] media-fullscreen-exit-icon { - opacity: 1; -} diff --git a/site/src/examples/html/fullscreen-button/fullscreen-button.js b/site/src/examples/html/fullscreen-button/fullscreen-button.js deleted file mode 100644 index f991418b..00000000 --- a/site/src/examples/html/fullscreen-button/fullscreen-button.js +++ /dev/null @@ -1,5 +0,0 @@ -import "@videojs/html-preview/define/media-fullscreen-button"; -import "@videojs/html-preview/icons"; - -// The web components will automatically register themselves -// No additional setup needed diff --git a/site/src/examples/html/fullscreen-button/snippet.html b/site/src/examples/html/fullscreen-button/snippet.html deleted file mode 100644 index 9b857f41..00000000 --- a/site/src/examples/html/fullscreen-button/snippet.html +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/site/src/examples/html/mute-button/basic.html b/site/src/examples/html/mute-button/basic.html deleted file mode 100644 index 966a6094..00000000 --- a/site/src/examples/html/mute-button/basic.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - Basic Mute Button - Video.js - - - - -
- - - -
- - - - - - - - - - - -
-
-
- - diff --git a/site/src/examples/html/mute-button/mute-button.css b/site/src/examples/html/mute-button/mute-button.css deleted file mode 100644 index d1ca4bf1..00000000 --- a/site/src/examples/html/mute-button/mute-button.css +++ /dev/null @@ -1,87 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 2rem; - font-family: - system-ui, - -apple-system, - sans-serif; - background: #0a0a0a; - color: white; -} - -.demo-container { - max-width: 800px; - margin: 0 auto; -} - -.media-container { - position: relative; - width: 100%; - max-width: 640px; -} - -video { - width: 100%; - height: auto; - display: block; -} - -.controls { - position: absolute; - bottom: 1rem; - left: 1rem; - z-index: 10; -} - -media-mute-button { - position: relative; - display: grid; - padding: 0.625rem; - border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); - border: none; - cursor: pointer; - color: white; - transition: background 150ms ease; -} - -media-mute-button:hover { - background: rgba(255, 255, 255, 0.15); -} - -media-mute-button:active { - background: rgba(255, 255, 255, 0.2); -} - -/* Icon positioning - all icons occupy same grid cell */ -media-volume-high-icon, -media-volume-low-icon, -media-volume-off-icon { - grid-area: 1/1; - transition: opacity 200ms ease; - width: 18px; - height: 18px; - opacity: 0; -} - -/* Show appropriate icon based on volume level using data attributes */ -/* High volume (> 50%) */ -media-mute-button[data-volume-level='high'] media-volume-high-icon { - opacity: 1; -} - -/* Medium/Low volume (1-50%) */ -media-mute-button[data-volume-level='medium'] media-volume-low-icon, -media-mute-button[data-volume-level='low'] media-volume-low-icon { - opacity: 1; -} - -/* Muted/Off volume (0%) */ -media-mute-button[data-volume-level='off'] media-volume-off-icon { - opacity: 1; -} diff --git a/site/src/examples/html/mute-button/mute-button.js b/site/src/examples/html/mute-button/mute-button.js deleted file mode 100644 index 37a4e1dd..00000000 --- a/site/src/examples/html/mute-button/mute-button.js +++ /dev/null @@ -1,5 +0,0 @@ -import "@videojs/html-preview/define/media-mute-button"; -import "@videojs/html-preview/icons"; - -// The web components will automatically register themselves -// No additional setup needed diff --git a/site/src/examples/html/mute-button/snippet.html b/site/src/examples/html/mute-button/snippet.html deleted file mode 100644 index b3c3fbb9..00000000 --- a/site/src/examples/html/mute-button/snippet.html +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/site/src/examples/html/play-button/basic.html b/site/src/examples/html/play-button/basic.html deleted file mode 100644 index 095ad105..00000000 --- a/site/src/examples/html/play-button/basic.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - Basic Play Button - Video.js - - - - -
- - - -
- - - - - - - - -
-
-
- - diff --git a/site/src/examples/html/play-button/play-button.css b/site/src/examples/html/play-button/play-button.css deleted file mode 100644 index cf9a4d61..00000000 --- a/site/src/examples/html/play-button/play-button.css +++ /dev/null @@ -1,85 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 2rem; - font-family: - system-ui, - -apple-system, - sans-serif; - background: #0a0a0a; - color: white; -} - -.demo-container { - max-width: 800px; - margin: 0 auto; -} - -.media-container { - position: relative; - width: 100%; - max-width: 640px; -} - -video { - width: 100%; - height: auto; - display: block; -} - -.controls { - position: absolute; - bottom: 1rem; - left: 1rem; - z-index: 10; -} - -media-play-button { - position: relative; - display: grid; - padding: 0.625rem; - border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); - border: none; - cursor: pointer; - color: white; - transition: background 150ms ease; -} - -media-play-button:hover { - background: rgba(255, 255, 255, 0.15); -} - -media-play-button:active { - background: rgba(255, 255, 255, 0.2); -} - -/* Icon positioning - both occupy same grid cell */ -media-play-icon, -media-pause-icon { - grid-area: 1/1; - transition: opacity 200ms ease; - width: 18px; - height: 18px; -} - -/* Show/hide icons based on paused state using data attributes */ -media-play-button[data-paused] media-play-icon { - opacity: 1; -} - -media-play-button[data-paused] media-pause-icon { - opacity: 0; -} - -media-play-button:not([data-paused]) media-play-icon { - opacity: 0; -} - -media-play-button:not([data-paused]) media-pause-icon { - opacity: 1; -} diff --git a/site/src/examples/html/play-button/play-button.js b/site/src/examples/html/play-button/play-button.js deleted file mode 100644 index 78d0a0a3..00000000 --- a/site/src/examples/html/play-button/play-button.js +++ /dev/null @@ -1,5 +0,0 @@ -import "@videojs/html-preview/define/media-play-button"; -import "@videojs/html-preview/icons"; - -// The web components will automatically register themselves -// No additional setup needed diff --git a/site/src/examples/html/play-button/snippet.html b/site/src/examples/html/play-button/snippet.html deleted file mode 100644 index a09c76fa..00000000 --- a/site/src/examples/html/play-button/snippet.html +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/site/src/examples/html/time-slider/snippet-horizontal.html b/site/src/examples/html/time-slider/snippet-horizontal.html deleted file mode 100644 index b9b5ee2a..00000000 --- a/site/src/examples/html/time-slider/snippet-horizontal.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/site/src/examples/html/time-slider/snippet-vertical.html b/site/src/examples/html/time-slider/snippet-vertical.html deleted file mode 100644 index ba8da6da..00000000 --- a/site/src/examples/html/time-slider/snippet-vertical.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/site/src/examples/html/time-slider/time-slider.css b/site/src/examples/html/time-slider/time-slider.css deleted file mode 100644 index 0c8ee391..00000000 --- a/site/src/examples/html/time-slider/time-slider.css +++ /dev/null @@ -1,64 +0,0 @@ -media-time-slider { - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -/* Horizontal orientation */ -media-time-slider[data-orientation='horizontal'] { - width: 100%; - min-width: 100px; - height: 20px; -} - -/* Vertical orientation */ -media-time-slider[data-orientation='vertical'] { - width: 20px; - height: 100px; - flex-direction: column; -} - -media-time-slider-track { - position: relative; - background-color: rgba(255, 255, 255, 0.2); - border-radius: 0.25rem; - overflow: hidden; -} - -/* Horizontal track */ -media-time-slider-track[data-orientation='horizontal'] { - width: 100%; - height: 0.375rem; -} - -/* Vertical track */ -media-time-slider-track[data-orientation='vertical'] { - width: 0.375rem; - height: 100%; -} - -media-time-slider-progress { - background-color: #007bff; - border-radius: inherit; - position: absolute; -} - -media-time-slider-pointer { - background-color: rgba(255, 255, 255, 0.5); - position: absolute; - pointer-events: none; -} - -media-time-slider-thumb { - width: 0.75rem; - height: 0.75rem; - background-color: #fff; - border-radius: 50%; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: transform 150ms ease; -} - -media-time-slider:hover media-time-slider-thumb { - transform: scale(1.2); -} diff --git a/site/src/examples/html/volume-slider/snippet-horizontal.html b/site/src/examples/html/volume-slider/snippet-horizontal.html deleted file mode 100644 index 12e3333e..00000000 --- a/site/src/examples/html/volume-slider/snippet-horizontal.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/site/src/examples/html/volume-slider/snippet-vertical.html b/site/src/examples/html/volume-slider/snippet-vertical.html deleted file mode 100644 index 6382c8a0..00000000 --- a/site/src/examples/html/volume-slider/snippet-vertical.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/site/src/examples/html/volume-slider/volume-slider.css b/site/src/examples/html/volume-slider/volume-slider.css deleted file mode 100644 index 05da4c61..00000000 --- a/site/src/examples/html/volume-slider/volume-slider.css +++ /dev/null @@ -1,58 +0,0 @@ -media-volume-slider { - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -/* Horizontal orientation */ -media-volume-slider[data-orientation='horizontal'] { - width: 80px; - min-width: 80px; - height: 20px; -} - -/* Vertical orientation */ -media-volume-slider[data-orientation='vertical'] { - width: 20px; - height: 80px; - flex-direction: column; -} - -media-volume-slider-track { - position: relative; - background-color: rgba(255, 255, 255, 0.2); - border-radius: 0.25rem; - overflow: hidden; -} - -/* Horizontal track */ -media-volume-slider-track[data-orientation='horizontal'] { - width: 100%; - height: 0.375rem; -} - -/* Vertical track */ -media-volume-slider-track[data-orientation='vertical'] { - width: 0.375rem; - height: 100%; -} - -media-volume-slider-indicator { - background-color: #007bff; - border-radius: inherit; - position: absolute; -} - -media-volume-slider-thumb { - width: 0.75rem; - height: 0.75rem; - background-color: #fff; - border-radius: 50%; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: transform 150ms ease; -} - -media-volume-slider:hover media-volume-slider-thumb { - transform: scale(1.2); -} diff --git a/site/src/examples/react/FullscreenButton/BasicFullscreenButton.tsx b/site/src/examples/react/FullscreenButton/BasicFullscreenButton.tsx deleted file mode 100644 index 64a0d680..00000000 --- a/site/src/examples/react/FullscreenButton/BasicFullscreenButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { FullscreenButton } from '@videojs/react-preview'; -import { FullscreenEnterIcon, FullscreenExitIcon } from '@videojs/react-preview/icons'; -import styles from './FullscreenButton.module.css'; - -/** - * Basic FullscreenButton example demonstrating: - * - Icon switching based on fullscreen state - * - Data attribute state selectors - * - Enter/exit fullscreen functionality - * - * Note: This component must be used within a VideoProvider context. - * See the usage example in the documentation. - */ -export function BasicFullscreenButton() { - return ( - - - - - ); -} diff --git a/site/src/examples/react/FullscreenButton/FullscreenButton.module.css b/site/src/examples/react/FullscreenButton/FullscreenButton.module.css deleted file mode 100644 index 07e0c724..00000000 --- a/site/src/examples/react/FullscreenButton/FullscreenButton.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.button { - position: relative; - display: grid; - padding: 0.625rem; - border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); - border: none; - cursor: pointer; - color: white; - transition: background 150ms ease; -} - -.button:hover { - background: rgba(255, 255, 255, 0.15); -} - -.button:active { - background: rgba(255, 255, 255, 0.2); -} - -/* Icon positioning - both occupy same grid cell */ -.fullscreenEnterIcon, -.fullscreenExitIcon { - grid-area: 1/1; - transition: opacity 200ms ease; - width: 18px; - height: 18px; -} - -/* Show/hide icons based on fullscreen state using data attributes */ -.button:not([data-fullscreen]) .fullscreenEnterIcon { - opacity: 1; -} - -.button:not([data-fullscreen]) .fullscreenExitIcon { - opacity: 0; -} - -.button[data-fullscreen] .fullscreenEnterIcon { - opacity: 0; -} - -.button[data-fullscreen] .fullscreenExitIcon { - opacity: 1; -} diff --git a/site/src/examples/react/FullscreenButton/FullscreenButtonDemo.tsx b/site/src/examples/react/FullscreenButton/FullscreenButtonDemo.tsx deleted file mode 100644 index 1ff5a675..00000000 --- a/site/src/examples/react/FullscreenButton/FullscreenButtonDemo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { MediaContainer, Video, VideoProvider } from '@videojs/react-preview'; -import { VJS8_DEMO_VIDEO } from '@/consts'; -import { BasicFullscreenButton } from './BasicFullscreenButton'; - -/** - * Demo showing proper VideoProvider usage with FullscreenButton. - * The FullscreenButton automatically toggles fullscreen mode for - * the containing MediaContainer. - */ -export function FullscreenButtonDemo() { - return ( - - - - - ); -} diff --git a/site/src/examples/react/MuteButton/BasicMuteButton.tsx b/site/src/examples/react/MuteButton/BasicMuteButton.tsx deleted file mode 100644 index f3c621e3..00000000 --- a/site/src/examples/react/MuteButton/BasicMuteButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { MuteButton } from '@videojs/react-preview'; -import { VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@videojs/react-preview/icons'; -import styles from './MuteButton.module.css'; - -/** - * Basic MuteButton example demonstrating: - * - Multi-state icon switching (high/medium/low/off) - * - Volume level data attributes - * - Smooth icon transitions - * - * Note: This component must be used within a VideoProvider context. - * See the usage example in the documentation. - */ -export function BasicMuteButton() { - return ( - - - - - - ); -} diff --git a/site/src/examples/react/MuteButton/MuteButton.module.css b/site/src/examples/react/MuteButton/MuteButton.module.css deleted file mode 100644 index aef039db..00000000 --- a/site/src/examples/react/MuteButton/MuteButton.module.css +++ /dev/null @@ -1,48 +0,0 @@ -.button { - position: relative; - display: grid; - padding: 0.625rem; - border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); - border: none; - cursor: pointer; - color: white; - transition: background 150ms ease; -} - -.button:hover { - background: rgba(255, 255, 255, 0.15); -} - -.button:active { - background: rgba(255, 255, 255, 0.2); -} - -/* Icon positioning - all icons occupy same grid cell */ -.volumeHighIcon, -.volumeLowIcon, -.volumeOffIcon { - grid-area: 1/1; - transition: opacity 200ms ease; - width: 18px; - height: 18px; - opacity: 0; -} - -/* Show appropriate icon based on volume level using data attributes */ -/* High volume (> 50%) */ -.button[data-volume-level='high'] .volumeHighIcon { - opacity: 1; -} - -/* Medium/Low volume (1-50%) */ -.button[data-volume-level='medium'] .volumeLowIcon, -.button[data-volume-level='low'] .volumeLowIcon { - opacity: 1; -} - -/* Muted/Off volume (0%) */ -.button[data-volume-level='off'] .volumeOffIcon { - opacity: 1; -} diff --git a/site/src/examples/react/MuteButton/MuteButtonDemo.tsx b/site/src/examples/react/MuteButton/MuteButtonDemo.tsx deleted file mode 100644 index 63dfb35c..00000000 --- a/site/src/examples/react/MuteButton/MuteButtonDemo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { MediaContainer, Video, VideoProvider } from '@videojs/react-preview'; -import { VJS8_DEMO_VIDEO } from '@/consts'; -import { BasicMuteButton } from './BasicMuteButton'; - -/** - * Demo showing proper VideoProvider usage with MuteButton. - * The MuteButton automatically reflects the current volume state - * and toggles mute/unmute on click. - */ -export function MuteButtonDemo() { - return ( - - - - - ); -} diff --git a/site/src/examples/react/PlayButton/BasicPlayButton.tsx b/site/src/examples/react/PlayButton/BasicPlayButton.tsx deleted file mode 100644 index 56acd1ce..00000000 --- a/site/src/examples/react/PlayButton/BasicPlayButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { PlayButton } from '@videojs/react-preview'; -import { PauseIcon, PlayIcon } from '@videojs/react-preview/icons'; -import styles from './PlayButton.module.css'; - -/** - * Basic PlayButton example demonstrating: - * - Icon switching based on paused state - * - CSS Modules for scoped styling - * - Data attribute selectors for state-based styling - * - * Note: This component must be used within a VideoProvider context. - * See the usage example in the documentation. - */ -export function BasicPlayButton() { - return ( - - - - - ); -} diff --git a/site/src/examples/react/PlayButton/PlayButton.module.css b/site/src/examples/react/PlayButton/PlayButton.module.css deleted file mode 100644 index 69742ba4..00000000 --- a/site/src/examples/react/PlayButton/PlayButton.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.button { - position: relative; - display: grid; - padding: 0.625rem; - border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); - border: none; - cursor: pointer; - color: white; - transition: background 150ms ease; -} - -.button:hover { - background: rgba(255, 255, 255, 0.15); -} - -.button:active { - background: rgba(255, 255, 255, 0.2); -} - -/* Icon positioning - both occupy same grid cell */ -.playIcon, -.pauseIcon { - grid-area: 1/1; - transition: opacity 200ms ease; - width: 18px; - height: 18px; -} - -/* Show/hide icons based on paused state using data attributes */ -.button[data-paused] .playIcon { - opacity: 1; -} - -.button[data-paused] .pauseIcon { - opacity: 0; -} - -.button:not([data-paused]) .playIcon { - opacity: 0; -} - -.button:not([data-paused]) .pauseIcon { - opacity: 1; -} diff --git a/site/src/examples/react/PlayButton/PlayButtonDemo.tsx b/site/src/examples/react/PlayButton/PlayButtonDemo.tsx deleted file mode 100644 index 1bfb7044..00000000 --- a/site/src/examples/react/PlayButton/PlayButtonDemo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { MediaContainer, Video, VideoProvider } from '@videojs/react-preview'; -import { VJS8_DEMO_VIDEO } from '@/consts'; -import { BasicPlayButton } from './BasicPlayButton'; - -/** - * Demo showing proper VideoProvider usage with PlayButton. - * The VideoProvider wraps the entire media experience and provides - * the necessary context for all media components. - */ -export function PlayButtonDemo() { - return ( - - - - - ); -} diff --git a/site/src/examples/react/TimeSlider/BasicTimeSlider.tsx b/site/src/examples/react/TimeSlider/BasicTimeSlider.tsx deleted file mode 100644 index ce78ae6a..00000000 --- a/site/src/examples/react/TimeSlider/BasicTimeSlider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { TimeSlider } from '@videojs/react-preview'; -import styles from './TimeSlider.module.css'; - -/** - * Basic TimeSlider example demonstrating: - * - Progress and pointer visualization - * - Horizontal orientation - * - CSS Modules for scoped styling - * - Data attribute selectors for state-based styling - * - * Note: This component must be used within a VideoProvider context. - * See the usage example in the documentation. - */ -export function BasicTimeSlider() { - return ( - - - - - - - - ); -} diff --git a/site/src/examples/react/TimeSlider/TimeSlider.module.css b/site/src/examples/react/TimeSlider/TimeSlider.module.css deleted file mode 100644 index ee481162..00000000 --- a/site/src/examples/react/TimeSlider/TimeSlider.module.css +++ /dev/null @@ -1,64 +0,0 @@ -.root { - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -/* Horizontal orientation */ -.root[data-orientation='horizontal'] { - width: 100%; - min-width: 100px; - height: 20px; -} - -/* Vertical orientation */ -.root[data-orientation='vertical'] { - width: 20px; - height: 100px; - flex-direction: column; -} - -.track { - position: relative; - background-color: rgba(255, 255, 255, 0.2); - border-radius: 0.25rem; - overflow: hidden; -} - -/* Horizontal track */ -.track[data-orientation='horizontal'] { - width: 100%; - height: 0.375rem; -} - -/* Vertical track */ -.track[data-orientation='vertical'] { - width: 0.375rem; - height: 100%; -} - -.progress { - background-color: #007bff; - border-radius: inherit; - position: absolute; -} - -.pointer { - background-color: rgba(255, 255, 255, 0.5); - position: absolute; - pointer-events: none; -} - -.thumb { - width: 0.75rem; - height: 0.75rem; - background-color: #fff; - border-radius: 50%; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: transform 150ms ease; -} - -.thumb:hover { - transform: scale(1.2); -} diff --git a/site/src/examples/react/TimeSlider/TimeSliderDemo.tsx b/site/src/examples/react/TimeSlider/TimeSliderDemo.tsx deleted file mode 100644 index 2060c858..00000000 --- a/site/src/examples/react/TimeSlider/TimeSliderDemo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { MediaContainer, Video, VideoProvider } from '@videojs/react-preview'; -import { VJS8_DEMO_VIDEO } from '@/consts'; -import { BasicTimeSlider } from './BasicTimeSlider'; - -/** - * Demo showing proper VideoProvider usage with TimeSlider. - * The VideoProvider wraps the entire media experience and provides - * the necessary context for all media components. - */ -export function TimeSliderDemo() { - return ( - - - - - ); -} diff --git a/site/src/examples/react/VolumeSlider/BasicVolumeSlider.tsx b/site/src/examples/react/VolumeSlider/BasicVolumeSlider.tsx deleted file mode 100644 index 22da78ba..00000000 --- a/site/src/examples/react/VolumeSlider/BasicVolumeSlider.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { VolumeSlider } from '@videojs/react-preview'; -import styles from './VolumeSlider.module.css'; - -/** - * Basic VolumeSlider example demonstrating: - * - Volume level visualization - * - Horizontal orientation - * - CSS Modules for scoped styling - * - Data attribute selectors for state-based styling - * - * Note: This component must be used within a VideoProvider context. - * See the usage example in the documentation. - */ -export function BasicVolumeSlider() { - return ( - - - - - - - ); -} diff --git a/site/src/examples/react/VolumeSlider/VolumeSlider.module.css b/site/src/examples/react/VolumeSlider/VolumeSlider.module.css deleted file mode 100644 index 03f231b3..00000000 --- a/site/src/examples/react/VolumeSlider/VolumeSlider.module.css +++ /dev/null @@ -1,58 +0,0 @@ -.root { - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -/* Horizontal orientation */ -.root[data-orientation='horizontal'] { - width: 80px; - min-width: 80px; - height: 20px; -} - -/* Vertical orientation */ -.root[data-orientation='vertical'] { - width: 20px; - height: 80px; - flex-direction: column; -} - -.track { - position: relative; - background-color: rgba(255, 255, 255, 0.2); - border-radius: 0.25rem; - overflow: hidden; -} - -/* Horizontal track */ -.track[data-orientation='horizontal'] { - width: 100%; - height: 0.375rem; -} - -/* Vertical track */ -.track[data-orientation='vertical'] { - width: 0.375rem; - height: 100%; -} - -.progress { - background-color: #007bff; - border-radius: inherit; - position: absolute; -} - -.thumb { - width: 0.75rem; - height: 0.75rem; - background-color: #fff; - border-radius: 50%; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: transform 150ms ease; -} - -.thumb:hover { - transform: scale(1.2); -} diff --git a/site/src/examples/react/VolumeSlider/VolumeSliderDemo.tsx b/site/src/examples/react/VolumeSlider/VolumeSliderDemo.tsx deleted file mode 100644 index 02522975..00000000 --- a/site/src/examples/react/VolumeSlider/VolumeSliderDemo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { MediaContainer, Video, VideoProvider } from '@videojs/react-preview'; -import { VJS8_DEMO_VIDEO } from '@/consts'; -import { BasicVolumeSlider } from './BasicVolumeSlider'; - -/** - * Demo showing proper VideoProvider usage with VolumeSlider. - * The VideoProvider wraps the entire media experience and provides - * the necessary context for all media components. - */ -export function VolumeSliderDemo() { - return ( - - - - - ); -} diff --git a/site/src/types/api-reference.ts b/site/src/types/api-reference.ts new file mode 100644 index 00000000..797620c0 --- /dev/null +++ b/site/src/types/api-reference.ts @@ -0,0 +1,44 @@ +/** + * Zod schemas for API reference JSON files. + * + * This is the single source of truth for the shape of generated API reference data. + * Both the builder (scripts/api-docs-builder) and Astro components import from here. + */ +import { z } from 'astro/zod'; + +export const PropDefSchema = z.object({ + type: z.string(), + shortType: z.string().optional(), + description: z.string().optional(), + default: z.string().optional(), + required: z.boolean().optional(), +}); + +export const StateDefSchema = z.object({ + type: z.string(), + description: z.string().optional(), +}); + +export const DataAttrDefSchema = z.object({ + description: z.string(), +}); + +export const ComponentApiReferenceSchema = z.object({ + name: z.string(), + description: z.string().optional(), + props: z.record(z.string(), PropDefSchema), + state: z.record(z.string(), StateDefSchema), + dataAttributes: z.record(z.string(), DataAttrDefSchema), + platforms: z.object({ + html: z + .object({ + tagName: z.string(), + }) + .optional(), + }), +}); + +export type PropDef = z.infer; +export type StateDef = z.infer; +export type DataAttrDef = z.infer; +export type ComponentApiReference = z.infer; diff --git a/site/src/utils/docs/__tests__/renderInlineMarkdown.test.ts b/site/src/utils/docs/__tests__/renderInlineMarkdown.test.ts new file mode 100644 index 00000000..0d65e81e --- /dev/null +++ b/site/src/utils/docs/__tests__/renderInlineMarkdown.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { renderInlineMarkdown } from '../renderInlineMarkdown'; + +describe('renderInlineMarkdown', () => { + it('returns plain text for a simple sentence', () => { + expect(renderInlineMarkdown('Whether the button is disabled.')).toBe('Whether the button is disabled.'); + }); + + it('unwraps a single paragraph', () => { + const result = renderInlineMarkdown('Hello **world**.'); + expect(result).not.toMatch(/^

world'); + }); + + it('preserves multiple paragraphs', () => { + const result = renderInlineMarkdown('First paragraph.\n\nSecond paragraph.'); + expect(result).toContain(' { + const result = renderInlineMarkdown('Use `foo` here.'); + expect(result).toContain(' { + const result = renderInlineMarkdown('**bold text**'); + expect(result).toContain('bold text'); + }); + + it('renders emphasized text', () => { + const result = renderInlineMarkdown('*italic text*'); + expect(result).toContain('italic text'); + }); + + it('renders links with correct classes', () => { + const result = renderInlineMarkdown('[link](https://example.com)'); + expect(result).toContain('href="https://example.com"'); + expect(result).toContain('underline'); + expect(result).toContain('intent:no-underline'); + }); + + it('renders unordered lists', () => { + const result = renderInlineMarkdown('- item one\n- item two'); + expect(result).toContain(' { + const result = renderInlineMarkdown('1. first\n2. second'); + expect(result).toContain(' { + const result = renderInlineMarkdown('# Heading'); + expect(result).not.toContain(' { + const result = renderInlineMarkdown('before\n\n---\n\nafter'); + expect(result).not.toContain(' { + const md = 'The volume level:\n\n- `0` — muted\n- `1` — max'; + const result = renderInlineMarkdown(md); + expect(result).toContain(' { + const result = renderInlineMarkdown('```\nconst x = 1;\n```'); + expect(result).toContain('${this.parser.parseInline(tokens)}

`; + }, + + list(token: Tokens.List) { + const tag = token.ordered ? 'ol' : 'ul'; + const cls = token.ordered ? classes.ol : classes.ul; + const body = token.items.map((item) => this.listitem(item)).join('\n'); + return `<${tag} class="${cls}">${body}`; + }, + + listitem(item: Tokens.ListItem) { + let body = this.parser.parse(item.tokens, !!item.loose); + if (!item.loose) { + body = body.replace(/^

/, '').replace(/<\/p>$/, ''); + } + return `

  • ${body}
  • `; + }, + + code({ text }) { + return `${text}`; + }, + + codespan({ text }) { + return `${text}`; + }, + + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + }, + + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + }, + + link({ href, tokens }) { + return `${this.parser.parseInline(tokens)}`; + }, + + // --- Unsupported elements — downgrade or suppress --- + + heading({ tokens }) { + return `

    ${this.parser.parseInline(tokens)}

    `; + }, + + blockquote({ tokens }) { + return this.parser.parse(tokens); + }, + + hr() { + return ''; + }, + + image({ href, text }) { + return text || href; + }, + + table() { + return ''; + }, + + tablerow() { + return ''; + }, + + tablecell() { + return ''; + }, +}; + +const marked = new Marked({ renderer }); + +/** + * Unwrap a single `

    ` wrapper so simple descriptions sit inline. + * + * If the output is a lone `

    ` with no other block-level + * elements, strip the wrapper and return only the inner content. + */ +function unwrapSingleParagraph(html: string): string { + const trimmed = html.trim(); + const match = trimmed.match(/^

    ([\s\S]*)<\/p>$/); + if (match && !trimmed.includes('