diff --git a/README.md b/README.md index c187387a4..f011ca5a7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ By default, the command-line generator will use the `tsconfig.json` file in the -e, --expose Type exposing (choices: "all", "none", "export", default: "export") -j, --jsDoc Read JsDoc annotations (choices: "none", "basic", "extended", default: "extended") --markdown-description Generate `markdownDescription` in addition to `description`. + --raw-jsdoc Include the full raw JSDoc comment as `rawJsDoc` in the schema. --functions How to handle functions. `fail` will throw an error. `comment` will add a comment. `hide` will treat the function like a NeverType or HiddenType. (choices: "fail", "comment", "hide", default: "comment") --minify Minify generated schema (default: false) diff --git a/factory/parser.ts b/factory/parser.ts index 6b3ca867a..b7fc7d315 100644 --- a/factory/parser.ts +++ b/factory/parser.ts @@ -77,7 +77,7 @@ export function createParser(program: ts.Program, config: CompletedConfig, augme if (config.jsDoc === "extended") { return new AnnotatedNodeParser( nodeParser, - new ExtendedAnnotationsReader(typeChecker, extraTags, config.markdownDescription), + new ExtendedAnnotationsReader(typeChecker, extraTags, config.markdownDescription, config.rawJsDoc), ); } else if (config.jsDoc === "basic") { return new AnnotatedNodeParser(nodeParser, new BasicAnnotationsReader(extraTags)); diff --git a/src/AnnotationsReader/ExtendedAnnotationsReader.ts b/src/AnnotationsReader/ExtendedAnnotationsReader.ts index 9e2a292ff..763d3dd5b 100644 --- a/src/AnnotationsReader/ExtendedAnnotationsReader.ts +++ b/src/AnnotationsReader/ExtendedAnnotationsReader.ts @@ -2,6 +2,7 @@ import json5 from "json5"; import type ts from "typescript"; import type { Annotations } from "../Type/AnnotatedType.js"; import { symbolAtNode } from "../Utils/symbolAtNode.js"; +import { getRawJsDoc } from "../Utils/getRawJsDoc.js"; import { BasicAnnotationsReader } from "./BasicAnnotationsReader.js"; export class ExtendedAnnotationsReader extends BasicAnnotationsReader { @@ -9,6 +10,7 @@ export class ExtendedAnnotationsReader extends BasicAnnotationsReader { private typeChecker: ts.TypeChecker, extraTags?: Set, private markdownDescription?: boolean, + private rawJsDoc?: boolean, ) { super(extraTags); } @@ -44,21 +46,34 @@ export class ExtendedAnnotationsReader extends BasicAnnotationsReader { return undefined; } + const annotations: { description?: string; markdownDescription?: string; rawJsDoc?: string } = {}; + const comments: ts.SymbolDisplayPart[] = symbol.getDocumentationComment(this.typeChecker); - if (!comments || !comments.length) { - return undefined; - } - const markdownDescription = comments - .map((comment) => comment.text) - .join(" ") - .replace(/\r/g, "") - .trim(); + if (comments && comments.length) { + const markdownDescription = comments + .map((comment) => comment.text) + .join(" ") + .replace(/\r/g, "") + .trim(); - const description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim(); + annotations.description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim(); + + if (this.markdownDescription) { + annotations.markdownDescription = markdownDescription; + } + } - return this.markdownDescription ? { description, markdownDescription } : { description }; + if (this.rawJsDoc) { + const rawJsDoc = getRawJsDoc(node)?.trim(); + if (rawJsDoc) { + annotations.rawJsDoc = rawJsDoc; + } + } + + return Object.keys(annotations).length ? annotations : undefined; } + private getTypeAnnotation(node: ts.Node): Annotations | undefined { const symbol = symbolAtNode(node); if (!symbol) { diff --git a/src/Config.ts b/src/Config.ts index 24d126c42..f9d25253d 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -8,6 +8,7 @@ export interface Config { topRef?: boolean; jsDoc?: "none" | "extended" | "basic"; markdownDescription?: boolean; + rawJsDoc?: boolean; sortProps?: boolean; strictTuples?: boolean; skipTypeCheck?: boolean; @@ -27,6 +28,7 @@ export const DEFAULT_CONFIG: Omit, "path" | "type" | "schemaId" topRef: true, jsDoc: "extended", markdownDescription: false, + rawJsDoc: false, sortProps: true, strictTuples: false, skipTypeCheck: false, diff --git a/src/Utils/getRawJsDoc.ts b/src/Utils/getRawJsDoc.ts new file mode 100644 index 000000000..82e019a00 --- /dev/null +++ b/src/Utils/getRawJsDoc.ts @@ -0,0 +1,39 @@ +import ts from "typescript"; + +export function getRawJsDoc(node: ts.Node): string | undefined { + const sourceFile = node.getSourceFile(); + const jsDocNodes = ts.getJSDocCommentsAndTags(node); + + if (!jsDocNodes || jsDocNodes.length === 0) { + return undefined; + } + + let rawText = ""; + + for (const jsDoc of jsDocNodes) { + rawText += jsDoc.getFullText(sourceFile) + "\n"; + } + + rawText = rawText.trim(); + + return getTextWithoutStars(rawText).trim(); +} + +function getTextWithoutStars(inputText: string) { + const innerTextWithStars = inputText.replace(/^\/\*\*[^\S\n]*\n?/, "").replace(/(\r?\n)?[^\S\n]*\*\/$/, ""); + + return innerTextWithStars + .split(/\n/) + .map((line) => { + const trimmedLine = line.trimStart(); + + if (trimmedLine[0] !== "*") { + return line; + } + + const textStartPos = trimmedLine[1] === " " ? 2 : 1; + + return trimmedLine.substring(textStartPos); + }) + .join("\n"); +} diff --git a/test/config.test.ts b/test/config.test.ts index 5cd680e01..062f63add 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -64,10 +64,14 @@ function assertSchema( expect(typeof actual).toBe("object"); expect(actual).toEqual(expected); + const keywords: string[] = []; + if (config.markdownDescription) keywords.push("markdownDescription"); + if (config.rawJsDoc) keywords.push("rawJsDoc"); + const validator = new Ajv({ // skip full check if we are not encoding refs validateFormats: config.encodeRefs === false ? undefined : true, - keywords: config.markdownDescription ? ["markdownDescription"] : undefined, + keywords: keywords.length ? keywords : undefined, }); addFormats(validator); @@ -341,6 +345,18 @@ describe("config", () => { markdownDescription: true, }), ); + it( + "jsdoc-raw", + assertSchema("jsdoc-raw", { + type: "MyObject", + expose: "export", + topRef: false, + jsDoc: "extended", + sortProps: true, + markdownDescription: true, + rawJsDoc: true, + }), + ); it( "tsconfig-support", assertSchema( diff --git a/test/config/jsdoc-raw/main.ts b/test/config/jsdoc-raw/main.ts new file mode 100644 index 000000000..464d489ce --- /dev/null +++ b/test/config/jsdoc-raw/main.ts @@ -0,0 +1,169 @@ +/** + * @title Raw Test Schema Interface + * @description Top-level interface: This interface is used to test the rawJsDoc output. + * It includes various formatting quirks, inline tags such as {@link SomeReference}, and multiple JSDoc sections. + * + * @markdownDescription **Markdown version:** Markdown description which should be preserved + * + * Additional info: Top-level details should be completely preserved in raw form. + */ +export interface MyObject { + /** + * @title Single-line Title and Description + * @description Single-line comment for raw extraction. + */ + singleLine: string; + + /** + * @title Multiline Field Title + * @description This is a multiline description. + * It spans multiple lines to test the preservation of newlines. + * + * @note 123 + * @format date-time + */ + multilineField: number; + + /** + * This field has a comment without explicit tags. + * It includes an inline reference: {@link ExampleReference} and extra text on several lines. + * + * The raw output should preserve this whole block as is. + */ + noTagField: boolean; + + /** + * @title Field with Special Formatting + * @description Extra spaces and indentation should be preserved. + * + * @pattern /^\w+$/ + */ + specialFormat: string; + + /** + * Some initial descriptive text that is not tagged. + * + * @description Field with initial untagged text followed by a tag. + * @default 42 + * + * Further comments in the same block should be preserved entirely. + */ + initialText: number; + + /** + * Some *code block*: + * ```yaml + * name: description + * length: 42 + * ``` + * + * Some list: + * - one + * - two + * - and three... + * + * @description This field tests `inline code` and bold text. + * + * Also includes an inline link: {@link https://example.com} and additional commentary. + */ + markdownField: string; + + /** + * @title Tag Only Field + * @note Only raw content should be available. + * @customTag Tag only content! + */ + tagOnlyField: string; + + /** + * @title Tag Only Field + * @description + * @note Only raw content should be available. + * @customTag Tag only content! + */ + tagOnlyFieldWithDescription: string; + + /** Some text */ + oneLineJsDoc: string; + + /** Some *text* - {@link https://example.com} - @see the `link` */ + oneLineJsDocComplex: string; + + /** */ + emptyJsDoc1?: null; + + /** + * + */ + emptyJsDoc2?: null; + + noJsDoc?: null; + + /** + * Some ignored comment description + * + * @description Export field description + * @default {"length": 10} + * @nullable + */ + exportString: MyExportString; + /** + * @description Export field description + * @default "private" + */ + privateString: MyPrivateString; + + /** + * @title Non empty array + */ + numberArray: MyNonEmptyArray; + + /** + * @nullable + */ + number: number; + + /** + * Some more examples: + * ```yaml + * name: description + * length: 42 + * ``` + */ + description: InheritedExample["description"]; + + /** + * @default "" + */ + inheritedDescription: InheritedExample["description"]; +} + +/** + * @title My export string + */ +export type MyExportString = string; +/** + * @title My private string + */ +type MyPrivateString = string; +/** + * @minItems 1 + */ +export type MyNonEmptyArray = T[]; + +/** + * @title Inherited Example Interface + * @description This interface is used to test inherited descriptions. + */ +export interface InheritedExample { + /** + * This is an inherited description. + * + * It may include multiple at-tags: + * @title Inherited Title + * @description Inherited description text. + * + * It contains an inline link: {@link https://example.com} and additional commentary. + */ + description: string; +} diff --git a/test/config/jsdoc-raw/schema.json b/test/config/jsdoc-raw/schema.json new file mode 100644 index 000000000..97afbb80c --- /dev/null +++ b/test/config/jsdoc-raw/schema.json @@ -0,0 +1,164 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "MyExportString": { + "rawJsDoc": "@title My export string", + "title": "My export string", + "type": "string" + }, + "MyNonEmptyArray": { + "items": { + "type": "number" + }, + "minItems": 1, + "rawJsDoc": "@minItems 1", + "type": "array" + } + }, + "description": "Top-level interface: This interface is used to test the rawJsDoc output.\nIt includes various formatting quirks, inline tags such as {@link SomeReference }, and multiple JSDoc sections.", + "properties": { + "description": { + "description": "Some more examples: ```yaml name: description length: 42 ```", + "markdownDescription": "Some more examples:\n```yaml\nname: description\nlength: 42\n```", + "rawJsDoc": "Some more examples:\n```yaml\nname: description\nlength: 42\n```", + "title": "Inherited Title", + "type": "string" + }, + "emptyJsDoc1": { + "type": "null" + }, + "emptyJsDoc2": { + "type": "null" + }, + "exportString": { + "anyOf": [ + { + "$ref": "#/definitions/MyExportString", + "markdownDescription": "Some ignored comment description", + "rawJsDoc": "Some ignored comment description\n\n@description Export field description\n@default {\"length\": 10}\n@nullable" + }, + { + "type": "null" + } + ], + "default": { + "length": 10 + }, + "description": "Export field description" + }, + "inheritedDescription": { + "default": "", + "description": "Inherited description text.\n\nIt contains an inline link: {@link https://example.com} and additional commentary.", + "markdownDescription": "This is an inherited description.\n\nIt may include multiple at-tags:", + "rawJsDoc": "@default \"\"", + "title": "Inherited Title", + "type": "string" + }, + "initialText": { + "default": "42\n\nFurther comments in the same block should be preserved entirely.", + "description": "Field with initial untagged text followed by a tag.", + "markdownDescription": "Some initial descriptive text that is not tagged.", + "rawJsDoc": "Some initial descriptive text that is not tagged.\n\n@description Field with initial untagged text followed by a tag.\n@default 42\n\nFurther comments in the same block should be preserved entirely.", + "type": "number" + }, + "markdownField": { + "description": "This field tests `inline code` and bold text.\n\nAlso includes an inline link: {@link https://example.com} and additional commentary.", + "markdownDescription": "Some *code block*:\n```yaml\nname: description\nlength: 42\n```\n\nSome list:\n- one\n- two\n- and three...", + "rawJsDoc": "Some *code block*:\n```yaml\nname: description\nlength: 42\n```\n\nSome list:\n- one\n- two\n- and three...\n\n@description This field tests `inline code` and bold text.\n\nAlso includes an inline link: {@link https://example.com} and additional commentary.", + "type": "string" + }, + "multilineField": { + "description": "This is a multiline description.\nIt spans multiple lines to test the preservation of newlines.", + "format": "date-time", + "rawJsDoc": "@title Multiline Field Title\n@description This is a multiline description.\nIt spans multiple lines to test the preservation of newlines.\n\n@note 123\n@format date-time", + "title": "Multiline Field Title", + "type": "number" + }, + "noJsDoc": { + "type": "null" + }, + "noTagField": { + "description": "This field has a comment without explicit tags. It includes an inline reference: {@link ExampleReference } and extra text on several lines.\n\nThe raw output should preserve this whole block as is.", + "markdownDescription": "This field has a comment without explicit tags.\nIt includes an inline reference: {@link ExampleReference } and extra text on several lines.\n\nThe raw output should preserve this whole block as is.", + "rawJsDoc": "This field has a comment without explicit tags.\nIt includes an inline reference: {@link ExampleReference} and extra text on several lines.\n\nThe raw output should preserve this whole block as is.", + "type": "boolean" + }, + "number": { + "rawJsDoc": "@nullable", + "type": [ + "number", + "null" + ] + }, + "numberArray": { + "$ref": "#/definitions/MyNonEmptyArray%3Cnumber%3E", + "rawJsDoc": "@title Non empty array", + "title": "Non empty array" + }, + "oneLineJsDoc": { + "description": "Some text", + "markdownDescription": "Some text", + "rawJsDoc": "Some text", + "type": "string" + }, + "oneLineJsDocComplex": { + "description": "Some *text* - {@link https://example.com } -", + "markdownDescription": "Some *text* - {@link https://example.com } -", + "rawJsDoc": "Some *text* - {@link https://example.com} - @see the `link`", + "type": "string" + }, + "privateString": { + "default": "private", + "description": "Export field description", + "rawJsDoc": "@description Export field description\n@default \"private\"", + "title": "My private string", + "type": "string" + }, + "singleLine": { + "description": "Single-line comment for raw extraction.", + "rawJsDoc": "@title Single-line Title and Description\n@description Single-line comment for raw extraction.", + "title": "Single-line Title and Description", + "type": "string" + }, + "specialFormat": { + "description": "Extra spaces and indentation should be preserved.", + "pattern": "/^\\w+$/", + "rawJsDoc": "@title Field with Special Formatting\n@description Extra spaces and indentation should be preserved.\n\n@pattern /^\\w+$/", + "title": "Field with Special Formatting", + "type": "string" + }, + "tagOnlyField": { + "rawJsDoc": "@title Tag Only Field\n@note Only raw content should be available.\n@customTag Tag only content!", + "title": "Tag Only Field", + "type": "string" + }, + "tagOnlyFieldWithDescription": { + "description": "", + "rawJsDoc": "@title Tag Only Field\n@description\n@note Only raw content should be available.\n@customTag Tag only content!", + "title": "Tag Only Field", + "type": "string" + } + }, + "rawJsDoc": "@title Raw Test Schema Interface\n@description Top-level interface: This interface is used to test the rawJsDoc output.\nIt includes various formatting quirks, inline tags such as {@link SomeReference}, and multiple JSDoc sections.\n\n@markdownDescription **Markdown version:** Markdown description which should be preserved\n\nAdditional info: Top-level details should be completely preserved in raw form.", + "required": [ + "singleLine", + "multilineField", + "noTagField", + "specialFormat", + "initialText", + "markdownField", + "tagOnlyField", + "tagOnlyFieldWithDescription", + "oneLineJsDoc", + "oneLineJsDocComplex", + "exportString", + "privateString", + "numberArray", + "number", + "description", + "inheritedDescription" + ], + "title": "Raw Test Schema Interface", + "type": "object" +} diff --git a/test/unit/getRawJsDoc.test.ts b/test/unit/getRawJsDoc.test.ts new file mode 100644 index 000000000..a1fc337cd --- /dev/null +++ b/test/unit/getRawJsDoc.test.ts @@ -0,0 +1,236 @@ +import { getRawJsDoc } from "../../src/Utils/getRawJsDoc"; +import ts from "typescript"; + +function dummyNode(jsDocComment: string): ts.Node { + const code = `${jsDocComment}\nfunction dummy() {}`; + const sourceFile = ts.createSourceFile("dummy.ts", code, ts.ScriptTarget.Latest, true); + const node = sourceFile.statements.find(ts.isFunctionDeclaration); + if (!node) { + throw new Error("Could not create node"); + } + return node; +} + +// All white-space charachters except new-line, starting and ending with simple space +const ANY_SPACE = " \t\f\v\r\u00A0\u2028\u2029 "; + +describe("getRawJsDoc", () => { + it("Returns undefined if no JSDoc", () => { + const jsdoc = "// no JSDoc"; + const result = getRawJsDoc(dummyNode(jsdoc)); + expect(result).toBeUndefined(); + }); + + const cases = [ + { + desc: "Removes single-space padding from a one-line JSDoc", + jsdoc: "/** Some text */", + expected: "Some text", + }, + { + desc: "Removes multiple-space padding from a one-line JSDoc", + jsdoc: `/**${ANY_SPACE}Some text${ANY_SPACE}*/`, + expected: "Some text", + }, + { + desc: "Extracts text between JSDoc markers when no padding is present", + jsdoc: "/**Some text*/", + expected: "Some text", + }, + { + desc: "Removes extra whitespace from a multi-line JSDoc with a single line of text", + jsdoc: ` + /** + * Some text + */`, + expected: "Some text", + }, + { + desc: "Removes irregular whitespace from a multi-line JSDoc on a single text line", + jsdoc: ` + /** + * ${ANY_SPACE}Some text${ANY_SPACE} + */`, + expected: "Some text", + }, + { + desc: "Extracts trimmed text from a multi-line JSDoc with a single non-empty line", + jsdoc: ` + /** ${ANY_SPACE} + * ${ANY_SPACE} + * ${ANY_SPACE}Some text${ANY_SPACE} + * ${ANY_SPACE} + ${ANY_SPACE} */ + `, + expected: "Some text", + }, + { + desc: "Processes multi-line JSDoc where some lines lack a space after the asterisk", + jsdoc: ` + /** + * Line 1 + *Line 2 + */`, + expected: "Line 1\nLine 2", + }, + { + desc: "Preserves intentional leading and trailing spaces within text lines in a multi-line JSDoc", + jsdoc: ` + /** + * Line 1 ${ANY_SPACE} + * ${ANY_SPACE}Line 2 + * ${ANY_SPACE} + * Line 4 + */`, + expected: `Line 1 ${ANY_SPACE}\n${ANY_SPACE}Line 2\n${ANY_SPACE}\nLine 4`, + }, + { + desc: "Preserves at-tags in a one-line JSDoc", + jsdoc: ` + /** @see https://example.com */`, + expected: `@see https://example.com`, + }, + { + desc: "Preserves inline at-tags in a one-line JSDoc", + jsdoc: ` + /** {@link https://example.com} */`, + expected: `{@link https://example.com}`, + }, + { + desc: "Preserves at-tags in a multi-line JSDoc", + jsdoc: ` + /** + * Some text + * @see https://example.com + * More text + */`, + expected: `Some text\n@see https://example.com\nMore text`, + }, + { + desc: "Preserves inline at-tags within a multi-line JSDoc", + jsdoc: ` + /** + * Some text + * Link {@link https://example.com} + * More text + */`, + expected: `Some text\nLink {@link https://example.com}\nMore text`, + }, + { + desc: "Handles multi-line JSDoc comments with asterisks and indentation", + jsdoc: ` + /** + * Some list: + * * Item A + * * Item B + * * Nested Item + * More text + */`, + expected: `Some list:\n* Item A\n* Item B\n * Nested Item\nMore text`, + }, + { + desc: "Preserves Unicode characters", + jsdoc: ` + /** + * \u2013\u2014\u2026\uD83D\uDE0A + */`, + expected: `\u2013\u2014\u2026\uD83D\uDE0A`, + }, + { + desc: "Handles case when end marker is on a new line", + jsdoc: ` + /** Some text + */`, + expected: "Some text", + }, + { + desc: "Handles case when the content and end marker on the other line than start marker", + jsdoc: ` + /** + Some text */`, + expected: "Some text", + }, + { + desc: "Handles case when there is no new line or space after start marker", + jsdoc: ` + /**Some text + */`, + expected: "Some text", + }, + { + desc: "Handles case when there is no new line or space before end marker", + jsdoc: ` + /** + * Some text*/`, + expected: "Some text", + }, + { + desc: "Preserves CRLF in multi-line JSDoc", + jsdoc: `/**\r\n * Line 1\r\nLine 2\r\n */`, + expected: "Line 1\r\nLine 2", + }, + { + desc: "Returns an empty string for an empty JSDoc", + jsdoc: "/***/", + expected: "", + }, + { + desc: "Returns an empty string for a one-line JSDoc containing only whitespace", + jsdoc: "/** */", + expected: "", + }, + { + desc: "Returns an empty string for a JSDoc with only newline characters", + jsdoc: `/**\n\n*/`, + expected: "", + }, + { + desc: "Returns an empty string for a multi-line JSDoc where all lines are empty or whitespace-only", + jsdoc: ` + /** + * + * ${ANY_SPACE} + * + */ + `, + expected: "", + }, + { + desc: "Returns an empty string for a one-line JSDoc containing only whitespace", + jsdoc: `/** ${ANY_SPACE} */`, + expected: "", + }, + + { + desc: `Handles multi-line JSDoc where the first content lines do not begin with an asterisk`, + jsdoc: `/**\nLine 1\nLine 2 + * Line 3 + */`, + expected: "Line 1\nLine 2\nLine 3", + }, + { + desc: `Handles multi-line JSDoc where a middle line lacks an asterisk prefix`, + jsdoc: ` + /** + * Line 1\nLine 2 + * Line 3 + */`, + expected: "Line 1\nLine 2\nLine 3", + }, + { + desc: `Handles non-standard whitespace preceding asterisks in a multi-line JSDoc`, + jsdoc: ` + /** + ${ANY_SPACE}* + ${ANY_SPACE}* Some text + ${ANY_SPACE}* + */`, + expected: "Some text", + }, + ]; + + it.each(cases)("$desc", ({ jsdoc, expected }) => { + const result = getRawJsDoc(dummyNode(jsdoc.trim())); + expect(result).toBe(expected); + }); +});