Skip to content

Commit 864db57

Browse files
committed
Add jsDocCompatibility option
Resolves #2219
1 parent 14c325b commit 864db57

File tree

11 files changed

+159
-18
lines changed

11 files changed

+159
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
### Features
4343

4444
- Added `--useTsLinkResolution` option (on by default) which tells TypeDoc to use TypeScript's `@link` resolution.
45+
- Added `--jsDocCompatibility` option (on by default) which controls TypeDoc's automatic detection of code blocks in `@example` and `@default` tags.
4546
- Reworked default theme navigation to add support for a page table of contents, #1478, #2189.
4647
- Added support for `@interface` on type aliases to tell TypeDoc to convert the fully resolved type as an interface, #1519
4748
- Added support for `@namespace` on variable declarations to tell TypeDoc to convert the variable as a namespace, #2055.

src/lib/converter/comments/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import ts from "typescript";
22
import { Comment, ReflectionKind } from "../../models";
33
import { assertNever, Logger } from "../../utils";
4-
import type { CommentStyle } from "../../utils/options/declaration";
4+
import type {
5+
CommentStyle,
6+
JsDocCompatibility,
7+
} from "../../utils/options/declaration";
58
import { lexBlockComment } from "./blockLexer";
69
import {
710
DiscoveredComment,
@@ -15,6 +18,7 @@ export interface CommentParserConfig {
1518
blockTags: Set<string>;
1619
inlineTags: Set<string>;
1720
modifierTags: Set<string>;
21+
jsDocCompatibility: JsDocCompatibility;
1822
}
1923

2024
const jsDocCommentKinds = [

src/lib/converter/comments/parser.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,57 @@ function blockTag(
205205
const tagName = aliasedTags.get(blockTag.text) || blockTag.text;
206206

207207
let content: CommentDisplayPart[];
208-
if (tagName === "@example") {
208+
if (tagName === "@example" && config.jsDocCompatibility.exampleTag) {
209209
content = exampleBlockContent(comment, lexer, config, warning);
210+
} else if (tagName === "@default" && config.jsDocCompatibility.defaultTag) {
211+
content = defaultBlockContent(comment, lexer, config, warning);
210212
} else {
211213
content = blockContent(comment, lexer, config, warning);
212214
}
213215

214216
return new CommentTag(tagName as `@${string}`, content);
215217
}
216218

219+
/**
220+
* The `@default` tag gets a special case because otherwise we will produce many warnings
221+
* about unescaped/mismatched/missing braces in legacy JSDoc comments
222+
*/
223+
function defaultBlockContent(
224+
comment: Comment,
225+
lexer: LookaheadGenerator<Token>,
226+
config: CommentParserConfig,
227+
warning: (msg: string, token: Token) => void
228+
): CommentDisplayPart[] {
229+
lexer.mark();
230+
const content = blockContent(comment, lexer, config, () => {});
231+
const end = lexer.done() || lexer.peek();
232+
lexer.release();
233+
234+
if (content.some((part) => part.kind === "code")) {
235+
return blockContent(comment, lexer, config, warning);
236+
}
237+
238+
const tokens: Token[] = [];
239+
while ((lexer.done() || lexer.peek()) !== end) {
240+
tokens.push(lexer.take());
241+
}
242+
243+
const blockText = tokens
244+
.map((tok) => tok.text)
245+
.join("")
246+
.trim();
247+
248+
return [
249+
{
250+
kind: "code",
251+
text: makeCodeBlock(blockText),
252+
},
253+
];
254+
}
255+
217256
/**
218257
* The `@example` tag gets a special case because otherwise we will produce many warnings
219-
* about unescaped/mismatched/missing braces
258+
* about unescaped/mismatched/missing braces in legacy JSDoc comments.
220259
*/
221260
function exampleBlockContent(
222261
comment: Comment,

src/lib/converter/converter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export class Converter extends ChildableComponent<
229229
this.resolve(context);
230230

231231
this.trigger(Converter.EVENT_END, context);
232+
this._config = undefined;
232233

233234
return project;
234235
}
@@ -493,6 +494,8 @@ export class Converter extends ChildableComponent<
493494
modifierTags: new Set(
494495
this.application.options.getValue("modifierTags")
495496
),
497+
jsDocCompatibility:
498+
this.application.options.getValue("jsDocCompatibility"),
496499
};
497500
return this._config;
498501
}

src/lib/utils/options/declaration.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export interface TypeDocOptionMap {
140140
navigationLinks: ManuallyValidatedOption<Record<string, string>>;
141141
sidebarLinks: ManuallyValidatedOption<Record<string, string>>;
142142

143+
jsDocCompatibility: JsDocCompatibility;
143144
commentStyle: typeof CommentStyle;
144145
useTsLinkResolution: boolean;
145146
blockTags: `@${string}`[];
@@ -200,6 +201,19 @@ export type ValidationOptions = {
200201
notDocumented: boolean;
201202
};
202203

204+
export type JsDocCompatibility = {
205+
/**
206+
* If set, TypeDoc will treat `@example` blocks as code unless they contain a code block.
207+
* On by default, this is how VSCode renders blocks.
208+
*/
209+
exampleTag: boolean;
210+
/**
211+
* If set, TypeDoc will treat `@default` blocks as code unless they contain a code block.
212+
* On by default, this is how VSCode renders blocks.
213+
*/
214+
defaultTag: boolean;
215+
};
216+
203217
/**
204218
* Converts a given TypeDoc option key to the type of the declaration expected.
205219
*/

src/lib/utils/options/options.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export interface OptionsReader {
6060
const optionSnapshots = new WeakMap<
6161
{ __optionSnapshot: never },
6262
{
63-
values: Record<string, unknown>;
63+
values: string;
6464
set: Set<string>;
6565
}
6666
>();
@@ -136,7 +136,7 @@ export class Options {
136136
const key = {} as { __optionSnapshot: never };
137137

138138
optionSnapshots.set(key, {
139-
values: { ...this._values },
139+
values: JSON.stringify(this._values),
140140
set: new Set(this._setOptions),
141141
});
142142

@@ -149,7 +149,7 @@ export class Options {
149149
*/
150150
restore(snapshot: { __optionSnapshot: never }) {
151151
const data = optionSnapshots.get(snapshot)!;
152-
this._values = { ...data.values };
152+
this._values = JSON.parse(data.values);
153153
this._setOptions = new Set(data.set);
154154
}
155155

src/lib/utils/options/sources/typedoc.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,16 @@ export function addTypeDocOptions(options: Pick<Options, "addDeclaration">) {
431431
///// Comment Options /////
432432
///////////////////////////
433433

434+
options.addDeclaration({
435+
name: "jsDocCompatibility",
436+
help: "Sets compatibility options for comment parsing that increase similarity with JSDoc comments.",
437+
type: ParameterType.Flags,
438+
defaults: {
439+
defaultTag: true,
440+
exampleTag: true,
441+
},
442+
});
443+
434444
options.addDeclaration({
435445
name: "commentStyle",
436446
help: "Determines how TypeDoc searches for comments.",

src/test/behavior.c2.test.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const base = getConverter2Base();
7676
const app = getConverter2App();
7777
const program = getConverter2Program();
7878

79-
function doConvert(entry: string) {
79+
function convert(entry: string) {
8080
const entryPoint = [
8181
join(base, `behavior/${entry}.ts`),
8282
join(base, `behavior/${entry}.d.ts`),
@@ -91,6 +91,7 @@ function doConvert(entry: string) {
9191
ok(sourceFile, `No source file found for ${entryPoint}`);
9292

9393
app.options.setValue("entryPoints", [entryPoint]);
94+
clearCommentCache();
9495
return app.converter.convert([
9596
{
9697
displayName: entry,
@@ -102,14 +103,11 @@ function doConvert(entry: string) {
102103

103104
describe("Behavior Tests", () => {
104105
let logger: TestLogger;
105-
let convert: (name: string) => ProjectReflection;
106106
let optionsSnap: { __optionSnapshot: never };
107107

108108
beforeEach(() => {
109109
app.logger = logger = new TestLogger();
110110
optionsSnap = app.options.snapshot();
111-
clearCommentCache();
112-
convert = (name) => doConvert(name);
113111
});
114112

115113
afterEach(() => {
@@ -261,7 +259,35 @@ describe("Behavior Tests", () => {
261259
]);
262260
});
263261

264-
it("Handles example tags", () => {
262+
it("Handles @default tags with JSDoc compat turned on", () => {
263+
const project = convert("defaultTag");
264+
const foo = query(project, "foo");
265+
const tags = foo.comment?.blockTags.map((tag) => tag.content);
266+
267+
equal(tags, [
268+
[{ kind: "code", text: "```ts\n\n```" }],
269+
[{ kind: "code", text: "```ts\nfn({})\n```" }],
270+
]);
271+
272+
logger.expectNoOtherMessages();
273+
});
274+
275+
it("Handles @default tags with JSDoc compat turned off", () => {
276+
app.options.setValue("jsDocCompatibility", false);
277+
const project = convert("defaultTag");
278+
const foo = query(project, "foo");
279+
const tags = foo.comment?.blockTags.map((tag) => tag.content);
280+
281+
equal(tags, [[], [{ kind: "text", text: "fn({})" }]]);
282+
283+
logger.expectMessage(
284+
"warn: Encountered an unescaped open brace without an inline tag"
285+
);
286+
logger.expectMessage("warn: Unmatched closing brace");
287+
logger.expectNoOtherMessages();
288+
});
289+
290+
it("Handles @example tags with JSDoc compat turned on", () => {
265291
const project = convert("exampleTags");
266292
const foo = query(project, "foo");
267293
const tags = foo.comment?.blockTags.map((tag) => tag.content);
@@ -288,6 +314,36 @@ describe("Behavior Tests", () => {
288314
logger.expectNoOtherMessages();
289315
});
290316

317+
it("Warns about example tags containing braces when compat options are off", () => {
318+
app.options.setValue("jsDocCompatibility", false);
319+
const project = convert("exampleTags");
320+
const foo = query(project, "foo");
321+
const tags = foo.comment?.blockTags.map((tag) => tag.content);
322+
323+
equal(tags, [
324+
[{ kind: "text", text: "// JSDoc style\ncodeHere();" }],
325+
[
326+
{
327+
kind: "text",
328+
text: "<caption>JSDoc specialness</caption>\n// JSDoc style\ncodeHere();",
329+
},
330+
],
331+
[
332+
{
333+
kind: "text",
334+
text: "<caption>JSDoc with braces</caption>\nx.map(() => { return 1; })",
335+
},
336+
],
337+
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
338+
]);
339+
340+
logger.expectMessage(
341+
"warn: Encountered an unescaped open brace without an inline tag"
342+
);
343+
logger.expectMessage("warn: Unmatched closing brace");
344+
logger.expectNoOtherMessages();
345+
});
346+
291347
it("Handles excludeNotDocumentedKinds", () => {
292348
app.options.setValue("excludeNotDocumented", true);
293349
app.options.setValue("excludeNotDocumentedKinds", ["Property"]);

src/test/comments.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,7 @@ describe("Comment Parser", () => {
12121212
"@event",
12131213
"@packageDocumentation",
12141214
]),
1215+
jsDocCompatibility: { defaultTag: true, exampleTag: true },
12151216
};
12161217

12171218
it("Should rewrite @inheritdoc to @inheritDoc", () => {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* @default
3+
* @default fn({})
4+
*/
5+
export const foo = 1;

src/test/issues.c2.test.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ function doConvert(entry: string) {
4747
ok(sourceFile, `No source file found for ${entryPoint}`);
4848

4949
app.options.setValue("entryPoints", [entryPoint]);
50+
clearCommentCache();
5051
return app.converter.convert([
5152
{
5253
displayName: entry,
@@ -70,7 +71,6 @@ describe("Issue Tests", () => {
7071
beforeEach(function () {
7172
app.logger = logger = new TestLogger();
7273
optionsSnap = app.options.snapshot();
73-
clearCommentCache();
7474
const issueNumber = this.currentTest?.title.match(/#(\d+)/)?.[1];
7575
ok(issueNumber, "Test name must contain an issue number.");
7676
convert = (name = `gh${issueNumber}`) => doConvert(name);
@@ -678,12 +678,20 @@ describe("Issue Tests", () => {
678678

679679
it("#1967", () => {
680680
const project = convert();
681-
equal(query(project, "abc").comment?.getTag("@example")?.content, [
682-
{
683-
kind: "code",
684-
text: "```ts\n\n```",
685-
},
686-
]);
681+
equal(
682+
query(project, "abc").comment,
683+
new Comment(
684+
[],
685+
[
686+
new CommentTag("@example", [
687+
{
688+
kind: "code",
689+
text: "```ts\n\n```",
690+
},
691+
]),
692+
]
693+
)
694+
);
687695
});
688696

689697
it("#1968", () => {

0 commit comments

Comments
 (0)