From 92ddf1e9fccebaf42066aab1640b104e8dabe253 Mon Sep 17 00:00:00 2001 From: dev wells Date: Sun, 25 Jan 2026 12:31:15 -0500 Subject: [PATCH 01/22] feat: prototyping phase for doc gen --- .npmrc | 1 + app/pages/[...package].vue | 12 + app/pages/docs/[...path].vue | 372 ++++++++++ package.json | 3 +- pnpm-lock.yaml | 161 +++- pnpm-workspace.yaml | 1 + prototypes/deno-doc/README.md | 183 +++++ prototypes/deno-doc/package.json | 17 + prototypes/deno-doc/src/test-deno-cli.js | 249 +++++++ prototypes/deno-doc/src/test-direct-wasm.js | 73 ++ prototypes/deno-doc/src/test.js | 209 ++++++ prototypes/deno-doc/src/test.ts | 143 ++++ server/api/registry/docs/[...pkg].get.ts | 89 +++ server/utils/docs.ts | 782 ++++++++++++++++++++ shared/types/docs.ts | 17 + shared/types/index.ts | 1 + 16 files changed, 2311 insertions(+), 2 deletions(-) create mode 100644 .npmrc create mode 100644 app/pages/docs/[...path].vue create mode 100644 prototypes/deno-doc/README.md create mode 100644 prototypes/deno-doc/package.json create mode 100644 prototypes/deno-doc/src/test-deno-cli.js create mode 100644 prototypes/deno-doc/src/test-direct-wasm.js create mode 100644 prototypes/deno-doc/src/test.js create mode 100644 prototypes/deno-doc/src/test.ts create mode 100644 server/api/registry/docs/[...pkg].get.ts create mode 100644 server/utils/docs.ts create mode 100644 shared/types/docs.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..41583e36ca --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 4e98283076..b22143c8db 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -727,6 +727,18 @@ defineOgImageComponent('Package', {
  • + + +
  • +
  • +import type { DocsResponse } from '#shared/types' + +definePageMeta({ + name: 'docs', +}) + +const route = useRoute('docs') +const router = useRouter() + +const parsedRoute = computed(() => { + const segments = route.params.path || [] + const vIndex = segments.indexOf('v') + + if (vIndex === -1 || vIndex >= segments.length - 1) { + return { + packageName: segments.join('/'), + version: null as string | null, + } + } + + return { + packageName: segments.slice(0, vIndex).join('/'), + version: segments.slice(vIndex + 1).join('/'), + } +}) + +const packageName = computed(() => parsedRoute.value.packageName) +const requestedVersion = computed(() => parsedRoute.value.version) + +const { data: pkg } = usePackage(packageName) + +const latestVersion = computed(() => pkg.value?.['dist-tags']?.latest ?? null) + +watch( + [requestedVersion, latestVersion, packageName], + ([version, latest, name]) => { + if (!version && latest && name) { + router.replace(`/docs/${name}/v/${latest}`) + } + }, + { immediate: true }, +) + +const resolvedVersion = computed(() => requestedVersion.value ?? latestVersion.value) + +const docsUrl = computed(() => { + if (!packageName.value || !resolvedVersion.value) return null + return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}` +}) + +const { data: docsData, status: docsStatus } = useLazyFetch(() => docsUrl.value!, { + watch: [docsUrl], + immediate: computed(() => !!docsUrl.value).value, + default: () => ({ + package: packageName.value, + version: resolvedVersion.value ?? '', + html: '', + toc: null, + breadcrumbs: null, + status: 'missing', + message: 'Docs are not available for this version.', + }), +}) + +const pageTitle = computed(() => { + if (!packageName.value) return 'API Docs - npmx' + if (!resolvedVersion.value) return `${packageName.value} docs - npmx` + return `${packageName.value}@${resolvedVersion.value} docs - npmx` +}) + +useSeoMeta({ + title: () => pageTitle.value, +}) + +const showLoading = computed(() => docsStatus.value === 'pending') +const showEmptyState = computed(() => docsData.value?.status !== 'ok') + + + + + diff --git a/package.json b/package.json index a3a4f5db05..7cc2c462f9 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "validate-npm-package-name": "^7.0.2", "virtua": "^0.48.3", "vue": "3.5.27", - "vue-data-ui": "^3.13.2" + "vue-data-ui": "^3.13.2", + "typedoc": "^0.28.16" }, "devDependencies": { "@iconify-json/carbon": "1.2.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd1ea698b9..378e808426 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: shiki: specifier: ^3.21.0 version: 3.21.0 + typedoc: + specifier: ^0.28.16 + version: 0.28.16(typescript@5.9.3) ufo: specifier: ^1.6.3 version: 1.6.3 @@ -216,6 +219,16 @@ importers: specifier: 5.9.3 version: 5.9.3 + prototypes/deno-doc: + dependencies: + '@deno/doc': + specifier: npm:@jsr/deno__doc@^0.189.1 + version: '@jsr/deno__doc@0.189.1' + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + packages: '@acemir/cssom@0.9.31': @@ -1228,6 +1241,9 @@ packages: '@noble/hashes': optional: true + '@gerrit0/mini-shiki@3.21.0': + resolution: {integrity: sha512-9PrsT5DjZA+w3lur/aOIx3FlDeHdyCEFlv9U+fmsVyjPZh61G5SYURQ/1ebe2U63KbDmI2V8IhIUegWb8hjOyg==} + '@html-validate/stylish@4.3.0': resolution: {integrity: sha512-eUfvKpRJg5TvzSfTf2EovrQoTKjkRnPUOUnXVJ2cQ4GbC/bQw98oxN+DdSf+HxOBK00YOhsP52xWdJPV1o4n5w==} engines: {node: '>= 18'} @@ -1508,6 +1524,36 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsr/deno__cache-dir@0.25.0': + resolution: {integrity: sha512-J5/kPR8sc+dsRyE8rnLPhqay7B4XgBmkv7QaeIV41sGibszQjIb8b599LssN1YWBbqPLakPRMVobg5u6LheWfg==, tarball: https://npm.jsr.io/~/11/@jsr/deno__cache-dir/0.25.0.tgz} + + '@jsr/deno__doc@0.189.1': + resolution: {integrity: sha512-GtlJz3nGX1zF+XBG5pEPYdRbo5feyP3eM1+zsEqFqjfvT/nfRdjyGNogEs4yd+DW8cD7tQceRd2vypDhG1X7kA==, tarball: https://npm.jsr.io/~/11/@jsr/deno__doc/0.189.1.tgz} + + '@jsr/deno__graph@0.100.1': + resolution: {integrity: sha512-mPemftpdwtz8fo+RNKujjXKpDQRcj5E0nOcxeNiDGLRzqQk/Q69IcGM4ruZGX+0xhTNNSLc8Uu3W4ynIDfTR6g==, tarball: https://npm.jsr.io/~/11/@jsr/deno__graph/0.100.1.tgz} + + '@jsr/deno__graph@0.86.9': + resolution: {integrity: sha512-+qrrma5/bL+hcG20mfaEeC8SLopqoyd1RjcKFMRu++3SAXyrTKuvuIjBJCn/NyN7X+kV+QrJG67BCHX38Rzw+g==, tarball: https://npm.jsr.io/~/11/@jsr/deno__graph/0.86.9.tgz} + + '@jsr/std__bytes@1.0.6': + resolution: {integrity: sha512-St6yKggjFGhxS52IFLJWvkchRFbAKg2Xh8UxA4S1EGz7GJ2Ui+ssDDldj/w2c8vCxvl6qgR0HaYbKeFJNqujmA==, tarball: https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.6.tgz} + + '@jsr/std__fmt@1.0.9': + resolution: {integrity: sha512-YFJJMozmORj2K91c5J9opWeh0VUwrd+Mwb7Pr0FkVCAKVLu2UhT4LyvJqWiyUT+eF+MdfqQ9F7RtQj4bXn9Smw==, tarball: https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.9.tgz} + + '@jsr/std__fs@1.0.22': + resolution: {integrity: sha512-PvDtgT25IqhFEX2LjQI0aTz/Wg61jCtJ8l19fE9MUSvSmtw57Kzr6sM7GcCsSrsZEdQ7wjLfXvvhy8irta4Zww==, tarball: https://npm.jsr.io/~/11/@jsr/std__fs/1.0.22.tgz} + + '@jsr/std__internal@1.0.12': + resolution: {integrity: sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==, tarball: https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz} + + '@jsr/std__io@0.225.2': + resolution: {integrity: sha512-QNImMbao6pKXvV4xpFkY2zviZi1r+1KpYzgMqaa2gHDPZhXQqlia/Og+VqMxxfAr8Pw6BF3tw/hSw3LrWWTRmA==, tarball: https://npm.jsr.io/~/11/@jsr/std__io/0.225.2.tgz} + + '@jsr/std__path@1.1.4': + resolution: {integrity: sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==, tarball: https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -2972,6 +3018,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/node@22.19.7': + resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + '@types/node@24.10.9': resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} @@ -5243,6 +5292,9 @@ packages: linebreak@1.1.0: resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lint-staged@16.2.7: resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} engines: {node: '>=20.17'} @@ -5306,6 +5358,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} @@ -5326,6 +5381,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + marked@17.0.1: resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} engines: {node: '>= 20'} @@ -5347,6 +5406,9 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -6022,6 +6084,10 @@ packages: protocols@2.0.2: resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6734,11 +6800,21 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typedoc@0.28.16: + resolution: {integrity: sha512-x4xW77QC3i5DUFMBp0qjukOTnr/sSg+oEs86nB3LjDslvAmwe/PUGDWbe3GrIqt59oTqoXK5GRK9tAa0sYMiog==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -6761,6 +6837,9 @@ packages: unctx@2.5.0: resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -8457,6 +8536,14 @@ snapshots: '@exodus/bytes@1.9.0': optional: true + '@gerrit0/mini-shiki@3.21.0': + dependencies: + '@shikijs/engine-oniguruma': 3.21.0 + '@shikijs/langs': 3.21.0 + '@shikijs/themes': 3.21.0 + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + '@html-validate/stylish@4.3.0': dependencies: kleur: 4.1.5 @@ -8708,6 +8795,42 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsr/deno__cache-dir@0.25.0': + dependencies: + '@jsr/deno__graph': 0.86.9 + '@jsr/std__fmt': 1.0.9 + '@jsr/std__fs': 1.0.22 + '@jsr/std__io': 0.225.2 + '@jsr/std__path': 1.1.4 + + '@jsr/deno__doc@0.189.1': + dependencies: + '@jsr/deno__cache-dir': 0.25.0 + '@jsr/deno__graph': 0.100.1 + + '@jsr/deno__graph@0.100.1': {} + + '@jsr/deno__graph@0.86.9': {} + + '@jsr/std__bytes@1.0.6': {} + + '@jsr/std__fmt@1.0.9': {} + + '@jsr/std__fs@1.0.22': + dependencies: + '@jsr/std__internal': 1.0.12 + '@jsr/std__path': 1.1.4 + + '@jsr/std__internal@1.0.12': {} + + '@jsr/std__io@0.225.2': + dependencies: + '@jsr/std__bytes': 1.0.6 + + '@jsr/std__path@1.1.4': + dependencies: + '@jsr/std__internal': 1.0.12 + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 @@ -10154,6 +10277,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node@22.19.7': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.9': dependencies: undici-types: 7.16.0 @@ -12609,7 +12736,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.10.9 + '@types/node': 22.19.7 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -12784,6 +12911,10 @@ snapshots: base64-js: 0.0.8 unicode-trie: 2.0.0 + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lint-staged@16.2.7: dependencies: commander: 14.0.2 @@ -12868,6 +12999,8 @@ snapshots: dependencies: yallist: 3.1.1 + lunr@2.3.9: {} + magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 @@ -12900,6 +13033,15 @@ snapshots: dependencies: semver: 7.7.3 + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + marked@17.0.1: {} marky@1.3.0: {} @@ -12922,6 +13064,8 @@ snapshots: mdn-data@2.12.2: {} + mdurl@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -13887,6 +14031,8 @@ snapshots: protocols@2.0.2: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} quansync@0.2.11: {} @@ -14744,8 +14890,19 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedoc@0.28.16(typescript@5.9.3): + dependencies: + '@gerrit0/mini-shiki': 3.21.0 + lunr: 2.3.9 + markdown-it: 14.1.0 + minimatch: 9.0.5 + typescript: 5.9.3 + yaml: 2.8.2 + typescript@5.9.3: {} + uc.micro@2.1.0: {} + ufo@1.6.3: {} ultrahtml@1.6.0: {} @@ -14779,6 +14936,8 @@ snapshots: magic-string: 0.30.21 unplugin: 2.3.11 + undici-types@6.21.0: {} + undici-types@7.16.0: {} unenv@2.0.0-rc.24: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3b8b7c4989..0a9e5ebc5a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - '.' - 'cli' + - 'prototypes/*' overrides: sharp: 0.34.5 diff --git a/prototypes/deno-doc/README.md b/prototypes/deno-doc/README.md new file mode 100644 index 0000000000..a2ee3da9de --- /dev/null +++ b/prototypes/deno-doc/README.md @@ -0,0 +1,183 @@ +# @deno/doc Prototype Findings + +## Summary + +This prototype explored using `@deno/doc` to generate TypeScript documentation for npm packages. + +## Key Discovery: Use esm.sh Instead of jsDelivr! + +**esm.sh properly resolves module imports**, while jsDelivr serves files as-is without resolution. + +```javascript +// WORKS - esm.sh resolves imports +deno doc --json "https://esm.sh/zod@4?target=deno" // 380 nodes! + +// FAILS - jsDelivr doesn't resolve relative imports +deno doc --json "https://cdn.jsdelivr.net/npm/zod@4/index.d.ts" // Error +``` + +## Approaches Tested + +### 1. Direct `@deno/doc` JSR Package in Node.js/Bun + +**Result: Does not work** + +- The JSR package (`@deno/doc`) can be installed via `npx jsr add @deno/doc` +- However, the WASM loading mechanism requires `file://` URLs which are blocked outside Deno +- The package throws: `"Loading local files are not supported in this environment"` + +### 2. `deno doc --json` CLI with esm.sh + +**Result: Works excellently!** + +```javascript +import { spawn } from "node:child_process"; + +async function denoDoc(packageName, version) { + const url = `https://esm.sh/${packageName}@${version}?target=deno`; + return new Promise((resolve, reject) => { + const proc = spawn("deno", ["doc", "--json", url]); + // ... collect stdout, parse JSON + }); +} + +const result = await denoDoc("zod", "4.3.6"); +// Returns: { version: 1, nodes: [...] } with 380 nodes! +``` + +## Test Results with esm.sh + +| Package | Nodes | Breakdown | +|---------|-------|-----------| +| **zod@4** | 380 | 112 functions, 82 interfaces, 87 variables, 11 type aliases | +| **vue@3** | 408 | Full Vue API documented | +| **date-fns@3** | 364 | All date functions | +| **axios@1** | 80 | 4 classes, 36 interfaces, 8 functions | +| **react@18** | 30 | 7 interfaces, 19 type aliases | +| **ufo** | 63 | 52 functions, 5 interfaces | + +### Packages Using @types/* (DefinitelyTyped) + +| Package | Direct | @types/* | Notes | +|---------|--------|----------|-------| +| express | 4 nodes | 4 nodes + 30 in namespace | Uses `export default namespace` pattern | +| lodash-es | 0 nodes | N/A | No bundled types | + +For packages like express that use namespace patterns, the documentation IS available - it's just nested inside the namespace node. + +## Limitations + +### 1. Packages Without Bundled Types +- Packages relying solely on `@types/*` may have limited results +- Can fall back to documenting the `@types/*` package directly +- Namespace patterns need flattening in the UI + +### 2. Server Requirements +- Requires Deno installed on the server +- Subprocess spawning has overhead (~200-500ms per call) +- Would need caching strategy (by package@version) + +### 3. esm.sh Dependency +- Relies on esm.sh for module resolution +- esm.sh is reliable but is a third-party service +- Could self-host esm.sh if needed + +## Recommended Architecture + +``` + ┌─────────────────────────┐ + │ npmx.dev Server │ + │ (Nitro) │ + └───────────┬─────────────┘ + │ + ┌───────────▼─────────────┐ + │ Doc Generation API │ + │ GET /api/docs/[pkg] │ + └───────────┬─────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐ + │ Check Cache │ │ Build esm.sh │ │ Subprocess │ + │ (KV/Redis) │ │ URL │ │ deno doc --json │ + └─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### URL Strategy + +```javascript +function getDocUrl(packageName, version) { + // esm.sh handles module resolution + return `https://esm.sh/${packageName}@${version}?target=deno`; +} + +// For @types/* fallback +function getTypesUrl(packageName, version) { + const typesName = packageName.startsWith("@") + ? `@types/${packageName.slice(1).replace("/", "__")}` + : `@types/${packageName}`; + return `https://esm.sh/${typesName}@${version}?target=deno`; +} +``` + +### Caching Strategy + +1. Cache by `{package}@{version}` (immutable - versions don't change) +2. Store in KV/Redis with 30-day TTL +3. Generate on first request, return cached thereafter +4. Pre-warm cache for popular packages + +### Handling Namespace Patterns + +For packages like express that use `export default namespace`: + +```javascript +function flattenNamespaces(nodes) { + return nodes.flatMap(node => { + if (node.kind === "namespace" && node.namespaceDef?.elements) { + // Include both the namespace and its flattened elements + return [node, ...node.namespaceDef.elements.map(el => ({ + ...el, + namespacePath: node.name + }))]; + } + return [node]; + }); +} +``` + +## Alternative: TypeDoc + +If the Deno dependency is undesirable, TypeDoc is the main alternative: + +```javascript +import { Application } from "typedoc"; + +const app = await Application.bootstrap({ + entryPoints: ["./index.d.ts"], + skipErrorChecking: true, +}); + +const project = await app.convert(); +const json = app.serializer.projectToObject(project, cwd); +``` + +**Pros**: Native npm package, no Deno dependency +**Cons**: Heavier, requires TypeScript compiler, slower + +## Files + +- `src/test-deno-cli.js` - Working Deno CLI integration test +- `src/test.js` - Failed direct @deno/doc import test +- `src/test-direct-wasm.js` - Failed direct WASM loading test + +## Conclusion + +**Recommendation**: Use `deno doc --json` CLI with esm.sh URLs + +- **Works reliably** - esm.sh resolves module imports correctly +- **High quality output** - Same format as JSR documentation +- **Good coverage** - Works for most packages with bundled types +- **Fallback available** - Can use @types/* for packages without bundled types +- **Server requirement** - Needs Deno installed (acceptable for Vercel) +- **Caching essential** - Doc generation takes ~1-3 seconds, cache by version diff --git a/prototypes/deno-doc/package.json b/prototypes/deno-doc/package.json new file mode 100644 index 0000000000..7731eb2912 --- /dev/null +++ b/prototypes/deno-doc/package.json @@ -0,0 +1,17 @@ +{ + "name": "deno-doc-prototype", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Prototype for @deno/doc integration to generate TypeScript documentation", + "scripts": { + "test": "node --experimental-strip-types src/test.ts", + "test:bun": "bun src/test.ts" + }, + "dependencies": { + "@deno/doc": "npm:@jsr/deno__doc@^0.189.1" + }, + "devDependencies": { + "@types/node": "^22.0.0" + } +} diff --git a/prototypes/deno-doc/src/test-deno-cli.js b/prototypes/deno-doc/src/test-deno-cli.js new file mode 100644 index 0000000000..a4810a4757 --- /dev/null +++ b/prototypes/deno-doc/src/test-deno-cli.js @@ -0,0 +1,249 @@ +/** + * Prototype test - Using `deno doc --json` CLI + * + * This is the officially recommended approach for non-Deno environments. + */ + +import { spawn } from "node:child_process"; +import { writeFile, unlink, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +console.log("=".repeat(60)); +console.log("@deno/doc Prototype - Using Deno CLI"); +console.log("=".repeat(60)); + +/** + * Run `deno doc --json` on a URL or file + * @param {string} specifier - URL or file path to document + * @returns {Promise<{version: number, nodes: object[]}>} - Doc output with nodes + */ +async function denoDoc(specifier) { + return new Promise((resolve, reject) => { + const args = ["doc", "--json", specifier]; + const proc = spawn("deno", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code !== 0) { + reject(new Error(`deno doc failed (code ${code}): ${stderr}`)); + return; + } + try { + const result = JSON.parse(stdout); + // Handle both old format (array) and new format ({ version, nodes }) + if (Array.isArray(result)) { + resolve({ version: 0, nodes: result }); + } else { + resolve(result); + } + } catch (e) { + reject(new Error(`Failed to parse JSON: ${e.message}`)); + } + }); + + proc.on("error", reject); + }); +} + +/** + * Create a temp file with TypeScript content and document it + * @param {string} content - TypeScript source code + * @returns {Promise} - Array of doc nodes + */ +async function docFromString(content) { + const tempDir = join(tmpdir(), "deno-doc-test"); + await mkdir(tempDir, { recursive: true }); + const tempFile = join(tempDir, `test-${Date.now()}.ts`); + + try { + await writeFile(tempFile, content); + return await denoDoc(tempFile); + } finally { + await unlink(tempFile).catch(() => {}); + } +} + +// Test 1: Inline TypeScript +console.log("\n[1] Testing with inline TypeScript..."); + +const testCode = ` +/** + * Adds two numbers together. + * @param a - The first number + * @param b - The second number + * @returns The sum of a and b + */ +export function add(a: number, b: number): number { + return a + b; +} + +/** + * Configuration options for the library. + */ +export interface Config { + /** Enable debug mode */ + debug?: boolean; + /** API endpoint URL */ + endpoint: string; +} + +/** + * A simple class example. + */ +export class Calculator { + /** The current value */ + value: number = 0; + + /** Add a number to the current value */ + add(n: number): this { + this.value += n; + return this; + } +} + +/** Type alias example */ +export type Handler = (event: Event) => void; +`; + +try { + const result = await docFromString(testCode); + const nodes = result.nodes; + console.log("SUCCESS: Got doc nodes!"); + console.log(`Total nodes: ${nodes.length}`); + + for (const node of nodes) { + console.log(`\n - ${node.kind}: ${node.name}`); + if (node.jsDoc?.doc) { + const doc = node.jsDoc.doc.trim().replace(/\n/g, " "); + console.log(` JSDoc: "${doc.slice(0, 60)}${doc.length > 60 ? "..." : ""}"`); + } + // Show function details + if (node.kind === "function" && node.functionDef) { + const params = node.functionDef.params?.map((p) => p.name).join(", ") || ""; + const returnType = node.functionDef.returnType?.repr || "void"; + console.log(` Signature: (${params}) => ${returnType}`); + } + // Show interface properties + if (node.kind === "interface" && node.interfaceDef?.properties) { + const props = node.interfaceDef.properties.map((p) => p.name).join(", "); + console.log(` Properties: ${props}`); + } + // Show class members + if (node.kind === "class" && node.classDef) { + const props = node.classDef.properties?.map((p) => p.name).join(", ") || ""; + const methods = node.classDef.methods?.map((m) => m.name).join(", ") || ""; + if (props) console.log(` Properties: ${props}`); + if (methods) console.log(` Methods: ${methods}`); + } + } +} catch (error) { + console.log("FAILED:", error.message); +} + +// Test 2: Remote URL (esm.sh - resolves imports correctly!) +console.log("\n\n[2] Testing with remote URL (zod@4 from esm.sh)..."); + +try { + // esm.sh resolves module imports correctly, unlike jsDelivr + const zodUrl = "https://esm.sh/zod@4.3.6?target=deno"; + console.log(`Documenting: ${zodUrl}`); + + const result = await denoDoc(zodUrl); + const nodes = result.nodes; + console.log(`SUCCESS: Got ${nodes.length} doc nodes!`); + + // Group by kind + const byKind = {}; + for (const node of nodes) { + byKind[node.kind] = (byKind[node.kind] || 0) + 1; + } + console.log("By kind:", byKind); + + // Show first few + console.log("\nFirst 10 exports:"); + for (const node of nodes.slice(0, 10)) { + let info = ` - ${node.kind}: ${node.name}`; + if (node.jsDoc?.doc) { + const doc = node.jsDoc.doc.trim().split("\n")[0]; + info += ` - "${doc.slice(0, 40)}${doc.length > 40 ? "..." : ""}"`; + } + console.log(info); + } +} catch (error) { + console.log("FAILED:", error.message); +} + +// Test 3: More packages via esm.sh +console.log("\n\n[3] Testing more packages via esm.sh..."); + +const packages = [ + { name: "axios", version: "1" }, + { name: "date-fns", version: "3" }, + { name: "vue", version: "3" }, +]; + +for (const pkg of packages) { + try { + const url = `https://esm.sh/${pkg.name}@${pkg.version}?target=deno`; + console.log(`\n ${pkg.name}@${pkg.version}:`); + + const result = await denoDoc(url); + const nodes = result.nodes; + + const byKind = {}; + for (const node of nodes) { + byKind[node.kind] = (byKind[node.kind] || 0) + 1; + } + console.log(` ${nodes.length} nodes:`, byKind); + } catch (error) { + console.log(` FAILED: ${error.message}`); + } +} + +// Test 4: @types/* fallback (express uses namespace pattern) +console.log("\n\n[4] Testing @types/express (namespace pattern)..."); + +try { + const url = "https://esm.sh/@types/express@4?target=deno"; + console.log(`Documenting: ${url}`); + + const result = await denoDoc(url); + const nodes = result.nodes; + console.log(`Got ${nodes.length} top-level nodes`); + + // Check for namespace content + const namespaceNode = nodes.find((n) => n.kind === "namespace"); + if (namespaceNode?.namespaceDef?.elements) { + console.log( + `Namespace "${namespaceNode.name}" contains ${namespaceNode.namespaceDef.elements.length} elements:` + ); + const nsElements = namespaceNode.namespaceDef.elements.slice(0, 10); + for (const el of nsElements) { + console.log(` - ${el.kind}: ${el.name}`); + } + if (namespaceNode.namespaceDef.elements.length > 10) { + console.log( + ` ... and ${namespaceNode.namespaceDef.elements.length - 10} more` + ); + } + } +} catch (error) { + console.log("FAILED:", error.message); +} + +console.log("\n" + "=".repeat(60)); +console.log("Deno CLI test complete!"); +console.log("=".repeat(60)); diff --git a/prototypes/deno-doc/src/test-direct-wasm.js b/prototypes/deno-doc/src/test-direct-wasm.js new file mode 100644 index 0000000000..65a40ed7a8 --- /dev/null +++ b/prototypes/deno-doc/src/test-direct-wasm.js @@ -0,0 +1,73 @@ +/** + * Prototype test - Direct WASM loading to bypass file:// URL limitation + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +console.log("=".repeat(60)); +console.log("@deno/doc Prototype - Direct WASM Loading"); +console.log("=".repeat(60)); + +// Find the WASM file path +const __dirname = dirname(fileURLToPath(import.meta.url)); +const wasmPath = join( + __dirname, + "../../../node_modules/.pnpm/@jsr+deno__doc@0.189.1/node_modules/@jsr/deno__doc/deno_doc_wasm_bg.wasm" +); + +console.log("\n[1] Loading WASM file directly..."); +console.log(`WASM path: ${wasmPath}`); + +try { + // Read WASM bytes + const wasmBytes = readFileSync(wasmPath); + console.log(`WASM size: ${wasmBytes.length} bytes`); + + // Import the generated JS module to get the imports and exports setup + // Use full path since subpath exports aren't defined + const wasmGenerated = await import( + "../../../node_modules/.pnpm/@jsr+deno__doc@0.189.1/node_modules/@jsr/deno__doc/deno_doc_wasm.generated.js" + ); + console.log("Generated JS exports:", Object.keys(wasmGenerated)); + + // Try to instantiate with the bytes directly + console.log("\n[2] Instantiating WASM..."); + + // The instantiate function should accept url option + const instance = await wasmGenerated.instantiate({ + // Provide WASM bytes via a data URL or custom approach + url: new URL( + `data:application/wasm;base64,${wasmBytes.toString("base64")}` + ), + }); + + console.log("SUCCESS: WASM instantiated!"); + console.log("Instance exports:", Object.keys(instance)); +} catch (error) { + console.log("FAILED:", error.message); + + // Alternative: Try using the instantiate with WebAssembly directly + console.log("\n[3] Trying alternative: WebAssembly.instantiate directly..."); + + try { + const wasmBytes = readFileSync(wasmPath); + + // We need the imports object from the generated file + // This is hacky but let's see if we can make it work + const { imports } = await import( + "../../../node_modules/.pnpm/@jsr+deno__doc@0.189.1/node_modules/@jsr/deno__doc/deno_doc_wasm.generated.js" + ).then((m) => ({ imports: m.imports || {} })); + + const { instance } = await WebAssembly.instantiate(wasmBytes, imports); + console.log("SUCCESS: Direct WebAssembly instantiation!"); + console.log("Instance exports:", Object.keys(instance.exports)); + } catch (e2) { + console.log("Also FAILED:", e2.message); + } +} + +console.log("\n" + "=".repeat(60)); +console.log("Direct WASM test complete"); +console.log("=".repeat(60)); diff --git a/prototypes/deno-doc/src/test.js b/prototypes/deno-doc/src/test.js new file mode 100644 index 0000000000..caf020fb53 --- /dev/null +++ b/prototypes/deno-doc/src/test.js @@ -0,0 +1,209 @@ +/** + * Prototype test for @deno/doc integration + * + * Goal: Generate TypeScript documentation from npm packages using @deno/doc + */ + +console.log("=".repeat(60)); +console.log("@deno/doc Prototype - Testing JSR Package in Node.js/Bun"); +console.log("=".repeat(60)); + +// Step 1: Try importing @deno/doc +console.log("\n[1] Attempting to import @deno/doc..."); + +try { + const denoDoc = await import("@deno/doc"); + console.log("SUCCESS: @deno/doc imported!"); + console.log("Exports:", Object.keys(denoDoc)); + + // Step 2: Test with inline content via a virtual HTTPS URL + // Note: file:// URLs don't work outside Deno, so we use HTTPS with custom loader + console.log("\n[2] Testing doc() with inline TypeScript (virtual HTTPS URL)..."); + + const testCode = ` +/** + * Adds two numbers together. + * @param a - The first number + * @param b - The second number + * @returns The sum of a and b + */ +export function add(a: number, b: number): number { + return a + b; +} + +/** + * Configuration options for the library. + */ +export interface Config { + /** Enable debug mode */ + debug?: boolean; + /** API endpoint URL */ + endpoint: string; +} + +/** + * A simple class example. + */ +export class Calculator { + /** The current value */ + value: number = 0; + + /** Add a number to the current value */ + add(n: number): this { + this.value += n; + return this; + } +} + +/** Type alias example */ +export type Handler = (event: Event) => void; +`; + + // Use a virtual HTTPS URL with a custom loader + const virtualUrl = "https://virtual.test/mod.ts"; + + const result = await denoDoc.doc([virtualUrl], { + load(specifier) { + console.log(` Loading: ${specifier}`); + if (specifier === virtualUrl) { + return Promise.resolve({ + kind: "module", + specifier, + headers: { "content-type": "application/typescript" }, + content: testCode, + }); + } + // Return undefined for unknown imports (will be treated as external) + return Promise.resolve(undefined); + }, + }); + + console.log("\nSUCCESS: doc() returned results!"); + console.log(`Modules documented: ${Object.keys(result).length}`); + + for (const [url, nodes] of Object.entries(result)) { + console.log(`\nModule: ${url}`); + console.log(`Doc nodes: ${nodes.length}`); + for (const node of nodes) { + console.log(` - ${node.kind}: ${node.name}`); + if (node.jsDoc?.doc) { + const doc = node.jsDoc.doc.trim().replace(/\n/g, " "); + console.log(` JSDoc: "${doc.slice(0, 60)}${doc.length > 60 ? "..." : ""}"`); + } + // Show params for functions + if (node.kind === "function" && node.functionDef?.params) { + console.log(` Params: ${node.functionDef.params.map((p) => p.name).join(", ")}`); + } + // Show properties for interfaces + if (node.kind === "interface" && node.interfaceDef?.properties) { + console.log( + ` Properties: ${node.interfaceDef.properties.map((p) => p.name).join(", ")}` + ); + } + } + } + + // Step 3: Test with a real npm package from jsDelivr + console.log("\n[3] Testing with real npm package (zod) from jsDelivr..."); + + const zodIndexUrl = "https://cdn.jsdelivr.net/npm/zod@3.24.0/lib/index.d.ts"; + + const zodResult = await denoDoc.doc([zodIndexUrl], { + async load(specifier) { + console.log(` Loading: ${specifier}`); + try { + const res = await fetch(specifier); + if (!res.ok) { + console.log(` Failed: ${res.status}`); + return undefined; + } + const content = await res.text(); + console.log(` Loaded ${content.length} bytes`); + return { + kind: "module", + specifier: res.url, // Use final URL after redirects + headers: Object.fromEntries(res.headers), + content, + }; + } catch (e) { + console.log(` Error: ${e}`); + return undefined; + } + }, + }); + + console.log("\nSUCCESS: Documented zod package!"); + for (const [url, nodes] of Object.entries(zodResult)) { + console.log(`\nModule: ${url}`); + console.log(`Total doc nodes: ${nodes.length}`); + + // Group by kind + const byKind = {}; + for (const node of nodes) { + byKind[node.kind] = (byKind[node.kind] || 0) + 1; + } + console.log("By kind:", byKind); + + // Show first few exports + console.log("\nFirst 15 exports:"); + for (const node of nodes.slice(0, 15)) { + let info = ` - ${node.kind}: ${node.name}`; + if (node.jsDoc?.doc) { + const doc = node.jsDoc.doc.trim().split("\n")[0]; + info += ` - "${doc.slice(0, 40)}${doc.length > 40 ? "..." : ""}"`; + } + console.log(info); + } + } + + // Step 4: Test with @types package (lodash) + console.log("\n[4] Testing with @types/lodash from jsDelivr..."); + + const lodashTypesUrl = + "https://cdn.jsdelivr.net/npm/@types/lodash@4.17.0/index.d.ts"; + + const lodashResult = await denoDoc.doc([lodashTypesUrl], { + async load(specifier) { + console.log(` Loading: ${specifier}`); + try { + const res = await fetch(specifier); + if (!res.ok) { + console.log(` Failed: ${res.status}`); + return undefined; + } + const content = await res.text(); + console.log(` Loaded ${content.length} bytes`); + return { + kind: "module", + specifier: res.url, + headers: Object.fromEntries(res.headers), + content, + }; + } catch (e) { + console.log(` Error: ${e}`); + return undefined; + } + }, + }); + + console.log("\nSUCCESS: Documented @types/lodash!"); + for (const [url, nodes] of Object.entries(lodashResult)) { + console.log(`\nModule: ${url}`); + console.log(`Total doc nodes: ${nodes.length}`); + + const byKind = {}; + for (const node of nodes) { + byKind[node.kind] = (byKind[node.kind] || 0) + 1; + } + console.log("By kind:", byKind); + } +} catch (error) { + console.log("FAILED:", error); + if (error instanceof Error) { + console.log("Stack:", error.stack); + } +} + +console.log("\n" + "=".repeat(60)); +console.log("Prototype test complete"); +console.log("=".repeat(60)); diff --git a/prototypes/deno-doc/src/test.ts b/prototypes/deno-doc/src/test.ts new file mode 100644 index 0000000000..7d16adc3c4 --- /dev/null +++ b/prototypes/deno-doc/src/test.ts @@ -0,0 +1,143 @@ +/** + * Prototype test for @deno/doc integration + * + * Goal: Generate TypeScript documentation from npm packages using @deno/doc + */ + +export {}; + +console.log("=".repeat(60)); +console.log("@deno/doc Prototype - Testing JSR Package in Node.js"); +console.log("=".repeat(60)); + +// Step 1: Try importing @deno/doc +console.log("\n[1] Attempting to import @deno/doc..."); + +try { + const denoDoc = await import("@deno/doc"); + console.log("SUCCESS: @deno/doc imported!"); + console.log("Exports:", Object.keys(denoDoc)); + + // Step 2: Try using the doc() function with a custom loader + console.log("\n[2] Testing doc() with inline TypeScript..."); + + const testCode = ` +/** + * Adds two numbers together. + * @param a - The first number + * @param b - The second number + * @returns The sum of a and b + */ +export function add(a: number, b: number): number { + return a + b; +} + +/** + * Configuration options for the library. + */ +export interface Config { + /** Enable debug mode */ + debug?: boolean; + /** API endpoint URL */ + endpoint: string; +} + +/** + * A simple class example. + */ +export class Calculator { + /** The current value */ + value: number = 0; + + /** Add a number to the current value */ + add(n: number): this { + this.value += n; + return this; + } +} +`; + + const result = await denoDoc.doc(["file:///test.ts"], { + load(specifier: string) { + console.log(` Loading: ${specifier}`); + if (specifier === "file:///test.ts") { + return Promise.resolve({ + kind: "module" as const, + specifier, + content: testCode, + }); + } + return Promise.resolve(undefined); + }, + }); + + console.log("\nSUCCESS: doc() returned results!"); + console.log(`Modules documented: ${Object.keys(result).length}`); + + for (const [url, nodes] of Object.entries(result)) { + console.log(`\nModule: ${url}`); + console.log(`Doc nodes: ${(nodes as any[]).length}`); + for (const node of nodes as any[]) { + console.log(` - ${node.kind}: ${node.name}`); + if (node.jsDoc?.doc) { + console.log(` "${node.jsDoc.doc.slice(0, 50)}..."`); + } + } + } + + // Step 3: Test with a real npm package from jsDelivr + console.log("\n[3] Testing with real npm package (zod) from jsDelivr..."); + + const zodDtsUrl = "https://cdn.jsdelivr.net/npm/zod@3.24.0/lib/types.d.ts"; + + const zodResult = await denoDoc.doc([zodDtsUrl], { + async load(specifier: string) { + console.log(` Loading: ${specifier}`); + try { + const res = await fetch(specifier); + if (!res.ok) { + console.log(` Failed: ${res.status}`); + return undefined; + } + const content = await res.text(); + console.log(` Loaded ${content.length} bytes`); + return { + kind: "module" as const, + specifier: res.url, + content, + }; + } catch (e) { + console.log(` Error: ${e}`); + return undefined; + } + }, + }); + + console.log("\nSUCCESS: Documented zod package!"); + for (const [url, nodes] of Object.entries(zodResult)) { + console.log(`\nModule: ${url}`); + console.log(`Total doc nodes: ${(nodes as any[]).length}`); + + // Group by kind + const byKind: Record = {}; + for (const node of nodes as any[]) { + byKind[node.kind] = (byKind[node.kind] || 0) + 1; + } + console.log("By kind:", byKind); + + // Show first few + console.log("\nFirst 10 exports:"); + for (const node of (nodes as any[]).slice(0, 10)) { + console.log(` - ${node.kind}: ${node.name}`); + } + } +} catch (error) { + console.log("FAILED:", error); + if (error instanceof Error) { + console.log("Stack:", error.stack); + } +} + +console.log("\n" + "=".repeat(60)); +console.log("Prototype test complete"); +console.log("=".repeat(60)); diff --git a/server/api/registry/docs/[...pkg].get.ts b/server/api/registry/docs/[...pkg].get.ts new file mode 100644 index 0000000000..d2cc5fd1b2 --- /dev/null +++ b/server/api/registry/docs/[...pkg].get.ts @@ -0,0 +1,89 @@ +import type { DocsResponse } from '#shared/types' +import { fetchNpmPackage } from '#server/utils/npm' +import { assertValidPackageName } from '#shared/utils/npm' +import { generateDocsWithDeno } from '#server/utils/docs' + +export default defineCachedEventHandler( + async (event) => { + const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] + if (segments.length === 0) { + throw createError({ statusCode: 400, message: 'Package name is required' }) + } + + // Parse package name and optional version from URL segments + // Patterns: [pkg] or [pkg, 'v', version] or [@scope, pkg] or [@scope, pkg, 'v', version] + let packageName: string + let version: string | undefined + + const vIndex = segments.indexOf('v') + if (vIndex !== -1 && vIndex < segments.length - 1) { + packageName = segments.slice(0, vIndex).join('/') + version = segments.slice(vIndex + 1).join('/') + } + else { + packageName = segments.join('/') + } + + if (!packageName) { + throw createError({ statusCode: 400, message: 'Package name is required' }) + } + assertValidPackageName(packageName) + + const packument = await fetchNpmPackage(packageName) + + if (!version) { + version = packument['dist-tags']?.latest + } + + if (!version) { + throw createError({ statusCode: 404, message: 'No latest version found' }) + } + + try { + const generated = await generateDocsWithDeno(packageName, version) + + if (!generated) { + return { + package: packageName, + version, + html: '', + toc: null, + breadcrumbs: null, + status: 'missing', + message: 'Docs are not available for this package. It may not have TypeScript types.', + } satisfies DocsResponse + } + + return { + package: packageName, + version, + html: generated.html, + toc: generated.toc, + breadcrumbs: null, + status: 'ok', + } satisfies DocsResponse + } + catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + console.error(`[docs] Error generating docs for ${packageName}@${version}:`, message) + + return { + package: packageName, + version, + html: '', + toc: null, + breadcrumbs: null, + status: 'error', + message: 'Failed to generate docs. The package may not export TypeScript types.', + } satisfies DocsResponse + } + }, + { + maxAge: 60 * 60, // 1 hour cache + swr: true, + getKey: (event) => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `docs:v5:${pkg}` + }, + }, +) diff --git a/server/utils/docs.ts b/server/utils/docs.ts new file mode 100644 index 0000000000..b50c548b38 --- /dev/null +++ b/server/utils/docs.ts @@ -0,0 +1,782 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { highlightCodeBlock } from './shiki' + +const execFileAsync = promisify(execFile) + +// More complete type definitions based on actual deno doc output +export interface JsDocTag { + kind: string + name?: string + doc?: string + optional?: boolean + type?: string +} + +export interface TsType { + repr: string + kind: string + keyword?: string + typeRef?: { + typeName: string + typeParams?: TsType[] | null + } + array?: TsType + union?: TsType[] + literal?: { + kind: string + string?: string + number?: number + boolean?: boolean + } +} + +export interface FunctionParam { + kind: string + name: string + optional?: boolean + tsType?: TsType +} + +export interface DenoDocNode { + name: string + kind: string + isDefault?: boolean + location?: { + filename: string + line: number + col: number + } + declarationKind?: string + jsDoc?: { + doc?: string + tags?: JsDocTag[] + } + functionDef?: { + params?: FunctionParam[] + returnType?: TsType + isAsync?: boolean + isGenerator?: boolean + typeParams?: Array<{ name: string }> + } + classDef?: { + isAbstract?: boolean + properties?: Array<{ + name: string + tsType?: TsType + readonly?: boolean + optional?: boolean + isStatic?: boolean + jsDoc?: { doc?: string } + }> + methods?: Array<{ + name: string + isStatic?: boolean + functionDef?: { + params?: FunctionParam[] + returnType?: TsType + } + jsDoc?: { doc?: string } + }> + constructors?: Array<{ + params?: FunctionParam[] + }> + extends?: TsType + implements?: TsType[] + } + interfaceDef?: { + properties?: Array<{ + name: string + tsType?: TsType + readonly?: boolean + optional?: boolean + jsDoc?: { doc?: string } + }> + methods?: Array<{ + name: string + params?: FunctionParam[] + returnType?: TsType + jsDoc?: { doc?: string } + }> + extends?: TsType[] + typeParams?: Array<{ name: string }> + } + typeAliasDef?: { + tsType?: TsType + typeParams?: Array<{ name: string }> + } + variableDef?: { + tsType?: TsType + kind?: string + } + enumDef?: { + members?: Array<{ name: string; init?: TsType }> + } + namespaceDef?: { + elements?: DenoDocNode[] + } +} + +export interface DenoDocResult { + version: number + nodes: DenoDocNode[] +} + +export interface DenoDocsGenerationResult { + html: string + toc: string | null + nodes: DenoDocNode[] +} + +/** + * Generate documentation for an npm package using `deno doc --json` via esm.sh + */ +export async function generateDocsWithDeno( + packageName: string, + version: string, +): Promise { + const url = buildEsmShUrl(packageName, version) + + try { + const result = await runDenoDoc(url) + + if (!result.nodes || result.nodes.length === 0) { + console.warn(`[docs-deno] no nodes found for ${packageName}@${version}`) + return null + } + + // Flatten namespace elements for better display + const flattenedNodes = flattenNamespaces(result.nodes) + + // Build symbol lookup for cross-references + const symbolLookup = buildSymbolLookup(flattenedNodes) + + const html = await renderDocNodes(flattenedNodes, packageName, version, symbolLookup) + const toc = renderToc(flattenedNodes) + + return { html, toc, nodes: flattenedNodes } + } + catch (error) { + console.error(`[docs-deno] failed to generate docs for ${packageName}@${version}`, error) + throw error + } +} + +function buildEsmShUrl(packageName: string, version: string): string { + return `https://esm.sh/${packageName}@${version}?target=deno` +} + +async function runDenoDoc(specifier: string): Promise { + try { + const { stdout } = await execFileAsync('deno', ['doc', '--json', specifier], { + maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large packages + }) + + const result = JSON.parse(stdout) as DenoDocResult + return result + } + catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + throw new Error(`deno doc failed: ${message}`) + } +} + +function flattenNamespaces(nodes: DenoDocNode[]): DenoDocNode[] { + const result: DenoDocNode[] = [] + + for (const node of nodes) { + // Skip import and reference nodes + if (node.kind === 'import' || node.kind === 'reference') { + continue + } + + result.push(node) + + // Flatten namespace elements + if (node.kind === 'namespace' && node.namespaceDef?.elements) { + for (const element of node.namespaceDef.elements) { + result.push({ + ...element, + name: `${node.name}.${element.name}`, + }) + } + } + } + + return result +} + +// Merged symbol with all overloads combined +export interface MergedSymbol { + name: string + kind: string + nodes: DenoDocNode[] + // Combined jsDoc from the node that has the best documentation + jsDoc?: DenoDocNode['jsDoc'] +} + +// Map of symbol names to their IDs for cross-referencing +type SymbolLookup = Map + +/** + * Build a lookup table of symbol names to their anchor IDs + */ +function buildSymbolLookup(nodes: DenoDocNode[]): SymbolLookup { + const lookup = new Map() + + for (const node of nodes) { + const cleanName = cleanSymbolName(node.name) + const id = `${node.kind}-${cleanName}`.replace(/[^a-zA-Z0-9-]/g, '_') + lookup.set(cleanName, id) + } + + return lookup +} + +/** + * Clean up symbol names - strip `default.` prefix that esm.sh adds + * for packages using @types/* definitions + */ +function cleanSymbolName(name: string): string { + // Strip "default." prefix (e.g., "default.useState" -> "useState") + if (name.startsWith('default.')) { + return name.slice(8) + } + // Strip "default_" prefix (alternative format) + if (name.startsWith('default_')) { + return name.slice(8) + } + return name +} + +/** + * Parse JSDoc {@link} tags into actual HTML links + * Handles: {@link URL}, {@link URL text}, {@link symbol} + */ +function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): string { + // First escape HTML, then process {@link} tags + let result = escapeHtml(text) + + // Match {@link URL} or {@link URL text} or {@link symbol} + result = result.replace(/\{@link\s+([^\s}]+)(?:\s+([^}]+))?\}/g, (_, target, label) => { + const displayText = label || target + // Check if it's a URL + if (target.startsWith('http://') || target.startsWith('https://')) { + return `${displayText}` + } + // Check if it's a known symbol we can link to + const symbolId = symbolLookup.get(target) + if (symbolId) { + return `${displayText}` + } + // Unknown symbol - display as code + return `${displayText}` + }) + + return result +} + +/** + * Merge function/method overloads into single entries. + * TypeScript packages often export many overloads for the same function. + */ +function mergeOverloads(nodes: DenoDocNode[]): MergedSymbol[] { + const byKey = new Map() + + for (const node of nodes) { + // Clean the name before using as key + const cleanName = cleanSymbolName(node.name) + const key = `${node.kind}:${cleanName}` + const existing = byKey.get(key) + if (existing) { + existing.push(node) + } + else { + byKey.set(key, [node]) + } + } + + const result: MergedSymbol[] = [] + + for (const [, groupedNodes] of byKey) { + const first = groupedNodes[0]! + // Find the node with the best documentation + const withDoc = groupedNodes.find(n => n.jsDoc?.doc) || first + + result.push({ + name: cleanSymbolName(first.name), + kind: first.kind, + nodes: groupedNodes, + jsDoc: withDoc.jsDoc, + }) + } + + // Sort by name + result.sort((a, b) => a.name.localeCompare(b.name)) + + return result +} + +async function renderDocNodes(nodes: DenoDocNode[], _packageName: string, _version: string, symbolLookup: SymbolLookup): Promise { + // Merge overloads before grouping + const merged = mergeOverloads(nodes) + const grouped = groupMergedByKind(merged) + const sections: string[] = [] + + const kindOrder = ['function', 'class', 'interface', 'typeAlias', 'variable', 'enum', 'namespace'] + + for (const kind of kindOrder) { + const kindSymbols = grouped[kind] + if (!kindSymbols || kindSymbols.length === 0) continue + + sections.push(await renderKindSection(kind, kindSymbols, symbolLookup)) + } + + return sections.join('\n') +} + +function groupMergedByKind(symbols: MergedSymbol[]): Record { + const grouped: Record = {} + + for (const sym of symbols) { + if (!grouped[sym.kind]) { + grouped[sym.kind] = [] + } + grouped[sym.kind]!.push(sym) + } + + return grouped +} + +async function renderKindSection(kind: string, symbols: MergedSymbol[], symbolLookup: SymbolLookup): Promise { + const title = getKindTitle(kind) + const lines: string[] = [] + + lines.push(`
    `) + lines.push(`

    ${title}

    `) + + for (const symbol of symbols) { + lines.push(await renderMergedSymbol(symbol, symbolLookup)) + } + + lines.push(`
    `) + + return lines.join('\n') +} + +function getKindTitle(kind: string): string { + const titles: Record = { + function: 'Functions', + class: 'Classes', + interface: 'Interfaces', + typeAlias: 'Type Aliases', + variable: 'Variables', + enum: 'Enums', + namespace: 'Namespaces', + } + return titles[kind] || kind +} + +function getSymbolId(symbol: MergedSymbol): string { + return `${symbol.kind}-${symbol.name}`.replace(/[^a-zA-Z0-9-]/g, '_') +} + +async function renderMergedSymbol(symbol: MergedSymbol, symbolLookup: SymbolLookup): Promise { + const lines: string[] = [] + const id = getSymbolId(symbol) + const primaryNode = symbol.nodes[0]! + const hasOverloads = symbol.nodes.length > 1 + + lines.push(`
    `) + + // Header with name and kind badge + lines.push(`
    `) + lines.push(`#`) + lines.push(`

    ${escapeHtml(symbol.name)}

    `) + lines.push(`${symbol.kind}`) + if (primaryNode.functionDef?.isAsync) { + lines.push(`async`) + } + if (hasOverloads) { + lines.push(`${symbol.nodes.length} overloads`) + } + lines.push(`
    `) + + // Signatures - show all overloads (limit to prevent huge blocks) + const maxSignatures = hasOverloads ? 5 : 1 + const signatures = symbol.nodes.slice(0, maxSignatures).map(n => getNodeSignature(n)).filter(Boolean) + const hasMore = symbol.nodes.length > maxSignatures + + if (signatures.length > 0) { + // Highlight signatures as TypeScript + const signatureCode = signatures.map(s => s!).join('\n') + const highlightedSignature = await highlightCodeBlock(signatureCode, 'typescript') + lines.push(`
    ${highlightedSignature}
    `) + if (hasMore) { + lines.push(`

    + ${symbol.nodes.length - maxSignatures} more overloads

    `) + } + } + + // JSDoc description (from best documented node) + if (symbol.jsDoc?.doc) { + const description = symbol.jsDoc.doc.trim() + lines.push(`
    ${renderMarkdown(description, symbolLookup)}
    `) + } + + // JSDoc tags (params, returns, examples) + if (symbol.jsDoc?.tags && symbol.jsDoc.tags.length > 0) { + lines.push(await renderJsDocTags(symbol.jsDoc.tags, symbolLookup)) + } + + // Members for classes/interfaces (use primary node) + if (symbol.kind === 'class' && primaryNode.classDef) { + lines.push(renderClassMembers(primaryNode.classDef)) + } + else if (symbol.kind === 'interface' && primaryNode.interfaceDef) { + lines.push(renderInterfaceMembers(primaryNode.interfaceDef)) + } + else if (symbol.kind === 'enum' && primaryNode.enumDef) { + lines.push(renderEnumMembers(primaryNode.enumDef)) + } + + lines.push(`
    `) + + return lines.join('\n') +} + + + +function getNodeSignature(node: DenoDocNode): string | null { + const name = cleanSymbolName(node.name) + + switch (node.kind) { + case 'function': { + const typeParams = node.functionDef?.typeParams?.map(t => t.name).join(', ') + const typeParamsStr = typeParams ? `<${typeParams}>` : '' + const params = node.functionDef?.params?.map(p => formatParam(p)).join(', ') || '' + const ret = formatType(node.functionDef?.returnType) || 'void' + const asyncStr = node.functionDef?.isAsync ? 'async ' : '' + return `${asyncStr}function ${name}${typeParamsStr}(${params}): ${ret}` + } + case 'class': { + const ext = node.classDef?.extends ? ` extends ${formatType(node.classDef.extends)}` : '' + const impl = node.classDef?.implements?.map(t => formatType(t)).join(', ') + const implStr = impl ? ` implements ${impl}` : '' + const abstractStr = node.classDef?.isAbstract ? 'abstract ' : '' + return `${abstractStr}class ${name}${ext}${implStr}` + } + case 'interface': { + const typeParams = node.interfaceDef?.typeParams?.map(t => t.name).join(', ') + const typeParamsStr = typeParams ? `<${typeParams}>` : '' + const ext = node.interfaceDef?.extends?.map(t => formatType(t)).join(', ') + const extStr = ext ? ` extends ${ext}` : '' + return `interface ${name}${typeParamsStr}${extStr}` + } + case 'typeAlias': { + const typeParams = node.typeAliasDef?.typeParams?.map(t => t.name).join(', ') + const typeParamsStr = typeParams ? `<${typeParams}>` : '' + const type = formatType(node.typeAliasDef?.tsType) || 'unknown' + return `type ${name}${typeParamsStr} = ${type}` + } + case 'variable': { + const keyword = node.variableDef?.kind === 'const' ? 'const' : 'let' + const type = formatType(node.variableDef?.tsType) || 'unknown' + return `${keyword} ${name}: ${type}` + } + case 'enum': { + return `enum ${name}` + } + default: + return null + } +} + +function formatParam(param: FunctionParam): string { + const optional = param.optional ? '?' : '' + const type = formatType(param.tsType) + return type ? `${param.name}${optional}: ${type}` : `${param.name}${optional}` +} + +function formatType(type?: TsType): string { + if (!type) return '' + + if (type.repr) return type.repr + + if (type.kind === 'keyword' && type.keyword) { + return type.keyword + } + + if (type.kind === 'typeRef' && type.typeRef) { + const params = type.typeRef.typeParams?.map(t => formatType(t)).join(', ') + return params ? `${type.typeRef.typeName}<${params}>` : type.typeRef.typeName + } + + if (type.kind === 'array' && type.array) { + return `${formatType(type.array)}[]` + } + + if (type.kind === 'union' && type.union) { + return type.union.map(t => formatType(t)).join(' | ') + } + + return type.repr || 'unknown' +} + +async function renderJsDocTags(tags: JsDocTag[], symbolLookup: SymbolLookup): Promise { + const lines: string[] = [] + const params = tags.filter(t => t.kind === 'param') + const returns = tags.find(t => t.kind === 'return') + const examples = tags.filter(t => t.kind === 'example') + const deprecated = tags.find(t => t.kind === 'deprecated') + const see = tags.filter(t => t.kind === 'see') + + // Deprecated warning + if (deprecated) { + lines.push(`
    `) + lines.push(`Deprecated`) + if (deprecated.doc) { + lines.push(`

    ${parseJsDocLinks(deprecated.doc, symbolLookup)}

    `) + } + lines.push(`
    `) + } + + // Parameters + if (params.length > 0) { + lines.push(`
    `) + lines.push(`

    Parameters

    `) + lines.push(`
    `) + for (const param of params) { + lines.push(`
    ${escapeHtml(param.name || '')}${param.optional ? '?' : ''}
    `) + if (param.doc) { + lines.push(`
    ${parseJsDocLinks(param.doc, symbolLookup)}
    `) + } + } + lines.push(`
    `) + lines.push(`
    `) + } + + // Return + if (returns?.doc) { + lines.push(`
    `) + lines.push(`

    Returns

    `) + lines.push(`

    ${parseJsDocLinks(returns.doc, symbolLookup)}

    `) + lines.push(`
    `) + } + + // Examples - use Shiki for syntax highlighting + if (examples.length > 0) { + lines.push(`
    `) + lines.push(`

    Example${examples.length > 1 ? 's' : ''}

    `) + for (const example of examples) { + if (example.doc) { + // Extract language and code from markdown code blocks + const langMatch = example.doc.match(/```(\w+)?/) + const lang = langMatch?.[1] || 'typescript' + const code = example.doc.replace(/```\w*\n?/g, '').trim() + const highlighted = await highlightCodeBlock(code, lang) + lines.push(highlighted) + } + } + lines.push(`
    `) + } + + // See also + if (see.length > 0) { + lines.push(`
    `) + lines.push(`

    See Also

    `) + lines.push(`
      `) + for (const s of see) { + if (s.doc) { + // Parse {@link} tags + lines.push(`
    • ${parseJsDocLinks(s.doc, symbolLookup)}
    • `) + } + } + lines.push(`
    `) + lines.push(`
    `) + } + + return lines.join('\n') +} + +function renderClassMembers(def: NonNullable): string { + const lines: string[] = [] + const { constructors, properties, methods } = def + + // Constructors + if (constructors && constructors.length > 0) { + lines.push(`
    `) + lines.push(`

    Constructor

    `) + for (const ctor of constructors) { + const params = ctor.params?.map(p => formatParam(p)).join(', ') || '' + lines.push(`
    constructor(${escapeHtml(params)})
    `) + } + lines.push(`
    `) + } + + // Properties + if (properties && properties.length > 0) { + lines.push(`
    `) + lines.push(`

    Properties

    `) + lines.push(`
    `) + for (const prop of properties) { + const modifiers: string[] = [] + if (prop.isStatic) modifiers.push('static') + if (prop.readonly) modifiers.push('readonly') + const modStr = modifiers.length > 0 ? `${modifiers.join(' ')} ` : '' + const type = formatType(prop.tsType) + const opt = prop.optional ? '?' : '' + lines.push(`
    ${escapeHtml(modStr)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}
    `) + const propDoc = prop.jsDoc?.doc + if (propDoc) { + lines.push(`
    ${escapeHtml(propDoc.split('\n')[0] ?? '')}
    `) + } + } + lines.push(`
    `) + lines.push(`
    `) + } + + // Methods + if (methods && methods.length > 0) { + lines.push(`
    `) + lines.push(`

    Methods

    `) + lines.push(`
    `) + for (const method of methods) { + const params = method.functionDef?.params?.map(p => formatParam(p)).join(', ') || '' + const ret = formatType(method.functionDef?.returnType) || 'void' + const staticStr = method.isStatic ? 'static ' : '' + lines.push(`
    ${escapeHtml(staticStr)}${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}
    `) + const methodDoc = method.jsDoc?.doc + if (methodDoc) { + lines.push(`
    ${escapeHtml(methodDoc.split('\n')[0] ?? '')}
    `) + } + } + lines.push(`
    `) + lines.push(`
    `) + } + + return lines.join('\n') +} + +function renderInterfaceMembers(def: NonNullable): string { + const lines: string[] = [] + const { properties, methods } = def + + if (properties && properties.length > 0) { + lines.push(`
    `) + lines.push(`

    Properties

    `) + lines.push(`
    `) + for (const prop of properties) { + const type = formatType(prop.tsType) + const opt = prop.optional ? '?' : '' + const ro = prop.readonly ? 'readonly ' : '' + lines.push(`
    ${escapeHtml(ro)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}
    `) + const propDoc = prop.jsDoc?.doc + if (propDoc) { + lines.push(`
    ${escapeHtml(propDoc.split('\n')[0] ?? '')}
    `) + } + } + lines.push(`
    `) + lines.push(`
    `) + } + + if (methods && methods.length > 0) { + lines.push(`
    `) + lines.push(`

    Methods

    `) + lines.push(`
    `) + for (const method of methods) { + const params = method.params?.map(p => formatParam(p)).join(', ') || '' + const ret = formatType(method.returnType) || 'void' + lines.push(`
    ${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}
    `) + const methodDoc = method.jsDoc?.doc + if (methodDoc) { + lines.push(`
    ${escapeHtml(methodDoc.split('\n')[0] ?? '')}
    `) + } + } + lines.push(`
    `) + lines.push(`
    `) + } + + return lines.join('\n') +} + +function renderEnumMembers(def: NonNullable): string { + const lines: string[] = [] + const { members } = def + + if (members && members.length > 0) { + lines.push(`
    `) + lines.push(`

    Members

    `) + lines.push(`
      `) + for (const member of members) { + lines.push(`
    • ${escapeHtml(member.name)}
    • `) + } + lines.push(`
    `) + lines.push(`
    `) + } + + return lines.join('\n') +} + +function renderToc(nodes: DenoDocNode[]): string { + // Use merged symbols for TOC to avoid duplicates + const merged = mergeOverloads(nodes) + const grouped = groupMergedByKind(merged) + const lines: string[] = [] + + lines.push(``) + + return lines.join('\n') +} + +function renderMarkdown(text: string, symbolLookup: SymbolLookup): string { + // First parse {@link} tags (this also escapes HTML) + let result = parseJsDocLinks(text, symbolLookup) + + // Then apply markdown formatting + result = result + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\n\n/g, '

    ') + .replace(/\n/g, '
    ') + + return result +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/shared/types/docs.ts b/shared/types/docs.ts new file mode 100644 index 0000000000..a59164f811 --- /dev/null +++ b/shared/types/docs.ts @@ -0,0 +1,17 @@ +export type DocsStatus = 'ok' | 'missing' | 'error' + +export interface DocsResponse { + package: string + version: string + html: string + toc: string | null + breadcrumbs?: string | null + status: DocsStatus + message?: string +} + +export interface DocsSearchResponse { + package: string + version: string + index: Record +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 2414a4f37f..918f91143e 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -2,3 +2,4 @@ export * from './npm-registry' export * from './jsr' export * from './osv' export * from './readme' +export * from './docs' From d87bfcdcb0d2990807d832faa8bbd7f6f0f81b27 Mon Sep 17 00:00:00 2001 From: dev wells Date: Sun, 25 Jan 2026 16:19:37 -0500 Subject: [PATCH 02/22] cleanup old prototype --- app/pages/docs/[...path].vue | 31 +- prototypes/deno-doc/README.md | 183 ------ prototypes/deno-doc/package.json | 17 - prototypes/deno-doc/src/test-deno-cli.js | 249 -------- prototypes/deno-doc/src/test-direct-wasm.js | 73 --- prototypes/deno-doc/src/test.js | 209 ------- prototypes/deno-doc/src/test.ts | 143 ----- server/utils/docs.ts | 652 ++++++++++++-------- 8 files changed, 435 insertions(+), 1122 deletions(-) delete mode 100644 prototypes/deno-doc/README.md delete mode 100644 prototypes/deno-doc/package.json delete mode 100644 prototypes/deno-doc/src/test-deno-cli.js delete mode 100644 prototypes/deno-doc/src/test-direct-wasm.js delete mode 100644 prototypes/deno-doc/src/test.js delete mode 100644 prototypes/deno-doc/src/test.ts diff --git a/app/pages/docs/[...path].vue b/app/pages/docs/[...path].vue index ed6878868a..6a0c25644d 100644 --- a/app/pages/docs/[...path].vue +++ b/app/pages/docs/[...path].vue @@ -49,9 +49,11 @@ const docsUrl = computed(() => { return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}` }) +const shouldFetch = computed(() => !!docsUrl.value) + const { data: docsData, status: docsStatus } = useLazyFetch(() => docsUrl.value!, { watch: [docsUrl], - immediate: computed(() => !!docsUrl.value).value, + immediate: shouldFetch.value, default: () => ({ package: packageName.value, version: resolvedVersion.value ?? '', @@ -78,9 +80,9 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')