/** * TSDoc/JSDoc parsing helpers using the TypeScript compiler API. * * Provides `parseComment` for extracting JSDoc/TSDoc from TypeScript nodes. * Primarily designed for build-time code generation but can be used at runtime. * * ## Design * * Pure extraction approach: extracts documentation as-is with minimal transformation, * preserving source intent. Works around TypeScript compiler API quirks where needed. * * Supports both regular TypeScript and Svelte components (via svelte2tsx output). * * ## Tag support * * Supports a subset of standard TSDoc tags: * `@param`, `@returns`, `@throws`, `@example`, `@deprecated`, `@see`, `@since`, `@default`, `@nodocs`. * * The `@nodocs` tag excludes exports from documentation and flat namespace validation. * The declaration is still exported and usable, just not documented. * * Also supports `@mutates` (non-standard) for documenting mutations to parameters or external state. * Uses same format as `@param`: `@mutates key - description of mutation`. The key is * unvalidated — typically a parameter name, but compound paths (`this.foo`, `obj.field`) * and external state references are accepted as-is. * * Only `@returns` is supported (not `@return`). * * The `@see` tag supports multiple formats: plain URLs (`https://...`), `{@link}` syntax, and module names. * Relative/absolute path support in `@see` is TBD. * * ## Behavioral notes * * JSDoc blocks tagged `@module` are excluded from `parseComment` entirely (text * and tags) — module comments attach to the file's first statement in the AST * and are owned by `extractModuleComment` instead. * * Due to TS Compiler API limitations: * - TS API includes dash separator in `@param` tag text; we strip the leading `- ` as it's syntax, not content * - `@throws` tags have `{Type}` stripped by TS API; fallback regex extracts first word as error type * - TS API strips URL protocols from `@see` tag text; we use `getText()` to preserve original format including `{@link}` syntax * * @see `declaration-build.ts` for `DeclarationJsonBuild` * @see `typescript-exports.ts` and `svelte.ts` as primary consumers * * @module */ import ts from 'typescript'; import type {DeclarationJsonBuild, MemberJsonBuild} from './declaration-build.js'; /** * Parsed JSDoc/TSDoc comment with structured metadata. * * Returned by `parseComment` — consumers typically pass this to `applyToDeclaration` * to populate `DeclarationJsonBuild` fields. */ export interface TsdocParsedComment { /** Comment text (excluding comment markers). */ text: string; /** Parameter descriptions mapped by parameter name. */ params: Record<string, string>; /** Return value description from `@returns`. */ returns?: string; /** Thrown errors from `@throws`. */ throws?: Array<{type?: string; description: string}>; /** Code examples from `@example`. */ examples?: Array<string>; /** Deprecation message from `@deprecated`. */ deprecatedMessage?: string; /** Related references from `@see`. */ seeAlso?: Array<string>; /** Version information from `@since`. */ since?: string; /** Default value from `@default` tag. */ defaultValue?: string; /** Mutation documentation from `@mutates` (non-standard), mapped by parameter name. */ mutates?: Record<string, string>; /** Whether to exclude from documentation. From `@nodocs` tag. */ nodocs?: boolean; } /** * Clean TSDoc tag description text by stripping leading `- ` separator. * TSDoc syntax uses `- ` as a separator, but it shouldn't be part of the description content. * Only strips the first `- ` to preserve markdown lists later in the text. * * @param text - the tag text to clean * @returns cleaned text with leading `- ` removed if present */ const cleanTagDescription = (text: string): string => text.trim().replace(/^-\s+/, ''); /** * Whether a JSDoc block is a module-level comment (carries a `@module` tag). */ const isModuleJsdocBlock = (jsdoc: ts.JSDoc): boolean => jsdoc.tags?.some((tag) => tag.tagName.text === 'module') ?? false; /** * Whether a JSDoc node or tag belongs to a module-level (`@module`) block. * * A module comment physically precedes the file's first statement, so the AST * attaches it to that statement's JSDoc — without this filter the module * comment (including tags like `@nodocs`) would read as the statement's own * docs. Per-block, so a statement's own JSDoc below a module comment still * applies. `extractModuleComment` owns module-comment extraction. */ const belongsToModuleBlock = (node: ts.JSDoc | ts.JSDocTag): boolean => ts.isJSDoc(node) ? isModuleJsdocBlock(node) : ts.isJSDoc(node.parent) && isModuleJsdocBlock(node.parent); /** * Parse JSDoc comment from a TypeScript node. * * Extracts and parses all JSDoc tags including: * * - `@param` - parameter descriptions * - `@returns` - return value description * - `@throws` - error documentation * - `@example` - code examples * - `@deprecated` - deprecation warnings * - `@see` - related references * - `@since` - version information * - `@default` - default value * - `@mutates` - mutation documentation (non-standard) * - `@nodocs` - exclusion flag (non-standard) * * JSDoc blocks tagged `@module` are excluded entirely (text and tags): a module * comment attaches to the file's first statement in the AST, and without the * filter it would read as that statement's own docs. `extractModuleComment` * (`typescript-exports.ts`) owns module comments. * * @param node - the TypeScript node to extract JSDoc from * @param sourceFile - source file (used for extracting full `@see` tag text) * @returns parsed comment with structured metadata, or undefined if no JSDoc found (or only `@module` blocks) * * @example * ```ts * const tsdoc = parseComment(declarationNode, sourceFile); * if (tsdoc) { * console.log(tsdoc.text); // main comment text * console.log(tsdoc.params); // {paramName: 'description'} * } * ``` */ export const parseComment = ( node: ts.Node, sourceFile: ts.SourceFile, ): TsdocParsedComment | undefined => { const tsdocComments = ts.getJSDocCommentsAndTags(node).filter((c) => !belongsToModuleBlock(c)); if (tsdocComments.length === 0) return undefined; let fullText = ''; // Null-prototype map: keys are parameter names parsed from source. Without it, // a parameter named after an `Object.prototype` key (`constructor`, `toString`, // `__proto__`, …) with no matching `@param` tag would read the inherited // prototype value on lookup (`tsdocParams?.[param.name]`) instead of `undefined`, // and writing such a key (`@param __proto__`) would pollute the prototype. const params: Record<string, string> = Object.create(null); let returns: string | undefined; let throws: Array<{type?: string; description: string}> | undefined; let examples: Array<string> | undefined; let deprecatedMessage: string | undefined; let seeAlso: Array<string> | undefined; let since: string | undefined; let defaultValue: string | undefined; let mutates: Record<string, string> | undefined; let nodocs: boolean | undefined; // Extract main comment text for (const comment of tsdocComments) { if (ts.isJSDoc(comment) && comment.comment) { const text = typeof comment.comment === 'string' ? comment.comment : comment.comment.map((c) => c.text).join(''); fullText += text + '\n'; } } // Extract tags (module-block tags filtered like their text above) const tags = ts.getJSDocTags(node).filter((tag) => !belongsToModuleBlock(tag)); for (const tag of tags) { const tagText = typeof tag.comment === 'string' ? tag.comment : tag.comment?.map((c) => c.text).join(''); const tagName = tag.tagName.text; if (tagName === 'param' && ts.isJSDocParameterTag(tag)) { // Extract parameter name and description const paramName = ts.isIdentifier(tag.name) ? tag.name.text : tag.name.getText(); if (paramName && tagText) { params[paramName] = cleanTagDescription(tagText); } } else if (tagName === 'returns' && tagText) { returns = tagText.trim(); } else if (tagName === 'throws' && tagText) { // Try to extract error type and description const match = /^\{?(\w+)\}?\s+(.+)/.exec(tagText); if (match) { (throws ??= []).push({type: match[1], description: cleanTagDescription(match[2]!)}); } else { (throws ??= []).push({description: cleanTagDescription(tagText)}); } } else if (tagName === 'example' && tagText) { (examples ??= []).push(tagText.trim()); } else if (tagName === 'deprecated') { deprecatedMessage = tagText?.trim() ?? ''; } else if (tagName === 'see') { // The TS API strips 'https' from URLs in @see tags, so get full text from source const fullTagText = tag.getText(sourceFile); // Extract content after @see, handling JSDoc formatting artifacts const seeContent = fullTagText .replace(/^@see\s+/, '') // remove @see prefix .replace(/\n\s*\*\s*/g, ' ') // remove JSDoc line continuations .replace(/\s*\*\s*$/, '') // remove trailing asterisk artifacts .trim(); if (seeContent) { (seeAlso ??= []).push(seeContent); } } else if (tagName === 'since' && tagText) { since = tagText.trim(); } else if (tagName === 'default' && tagText) { defaultValue = tagText.trim(); } else if (tagName === 'mutates' && tagText) { // Extract parameter name and description (format: @mutates paramName - description) const cleanedText = cleanTagDescription(tagText); const match = /^(\w+)\s+-?\s*(.+)/.exec(cleanedText); if (match) { const paramName = match[1]!; const description = match[2]!.trim(); // Null-prototype map: `paramName` comes from source (`\w+` matches // `__proto__`, `constructor`, …); a plain object would let such a key // pollute the prototype on write and read back later by key. (mutates ??= Object.create(null))[paramName] = description; } } else if (tagName === 'nodocs') { nodocs = true; } } fullText = fullText.trim(); return { text: fullText, params, returns, throws, examples, deprecatedMessage, seeAlso, since, defaultValue, mutates, nodocs, }; }; /** * Apply parsed TSDoc metadata to a declaration. * * Consolidates the common pattern of assigning TSDoc fields to declarations, * with conditional assignment for array fields (only if non-empty). * * @param declaration - declaration object to update * @param tsdoc - parsed TSDoc comment (if available) * @mutates declaration - adds docComment, deprecatedMessage, examples, seeAlso, throws, since, mutates, defaultValue fields */ export const applyToDeclaration = ( declaration: DeclarationJsonBuild | MemberJsonBuild, tsdoc: TsdocParsedComment | undefined, ): void => { if (!tsdoc) return; if (tsdoc.text) { declaration.docComment = tsdoc.text; } if (tsdoc.deprecatedMessage !== undefined) { declaration.deprecatedMessage = tsdoc.deprecatedMessage; } // Only assign arrays if they have content if (tsdoc.examples?.length) { declaration.examples = tsdoc.examples; } if (tsdoc.seeAlso?.length) { declaration.seeAlso = tsdoc.seeAlso; } if (tsdoc.throws?.length) { declaration.throws = tsdoc.throws; } if (tsdoc.since) { declaration.since = tsdoc.since; } if (tsdoc.mutates && Object.keys(tsdoc.mutates).length > 0) { declaration.mutates = tsdoc.mutates; } // `defaultValue` is schema-allowed on variable declarations/members only; // `z.strictObject` would reject it on other kinds. Component props consume // the parsed `defaultValue` directly in `svelte.ts` (not via this helper). if (tsdoc.defaultValue !== undefined && declaration.kind === 'variable') { declaration.defaultValue = tsdoc.defaultValue; } }; /** * Clean raw JSDoc comment text by removing comment markers and leading asterisks. * * Transforms `/** ... *\/` style comments into clean text. * * @param commentText - the raw comment text including `/**` and `*\/` markers * @returns cleaned comment text, or undefined if empty after cleaning * * @example * ```ts * cleanComment('/** Hello world *\/') // => 'Hello world' * cleanComment('/**\n * Line 1\n * Line 2\n *\/') // => 'Line 1\nLine 2' * ``` */ export const cleanComment = (commentText: string): string | undefined => { const text = commentText .replace(/^\/\*\*/, '') .replace(/\*\/$/, '') .replace(/\r\n/g, '\n') .split('\n') .map((line) => line.replace(/^\s*\*\s?/, '')) .join('\n') .trim(); return text || undefined; };
{ "path": "tsdoc.ts", "declarations": [ { "name": "TsdocParsedComment", "kind": "interface", "docComment": "Parsed JSDoc/TSDoc comment with structured metadata.\n\nReturned by `parseComment` — consumers typically pass this to `applyToDeclaration`\nto populate `DeclarationJsonBuild` fields.", "typeSignature": "TsdocParsedComment", "sourceLine": 59, "members": [ { "name": "text", "kind": "variable", "docComment": "Comment text (excluding comment markers).", "typeSignature": "string" }, { "name": "params", "kind": "variable", "docComment": "Parameter descriptions mapped by parameter name.", "typeSignature": "Record<string, string>" }, { "name": "returns", "kind": "variable", "docComment": "Return value description from `@returns`.", "typeSignature": "string", "optional": true }, { "name": "throws", "kind": "variable", "docComment": "Thrown errors from `@throws`.", "typeSignature": "Array<{type?: string; description: string}>", "optional": true }, { "name": "examples", "kind": "variable", "docComment": "Code examples from `@example`.", "typeSignature": "Array<string>", "optional": true }, { "name": "deprecatedMessage", "kind": "variable", "docComment": "Deprecation message from `@deprecated`.", "typeSignature": "string", "optional": true }, { "name": "seeAlso", "kind": "variable", "docComment": "Related references from `@see`.", "typeSignature": "Array<string>", "optional": true }, { "name": "since", "kind": "variable", "docComment": "Version information from `@since`.", "typeSignature": "string", "optional": true }, { "name": "defaultValue", "kind": "variable", "docComment": "Default value from `@default` tag.", "typeSignature": "string", "optional": true }, { "name": "mutates", "kind": "variable", "docComment": "Mutation documentation from `@mutates` (non-standard), mapped by parameter name.", "typeSignature": "Record<string, string>", "optional": true }, { "name": "nodocs", "kind": "variable", "docComment": "Whether to exclude from documentation. From `@nodocs` tag.", "typeSignature": "boolean", "optional": true } ] }, { "name": "parseComment", "kind": "function", "docComment": "Parse JSDoc comment from a TypeScript node.\n\nExtracts and parses all JSDoc tags including:\n\n- `@param` - parameter descriptions\n- `@returns` - return value description\n- `@throws` - error documentation\n- `@example` - code examples\n- `@deprecated` - deprecation warnings\n- `@see` - related references\n- `@since` - version information\n- `@default` - default value\n- `@mutates` - mutation documentation (non-standard)\n- `@nodocs` - exclusion flag (non-standard)\n\nJSDoc blocks tagged `@module` are excluded entirely (text and tags): a module\ncomment attaches to the file's first statement in the AST, and without the\nfilter it would read as that statement's own docs. `extractModuleComment`\n(`typescript-exports.ts`) owns module comments.", "typeSignature": "(node: Node, sourceFile: SourceFile): TsdocParsedComment | undefined", "sourceLine": 148, "examples": [ "```ts\nconst tsdoc = parseComment(declarationNode, sourceFile);\nif (tsdoc) {\n console.log(tsdoc.text); // main comment text\n console.log(tsdoc.params); // {paramName: 'description'}\n}\n```" ], "parameters": [ { "name": "node", "type": "Node", "description": "the TypeScript node to extract JSDoc from" }, { "name": "sourceFile", "type": "SourceFile", "description": "source file (used for extracting full `@see` tag text)" } ], "returnType": "TsdocParsedComment | undefined", "returnDescription": "parsed comment with structured metadata, or undefined if no JSDoc found (or only `@module` blocks)" }, { "name": "applyToDeclaration", "kind": "function", "docComment": "Apply parsed TSDoc metadata to a declaration.\n\nConsolidates the common pattern of assigning TSDoc fields to declarations,\nwith conditional assignment for array fields (only if non-empty).", "typeSignature": "(declaration: MemberJsonBuild | DeclarationJsonBuild, tsdoc: TsdocParsedComment | undefined): void", "sourceLine": 271, "mutates": { "declaration": "adds docComment, deprecatedMessage, examples, seeAlso, throws, since, mutates, defaultValue fields" }, "parameters": [ { "name": "declaration", "type": "MemberJsonBuild | DeclarationJsonBuild", "description": "declaration object to update" }, { "name": "tsdoc", "type": "TsdocParsedComment | undefined", "description": "parsed TSDoc comment (if available)" } ], "returnType": "void" }, { "name": "cleanComment", "kind": "function", "docComment": "Clean raw JSDoc comment text by removing comment markers and leading asterisks.\n\nTransforms `/** ... *\\/` style comments into clean text.", "typeSignature": "(commentText: string): string | undefined", "sourceLine": 322, "examples": [ "```ts\ncleanComment('/** Hello world *\\/') // => 'Hello world'\ncleanComment('/**\\n * Line 1\\n * Line 2\\n *\\/') // => 'Line 1\\nLine 2'\n```" ], "parameters": [ { "name": "commentText", "type": "string", "description": "the raw comment text including `/**` and `*\\/` markers" } ], "returnType": "string | undefined", "returnDescription": "cleaned comment text, or undefined if empty after cleaning" } ], "moduleComment": "TSDoc/JSDoc parsing helpers using the TypeScript compiler API.\n\nProvides `parseComment` for extracting JSDoc/TSDoc from TypeScript nodes.\nPrimarily designed for build-time code generation but can be used at runtime.\n\n## Design\n\nPure extraction approach: extracts documentation as-is with minimal transformation,\npreserving source intent. Works around TypeScript compiler API quirks where needed.\n\nSupports both regular TypeScript and Svelte components (via svelte2tsx output).\n\n## Tag support\n\nSupports a subset of standard TSDoc tags:\n`@param`, `@returns`, `@throws`, `@example`, `@deprecated`, `@see`, `@since`, `@default`, `@nodocs`.\n\nThe `@nodocs` tag excludes exports from documentation and flat namespace validation.\nThe declaration is still exported and usable, just not documented.\n\nAlso supports `@mutates` (non-standard) for documenting mutations to parameters or external state.\nUses same format as `@param`: `@mutates key - description of mutation`. The key is\nunvalidated — typically a parameter name, but compound paths (`this.foo`, `obj.field`)\nand external state references are accepted as-is.\n\nOnly `@returns` is supported (not `@return`).\n\nThe `@see` tag supports multiple formats: plain URLs (`https://...`), `{@link}` syntax, and module names.\nRelative/absolute path support in `@see` is TBD.\n\n## Behavioral notes\n\nJSDoc blocks tagged `@module` are excluded from `parseComment` entirely (text\nand tags) — module comments attach to the file's first statement in the AST\nand are owned by `extractModuleComment` instead.\n\nDue to TS Compiler API limitations:\n- TS API includes dash separator in `@param` tag text; we strip the leading `- ` as it's syntax, not content\n- `@throws` tags have `{Type}` stripped by TS API; fallback regex extracts first word as error type\n- TS API strips URL protocols from `@see` tag text; we use `getText()` to preserve original format including `{@link}` syntax\n\n@see `declaration-build.ts` for `DeclarationJsonBuild`\n@see `typescript-exports.ts` and `svelte.ts` as primary consumers", "dependencies": [ "declaration-build.ts" ], "dependents": [ "svelte.ts", "typescript-exports.ts", "typescript-extract-class.ts", "typescript-extract-function.ts", "typescript-extract-shared.ts", "typescript-extract-type-properties.ts", "typescript-extract-type.ts" ] }