commit c437f04e0becf195c40d6cf890dba7487183d12a Author: lennyzeltser Date: Wed Jan 14 17:55:56 2026 -0500 Initial commit diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 120000 index 0000000..6100270 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1 @@ +../../CLAUDE.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1514851 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Agents + +Before working on this project, review [README.md](./README.md) for: +- Project purpose: CLI tool generating animated conversation demos from YAML +- YAML schema for scenarios, participants, and steps +- Repository structure and key source files +- Development commands and architecture +- Security considerations (input validation, output safety) + +See the **AI Agent Quick Reference** section for file locations and common tasks. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7e9a668 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zeltser Security Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9acc83b --- /dev/null +++ b/README.md @@ -0,0 +1,388 @@ +# Conversation Replay + +Create animated conversation demos from YAML for security awareness, IR training, and communication skills education. + +**"VHS for conversations"** — Define conversations declaratively, generate self-contained HTML demos that play back like videos. + +--- + +- [Why This Matters](#why-this-matters) +- [Quick Start](#quick-start) +- [YAML Schema](#yaml-schema) +- [Output Features](#output-features) +- [Embedding in Websites](#embedding-in-websites) +- [Use Cases](#use-cases) +- [Development](#development) +- [AI Agent Quick Reference](#ai-agent-quick-reference) +- [Security](#security) +- [Author](#author) + +--- + +## Why This Matters + +Security training often involves showing how attacks unfold through conversation — phishing emails, social engineering calls, BEC attempts. Static screenshots lose the temporal element. Video production is time-consuming and hard to update. + +Conversation Replay lets you: + +- **Define conversations in YAML** — Easy to write, review, and version control +- **Generate self-contained HTML** — No external dependencies, works offline +- **Embed anywhere** — Drop into articles, training materials, or presentations +- **Update easily** — Change the YAML, regenerate the HTML + +## Quick Start + +**Want to see it in action first?** Open [examples/london-scam.html](examples/london-scam.html) or [examples/ir-report.html](examples/ir-report.html) directly in your browser — no build step required. + +**To build your own demos:** + +```bash +# Prerequisite: Bun (https://bun.sh) or Node.js 18+ + +# Install dependencies +bun install + +# Build a demo from YAML +bun run src/cli.ts build examples/london-scam.yaml -o demo.html +``` + +Output: +``` +Loading examples/london-scam.yaml... +Building demo.html... +Done! Generated demo.html + Title: London Scam - Social Engineering Demo + Scenarios: 1 + - London Scam: 17 steps (Matt (compromised account), Rakesh) +``` + +```bash +# Open in browser +open demo.html +``` + +The generated HTML is completely self-contained — CSS, JavaScript, and content are all inlined. No external dependencies, works offline. + +## YAML Schema + +### Basic Structure + +```yaml +meta: + title: "Demo Title" + description: "Shown in header" + theme: chat # chat | email | slack | terminal | generic + autoAdvance: true # Auto-advance between scenarios + +scenarios: + - id: scenario-1 + title: "Scenario Name" # Shown in tab (if multiple scenarios) + participants: + - id: attacker + label: "Scammer" + role: left # Messages appear on left + - id: victim + label: "Target" + role: right # Messages appear on right + steps: + - type: message + from: attacker + content: "Hey, how are you?" +``` + +### Step Types + +| Type | Purpose | Renders As | +|------|---------|------------| +| `message` | Conversation turn | Chat bubble aligned left or right based on participant role | +| `annotation` | Educational note | Highlighted callout with vertical accent bar | +| `transition` | Scene break | Centered text card indicating time/scene change | + +### Message Options + +```yaml +# Basic message +- type: message + from: attacker + content: "Transfer the funds immediately." + +# Message with code block +- type: message + from: analyst + content: "Here's what I found in the logs:" + codeBlock: | + error: unauthorized access attempt + source: 192.168.1.105 + timestamp: 2024-01-15T14:23:00Z + +# Message with footnote +- type: message + from: ai + content: "I can help with that." + footnote: "The AI retrieves context from the MCP server" +``` + +### Meta Options + +**Required:** +- `title` — Demo title shown in header + +**Display options:** +```yaml +meta: + description: "Shown below title" + theme: chat # chat | email | slack | terminal | generic + articleUrl: "/related-article" # "View Article" link in header + annotationLabel: "Security Note" # Label for annotations (default: "Behind the Scenes") + timerStyle: circle # circle | bar (progress indicator style) + cornerStyle: rounded # rounded | straight (bubble corners) +``` + +**Behavior options:** +```yaml +meta: + autoAdvance: true # Auto-play next scenario when current ends + hideHeaderInIframe: true # Hide header when embedded (default: true) +``` + +**Custom colors:** +```yaml +meta: + colors: + accent: "#1a45bc" # Buttons, links + pageBg: "#f6f7f9" # Page background (standalone mode) + canvasBg: "#ffffff" # Chat container background + leftBg: "#e0f2fe" # Left participant bubble background + leftBorder: "#7dd3fc" # Left participant bubble border + rightBg: "#f0fdf4" # Right participant bubble background + rightBorder: "#86efac" # Right participant bubble border + tabInactiveColor: "#666666" # Inactive tab text +``` + +**Timing configuration:** +```yaml +meta: + speed: + minDelay: 3000 # Minimum pause between steps (ms) + maxDelay: 8000 # Maximum pause between steps (ms) + msPerWord: 200 # Reading time per word + annotationMultiplier: 1.15 # Extra time multiplier for annotations + upNextDelay: 2500 # How long to show "Up Next" before transitioning +``` + +### Multi-Scenario Demos + +When you define multiple scenarios, they appear as tabs: + +```yaml +scenarios: + - id: create + title: "Creating Reports" + # ...steps... + + - id: review + title: "Reviewing Reports" + # ...steps... + + - id: tips + title: "Writing Tips" + # ...steps... +``` + +With `autoAdvance: true`, the demo automatically transitions between scenarios. + +## Output Features + +Generated HTML files include: + +- **Zero external dependencies** — Everything inlined +- **Dark mode support** — Respects `prefers-color-scheme` and syncs with parent page +- **Responsive design** — Fills screen standalone, fixed height when embedded +- **Accessibility** — Respects `prefers-reduced-motion`, includes ARIA labels, keyboard navigation +- **Playback controls** — Play/pause, restart, speed selector (0.5x–4x) +- **Tab navigation** — Arrow keys navigate between scenario tabs +- **Progress indicator** — Timer and step counter + +## Embedding in Websites + +### Basic Iframe + +```html + +``` + +### Dark Mode Sync + +The demo automatically respects `prefers-color-scheme`. If your site has a manual dark mode toggle, sync it to the iframe: + +```javascript +// When your page toggles dark mode +const iframe = document.querySelector('iframe'); +iframe.contentWindow.postMessage({ + type: 'theme-change', + theme: 'dark' // or 'light' +}, '*'); + +// On page load, sync initial theme +iframe.addEventListener('load', function() { + const theme = document.documentElement.getAttribute('data-theme') || 'light'; + iframe.contentWindow.postMessage({ type: 'theme-change', theme }, '*'); +}); +``` + +### Behavior Differences + +| Context | Behavior | +|---------|----------| +| **Standalone** | Chat fills available screen height, header visible | +| **Embedded (iframe)** | Fixed height, transparent background, header hidden | + +## Use Cases + +- **Social engineering awareness** — Show how scams unfold over time ([example](examples/london-scam.html)) +- **IR training** — Demonstrate incident communication patterns ([example](examples/ir-report.html)) +- **Communication skills** — Good vs. bad professional dialogue +- **Tool demos** — Showcase AI/chatbot interactions +- **Phishing education** — Step-by-step attack anatomy + +## Development + +### Repository Structure + +``` +conversation-replay/ +├── src/ +│ ├── cli.ts # CLI entry point (build, validate commands) +│ ├── parser.ts # YAML parsing and validation +│ ├── generator.ts # HTML generation with embedded CSS/JS +│ └── types.ts # TypeScript type definitions +├── examples/ +│ ├── README.md # Examples documentation +│ ├── london-scam.yaml # Single-scenario demo (source) +│ ├── london-scam.html # Pre-built HTML demo +│ ├── ir-report.yaml # Multi-scenario demo (source) +│ └── ir-report.html # Pre-built HTML demo +├── package.json +└── tsconfig.json +``` + +See [examples/README.md](examples/README.md) for detailed descriptions of each demo. + +### Commands + +```bash +# Build a demo +bun run src/cli.ts build examples/london-scam.yaml -o demo.html + +# Validate scenario without building (catches errors before generation) +bun run src/cli.ts validate examples/london-scam.yaml + +# Build with different theme +bun run src/cli.ts build examples/london-scam.yaml -o demo.html --theme email + +# Build without header +bun run src/cli.ts build examples/london-scam.yaml -o demo.html --no-header +``` + +### CLI Reference + +``` +conversation-replay build -o [options] +conversation-replay validate + +Options: + -o, --output Output HTML file (required for build) + --theme Override theme: chat, email, slack, terminal, generic + --no-header Exclude the demo header + -h, --help Show help +``` + +### Adding New Themes + +1. Add theme name to `VALID_THEMES` in `src/types.ts` +2. Add CSS variables in `generateCss()` in `src/generator.ts` +3. Theme is automatically available via `--theme` CLI option + +--- + +## AI Agent Quick Reference + +### Key Files + +| File | Purpose | +|------|---------| +| `src/types.ts` | TypeScript interfaces defining YAML schema | +| `src/parser.ts` | YAML parsing with validation (color sanitization, type checking) | +| `src/generator.ts` | HTML generation with embedded CSS/JS (~1700 lines) | +| `src/cli.ts` | CLI interface | + +### Architecture + +``` +YAML Input → Parser (validation) → Generator (HTML/CSS/JS) → Self-contained HTML +``` + +The generator produces a single HTML file with: +- All CSS in a ` + + +
+ +
+

