export interface SootsimJumpToSourceBabelOptions { enabled?: boolean attrName?: string include?: readonly SourcePathMatcher[] exclude?: readonly SourcePathMatcher[] } type SourcePathMatcher = string | RegExp interface BabelApi { types: { jsxAttribute(name: unknown, value: unknown): JSXAttributeNode jsxIdentifier(name: string): unknown stringLiteral(value: string): unknown } } interface BabelPluginState { file?: { opts?: { filename?: string } } } interface SourceLocation { line: number column: number } interface JSXNameNode { type: string name?: string object?: JSXNameNode property?: JSXNameNode } interface JSXAttributeNode { type: string name?: JSXNameNode } interface JSXOpeningElementNode { name?: JSXNameNode attributes?: JSXAttributeNode[] loc?: { start?: SourceLocation } } interface JSXOpeningElementPath { node: JSXOpeningElementNode } function normalizeSourcePath(path: string): string { const queryStart = path.indexOf('?') const clean = queryStart >= 0 ? path.slice(0, queryStart) : path return clean.replace(/\\/g, '/') } function matchesPath(path: string, matcher: SourcePathMatcher): boolean { return typeof matcher === 'string' ? path.includes(matcher) : matcher.test(path) } function shouldStampFile( filename: string, options: SootsimJumpToSourceBabelOptions, ): boolean { const normalized = normalizeSourcePath(filename) if (normalized.includes('/node_modules/')) return false if (normalized.includes('/dist/') || normalized.includes('/build/')) return false if (options.exclude?.some((matcher) => matchesPath(normalized, matcher))) return false if (options.include?.length) { return options.include.some((matcher) => matchesPath(normalized, matcher)) } return true } function getJSXName(node: JSXNameNode | undefined): string | null { if (!node) return null if (node.type === 'JSXIdentifier' && node.name) return node.name if (node.type === 'JSXMemberExpression') { const object = getJSXName(node.object) const property = getJSXName(node.property) return object && property ? `${object}.${property}` : null } return null } function hasAttribute(node: JSXOpeningElementNode, attrName: string): boolean { return ( node.attributes?.some((attr) => { if (attr.type !== 'JSXAttribute') return false return attr.name?.type === 'JSXIdentifier' && attr.name.name === attrName }) ?? false ) } export default function sootsimJumpToSourceBabelPlugin( api: BabelApi, options: SootsimJumpToSourceBabelOptions = {}, ) { const attrName = options.attrName || 'srcloc' return { name: 'sootsim-jump-to-source', visitor: { JSXOpeningElement(path: JSXOpeningElementPath, state: BabelPluginState) { if (options.enabled === false) return const filename = state.file?.opts?.filename if (!filename || !shouldStampFile(filename, options)) return const node = path.node const tagName = getJSXName(node.name) if (!tagName || tagName === 'Fragment' || tagName.endsWith('.Fragment')) return if (hasAttribute(node, attrName)) return const loc = node.loc?.start if (!loc) return const source = `${normalizeSourcePath(filename)}:${loc.line}:${loc.column + 1}` node.attributes ??= [] node.attributes.push( api.types.jsxAttribute( api.types.jsxIdentifier(attrName), api.types.stringLiteral(source), ), ) }, }, } } export { sootsimJumpToSourceBabelPlugin }