Skip to main content
Glama
babel-plugin-intlayer-extract.ts24.6 kB
import { basename, dirname, extname } from 'node:path'; import type { NodePath, PluginObj, PluginPass } from '@babel/core'; import type * as BabelTypes from '@babel/types'; import { ATTRIBUTES_TO_EXTRACT, shouldExtract as defaultShouldExtract, generateKey, } from '@intlayer/chokidar'; type ExtractedContent = Record<string, string>; /** * Extracted content result from a file transformation */ export type ExtractResult = { /** Dictionary key derived from the file path */ dictionaryKey: string; /** File path that was processed */ filePath: string; /** Extracted content key-value pairs */ content: ExtractedContent; /** Default locale used */ locale: string; }; /** * Options for the extraction Babel plugin */ export type ExtractPluginOptions = { /** * The default locale for the extracted content */ defaultLocale?: string; /** * The package to import useIntlayer from * @default 'react-intlayer' */ packageName?: string; /** * Files list to traverse. If provided, only files in this list will be processed. */ filesList?: string[]; /** * Custom function to determine if a string should be extracted */ shouldExtract?: (text: string) => boolean; /** * Callback function called when content is extracted from a file. * This allows the compiler to capture the extracted content and write it to files. * The dictionary will be updated: new keys added, unused keys removed. */ onExtract?: (result: ExtractResult) => void; }; type State = PluginPass & { opts: ExtractPluginOptions; /** Extracted content from this file */ _extractedContent?: ExtractedContent; /** Set of existing keys to avoid duplicates */ _existingKeys?: Set<string>; /** The dictionary key for this file */ _dictionaryKey?: string; /** whether the current file is included in the filesList */ _isIncluded?: boolean; /** Whether this file has JSX (React component) */ _hasJSX?: boolean; /** Whether we already have useIntlayer imported */ _hasUseIntlayerImport?: boolean; /** The local name for useIntlayer (in case it's aliased) */ _useIntlayerLocalName?: string; /** Whether we already have getIntlayer imported */ _hasGetIntlayerImport?: boolean; /** The local name for getIntlayer (in case it's aliased) */ _getIntlayerLocalName?: string; /** The variable name to use for content (content or _compContent if content is already used) */ _contentVarName?: string; /** Set of function start positions that have extracted content (only inject hooks into these) */ _functionsWithExtractedContent?: Set<number>; }; /* ────────────────────────────────────────── helpers ─────────────────────── */ /** * Extract dictionary key from file path */ const extractDictionaryKeyFromPath = (filePath: string): string => { const ext = extname(filePath); let baseName = basename(filePath, ext); if (baseName === 'index') { baseName = basename(dirname(filePath)); } // Convert to kebab-case const key = baseName .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/[\s_]+/g, '-') .toLowerCase(); return `comp-${key}`; }; /* ────────────────────────────────────────── plugin ──────────────────────── */ /** * Autonomous Babel plugin that extracts content and transforms JSX to use useIntlayer. * * This plugin: * 1. Scans files for extractable text (JSX text, attributes) * 2. Auto-injects useIntlayer import and hook call * 3. Reports extracted content via onExtract callback (for the compiler to write dictionaries) * 4. Replaces extractable strings with content references * * ## Input * ```tsx * export const MyComponent = () => { * return <div>Hello World</div>; * }; * ``` * * ## Output * ```tsx * import { useIntlayer } from 'react-intlayer'; * * export const MyComponent = () => { * const content = useIntlayer('comp-my-component'); * return <div>{content.helloWorld}</div>; * }; * ``` * * ## When useIntlayer is already present * * If the component already has a `content` variable from an existing `useIntlayer` call, * the plugin will use `_compContent` to avoid naming conflicts: * * ### Input * ```tsx * export const Page = () => { * const content = useIntlayer('page'); * return <div>{content.title} - Hello World</div>; * }; * ``` * * ### Output * ```tsx * export const Page = () => { * const _compContent = useIntlayer('comp-page'); * const content = useIntlayer('page'); * return <div>{content.title} - {_compContent.helloWorld}</div>; * }; * ``` * * The extracted content is reported via the `onExtract` callback, allowing the * compiler to write the dictionary to disk separately: * ```json * // my-component.content.json (written by compiler) * { * "key": "comp-my-component", * "content": { * "helloWorld": { "nodeType": "translation", "translation": { "en": "Hello World" } } * } * } * ``` */ export const intlayerExtractBabelPlugin = (babel: { types: typeof BabelTypes; }): PluginObj<State> => { const { types: t } = babel; return { name: 'babel-plugin-intlayer-extract', pre() { this._extractedContent = {}; this._existingKeys = new Set(); this._functionsWithExtractedContent = new Set(); this._isIncluded = true; this._hasJSX = false; this._hasUseIntlayerImport = false; this._useIntlayerLocalName = 'useIntlayer'; this._hasGetIntlayerImport = false; this._getIntlayerLocalName = 'getIntlayer'; this._contentVarName = 'content'; // Will be updated in Program.enter if 'content' is already used const filename = this.file.opts.filename; // If filesList is provided, check if current file is included if (this.opts.filesList && filename) { // Normalize paths for comparison (handle potential path separator issues) const normalizedFilename = filename.replace(/\\/g, '/'); const isIncluded = this.opts.filesList.some((f) => { const normalizedF = f.replace(/\\/g, '/'); return normalizedF === normalizedFilename; }); if (!isIncluded) { this._isIncluded = false; return; } } // Extract dictionary key from filename if (filename) { this._dictionaryKey = extractDictionaryKeyFromPath(filename); } }, visitor: { /* Check if useIntlayer is already imported */ ImportDeclaration(path, state) { if (!state._isIncluded) return; for (const spec of path.node.specifiers) { if (!t.isImportSpecifier(spec)) continue; const importedName = t.isIdentifier(spec.imported) ? spec.imported.name : (spec.imported as BabelTypes.StringLiteral).value; if (importedName === 'useIntlayer') { state._hasUseIntlayerImport = true; state._useIntlayerLocalName = spec.local.name; } if (importedName === 'getIntlayer') { state._hasGetIntlayerImport = true; state._getIntlayerLocalName = spec.local.name; } } }, /* Detect JSX elements to know this is a component file */ JSXElement(_path, state) { if (!state._isIncluded) return; state._hasJSX = true; }, /* Extract JSX text content */ JSXText(path, state) { if (!state._isIncluded) return; const text = path.node.value; const shouldExtract = state.opts.shouldExtract ?? defaultShouldExtract; if (shouldExtract(text)) { const key = generateKey(text, state._existingKeys!); state._existingKeys!.add(key); // Collect extracted content state._extractedContent![key] = text.replace(/\s+/g, ' ').trim(); // Track which function has extracted content const funcParent = path.getFunctionParent(); if (funcParent?.node.start != null) { state._functionsWithExtractedContent!.add(funcParent.node.start); } // Replace with {content.key} or {_compContent.key} path.replaceWith( t.jsxExpressionContainer( t.memberExpression( t.identifier(state._contentVarName!), t.identifier(key), false ) ) ); } }, /* Extract JSX attributes */ JSXAttribute(path, state) { if (!state._isIncluded) return; const name = path.node.name; if (!t.isJSXIdentifier(name)) return; const attrName = name.name; if (!ATTRIBUTES_TO_EXTRACT.includes(attrName)) return; const value = path.node.value; // Handle both direct StringLiteral and JSXExpressionContainer with StringLiteral // Case 1: attr="value" -> value is StringLiteral // Case 2: attr={"value"} -> value is JSXExpressionContainer containing StringLiteral let text: string | null = null; if (t.isStringLiteral(value)) { text = value.value; } else if ( t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression) ) { text = value.expression.value; } if (text === null) return; const shouldExtract = state.opts.shouldExtract ?? defaultShouldExtract; if (shouldExtract(text)) { const key = generateKey(text, state._existingKeys!); state._existingKeys!.add(key); // Collect extracted content state._extractedContent![key] = text.trim(); // Track which function has extracted content const funcParent = path.getFunctionParent(); if (funcParent?.node.start != null) { state._functionsWithExtractedContent!.add(funcParent.node.start); } // Replace with {content.key.value} or {_compContent.key.value} path.node.value = t.jsxExpressionContainer( t.memberExpression( t.memberExpression( t.identifier(state._contentVarName!), t.identifier(key), false ), t.identifier('value'), false ) ); } }, /* Extract String Literals in code (variables, props, etc.) */ StringLiteral(path, state) { if (!state._isIncluded) return; if (path.parentPath.isJSXAttribute()) return; // Already handled if (path.parentPath.isImportDeclaration()) return; if (path.parentPath.isExportDeclaration()) return; if (path.parentPath.isImportSpecifier()) return; // Check if it is a key in an object property if (path.parentPath.isObjectProperty() && path.key === 'key') return; // Check if it is a call expression to console or useIntlayer if (path.parentPath.isCallExpression()) { const callee = path.parentPath.node.callee; // Check for console.log/error/etc if ( t.isMemberExpression(callee) && t.isIdentifier(callee.object) && callee.object.name === 'console' ) { return; } // Check for useIntlayer('key') if ( t.isIdentifier(callee) && callee.name === state._useIntlayerLocalName ) { return; } // Check for getIntlayer('key') if ( t.isIdentifier(callee) && callee.name === state._getIntlayerLocalName ) { return; } // Check for dynamic import import() if (callee.type === 'Import') return; // Check for require() if (t.isIdentifier(callee) && callee.name === 'require') return; } const text = path.node.value; const shouldExtract = state.opts.shouldExtract ?? defaultShouldExtract; if (shouldExtract(text)) { const key = generateKey(text, state._existingKeys!); state._existingKeys!.add(key); // Collect extracted content state._extractedContent![key] = text.trim(); // Track which function has extracted content const funcParent = path.getFunctionParent(); if (funcParent?.node.start != null) { state._functionsWithExtractedContent!.add(funcParent.node.start); } // Replace with content.key or _compContent.key path.replaceWith( t.memberExpression( t.identifier(state._contentVarName!), t.identifier(key), false ) ); } }, /* Inject useIntlayer hook at program exit */ Program: { enter(programPath, state) { if (!state._isIncluded) return; // Check if 'content' variable is already used in any function // If so, we'll use '_compContent' to avoid conflicts let contentVarUsed = false; programPath.traverse({ VariableDeclarator(varPath) { if ( t.isIdentifier(varPath.node.id) && varPath.node.id.name === 'content' ) { contentVarUsed = true; } }, }); state._contentVarName = contentVarUsed ? '_compContent' : 'content'; }, exit(programPath, state) { if (!state._isIncluded) return; const extractedKeys = Object.keys(state._extractedContent!); const hasExtractedContent = extractedKeys.length > 0; // If no content was extracted, skip - don't inject useIntlayer for files with no extractable text if (!hasExtractedContent) return; // Only process JSX files (React components) if (!state._hasJSX) return; const defaultLocale = state.opts.defaultLocale; const packageName = state.opts.packageName; // Call the onExtract callback with extracted content // This will update the dictionary, adding new keys and removing unused ones if ( state.opts.onExtract && state._dictionaryKey && hasExtractedContent ) { state.opts.onExtract({ dictionaryKey: state._dictionaryKey, filePath: state.file.opts.filename!, content: { ...state._extractedContent! }, locale: defaultLocale!, }); } // Track what we need to inject let needsUseIntlayer = false; let needsGetIntlayer = false; // Now inject hooks only into functions that have extracted content const functionsWithContent = state._functionsWithExtractedContent!; programPath.traverse({ // Handle function declarations FunctionDeclaration(funcPath) { // Only inject if this function has extracted content if ( funcPath.node.start != null && functionsWithContent.has(funcPath.node.start) ) { const type = injectHookIntoFunction(funcPath, state, t); if (type === 'hook') needsUseIntlayer = true; if (type === 'core') needsGetIntlayer = true; } }, // Handle arrow functions and function expressions in variable declarations VariableDeclarator(varPath) { const init = varPath.node.init; if ( t.isArrowFunctionExpression(init) || t.isFunctionExpression(init) ) { // Only inject if this function has extracted content if ( init.start != null && functionsWithContent.has(init.start) ) { const type = injectHookIntoArrowOrExpression( varPath as NodePath<BabelTypes.VariableDeclarator>, init, state, t ); if (type === 'hook') needsUseIntlayer = true; if (type === 'core') needsGetIntlayer = true; } } }, }); // Add imports if needed if (needsUseIntlayer || needsGetIntlayer) { const bodyPaths = programPath.get( 'body' ) as NodePath<BabelTypes.Statement>[]; // Find the best position for import (after directives but before other imports) let importInsertPos = 0; for (const stmtPath of bodyPaths) { const stmt = stmtPath.node; if ( t.isExpressionStatement(stmt) && t.isStringLiteral(stmt.expression) ) { importInsertPos += 1; continue; } break; } // Inject useIntlayer import if (needsUseIntlayer && !state._hasUseIntlayerImport) { const importDeclaration = t.importDeclaration( [ t.importSpecifier( t.identifier('useIntlayer'), t.identifier('useIntlayer') ), ], t.stringLiteral(packageName!) ); programPath.node.body.splice( importInsertPos, 0, importDeclaration ); // adjust position for next import importInsertPos++; } // Inject getIntlayer import if (needsGetIntlayer && !state._hasGetIntlayerImport) { const importDeclaration = t.importDeclaration( [ t.importSpecifier( t.identifier('getIntlayer'), t.identifier('getIntlayer') ), ], t.stringLiteral(packageName!) ); programPath.node.body.splice( importInsertPos, 0, importDeclaration ); } } }, }, }, }; }; /** * Inject useIntlayer hook into a function declaration * Returns 'hook' if useIntlayer was injected (or needed), 'core' if getIntlayer was injected, or null. */ const injectHookIntoFunction = ( funcPath: NodePath<BabelTypes.FunctionDeclaration>, state: State, t: typeof BabelTypes ): 'hook' | 'core' | null => { const body = funcPath.node.body; if (!t.isBlockStatement(body)) return null; // Check if this function returns JSX let returnsJSX = false; funcPath.traverse({ ReturnStatement(returnPath) { const arg = returnPath.node.argument; if (t.isJSXElement(arg) || t.isJSXFragment(arg)) { returnsJSX = true; } }, }); const contentVarName = state._contentVarName!; if (returnsJSX) { // Inject useIntlayer // Check if hook with this specific variable name is already injected const hasHook = body.body.some( (stmt) => t.isVariableDeclaration(stmt) && stmt.declarations.some( (decl) => t.isIdentifier(decl.id) && decl.id.name === contentVarName && t.isCallExpression(decl.init) && t.isIdentifier(decl.init.callee) && decl.init.callee.name === state._useIntlayerLocalName ) ); if (hasHook) return 'hook'; // Inject: const content = useIntlayer('dictionary-key'); const hookCall = t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(contentVarName), t.callExpression(t.identifier(state._useIntlayerLocalName!), [ t.stringLiteral(state._dictionaryKey!), ]) ), ]); body.body.unshift(hookCall); return 'hook'; } else { // Inject getIntlayer // Check if getIntlayer call with this variable name is already injected const hasCall = body.body.some( (stmt) => t.isVariableDeclaration(stmt) && stmt.declarations.some( (decl) => t.isIdentifier(decl.id) && decl.id.name === contentVarName && t.isCallExpression(decl.init) && t.isIdentifier(decl.init.callee) && decl.init.callee.name === state._getIntlayerLocalName ) ); if (hasCall) return 'core'; // Inject: const content = getIntlayer('dictionary-key'); const call = t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(contentVarName), t.callExpression(t.identifier(state._getIntlayerLocalName!), [ t.stringLiteral(state._dictionaryKey!), ]) ), ]); body.body.unshift(call); return 'core'; } }; /** * Inject useIntlayer hook into an arrow function or function expression */ const injectHookIntoArrowOrExpression = ( varPath: NodePath<BabelTypes.VariableDeclarator>, init: BabelTypes.ArrowFunctionExpression | BabelTypes.FunctionExpression, state: State, t: typeof BabelTypes ): 'hook' | 'core' | null => { const body = init.body; const contentVarName = state._contentVarName!; // If the body is JSX directly (implicit return), wrap it in a block if (t.isJSXElement(body) || t.isJSXFragment(body)) { // Transform: () => <div>...</div> // To: () => { const content = useIntlayer('key'); return <div>...</div>; } const hookCall = t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(contentVarName), t.callExpression(t.identifier(state._useIntlayerLocalName!), [ t.stringLiteral(state._dictionaryKey!), ]) ), ]); const returnStmt = t.returnStatement(body); init.body = t.blockStatement([hookCall, returnStmt]); return 'hook'; } if (!t.isBlockStatement(body)) { // Transform: () => "string" // To: () => { const content = getIntlayer('key'); return "string"; } const call = t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(contentVarName), t.callExpression(t.identifier(state._getIntlayerLocalName!), [ t.stringLiteral(state._dictionaryKey!), ]) ), ]); const returnStmt = t.returnStatement(body); init.body = t.blockStatement([call, returnStmt]); return 'core'; } // Check if this function returns JSX let returnsJSX = false; varPath.traverse({ ReturnStatement(returnPath) { const arg = returnPath.node.argument; if (t.isJSXElement(arg) || t.isJSXFragment(arg)) { returnsJSX = true; } }, }); if (returnsJSX) { // Inject useIntlayer const hasHook = body.body.some( (stmt) => t.isVariableDeclaration(stmt) && stmt.declarations.some( (decl) => t.isIdentifier(decl.id) && decl.id.name === contentVarName && t.isCallExpression(decl.init) && t.isIdentifier(decl.init.callee) && decl.init.callee.name === state._useIntlayerLocalName ) ); if (hasHook) return 'hook'; const hookCall = t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(contentVarName), t.callExpression(t.identifier(state._useIntlayerLocalName!), [ t.stringLiteral(state._dictionaryKey!), ]) ), ]); body.body.unshift(hookCall); return 'hook'; } else { // Inject getIntlayer const hasCall = body.body.some( (stmt) => t.isVariableDeclaration(stmt) && stmt.declarations.some( (decl) => t.isIdentifier(decl.id) && decl.id.name === contentVarName && t.isCallExpression(decl.init) && t.isIdentifier(decl.init.callee) && decl.init.callee.name === state._getIntlayerLocalName ) ); if (hasCall) return 'core'; const call = t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(contentVarName), t.callExpression(t.identifier(state._getIntlayerLocalName!), [ t.stringLiteral(state._dictionaryKey!), ]) ), ]); body.body.unshift(call); return 'core'; } };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server