Example Conversation

+

A test scenario to evaluate the UI.

+
+ + +
+
+
+
+ +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..153fd84 --- /dev/null +++ b/example.yaml @@ -0,0 +1,32 @@ +meta: + title: Example Conversation + description: A test scenario to evaluate the UI. + theme: chat + +scenarios: + - id: scenario1 + title: Basic Chat + participants: + - id: user + label: User + role: left + - id: ai + label: Assistant + role: right + steps: + - type: message + from: user + content: "Hello, can you help me with something?" + - type: message + from: ai + content: "Of course! What do you need help with?" + - type: annotation + content: "The assistant is ready to help." + - type: message + from: user + content: "I want to improve my project's design." + - type: transition + content: "Processing request..." + - type: message + from: ai + content: "I can definitely help with that. Let's look at the code." diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..22ca7dc --- /dev/null +++ b/examples/README.md @@ -0,0 +1,47 @@ +# Examples + +Example YAML source files and their pre-built HTML demos. + +## Files + +| File | Description | +|------|-------------| +| `london-scam.yaml` | Single-scenario social engineering demo (source) | +| `london-scam.html` | Pre-built HTML demo | +| `ir-report.yaml` | Multi-scenario IR report writing demo (source) | +| `ir-report.html` | Pre-built HTML demo | + +## Viewing the Demos + +Open any `.html` file directly in your browser — no server required. + +## Example Descriptions + +### London Scam + +**Source:** [Facebook Fraud: A Transcript](https://rake.sh/2009/01/20/facebook-fraud-a-transcript/) by Rakesh Pai + +A social engineering awareness demo showing how attackers use compromised social media accounts to scam victims with the "stranded traveler" pretext. + +**Features demonstrated:** Single scenario with linear flow, `message` steps with left/right participant roles, `annotation` steps for educational callouts, `transition` steps for scene breaks, custom `annotationLabel`. + +### IR Report Writing + +**Source:** [AI Can Help You Create Good Incident Response Reports](https://zeltser.com/good-ir-reports-with-ai) by Lenny Zeltser + +Three-part demo showing how AI can assist with incident response documentation: + +1. **IR Report Creation** — Gathering incident details for a BEC attack +2. **IR Report Review** — Improving clarity and eliminating jargon +3. **Writing Coach** — Executive summary structure and tone guidance + +**Features demonstrated:** Multiple scenarios with tab navigation, `autoAdvance` for automatic scenario progression, `codeBlock` for report text, `footnote` for supplementary information. + +## Rebuilding the HTML Files + +```bash +bun run src/cli.ts build examples/london-scam.yaml -o examples/london-scam.html +bun run src/cli.ts build examples/ir-report.yaml -o examples/ir-report.html +``` + +See the [main README](../README.md) for the complete YAML schema reference. diff --git a/examples/ir-report.html b/examples/ir-report.html new file mode 100644 index 0000000..5e6c1ca --- /dev/null +++ b/examples/ir-report.html @@ -0,0 +1,1512 @@ + + + + + + AI-Assisted IR Report Writing + + + +
+ +
+

AI-Assisted IR Report Writing

+

Using AI tools to create, review, and improve incident response documentation

+
+ + + + +
+
+
+
+ +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/examples/ir-report.yaml b/examples/ir-report.yaml new file mode 100644 index 0000000..08ee582 --- /dev/null +++ b/examples/ir-report.yaml @@ -0,0 +1,190 @@ +# Multi-scenario demo: AI-Assisted IR Report Writing +# Demonstrates the tab system with three scenarios +# +# Based on: https://zeltser.com/good-ir-reports-with-ai + +meta: + title: "AI-Assisted IR Report Writing" + description: "Using AI tools to create, review, and improve incident response documentation" + theme: chat + autoAdvance: true + +scenarios: + - id: create + title: "IR Report Creation" + participants: + - id: analyst + label: "Security Analyst" + role: left + - id: ai + label: "AI Assistant" + role: right + steps: + - type: message + from: analyst + content: "I need to create an IR report for a BEC incident. The CFO received a fraudulent wire transfer request that appeared to be from the CEO." + + - type: message + from: ai + content: "I can help you draft that IR report. Let me ask a few questions first to ensure we capture all the relevant details." + + - type: annotation + content: "The AI uses a structured approach to gather incident details systematically." + + - type: message + from: ai + content: "When was the incident discovered, and what was the initial indicator that something was wrong?" + + - type: message + from: analyst + content: "Discovered yesterday at 2pm when the CFO called the CEO to confirm the transfer. The CEO had no knowledge of the request." + + - type: message + from: ai + content: "Good catch by the CFO. What was the requested transfer amount and where was it supposed to go?" + + - type: message + from: analyst + content: "$47,500 to an account at First National Bank, routing number ending in 4521." + + - type: message + from: ai + content: "Based on what you've shared, here's a draft executive summary:" + codeBlock: | + EXECUTIVE SUMMARY + + On [date], a Business Email Compromise (BEC) attempt + targeted our organization. An attacker impersonating + the CEO requested a $47,500 wire transfer from the CFO. + + The attack was detected before funds were transferred + when the CFO verbally confirmed the request with the CEO. + + Immediate actions: Email headers analyzed, account + security reviewed, finance team alerted to new protocols. + + - type: transition + content: "The analyst continues building the full report..." + + - id: review + title: "IR Report Review" + participants: + - id: analyst + label: "Security Analyst" + role: left + - id: ai + label: "AI Assistant" + role: right + steps: + - type: message + from: analyst + content: "Can you review this section of my IR report? I want to make sure it's clear for executives." + codeBlock: | + The threat actor utilized sophisticated social engineering + techniques leveraging compromised email infrastructure to + perpetrate a financial fraud attempt via BEC methodology. + + - type: annotation + content: "The AI identifies jargon and passive voice that could confuse non-technical readers." + + - type: message + from: ai + content: "This section could be clearer for executives. Here are specific suggestions:" + + - type: message + from: ai + content: "1. 'sophisticated social engineering techniques' - What specifically made it sophisticated? Was it personalized?" + footnote: "Executives appreciate specifics over vague adjectives" + + - type: message + from: ai + content: "2. 'leveraging compromised email infrastructure' - Whose email was compromised? The CEO's? An external vendor's?" + + - type: message + from: ai + content: "3. 'perpetrate a financial fraud attempt via BEC methodology' - This is passive and jargon-heavy. Try: 'The attacker posed as our CEO to request a fraudulent wire transfer.'" + + - type: message + from: analyst + content: "Good points. Let me revise that paragraph." + + - type: message + from: analyst + content: "Revised version:" + codeBlock: | + An attacker gained access to the CEO's email account + and sent a convincing wire transfer request to the CFO. + The email matched the CEO's writing style and referenced + an actual pending acquisition, showing the attacker had + researched our company. + + - type: message + from: ai + content: "Much better. This version is specific, active voice, and tells executives exactly what happened without requiring security background knowledge." + + - id: coach + title: "Writing Coach" + participants: + - id: analyst + label: "Security Analyst" + role: left + - id: ai + label: "Writing Coach" + role: right + steps: + - type: message + from: analyst + content: "I struggle with executive summaries. They either end up too technical or too vague. Any tips?" + + - type: message + from: ai + content: "Executive summaries for IR reports should answer four questions in plain language:" + + - type: message + from: ai + content: "1. WHAT happened? (One sentence describing the incident)" + + - type: message + from: ai + content: "2. WHAT's the impact? (Business terms: financial, operational, reputational)" + + - type: message + from: ai + content: "3. WHAT did we do? (Immediate response actions)" + + - type: message + from: ai + content: "4. WHAT's next? (Recommended follow-up actions)" + + - type: annotation + content: "This framework helps analysts structure their thinking and ensures coverage of key points." + + - type: message + from: analyst + content: "That's helpful. What about tone? My manager says my reports sound too alarmist." + + - type: message + from: ai + content: "Good question. Compare these two approaches:" + codeBlock: | + ALARMIST: + "A catastrophic security breach nearly resulted in + massive financial losses. Urgent action is required + immediately to prevent future attacks." + + MEASURED: + "A BEC attempt was detected and blocked before any + funds transferred. We recommend three process + improvements to reduce future risk." + + - type: message + from: ai + content: "The measured version states facts, acknowledges what went right, and focuses on actionable improvements rather than fear." + + - type: message + from: analyst + content: "I see the difference. Facts over fear, and focus on what we're doing about it." + + - type: message + from: ai + content: "Exactly. Executives want to know the situation is under control and what resources you need. Alarmist language undermines confidence in the security team." diff --git a/examples/london-scam.html b/examples/london-scam.html new file mode 100644 index 0000000..acd263a --- /dev/null +++ b/examples/london-scam.html @@ -0,0 +1,1438 @@ + + + + + + London Scam - Social Engineering Demo + + + +
+ +
+

London Scam - Social Engineering Demo

+

How attackers use compromised accounts to social engineer victims via chat

