From 6e9b8cee87a58036caea034523e2bf7941fb93ce Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Thu, 26 Feb 2026 14:22:58 +0800 Subject: [PATCH 1/3] fix: unexpected `unknown` type --- server/utils/docs/format.ts | 41 +++++++++++++++++++++++++++++++++---- shared/types/deno-doc.ts | 23 ++++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/server/utils/docs/format.ts b/server/utils/docs/format.ts index def6f30f32..f2b8dbe16b 100644 --- a/server/utils/docs/format.ts +++ b/server/utils/docs/format.ts @@ -25,7 +25,7 @@ export function getNodeSignature(node: DenoDocNode): string | null { return `${asyncStr}function ${name}${typeParamsStr}(${params}): ${ret}` } case 'class': { - const ext = node.classDef?.extends ? ` extends ${formatType(node.classDef.extends)}` : '' + const ext = node.classDef?.extends ? ` extends ${node.classDef.extends}` : '' const impl = node.classDef?.implements?.map(t => formatType(t)).join(', ') const implStr = impl ? ` implements ${impl}` : '' const abstractStr = node.classDef?.isAbstract ? 'abstract ' : '' @@ -72,9 +72,6 @@ export function formatParam(param: FunctionParam): string { export function formatType(type?: TsType): string { if (!type) return '' - // Strip ANSI codes from repr (deno doc may include terminal colors since it's built for that) - if (type.repr) return stripAnsi(type.repr) - if (type.kind === 'keyword' && type.keyword) { return type.keyword } @@ -92,5 +89,41 @@ export function formatType(type?: TsType): string { return type.union.map(t => formatType(t)).join(' | ') } + if (type.kind === 'this') { + return 'this' + } + + if (type.kind === 'indexedAccess' && type.indexedAccess) { + return `${formatType(type.indexedAccess.objType)}[${formatType(type.indexedAccess.indexType)}]` + } + + if (type.kind === 'typeOperator' && type.typeOperator) { + return `${type.typeOperator.operator} ${formatType(type.typeOperator.tsType)}` + } + + if (type.kind === 'fnOrConstructor' && type.fnOrConstructor) { + const { fnOrConstructor: fn } = type + const typeParams = fn.typeParams?.map(t => t.name).join(', ') + const typeParamsStr = typeParams ? `<${typeParams}>` : '' + const params = fn.params.map(p => formatParam(p)).join(', ') + const ret = formatType(fn.tsType) || 'void' + return `${typeParamsStr}(${params}) => ${ret}` + } + + if (type.kind === 'typeLiteral' && type.typeLiteral) { + const parts: string[] = [] + for (const prop of type.typeLiteral.properties) { + const opt = prop.optional ? '?' : '' + const ro = prop.readonly ? 'readonly ' : '' + parts.push(`${ro}${prop.name}${opt}: ${formatType(prop.tsType) || 'unknown'}`) + } + for (const method of type.typeLiteral.methods) { + const params = method.params?.map(p => formatParam(p)).join(', ') || '' + const ret = formatType(method.returnType) || 'void' + parts.push(`${method.name}(${params}): ${ret}`) + } + return `{ ${parts.join('; ')} }` + } + return type.repr ? stripAnsi(type.repr) : 'unknown' } diff --git a/shared/types/deno-doc.ts b/shared/types/deno-doc.ts index 45d099ad0e..96676c861d 100644 --- a/shared/types/deno-doc.ts +++ b/shared/types/deno-doc.ts @@ -35,6 +35,27 @@ export interface TsType { number?: number boolean?: boolean } + fnOrConstructor?: { + constructor: boolean + tsType: TsType + params: FunctionParam[] + typeParams?: Array<{ name: string; constraint?: TsType }> + } + indexedAccess?: { + objType: TsType + indexType: TsType + } + typeOperator?: { + operator: string + tsType: TsType + } + this?: boolean + typeLiteral?: { + properties: Array<{ name: string; tsType?: TsType; readonly?: boolean; optional?: boolean }> + methods: Array<{ name: string; params?: FunctionParam[]; returnType?: TsType }> + callSignatures: Array<{ params?: FunctionParam[]; tsType?: TsType }> + indexSignatures: Array<{ params: FunctionParam[]; tsType?: TsType }> + } } /** Function parameter from deno doc */ @@ -89,7 +110,7 @@ export interface DenoDocNode { constructors?: Array<{ params?: FunctionParam[] }> - extends?: TsType + extends?: string implements?: TsType[] } interfaceDef?: { From df8c4d4ef86c81dba08ddea690e833efed7559f7 Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Thu, 26 Feb 2026 17:17:55 +0800 Subject: [PATCH 2/3] test: add docs format test case for #1411 --- .../linkdave@0.0.2-client-message.json | 63 +++++++ .../doc-nodes/linkdave@0.0.2-client.json | 158 ++++++++++++++++++ .../linkdave@0.0.2-manager-events.json | 98 +++++++++++ .../doc-nodes/linkdave@0.0.2-pick-type.json | 42 +++++ .../linkdave@0.0.2-send-to-shard.json | 34 ++++ test/unit/server/utils/docs/format.spec.ts | 86 ++++++++++ 6 files changed, 481 insertions(+) create mode 100644 test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client-message.json create mode 100644 test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client.json create mode 100644 test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-manager-events.json create mode 100644 test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-pick-type.json create mode 100644 test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-send-to-shard.json create mode 100644 test/unit/server/utils/docs/format.spec.ts diff --git a/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client-message.json b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client-message.json new file mode 100644 index 0000000000..199081b7da --- /dev/null +++ b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client-message.json @@ -0,0 +1,63 @@ +{ + "name": "ClientMessage", + "kind": "typeAlias", + "typeAliasDef": { + "tsType": { + "repr": "", + "kind": "union", + "union": [ + { + "repr": "", + "kind": "typeLiteral", + "typeLiteral": { + "properties": [ + { + "name": "op", + "optional": false, + "tsType": { + "repr": "voiceUpdate", + "kind": "literal", + "literal": { "kind": "string", "string": "voiceUpdate" } + } + }, + { + "name": "guildId", + "optional": false, + "tsType": { "repr": "string", "kind": "keyword", "keyword": "string" } + } + ], + "methods": [], + "callSignatures": [], + "indexSignatures": [] + } + }, + { + "repr": "", + "kind": "typeLiteral", + "typeLiteral": { + "properties": [ + { + "name": "op", + "optional": false, + "tsType": { + "repr": "play", + "kind": "literal", + "literal": { "kind": "string", "string": "play" } + } + }, + { + "name": "guildId", + "optional": false, + "tsType": { "repr": "string", "kind": "keyword", "keyword": "string" } + } + ], + "methods": [], + "callSignatures": [], + "indexSignatures": [] + } + } + ] + }, + "typeParams": [] + } +} diff --git a/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client.json b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client.json new file mode 100644 index 0000000000..abcc3545e8 --- /dev/null +++ b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-client.json @@ -0,0 +1,158 @@ +{ + "name": "LinkDaveClient", + "kind": "interface", + "interfaceDef": { + "extends": [], + "constructors": [], + "methods": [], + "properties": [ + { + "name": "on", + "computed": false, + "optional": false, + "tsType": { + "repr": "", + "kind": "fnOrConstructor", + "fnOrConstructor": { + "constructor": false, + "tsType": { "repr": "this", "kind": "this", "this": true }, + "params": [ + { + "kind": "identifier", + "name": "event", + "optional": false, + "tsType": { + "repr": "K", + "kind": "typeRef", + "typeRef": { "typeName": "K" } + } + }, + { + "kind": "identifier", + "name": "listener", + "optional": false, + "tsType": { + "repr": "", + "kind": "fnOrConstructor", + "fnOrConstructor": { + "constructor": false, + "tsType": { "repr": "void", "kind": "keyword", "keyword": "void" }, + "params": [ + { + "kind": "identifier", + "name": "data", + "optional": false, + "tsType": { + "repr": "", + "kind": "indexedAccess", + "indexedAccess": { + "readonly": false, + "objType": { + "repr": "ManagerEvents", + "kind": "typeRef", + "typeRef": { "typeName": "ManagerEvents" } + }, + "indexType": { + "repr": "K", + "kind": "typeRef", + "typeRef": { "typeName": "K" } + } + } + } + } + ], + "typeParams": [] + } + } + } + ], + "typeParams": [ + { + "name": "K", + "constraint": { + "repr": "", + "kind": "typeOperator", + "typeOperator": { + "operator": "keyof", + "tsType": { + "repr": "ManagerEvents", + "kind": "typeRef", + "typeRef": { "typeName": "ManagerEvents" } + } + } + } + } + ] + } + } + }, + { + "name": "emit", + "computed": false, + "optional": false, + "tsType": { + "repr": "", + "kind": "fnOrConstructor", + "fnOrConstructor": { + "constructor": false, + "tsType": { "repr": "boolean", "kind": "keyword", "keyword": "boolean" }, + "params": [ + { + "kind": "identifier", + "name": "event", + "optional": false, + "tsType": { + "repr": "K", + "kind": "typeRef", + "typeRef": { "typeName": "K" } + } + }, + { + "kind": "identifier", + "name": "data", + "optional": false, + "tsType": { + "repr": "", + "kind": "indexedAccess", + "indexedAccess": { + "readonly": false, + "objType": { + "repr": "ManagerEvents", + "kind": "typeRef", + "typeRef": { "typeName": "ManagerEvents" } + }, + "indexType": { + "repr": "K", + "kind": "typeRef", + "typeRef": { "typeName": "K" } + } + } + } + } + ], + "typeParams": [ + { + "name": "K", + "constraint": { + "repr": "", + "kind": "typeOperator", + "typeOperator": { + "operator": "keyof", + "tsType": { + "repr": "ManagerEvents", + "kind": "typeRef", + "typeRef": { "typeName": "ManagerEvents" } + } + } + } + } + ] + } + } + } + ], + "callSignatures": [], + "indexSignatures": [], + "typeParams": [] + } +} diff --git a/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-manager-events.json b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-manager-events.json new file mode 100644 index 0000000000..86b23d7b3d --- /dev/null +++ b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-manager-events.json @@ -0,0 +1,98 @@ +{ + "name": "ManagerEvents", + "kind": "interface", + "interfaceDef": { + "extends": [{ "repr": "Events", "kind": "typeRef", "typeRef": { "typeName": "Events" } }], + "constructors": [], + "methods": [], + "properties": [ + { + "name": "[ManagerEventName.NodeAdd]", + "computed": true, + "optional": false, + "tsType": { + "repr": "", + "kind": "typeLiteral", + "typeLiteral": { + "properties": [ + { + "name": "node", + "optional": false, + "tsType": { + "repr": "Node", + "kind": "typeRef", + "typeRef": { "typeName": "Node" } + } + } + ], + "methods": [], + "callSignatures": [], + "indexSignatures": [] + } + } + }, + { + "name": "[ManagerEventName.NodeRemove]", + "computed": true, + "optional": false, + "tsType": { + "repr": "", + "kind": "typeLiteral", + "typeLiteral": { + "properties": [ + { + "name": "node", + "optional": false, + "tsType": { + "repr": "Node", + "kind": "typeRef", + "typeRef": { "typeName": "Node" } + } + } + ], + "methods": [], + "callSignatures": [], + "indexSignatures": [] + } + } + }, + { + "name": "[ManagerEventName.NodeReconnectAttempt]", + "computed": true, + "optional": false, + "tsType": { + "repr": "", + "kind": "typeLiteral", + "typeLiteral": { + "properties": [ + { + "name": "node", + "optional": false, + "tsType": { + "repr": "Node", + "kind": "typeRef", + "typeRef": { "typeName": "Node" } + } + }, + { + "name": "attempt", + "optional": false, + "tsType": { + "repr": "number", + "kind": "keyword", + "keyword": "number" + } + } + ], + "methods": [], + "callSignatures": [], + "indexSignatures": [] + } + } + } + ], + "callSignatures": [], + "indexSignatures": [], + "typeParams": [] + } +} diff --git a/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-pick-type.json b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-pick-type.json new file mode 100644 index 0000000000..cde489d816 --- /dev/null +++ b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-pick-type.json @@ -0,0 +1,42 @@ +{ + "name": "RawVoiceServerUpdate", + "kind": "typeAlias", + "typeAliasDef": { + "tsType": { + "repr": "Pick", + "kind": "typeRef", + "typeRef": { + "typeName": "Pick", + "typeParams": [ + { + "repr": "GatewayVoiceServerUpdateDispatchData", + "kind": "typeRef", + "typeRef": { "typeName": "GatewayVoiceServerUpdateDispatchData" } + }, + { + "repr": "", + "kind": "union", + "union": [ + { + "repr": "token", + "kind": "literal", + "literal": { "kind": "string", "string": "token" } + }, + { + "repr": "guild_id", + "kind": "literal", + "literal": { "kind": "string", "string": "guild_id" } + }, + { + "repr": "endpoint", + "kind": "literal", + "literal": { "kind": "string", "string": "endpoint" } + } + ] + } + ] + } + }, + "typeParams": [] + } +} diff --git a/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-send-to-shard.json b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-send-to-shard.json new file mode 100644 index 0000000000..24cdf24dc4 --- /dev/null +++ b/test/fixtures/esm-sh/doc-nodes/linkdave@0.0.2-send-to-shard.json @@ -0,0 +1,34 @@ +{ + "name": "SendToShardFn", + "kind": "typeAlias", + "typeAliasDef": { + "tsType": { + "repr": "", + "kind": "fnOrConstructor", + "fnOrConstructor": { + "constructor": false, + "tsType": { "repr": "void", "kind": "keyword", "keyword": "void" }, + "params": [ + { + "kind": "identifier", + "name": "guildId", + "optional": false, + "tsType": { "repr": "string", "kind": "keyword", "keyword": "string" } + }, + { + "kind": "identifier", + "name": "payload", + "optional": false, + "tsType": { + "repr": "GatewayVoiceStateUpdate", + "kind": "typeRef", + "typeRef": { "typeName": "GatewayVoiceStateUpdate" } + } + } + ], + "typeParams": [] + } + }, + "typeParams": [] + } +} diff --git a/test/unit/server/utils/docs/format.spec.ts b/test/unit/server/utils/docs/format.spec.ts new file mode 100644 index 0000000000..a478c7244e --- /dev/null +++ b/test/unit/server/utils/docs/format.spec.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { formatType, getNodeSignature } from '../../../../../server/utils/docs/format' +import type { DenoDocNode } from '#shared/types/deno-doc' + +function loadFixture(name: string): DenoDocNode { + const path = resolve(__dirname, '../../../../fixtures/esm-sh/doc-nodes', name) + return JSON.parse(readFileSync(path, 'utf-8')) +} + +// ============================================================================= +// Issue #1411: wrong `unknown` types in package api docs +// https://github.com/npmx-dev/npmx.dev/issues/1411 +// ============================================================================= + +describe('issue #1411 - linkdave@0.0.2 unknown types', () => { + it('event listener: interface properties with fnOrConstructor type (client.d.ts)', () => { + const node = loadFixture('linkdave@0.0.2-client.json') + const onProp = node.interfaceDef!.properties![0]! + const emitProp = node.interfaceDef!.properties![1]! + + const onType = formatType(onProp.tsType) + expect(onType).not.toBe('unknown') + expect(onType).toContain('event: K') + expect(onType).toContain('=> this') + + const emitType = formatType(emitProp.tsType) + expect(emitType).not.toBe('unknown') + expect(emitType).toContain('=> boolean') + }) + + it('interface with enum keys: typeLiteral properties (types.d.ts)', () => { + const node = loadFixture('linkdave@0.0.2-manager-events.json') + + for (const prop of node.interfaceDef!.properties!) { + const type = formatType(prop.tsType) + expect(type).not.toBe('unknown') + expect(type).toContain('node: Node') + } + + const reconnectProp = node.interfaceDef!.properties![2]! + expect(formatType(reconnectProp.tsType)).toContain('attempt: number') + }) + + it('type alias with union of object literals (types.d.ts)', () => { + const node = loadFixture('linkdave@0.0.2-client-message.json') + const type = formatType(node.typeAliasDef!.tsType) + + expect(type).not.toBe('unknown') + expect(type).toContain('op:') + expect(type).toContain('guildId: string') + }) + + it('arrow function type alias (client.d.ts)', () => { + const node = loadFixture('linkdave@0.0.2-send-to-shard.json') + const type = formatType(node.typeAliasDef!.tsType) + + expect(type).not.toBe('unknown') + expect(type).toContain('guildId: string') + expect(type).toContain('payload: GatewayVoiceStateUpdate') + expect(type).toContain('=> void') + }) + + it('Pick<> generic type alias (player.d.ts)', () => { + const node = loadFixture('linkdave@0.0.2-pick-type.json') + const type = formatType(node.typeAliasDef!.tsType) + + expect(type).not.toBe('unknown') + expect(type).toBe( + 'Pick', + ) + }) + + it('getNodeSignature produces valid signatures for issue nodes', () => { + const sendToShard = loadFixture('linkdave@0.0.2-send-to-shard.json') + const sig = getNodeSignature(sendToShard as DenoDocNode) + expect(sig).not.toContain('unknown') + expect(sig).toContain('type SendToShardFn =') + + const pickType = loadFixture('linkdave@0.0.2-pick-type.json') + const pickSig = getNodeSignature(pickType as DenoDocNode) + expect(pickSig).toContain('type RawVoiceServerUpdate =') + expect(pickSig).not.toContain('= unknown') + }) +}) From 789bcd3ff0c6e24e1361f33cb1eb8a82e21fa189 Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Thu, 26 Feb 2026 17:29:10 +0800 Subject: [PATCH 3/3] refactor: improve the `formatType` method --- server/utils/docs/format.ts | 100 +++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/server/utils/docs/format.ts b/server/utils/docs/format.ts index f2b8dbe16b..0027d6e7c6 100644 --- a/server/utils/docs/format.ts +++ b/server/utils/docs/format.ts @@ -72,58 +72,66 @@ export function formatParam(param: FunctionParam): string { export function formatType(type?: TsType): string { if (!type) return '' - if (type.kind === 'keyword' && type.keyword) { - return type.keyword - } + const formatter = TYPE_FORMATTERS[type.kind] + const formatted = formatter?.(type) + + if (formatted) return formatted + return type.repr ? stripAnsi(type.repr) : 'unknown' +} - if (type.kind === 'typeRef' && type.typeRef) { +const TYPE_FORMATTERS: Partial string>> = { + keyword: type => type.keyword || '', + literal: type => { + if (!type.literal) return '' + if (type.literal.kind === 'string') return `"${type.literal.string}"` + if (type.literal.kind === 'number') return String(type.literal.number) + if (type.literal.kind === 'boolean') return String(type.literal.boolean) + return '' + }, + typeRef: type => { + if (!type.typeRef) return '' 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(' | ') - } - - if (type.kind === 'this') { - return 'this' - } - - if (type.kind === 'indexedAccess' && type.indexedAccess) { + }, + array: type => { + if (!type.array) return '' + const element = formatType(type.array) + return type.array.kind === 'union' ? `(${element})[]` : `${element}[]` + }, + union: type => (type.union ? type.union.map(t => formatType(t)).join(' | ') : ''), + this: () => 'this', + indexedAccess: type => { + if (!type.indexedAccess) return '' return `${formatType(type.indexedAccess.objType)}[${formatType(type.indexedAccess.indexType)}]` - } - - if (type.kind === 'typeOperator' && type.typeOperator) { + }, + typeOperator: type => { + if (!type.typeOperator) return '' return `${type.typeOperator.operator} ${formatType(type.typeOperator.tsType)}` - } + }, + fnOrConstructor: type => + type.fnOrConstructor ? formatFnOrConstructorType(type.fnOrConstructor) : '', + typeLiteral: type => (type.typeLiteral ? formatTypeLiteralType(type.typeLiteral) : ''), +} - if (type.kind === 'fnOrConstructor' && type.fnOrConstructor) { - const { fnOrConstructor: fn } = type - const typeParams = fn.typeParams?.map(t => t.name).join(', ') - const typeParamsStr = typeParams ? `<${typeParams}>` : '' - const params = fn.params.map(p => formatParam(p)).join(', ') - const ret = formatType(fn.tsType) || 'void' - return `${typeParamsStr}(${params}) => ${ret}` - } +function formatFnOrConstructorType(fn: NonNullable): string { + const typeParams = fn.typeParams?.map(t => t.name).join(', ') + const typeParamsStr = typeParams ? `<${typeParams}>` : '' + const params = fn.params.map(p => formatParam(p)).join(', ') + const ret = formatType(fn.tsType) || 'void' + return `${typeParamsStr}(${params}) => ${ret}` +} - if (type.kind === 'typeLiteral' && type.typeLiteral) { - const parts: string[] = [] - for (const prop of type.typeLiteral.properties) { - const opt = prop.optional ? '?' : '' - const ro = prop.readonly ? 'readonly ' : '' - parts.push(`${ro}${prop.name}${opt}: ${formatType(prop.tsType) || 'unknown'}`) - } - for (const method of type.typeLiteral.methods) { - const params = method.params?.map(p => formatParam(p)).join(', ') || '' - const ret = formatType(method.returnType) || 'void' - parts.push(`${method.name}(${params}): ${ret}`) - } - return `{ ${parts.join('; ')} }` +function formatTypeLiteralType(lit: NonNullable): string { + const parts: string[] = [] + for (const prop of lit.properties) { + const opt = prop.optional ? '?' : '' + const ro = prop.readonly ? 'readonly ' : '' + parts.push(`${ro}${prop.name}${opt}: ${formatType(prop.tsType) || 'unknown'}`) } - - return type.repr ? stripAnsi(type.repr) : 'unknown' + for (const method of lit.methods) { + const params = method.params?.map(p => formatParam(p)).join(', ') || '' + const ret = formatType(method.returnType) || 'void' + parts.push(`${method.name}(${params}): ${ret}`) + } + return `{ ${parts.join('; ')} }` }