Skip to main content
Glama
vue-intlayer-extract.ts12 kB
import { basename, dirname, extname } from 'node:path'; /* ────────────────────────────────────────── constants ───────────────────── */ /** * Attributes that should be extracted for localization */ export const ATTRIBUTES_TO_EXTRACT = [ 'title', 'placeholder', 'alt', 'aria-label', 'label', ]; /* ────────────────────────────────────────── types ───────────────────────── */ export 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 extraction plugins */ export type ExtractPluginOptions = { /** * The default locale for the extracted content * @default 'en' */ defaultLocale?: string; /** * The package to import useIntlayer from * @default 'vue-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; }; /* ────────────────────────────────────────── helpers ─────────────────────── */ /** * Default function to determine if a string should be extracted */ export const defaultShouldExtract = (text: string): boolean => { const trimmed = text.trim(); if (!trimmed) return false; // Must contain at least one space (likely a sentence/phrase) if (!trimmed.includes(' ')) return false; // Must start with a capital letter if (!/^[A-Z]/.test(trimmed)) return false; // Filter out template logic identifiers if (trimmed.startsWith('{') || trimmed.startsWith('v-')) return false; return true; }; /** * Generate a unique key from text */ export const generateKey = ( text: string, existingKeys: Set<string> ): string => { const maxWords = 5; let key = text .replace(/\s+/g, ' ') .replace(/_+/g, ' ') .replace(/-+/g, ' ') .replace(/[^a-zA-Z0-9 ]/g, '') .trim() .split(' ') .filter(Boolean) .slice(0, maxWords) .map((word, index) => index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() ) .join(''); if (!key) key = 'content'; if (existingKeys.has(key)) { let i = 1; while (existingKeys.has(`${key}${i}`)) i++; key = `${key}${i}`; } return key; }; /** * Extract dictionary key from file path */ export 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}`; }; /** * Check if a file should be processed based on filesList */ export const shouldProcessFile = ( filename: string | undefined, filesList?: string[] ): boolean => { if (!filename) return false; if (!filesList || filesList.length === 0) return true; // Normalize paths for comparison (handle potential path separator issues) const normalizedFilename = filename.replace(/\\/g, '/'); return filesList.some((f) => { const normalizedF = f.replace(/\\/g, '/'); return normalizedF === normalizedFilename; }); }; /* ────────────────────────────────────────── Vue types ───────────────────── */ type VueParseResult = { descriptor: { template?: { ast: VueAstNode; loc: { start: { offset: number }; end: { offset: number } }; }; script?: { content: string; loc: { start: { offset: number }; end: { offset: number } }; }; scriptSetup?: { content: string; loc: { start: { offset: number }; end: { offset: number } }; }; }; }; type VueAstNode = { type: number; content?: string; children?: VueAstNode[]; props?: VueAstProp[]; loc: { start: { offset: number }; end: { offset: number } }; }; type VueAstProp = { type: number; name: string; value?: { content: string }; loc: { start: { offset: number }; end: { offset: number } }; }; // Vue AST NodeTypes const NODE_TYPES = { TEXT: 2, ELEMENT: 1, ATTRIBUTE: 6, }; // MagicString type for dynamic import type MagicStringType = { overwrite: (start: number, end: number, content: string) => void; appendLeft: (index: number, content: string) => void; prepend: (content: string) => void; toString: () => string; generateMap: (options: { source: string; includeContent: boolean; }) => unknown; }; /* ────────────────────────────────────────── plugin ──────────────────────── */ /** * Vue extraction plugin that extracts content and transforms Vue SFC to use useIntlayer. * * This plugin: * 1. Scans Vue SFC files for extractable text (template text, attributes) * 2. Auto-injects useIntlayer import and composable call * 3. Reports extracted content via onExtract callback (for the compiler to write dictionaries) * 4. Replaces extractable strings with content references * * ## Input * ```vue * <template> * <div>Hello World</div> * </template> * ``` * * ## Output * ```vue * <script setup> * import { useIntlayer } from 'vue-intlayer'; * const content = useIntlayer('hello-world'); * </script> * <template> * <div>{{ content.helloWorld }}</div> * </template> * ``` */ export const intlayerVueExtract = async ( code: string, filename: string, options: ExtractPluginOptions = {} ): Promise<{ code: string; map?: unknown; extracted: boolean } | null> => { const { defaultLocale = 'en', packageName = 'vue-intlayer', filesList, shouldExtract = defaultShouldExtract, onExtract, } = options; // Check if file should be processed if (!shouldProcessFile(filename, filesList)) { return null; } // Skip non-Vue files if (!filename.endsWith('.vue')) { return null; } // Dynamic imports for dependencies (peer dependencies) let parseVue: (code: string) => VueParseResult; let MagicString: new (code: string) => MagicStringType; try { const vueSfc = await import('@vue/compiler-sfc'); // Type assertion needed because Vue's SFCParseResult uses `null` for optional properties // while our VueParseResult uses `undefined` (optional). This is safe since we check // for truthy values before accessing template/script properties. parseVue = vueSfc.parse as unknown as (code: string) => VueParseResult; } catch { console.warn( 'Vue extraction: @vue/compiler-sfc not found. Install it to enable Vue content extraction.' ); return null; } try { const magicStringModule = await import('magic-string'); MagicString = magicStringModule.default; } catch { console.warn( 'Vue extraction: magic-string not found. Install it to enable Vue content extraction.' ); return null; } const sfc = parseVue(code); const magic = new MagicString(code); const extractedContent: ExtractedContent = {}; const existingKeys = new Set<string>(); const dictionaryKey = extractDictionaryKeyFromPath(filename); // Walk the template AST if (sfc.descriptor.template) { const walkVueAst = (node: VueAstNode) => { if (node.type === NODE_TYPES.TEXT) { // Text node const text = node.content ?? ''; if (shouldExtract(text)) { const key = generateKey(text, existingKeys); existingKeys.add(key); extractedContent[key] = text.replace(/\s+/g, ' ').trim(); magic.overwrite( node.loc.start.offset, node.loc.end.offset, `{{ content.${key} }}` ); } } else if (node.type === NODE_TYPES.ELEMENT) { // Element node - check attributes node.props?.forEach((prop) => { if ( prop.type === NODE_TYPES.ATTRIBUTE && ATTRIBUTES_TO_EXTRACT.includes(prop.name) && prop.value ) { const text = prop.value.content; if (shouldExtract(text)) { const key = generateKey(text, existingKeys); existingKeys.add(key); extractedContent[key] = text.trim(); magic.overwrite( prop.loc.start.offset, prop.loc.end.offset, `:${prop.name}="content.${key}.value"` ); } } }); } // Recurse into children if (node.children) { node.children.forEach(walkVueAst); } }; walkVueAst(sfc.descriptor.template.ast); } // If nothing was extracted, return null if (Object.keys(extractedContent).length === 0) { return null; } // Get script content for checking existing imports/declarations const scriptContent = sfc.descriptor.scriptSetup?.content ?? sfc.descriptor.script?.content ?? ''; // Check if useIntlayer is already imported const hasUseIntlayerImport = /import\s*{[^}]*useIntlayer[^}]*}\s*from\s*['"][^'"]+['"]/.test( scriptContent ) || /import\s+useIntlayer\s+from\s*['"][^'"]+['"]/.test(scriptContent); // Check if content variable is already declared with useIntlayer const hasContentDeclaration = /const\s+content\s*=\s*useIntlayer\s*\(/.test( scriptContent ); // Skip injection if already using useIntlayer if (hasUseIntlayerImport && hasContentDeclaration) { return null; } // Prepare injection statements (only what's missing) const importStmt = hasUseIntlayerImport ? '' : `import { useIntlayer } from '${packageName}';`; const contentDecl = hasContentDeclaration ? '' : `const content = useIntlayer('${dictionaryKey}');`; // Build injection string const injectionParts = [importStmt, contentDecl].filter(Boolean); if (injectionParts.length === 0) { return null; } const injection = `\n${injectionParts.join('\n')}\n`; if (sfc.descriptor.scriptSetup) { // Insert at the beginning of script setup content magic.appendLeft(sfc.descriptor.scriptSetup.loc.start.offset, injection); } else if (sfc.descriptor.script) { // Insert at the beginning of script content magic.appendLeft(sfc.descriptor.script.loc.start.offset, injection); } else { // No script block, create one magic.prepend(`<script setup>\n${importStmt}\n${contentDecl}\n</script>\n`); } // Call the onExtract callback with extracted content if (onExtract) { const result: ExtractResult = { dictionaryKey, filePath: filename, content: { ...extractedContent }, locale: defaultLocale, }; onExtract(result); } return { code: magic.toString(), map: magic.generateMap({ source: filename, includeContent: true }), extracted: true, }; };

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