/ src / utils / spintax.js
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  }