import MagicString from 'magic-string' import { parseSync, Visitor } from 'oxc-parser' import type { SootsimJumpToSourceBabelOptions } from './jump-to-source-babel.ts' export type JumpToSourceNativeOptions = SootsimJumpToSourceBabelOptions function matchesPath(path: string, matcher: string | RegExp): boolean { return typeof matcher === 'string' ? path.includes(matcher) : matcher.test(path) } // stamp the same one-based source locations as the babel plugin before native // jsx compilation, without running babel in the native bundle pipeline. export function stampJsxSourceLocations( code: string, filename: string, options: JumpToSourceNativeOptions = {}, ): string | null { if (options.enabled === false || !filename || !code.includes('<')) return null const source = filename.split('?')[0].replace(/\\/g, '/') if ( source.includes('/node_modules/') || source.includes('/dist/') || source.includes('/build/') ) return null if (options.exclude?.some((matcher) => matchesPath(source, matcher))) return null if ( options.include?.length && !options.include.some((matcher) => matchesPath(source, matcher)) ) return null const { program, errors } = parseSync(source, code, { lang: source.endsWith('.tsx') ? 'tsx' : source.endsWith('.ts') ? 'ts' : 'jsx', }) if (errors.length) return null const attrName = options.attrName || 'srcloc' const lineStarts = [0] for (let i = 0; i < code.length; i++) { if (code.charCodeAt(i) === 10) lineStarts.push(i + 1) } const magic = new MagicString(code) let stamped = false new Visitor({ JSXOpeningElement(node) { const name = node.name if (name.type === 'JSXNamespacedName') return if (name.type === 'JSXIdentifier' && name.name === 'Fragment') return if (name.type === 'JSXMemberExpression' && name.property.name === 'Fragment') return if ( node.attributes.some( (attr) => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === attrName, ) ) return // binary search keeps location lookup logarithmic for large source files. let lo = 0 let hi = lineStarts.length - 1 while (lo < hi) { const mid = (lo + hi + 1) >> 1 if (lineStarts[mid] <= node.start) lo = mid else hi = mid - 1 } const location = `${source}:${lo + 1}:${node.start - lineStarts[lo] + 1}` // append after spreads, matching babel's precedence. a jsx expression // preserves path characters that a quoted jsx attribute would decode. magic.appendLeft( node.end - (node.selfClosing ? 2 : 1), ` ${attrName}={${JSON.stringify(location)}}`, ) stamped = true }, }).visit(program) return stamped ? magic.toString() : null } // metro's nativeTransformModules accepts module ids; consumers can export an // instance of this transform from a module to supply their include filters. export function createJumpToSourceNativeTransform( options: JumpToSourceNativeOptions = {}, ) { return function jumpToSourceNativeTransform(code: string, ctx: { filename: string }) { return stampJsxSourceLocations(code, ctx.filename, options) } } export function jumpToSourceNativePlugin(options: JumpToSourceNativeOptions = {}) { return { name: 'sootsim-jump-to-source', transform(code: string, id: string) { const stamped = stampJsxSourceLocations(code, id, options) return stamped == null ? null : { code: stamped, map: null } }, } } export default createJumpToSourceNativeTransform()