+
+ + +
+
+
+
+ +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/examples/london-scam.yaml b/examples/london-scam.yaml new file mode 100644 index 0000000..ebe8199 --- /dev/null +++ b/examples/london-scam.yaml @@ -0,0 +1,86 @@ +# London Scam - Social Engineering Demo +# +# Based on real transcript from: +# https://rake.sh/2009/01/20/facebook-fraud-a-transcript/ +# +# This demonstrates how attackers use compromised social media +# accounts to social engineer victims. + +meta: + title: "London Scam - Social Engineering Demo" + description: "How attackers use compromised accounts to social engineer victims via chat" + theme: chat + articleUrl: "/bots-chatting-on-social-networks" + annotationLabel: "Security Note" + +scenarios: + - id: main + title: "London Scam" + participants: + - id: attacker + label: "Matt (compromised account)" + role: left + - id: victim + label: "Rakesh" + role: right + steps: + - type: message + from: attacker + content: "hi. whats up?" + + - type: message + from: victim + content: "Hi Matt. Everything OK?" + + - type: annotation + content: "The attacker establishes rapport using the victim's friend's account. Notice how the response seems normal at first." + + - type: message + from: attacker + content: "well, im really stuck here in london. i had to visit a resort here in london and i got robbed at the hotel im staying" + + - type: annotation + content: "The scam begins. The attacker creates urgency with a crisis story. The 'London emergency' is one of the most common social engineering pretexts." + + - type: message + from: victim + content: "Oh no! Are you OK? Did you call the police?" + + - type: message + from: attacker + content: "yes i did, but they said it might take a while to get my stuff back. the problem is i need to pay my hotel bill and get a flight home" + + - type: annotation + content: "The attacker builds on the crisis, establishing a clear financial need. They're setting up the ask." + + - type: message + from: attacker + content: "i was wondering if you could help me out with some money? ill pay you back as soon as i get home" + + - type: transition + content: "This is where the scam becomes explicit. The attacker asks for money." + + - type: message + from: victim + content: "How much do you need? And how would I send it?" + + - type: message + from: attacker + content: "about $1500 would cover the hotel and flight. you can send it through western union - its the fastest way" + + - type: annotation + content: "Western Union is the preferred method because transfers are difficult to trace or reverse. This is a major red flag." + + - type: message + from: victim + content: "That's a lot of money. Can't you call the embassy or your bank?" + + - type: message + from: attacker + content: "i tried but they said it would take days. please, i really need your help. i promise ill pay you back" + + - type: annotation + content: "The attacker increases pressure and appeals to friendship. Real friends would understand delays - scammers push urgency." + + - type: transition + content: "Red flags in this conversation: urgency, money request, Western Union, resistance to alternatives" diff --git a/package.json b/package.json new file mode 100644 index 0000000..68431be --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "conversation-replay", + "version": "0.1.0", + "description": "Create animated conversation demos from YAML for security awareness, IR training, and communication skills education", + "module": "src/cli.ts", + "type": "module", + "bin": { + "conversation-replay": "./src/cli.ts" + }, + "scripts": { + "build": "bun run src/cli.ts build", + "validate": "bun run src/cli.ts validate", + "example": "bun run src/cli.ts build examples/london-scam.yaml -o examples/london-scam.html" + }, + "keywords": [ + "conversation", + "demo", + "animation", + "security-awareness", + "training", + "chat", + "replay" + ], + "author": "Lenny Zeltser", + "license": "MIT", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "yaml": "^2.8.2" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..fc7fa86 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env bun +/** + * Conversation Replay - CLI + * + * Usage: + * conversation-replay build -o + * conversation-replay validate + */ + +import { parseArgs } from 'util'; +import { loadDemo, ParseError } from './parser'; +import { buildDemo } from './generator'; +import type { Theme } from './types'; + +const HELP = ` +conversation-replay - Create animated conversation demos from YAML + +Usage: + conversation-replay build -o [options] + conversation-replay validate + conversation-replay --help + +Commands: + build Generate HTML from a scenario file + validate Check a scenario file for errors + +Options: + -o, --output Output HTML file path (required for build) + --theme Override theme (chat, email, slack, terminal, generic) + --no-header Exclude the demo header + -h, --help Show this help message + +Examples: + conversation-replay build demo.yaml -o demo.html + conversation-replay build demo.yaml -o demo.html --theme email + conversation-replay validate demo.yaml +`; + +const VALID_THEMES = ['chat', 'email', 'slack', 'terminal', 'generic']; + +async function main() { + const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), + options: { + output: { type: 'string', short: 'o' }, + theme: { type: 'string' }, + 'no-header': { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + }, + allowPositionals: true, + }); + + if (values.help || positionals.length === 0) { + console.log(HELP); + process.exit(0); + } + + const command = positionals[0]; + const inputFile = positionals[1]; + + if (!inputFile) { + console.error('Error: No input file specified\n'); + console.log(HELP); + process.exit(1); + } + + // Validate theme if provided + if (values.theme && !VALID_THEMES.includes(values.theme)) { + console.error(`Error: Invalid theme "${values.theme}"`); + console.error(`Valid themes: ${VALID_THEMES.join(', ')}`); + process.exit(1); + } + + try { + switch (command) { + case 'build': + await handleBuild(inputFile, values); + break; + + case 'validate': + await handleValidate(inputFile); + break; + + default: + console.error(`Error: Unknown command "${command}"\n`); + console.log(HELP); + process.exit(1); + } + } catch (error) { + if (error instanceof ParseError) { + console.error(`Parse Error: ${error.message}`); + if (error.path) { + console.error(` File: ${error.path}`); + } + process.exit(1); + } + + throw error; + } +} + +async function handleBuild( + inputFile: string, + options: { output?: string; theme?: string; 'no-header'?: boolean } +) { + if (!options.output) { + console.error('Error: Output file required (-o )\n'); + console.log('Usage: conversation-replay build -o '); + process.exit(1); + } + + console.log(`Loading ${inputFile}...`); + const demo = await loadDemo(inputFile); + + console.log(`Building ${options.output}...`); + await buildDemo(demo, options.output, { + theme: options.theme as Theme | undefined, + includeHeader: !options['no-header'], + }); + + console.log(`Done! Generated ${options.output}`); + console.log(` Title: ${demo.meta.title}`); + console.log(` Scenarios: ${demo.scenarios.length}`); + for (const scenario of demo.scenarios) { + const stepCount = scenario.steps.length; + const participants = scenario.participants.map(p => p.label).join(', '); + console.log(` - ${scenario.title}: ${stepCount} steps (${participants})`); + } +} + +async function handleValidate(inputFile: string) { + console.log(`Validating ${inputFile}...`); + const demo = await loadDemo(inputFile); + + console.log('Valid!'); + console.log(` Title: ${demo.meta.title}`); + console.log(` Theme: ${demo.meta.theme ?? 'chat (default)'}`); + console.log(` Scenarios: ${demo.scenarios.length}`); + + for (const scenario of demo.scenarios) { + console.log(`\n Scenario: ${scenario.title} (${scenario.id})`); + console.log(` Participants:`); + for (const p of scenario.participants) { + console.log(` - ${p.label} (${p.id}, ${p.role})`); + } + + // Count step types + const counts = { message: 0, annotation: 0, transition: 0 }; + for (const step of scenario.steps) { + if (step.type === 'message') counts.message++; + else if (step.type === 'annotation') counts.annotation++; + else if (step.type === 'transition') counts.transition++; + } + + console.log(` Steps: ${scenario.steps.length}`); + console.log(` - Messages: ${counts.message}`); + if (counts.annotation > 0) console.log(` - Annotations: ${counts.annotation}`); + if (counts.transition > 0) console.log(` - Transitions: ${counts.transition}`); + } +} + +main().catch(error => { + console.error('Unexpected error:', error); + process.exit(1); +}); diff --git a/src/generator.ts b/src/generator.ts new file mode 100644 index 0000000..5e384cb --- /dev/null +++ b/src/generator.ts @@ -0,0 +1,1749 @@ +/** + * Conversation Replay - HTML Generator + * + * Generates self-contained HTML files from demo definitions. + * Supports multi-scenario demos with tabs. + * + * Security: All dynamic content is rendered using safe DOM methods (textContent, + * createElement) - no innerHTML with untrusted content. + */ + +import type { Demo, Scenario, Step, Participant, Theme, BuildOptions, ColorConfig, TimerStyle, CornerStyle, SpeedConfig } from './types'; + +/** + * Escape HTML special characters for static content only + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Validate URL is safe (not javascript:, data:, etc.) + */ +function isSafeUrl(url: string): boolean { + const trimmed = url.trim().toLowerCase(); + // Block dangerous protocols + if (trimmed.startsWith('javascript:')) return false; + if (trimmed.startsWith('data:')) return false; + if (trimmed.startsWith('vbscript:')) return false; + // Allow http, https, mailto, tel, and relative URLs + return true; +} + +/** + * Parse markdown-style links in text: [text](url) -> text + * Text is HTML-escaped first, then links are converted. + * Dangerous URLs (javascript:, data:, etc.) are rendered as plain text. + */ +function parseMarkdownLinks(text: string): string { + return escapeHtml(text).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (match, linkText, url) => { + if (isSafeUrl(url)) { + return `${linkText}`; + } + // Render dangerous URLs as plain text (already escaped) + return match; + } + ); +} + +/** + * Convert scenario steps to JavaScript object notation + */ +function stepsToJs(scenario: Scenario): string { + const participantMap = new Map( + scenario.participants.map(p => [p.id, p]) + ); + + const jsSteps = scenario.steps.map(step => { + if (step.type === 'annotation') { + return `{ type: "annotation", plainText: ${JSON.stringify(step.content)} }`; + } + + if (step.type === 'transition') { + return `{ type: "transition", plainText: ${JSON.stringify(step.content)} }`; + } + + // Message step + const participant = participantMap.get(step.from)!; + const role = participant.role === 'left' ? 'user' : 'ai'; + + let js = `{ type: ${JSON.stringify(role)}, plainText: ${JSON.stringify(step.content)}`; + + if (step.codeBlock) { + js += `, codeBlock: ${JSON.stringify(step.codeBlock)}`; + } + + if (step.footnote) { + js += `, footnote: ${JSON.stringify(step.footnote)}`; + } + + js += ' }'; + return js; + }); + + return `[${jsSteps.join(', ')}]`; +} + +/** + * Get participant labels for a scenario + */ +function getParticipantLabels(scenario: Scenario): { left: string; right: string } { + const left = scenario.participants.find(p => p.role === 'left'); + const right = scenario.participants.find(p => p.role === 'right'); + + return { + left: left?.label ?? 'User', + right: right?.label ?? 'Assistant', + }; +} + +/** + * Generate scenarios JavaScript object + */ +function generateScenariosJs(demo: Demo): string { + const scenarioEntries = demo.scenarios.map(scenario => { + const labels = getParticipantLabels(scenario); + return ` + ${JSON.stringify(scenario.id)}: { + title: ${JSON.stringify(scenario.title)}, + labels: { user: ${JSON.stringify(labels.left)}, ai: ${JSON.stringify(labels.right)} }, + steps: ${stepsToJs(scenario)} + }`; + }); + + return `{${scenarioEntries.join(',')}\n }`; +} + +/** + * Generate custom color CSS overrides + */ +function generateColorOverrides(colors?: ColorConfig): string { + if (!colors) return ''; + + const overrides: string[] = []; + + if (colors.accent) { + overrides.push(`--accent: ${colors.accent};`); + } + if (colors.pageBg) { + overrides.push(`--bg-primary: ${colors.pageBg};`); + } + if (colors.canvasBg) { + overrides.push(`--bg-chat: ${colors.canvasBg};`); + } + if (colors.leftBg) { + overrides.push(`--user-bg: ${colors.leftBg};`); + } + if (colors.leftBorder) { + overrides.push(`--user-border: ${colors.leftBorder};`); + } + if (colors.rightBg) { + overrides.push(`--ai-bg: ${colors.rightBg};`); + } + if (colors.rightBorder) { + overrides.push(`--ai-border: ${colors.rightBorder};`); + } + if (colors.tabInactiveColor) { + overrides.push(`--tab-inactive-color: ${colors.tabInactiveColor};`); + } + + if (overrides.length === 0) return ''; + + return ` + :root { + ${overrides.join('\n ')} + } + `; +} + +/** + * Generate the CSS for the demo player + */ +function generateCss(theme: Theme, hasMultipleScenarios: boolean, colors?: ColorConfig, cornerStyle?: CornerStyle): string { + const colorOverrides = generateColorOverrides(colors); + const radius = cornerStyle === 'straight' ? '0' : '8px'; + const radiusLg = cornerStyle === 'straight' ? '0' : '12px'; + + const tabCss = hasMultipleScenarios ? ` + /* Tabs - browser-style integrated with canvas */ + .tabs { + display: flex; + gap: 0; + margin-left: 12px; + margin-bottom: -1px; + flex-wrap: wrap; + position: relative; + z-index: 2; + } + + .tab { + padding: 10px 20px; + border: 1px solid transparent; + border-bottom: none; + background: transparent; + color: var(--tab-inactive-color, var(--text-muted)); + border-radius: var(--radius) var(--radius) 0 0; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + opacity: 0.75; + } + + .tab:hover { + opacity: 0.9; + color: var(--text-primary); + } + + .tab.active { + background: var(--bg-chat); + color: var(--text-primary); + border-color: var(--border-color); + opacity: 1; + position: relative; + } + + .tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: var(--bg-chat); + } + + @media (max-width: 600px) { + .tab { + padding: 10px 14px; + font-size: 12px; + flex: 1; + text-align: center; + min-width: 0; + } + } + ` : ''; + + return ` + *, *::before, *::after { + box-sizing: border-box; + } + + :root { + --bg-primary: #f6f7f9; + --bg-secondary: #ffffff; + --bg-chat: #ffffff; + --text-primary: #060426; + --text-secondary: #666666; + --text-muted: #666666; + --accent: #1a45bc; + --accent-light: #e0e7f8; + --user-bg: #e0f2fe; + --user-border: #7dd3fc; + --ai-bg: #f0fdf4; + --ai-border: #86efac; + --annotation-bg: transparent; + --annotation-border: #94a3b8; + --annotation-text: #64748b; + --transition-bg: #f3e8ff; + --transition-border: #c084fc; + --border-color: #e0e7f8; + --radius: ${radius}; + --radius-lg: ${radiusLg}; + } + + @media (prefers-color-scheme: dark) { + :root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-chat: #161b22; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-muted: #8b949e; + --accent: #58a6ff; + --accent-light: #1f2937; + --user-bg: #0c4a6e; + --user-border: #0284c7; + --ai-bg: #14532d; + --ai-border: #22c55e; + --annotation-bg: transparent; + --annotation-border: #475569; + --annotation-text: #94a3b8; + --transition-bg: #4c1d95; + --transition-border: #a855f7; + --border-color: #30363d; + } + } + + /* Dark mode via data-theme attribute (for iframe sync with parent) */ + :root[data-theme="dark"] { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-chat: #161b22; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-muted: #8b949e; + --accent: #58a6ff; + --accent-light: #1f2937; + --user-bg: #0c4a6e; + --user-border: #0284c7; + --ai-bg: #14532d; + --ai-border: #22c55e; + --annotation-bg: transparent; + --annotation-border: #475569; + --annotation-text: #94a3b8; + --transition-bg: #4c1d95; + --transition-border: #a855f7; + --border-color: #30363d; + } + + :root[data-theme="dark"] .play-overlay { + background: rgba(0, 0, 0, 0.5); + } + + :root[data-theme="dark"] .play-overlay-icon { + background: rgba(255, 255, 255, 0.9); + } + + ${colorOverrides} + + body { + margin: 0; + padding: 16px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + background: var(--bg-primary); + color: var(--text-primary); + } + + .demo-container { + max-width: 800px; + margin: 0 auto; + } + + .demo-header { + margin-bottom: 16px; + padding-bottom: 12px; + } + + .demo-header.hidden { + display: none; + } + + .demo-title { + font-size: 18px; + font-weight: 600; + margin: 0 0 8px 0; + } + + .demo-title-link { + color: inherit; + text-decoration: none; + } + + .demo-title-link:hover { + text-decoration: underline; + } + + .demo-description { + font-size: 14px; + color: var(--text-secondary); + margin: 0; + line-height: 1.5; + } + + .demo-description a { + color: var(--accent); + } + + ${tabCss} + + .chat-wrapper { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + + .chat-container { + position: relative; + background: var(--bg-chat); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + padding: 16px; + flex: 1; + min-height: 350px; + max-height: var(--chat-max-height, 500px); + overflow-y: auto; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + transition: opacity 0.3s ease; + } + + .chat-container.fading { + opacity: 0; + } + + /* Standalone mode: chat fills available space, controls stay visible */ + body:not(.in-iframe) .demo-container { + height: calc(100vh - 32px); + display: flex; + flex-direction: column; + } + + body:not(.in-iframe) .chat-container { + /* Fills remaining space after header/tabs/controls */ + --chat-max-height: none; + flex: 1; + min-height: 200px; + } + + /* Iframe mode: fill viewport, transparent background */ + html.in-iframe, + body.in-iframe { + height: 100%; + background: transparent; + padding: 0; + margin: 0; + overflow: hidden; + } + + body.in-iframe .demo-container { + height: 100%; + display: flex; + flex-direction: column; + background: transparent; + padding: 0; + } + + body.in-iframe .tabs { + flex-shrink: 0; + } + + body.in-iframe .chat-container { + flex: 1; + min-height: 0; + } + + body.in-iframe .controls { + flex-shrink: 0; + margin-top: 12px; + padding-top: 8px; + padding-bottom: 8px; + } + + .chat-messages { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 20px; + } + + .play-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.25); + border-radius: var(--radius-lg); + cursor: pointer; + transition: opacity 0.3s ease; + z-index: 10; + } + + .play-overlay.hidden { + opacity: 0; + pointer-events: none; + } + + .play-overlay-icon { + width: 80px; + height: 80px; + background: rgba(255, 255, 255, 0.95); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + transition: transform 0.2s ease; + } + + .play-overlay:hover .play-overlay-icon { + transform: scale(1.05); + } + + .play-overlay-icon svg { + margin-left: 4px; + } + + @media (prefers-color-scheme: dark) { + .play-overlay { + background: rgba(0, 0, 0, 0.5); + } + .play-overlay-icon { + background: rgba(255, 255, 255, 0.9); + } + } + + .message { + max-width: 85%; + opacity: 0; + transform: translateY(10px); + } + + .message.visible { + opacity: 1; + transform: translateY(0); + transition: opacity 0.3s ease, transform 0.3s ease; + } + + .message.user { + align-self: flex-start; + } + + .message.ai { + align-self: flex-end; + margin-right: 32px; + } + + .message-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + color: var(--text-muted); + } + + .message.ai .message-label { + text-align: right; + } + + .message-content { + padding: 12px 16px; + border-radius: var(--radius); + border-left: 3px solid; + } + + .message.user .message-content { + background: var(--user-bg); + border-color: var(--user-border); + } + + .message.ai .message-content { + background: var(--ai-bg); + border-color: var(--ai-border); + } + + .message-content pre { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; + overflow-x: auto; + font-size: 12px; + margin: 8px 0; + white-space: pre-wrap; + word-wrap: break-word; + } + + .message-content code { + font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; + } + + .annotation { + position: relative; + width: 100%; + margin: 20px 0; + padding: 0 0 0 16px; + font-size: 14px; + font-style: italic; + color: var(--annotation-text); + opacity: 0; + transform: translateY(10px); + } + + .annotation::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--annotation-border); + border-radius: 2px; + } + + .annotation.visible { + opacity: 1; + transform: translateY(0); + transition: opacity 0.4s ease, transform 0.4s ease; + } + + .annotation-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--annotation-border); + margin-bottom: 4px; + font-style: normal; + } + + .annotation-content { + line-height: 1.5; + } + + .transition { + position: relative; + width: 100%; + margin: 20px 0; + padding: 0 0 0 16px; + font-size: 14px; + font-weight: 500; + font-style: italic; + color: var(--transition-text, var(--text-muted)); + opacity: 0; + transform: translateY(10px); + } + + .transition::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--transition-border); + border-radius: 2px; + } + + .transition.visible { + opacity: 1; + transform: translateY(0); + transition: opacity 0.4s ease, transform 0.4s ease; + } + + .scenario-ending { + text-align: center; + padding: 24px 20px; + margin-top: 24px; + color: var(--text-muted); + font-size: 13px; + opacity: 0; + transition: opacity 0.4s ease; + } + + .scenario-ending.visible { + opacity: 1; + } + + .scenario-ending .next-label { + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; + margin-bottom: 4px; + } + + .scenario-ending .next-title { + font-weight: 600; + font-size: 15px; + color: var(--text-primary); + } + + .controls { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + flex-wrap: wrap; + } + + .control-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + } + + .control-btn:hover:not(:disabled) { + background: var(--accent-light); + border-color: var(--accent); + } + + .control-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .control-btn.primary { + background: var(--accent); + color: white; + border-color: var(--accent); + } + + .control-btn.primary:hover:not(:disabled) { + opacity: 0.9; + } + + .control-btn.icon-only { + padding: 8px 10px; + } + + .speed-select { + padding: 8px 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: var(--radius); + font-size: 13px; + cursor: pointer; + } + + .progress { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + } + + .timer-bar { + position: absolute; + bottom: 1px; + left: 1px; + height: 3px; + width: 0%; + background: var(--accent); + opacity: 0; + border-radius: 0 0 2px 10px; + z-index: 5; + transform-origin: left; + transition: opacity 0.2s ease; + pointer-events: none; + } + + .timer-bar.active { + opacity: 0.6; + transition: width 0.1s linear, opacity 0.2s ease; + } + + .timer-circle { + position: absolute; + top: 12px; + right: 12px; + width: 18px; + height: 18px; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 5; + pointer-events: none; + } + + .timer-circle.active { + opacity: 0.6; + } + + .timer-circle circle { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-dasharray: 44; + stroke-dashoffset: 0; + transform: rotate(-90deg); + transform-origin: center; + } + + .browser-error { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); + } + + @media (prefers-reduced-motion: reduce) { + .message, + .annotation, + .transition { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } + .play-overlay { + transition: none !important; + } + } + + @media (max-width: 600px) { + body { + padding: 12px; + } + + .demo-title { + font-size: 16px; + } + + .demo-description { + font-size: 13px; + } + + .tabs { + gap: 6px; + } + + .tab { + padding: 10px 14px; + font-size: 12px; + min-height: 44px; + } + + .message { + max-width: 95%; + } + + .chat-container { + padding: 12px; + min-height: 250px; + } + + body.in-iframe .chat-container { + max-height: 400px; + } + + .controls { + gap: 8px; + padding-top: 12px; + } + + .control-btn { + padding: 10px 14px; + font-size: 13px; + min-height: 44px; + } + + .control-btn.icon-only { + padding: 10px 12px; + min-width: 44px; + min-height: 44px; + } + + .speed-select { + padding: 10px 12px; + font-size: 13px; + min-height: 44px; + } + + .play-overlay-icon { + width: 70px; + height: 70px; + } + + .play-overlay-icon svg { + width: 28px; + height: 28px; + } + + .message-content pre { + overflow-x: auto; + max-width: 100%; + } + } + + /* Very small phones */ + @media (max-width: 400px) { + body { + padding: 8px; + } + + .tabs { + flex-direction: column; + gap: 4px; + } + + .tab { + width: 100%; + text-align: center; + } + + .controls { + flex-wrap: wrap; + justify-content: center; + } + + .control-btn { + flex: 0 0 auto; + } + + .control-btn.primary { + order: -1; + flex: 1 1 100%; + justify-content: center; + margin-bottom: 4px; + } + } + `; +} + +/** + * Generate the JavaScript for the demo player + */ +function generateJs(demo: Demo, timerStyle: TimerStyle): string { + const scenariosJs = generateScenariosJs(demo); + const scenarioOrder = JSON.stringify(demo.scenarios.map(s => s.id)); + const autoAdvance = demo.meta.autoAdvance !== false; + const hasMultipleScenarios = demo.scenarios.length > 1; + const annotationLabel = demo.meta.annotationLabel ?? 'Behind the Scenes'; + const useCircleTimer = timerStyle === 'circle'; + + return ` + (function() { + 'use strict'; + + // Feature detection + var hasRequiredFeatures = ( + typeof document.querySelector === 'function' && + typeof document.querySelectorAll === 'function' && + typeof window.addEventListener === 'function' && + typeof Array.prototype.forEach === 'function' + ); + + if (!hasRequiredFeatures) { + var errorDiv = document.querySelector('.browser-error'); + if (errorDiv) { + errorDiv.style.display = 'block'; + } + return; + } + + // Iframe detection - add class for CSS and hide header when embedded + var isInIframe = false; + try { + isInIframe = window.self !== window.top; + } catch (e) { + isInIframe = true; + } + + if (isInIframe) { + document.documentElement.classList.add('in-iframe'); + document.body.classList.add('in-iframe'); + + // Listen for theme changes from parent page + window.addEventListener('message', function(event) { + if (event.data && event.data.type === 'theme-change') { + var theme = event.data.theme; + if (theme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + } + }); + } + + var header = document.getElementById('demo-header'); + if (isInIframe && header) { + header.classList.add('hidden'); + } + + // Configurable annotation label + var annotationLabel = ${JSON.stringify(annotationLabel)}; + + // Scenarios data + var scenarios = ${scenariosJs}; + var scenarioOrder = ${scenarioOrder}; + var autoAdvance = ${autoAdvance}; + var hasMultipleScenarios = ${hasMultipleScenarios}; + var useCircleTimer = ${useCircleTimer}; + + // Speed configuration + var speedConfig = { + minDelay: ${demo.meta.speed?.minDelay ?? 3000}, + maxDelay: ${demo.meta.speed?.maxDelay ?? 8000}, + msPerWord: ${demo.meta.speed?.msPerWord ?? 200}, + annotationMultiplier: ${demo.meta.speed?.annotationMultiplier ?? 1.15}, + upNextDelay: ${demo.meta.speed?.upNextDelay ?? 2500} + }; + + // Animation state + var currentScenario = scenarioOrder[0]; + var currentStepIndex = 0; + var isPlaying = false; + var isPaused = false; + var animationTimeout = null; + var speed = 1; + var hasStarted = false; + + // DOM elements + var chatMessages = document.getElementById('chat-messages'); + var playPauseBtn = document.getElementById('play-pause-btn'); + var playIcon = document.getElementById('play-icon'); + var pauseIcon = document.getElementById('pause-icon'); + var playPauseText = document.getElementById('play-pause-text'); + var resetBtn = document.getElementById('reset-btn'); + var speedSelect = document.getElementById('speed-select'); + var progressEl = document.getElementById('progress'); + var playOverlay = document.getElementById('play-overlay'); + var timerElement = document.getElementById('timer-element'); + var chatContainer = document.getElementById('chat-container'); + var tabs = document.querySelectorAll('.tab'); + + // Timer animation + var timerAnimationFrame = null; + var timerStartTime = 0; + var timerDuration = 0; + + // Reduced motion preference + var prefersReducedMotion = false; + try { + prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } catch (e) {} + + function getSteps() { + return scenarios[currentScenario].steps; + } + + function getLabels() { + return scenarios[currentScenario].labels; + } + + // Content-aware delay calculation + function calculateDelay(step) { + var wordCount = 0; + + if (step.plainText) { + wordCount += step.plainText.split(/\\s+/).length; + } + if (step.codeBlock) { + wordCount += Math.floor(step.codeBlock.split(/\\s+/).length * 1.3); + } + if (step.footnote) { + wordCount += step.footnote.split(/\\s+/).length; + } + + var baseDelay = Math.max(speedConfig.minDelay, Math.min(speedConfig.maxDelay, wordCount * speedConfig.msPerWord)); + + if (step.type === 'annotation') { + baseDelay = Math.floor(baseDelay * speedConfig.annotationMultiplier); + } + + return baseDelay / speed; + } + + // Timer functions - supports bar (grows left to right) or circle (stroke countdown) + function startTimer(duration) { + timerDuration = duration; + timerStartTime = Date.now(); + timerElement.classList.add('active'); + + if (useCircleTimer) { + // Circle: animate stroke-dashoffset from 0 to 44 (full circle = 44) + var circle = timerElement.querySelector('circle'); + if (circle) circle.style.strokeDashoffset = '0'; + } else { + // Bar: start at 0% width + timerElement.style.width = '0%'; + } + + function animate() { + var elapsed = Date.now() - timerStartTime; + var progress = Math.min(elapsed / timerDuration, 1); + + if (useCircleTimer) { + // Circle: offset goes from 0 to 44 as time progresses + var circle = timerElement.querySelector('circle'); + if (circle) circle.style.strokeDashoffset = (progress * 44) + ''; + } else { + // Bar: width grows from 0% to 100% + timerElement.style.width = (progress * 100) + '%'; + } + + if (progress < 1 && isPlaying && !isPaused) { + timerAnimationFrame = requestAnimationFrame(animate); + } + } + + if (typeof requestAnimationFrame === 'function') { + timerAnimationFrame = requestAnimationFrame(animate); + } + } + + function stopTimer() { + if (timerAnimationFrame) { + cancelAnimationFrame(timerAnimationFrame); + timerAnimationFrame = null; + } + timerElement.classList.remove('active'); + if (useCircleTimer) { + var circle = timerElement.querySelector('circle'); + if (circle) circle.style.strokeDashoffset = '0'; + } else { + timerElement.style.width = '0%'; + } + } + + function clearChatMessages() { + while (chatMessages.firstChild) { + chatMessages.removeChild(chatMessages.firstChild); + } + } + + function smoothScrollToBottom() { + var container = chatMessages.parentElement; + if (typeof container.scrollTo === 'function') { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + } else { + container.scrollTop = container.scrollHeight; + } + } + + function createStepElement(step) { + var div = document.createElement('div'); + var labels = getLabels(); + + if (step.type === 'annotation') { + div.className = 'annotation'; + + // Label + var label = document.createElement('div'); + label.className = 'annotation-label'; + label.textContent = annotationLabel; + div.appendChild(label); + + // Content + var content = document.createElement('div'); + content.className = 'annotation-content'; + content.textContent = step.plainText; + div.appendChild(content); + return div; + } + + if (step.type === 'transition') { + div.className = 'transition'; + div.textContent = step.plainText; + return div; + } + + // Message + div.className = 'message ' + step.type; + + var label = document.createElement('div'); + label.className = 'message-label'; + label.textContent = labels[step.type] || step.type; + div.appendChild(label); + + var contentWrapper = document.createElement('div'); + contentWrapper.className = 'message-content'; + + if (step.plainText) { + var textNode = document.createElement('p'); + var hasMoreContent = step.codeBlock || step.footnote; + textNode.style.margin = hasMoreContent ? '0 0 8px 0' : '0'; + textNode.textContent = step.plainText; + contentWrapper.appendChild(textNode); + } + + if (step.codeBlock) { + var pre = document.createElement('pre'); + var code = document.createElement('code'); + code.textContent = step.codeBlock; + pre.appendChild(code); + contentWrapper.appendChild(pre); + } + + if (step.footnote) { + var footnote = document.createElement('p'); + footnote.style.cssText = 'margin: 8px 0 0 0; font-style: italic; font-size: 12px; color: var(--text-secondary);'; + footnote.textContent = step.footnote; + contentWrapper.appendChild(footnote); + } + + div.appendChild(contentWrapper); + return div; + } + + function updateTabStates() { + tabs.forEach(function(t) { + var isActive = t.getAttribute('data-scenario') === currentScenario; + if (isActive) { + t.classList.add('active'); + t.setAttribute('aria-selected', 'true'); + t.setAttribute('tabindex', '0'); + } else { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + t.setAttribute('tabindex', '-1'); + } + }); + } + + function updateButtonStates() { + var steps = getSteps(); + var isComplete = currentStepIndex >= steps.length; + var isActivelyPlaying = isPlaying && !isPaused; + + // Toggle play/pause button icon and text + if (isActivelyPlaying) { + playIcon.style.display = 'none'; + pauseIcon.style.display = 'block'; + playPauseText.textContent = 'Pause'; + playPauseBtn.setAttribute('aria-label', 'Pause animation'); + } else { + playIcon.style.display = 'block'; + pauseIcon.style.display = 'none'; + playPauseText.textContent = 'Play'; + playPauseBtn.setAttribute('aria-label', 'Play animation'); + } + + playPauseBtn.disabled = isComplete && !isActivelyPlaying; + } + + function updateProgress() { + var steps = getSteps(); + progressEl.textContent = currentStepIndex + ' / ' + steps.length; + } + + function showInitialPreview() { + var steps = getSteps(); + if (steps.length > 0) { + var element = createStepElement(steps[0]); + element.classList.add('visible'); + chatMessages.appendChild(element); + currentStepIndex = 0; + } + playOverlay.classList.remove('hidden'); + hasStarted = false; + updateProgress(); + updateButtonStates(); + } + + function switchToScenario(scenarioId, withFade) { + stopTimer(); + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + } + + function doSwitch() { + currentScenario = scenarioId; + currentStepIndex = 0; + isPlaying = false; + isPaused = false; + hasStarted = false; + + updateTabStates(); + clearChatMessages(); + showInitialPreview(); + + if (withFade) { + // Fade back in + chatContainer.classList.remove('fading'); + } + } + + if (withFade) { + // Fade out first + chatContainer.classList.add('fading'); + setTimeout(doSwitch, 300); + } else { + doSwitch(); + } + } + + function showTransitionMessage(nextScenarioTitle) { + var div = document.createElement('div'); + div.className = 'transition'; + div.textContent = 'Switching to: ' + nextScenarioTitle; + chatMessages.appendChild(div); + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function() { + div.classList.add('visible'); + smoothScrollToBottom(); + }); + } else { + div.classList.add('visible'); + smoothScrollToBottom(); + } + } + + function advanceToNextScenario() { + var currentIndex = scenarioOrder.indexOf(currentScenario); + var nextIndex = (currentIndex + 1) % scenarioOrder.length; + var nextScenarioId = scenarioOrder[nextIndex]; + var nextTitle = scenarios[nextScenarioId].title; + + // Show "Up Next" indicator at end of current scenario + var upNextDiv = document.createElement('div'); + upNextDiv.className = 'scenario-ending'; + var labelDiv = document.createElement('div'); + labelDiv.className = 'next-label'; + labelDiv.textContent = 'Up Next'; + var titleDiv = document.createElement('div'); + titleDiv.className = 'next-title'; + titleDiv.textContent = nextTitle; + upNextDiv.appendChild(labelDiv); + upNextDiv.appendChild(titleDiv); + chatMessages.appendChild(upNextDiv); + + // Animate it in + requestAnimationFrame(function() { + upNextDiv.classList.add('visible'); + smoothScrollToBottom(); + }); + + // Wait for user to see it, then fade and switch + var upNextDelay = speedConfig.upNextDelay / speed; + startTimer(upNextDelay); + animationTimeout = setTimeout(function() { + stopTimer(); + chatContainer.classList.add('fading'); + + setTimeout(function() { + currentScenario = nextScenarioId; + currentStepIndex = 0; + isPlaying = false; + isPaused = false; + hasStarted = false; + + updateTabStates(); + clearChatMessages(); + // Skip showInitialPreview() during auto-advance to avoid overlay flash + // Keep overlay hidden and go directly to playing + playOverlay.classList.add('hidden'); + chatContainer.classList.remove('fading'); + + // Auto-play immediately + play(); + }, 300); + }, upNextDelay); + } + + function showNextStep() { + stopTimer(); + var steps = getSteps(); + + if (currentStepIndex >= steps.length) { + // Scenario complete + if (autoAdvance && hasMultipleScenarios) { + // Advance to next scenario - it handles its own "Up Next" timing + advanceToNextScenario(); + } else { + isPlaying = false; + updateButtonStates(); + } + return; + } + + if (isPaused) return; + + var step = steps[currentStepIndex]; + var element = createStepElement(step); + chatMessages.appendChild(element); + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function() { + element.classList.add('visible'); + smoothScrollToBottom(); + }); + } else { + element.classList.add('visible'); + smoothScrollToBottom(); + } + + currentStepIndex++; + updateProgress(); + + if (currentStepIndex < steps.length) { + var delay = calculateDelay(step); + startTimer(delay); + animationTimeout = setTimeout(showNextStep, delay); + } else { + // Check if we should auto-advance + if (autoAdvance && hasMultipleScenarios) { + // Advance to next scenario - it handles its own "Up Next" timing + advanceToNextScenario(); + } else { + isPlaying = false; + updateButtonStates(); + } + } + } + + function play() { + if (!hasStarted) { + playOverlay.classList.add('hidden'); + hasStarted = true; + isPlaying = true; + isPaused = false; + updateButtonStates(); + + var steps = getSteps(); + var firstStep = steps[0]; + var delay = calculateDelay(firstStep); + currentStepIndex = 1; + + startTimer(delay); + animationTimeout = setTimeout(showNextStep, delay); + return; + } + + if (isPaused) { + isPaused = false; + updateButtonStates(); + showNextStep(); + return; + } + + if (isPlaying) return; + + var steps = getSteps(); + if (currentStepIndex >= steps.length) { + currentStepIndex = 0; + clearChatMessages(); + var element = createStepElement(steps[0]); + element.classList.add('visible'); + chatMessages.appendChild(element); + currentStepIndex = 1; + + isPlaying = true; + isPaused = false; + updateButtonStates(); + + var delay = calculateDelay(steps[0]); + startTimer(delay); + animationTimeout = setTimeout(showNextStep, delay); + return; + } + + isPlaying = true; + isPaused = false; + updateButtonStates(); + showNextStep(); + } + + function pause() { + isPaused = true; + updateButtonStates(); + stopTimer(); + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + } + } + + function reset() { + switchToScenario(currentScenario); + } + + function showAllInstantly() { + var steps = getSteps(); + clearChatMessages(); + playOverlay.classList.add('hidden'); + hasStarted = true; + + steps.forEach(function(step) { + var element = createStepElement(step); + element.classList.add('visible'); + chatMessages.appendChild(element); + }); + + currentStepIndex = steps.length; + isPlaying = false; + updateProgress(); + updateButtonStates(); + } + + // Event listeners + playOverlay.addEventListener('click', function() { + if (prefersReducedMotion) { + showAllInstantly(); + } else { + play(); + } + }); + + playOverlay.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (prefersReducedMotion) { + showAllInstantly(); + } else { + play(); + } + } + }); + + // Tab switching logic (shared between click and keyboard) + function switchTab(tab) { + var scenarioId = tab.getAttribute('data-scenario'); + if (scenarioId === currentScenario) return; // Already on this tab + + // Remember if we were playing before switching + var wasPlaying = isPlaying && !isPaused; + + // Stop any current animation + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + } + stopTimer(); + + // Fade out, switch, fade in + chatContainer.classList.add('fading'); + setTimeout(function() { + currentScenario = scenarioId; + currentStepIndex = 0; + isPlaying = false; + isPaused = false; + hasStarted = false; + + updateTabStates(); + clearChatMessages(); + + // Keep overlay hidden to avoid flash + playOverlay.classList.add('hidden'); + chatContainer.classList.remove('fading'); + + // If was playing, continue playing; otherwise stay paused + if (wasPlaying && !prefersReducedMotion) { + play(); + } else if (prefersReducedMotion) { + showAllInstantly(); + } else { + // Stay paused - show initial preview but keep overlay hidden + // User can click play button or canvas to start + showInitialPreview(); + } + }, 300); + } + + // Tab click handlers + tabs.forEach(function(tab) { + tab.addEventListener('click', function() { + switchTab(tab); + }); + }); + + // Tab keyboard navigation (arrow keys per ARIA authoring practices) + tabs.forEach(function(tab, index) { + tab.addEventListener('keydown', function(e) { + var tabArray = Array.prototype.slice.call(tabs); + var currentIndex = tabArray.indexOf(tab); + var newIndex = currentIndex; + + if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + newIndex = currentIndex === 0 ? tabArray.length - 1 : currentIndex - 1; + e.preventDefault(); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + newIndex = currentIndex === tabArray.length - 1 ? 0 : currentIndex + 1; + e.preventDefault(); + } else if (e.key === 'Home') { + newIndex = 0; + e.preventDefault(); + } else if (e.key === 'End') { + newIndex = tabArray.length - 1; + e.preventDefault(); + } + + if (newIndex !== currentIndex) { + tabArray[newIndex].focus(); + switchTab(tabArray[newIndex]); + } + }); + }); + + function togglePlayPause() { + if (isPlaying && !isPaused) { + pause(); + } else { + play(); + } + } + + playPauseBtn.addEventListener('click', togglePlayPause); + resetBtn.addEventListener('click', reset); + + speedSelect.addEventListener('change', function(e) { + speed = parseFloat(e.target.value); + }); + + // Handle page visibility changes (pause timer animation when tab is hidden) + if (typeof document.hidden !== 'undefined') { + document.addEventListener('visibilitychange', function() { + if (document.hidden && timerAnimationFrame) { + cancelAnimationFrame(timerAnimationFrame); + timerAnimationFrame = null; + } + }); + } + + // Initialize + updateTabStates(); + showInitialPreview(); + + if (prefersReducedMotion) { + showAllInstantly(); + } + })(); + `; +} + +/** + * Generate tabs HTML + */ +function generateTabsHtml(demo: Demo): string { + if (demo.scenarios.length <= 1) { + return ''; + } + + const tabButtons = demo.scenarios.map((scenario, i) => { + const activeClass = i === 0 ? ' active' : ''; + const ariaSelected = i === 0 ? 'true' : 'false'; + const tabIndex = i === 0 ? '0' : '-1'; // Only first tab in focus order initially + return ``; + }).join('\n '); + + return ` + + `; +} + +/** + * Generate the complete HTML document + */ +export function generateHtml(demo: Demo, options: BuildOptions = { outputPath: '' }): string { + const theme = options.theme ?? demo.meta.theme ?? 'chat'; + const includeHeader = options.includeHeader !== false; + const hasMultipleScenarios = demo.scenarios.length > 1; + const timerStyle = demo.meta.timerStyle ?? 'circle'; + const cornerStyle = demo.meta.cornerStyle ?? 'rounded'; + + const css = generateCss(theme, hasMultipleScenarios, demo.meta.colors, cornerStyle); + const js = generateJs(demo, timerStyle); + + const titleHtml = demo.meta.articleUrl + ? `${escapeHtml(demo.meta.title)}` + : escapeHtml(demo.meta.title); + + const descriptionHtml = demo.meta.description + ? `

