Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 62 additions & 25 deletions server/utils/docs/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div class="docs-members">`)
lines.push(`<h4>${title}</h4>`)
lines.push(`<dl>`)
for (const item of items) {
lines.push(`<dt><code>${escapeHtml(item.signature)}</code></dt>`)
if (item.description) {
lines.push(`<dd>${escapeHtml(item.description.split('\n')[0] ?? '')}</dd>`)
}
}
lines.push(`</dl>`)
lines.push(`</div>`)

return lines.join('\n')
}

/**
* Render class members (constructor, properties, methods).
*/
Expand All @@ -272,44 +299,54 @@ function renderClassMembers(def: NonNullable<DenoDocNode['classDef']>): string {
}

if (properties && properties.length > 0) {
lines.push(`<div class="docs-members">`)
lines.push(`<h4>Properties</h4>`)
lines.push(`<dl>`)
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(
`<dt><code>${escapeHtml(modStr)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}</code></dt>`,
)
if (prop.jsDoc?.doc) {
lines.push(`<dd>${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}</dd>`)
const typeStr = type ? `: ${type}` : ''

return {
signature: `${modStr}${prop.name}${opt}${typeStr}`,
description: prop.jsDoc?.doc,
}
}
lines.push(`</dl>`)
lines.push(`</div>`)
})

lines.push(renderMemberList('Properties', propertyItems))
}

if (methods && methods.length > 0) {
lines.push(`<div class="docs-members">`)
lines.push(`<h4>Methods</h4>`)
lines.push(`<dl>`)
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(
`<dt><code>${escapeHtml(staticStr)}${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}</code></dt>`,
)
if (method.jsDoc?.doc) {
lines.push(`<dd>${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}</dd>`)

return {
signature: `${staticStr}${method.name}(${params}): ${ret}`,
description: method.jsDoc?.doc,
}
}
lines.push(`</dl>`)
lines.push(`</div>`)
})

lines.push(renderMemberList('Methods', methodItems))
}

return lines.join('\n')
Expand Down
1 change: 1 addition & 0 deletions shared/types/deno-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface DenoDocNode {
}>
methods?: Array<{
name: string
kind?: 'method' | 'getter'
isStatic?: boolean
functionDef?: {
params?: FunctionParam[]
Expand Down
133 changes: 133 additions & 0 deletions test/unit/server/utils/docs/render.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<h4>Getters</h4>')
expect(html).toContain('get clientId')
expect(html).not.toContain('<h4>Methods</h4>')
})

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('<h4>Methods</h4>')
expect(html).toContain('connect(')
expect(html).not.toContain('<h4>Getters</h4>')
})

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('<h4>Getters</h4>')
expect(html).toContain('<h4>Methods</h4>')

// 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('<h4>Getters</h4>')
const methodsIndex = html.indexOf('<h4>Methods</h4>')
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')
})
})
Loading