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')
+ })
+})