${parseMarkdownLinks(demo.meta.description)}

` + : ''; + + const headerHtml = includeHeader + ? ` +
+

${titleHtml}

+ ${descriptionHtml} +
+ ` + : ''; + + const tabsHtml = generateTabsHtml(demo); + + const timerHtml = timerStyle === 'circle' + ? `` + : ``; + + return ` + + + + + ${escapeHtml(demo.meta.title)} + + + +
+ ${headerHtml} + ${tabsHtml} +
+
+
+
+ ${timerHtml} +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + +`; +} + +/** + * Build a demo file to HTML output + */ +export async function buildDemo( + demo: Demo, + outputPath: string, + options: Partial = {} +): Promise { + const html = generateHtml(demo, { ...options, outputPath }); + await Bun.write(outputPath, html); +} + diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..51d0880 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,430 @@ +/** + * Conversation Replay - YAML Parser + * + * Parses and validates scenario YAML files. + */ + +import { parse } from 'yaml'; +import type { Demo, DemoMeta, Scenario, Step, Participant, Theme, ColorConfig, TimerStyle, CornerStyle, SpeedConfig } from './types'; + +const VALID_THEMES: Theme[] = ['chat', 'email', 'slack', 'terminal', 'generic']; +const VALID_TIMER_STYLES: TimerStyle[] = ['bar', 'circle']; +const VALID_CORNER_STYLES: CornerStyle[] = ['rounded', 'straight']; + +export class ParseError extends Error { + constructor(message: string, public path?: string) { + super(message); + this.name = 'ParseError'; + } +} + +/** + * Parse YAML content into a Demo object + */ +export function parseDemo(yamlContent: string, filePath?: string): Demo { + let raw: unknown; + + try { + raw = parse(yamlContent); + } catch (e) { + throw new ParseError(`Invalid YAML syntax: ${(e as Error).message}`, filePath); + } + + if (!raw || typeof raw !== 'object') { + throw new ParseError('Demo must be an object', filePath); + } + + const data = raw as Record; + + if (!Array.isArray(data.scenarios)) { + throw new ParseError('Demo must have a "scenarios" array', filePath); + } + + const meta = validateDemoMeta(data.meta, filePath); + const scenarios = validateScenarios(data.scenarios as unknown[], filePath); + + return { meta, scenarios }; +} + +function validateDemoMeta(raw: unknown, filePath?: string): DemoMeta { + if (!raw || typeof raw !== 'object') { + throw new ParseError('meta section is required', filePath); + } + + const meta = raw as Record; + + if (typeof meta.title !== 'string' || !meta.title.trim()) { + throw new ParseError('meta.title is required and must be a non-empty string', filePath); + } + + const result: DemoMeta = { + title: meta.title.trim(), + }; + + if (meta.description !== undefined) { + if (typeof meta.description !== 'string') { + throw new ParseError('meta.description must be a string', filePath); + } + result.description = meta.description.trim(); + } + + if (meta.theme !== undefined) { + if (!VALID_THEMES.includes(meta.theme as Theme)) { + throw new ParseError( + `meta.theme must be one of: ${VALID_THEMES.join(', ')}`, + filePath + ); + } + result.theme = meta.theme as Theme; + } + + if (meta.articleUrl !== undefined) { + if (typeof meta.articleUrl !== 'string') { + throw new ParseError('meta.articleUrl must be a string', filePath); + } + result.articleUrl = meta.articleUrl; + } + + if (meta.hideHeaderInIframe !== undefined) { + result.hideHeaderInIframe = Boolean(meta.hideHeaderInIframe); + } + + if (meta.autoAdvance !== undefined) { + result.autoAdvance = Boolean(meta.autoAdvance); + } + + if (meta.annotationLabel !== undefined) { + if (typeof meta.annotationLabel !== 'string') { + throw new ParseError('meta.annotationLabel must be a string', filePath); + } + result.annotationLabel = meta.annotationLabel.trim(); + } + + if (meta.colors !== undefined) { + result.colors = validateColors(meta.colors, filePath); + } + + if (meta.timerStyle !== undefined) { + if (!VALID_TIMER_STYLES.includes(meta.timerStyle as TimerStyle)) { + throw new ParseError( + `meta.timerStyle must be one of: ${VALID_TIMER_STYLES.join(', ')}`, + filePath + ); + } + result.timerStyle = meta.timerStyle as TimerStyle; + } + + if (meta.cornerStyle !== undefined) { + if (!VALID_CORNER_STYLES.includes(meta.cornerStyle as CornerStyle)) { + throw new ParseError( + `meta.cornerStyle must be one of: ${VALID_CORNER_STYLES.join(', ')}`, + filePath + ); + } + result.cornerStyle = meta.cornerStyle as CornerStyle; + } + + if (meta.speed !== undefined) { + result.speed = validateSpeedConfig(meta.speed, filePath); + } + + return result; +} + +function validateSpeedConfig(raw: unknown, filePath?: string): SpeedConfig { + if (!raw || typeof raw !== 'object') { + throw new ParseError('meta.speed must be an object', filePath); + } + + const speed = raw as Record; + const result: SpeedConfig = {}; + + const numericFields: (keyof SpeedConfig)[] = [ + 'minDelay', 'maxDelay', 'msPerWord', 'annotationMultiplier', 'upNextDelay' + ]; + + for (const field of numericFields) { + if (speed[field] !== undefined) { + const value = speed[field]; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new ParseError( + `meta.speed.${field} must be a positive number`, + filePath + ); + } + result[field] = value; + } + } + + // Validate logical constraints + if (result.minDelay !== undefined && result.maxDelay !== undefined) { + if (result.minDelay > result.maxDelay) { + throw new ParseError( + 'meta.speed.minDelay cannot be greater than meta.speed.maxDelay', + filePath + ); + } + } + + return result; +} + +/** + * Validate a CSS color value to prevent CSS injection attacks. + * Allows: hex colors, rgb/rgba/hsl/hsla, named colors, CSS variables + * Blocks: semicolons, braces, and other CSS injection attempts + */ +function isValidCssColor(value: string): boolean { + // Block obvious injection attempts + if (/[;{}]/.test(value)) return false; + if (/url\s*\(/i.test(value)) return false; + if (/expression\s*\(/i.test(value)) return false; + if (/javascript:/i.test(value)) return false; + + // Allow common color formats + const validPatterns = [ + /^#[0-9a-f]{3,8}$/i, // hex colors + /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/i, // rgb() + /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/i, // rgba() + /^hsl\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*\)$/i, // hsl() + /^hsla\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*,\s*[\d.]+\s*\)$/i, // hsla() + /^var\(--[a-z0-9-]+\)$/i, // CSS variables + /^[a-z]+$/i, // named colors (red, blue, etc.) + /^transparent$/i, + /^inherit$/i, + /^currentColor$/i, + ]; + + return validPatterns.some(pattern => pattern.test(value.trim())); +} + +function validateColors(raw: unknown, filePath?: string): ColorConfig { + if (!raw || typeof raw !== 'object') { + throw new ParseError('meta.colors must be an object', filePath); + } + + const colors = raw as Record; + const result: ColorConfig = {}; + + const colorFields: (keyof ColorConfig)[] = [ + 'accent', 'pageBg', 'canvasBg', 'leftBg', 'leftBorder', + 'rightBg', 'rightBorder', 'tabInactiveColor' + ]; + + for (const field of colorFields) { + if (colors[field] !== undefined) { + if (typeof colors[field] !== 'string') { + throw new ParseError(`meta.colors.${field} must be a string`, filePath); + } + const colorValue = colors[field] as string; + if (!isValidCssColor(colorValue)) { + throw new ParseError( + `meta.colors.${field} contains invalid CSS color value: "${colorValue}"`, + filePath + ); + } + result[field] = colorValue; + } + } + + return result; +} + +function validateScenarios(raw: unknown[], filePath?: string): Scenario[] { + if (raw.length === 0) { + throw new ParseError('at least one scenario is required', filePath); + } + + const ids = new Set(); + + return raw.map((s, i) => { + if (!s || typeof s !== 'object') { + throw new ParseError(`scenarios[${i}] must be an object`, filePath); + } + + const scenario = s as Record; + const prefix = `scenarios[${i}]`; + + // Validate id + if (typeof scenario.id !== 'string' || !scenario.id.trim()) { + throw new ParseError(`${prefix}.id is required`, filePath); + } + + const id = scenario.id.trim(); + if (ids.has(id)) { + throw new ParseError(`duplicate scenario id: ${id}`, filePath); + } + ids.add(id); + + // Validate title + if (typeof scenario.title !== 'string' || !scenario.title.trim()) { + throw new ParseError(`${prefix}.title is required`, filePath); + } + + // Validate participants + const participants = validateParticipants(scenario.participants, prefix, filePath); + + // Validate steps + const steps = validateSteps(scenario.steps, participants, prefix, filePath); + + return { + id, + title: scenario.title.trim(), + participants, + steps, + }; + }); +} + +function validateParticipants( + raw: unknown, + prefix: string, + filePath?: string +): Participant[] { + if (!Array.isArray(raw)) { + throw new ParseError(`${prefix}.participants must be an array`, filePath); + } + + if (raw.length === 0) { + throw new ParseError(`${prefix}: at least one participant is required`, filePath); + } + + const ids = new Set(); + + return raw.map((p, i) => { + if (!p || typeof p !== 'object') { + throw new ParseError(`${prefix}.participants[${i}] must be an object`, filePath); + } + + const participant = p as Record; + + if (typeof participant.id !== 'string' || !participant.id.trim()) { + throw new ParseError(`${prefix}.participants[${i}].id is required`, filePath); + } + + const id = participant.id.trim(); + if (ids.has(id)) { + throw new ParseError(`${prefix}: duplicate participant id: ${id}`, filePath); + } + ids.add(id); + + if (typeof participant.label !== 'string' || !participant.label.trim()) { + throw new ParseError(`${prefix}.participants[${i}].label is required`, filePath); + } + + const role = participant.role ?? 'left'; + if (role !== 'left' && role !== 'right') { + throw new ParseError( + `${prefix}.participants[${i}].role must be 'left' or 'right'`, + filePath + ); + } + + return { + id, + label: participant.label.trim(), + role: role as 'left' | 'right', + }; + }); +} + +function validateSteps( + raw: unknown, + participants: Participant[], + prefix: string, + filePath?: string +): Step[] { + if (!Array.isArray(raw)) { + throw new ParseError(`${prefix}.steps must be an array`, filePath); + } + + if (raw.length === 0) { + throw new ParseError(`${prefix}: at least one step is required`, filePath); + } + + const participantIds = new Set(participants.map(p => p.id)); + + return raw.map((s, i) => { + if (!s || typeof s !== 'object') { + throw new ParseError(`${prefix}.steps[${i}] must be an object`, filePath); + } + + const step = s as Record; + const type = step.type ?? 'message'; + const stepPrefix = `${prefix}.steps[${i}]`; + + if (type === 'annotation') { + if (typeof step.content !== 'string' || !step.content.trim()) { + throw new ParseError(`${stepPrefix}.content is required for annotation`, filePath); + } + return { type: 'annotation' as const, content: step.content.trim() }; + } + + if (type === 'transition') { + if (typeof step.content !== 'string' || !step.content.trim()) { + throw new ParseError(`${stepPrefix}.content is required for transition`, filePath); + } + return { type: 'transition' as const, content: step.content.trim() }; + } + + if (type === 'message') { + if (typeof step.from !== 'string' || !step.from.trim()) { + throw new ParseError(`${stepPrefix}.from is required for message`, filePath); + } + + const from = step.from.trim(); + if (!participantIds.has(from)) { + throw new ParseError( + `${stepPrefix}.from "${from}" is not a valid participant id`, + filePath + ); + } + + if (typeof step.content !== 'string' || !step.content.trim()) { + throw new ParseError(`${stepPrefix}.content is required for message`, filePath); + } + + const result: Step = { + type: 'message', + from, + content: step.content.trim(), + }; + + if (step.codeBlock !== undefined) { + if (typeof step.codeBlock !== 'string') { + throw new ParseError(`${stepPrefix}.codeBlock must be a string`, filePath); + } + (result as any).codeBlock = step.codeBlock; + } + + if (step.footnote !== undefined) { + if (typeof step.footnote !== 'string') { + throw new ParseError(`${stepPrefix}.footnote must be a string`, filePath); + } + (result as any).footnote = step.footnote.trim(); + } + + return result; + } + + throw new ParseError( + `${stepPrefix}.type must be 'message', 'annotation', or 'transition'`, + filePath + ); + }); +} + +/** + * Load and parse a demo from a file path + */ +export async function loadDemo(filePath: string): Promise { + const file = Bun.file(filePath); + + if (!(await file.exists())) { + throw new ParseError(`File not found: ${filePath}`, filePath); + } + + const content = await file.text(); + return parseDemo(content, filePath); +} + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..35d47a1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,129 @@ +/** + * Conversation Replay - Type Definitions + * + * Defines the schema for YAML scenario files. + */ + +export type Theme = 'chat' | 'email' | 'slack' | 'terminal' | 'generic'; +export type ParticipantRole = 'left' | 'right'; +export type StepType = 'message' | 'annotation' | 'transition'; +export type TimerStyle = 'bar' | 'circle'; +export type CornerStyle = 'rounded' | 'straight'; + +/** + * Speed/timing configuration for frame progression + */ +export interface SpeedConfig { + /** Minimum delay between frames in ms (default: 3000) */ + minDelay?: number; + /** Maximum delay between frames in ms (default: 8000) */ + maxDelay?: number; + /** Milliseconds per word for reading time calculation (default: 200) */ + msPerWord?: number; + /** Multiplier for annotation display time (default: 1.15) */ + annotationMultiplier?: number; + /** Delay to show "Up Next" before transitioning in ms (default: 2500) */ + upNextDelay?: number; +} + +export interface Participant { + id: string; + label: string; + role: ParticipantRole; +} + +export interface MessageStep { + type: 'message'; + from: string; // participant id + content: string; + codeBlock?: string; + footnote?: string; +} + +export interface AnnotationStep { + type: 'annotation'; + content: string; +} + +export interface TransitionStep { + type: 'transition'; + content: string; +} + +export type Step = MessageStep | AnnotationStep | TransitionStep; + +/** + * A single scenario (one tab in a multi-scenario demo) + */ +export interface Scenario { + /** Unique identifier for this scenario (used in tabs) */ + id: string; + /** Display title shown in tab */ + title: string; + /** Participants in this scenario */ + participants: Participant[]; + /** Steps in this scenario */ + steps: Step[]; +} + +/** + * Custom color configuration + */ +export interface ColorConfig { + /** Primary accent color (used for buttons, links) */ + accent?: string; + /** Page background color (when not embedded) */ + pageBg?: string; + /** Chat canvas/container background color */ + canvasBg?: string; + /** Left participant message background */ + leftBg?: string; + /** Left participant message border */ + leftBorder?: string; + /** Right participant message background */ + rightBg?: string; + /** Right participant message border */ + rightBorder?: string; + /** Inactive tab text color */ + tabInactiveColor?: string; +} + +/** + * Overall demo metadata + */ +export interface DemoMeta { + title: string; + description?: string; + theme?: Theme; + /** Link back to related article */ + articleUrl?: string; + /** Whether to auto-hide header when embedded in iframe */ + hideHeaderInIframe?: boolean; + /** Whether to auto-advance to next scenario when one completes */ + autoAdvance?: boolean; + /** Label for annotation steps (default: "Behind the Scenes") */ + annotationLabel?: string; + /** Custom colors */ + colors?: ColorConfig; + /** Timer display style: 'bar' (horizontal line) or 'circle' (circular countdown). Default: 'circle' */ + timerStyle?: TimerStyle; + /** Corner style for chat container and bubbles. Default: 'rounded' */ + cornerStyle?: CornerStyle; + /** Speed/timing configuration for frame progression */ + speed?: SpeedConfig; +} + +/** + * A complete demo with one or more scenarios + */ +export interface Demo { + meta: DemoMeta; + scenarios: Scenario[]; +} + +export interface BuildOptions { + outputPath: string; + theme?: Theme; + /** Include demo header with title/description */ + includeHeader?: boolean; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}