155 lines
4.2 KiB
TypeScript
155 lines
4.2 KiB
TypeScript
// Spintax Resolver - Handles {A|B|C} syntax
|
|
import crypto from 'crypto';
|
|
|
|
export interface SpintaxChoice {
|
|
path: string;
|
|
chosen: string;
|
|
allOptions: string[];
|
|
}
|
|
|
|
/**
|
|
* Resolves spintax syntax {A|B|C} to a single choice
|
|
* Tracks which choices were made for uniqueness
|
|
*/
|
|
export class SpintaxResolver {
|
|
private choices: SpintaxChoice[] = [];
|
|
private seed: string;
|
|
|
|
constructor(seed?: string) {
|
|
this.seed = seed || crypto.randomBytes(16).toString('hex');
|
|
}
|
|
|
|
/**
|
|
* Resolve all spintax in text
|
|
*/
|
|
resolve(text: string): string {
|
|
let resolved = text;
|
|
let iteration = 0;
|
|
|
|
// Handle nested spintax with multiple passes
|
|
while (resolved.includes('{') && resolved.includes('}') && iteration < 10) {
|
|
resolved = this.resolvePass(resolved, iteration);
|
|
iteration++;
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
private resolvePass(text: string, iteration: number): string {
|
|
const regex = /\{([^{}]+)\}/g;
|
|
let result = text;
|
|
let match;
|
|
let offset = 0;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
const fullMatch = match[0];
|
|
const options = match[1].split('|');
|
|
|
|
// Deterministic choice based on seed + position
|
|
const choiceIndex = this.getChoiceIndex(match.index + iteration, options.length);
|
|
const chosen = options[choiceIndex].trim();
|
|
|
|
// Track the choice
|
|
this.choices.push({
|
|
path: `pos_${match.index}_iter_${iteration}`,
|
|
chosen,
|
|
allOptions: options
|
|
});
|
|
|
|
// Replace in result
|
|
const beforeMatch = result.substring(0, match.index + offset);
|
|
const afterMatch = result.substring(match.index + offset + fullMatch.length);
|
|
result = beforeMatch + chosen + afterMatch;
|
|
|
|
offset += chosen.length - fullMatch.length;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private getChoiceIndex(position: number, optionsCount: number): number {
|
|
const hash = crypto.createHash('sha256')
|
|
.update(`${this.seed}_${position}`)
|
|
.digest('hex');
|
|
return parseInt(hash.substring(0, 8), 16) % optionsCount;
|
|
}
|
|
|
|
/**
|
|
* Get all choices made during resolution
|
|
*/
|
|
getChoices(): SpintaxChoice[] {
|
|
return this.choices;
|
|
}
|
|
|
|
/**
|
|
* Generate hash of choices for uniqueness checking
|
|
*/
|
|
getChoicesHash(): string {
|
|
const choiceString = this.choices
|
|
.map(c => c.chosen)
|
|
.join('::');
|
|
return crypto.createHash('sha256')
|
|
.update(choiceString)
|
|
.digest('hex')
|
|
.substring(0, 32);
|
|
}
|
|
|
|
/**
|
|
* Reset for new resolution
|
|
*/
|
|
reset(newSeed?: string) {
|
|
this.choices = [];
|
|
if (newSeed) this.seed = newSeed;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expand variables like {{CITY}} in text
|
|
*/
|
|
export function expandVariables(text: string, variables: Record<string, string>): string {
|
|
let result = text;
|
|
|
|
for (const [key, value] of Object.entries(variables)) {
|
|
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
|
result = result.replace(regex, value);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Generate cartesian product of pipe-separated values
|
|
* Example: { CITY: "A|B", STATE: "X|Y" } => 4 combinations
|
|
*/
|
|
export function generateCartesianProduct(
|
|
variables: Record<string, string>
|
|
): Array<Record<string, string>> {
|
|
const keys = Object.keys(variables);
|
|
const values = keys.map(key => variables[key].split('|').map(v => v.trim()));
|
|
|
|
function* cartesian(arrays: string[][]): Generator<string[]> {
|
|
if (arrays.length === 0) {
|
|
yield [];
|
|
return;
|
|
}
|
|
|
|
const [first, ...rest] = arrays;
|
|
for (const value of first) {
|
|
for (const combo of cartesian(rest)) {
|
|
yield [value, ...combo];
|
|
}
|
|
}
|
|
}
|
|
|
|
const results: Array<Record<string, string>> = [];
|
|
for (const combo of cartesian(values)) {
|
|
const obj: Record<string, string> = {};
|
|
keys.forEach((key, i) => {
|
|
obj[key] = combo[i];
|
|
});
|
|
results.push(obj);
|
|
}
|
|
|
|
return results;
|
|
}
|