import MagicString from 'magic-string' import { parseSync } from 'oxc-parser' import { normalizePath } from './normalize-path' import { createLocMapper } from './offset-to-loc' import { matcher } from './matcher' import { forEachChild } from './ast-utils' import type { JSXElementName, JSXOpeningElement, Node, ParamPattern, } from 'oxc-parser' import type { Loc } from './offset-to-loc' /** * Extract the props name from a function's first parameter. * Handles: `(props) => {}`, `({ ...props }) => {}`, `function(props) {}` */ function getPropsName(params: Array): string | null { const first = params[0] if (!first) return null // (props) => {} or function(props) {} if (first.type === 'Identifier') return first.name // ({ ...rest }) => {} or function({ ...rest }) {} if (first.type === 'ObjectPattern') { for (const prop of first.properties) { if (prop.type === 'RestElement' && prop.argument.type === 'Identifier') { return prop.argument.name } } } return null } /** * Get the display name from a JSX element name node. */ function getNameOfElement(name: JSXElementName): string { if (name.type === 'JSXIdentifier') return name.name if (name.type === 'JSXMemberExpression') { return `${getNameOfElement(name.object)}.${name.property.name}` } // JSXNamespacedName return `${name.namespace.name}:${name.name.name}` } /** * Check whether a JSXOpeningElement should receive `data-tsd-source`. */ function shouldTransform( node: JSXOpeningElement, propsName: string | null, ignorePatterns: Array, ): boolean { const nameOfElement = getNameOfElement(node.name) if (nameOfElement === 'Fragment' || nameOfElement === 'React.Fragment') { return false } if (matcher(ignorePatterns, nameOfElement)) return false // Already annotated? if ( node.attributes.some( (a) => a.type === 'JSXAttribute' && a.name.type === 'JSXIdentifier' && a.name.name === 'data-tsd-source', ) ) { return false } // Props spread? if ( node.attributes.some( (a) => a.type === 'JSXSpreadAttribute' && a.argument.type === 'Identifier' && a.argument.name === propsName, ) ) { return false } return true } function isFunctionLike(node: Node): boolean { return ( node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' ) } /** * Walk a subtree collecting JSXOpeningElements owned by the current function. * Function-like nodes are traversal boundaries so inner components keep their * own propsName context and aren't annotated with an outer function's props. */ function collectJsx(node: Node, out: Array) { if (node.type === 'JSXOpeningElement') { out.push(node) return } if (isFunctionLike(node)) return forEachChild(node, (child) => collectJsx(child, out)) } /** * Walk the AST to find all function-like nodes (in document order). * For each, extract propsName and collect JSX elements from its body. */ function visitFunctions( node: Node, annotated: Set, file: string, ignorePatterns: Array, offsetToLoc: (offset: number) => Loc, s: MagicString, code: string, ): boolean { let didTransform = false const processFunction = (params: Array, body: Node) => { const propsName = getPropsName(params) const jsxNodes: Array = [] collectJsx(body, jsxNodes) for (const jsx of jsxNodes) { if (annotated.has(jsx.start)) continue if (!shouldTransform(jsx, propsName, ignorePatterns)) continue const loc = offsetToLoc(jsx.start) const attrStr = ` data-tsd-source="${file}:${loc.line}:${loc.column + 1}"` // Insert before '>' or '/>' at the end of the opening element if (jsx.selfClosing) { // ends with '/>' — insert before '/>' s.appendLeft(jsx.end - 2, attrStr) } else { // ends with '>' — insert before '>' s.appendLeft(jsx.end - 1, attrStr) } annotated.add(jsx.start) didTransform = true } } // Check if this node is a function-like node. Variable-initialized // functions (`const Foo = () => {}`) are handled when forEachChild reaches // their init, so VariableDeclaration doesn't need a special case here. if ( node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' ) { if (node.body) processFunction(node.params, node.body) } else if (node.type === 'ArrowFunctionExpression') { processFunction(node.params, node.body) } // Recurse into children to find nested functions forEachChild(node, (child) => { if ( visitFunctions( child, annotated, file, ignorePatterns, offsetToLoc, s, code, ) ) { didTransform = true } }) return didTransform } export function addSourceToJsx( code: string, id: string, ignore: { files?: Array components?: Array } = {}, ) { const filePath = id.split('?')[0]! // Normalize filePath before stripping cwd: under Rspack on Windows the module // id has backslashes, so comparing against the forward-slashed cwd would fail // to strip and leak an absolute path into the injected data-tsd-source attr. const location = normalizePath(filePath).replace( normalizePath(process.cwd()), '', ) const fileIgnored = matcher(ignore.files || [], location) if (fileIgnored) return try { const result = parseSync(filePath, code, { sourceType: 'module', lang: 'tsx', }) if (result.errors.length > 0) return const offsetToLoc = createLocMapper(code) const s = new MagicString(code) const annotated = new Set() const didTransform = visitFunctions( result.program, annotated, location, ignore.components || [], offsetToLoc, s, code, ) if (!didTransform) return return { code: s.toString(), map: s.generateMap({ source: filePath, file: id, includeContent: true, }), } } catch (e) { return } }