// 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.' ); } // Locate each label wherever it appears and slice between them. Deliberately // forgiving: the colon is optional (models drop it — "STORY" alone broke the // first build), markdown emphasis is tolerated, order is taken from the text. function parseSections(raw) { const out = { headline: '', subhead: '', story: [], bullets: [], closing: '' }; const text = String(raw || '').replace(/\r/g, ''); const LABELS = ['HEADLINE', 'SUBHEAD', 'STORY', 'BULLETS', 'CLOSING']; const found = []; LABELS.forEach(function (L) { const m = text.match(new RegExp('(?:^|\\n)[ \\t]*[*#>\\s]*' + L + '[ \\t]*[*#]*[ \\t]*:?[ \\t]*[*#]*[ \\t]*', 'i')); if (m) found.push({ label: L, at: m.index, from: m.index + m[0].length }); }); if (!found.length) return out; found.sort(function (a, b) { return a.at - b.at; }); const seg = {}; found.forEach(function (f, i) { const end = i + 1 < found.length ? found[i + 1].at : text.length; // strip any leftover markdown emphasis the label pattern didn't absorb seg[f.label] = text.slice(f.from, end).replace(/^[*#\s]+/, '').replace(/[*#\s]+$/, '').trim(); }); out.headline = (seg.HEADLINE || '').split('\n')[0].replace(/^["“]|["”]$/g, '').trim().slice(0, 90); out.subhead = (seg.SUBHEAD || '').split('\n')[0].trim().slice(0, 200); out.story = (seg.STORY || '').split(/\n{2,}/).map(function (p) { return p.replace(/\s+/g, ' ').trim(); }).filter(Boolean).slice(0, 4); out.bullets = (seg.BULLETS || '').split('\n').map(function (b) { return b.replace(/^[-•*]\s*/, '').trim(); }).filter(Boolean).slice(0, 4); out.closing = (seg.CLOSING || '').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(b) + '
  • '; }).join(''); return '' + '' + '' + esc(c.headline || 'An invitation') + ' | ' + who + '' + '' + '' + '' + '' + '' + '' + '
    ' + '
    Shared by ' + who + ' · Member #' + esc(rec.id) + '
    ' + '

    ' + esc(c.headline || '') + '

    ' + (c.subhead ? '

    ' + esc(c.subhead) + '

    ' : '') + '
    ' + story + (bullets ? '' : '') + '

    ' + esc(c.closing || 'Take a look and see what you think.') + '

    ' + 'See how it works →' + '
    or scan · ' + esc(link.replace(/^https:\/\//, '')) + '
    ' + '
    Independent team resource shared by an individual member. Participation takes real effort and involves cryptocurrency risk, including risk of total loss. No income is guaranteed. ' + 'Disclaimers · How the contract works
    ' + '
    ' + '' + '' + ''; } module.exports = { init, load, save, generate, render, ANGLES, parseSections };