diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b39ad861..d4b4e0dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ ### Features - Added `--useTsLinkResolution` option (on by default) which tells TypeDoc to use TypeScript's `@link` resolution. +- Added `--jsDocCompatibility` option (on by default) which controls TypeDoc's automatic detection of code blocks in `@example` and `@default` tags. - Reworked default theme navigation to add support for a page table of contents, #1478, #2189. - Added support for `@interface` on type aliases to tell TypeDoc to convert the fully resolved type as an interface, #1519 - Added support for `@namespace` on variable declarations to tell TypeDoc to convert the variable as a namespace, #2055. diff --git a/src/lib/converter/comments/index.ts b/src/lib/converter/comments/index.ts index 341653b9e..5d8e3d518 100644 --- a/src/lib/converter/comments/index.ts +++ b/src/lib/converter/comments/index.ts @@ -1,7 +1,10 @@ import ts from "typescript"; import { Comment, ReflectionKind } from "../../models"; import { assertNever, Logger } from "../../utils"; -import type { CommentStyle } from "../../utils/options/declaration"; +import type { + CommentStyle, + JsDocCompatibility, +} from "../../utils/options/declaration"; import { lexBlockComment } from "./blockLexer"; import { DiscoveredComment, @@ -15,6 +18,7 @@ export interface CommentParserConfig { blockTags: Set; inlineTags: Set; modifierTags: Set; + jsDocCompatibility: JsDocCompatibility; } const jsDocCommentKinds = [ diff --git a/src/lib/converter/comments/parser.ts b/src/lib/converter/comments/parser.ts index 940704582..a29d98f5b 100644 --- a/src/lib/converter/comments/parser.ts +++ b/src/lib/converter/comments/parser.ts @@ -205,8 +205,10 @@ function blockTag( const tagName = aliasedTags.get(blockTag.text) || blockTag.text; let content: CommentDisplayPart[]; - if (tagName === "@example") { + if (tagName === "@example" && config.jsDocCompatibility.exampleTag) { content = exampleBlockContent(comment, lexer, config, warning); + } else if (tagName === "@default" && config.jsDocCompatibility.defaultTag) { + content = defaultBlockContent(comment, lexer, config, warning); } else { content = blockContent(comment, lexer, config, warning); } @@ -214,9 +216,46 @@ function blockTag( return new CommentTag(tagName as `@${string}`, content); } +/** + * The `@default` tag gets a special case because otherwise we will produce many warnings + * about unescaped/mismatched/missing braces in legacy JSDoc comments + */ +function defaultBlockContent( + comment: Comment, + lexer: LookaheadGenerator, + config: CommentParserConfig, + warning: (msg: string, token: Token) => void +): CommentDisplayPart[] { + lexer.mark(); + const content = blockContent(comment, lexer, config, () => {}); + const end = lexer.done() || lexer.peek(); + lexer.release(); + + if (content.some((part) => part.kind === "code")) { + return blockContent(comment, lexer, config, warning); + } + + const tokens: Token[] = []; + while ((lexer.done() || lexer.peek()) !== end) { + tokens.push(lexer.take()); + } + + const blockText = tokens + .map((tok) => tok.text) + .join("") + .trim(); + + return [ + { + kind: "code", + text: makeCodeBlock(blockText), + }, + ]; +} + /** * The `@example` tag gets a special case because otherwise we will produce many warnings - * about unescaped/mismatched/missing braces + * about unescaped/mismatched/missing braces in legacy JSDoc comments. */ function exampleBlockContent( comment: Comment, diff --git a/src/lib/converter/converter.ts b/src/lib/converter/converter.ts index 1cf8316a5..8a4dab1ad 100644 --- a/src/lib/converter/converter.ts +++ b/src/lib/converter/converter.ts @@ -229,6 +229,7 @@ export class Converter extends ChildableComponent< this.resolve(context); this.trigger(Converter.EVENT_END, context); + this._config = undefined; return project; } @@ -493,6 +494,8 @@ export class Converter extends ChildableComponent< modifierTags: new Set( this.application.options.getValue("modifierTags") ), + jsDocCompatibility: + this.application.options.getValue("jsDocCompatibility"), }; return this._config; } diff --git a/src/lib/utils/options/declaration.ts b/src/lib/utils/options/declaration.ts index 26318491b..2f594fa10 100644 --- a/src/lib/utils/options/declaration.ts +++ b/src/lib/utils/options/declaration.ts @@ -140,6 +140,7 @@ export interface TypeDocOptionMap { navigationLinks: ManuallyValidatedOption>; sidebarLinks: ManuallyValidatedOption>; + jsDocCompatibility: JsDocCompatibility; commentStyle: typeof CommentStyle; useTsLinkResolution: boolean; blockTags: `@${string}`[]; @@ -200,6 +201,19 @@ export type ValidationOptions = { notDocumented: boolean; }; +export type JsDocCompatibility = { + /** + * If set, TypeDoc will treat `@example` blocks as code unless they contain a code block. + * On by default, this is how VSCode renders blocks. + */ + exampleTag: boolean; + /** + * If set, TypeDoc will treat `@default` blocks as code unless they contain a code block. + * On by default, this is how VSCode renders blocks. + */ + defaultTag: boolean; +}; + /** * Converts a given TypeDoc option key to the type of the declaration expected. */ diff --git a/src/lib/utils/options/options.ts b/src/lib/utils/options/options.ts index 5c8bc9438..c89edb73e 100644 --- a/src/lib/utils/options/options.ts +++ b/src/lib/utils/options/options.ts @@ -60,7 +60,7 @@ export interface OptionsReader { const optionSnapshots = new WeakMap< { __optionSnapshot: never }, { - values: Record; + values: string; set: Set; } >(); @@ -136,7 +136,7 @@ export class Options { const key = {} as { __optionSnapshot: never }; optionSnapshots.set(key, { - values: { ...this._values }, + values: JSON.stringify(this._values), set: new Set(this._setOptions), }); @@ -149,7 +149,7 @@ export class Options { */ restore(snapshot: { __optionSnapshot: never }) { const data = optionSnapshots.get(snapshot)!; - this._values = { ...data.values }; + this._values = JSON.parse(data.values); this._setOptions = new Set(data.set); } diff --git a/src/lib/utils/options/sources/typedoc.ts b/src/lib/utils/options/sources/typedoc.ts index a5d171af5..e024dbff1 100644 --- a/src/lib/utils/options/sources/typedoc.ts +++ b/src/lib/utils/options/sources/typedoc.ts @@ -431,6 +431,16 @@ export function addTypeDocOptions(options: Pick) { ///// Comment Options ///// /////////////////////////// + options.addDeclaration({ + name: "jsDocCompatibility", + help: "Sets compatibility options for comment parsing that increase similarity with JSDoc comments.", + type: ParameterType.Flags, + defaults: { + defaultTag: true, + exampleTag: true, + }, + }); + options.addDeclaration({ name: "commentStyle", help: "Determines how TypeDoc searches for comments.", diff --git a/src/test/behavior.c2.test.ts b/src/test/behavior.c2.test.ts index f8e41de5b..910b827ae 100644 --- a/src/test/behavior.c2.test.ts +++ b/src/test/behavior.c2.test.ts @@ -76,7 +76,7 @@ const base = getConverter2Base(); const app = getConverter2App(); const program = getConverter2Program(); -function doConvert(entry: string) { +function convert(entry: string) { const entryPoint = [ join(base, `behavior/${entry}.ts`), join(base, `behavior/${entry}.d.ts`), @@ -91,6 +91,7 @@ function doConvert(entry: string) { ok(sourceFile, `No source file found for ${entryPoint}`); app.options.setValue("entryPoints", [entryPoint]); + clearCommentCache(); return app.converter.convert([ { displayName: entry, @@ -102,14 +103,11 @@ function doConvert(entry: string) { describe("Behavior Tests", () => { let logger: TestLogger; - let convert: (name: string) => ProjectReflection; let optionsSnap: { __optionSnapshot: never }; beforeEach(() => { app.logger = logger = new TestLogger(); optionsSnap = app.options.snapshot(); - clearCommentCache(); - convert = (name) => doConvert(name); }); afterEach(() => { @@ -261,7 +259,35 @@ describe("Behavior Tests", () => { ]); }); - it("Handles example tags", () => { + it("Handles @default tags with JSDoc compat turned on", () => { + const project = convert("defaultTag"); + const foo = query(project, "foo"); + const tags = foo.comment?.blockTags.map((tag) => tag.content); + + equal(tags, [ + [{ kind: "code", text: "```ts\n\n```" }], + [{ kind: "code", text: "```ts\nfn({})\n```" }], + ]); + + logger.expectNoOtherMessages(); + }); + + it("Handles @default tags with JSDoc compat turned off", () => { + app.options.setValue("jsDocCompatibility", false); + const project = convert("defaultTag"); + const foo = query(project, "foo"); + const tags = foo.comment?.blockTags.map((tag) => tag.content); + + equal(tags, [[], [{ kind: "text", text: "fn({})" }]]); + + logger.expectMessage( + "warn: Encountered an unescaped open brace without an inline tag" + ); + logger.expectMessage("warn: Unmatched closing brace"); + logger.expectNoOtherMessages(); + }); + + it("Handles @example tags with JSDoc compat turned on", () => { const project = convert("exampleTags"); const foo = query(project, "foo"); const tags = foo.comment?.blockTags.map((tag) => tag.content); @@ -288,6 +314,36 @@ describe("Behavior Tests", () => { logger.expectNoOtherMessages(); }); + it("Warns about example tags containing braces when compat options are off", () => { + app.options.setValue("jsDocCompatibility", false); + const project = convert("exampleTags"); + const foo = query(project, "foo"); + const tags = foo.comment?.blockTags.map((tag) => tag.content); + + equal(tags, [ + [{ kind: "text", text: "// JSDoc style\ncodeHere();" }], + [ + { + kind: "text", + text: "JSDoc specialness\n// JSDoc style\ncodeHere();", + }, + ], + [ + { + kind: "text", + text: "JSDoc with braces\nx.map(() => { return 1; })", + }, + ], + [{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }], + ]); + + logger.expectMessage( + "warn: Encountered an unescaped open brace without an inline tag" + ); + logger.expectMessage("warn: Unmatched closing brace"); + logger.expectNoOtherMessages(); + }); + it("Handles excludeNotDocumentedKinds", () => { app.options.setValue("excludeNotDocumented", true); app.options.setValue("excludeNotDocumentedKinds", ["Property"]); diff --git a/src/test/comments.test.ts b/src/test/comments.test.ts index 230a63d49..e5fd117eb 100644 --- a/src/test/comments.test.ts +++ b/src/test/comments.test.ts @@ -1212,6 +1212,7 @@ describe("Comment Parser", () => { "@event", "@packageDocumentation", ]), + jsDocCompatibility: { defaultTag: true, exampleTag: true }, }; it("Should rewrite @inheritdoc to @inheritDoc", () => { diff --git a/src/test/converter2/behavior/defaultTag.ts b/src/test/converter2/behavior/defaultTag.ts new file mode 100644 index 000000000..32f0ecd8c --- /dev/null +++ b/src/test/converter2/behavior/defaultTag.ts @@ -0,0 +1,5 @@ +/** + * @default + * @default fn({}) + */ +export const foo = 1; diff --git a/src/test/issues.c2.test.ts b/src/test/issues.c2.test.ts index 4c3cc5dd9..395b2bb7f 100644 --- a/src/test/issues.c2.test.ts +++ b/src/test/issues.c2.test.ts @@ -47,6 +47,7 @@ function doConvert(entry: string) { ok(sourceFile, `No source file found for ${entryPoint}`); app.options.setValue("entryPoints", [entryPoint]); + clearCommentCache(); return app.converter.convert([ { displayName: entry, @@ -70,7 +71,6 @@ describe("Issue Tests", () => { beforeEach(function () { app.logger = logger = new TestLogger(); optionsSnap = app.options.snapshot(); - clearCommentCache(); const issueNumber = this.currentTest?.title.match(/#(\d+)/)?.[1]; ok(issueNumber, "Test name must contain an issue number."); convert = (name = `gh${issueNumber}`) => doConvert(name); @@ -678,12 +678,20 @@ describe("Issue Tests", () => { it("#1967", () => { const project = convert(); - equal(query(project, "abc").comment?.getTag("@example")?.content, [ - { - kind: "code", - text: "```ts\n\n```", - }, - ]); + equal( + query(project, "abc").comment, + new Comment( + [], + [ + new CommentTag("@example", [ + { + kind: "code", + text: "```ts\n\n```", + }, + ]), + ] + ) + ); }); it("#1968", () => {