export const MAX_LINES = 5; export const DEFAULT_LINE_CHARS = 200; export const LINE_CENTS_PER_PERSON = 3; const KEY_MAX = 40; const LINE_FIELDS = [ "key", "label", "instructions", "prompt", "fallback", "fallbackFrom", "maxChars", ] as const; export type LineVariable = { key: string; label: string; prompt: string; fallback: string; fallbackFrom?: string; maxChars?: number; }; export type LineTemplate = { variables: LineVariable[]; context: { sells: string; offer?: string }; }; export class LinesFileError extends Error {} export function normalizedLineKey(key: string): string { return key .replace(/^(crm|person):/i, "") .toLowerCase() .replace(/[^a-z0-9]/g, ""); } export function keyFromLabel(label: string): string { const key = label .normalize("NFKD") .replace(/\p{M}+/gu, "") .split(/[^a-zA-Z0-9]+/) .filter(Boolean) .map((word, index) => index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), ) .join("") .replace(/^[0-9]+/, "") .slice(0, KEY_MAX); return key || "line"; } function uniqueKey(label: string, used: Set): string { const base = keyFromLabel(label); if (!used.has(normalizedLineKey(base))) return base; for (let suffix = 2; ; suffix += 1) { const tail = String(suffix); const key = `${base.slice(0, KEY_MAX - tail.length)}${tail}`; if (!used.has(normalizedLineKey(key))) return key; } } function labelKey(label: string): string { return label.trim().replace(/\s+/g, " ").toLowerCase(); } function text( row: Record, field: string, where: string, ): string | undefined { const value = row[field]; if (value === undefined || value === null) return undefined; if (typeof value !== "string") throw new LinesFileError(`${where}.${field} must be text.`); return value.trim() || undefined; } function record(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } export function linesTemplateFromFile( parsed: unknown, options: { existing?: LineTemplate | null; suggestedSells?: string; source?: string; } = {}, ): LineTemplate { const source = options.source ?? "The lines file"; const root = record(parsed); const rows = Array.isArray(parsed) ? parsed : Array.isArray(root?.lines) ? (root?.lines as unknown[]) : Array.isArray(root?.variables) ? (root?.variables as unknown[]) : null; if (!rows) throw new LinesFileError( `${source} must be {"sells": "...", "lines": [{"label": "...", "instructions": "...", "fallback": "..."}]} or a JSON array of lines.`, ); if (rows.length === 0) throw new LinesFileError( `${source} has no lines. To turn personalized lines off, run dench campaign personalize clear .`, ); if (rows.length > MAX_LINES) throw new LinesFileError( `${source} has ${rows.length} lines; a campaign can have up to ${MAX_LINES}.`, ); const context = record(root?.context); const sells = (root ? text(root, "sells", "file") : undefined) ?? (context ? text(context, "sells", "context") : undefined) ?? options.existing?.context.sells ?? options.suggestedSells?.trim(); if (!sells) throw new LinesFileError( `${source} needs "sells": one or two sentences on what your company sells, so the writer knows what each line should lead to.`, ); const offer = (root ? text(root, "offer", "file") : undefined) ?? (context ? text(context, "offer", "context") : undefined); const existingByLabel = new Map( (options.existing?.variables ?? []).map((variable) => [ labelKey(variable.label), variable.key, ]), ); const prepared = rows.map((value, index) => { const where = `lines[${index}]`; const row = record(value); if (!row) throw new LinesFileError( `${where} must be an object like {"label": "Company hook", "instructions": "One sentence about ...", "fallback": "Your team caught my eye."}.`, ); const unknown = Object.keys(row).filter( (key) => !(LINE_FIELDS as readonly string[]).includes(key), ); if (unknown.length) throw new LinesFileError( `${where} has ${unknown.join(", ")}, which a line does not have. Use: label, instructions, fallback, and optionally key, maxChars, fallbackFrom.`, ); const label = text(row, "label", where); const prompt = text(row, "instructions", where) ?? text(row, "prompt", where); const fallback = text(row, "fallback", where); if (!label) throw new LinesFileError(`${where} needs a "label".`); if (!prompt) throw new LinesFileError( `${where} ("${label}") needs "instructions": what the writer should say.`, ); if (!fallback) throw new LinesFileError( `${where} ("${label}") needs a "fallback": the line sent when there is nothing specific to say.`, ); const maxChars = row.maxChars; if ( maxChars !== undefined && (typeof maxChars !== "number" || !Number.isInteger(maxChars)) ) throw new LinesFileError( `${where} ("${label}"): "maxChars" must be a whole number of characters.`, ); return { key: text(row, "key", where), label, prompt, fallback, fallbackFrom: text(row, "fallbackFrom", where), maxChars: maxChars as number | undefined, }; }); const seenLabels = new Map(); prepared.forEach((line, index) => { const earlier = seenLabels.get(labelKey(line.label)); if (earlier !== undefined) throw new LinesFileError( `lines[${index}] has the same label as lines[${earlier}] ("${line.label}"); each line needs its own name.`, ); seenLabels.set(labelKey(line.label), index); }); const used = new Set(); for (const line of prepared) { const kept = line.key ?? existingByLabel.get(labelKey(line.label)); if (kept) used.add(normalizedLineKey(kept)); } const keyOwners = new Map(); const variables = prepared.map((line, index) => { let key = line.key ?? existingByLabel.get(labelKey(line.label)); if (!key) { key = uniqueKey(line.label, used); used.add(normalizedLineKey(key)); } const owner = keyOwners.get(normalizedLineKey(key)); if (owner !== undefined) throw new LinesFileError( `lines[${index}] and lines[${owner}] both use the key "${key}"; give each line its own key.`, ); keyOwners.set(normalizedLineKey(key), index); return { key, label: line.label, prompt: line.prompt, fallback: line.fallback, ...(line.fallbackFrom ? { fallbackFrom: line.fallbackFrom } : {}), ...(line.maxChars !== undefined ? { maxChars: line.maxChars } : {}), }; }); return { variables, context: { sells, ...(offer ? { offer } : {}) } }; }