diff --git a/server/utils/docs/render.ts b/server/utils/docs/render.ts index bf6aedf58f..c4b259dbf6 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -254,6 +254,33 @@ async function renderJsDocTags(tags: JsDocTag[], symbolLookup: SymbolLookup): Pr // Member Rendering // ============================================================================= +type DefinitionListItem = { + signature: string + description?: string +} + +function renderMemberList(title: string, items: DefinitionListItem[]): string { + const lines: string[] = [] + + if (items.length === 0) { + return '' + } + + lines.push(`
`) + lines.push(`

${title}

`) + lines.push(`
`) + for (const item of items) { + lines.push(`
${escapeHtml(item.signature)}
`) + if (item.description) { + lines.push(`
${escapeHtml(item.description.split('\n')[0] ?? '')}
`) + } + } + lines.push(`
`) + lines.push(`
`) + + return lines.join('\n') +} + /** * Render class members (constructor, properties, methods). */ @@ -272,44 +299,54 @@ function renderClassMembers(def: NonNullable): string { } if (properties && properties.length > 0) { - lines.push(`
`) - lines.push(`

Properties

`) - lines.push(`
`) - for (const prop of properties) { + const propertyItems: DefinitionListItem[] = properties.map(prop => { 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)}
`, - ) - if (prop.jsDoc?.doc) { - lines.push(`
${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}
`) + const typeStr = type ? `: ${type}` : '' + + return { + signature: `${modStr}${prop.name}${opt}${typeStr}`, + description: prop.jsDoc?.doc, } - } - lines.push(`
`) - lines.push(`
`) + }) + + lines.push(renderMemberList('Properties', propertyItems)) } - if (methods && methods.length > 0) { - lines.push(`
`) - lines.push(`

Methods

`) - lines.push(`
`) - for (const method of methods) { + const getters = methods?.filter(m => m.kind === 'getter') || [] + const regularMethods = methods?.filter(m => m.kind !== 'getter') || [] + + if (getters.length > 0) { + const getterItems: DefinitionListItem[] = getters.map(getter => { + const ret = formatType(getter.functionDef?.returnType) || 'unknown' + const staticStr = getter.isStatic ? 'static ' : '' + + return { + signature: `${staticStr}get ${getter.name}: ${ret}`, + description: getter.jsDoc?.doc, + } + }) + + lines.push(renderMemberList('Getters', getterItems)) + } + + if (regularMethods.length > 0) { + const methodItems: DefinitionListItem[] = regularMethods.map(method => { 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)}
`, - ) - if (method.jsDoc?.doc) { - lines.push(`
${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}
`) + + return { + signature: `${staticStr}${method.name}(${params}): ${ret}`, + description: method.jsDoc?.doc, } - } - lines.push(`
`) - lines.push(`
`) + }) + + lines.push(renderMemberList('Methods', methodItems)) } return lines.join('\n') diff --git a/shared/types/deno-doc.ts b/shared/types/deno-doc.ts index 96676c861d..80a30b2b97 100644 --- a/shared/types/deno-doc.ts +++ b/shared/types/deno-doc.ts @@ -100,6 +100,7 @@ export interface DenoDocNode { }> methods?: Array<{ name: string + kind?: 'method' | 'getter' isStatic?: boolean functionDef?: { params?: FunctionParam[] diff --git a/test/unit/server/utils/docs/render.spec.ts b/test/unit/server/utils/docs/render.spec.ts new file mode 100644 index 0000000000..eefe37408a --- /dev/null +++ b/test/unit/server/utils/docs/render.spec.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { renderDocNodes } from '../../../../../server/utils/docs/render' +import type { DenoDocNode } from '#shared/types/deno-doc' +import type { MergedSymbol } from '../../../../../server/utils/docs/types' + +// ============================================================================= +// Issue #1943: class getters shown as methods +// https://github.com/npmx-dev/npmx.dev/issues/1943 +// ============================================================================= + +function createClassSymbol(classDef: DenoDocNode['classDef']): MergedSymbol { + const node: DenoDocNode = { + name: 'TestClass', + kind: 'class', + classDef, + } + return { + name: 'TestClass', + kind: 'class', + nodes: [node], + } +} + +describe('issue #1943 - class getters separated from methods', () => { + it('renders getters under a "Getters" heading, not "Methods"', async () => { + const symbol = createClassSymbol({ + methods: [ + { + name: 'clientId', + kind: 'getter', + functionDef: { + returnType: { repr: 'string', kind: 'keyword', keyword: 'string' }, + }, + }, + ], + }) + + const html = await renderDocNodes([symbol], new Map()) + + expect(html).toContain('

Getters

') + expect(html).toContain('get clientId') + expect(html).not.toContain('

Methods

') + }) + + it('renders regular methods under "Methods" heading', async () => { + const symbol = createClassSymbol({ + methods: [ + { + name: 'connect', + kind: 'method', + functionDef: { + params: [], + returnType: { repr: 'void', kind: 'keyword', keyword: 'void' }, + }, + }, + ], + }) + + const html = await renderDocNodes([symbol], new Map()) + + expect(html).toContain('

Methods

') + expect(html).toContain('connect(') + expect(html).not.toContain('

Getters

') + }) + + it('renders both getters and methods in separate sections', async () => { + const symbol = createClassSymbol({ + methods: [ + { + name: 'clientId', + kind: 'getter', + functionDef: { + returnType: { repr: 'string', kind: 'keyword', keyword: 'string' }, + }, + jsDoc: { doc: 'The client ID' }, + }, + { + name: 'connect', + kind: 'method', + functionDef: { + params: [ + { + kind: 'identifier', + name: 'url', + tsType: { repr: 'string', kind: 'keyword', keyword: 'string' }, + }, + ], + returnType: { repr: 'void', kind: 'keyword', keyword: 'void' }, + }, + jsDoc: { doc: 'Connect to server' }, + }, + ], + }) + + const html = await renderDocNodes([symbol], new Map()) + + // Both sections should exist + expect(html).toContain('

Getters

') + expect(html).toContain('

Methods

') + + // Getter should use "get" prefix without parentheses + expect(html).toContain('get clientId') + expect(html).toContain('The client ID') + + // Method should have parentheses + expect(html).toContain('connect(') + expect(html).toContain('Connect to server') + + // Getters section should appear before Methods section + const gettersIndex = html.indexOf('

Getters

') + const methodsIndex = html.indexOf('

Methods

') + expect(gettersIndex).toBeLessThan(methodsIndex) + }) + + it('renders static getter correctly', async () => { + const symbol = createClassSymbol({ + methods: [ + { + name: 'instance', + kind: 'getter', + isStatic: true, + functionDef: { + returnType: { repr: 'TestClass', kind: 'typeRef', typeRef: { typeName: 'TestClass' } }, + }, + }, + ], + }) + + const html = await renderDocNodes([symbol], new Map()) + + expect(html).toContain('static get instance') + }) +})