' + esc(c.headline || '') + '
' + (c.subhead ? '' + esc(c.subhead) + '
' : '') + '' + story + (bullets ? '- ' + bullets + '
' + esc(c.closing || 'Take a look and see what you think.') + '
' + 'See how it works →' + '// Circle Suite — Page Builder. Ports the Branded Voice pattern that works: // the model NEVER writes HTML. It returns labelled plain text; a deterministic // renderer turns that into the page. That buys human-editable output, cheap // re-renders when the design changes, and total XSS control (everything is // escaped; nothing the model emits can become markup). // // One page per position, stored as its source text in the data volume and // re-rendered on every request — same as BV re-renders from `body`. 'use strict'; const fs = require('fs'); const path = require('path'); const suiteAI = require('./suite-ai'); let DATA_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; } function dir() { const d = path.join(DATA_DIR, 'pages'); try { fs.mkdirSync(d, { recursive: true }); } catch (e) {} return d; } function file(id) { return path.join(dir(), String(id).replace(/\D/g, '') + '.json'); } function load(id) { try { return JSON.parse(fs.readFileSync(file(id), 'utf8')); } catch (e) { return null; } } function save(id, rec) { fs.writeFileSync(file(id), JSON.stringify(rec)); return rec; } // Angles reuse the team's existing hook videos + their landing variant. const ANGLES = { pocket: { label: 'Pocket change', video: '/v/rmc-pocket-change-b403fb2f75.mp4', poster: '/v/rmc-pocket-change-poster.jpg' }, two: { label: 'You only need two', video: '/v/rmc-two-people-4102f2d719.mp4', poster: '/v/rmc-two-people-poster.jpg' }, phone: { label: 'Runs from your phone', video: '/v/rmc-phone-32ac648844.mp4', poster: '/v/rmc-phone-poster.jpg' }, graveyard: { label: 'Side-hustle graveyard', video: '/v/rmc-graveyard-290fabee9d.mp4', poster: '/v/rmc-graveyard-poster.jpg' }, overview: { label: 'General overview', video: '/v/rmc-combined-16284edbab.mp4', poster: '/v/rmc-combined-poster.jpg' } }; // ── Generation: labelled sections, parsed deterministically ──────────────── function buildPrompt(input) { const angle = ANGLES[input.angle] ? ANGLES[input.angle].label : 'General overview'; return ( 'Write the copy for a personal invitation page. The person inviting is ' + (input.name || 'a member of our team') + '.\n\n' + 'THEIR ANGLE: ' + angle + '\n' + 'WHO IT IS FOR: ' + (input.audience || 'people they know who are open to something new') + '\n' + 'IN THEIR WORDS, why they are doing this / what they want to say:\n' + (input.story || '(they did not add anything — write something honest and general)') + '\n\n' + 'Return EXACTLY these labelled sections, nothing else:\n' + 'HEADLINE: one line, max 60 characters, plain and human — not a slogan, not clickbait\n' + 'SUBHEAD: one sentence, max 140 characters\n' + 'STORY: two or three short paragraphs in FIRST PERSON as ' + (input.name || 'the member') + ', separated by blank lines. Honest, personal, no hype.\n' + 'BULLETS: exactly three lines, each starting with "- ", each under 90 characters, describing how the team build actually works\n' + 'CLOSING: one or two sentences inviting them to watch the short video and take a look. No pressure.\n\n' + 'Do not write any HTML, markdown headers, links, or emoji. Do not invent earnings or results.' ); } function parseSections(raw) { const out = { headline: '', subhead: '', story: [], bullets: [], closing: '' }; const text = String(raw || '').replace(/\r/g, ''); const grab = function (label, next) { const re = new RegExp(label + ':\\s*([\\s\\S]*?)(?=\\n(?:' + next + '):|$)', 'i'); const m = text.match(re); return m ? m[1].trim() : ''; }; out.headline = grab('HEADLINE', 'SUBHEAD|STORY|BULLETS|CLOSING').split('\n')[0].trim().slice(0, 90); out.subhead = grab('SUBHEAD', 'STORY|BULLETS|CLOSING').split('\n')[0].trim().slice(0, 200); out.story = grab('STORY', 'BULLETS|CLOSING').split(/\n{2,}/).map(function (p) { return p.replace(/\s+/g, ' ').trim(); }).filter(Boolean).slice(0, 4); out.bullets = grab('BULLETS', 'CLOSING').split('\n').map(function (b) { return b.replace(/^[-•*]\s*/, '').trim(); }).filter(Boolean).slice(0, 4); out.closing = grab('CLOSING', 'ZZZZ').replace(/\s+/g, ' ').trim().slice(0, 400); return out; } async function generate(input) { // reuse the Suite engine + compliance system prompt via a bespoke kind const text = await suiteAI.generateRaw(buildPrompt(input)); const parsed = parseSections(text); if (!parsed.headline || !parsed.story.length) throw new Error('The writer came back incomplete — try again.'); return parsed; } // ── Rendering: everything escaped, no model output ever becomes markup ───── function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; }); } function render(rec) { const a = ANGLES[rec.angle] || ANGLES.overview; const link = 'https://rmcircle.team/join/' + rec.id + (rec.angle && rec.angle !== 'overview' ? '?v=' + rec.angle : ''); const who = esc(rec.name || ('Member #' + rec.id)); const c = rec.copy || {}; const story = (c.story || []).map(function (p) { return '
' + esc(p) + '
'; }).join(''); const bullets = (c.bullets || []).map(function (b) { return '' + esc(c.subhead) + '
' : '') + '' + story + (bullets ? '' + esc(c.closing || 'Take a look and see what you think.') + '
' + 'See how it works →' + '