Skip to main content
Glama
svelte-intlayer-extract.ts13.1 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 'svelte-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; }); }; /* ────────────────────────────────────────── MagicString type ────────────── */ // 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 ──────────────────────── */ /** * Svelte extraction plugin that extracts content and transforms Svelte components to use useIntlayer. * * This plugin: * 1. Scans Svelte files for extractable text (template text, attributes) * 2. Auto-injects useIntlayer import and store binding * 3. Reports extracted content via onExtract callback (for the compiler to write dictionaries) * 4. Replaces extractable strings with content references using Svelte's reactive `$` prefix * * ## Input * ```svelte * <h1>Hello World</h1> * <p>Welcome to our app</p> * ``` * * ## Output * ```svelte * <script> * import { useIntlayer } from 'svelte-intlayer'; * const content = useIntlayer('hello-world'); * </script> * <h1>{$content.helloWorld}</h1> * <p>{$content.welcomeToOurApp}</p> * ``` * * Note: Svelte uses reactive stores with `$` prefix for automatic subscription. * The `useIntlayer` composable returns a Svelte store that can be accessed reactively. */ export const intlayerSvelteExtract = async ( code: string, filename: string, options: ExtractPluginOptions = {} ): Promise<{ code: string; map?: unknown; extracted: boolean } | null> => { const { defaultLocale = 'en', packageName = 'svelte-intlayer', filesList, shouldExtract = defaultShouldExtract, onExtract, } = options; // Check if file should be processed if (!shouldProcessFile(filename, filesList)) { return null; } // Skip non-Svelte files if (!filename.endsWith('.svelte')) { return null; } // Dynamic import for MagicString let MagicString: new (code: string) => MagicStringType; try { const magicStringModule = await import('magic-string'); MagicString = magicStringModule.default; } catch { console.warn( 'Svelte extraction: magic-string not found. Install it to enable Svelte content extraction.' ); return null; } const magic = new MagicString(code); const extractedContent: ExtractedContent = {}; const existingKeys = new Set<string>(); const dictionaryKey = extractDictionaryKeyFromPath(filename); // Collect all replacements first, then apply them in reverse order // This prevents MagicString "chunk already edited" errors type Replacement = { start: number; end: number; replacement: string; key: string; value: string; }; const replacements: Replacement[] = []; // Extract template content (everything outside <script> and <style> tags) // This regex-based approach works with both TypeScript and JavaScript files const scriptBlockRegex = /<script[^>]*>[\s\S]*?<\/script>/gi; const styleBlockRegex = /<style[^>]*>[\s\S]*?<\/style>/gi; // Get ranges of script and style blocks to skip const skipRanges: Array<{ start: number; end: number }> = []; // Find all script blocks const scriptMatches = code.matchAll(scriptBlockRegex); for (const match of scriptMatches) { if (match.index !== undefined) { skipRanges.push({ start: match.index, end: match.index + match[0].length, }); } } // Find all style blocks const styleMatches = code.matchAll(styleBlockRegex); for (const match of styleMatches) { if (match.index !== undefined) { skipRanges.push({ start: match.index, end: match.index + match[0].length, }); } } // Sort ranges by start position skipRanges.sort((a, b) => a.start - b.start); // Function to check if a position is within a skip range const isInSkipRange = (pos: number): boolean => { return skipRanges.some((range) => pos >= range.start && pos < range.end); }; // Extract text content between HTML tags (but not inside script/style) // Match text that's between > and < (tag content) const textContentRegex = />([^<]+)</g; const textMatches = code.matchAll(textContentRegex); for (const match of textMatches) { if (match.index === undefined) continue; const textStart = match.index + 1; // Skip the > const text = match[1]; const textEnd = textStart + text.length; // Skip if inside script or style block if (isInSkipRange(textStart)) { continue; } if (shouldExtract(text)) { const key = generateKey(text, existingKeys); existingKeys.add(key); const normalizedValue = text.replace(/\s+/g, ' ').trim(); // Collect replacement instead of applying immediately replacements.push({ start: textStart, end: textEnd, replacement: `{$content.${key}}`, key, value: normalizedValue, }); } } // Extract localizable attributes (title, placeholder, alt, aria-label, label) for (const attrName of ATTRIBUTES_TO_EXTRACT) { // Match attribute="value" or attribute='value' const attrRegex = new RegExp(`(${attrName})=["']([^"']+)["']`, 'gi'); const attrMatches = code.matchAll(attrRegex); for (const match of attrMatches) { if (match.index === undefined) continue; const attrStart = match.index; const attrEnd = attrStart + match[0].length; const text = match[2]; // Skip if inside script or style block if (isInSkipRange(attrStart)) { continue; } if (shouldExtract(text)) { const key = generateKey(text, existingKeys); existingKeys.add(key); // Collect replacement instead of applying immediately replacements.push({ start: attrStart, end: attrEnd, replacement: `${attrName}={$content.${key}.value}`, key, value: text.trim(), }); } } } // Sort replacements by start position in REVERSE order (end to start) // This ensures earlier edits don't affect the positions of later edits replacements.sort((a, b) => b.start - a.start); // Apply all replacements and collect extracted content for (const { start, end, replacement, key, value } of replacements) { magic.overwrite(start, end, replacement); extractedContent[key] = value; } // If nothing was extracted, return null if (Object.keys(extractedContent).length === 0) { return null; } // Find existing script tag const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/; const scriptMatch = scriptRegex.exec(code); const scriptContent = scriptMatch ? scriptMatch[1] : ''; // 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 (scriptMatch) { // Insert at the beginning of script content const scriptContentStart = scriptMatch.index + scriptMatch[0].indexOf('>') + 1; magic.appendLeft(scriptContentStart, injection); } else { // No script block, create one magic.prepend(`<script>\n ${importStmt}\n ${contentDecl}\n</script>\n\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