spintax.js
1 /** 2 * Spintax parser and generator 3 * Converts spintax templates like "{this|that} is {good|great}" into unique variations 4 */ 5 6 /** 7 * Parse and spin spintax text to generate a unique variation 8 * @param {string} text - Text with spintax syntax {option1|option2|option3} 9 * @param {number} seed - Optional seed for deterministic results 10 * @returns {string} - Spun text with one random option selected from each group 11 */ 12 export function spin(text, seed = null) { 13 if (!text) return text; 14 15 const rng = seed !== null ? seededRandom(seed) : Math.random; 16 17 // Process nested spintax from innermost to outermost 18 let result = text; 19 const maxIterations = 100; // Prevent infinite loops 20 let iterations = 0; 21 22 while (result.includes('{') && iterations < maxIterations) { 23 // Find innermost spintax group (no nested braces inside) 24 const match = result.match(/\{([^{}]+)\}/); 25 if (!match) break; 26 27 const [fullMatch, options] = match; 28 const choices = options.split('|'); 29 const selected = choices[Math.floor(rng() * choices.length)]; 30 31 // Replace this occurrence only 32 result = result.replace(fullMatch, selected); 33 iterations++; 34 } 35 36 if (iterations >= maxIterations) { 37 console.warn('Spintax parsing exceeded max iterations, possible malformed template'); 38 } 39 40 return result; 41 } 42 43 /** 44 * Generate multiple unique variations from spintax text 45 * @param {string} text - Text with spintax syntax 46 * @param {number} count - Number of variations to generate 47 * @returns {string[]} - Array of unique variations 48 */ 49 export function generateVariations(text, count = 5) { 50 const variations = new Set(); 51 const maxAttempts = count * 10; // Try up to 10x to get unique variations 52 let attempts = 0; 53 54 while (variations.size < count && attempts < maxAttempts) { 55 const variation = spin(text); 56 variations.add(variation); 57 attempts++; 58 } 59 60 return Array.from(variations); 61 } 62 63 /** 64 * Count total possible unique variations in spintax text 65 * @param {string} text - Text with spintax syntax 66 * @returns {number} - Total possible unique combinations 67 */ 68 export function countPossibleVariations(text) { 69 if (!text) return 1; 70 71 let total = 1; 72 const matches = text.matchAll(/\{([^{}]+)\}/g); 73 74 for (const match of matches) { 75 const options = match[1].split('|'); 76 total *= options.length; 77 } 78 79 return total; 80 } 81 82 /** 83 * Validate spintax syntax (balanced braces, valid separators) 84 * @param {string} text - Text to validate 85 * @returns {{valid: boolean, errors: string[]}} 86 */ 87 export function validateSpintax(text) { 88 const errors = []; 89 90 if (!text) { 91 return { valid: true, errors: [] }; 92 } 93 94 // Check balanced braces 95 let depth = 0; 96 let maxDepth = 0; 97 for (let i = 0; i < text.length; i++) { 98 if (text[i] === '{') { 99 depth++; 100 maxDepth = Math.max(maxDepth, depth); 101 } else if (text[i] === '}') { 102 depth--; 103 if (depth < 0) { 104 errors.push(`Unbalanced closing brace at position ${i}`); 105 } 106 } 107 } 108 109 if (depth !== 0) { 110 errors.push(`Unbalanced braces: ${depth > 0 ? 'missing closing' : 'missing opening'}`); 111 } 112 113 // Check for empty options 114 if (text.includes('{|}') || text.includes('||')) { 115 errors.push('Empty spintax option found'); 116 } 117 118 // Check for unclosed groups 119 if (text.includes('{') && !text.includes('}')) { 120 errors.push('Spintax group opened but not closed'); 121 } 122 123 // Warn about deep nesting (may cause performance issues) 124 if (maxDepth > 5) { 125 errors.push(`Deep nesting detected (depth: ${maxDepth}). May cause performance issues.`); 126 } 127 128 return { 129 valid: errors.length === 0, 130 errors, 131 }; 132 } 133 134 /** 135 * Seeded random number generator for deterministic spintax 136 * @param {number} seed - Seed value 137 * @returns {function} - Random function that returns 0-1 138 */ 139 function seededRandom(seed) { 140 let state = seed; 141 return function () { 142 // Simple LCG (Linear Congruential Generator) 143 state = (state * 1664525 + 1013904223) % 4294967296; 144 return state / 4294967296; 145 }; 146 } 147 148 /** 149 * Load and spin a template by ID 150 * @param {string} templateId - Template ID (e.g., 'email_pauljames_01') 151 * @param {object} replacements - Key-value pairs for variable replacement (e.g., {kwd: 'plumbing', firstname: 'Alice'}) 152 * @returns {object} - {subject, body} with spun text 153 */ 154 export async function loadAndSpinTemplate( 155 templateId, 156 replacements = {}, 157 country = null, 158 channel = null 159 ) { 160 const fs = await import('fs/promises'); 161 const path = await import('path'); 162 const { fileURLToPath } = await import('url'); 163 164 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 165 166 // Parse templateId - support formats like "US/email_001" or "email_pauljames_01" 167 let countryCode = country; 168 let cleanId = templateId; 169 170 if (templateId.includes('/')) { 171 const parts = templateId.split('/'); 172 countryCode = parts[0]; 173 cleanId = parts[1]; 174 } 175 176 // Auto-detect channel from template ID if not provided 177 let templateChannel = channel; 178 if (!templateChannel) { 179 templateChannel = cleanId.startsWith('email_') ? 'email' : 'sms'; 180 } 181 182 // Default to US if no country specified 183 if (!countryCode) { 184 countryCode = 'US'; 185 } 186 187 // Load templates from country-specific file 188 const templatesPath = path.join( 189 __dirname, 190 `../../data/templates/${countryCode}/${templateChannel}.json` 191 ); 192 193 let templatesData; 194 try { 195 const templatesJson = await fs.readFile(templatesPath, 'utf-8'); 196 templatesData = JSON.parse(templatesJson); 197 } catch (error) { 198 throw new Error(`Failed to load templates from ${templatesPath}: ${error.message}`); 199 } 200 201 const templates = templatesData.templates || []; 202 const template = templates.find(t => t.id === cleanId || t.id === templateId); 203 204 if (!template) { 205 throw new Error(`Template not found: ${templateId} in ${templatesPath}`); 206 } 207 208 // Resolve [key] and [key|fallback] variables BEFORE spinning spintax. 209 // Critical: [firstname|there] inside a {…} group has a | that the spintax 210 // engine would otherwise treat as an option separator, producing garbled output 211 // like "Hey [firstname" or "there]!" as separate spin options. 212 const varPattern = /\[(\w+)(?:\|([^\]]*))?\]/g; 213 const resolveVars = str => { 214 if (!str) return str; 215 return str.replace(varPattern, (_, key, fallback) => { 216 const val = replacements[key]; // eslint-disable-line security/detect-object-injection 217 if (val !== null && val !== undefined && val !== '') return val; 218 return fallback !== undefined ? fallback : ''; 219 }); 220 }; 221 222 // Resolve variables first, then spin (order matters — see note above) 223 const subject = template.subject_spintax ? spin(resolveVars(template.subject_spintax)) : null; 224 const body = spin(resolveVars(template.body_spintax)); 225 226 return { 227 id: template.id, 228 channel: template.channel, 229 author: template.author, 230 country: template.country, 231 tone: template.tone, 232 approach: template.approach, 233 subject, 234 body, 235 }; 236 } 237 238 /** 239 * Test a spintax template by generating multiple variations 240 * @param {string} text - Spintax text to test 241 * @param {number} count - Number of test variations to generate 242 * @returns {object} - {valid, errors, stats, samples} 243 */ 244 export function testSpintax(text, count = 10) { 245 const validation = validateSpintax(text); 246 const variations = generateVariations(text, count); 247 const possibleVariations = countPossibleVariations(text); 248 249 return { 250 valid: validation.valid, 251 errors: validation.errors, 252 stats: { 253 possibleVariations, 254 uniqueGenerated: variations.length, 255 uniquenessRatio: variations.length / count, 256 }, 257 samples: variations, 258 }; 259 }