diff --git a/CHANGELOG.md b/CHANGELOG.md index e82958e30..8b59d6069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,13 @@ ## Features - Added a new `--sitemapBaseUrl` option. When specified, TypeDoc will generate a `sitemap.xml` in your output folder that describes the site, #2480. +- Added support for the `@class` tag. When added to a comment on a variable or function, TypeDoc will convert the member as a class, #2479. + Note: This should only be used on symbols which actually represent a class, but are not declared as a class for some reason. ## Bug Fixes - Fixed an issue where a namespace would not be created for merged function-namespaces which are declared as variables, #2478. +- Variable functions which have construct signatures will no longer be converted as functions, ignoring the construct signatures. - Fixed an issue where, if the index section was collapsed when loading the page, all content within it would be hidden until expanded, and a member visibility checkbox was changed. ## v0.25.7 (2024-01-08) diff --git a/package.json b/package.json index 05bd1e13d..9c48a90ca 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,8 @@ "test:cov": "c8 mocha --config .config/mocha.fast.json", "doc:c": "node bin/typedoc --tsconfig src/test/converter/tsconfig.json", "doc:cd": "node --inspect-brk bin/typedoc --tsconfig src/test/converter/tsconfig.json", - "doc:c2": "node bin/typedoc --tsconfig src/test/converter2/tsconfig.json", - "doc:c2d": "node --inspect-brk bin/typedoc --tsconfig src/test/converter2/tsconfig.json", + "doc:c2": "node bin/typedoc --options src/test/converter2 --tsconfig src/test/converter2/tsconfig.json", + "doc:c2d": "node --inspect-brk bin/typedoc --options src/test/converter2 --tsconfig src/test/converter2/tsconfig.json", "example": "cd example && node ../bin/typedoc", "test:full": "c8 mocha --config .config/mocha.full.json", "test:visual": "ts-node ./src/test/capture-screenshots.ts && ./scripts/compare_screenshots.sh", diff --git a/src/lib/converter/comments/discovery.ts b/src/lib/converter/comments/discovery.ts index 81b9f826f..86ed6f234 100644 --- a/src/lib/converter/comments/discovery.ts +++ b/src/lib/converter/comments/discovery.ts @@ -65,6 +65,10 @@ const wantedKinds: Record = { [ReflectionKind.Class]: [ ts.SyntaxKind.ClassDeclaration, ts.SyntaxKind.BindingElement, + // If marked with @class + ts.SyntaxKind.VariableDeclaration, + ts.SyntaxKind.ExportAssignment, + ts.SyntaxKind.FunctionDeclaration, ], [ReflectionKind.Interface]: [ ts.SyntaxKind.InterfaceDeclaration, diff --git a/src/lib/converter/factories/signature.ts b/src/lib/converter/factories/signature.ts index ab1578d24..97490fa1c 100644 --- a/src/lib/converter/factories/signature.ts +++ b/src/lib/converter/factories/signature.ts @@ -6,6 +6,7 @@ import { IntrinsicType, ParameterReflection, PredicateType, + ReferenceType, Reflection, ReflectionFlag, ReflectionKind, @@ -43,6 +44,11 @@ export function createSignature( kind, context.scope, ); + // This feels awful, but we need some way to tell if callable signatures on classes + // are "static" (e.g. `Foo()`) or not (e.g. `(new Foo())()`) + if (context.shouldBeStatic) { + sigRef.setFlag(ReflectionFlag.Static); + } const sigRefCtx = context.withScope(sigRef); if (symbol && declaration) { context.project.registerSymbolId( @@ -132,6 +138,57 @@ export function createSignature( ); } +/** + * Special cased constructor factory for functions tagged with `@class` + */ +export function createConstructSignatureWithType( + context: Context, + signature: ts.Signature, + classType: Reflection, +) { + assert(context.scope instanceof DeclarationReflection); + + const declaration = signature.getDeclaration() as + | ts.SignatureDeclaration + | undefined; + + const sigRef = new SignatureReflection( + `new ${context.scope.parent!.name}`, + ReflectionKind.ConstructorSignature, + context.scope, + ); + const sigRefCtx = context.withScope(sigRef); + + if (declaration) { + sigRef.comment = context.getSignatureComment(declaration); + } + + sigRef.typeParameters = convertTypeParameters( + sigRefCtx, + sigRef, + signature.typeParameters, + ); + + sigRef.type = ReferenceType.createResolvedReference( + context.scope.parent!.name, + classType, + context.project, + ); + + context.registerReflection(sigRef, undefined); + + context.scope.signatures ??= []; + context.scope.signatures.push(sigRef); + + context.converter.trigger( + ConverterEvents.CREATE_SIGNATURE, + context, + sigRef, + declaration, + signature, + ); +} + function convertParameters( context: Context, sigRef: SignatureReflection, diff --git a/src/lib/converter/symbols.ts b/src/lib/converter/symbols.ts index 8a94359cd..d94e586a1 100644 --- a/src/lib/converter/symbols.ts +++ b/src/lib/converter/symbols.ts @@ -20,6 +20,7 @@ import type { Context } from "./context"; import { convertDefaultValue } from "./convert-expression"; import { convertIndexSignature } from "./factories/index-signature"; import { + createConstructSignatureWithType, createSignature, createTypeParamReflection, } from "./factories/signature"; @@ -72,9 +73,9 @@ const conversionOrder = [ ts.SymbolFlags.BlockScopedVariable, ts.SymbolFlags.FunctionScopedVariable, ts.SymbolFlags.ExportValue, + ts.SymbolFlags.Function, // Before NamespaceModule ts.SymbolFlags.TypeAlias, - ts.SymbolFlags.Function, // Before NamespaceModule ts.SymbolFlags.Method, ts.SymbolFlags.Interface, ts.SymbolFlags.Property, @@ -427,6 +428,13 @@ function convertFunctionOrMethod( (ts.SymbolFlags.Property | ts.SymbolFlags.Method) ); + if (!isMethod) { + const comment = context.getComment(symbol, ReflectionKind.Function); + if (comment?.hasModifier("@class")) { + return convertSymbolAsClass(context, symbol, exportSymbol); + } + } + const declarations = symbol.getDeclarations()?.filter(ts.isFunctionLike) ?? []; @@ -892,7 +900,14 @@ function convertVariable( return convertVariableAsNamespace(context, symbol, exportSymbol); } - if (type.getCallSignatures().length) { + if (comment?.hasModifier("@class")) { + return convertSymbolAsClass(context, symbol, exportSymbol); + } + + if ( + type.getCallSignatures().length && + !type.getConstructSignatures().length + ) { return convertVariableAsFunction(context, symbol, exportSymbol); } @@ -1075,6 +1090,88 @@ function convertFunctionProperties( return ts.SymbolFlags.None; } +function convertSymbolAsClass( + context: Context, + symbol: ts.Symbol, + exportSymbol?: ts.Symbol, +) { + const reflection = context.createDeclarationReflection( + ReflectionKind.Class, + symbol, + exportSymbol, + ); + const rc = context.withScope(reflection); + + context.finalizeDeclarationReflection(reflection); + + if (!symbol.valueDeclaration) { + context.logger.error( + `No value declaration found when converting ${symbol.name} as a class`, + symbol.declarations?.[0], + ); + return; + } + + const type = context.checker.getTypeOfSymbolAtLocation( + symbol, + symbol.valueDeclaration, + ); + + rc.shouldBeStatic = true; + convertSymbols( + rc, + // Prototype is implicitly this class, don't document it. + type.getProperties().filter((prop) => prop.name !== "prototype"), + ); + + for (const sig of type.getCallSignatures()) { + createSignature(rc, ReflectionKind.CallSignature, sig, undefined); + } + + rc.shouldBeStatic = false; + + const ctors = type.getConstructSignatures(); + if (ctors.length) { + const constructMember = rc.createDeclarationReflection( + ReflectionKind.Constructor, + ctors?.[0]?.declaration?.symbol, + void 0, + "constructor", + ); + + // Modifiers are the same for all constructors + if (ctors.length && ctors[0].declaration) { + setModifiers(symbol, ctors[0].declaration, constructMember); + } + + context.finalizeDeclarationReflection(constructMember); + + const constructContext = rc.withScope(constructMember); + + for (const sig of ctors) { + createConstructSignatureWithType(constructContext, sig, reflection); + } + + const instType = ctors[0].getReturnType(); + convertSymbols(rc, instType.getProperties()); + + for (const sig of instType.getCallSignatures()) { + createSignature(rc, ReflectionKind.CallSignature, sig, undefined); + } + } else { + context.logger.warn( + `${reflection.getFriendlyFullName()} is being converted as a class, but does not have any construct signatures`, + symbol.valueDeclaration, + ); + } + + return ( + ts.SymbolFlags.TypeAlias | + ts.SymbolFlags.Interface | + ts.SymbolFlags.Namespace + ); +} + function convertAccessor( context: Context, symbol: ts.Symbol, diff --git a/src/lib/utils/options/tsdoc-defaults.ts b/src/lib/utils/options/tsdoc-defaults.ts index 2f4e64184..c562cf438 100644 --- a/src/lib/utils/options/tsdoc-defaults.ts +++ b/src/lib/utils/options/tsdoc-defaults.ts @@ -51,6 +51,7 @@ export const modifierTags = [ ...tsdocModifierTags, "@hidden", "@ignore", + "@class", "@enum", "@event", "@overload", diff --git a/src/test/behavior.c2.test.ts b/src/test/behavior.c2.test.ts index 8e81a6237..c72cd45a8 100644 --- a/src/test/behavior.c2.test.ts +++ b/src/test/behavior.c2.test.ts @@ -19,7 +19,7 @@ import { import { join } from "path"; import { existsSync } from "fs"; import { clearCommentCache } from "../lib/converter/comments"; -import { query, querySig } from "./utils"; +import { getComment, query, querySig } from "./utils"; type NameTree = { [name: string]: NameTree }; @@ -106,6 +106,7 @@ describe("Behavior Tests", () => { afterEach(() => { app.options.restore(optionsSnap); + logger.expectNoOtherMessages(); }); it("Handles 'as const' style enums", () => { @@ -221,6 +222,63 @@ describe("Behavior Tests", () => { ); }); + it("Should allow the user to mark a variable or function as a class with @class", () => { + const project = convert("classTag"); + logger.expectMessage( + `warn: BadClass is being converted as a class, but does not have any construct signatures`, + ); + + const CallableClass = query(project, "CallableClass"); + equal(CallableClass.signatures?.length, 2); + equal( + CallableClass.signatures.map((sig) => sig.type?.toString()), + ["number", "string"], + ); + equal( + CallableClass.signatures.map((sig) => sig.flags.isStatic), + [true, false], + ); + equal( + CallableClass.children?.map((child) => [ + child.name, + ReflectionKind.singularString(child.kind), + ]), + [ + [ + "constructor", + ReflectionKind.singularString(ReflectionKind.Constructor), + ], + [ + "inst", + ReflectionKind.singularString(ReflectionKind.Property), + ], + [ + "stat", + ReflectionKind.singularString(ReflectionKind.Property), + ], + [ + "method", + ReflectionKind.singularString(ReflectionKind.Method), + ], + ], + ); + + equal(query(project, "CallableClass.stat").flags.isStatic, true); + + equal( + ["VariableClass", "VariableClass.stat", "VariableClass.inst"].map( + (name) => getComment(project, name), + ), + ["Variable class", "Stat docs", "Inst docs"], + ); + + equal(project.children?.map((c) => c.name), [ + "BadClass", + "CallableClass", + "VariableClass", + ]); + }); + it("Handles const type parameters", () => { const project = convert("constTypeParam"); const getNamesExactly = query(project, "getNamesExactly"); @@ -831,6 +889,9 @@ describe("Behavior Tests", () => { logger.expectMessage( "warn: MultiCommentMultiDeclaration has multiple declarations with a comment. An arbitrary comment will be used.", ); + logger.expectMessage( + "info: The comments for MultiCommentMultiDeclaration are declared at*", + ); }); it("Handles named tuple declarations", () => { diff --git a/src/test/converter/comment/comment.ts b/src/test/converter/comment/comment.ts index 2281daf3f..c6a3d2d9a 100644 --- a/src/test/converter/comment/comment.ts +++ b/src/test/converter/comment/comment.ts @@ -27,7 +27,6 @@ import "./comment2"; * @deprecated * @todo something * - * @class will be removed * @type {Data} will also be removed */ export class CommentedClass { diff --git a/src/test/converter/comment/specs.json b/src/test/converter/comment/specs.json index 6eacf6d2f..67458dcbd 100644 --- a/src/test/converter/comment/specs.json +++ b/src/test/converter/comment/specs.json @@ -85,9 +85,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 37, + "line": 36, "character": 4, - "url": "typedoc://comment.ts#L37" + "url": "typedoc://comment.ts#L36" } ], "type": { @@ -104,9 +104,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 77, + "line": 76, "character": 4, - "url": "typedoc://comment.ts#L77" + "url": "typedoc://comment.ts#L76" } ], "signatures": [ @@ -127,9 +127,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 77, + "line": 76, "character": 4, - "url": "typedoc://comment.ts#L77" + "url": "typedoc://comment.ts#L76" } ], "parameters": [ @@ -187,9 +187,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 33, + "line": 32, "character": 13, - "url": "typedoc://comment.ts#L33" + "url": "typedoc://comment.ts#L32" } ] } diff --git a/src/test/converter2/behavior/classTag.ts b/src/test/converter2/behavior/classTag.ts new file mode 100644 index 000000000..a964db8e5 --- /dev/null +++ b/src/test/converter2/behavior/classTag.ts @@ -0,0 +1,40 @@ +/** + * Variable class + * @class + */ +export const VariableClass = class { + /** Stat docs */ + static stat = 1; + /** Inst docs */ + inst = "2"; +}; + +/** + * Normal classes can't have call signatures, but this does. + * @class + */ +export declare const CallableClass: { + /** Stat docs */ + stat: string; + /** Static signature */ + (): number; +} & { + /** Ctor docs */ + new (): { + /** Call docs */ + (): string; + + /** Inst docs */ + inst: string; + /** Method docs */ + method(): string; + }; +}; + +/** + * Will not be converted because the class is declared with `@class` + */ +export type CallableClass = any; + +/** @class */ +export const BadClass = 123; diff --git a/src/test/converter2/typedoc.json b/src/test/converter2/typedoc.json new file mode 100644 index 000000000..8e5327731 --- /dev/null +++ b/src/test/converter2/typedoc.json @@ -0,0 +1,4 @@ +// This is here so we can point to it with doc:c2 and avoid warnings caused by TypeDoc's actual configuration. +{ + "logLevel": "Verbose" +} diff --git a/tsdoc.json b/tsdoc.json index 0b07b7384..b9c6d049e 100644 --- a/tsdoc.json +++ b/tsdoc.json @@ -44,6 +44,10 @@ "tagName": "@ignore", "syntaxKind": "modifier" }, + { + "tagName": "@class", + "syntaxKind": "modifier" + }, { "tagName": "@enum", "syntaxKind": "modifier"