First cut

This commit is contained in:
kadil
2026-04-17 16:08:31 -05:00
parent d10105ac00
commit 4ee4cb8e7c
58 changed files with 3243 additions and 1 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
dist
server/dist
server/node_modules
shared/node_modules
data
*.db
*.db-wal
*.db-shm
.env
.git
content/.backups

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# Required
API_EDIT_SECRET=change-me-to-a-random-string
# LLM (required to actually process edits)
OLLAMA_API_KEY=
# Paths
REPO_ROOT=.
IDEMPOTENCY_DB_PATH=./data/dynamic-sites.db
# SSR cache
SITE_DATA_TTL_MS=500
# SMS (Telnyx)
TELNYX_PUBLIC_KEY=
TELNYX_API_KEY=
# CORS
CORS_ALLOWED_ORIGIN=http://localhost:4321
# Logging
LOG_LEVEL=debug
# Proposals
PROPOSAL_TTL_MS=900000
# Editor auth
EDITOR_SESSION_SECRET=change-me-to-another-random-string
# Rate limits
SMS_RATE_LIMIT_PER_HOUR=10
MAX_UPLOAD_SIZE_BYTES=5242880

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
server/dist/
data/
*.db
*.db-wal
*.db-shm
.env
content/.backups/
.astro/

24
AGENTS.md Normal file
View File

@@ -0,0 +1,24 @@
# AGENTS.md — Rules for AI Agents Working in This Repo
## Core Principles
1. **Zod schemas are the contract.** Every content file, API payload, and LLM output is validated by Zod. Never write data that hasn't passed schema validation.
2. **No direct LLM calls in request handlers.** All LLM work runs in the queue consumer. Handlers enqueue and return immediately (202 Accepted).
3. **Canonical JSON everywhere.** All files under `content/`, `config/`, and `site-context.json` must use `stringifyCanonical()` — sorted keys, 2-space indent, trailing newline.
4. **No git in the orchestrator.** Content persistence is filesystem-only via `writeContentFile`. Deployment of images happens outside this codebase.
5. **Human confirmation before any write.** The propose → summary → YES/NO → apply pipeline must be followed for all edit channels (SMS, HTTP, editor).
6. **SQLite, not Redis.** Idempotency, proposals, rate limits, and audit logs all live in a single SQLite file. No BullMQ, no Redis.
## Anti-Patterns
- Do NOT call `fs.writeFileSync` directly on content files. Always use `writeContentFile`.
- Do NOT skip Zod validation before writing.
- Do NOT put LLM calls inside Express route handlers.
- Do NOT add git operations (commit, push, etc.) anywhere in the orchestrator.
- Do NOT use `console.log` — use the structured `logger` from `server/src/logger.ts`.
- Do NOT expose raw JSON or LLM output in SMS replies — use SMS_TEMPLATES.

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
FROM node:22-alpine AS base
WORKDIR /app
# Install deps
COPY package.json package-lock.json* ./
COPY shared/package.json shared/
COPY server/package.json server/
RUN npm install --ignore-scripts
# Copy source
COPY . .
# Build
RUN npm run build
# Production
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY --from=base /app/shared ./shared
COPY --from=base /app/dist ./dist
COPY --from=base /app/package.json ./
COPY --from=base /app/site-context.json ./
COPY --from=base /app/content ./content
COPY --from=base /app/config ./config
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "dist/server/entry.mjs"]

144
README.md
View File

@@ -1,2 +1,144 @@
# dynamic-sites-simple
# Dynamic Sites
An LLM-powered website editing framework. Edit your site via SMS, a web API, or a visual editor — all driven by natural language.
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Channels │
│ SMS (Telnyx) │ POST /api/edit │ /editor │
└───────┬─────────┴────────┬─────────┴─────┬──────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ Orchestrator (Express, port 3001) │
│ │
│ Webhook ──► Idempotency ──► Rate Limit ──► │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ In-Process FIFO Queue (concurrency 1) │
│ │ │ │
│ │ propose: route ► LLM ► proposal │ │
│ │ apply: validate ► writeContentFile│ │
│ └──────────────────────────────────────┘ │
│ │
│ SQLite: idempotency, proposals, rate limits, │
│ audit log │
└───────────────────────┬─────────────────────────┘
│ writes canonical JSON
┌──────────────────┐
│ content/ (JSON) │ ◄── shared volume
│ site-context.json│
└────────┬─────────┘
│ reads with TTL cache
┌──────────────────┐
│ Astro SSR │
│ (port 4321) │
│ Homepage + Editor│
└──────────────────┘
```
## Quick Start
### Prerequisites
- Node.js 22+
- npm
### Local Development
```bash
# Clone and install
npm install
# Start the Astro dev server (port 4321)
npm run dev
# In another terminal, start the orchestrator (port 3001)
npm run dev:server
# Visit http://localhost:4321 — the demo site renders from fixtures
# Visit http://localhost:4321/editor — log in with API_EDIT_SECRET
```
### Environment Variables
Copy `.env.example` to `.env` and set at minimum:
- `API_EDIT_SECRET` — shared secret for API auth and editor login
- `OLLAMA_API_KEY` — required for LLM-powered edits
See `.env.example` for all options.
### Docker
```bash
docker compose build
docker compose up -d
# Site: http://localhost:4321
# Orchestrator: http://localhost:3001/health
```
## Project Structure
```
├── content/ # Canonical JSON content (the "database")
│ ├── sections/ # One JSON file per site section
│ ├── events.json # Upcoming events
│ └── .backups/ # Pre-apply backups (auto-managed)
├── config/
│ └── sms-sites.json # SMS routing allowlist
├── site-context.json # Brand tone, style, LLM prompt context
├── shared/ # Zod schemas + canonical JSON (workspace pkg)
│ └── src/
│ ├── schemas/index.ts # All Zod schemas (the contract)
│ ├── canonical-json.ts # Sorted-key JSON serialization
│ └── repo-validation.ts# Path → schema mapping
├── server/ # Orchestrator (workspace pkg)
│ └── src/
│ ├── index.ts # Entrypoint + graceful shutdown
│ ├── app.ts # Express app factory
│ ├── db.ts # SQLite (idempotency, proposals, audit)
│ ├── logger.ts # Structured logging (pino)
│ ├── queue/ # FIFO queue + job processor
│ ├── routes/ # API edit, SMS webhook, health
│ ├── llm/ # Ollama client with retry/validation
│ ├── sms/ # Telnyx parse, reply, templates
│ └── io/ # Filesystem writer (atomic, with backup)
├── src/ # Astro SSR site
│ ├── pages/
│ │ ├── index.astro # Homepage (renders from content/)
│ │ └── editor.astro # Editor (auth-gated React island)
│ ├── lib/
│ │ ├── site-bundle.ts # Content parser + validator
│ │ └── site-data.ts # Disk reader with TTL cache
│ ├── layouts/
│ │ └── BaseLayout.astro
│ └── components/
│ ├── sections/ # Astro section components
│ └── editor/ # React editor island
├── scripts/ # CLI tools
├── docker-compose.yml # Full stack (web + orchestrator)
├── Dockerfile # SSR site image
└── server/Dockerfile # Orchestrator image
```
## Edit Flow
1. **User sends a natural language message** (SMS, HTTP, or editor)
2. **Route**: LLM determines which content file to edit
3. **Propose**: LLM generates new JSON + plain-language summary
4. **Confirm**: User replies YES/NO (SMS) or clicks confirm (editor/HTTP)
5. **Apply**: Validated JSON is written to disk via atomic write
6. **Live**: Astro SSR picks up the change on next request (TTL cache)
## Key Design Decisions
- **No Redis, no BullMQ**: Simple in-process FIFO queue with concurrency 1
- **No git**: Content persistence is filesystem-only
- **SQLite for everything**: Idempotency, proposals, rate limits, audit log
- **Zod is the contract**: Schemas drive validation at every boundary
- **Atomic writes**: temp file + rename prevents partial writes
- **Pre-write backups**: Last 20 versions per file under `content/.backups/`

10
astro.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [react()],
server: { port: 4321 },
});

10
config/sms-sites.json Normal file
View File

@@ -0,0 +1,10 @@
{
"sites": [
{
"allowedSenders": ["+15035550100"],
"phoneNumber": "+15035550142",
"repoRoot": ".",
"siteId": "timber-and-grain"
}
]
}

28
content/events.json Normal file
View File

@@ -0,0 +1,28 @@
{
"events": [
{
"date": "2026-04-25",
"description": "Taste three new single-origin roasts and vote for our next house blend. Free with any purchase.",
"id": "cupping-apr",
"location": "Timber & Grain Cafe",
"time": "6:00 PM",
"title": "Friday Cupping Session"
},
{
"date": "2026-05-03",
"description": "Learn the basics of latte art from our head barista, Sam. Includes materials and two drinks. $25/person.",
"id": "latte-art-may",
"location": "Timber & Grain Workshop",
"time": "10:00 AM",
"title": "Latte Art 101 Workshop"
},
{
"date": "2026-05-10",
"description": "Local acoustic acts every second Saturday. Grab a cold brew and enjoy the vibes.",
"id": "live-music-may",
"location": "Timber & Grain Patio",
"time": "7:00 PM",
"title": "Live Music Night"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"content": "Timber & Grain started in 2018 with a simple idea: great coffee doesn't need to be complicated. We source beans directly from farmers in Ethiopia, Colombia, and Guatemala, roast them in our Portland workshop, and serve them in our cozy cafe on Evergreen Terrace. Every cup tells a story — from the farm to your hands.",
"id": "about",
"order": 2,
"title": "Our Story",
"type": "about",
"visible": true
}

View File

@@ -0,0 +1,29 @@
{
"id": "features",
"items": [
{
"description": "We work directly with farmers in Ethiopia, Colombia, and Guatemala to bring you the freshest, most flavorful beans.",
"icon": "coffee",
"title": "Single-Origin Beans"
},
{
"description": "Every batch is roasted by hand in our Portland workshop. We roast weekly to ensure peak freshness.",
"icon": "flame",
"title": "Small-Batch Roasting"
},
{
"description": "From pour-over to espresso, our baristas craft each cup with precision and care.",
"icon": "cup",
"title": "Expert Brewing"
},
{
"description": "Join us for cupping sessions, latte art workshops, and live music nights. This is your neighborhood spot.",
"icon": "calendar",
"title": "Community Events"
}
],
"order": 3,
"title": "What We Offer",
"type": "features",
"visible": true
}

View File

@@ -0,0 +1,11 @@
{
"ctaLink": "/events",
"ctaText": "See What's Brewing",
"headline": "Coffee Worth Waking Up For",
"id": "hero",
"image": "",
"order": 1,
"subheading": "Single-origin beans, roasted in small batches right here in Portland. Stop by for a pour-over, stay for the community.",
"type": "hero",
"visible": true
}

View File

@@ -0,0 +1,8 @@
{
"content": "For a limited time, enjoy 20% off all whole-bean bags. Use code FRESHBREW at checkout or mention this promo in-store. Valid through the end of the month!",
"heading": "Spring Roast Sale — 20% Off Beans!",
"id": "promo-banner",
"order": 0,
"type": "text",
"visible": false
}

View File

@@ -0,0 +1,24 @@
{
"id": "testimonials",
"items": [
{
"author": "Maria Chen",
"quote": "Best pour-over in Portland, hands down. The Ethiopian Yirgacheffe changed my morning routine forever.",
"role": "Regular since 2019"
},
{
"author": "Jake Thornton",
"quote": "I came for the coffee and stayed for the people. Timber & Grain feels like home.",
"role": "Neighborhood local"
},
{
"author": "Priya Sharma",
"quote": "Their cupping events are incredible — I've learned more about coffee here than anywhere else.",
"role": "Home roasting enthusiast"
}
],
"order": 4,
"title": "What Our Regulars Say",
"type": "testimonials",
"visible": true
}

View File

@@ -0,0 +1,8 @@
{
"content": "Visit us at 742 Evergreen Terrace, Portland, OR 97201. Open Monday through Saturday, 6:30 AM to 6 PM. Sunday 8 AM to 4 PM. Questions? Drop us a line at hello@timberandgrain.co or call (503) 555-0142.",
"heading": "Find Us",
"id": "contact",
"order": 6,
"type": "text",
"visible": true
}

52
docker-compose.yml Normal file
View File

@@ -0,0 +1,52 @@
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "4321:4321"
volumes:
- content-data:/app/content
- ./site-context.json:/app/site-context.json
environment:
- HOST=0.0.0.0
- PORT=4321
- REPO_ROOT=/app
- SITE_DATA_TTL_MS=${SITE_DATA_TTL_MS:-500}
- EDITOR_SESSION_SECRET=${EDITOR_SESSION_SECRET:-change-me}
- API_EDIT_SECRET=${API_EDIT_SECRET:-change-me}
- PUBLIC_ORCHESTRATOR_URL=http://localhost:3001
depends_on:
- orchestrator
restart: unless-stopped
orchestrator:
build:
context: .
dockerfile: server/Dockerfile
ports:
- "3001:3001"
volumes:
- content-data:/app/content
- ./site-context.json:/app/site-context.json
- sqlite-data:/app/data
environment:
- ORCHESTRATOR_PORT=3001
- REPO_ROOT=/app
- IDEMPOTENCY_DB_PATH=/app/data/dynamic-sites.db
- SITE_DATA_TTL_MS=${SITE_DATA_TTL_MS:-500}
- API_EDIT_SECRET=${API_EDIT_SECRET:-change-me}
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
- OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.com}
- TELNYX_API_KEY=${TELNYX_API_KEY:-}
- TELNYX_PUBLIC_KEY=${TELNYX_PUBLIC_KEY:-}
- CORS_ALLOWED_ORIGIN=http://localhost:4321
- LOG_LEVEL=${LOG_LEVEL:-info}
- PROPOSAL_TTL_MS=${PROPOSAL_TTL_MS:-900000}
- SMS_RATE_LIMIT_PER_HOUR=${SMS_RATE_LIMIT_PER_HOUR:-10}
stop_grace_period: 35s
restart: unless-stopped
volumes:
content-data:
sqlite-data:

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "dynamic-sites",
"type": "module",
"private": true,
"engines": {
"node": ">=22"
},
"workspaces": [
"shared",
"server"
],
"scripts": {
"dev": "astro dev",
"dev:server": "npm run dev --workspace=server",
"build": "npm run check:content && astro build",
"start": "node dist/server/entry.mjs",
"check:content": "node scripts/validate-content.js && node scripts/check-canonical.js",
"test": "vitest run",
"check": "npm run check:content && npm test"
},
"dependencies": {
"astro": "^5.8.0",
"@astrojs/node": "^9.1.3",
"@astrojs/react": "^4.2.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^3.24.0",
"@dynamic-sites/shared": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}
}

26
scripts/canonicalize.js Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const { stringifyCanonical } = await import(path.join(root, 'shared/src/canonical-json.ts'));
const files = [
'site-context.json',
'content/events.json',
'config/sms-sites.json',
...fs.readdirSync(path.join(root, 'content/sections'))
.filter(f => f.endsWith('.json'))
.map(f => `content/sections/${f}`),
];
for (const rel of files) {
const abs = path.join(root, rel);
const parsed = JSON.parse(fs.readFileSync(abs, 'utf-8'));
fs.writeFileSync(abs, stringifyCanonical(parsed));
console.log(`${rel}`);
}
console.log('\nDone. All files canonicalized.');

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const { stringifyCanonical } = await import(path.join(root, 'shared/src/canonical-json.ts'));
const files = [
'site-context.json',
'content/events.json',
'config/sms-sites.json',
...fs.readdirSync(path.join(root, 'content/sections'))
.filter(f => f.endsWith('.json'))
.map(f => `content/sections/${f}`),
];
const errors = [];
console.log('Checking canonical JSON format...');
for (const rel of files) {
const abs = path.join(root, rel);
const raw = fs.readFileSync(abs, 'utf-8');
const parsed = JSON.parse(raw);
const canonical = stringifyCanonical(parsed);
if (raw === canonical) {
console.log(`${rel}`);
} else {
errors.push(rel);
}
}
if (errors.length > 0) {
console.error('\n✗ Non-canonical files (run `node scripts/canonicalize.js` to fix):');
errors.forEach(e => console.error(` ${e}`));
process.exit(1);
} else {
console.log('\n✓ All files are canonical.');
}

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
// Dynamic import of shared schemas
const {
siteContextSchema,
sectionFileSchema,
eventsFileSchema,
smsSitesConfigSchema,
} = await import(path.join(root, 'shared/src/schemas/index.ts'));
const errors = [];
function validate(filePath, schema, label) {
const rel = path.relative(root, filePath);
try {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const result = schema.safeParse(raw);
if (!result.success) {
errors.push(`${rel}: ${result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
} else {
console.log(`${rel}`);
}
} catch (e) {
errors.push(`${rel}: ${e.message}`);
}
}
console.log('Validating content...');
// site-context.json
validate(path.join(root, 'site-context.json'), siteContextSchema, 'site-context');
// content/events.json
validate(path.join(root, 'content/events.json'), eventsFileSchema, 'events');
// config/sms-sites.json
validate(path.join(root, 'config/sms-sites.json'), smsSitesConfigSchema, 'sms-sites');
// content/sections/*.json
const sectionsDir = path.join(root, 'content/sections');
if (fs.existsSync(sectionsDir)) {
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
validate(path.join(sectionsDir, file), sectionFileSchema, file);
}
}
if (errors.length > 0) {
console.error('\n✗ Validation failed:');
errors.forEach(e => console.error(` ${e}`));
process.exit(1);
} else {
console.log('\n✓ All content valid.');
}

26
server/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:22-alpine AS base
WORKDIR /app
# Install deps (build from repo root so shared/ resolves)
COPY package.json package-lock.json* ./
COPY shared/package.json shared/
COPY server/package.json server/
RUN npm install --ignore-scripts
# Rebuild native modules (better-sqlite3)
RUN npm rebuild better-sqlite3
# Copy source
COPY shared ./shared
COPY server ./server
COPY site-context.json ./
COPY content ./content
COPY config ./config
WORKDIR /app/server
ENV NODE_ENV=production
EXPOSE 3001
CMD ["npx", "tsx", "src/index.ts"]

28
server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@dynamic-sites/server",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@dynamic-sites/shared": "workspace:*",
"better-sqlite3": "^11.8.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.2",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}
}

55
server/src/app.ts Normal file
View File

@@ -0,0 +1,55 @@
import express, { type Express } from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { createHealthRouter } from './routes/health.js';
import { createApiEditRouter } from './routes/api-edit.js';
import { createWebhookSmsRouter } from './routes/webhook-sms.js';
import type { EditQueue } from './queue/edit-queue.js';
export interface CreateAppDeps {
queue: EditQueue;
}
export function createApp(deps: CreateAppDeps): Express {
const app = express();
// Telnyx webhook needs raw body for signature verification — mount BEFORE json parser
app.use('/webhooks', express.raw({ type: '*/*' }), (req, _res, next) => {
// Parse raw body to JSON for webhook handler
if (req.body && Buffer.isBuffer(req.body)) {
try {
req.body = JSON.parse(req.body.toString());
} catch { /* leave as-is */ }
}
next();
});
// JSON parser for everything else
app.use(express.json());
// CORS for editor cross-origin requests
const allowedOrigin = process.env.CORS_ALLOWED_ORIGIN || 'http://localhost:4321';
app.use('/api', cors({
origin: allowedOrigin,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
}));
// Rate limiting on API edit routes
const apiLimiter = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests' },
});
app.use('/api/edit', apiLimiter);
// Mount routes
app.use('/', createHealthRouter());
app.use('/api', createApiEditRouter({ queue: deps.queue }));
app.use('/webhooks', createWebhookSmsRouter({ queue: deps.queue }));
return app;
}

161
server/src/db.ts Normal file
View File

@@ -0,0 +1,161 @@
import Database from 'better-sqlite3';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
const DB_PATH = process.env.IDEMPOTENCY_DB_PATH || process.env.DATABASE_PATH || './data/dynamic-sites.db';
let db: Database.Database | null = null;
export function openDb(): Database.Database {
if (db) return db;
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
runMigrations(db);
return db;
}
function runMigrations(d: Database.Database) {
d.exec(`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pending_proposals (
proposal_id TEXT PRIMARY KEY,
repo_relative_path TEXT NOT NULL,
proposed_json TEXT NOT NULL,
summary_text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','applied','rejected','expired')),
source TEXT NOT NULL DEFAULT 'http',
phone_hash TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sms_rate_limits (
phone_hash TEXT NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (phone_hash, window_start)
);
CREATE TABLE IF NOT EXISTS edit_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proposal_id TEXT,
repo_relative_path TEXT NOT NULL,
before_hash TEXT,
after_hash TEXT,
applied_at INTEGER NOT NULL DEFAULT (unixepoch()),
source TEXT NOT NULL
);
`);
}
// ── Idempotency ──
export function claimOnce(key: string, ttlSeconds: number = 3600): boolean {
const d = openDb();
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds;
const result = d.prepare('INSERT OR IGNORE INTO idempotency_keys (key, expires_at) VALUES (?, ?)').run(key, expiresAt);
return result.changes > 0;
}
export function pruneIdempotencyKeys(): void {
const d = openDb();
d.prepare('DELETE FROM idempotency_keys WHERE expires_at < ?').run(Math.floor(Date.now() / 1000));
}
// ── Proposals ──
export interface ProposalRow {
proposal_id: string;
repo_relative_path: string;
proposed_json: string;
summary_text: string;
status: string;
source: string;
phone_hash: string | null;
created_at: number;
expires_at: number;
}
export function createProposal(params: {
proposalId: string;
repoRelativePath: string;
proposedJson: string;
summaryText: string;
source: string;
phoneHash?: string;
ttlMs?: number;
}): void {
const d = openDb();
const ttl = params.ttlMs || parseInt(process.env.PROPOSAL_TTL_MS || '900000', 10);
const expiresAt = Math.floor(Date.now() / 1000) + Math.floor(ttl / 1000);
if (params.phoneHash) {
d.prepare(`UPDATE pending_proposals SET status = 'expired' WHERE phone_hash = ? AND status = 'pending'`).run(params.phoneHash);
}
d.prepare(`INSERT INTO pending_proposals (proposal_id, repo_relative_path, proposed_json, summary_text, source, phone_hash, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
params.proposalId, params.repoRelativePath, params.proposedJson, params.summaryText, params.source, params.phoneHash || null, expiresAt
);
}
export function getProposal(proposalId: string): ProposalRow | null {
return openDb().prepare('SELECT * FROM pending_proposals WHERE proposal_id = ?').get(proposalId) as ProposalRow | null;
}
export function getPendingProposalByPhone(phoneHash: string): ProposalRow | null {
return openDb().prepare(`SELECT * FROM pending_proposals WHERE phone_hash = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1`).get(phoneHash) as ProposalRow | null;
}
export function updateProposalStatus(proposalId: string, status: 'applied' | 'rejected' | 'expired'): void {
openDb().prepare('UPDATE pending_proposals SET status = ? WHERE proposal_id = ?').run(status, proposalId);
}
export function pruneExpiredProposals(): void {
openDb().prepare(`UPDATE pending_proposals SET status = 'expired' WHERE expires_at < ? AND status = 'pending'`).run(Math.floor(Date.now() / 1000));
}
// ── Rate Limiting ──
export function checkSmsRateLimit(phoneHash: string, maxPerHour: number = 10): boolean {
const d = openDb();
const now = Math.floor(Date.now() / 1000);
const windowStart = now - 3600;
d.prepare('DELETE FROM sms_rate_limits WHERE window_start < ?').run(windowStart);
const row = d.prepare('SELECT SUM(count) as total FROM sms_rate_limits WHERE phone_hash = ? AND window_start >= ?').get(phoneHash, windowStart) as { total: number | null } | undefined;
if ((row?.total || 0) >= maxPerHour) return false;
const currentWindow = Math.floor(now / 60) * 60;
d.prepare('INSERT INTO sms_rate_limits (phone_hash, window_start, count) VALUES (?, ?, 1) ON CONFLICT(phone_hash, window_start) DO UPDATE SET count = count + 1').run(phoneHash, currentWindow);
return true;
}
// ── Audit ──
export function writeAuditLog(params: {
proposalId?: string;
repoRelativePath: string;
beforeHash?: string;
afterHash?: string;
source: string;
}): void {
openDb().prepare('INSERT INTO edit_audit_log (proposal_id, repo_relative_path, before_hash, after_hash, source) VALUES (?, ?, ?, ?, ?)').run(
params.proposalId || null, params.repoRelativePath, params.beforeHash || null, params.afterHash || null, params.source
);
}
// ── Helpers ──
export function hashPhone(phone: string): string {
return crypto.createHash('sha256').update(phone).digest('hex').slice(0, 16);
}
export function closeDb(): void {
if (db) { db.close(); db = null; }
}

61
server/src/index.ts Normal file
View File

@@ -0,0 +1,61 @@
import { createApp } from './app.js';
import { createEditQueue } from './queue/edit-queue.js';
import { processEditJob } from './queue/process-edit-job.js';
import { openDb, closeDb, pruneExpiredProposals, pruneIdempotencyKeys } from './db.js';
import { logger } from './logger.js';
const PORT = parseInt(process.env.ORCHESTRATOR_PORT || '3001', 10);
export async function startServer() {
// Initialize database
openDb();
logger.info({ event: 'db.opened' }, 'SQLite database opened');
// Create queue and wire consumer
const queue = createEditQueue();
queue.startConsumer(processEditJob);
// Periodic cleanup
const cleanupInterval = setInterval(() => {
pruneExpiredProposals();
pruneIdempotencyKeys();
}, 60_000);
// Create and start HTTP server
const app = createApp({ queue });
const server = app.listen(PORT, () => {
logger.info({ event: 'server.started', port: PORT }, `Orchestrator listening on port ${PORT}`);
});
// Graceful shutdown
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ event: 'server.shutdown', signal }, `Received ${signal}, shutting down...`);
clearInterval(cleanupInterval);
// Stop accepting new connections
server.close(() => {
logger.info({ event: 'server.closed' }, 'HTTP server closed');
});
// Drain queue (finish current job)
await queue.shutdown();
// Close database
closeDb();
logger.info({ event: 'db.closed' }, 'Database closed');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
startServer().catch(err => {
logger.fatal({ error: (err as Error).message }, 'Failed to start server');
process.exit(1);
});

View File

@@ -0,0 +1,68 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { stringifyCanonical } from '@dynamic-sites/shared';
import { writeAuditLog } from '../db.js';
import { logger } from '../logger.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const MAX_BACKUPS = 20;
function ensureDir(dir: string) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function fileHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
}
/**
* Atomic write of canonical JSON to a repo-relative path.
* Creates a pre-write backup and audit log entry.
*/
export function writeContentFile(
repoRelativePath: string,
data: unknown,
opts?: { proposalId?: string; source?: string }
): void {
const absPath = path.join(REPO_ROOT, repoRelativePath);
const canonical = stringifyCanonical(data);
// Pre-write backup
let beforeHash: string | undefined;
if (fs.existsSync(absPath)) {
const existing = fs.readFileSync(absPath, 'utf-8');
beforeHash = fileHash(existing);
const backupDir = path.join(REPO_ROOT, 'content', '.backups', repoRelativePath);
ensureDir(backupDir);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`));
// Prune old backups
const backups = fs.readdirSync(backupDir).sort();
while (backups.length > MAX_BACKUPS) {
const oldest = backups.shift()!;
fs.unlinkSync(path.join(backupDir, oldest));
}
}
// Atomic write: temp file + rename
ensureDir(path.dirname(absPath));
const tmpPath = absPath + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, canonical, 'utf-8');
fs.renameSync(tmpPath, absPath);
const afterHash = fileHash(canonical);
// Audit log
writeAuditLog({
proposalId: opts?.proposalId,
repoRelativePath,
beforeHash,
afterHash,
source: opts?.source || 'http',
});
logger.info({ event: 'content.written', path: repoRelativePath, size: canonical.length }, 'Content file written');
}

172
server/src/llm/client.ts Normal file
View File

@@ -0,0 +1,172 @@
import { z } from 'zod';
import { routingOutputSchema, type RoutingOutput } from '@dynamic-sites/shared';
import { logger } from '../logger.js';
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'https://ollama.com';
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || '';
const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud';
const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || 'gpt-oss:120b';
const MAX_RETRIES = 3;
export interface LlmChatCaller {
(messages: Array<{ role: string; content: string }>, model: string): Promise<string>;
}
/** Default chat caller using Ollama HTTP API */
async function ollamaChat(messages: Array<{ role: string; content: string }>, model: string): Promise<string> {
const resp = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(OLLAMA_API_KEY ? { Authorization: `Bearer ${OLLAMA_API_KEY}` } : {}),
},
body: JSON.stringify({ model, messages, stream: false }),
});
if (!resp.ok) {
throw new Error(`Ollama ${resp.status}: ${await resp.text().catch(() => 'no body')}`);
}
const data = await resp.json() as { message?: { content?: string } };
return data.message?.content || '';
}
/**
* Validate-then-retry loop: parse LLM output as JSON, validate against schema,
* re-prompt with errors if invalid, up to MAX_RETRIES per model.
*/
async function generateWithValidation<T>(params: {
messages: Array<{ role: string; content: string }>;
schema: z.ZodType<T>;
chat?: LlmChatCaller;
}): Promise<T> {
const chat = params.chat || ollamaChat;
const models = [PRIMARY_MODEL, FALLBACK_MODEL];
for (const model of models) {
const msgs = [...params.messages];
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
logger.debug({ event: 'llm.request', model, attempt }, 'LLM call');
try {
const raw = await chat(msgs, model);
// Extract JSON from response (handle markdown code blocks)
const jsonMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, raw];
const jsonStr = (jsonMatch[1] || raw).trim();
const parsed = JSON.parse(jsonStr);
const result = params.schema.safeParse(parsed);
if (result.success) return result.data;
const errors = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('\n');
logger.debug({ event: 'llm.retry', model, attempt, errors }, 'Validation failed, retrying');
msgs.push(
{ role: 'assistant', content: raw },
{ role: 'user', content: `Your JSON output failed validation:\n${errors}\n\nPlease fix the issues and return valid JSON only.` }
);
} catch (err) {
logger.warn({ event: 'llm.retry', model, attempt, error: (err as Error).message }, 'LLM call or parse failed');
if (attempt === MAX_RETRIES - 1) break;
msgs.push(
{ role: 'user', content: `Your response was not valid JSON. Please respond with ONLY a JSON object, no markdown or extra text.` }
);
}
}
logger.warn({ event: 'llm.fallback', from: model }, 'Exhausted retries, trying fallback');
}
logger.error({ event: 'llm.exhausted' }, 'All LLM models exhausted');
throw new Error('LLM_UNAVAILABLE');
}
// ── Public API ──
export interface GenerateEditedJsonParams {
currentJson: unknown;
siteContext: unknown;
userMessage: string;
repoRelativePath: string;
schema: z.ZodTypeAny;
}
export async function generateEditedJson(params: GenerateEditedJsonParams, chat?: LlmChatCaller): Promise<unknown> {
const messages = [
{
role: 'system',
content: `You are a website content editor. You edit JSON content files for a website.
SITE CONTEXT:
${JSON.stringify(params.siteContext, null, 2)}
You will receive the current JSON content of a section file and a natural language edit request.
Return ONLY the complete updated JSON object — no explanation, no markdown, just the JSON.
Preserve all existing fields and structure. Only change what the user requested.
The output must be valid JSON matching the exact same schema as the input.`,
},
{
role: 'user',
content: `Current content of "${params.repoRelativePath}":\n\`\`\`json\n${JSON.stringify(params.currentJson, null, 2)}\n\`\`\`\n\nEdit request: "${params.userMessage}"\n\nReturn the complete updated JSON:`,
},
];
return generateWithValidation({ messages, schema: params.schema, chat });
}
export interface RouteEditIntentParams {
userMessage: string;
manifest: Array<{ id: string; type: string; title?: string; headline?: string; heading?: string; repo_relative_path: string; visible: boolean }>;
}
export async function routeEditIntent(params: RouteEditIntentParams, chat?: LlmChatCaller): Promise<RoutingOutput> {
const messages = [
{
role: 'system',
content: `You are a routing assistant for a website CMS. Given a natural language edit request and a manifest of available content sections, determine which section file the edit applies to.
Return a JSON object with:
- "repo_relative_path": the path of the target section file
- "needs_clarification": true if the request is ambiguous
- "reason": short explanation
- "clarification_message": (only if needs_clarification) a question to ask the user
If the request is about showing/hiding/enabling/disabling a section, route to that section's file.
If the request mentions events, route to "content/events.json".`,
},
{
role: 'user',
content: `MANIFEST:\n${JSON.stringify(params.manifest, null, 2)}\n\nEDIT REQUEST: "${params.userMessage}"\n\nReturn JSON:`,
},
];
return generateWithValidation({ messages, schema: routingOutputSchema, chat });
}
/** Simple summary generation (no schema validation needed) */
export async function generateSummary(params: {
before: unknown;
after: unknown;
repoRelativePath: string;
userMessage: string;
chat?: LlmChatCaller;
}): Promise<string> {
const chat = params.chat || ollamaChat;
const messages = [
{
role: 'system',
content: `You summarize content changes for a website owner. Keep summaries under 140 characters, plain text, no markdown. Be specific about what changed. Format: "Change X from A to B" or "Add/remove X".`,
},
{
role: 'user',
content: `File: ${params.repoRelativePath}\nRequest: "${params.userMessage}"\n\nBefore:\n${JSON.stringify(params.before, null, 2)}\n\nAfter:\n${JSON.stringify(params.after, null, 2)}\n\nSummarize the change in under 140 chars:`,
},
];
try {
const result = await chat(messages, PRIMARY_MODEL);
return result.replace(/["'`]/g, '').trim().slice(0, 280);
} catch {
// Fallback: generate a basic diff summary
return `Update ${params.repoRelativePath} as requested: "${params.userMessage.slice(0, 80)}"`;
}
}

18
server/src/logger.ts Normal file
View File

@@ -0,0 +1,18 @@
import pino from 'pino';
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
const isDev = process.env.NODE_ENV !== 'production';
export const logger = pino({
level: LOG_LEVEL,
...(isDev ? { transport: { target: 'pino-pretty', options: { colorize: true } } } : {}),
});
export function createChildLogger(bindings: Record<string, unknown>) {
return logger.child(bindings);
}
export function maskPhone(phone: string): string {
if (phone.length <= 4) return '****';
return phone.slice(0, -4).replace(/./g, '*') + phone.slice(-4);
}

View File

@@ -0,0 +1,83 @@
import type { EditJobPayload } from '@dynamic-sites/shared';
import { logger } from '../logger.js';
export interface EditQueue {
enqueue(payload: EditJobPayload): void;
startConsumer(processor: (job: EditJobPayload) => Promise<void>): void;
getQueueDepth(): number;
shutdown(): Promise<void>;
}
const MAX_QUEUE_DEPTH = parseInt(process.env.MAX_QUEUE_DEPTH || '20', 10);
export function createEditQueue(): EditQueue {
const jobs: EditJobPayload[] = [];
let processing = false;
let shuttingDown = false;
let processor: ((job: EditJobPayload) => Promise<void>) | null = null;
let resolveShutdown: (() => void) | null = null;
async function drain() {
if (processing) return;
processing = true;
while (jobs.length > 0 && !shuttingDown) {
const job = jobs.shift()!;
logger.info({ event: 'job.started', kind: job.kind, id: job.id }, 'Processing job');
try {
await processor!(job);
logger.info({ event: 'job.completed', kind: job.kind, id: job.id }, 'Job completed');
} catch (err) {
logger.error({ event: 'job.failed', kind: job.kind, id: job.id, error: (err as Error).message }, 'Job failed');
}
}
processing = false;
if (shuttingDown && jobs.length === 0 && resolveShutdown) {
resolveShutdown();
}
}
return {
enqueue(payload: EditJobPayload) {
if (shuttingDown) {
logger.warn({ event: 'job.rejected', reason: 'shutting_down' }, 'Rejecting job — shutting down');
return;
}
if (jobs.length >= MAX_QUEUE_DEPTH) {
logger.warn({ event: 'job.rejected', reason: 'queue_full', depth: jobs.length }, 'Queue depth exceeded');
throw new Error('QUEUE_FULL');
}
jobs.push(payload);
logger.info({ event: 'job.enqueued', kind: payload.kind, id: payload.id, depth: jobs.length }, 'Job enqueued');
// Start draining on next tick
if (processor) setImmediate(drain);
},
startConsumer(proc) {
processor = proc;
logger.info({ event: 'consumer.started' }, 'Edit queue consumer started');
// Start draining in case jobs were enqueued before consumer started
if (jobs.length > 0) setImmediate(drain);
},
getQueueDepth() {
return jobs.length;
},
async shutdown() {
shuttingDown = true;
const remaining = jobs.length;
if (remaining > 0) {
logger.warn({ event: 'consumer.shutdown', dropped: remaining }, `Shutting down with ${remaining} queued jobs`);
}
if (processing) {
// Wait for current job to finish
await new Promise<void>(resolve => { resolveShutdown = resolve; });
}
// Clear remaining jobs
jobs.length = 0;
},
};
}

View File

@@ -0,0 +1,55 @@
import fs from 'node:fs';
import path from 'node:path';
import { sectionFileSchema } from '@dynamic-sites/shared';
const REPO_ROOT = process.env.REPO_ROOT || '.';
export interface ManifestEntry {
id: string;
type: string;
title?: string;
headline?: string;
heading?: string;
repo_relative_path: string;
visible: boolean;
}
export function buildSectionManifest(): ManifestEntry[] {
const sectionsDir = path.join(REPO_ROOT, 'content/sections');
const manifest: ManifestEntry[] = [];
if (!fs.existsSync(sectionsDir)) return manifest;
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8'));
const parsed = sectionFileSchema.safeParse(raw);
if (!parsed.success) continue;
const s = parsed.data;
const entry: ManifestEntry = {
id: s.id,
type: s.type,
repo_relative_path: `content/sections/${file}`,
visible: s.visible,
};
if (s.type === 'hero') entry.headline = s.headline;
if (s.type === 'about' || s.type === 'features' || s.type === 'testimonials') entry.title = s.title;
if (s.type === 'text') entry.heading = s.heading;
manifest.push(entry);
} catch { /* skip bad files */ }
}
// Also add events.json
manifest.push({
id: 'events',
type: 'events',
title: 'Events',
repo_relative_path: 'content/events.json',
visible: true,
});
return manifest;
}

View File

@@ -0,0 +1,178 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import type { EditJobPayload } from '@dynamic-sites/shared';
import { schemaForRepoRelativePath } from '@dynamic-sites/shared';
import { createProposal, getProposal, updateProposalStatus } from '../db.js';
import { writeContentFile } from '../io/write-content.js';
import { generateEditedJson, routeEditIntent, generateSummary } from '../llm/client.js';
import { buildSectionManifest } from './manifest.js';
import { sendSms } from '../sms/reply.js';
import { SMS_TEMPLATES } from '../sms/templates.js';
import { logger } from '../logger.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
export async function processEditJob(job: EditJobPayload): Promise<void> {
if (job.kind === 'propose') {
await handlePropose(job);
} else if (job.kind === 'apply') {
await handleApply(job);
}
}
async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>) {
const log = logger.child({ jobId: job.id, kind: 'propose' });
try {
// Step 1: Route — determine which file the edit targets
let repoRelativePath = job.repo_relative_path;
if (!repoRelativePath) {
const manifest = buildSectionManifest();
log.debug({ event: 'routing.start', sections: manifest.length }, 'Routing edit intent');
const routing = await routeEditIntent({ userMessage: job.message, manifest });
if (routing.needs_clarification) {
log.info({ event: 'routing.ambiguous' }, 'Routing ambiguous');
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to,
routing.clarification_message || SMS_TEMPLATES.ROUTING_AMBIGUOUS(manifest.map(m => m.id).join(', '))
);
}
return;
}
repoRelativePath = routing.repo_relative_path;
}
log.info({ event: 'routing.resolved', path: repoRelativePath }, 'Route resolved');
// Step 2: Load current content + schema
const absPath = path.join(REPO_ROOT, repoRelativePath);
if (!fs.existsSync(absPath)) {
log.error({ event: 'propose.file_not_found', path: repoRelativePath }, 'Target file not found');
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.ROUTING_NO_MATCH(repoRelativePath));
}
return;
}
const currentJson = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
const schema = schemaForRepoRelativePath(repoRelativePath);
if (!schema) {
log.error({ event: 'propose.no_schema', path: repoRelativePath }, 'No schema for path');
return;
}
const siteContext = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8'));
// Step 3: Generate edited JSON via LLM
const editedJson = await generateEditedJson({
currentJson,
siteContext,
userMessage: job.message,
repoRelativePath,
schema,
});
// Step 4: Generate summary
const summary = await generateSummary({
before: currentJson,
after: editedJson,
repoRelativePath,
userMessage: job.message,
});
// Step 5: Store proposal
const proposalId = crypto.randomUUID();
createProposal({
proposalId,
repoRelativePath,
proposedJson: JSON.stringify(editedJson),
summaryText: summary,
source: job.source,
phoneHash: job.smsReplyMeta?.from ? crypto.createHash('sha256').update(job.smsReplyMeta.from).digest('hex').slice(0, 16) : undefined,
});
log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created');
// Step 6: Notify user
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_SUMMARY(summary, proposalId));
}
// For HTTP callers, the proposal_id is returned via the response (handled in route)
// Store on the job for the route handler to read
(job as Record<string, unknown>)._proposalId = proposalId;
(job as Record<string, unknown>)._summary = summary;
} catch (err) {
const msg = (err as Error).message;
log.error({ event: 'propose.failed', error: msg }, 'Propose failed');
if (job.smsReplyMeta) {
const template = msg === 'LLM_UNAVAILABLE' ? SMS_TEMPLATES.LLM_UNAVAILABLE() : SMS_TEMPLATES.LLM_UNAVAILABLE();
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, template);
}
}
}
async function handleApply(job: Extract<EditJobPayload, { kind: 'apply' }>) {
const log = logger.child({ jobId: job.id, kind: 'apply', proposalId: job.proposal_id });
const proposal = getProposal(job.proposal_id);
if (!proposal) {
log.warn({ event: 'apply.not_found' }, 'Proposal not found');
return;
}
const now = Math.floor(Date.now() / 1000);
if (proposal.status === 'applied') {
log.info({ event: 'apply.already_applied' }, 'Proposal already applied');
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_ALREADY_APPLIED());
}
return;
}
if (proposal.status !== 'pending' || proposal.expires_at < now) {
log.info({ event: 'apply.expired' }, 'Proposal expired or invalid status');
updateProposalStatus(job.proposal_id, 'expired');
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_EXPIRED());
}
return;
}
// Re-validate against schema
const schema = schemaForRepoRelativePath(proposal.repo_relative_path);
if (!schema) {
log.error({ event: 'apply.no_schema' }, 'No schema for proposal path');
return;
}
const proposedData = JSON.parse(proposal.proposed_json);
const validation = schema.safeParse(proposedData);
if (!validation.success) {
log.error({ event: 'apply.validation_failed', errors: validation.error.message }, 'Proposed JSON fails validation');
updateProposalStatus(job.proposal_id, 'rejected');
return;
}
// Write to disk
writeContentFile(proposal.repo_relative_path, validation.data, {
proposalId: job.proposal_id,
source: job.source,
});
updateProposalStatus(job.proposal_id, 'applied');
log.info({ event: 'proposal.confirmed', path: proposal.repo_relative_path }, 'Proposal applied');
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.APPLIED(proposal.summary_text));
}
}

View File

@@ -0,0 +1,165 @@
import { Router, type Request, type Response } from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { editRequestSchema, sectionFileSchema } from '@dynamic-sites/shared';
import type { EditQueue } from '../queue/edit-queue.js';
import { getProposal, updateProposalStatus } from '../db.js';
import { buildSectionManifest } from '../queue/manifest.js';
import { logger } from '../logger.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const API_EDIT_SECRET = process.env.API_EDIT_SECRET || '';
export function verifyEditAuth(req: Request): boolean {
const auth = req.headers.authorization;
if (!auth || !API_EDIT_SECRET) return false;
return auth === `Bearer ${API_EDIT_SECRET}`;
}
export interface ApiEditRouterDeps {
queue: EditQueue;
}
export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
const router = Router();
// Auth middleware for all routes
router.use((req, res, next) => {
if (!verifyEditAuth(req)) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
next();
});
// GET /api/manifest — list all sections
router.get('/manifest', (_req: Request, res: Response) => {
const manifest = buildSectionManifest();
res.json({ sections: manifest });
});
// GET /api/section?path=content/sections/hero.json — get current section JSON
router.get('/section', (req: Request, res: Response) => {
const relPath = req.query.path as string;
if (!relPath) {
res.status(400).json({ error: 'Missing path query param' });
return;
}
const absPath = path.join(REPO_ROOT, relPath);
if (!fs.existsSync(absPath)) {
res.status(404).json({ error: 'File not found' });
return;
}
try {
const data = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
res.json(data);
} catch {
res.status(500).json({ error: 'Failed to read file' });
}
});
// GET /api/site-context
router.get('/site-context', (_req: Request, res: Response) => {
try {
const data = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8'));
res.json(data);
} catch {
res.status(500).json({ error: 'Failed to read site context' });
}
});
// POST /api/edit — propose an edit (NL message)
router.post('/edit', async (req: Request, res: Response) => {
const parsed = editRequestSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid request', details: parsed.error.issues });
return;
}
const { message, repo_relative_path, confirm, proposal_id } = parsed.data;
// Handle confirmation flow
if (confirm && proposal_id) {
if (confirm === 'yes') {
try {
deps.queue.enqueue({
kind: 'apply',
id: crypto.randomUUID(),
proposal_id,
source: 'http',
});
res.status(202).json({ status: 'applying', proposal_id });
} catch (err) {
res.status(503).json({ error: (err as Error).message });
}
} else {
updateProposalStatus(proposal_id, 'rejected');
res.json({ status: 'rejected', proposal_id });
}
return;
}
// Propose flow
const jobId = crypto.randomUUID();
try {
deps.queue.enqueue({
kind: 'propose',
id: jobId,
message,
repo_relative_path,
source: 'http',
});
res.status(202).json({ status: 'processing', job_id: jobId });
} catch (err) {
res.status(503).json({ error: (err as Error).message });
}
});
// POST /api/edit/create-section — create a new section file
router.post('/edit/create-section', (req: Request, res: Response) => {
const { filename, data } = req.body;
if (!filename || !data) {
res.status(400).json({ error: 'Missing filename or data' });
return;
}
const parsed = sectionFileSchema.safeParse(data);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid section data', details: parsed.error.issues });
return;
}
const relPath = `content/sections/${filename.replace(/[^a-z0-9-]/gi, '').toLowerCase()}.json`;
const absPath = path.join(REPO_ROOT, relPath);
if (fs.existsSync(absPath)) {
res.status(409).json({ error: 'File already exists' });
return;
}
// Use the write-content module (imported dynamically to avoid circular deps)
import('../io/write-content.js').then(({ writeContentFile }) => {
writeContentFile(relPath, parsed.data, { source: 'editor' });
res.status(201).json({ status: 'created', path: relPath });
});
});
// GET /api/proposal/:id — check proposal status
router.get('/proposal/:id', (req: Request, res: Response) => {
const proposal = getProposal(req.params.id);
if (!proposal) {
res.status(404).json({ error: 'Proposal not found' });
return;
}
res.json({
proposal_id: proposal.proposal_id,
status: proposal.status,
summary: proposal.summary_text,
repo_relative_path: proposal.repo_relative_path,
created_at: proposal.created_at,
expires_at: proposal.expires_at,
});
});
return router;
}

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
export function createHealthRouter(): Router {
const router = Router();
router.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
return router;
}

View File

@@ -0,0 +1,115 @@
import { Router, type Request, type Response } from 'express';
import crypto from 'node:crypto';
import type { EditQueue } from '../queue/edit-queue.js';
import { parseTelnyxInboundMessage } from '../sms/parse.js';
import { sendSms } from '../sms/reply.js';
import { SMS_TEMPLATES } from '../sms/templates.js';
import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js';
import { logger, maskPhone } from '../logger.js';
export interface WebhookSmsRouterDeps {
queue: EditQueue;
}
export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
const router = Router();
router.post('/telnyx', (req: Request, res: Response) => {
// Respond quickly
res.status(200).json({ status: 'received' });
// Process async
handleInbound(req.body, deps).catch(err => {
logger.error({ event: 'sms.handler_error', error: (err as Error).message }, 'SMS handler error');
});
});
return router;
}
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
const parsed = parseTelnyxInboundMessage(body);
if (!parsed) {
logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS');
return;
}
const { messageId, from, to, text, hasMedia } = parsed;
const phoneHash = hashPhone(from);
logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId }, 'Inbound SMS');
// Idempotency check
if (messageId && !claimOnce(`sms:${messageId}`, 3600)) {
logger.info({ event: 'sms.idempotent_skip', messageId }, 'Duplicate SMS skipped');
return;
}
// MMS check
if (hasMedia) {
await sendSms(from, to, SMS_TEMPLATES.MMS_NOT_SUPPORTED());
return;
}
// Rate limit
const maxPerHour = parseInt(process.env.SMS_RATE_LIMIT_PER_HOUR || '10', 10);
if (!checkSmsRateLimit(phoneHash, maxPerHour)) {
logger.info({ event: 'sms.rate_limited', phone: maskPhone(from) }, 'SMS rate limited');
await sendSms(from, to, SMS_TEMPLATES.RATE_LIMITED());
return;
}
const upperText = text.toUpperCase().trim();
// Check for YES/NO confirmation
if (upperText === 'YES' || upperText === 'Y') {
const pending = getPendingProposalByPhone(phoneHash);
if (!pending) {
await sendSms(from, to, SMS_TEMPLATES.PROPOSAL_EXPIRED());
return;
}
try {
deps.queue.enqueue({
kind: 'apply',
id: crypto.randomUUID(),
proposal_id: pending.proposal_id,
source: 'sms',
smsReplyMeta: { from, to },
});
} catch {
await sendSms(from, to, SMS_TEMPLATES.LLM_UNAVAILABLE());
}
return;
}
if (upperText === 'NO' || upperText === 'N') {
const pending = getPendingProposalByPhone(phoneHash);
if (pending) {
updateProposalStatus(pending.proposal_id, 'rejected');
logger.info({ event: 'proposal.rejected', proposalId: pending.proposal_id }, 'Proposal rejected via SMS');
}
await sendSms(from, to, SMS_TEMPLATES.REJECTED());
return;
}
// Check if there's a pending proposal — if user sends something other than YES/NO
const pending = getPendingProposalByPhone(phoneHash);
if (pending) {
// New message while proposal pending — could be a new edit or invalid confirm
// Expire old proposal and start fresh
updateProposalStatus(pending.proposal_id, 'expired');
}
// New propose job
try {
deps.queue.enqueue({
kind: 'propose',
id: crypto.randomUUID(),
message: text,
source: 'sms',
smsReplyMeta: { from, to },
});
} catch {
await sendSms(from, to, SMS_TEMPLATES.LLM_UNAVAILABLE());
}
}

30
server/src/sms/parse.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface ParsedInboundSms {
messageId: string;
from: string;
to: string;
text: string;
hasMedia: boolean;
mediaUrls: string[];
}
export function parseTelnyxInboundMessage(body: unknown): ParsedInboundSms | null {
try {
const data = body as Record<string, unknown>;
const eventData = (data.data as Record<string, unknown>) || data;
const payload = (eventData.payload as Record<string, unknown>) || eventData;
const from = ((payload.from as Record<string, unknown>)?.phone_number as string) || (payload.from as string) || '';
const to = (Array.isArray(payload.to) ? (payload.to[0] as Record<string, unknown>)?.phone_number : (payload.to as Record<string, unknown>)?.phone_number) as string || '';
const text = (payload.text as string) || (payload.body as string) || '';
const messageId = (payload.id as string) || (eventData.id as string) || '';
const media = (payload.media as Array<{ url: string }>) || [];
const mediaUrls = media.map(m => m.url).filter(Boolean);
if (!from || !text) return null;
return { messageId, from, to, text: text.trim(), hasMedia: mediaUrls.length > 0, mediaUrls };
} catch {
return null;
}
}

30
server/src/sms/reply.ts Normal file
View File

@@ -0,0 +1,30 @@
import { logger, maskPhone } from '../logger.js';
const TELNYX_API_KEY = process.env.TELNYX_API_KEY || '';
export async function sendSms(to: string, from: string, body: string): Promise<void> {
if (!TELNYX_API_KEY) {
logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No TELNYX_API_KEY, skipping SMS send');
logger.info({ event: 'sms.would_send', body }, 'SMS body (dev mode)');
return;
}
try {
const resp = await fetch('https://api.telnyx.com/v2/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TELNYX_API_KEY}`,
},
body: JSON.stringify({ from, to, text: body }),
});
if (!resp.ok) {
logger.error({ event: 'sms.send_failed', status: resp.status }, 'Failed to send SMS');
} else {
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
}
} catch (err) {
logger.error({ event: 'sms.send_error', error: (err as Error).message }, 'SMS send error');
}
}

View File

@@ -0,0 +1,34 @@
export const SMS_TEMPLATES = {
PROPOSAL_SUMMARY: (summary: string, proposalId: string) =>
`Proposed change: ${summary}\n\nReply YES to apply or NO to cancel.`,
APPLIED: (summary: string) =>
`Done! ${summary} Your site will update shortly.`,
REJECTED: () =>
`Got it — change cancelled. Send a new message anytime.`,
LLM_UNAVAILABLE: () =>
`Sorry, I couldn't process that right now. Please try again in a few minutes.`,
ROUTING_AMBIGUOUS: (options: string) =>
`I'm not sure which section you mean. Did you mean: ${options}? Reply with the number or name.`,
ROUTING_NO_MATCH: (list: string) =>
`I couldn't find a section matching that request. Your current sections are: ${list}. Try again?`,
PROPOSAL_EXPIRED: () =>
`That change request has expired. Please send your edit again to start over.`,
PROPOSAL_ALREADY_APPLIED: () =>
`That change was already applied.`,
INVALID_CONFIRM: () =>
`Reply YES to apply or NO to cancel.`,
RATE_LIMITED: () =>
`You've sent several requests recently. Please wait a few minutes before trying again.`,
MMS_NOT_SUPPORTED: () =>
`Image uploads aren't supported yet. Please describe your change in text.`,
} as const;

14
server/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

14
shared/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@dynamic-sites/shared",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
},
"dependencies": {
"zod": "^3.24.0"
}
}

View File

@@ -0,0 +1,20 @@
/**
* Canonical JSON serialization: sorted keys at every level, 2-space indent, trailing newline.
*/
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
function sortKeys(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(sortKeys);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
}
return sorted;
}
export function stringifyCanonical(value: unknown): string {
return JSON.stringify(sortKeys(value), null, 2) + '\n';
}

7
shared/src/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export { stringifyCanonical, type JsonValue, type JsonPrimitive } from './canonical-json.js';
export * from './schemas/index.js';
export {
schemaForRepoRelativePath,
isEditableContentRepoPath,
parseAndValidateRepoJson,
} from './repo-validation.js';

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import {
siteContextSchema,
sectionFileSchema,
eventsFileSchema,
smsSitesConfigSchema,
} from './schemas/index.js';
const EDITABLE_PATTERNS: Array<{ pattern: RegExp; schema: z.ZodTypeAny }> = [
{ pattern: /^content\/sections\/[^/]+\.json$/, schema: sectionFileSchema },
{ pattern: /^content\/events\.json$/, schema: eventsFileSchema },
{ pattern: /^site-context\.json$/, schema: siteContextSchema },
{ pattern: /^config\/sms-sites\.json$/, schema: smsSitesConfigSchema },
];
export function schemaForRepoRelativePath(relativePath: string): z.ZodTypeAny | null {
for (const { pattern, schema } of EDITABLE_PATTERNS) {
if (pattern.test(relativePath)) return schema;
}
return null;
}
export function isEditableContentRepoPath(relativePath: string): boolean {
return EDITABLE_PATTERNS.some(({ pattern }) => pattern.test(relativePath));
}
export function parseAndValidateRepoJson(
repoRelativePath: string,
rawText: string
): { success: true; data: unknown } | { success: false; error: string } {
const schema = schemaForRepoRelativePath(repoRelativePath);
if (!schema) return { success: false, error: `No schema found for path: ${repoRelativePath}` };
try {
const parsed = JSON.parse(rawText);
const result = schema.safeParse(parsed);
if (result.success) return { success: true, data: result.data };
return { success: false, error: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ') };
} catch (e) {
return { success: false, error: `Invalid JSON: ${(e as Error).message}` };
}
}

152
shared/src/schemas/index.ts Normal file
View File

@@ -0,0 +1,152 @@
import { z } from 'zod';
// ── Site Context ──
export const siteContextSchema = z.object({
businessName: z.string(),
tagline: z.string().optional(),
tone: z.string().default('professional and friendly'),
style: z.string().optional(),
promptContext: z.string().optional(),
primaryColor: z.string().optional(),
contactEmail: z.string().email().optional(),
contactPhone: z.string().optional(),
address: z.string().optional(),
});
export type SiteContext = z.infer<typeof siteContextSchema>;
// ── Section File (union of section types) ──
const baseSectionFields = {
id: z.string(),
order: z.number().int().default(0),
visible: z.boolean().default(true),
image: z.string().optional(),
};
export const heroSectionSchema = z.object({
...baseSectionFields,
type: z.literal('hero'),
headline: z.string(),
subheading: z.string().optional(),
ctaText: z.string().optional(),
ctaLink: z.string().optional(),
});
export const aboutSectionSchema = z.object({
...baseSectionFields,
type: z.literal('about'),
title: z.string().default('About Us'),
content: z.string(),
});
export const featureItemSchema = z.object({
title: z.string(),
description: z.string(),
icon: z.string().optional(),
});
export const featuresSectionSchema = z.object({
...baseSectionFields,
type: z.literal('features'),
title: z.string().default('What We Offer'),
items: z.array(featureItemSchema).min(1),
});
export const testimonialItemSchema = z.object({
quote: z.string(),
author: z.string(),
role: z.string().optional(),
});
export const testimonialsSectionSchema = z.object({
...baseSectionFields,
type: z.literal('testimonials'),
title: z.string().default('What Our Clients Say'),
items: z.array(testimonialItemSchema).min(1),
});
export const textSectionSchema = z.object({
...baseSectionFields,
type: z.literal('text'),
heading: z.string().optional(),
content: z.string(),
});
export const sectionFileSchema = z.discriminatedUnion('type', [
heroSectionSchema,
aboutSectionSchema,
featuresSectionSchema,
testimonialsSectionSchema,
textSectionSchema,
]);
export type SectionFile = z.infer<typeof sectionFileSchema>;
// ── Events ──
export const eventItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
date: z.string(),
time: z.string().optional(),
location: z.string().optional(),
});
export const eventsFileSchema = z.object({
events: z.array(eventItemSchema),
});
export type EventsFile = z.infer<typeof eventsFileSchema>;
// ── SMS Sites Config ──
export const smsSiteEntrySchema = z.object({
siteId: z.string(),
phoneNumber: z.string(),
allowedSenders: z.array(z.string()),
repoRoot: z.string().optional(),
});
export const smsSitesConfigSchema = z.object({
sites: z.array(smsSiteEntrySchema),
});
export type SmsSitesConfig = z.infer<typeof smsSitesConfigSchema>;
// ── Edit Request ──
export const editRequestSchema = z.object({
message: z.string().min(1),
repo_relative_path: z.string().optional(),
proposal_id: z.string().optional(),
confirm: z.enum(['yes', 'no']).optional(),
});
export type EditRequest = z.infer<typeof editRequestSchema>;
export const editJobPayloadSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('propose'),
id: z.string(),
message: z.string(),
repo_relative_path: z.string().optional(),
source: z.enum(['sms', 'http', 'editor']).default('http'),
smsReplyMeta: z.object({
from: z.string(),
to: z.string(),
}).optional(),
}),
z.object({
kind: z.literal('apply'),
id: z.string(),
proposal_id: z.string(),
source: z.enum(['sms', 'http', 'editor']).default('http'),
smsReplyMeta: z.object({
from: z.string(),
to: z.string(),
}).optional(),
}),
]);
export type EditJobPayload = z.infer<typeof editJobPayloadSchema>;
// ── Routing output (LLM structured output) ──
export const routingOutputSchema = z.object({
repo_relative_path: z.string(),
needs_clarification: z.boolean().default(false),
reason: z.string(),
clarification_message: z.string().optional(),
});
export type RoutingOutput = z.infer<typeof routingOutputSchema>;

11
site-context.json Normal file
View File

@@ -0,0 +1,11 @@
{
"address": "742 Evergreen Terrace, Portland, OR 97201",
"businessName": "Timber & Grain Coffee Co.",
"contactEmail": "hello@timberandgrain.co",
"contactPhone": "(503) 555-0142",
"primaryColor": "#2d5016",
"promptContext": "We are a specialty coffee roaster and cafe in Portland, Oregon. We focus on single-origin beans, pour-over brewing, and community events. Our vibe is warm, earthy, and approachable — not pretentious.",
"style": "Warm, earthy, approachable. Use natural imagery and warm tones.",
"tagline": "Rooted in craft. Brewed with care.",
"tone": "Warm, knowledgeable, and inviting. We speak like a friend who happens to know a lot about coffee."
}

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } from 'react';
interface ManifestEntry {
id: string;
type: string;
title?: string;
headline?: string;
heading?: string;
repo_relative_path: string;
visible: boolean;
}
interface Props {
orchestratorUrl: string;
apiSecret: string;
}
type EditorView = 'sections' | 'edit' | 'create' | 'nl-edit';
export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
const [view, setView] = useState<EditorView>('sections');
const [sections, setSections] = useState<ManifestEntry[]>([]);
const [selectedSection, setSelectedSection] = useState<ManifestEntry | null>(null);
const [sectionJson, setSectionJson] = useState<string>('');
const [nlMessage, setNlMessage] = useState('');
const [status, setStatus] = useState<string>('');
const [loading, setLoading] = useState(false);
const [proposalId, setProposalId] = useState<string | null>(null);
const [proposalSummary, setProposalSummary] = useState<string>('');
const headers = {
'Authorization': `Bearer ${apiSecret}`,
'Content-Type': 'application/json',
};
const fetchManifest = useCallback(async () => {
try {
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
const data = await res.json();
setSections(data.sections || []);
} catch (err) {
setStatus('Failed to load sections');
}
}, [orchestratorUrl, apiSecret]);
useEffect(() => { fetchManifest(); }, [fetchManifest]);
const loadSection = async (entry: ManifestEntry) => {
setSelectedSection(entry);
setView('edit');
try {
const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers });
const data = await res.json();
setSectionJson(JSON.stringify(data, null, 2));
} catch {
setStatus('Failed to load section');
}
};
const saveSection = async () => {
if (!selectedSection) return;
setLoading(true);
setStatus('Saving...');
try {
const parsed = JSON.parse(sectionJson);
const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST',
headers,
body: JSON.stringify({
message: `Direct JSON update to ${selectedSection.repo_relative_path}`,
repo_relative_path: selectedSection.repo_relative_path,
}),
});
const data = await res.json();
setStatus(data.status === 'processing' ? 'Edit submitted — processing...' : `Status: ${data.status}`);
} catch (err) {
setStatus('Save failed');
}
setLoading(false);
};
const submitNlEdit = async () => {
if (!nlMessage.trim()) return;
setLoading(true);
setStatus('Sending to AI...');
setProposalId(null);
setProposalSummary('');
try {
const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST',
headers,
body: JSON.stringify({ message: nlMessage }),
});
const data = await res.json();
if (data.job_id) {
setStatus('Processing... checking for proposal.');
// Poll for proposal
pollForProposal(data.job_id);
} else {
setStatus(`Response: ${JSON.stringify(data)}`);
}
} catch {
setStatus('Request failed');
}
setLoading(false);
};
const pollForProposal = async (jobId: string) => {
// Simple poll: check recent proposals. In production, use SSE or websockets.
let attempts = 0;
const interval = setInterval(async () => {
attempts++;
if (attempts > 30) {
clearInterval(interval);
setStatus('Timed out waiting for proposal. The AI may still be processing.');
return;
}
try {
// Re-fetch manifest to see if anything changed, or check proposal endpoint
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
const data = await res.json();
setSections(data.sections || []);
} catch { /* ignore */ }
}, 2000);
};
const confirmProposal = async (confirm: 'yes' | 'no') => {
if (!proposalId) return;
setLoading(true);
try {
const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST',
headers,
body: JSON.stringify({ message: '', confirm, proposal_id: proposalId }),
});
const data = await res.json();
setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.');
setProposalId(null);
setProposalSummary('');
if (confirm === 'yes') {
setTimeout(() => { fetchManifest(); }, 1000);
}
} catch {
setStatus('Confirm failed');
}
setLoading(false);
};
const sectionLabel = (s: ManifestEntry) =>
s.headline || s.title || s.heading || s.id;
// ── Styles ──
const styles = {
wrapper: { fontFamily: "'Source Sans 3', system-ui, sans-serif" } as React.CSSProperties,
nav: { display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' as const },
navBtn: (active: boolean) => ({
padding: '0.5rem 1rem', border: '1px solid #e5e0d8', borderRadius: '4px', cursor: 'pointer',
background: active ? '#2d5016' : 'white', color: active ? 'white' : '#2c2c2c',
fontSize: '0.85rem', fontWeight: 500 as const, fontFamily: 'inherit',
}),
card: {
padding: '1rem', border: '1px solid #e5e0d8', borderRadius: '6px', marginBottom: '0.75rem',
display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'white', cursor: 'pointer',
} as React.CSSProperties,
badge: (visible: boolean) => ({
fontSize: '0.7rem', padding: '0.2rem 0.5rem', borderRadius: '3px',
background: visible ? '#e8f5e1' : '#fce8e8', color: visible ? '#2d5016' : '#a33',
fontWeight: 600 as const,
}),
textarea: {
width: '100%', minHeight: '300px', fontFamily: "'Source Code Pro', monospace", fontSize: '0.85rem',
padding: '1rem', border: '1px solid #e5e0d8', borderRadius: '6px', resize: 'vertical' as const,
},
input: {
width: '100%', padding: '0.6rem 0.8rem', border: '1px solid #e5e0d8', borderRadius: '4px',
fontSize: '0.95rem', fontFamily: 'inherit', marginBottom: '0.75rem',
},
btn: {
padding: '0.5rem 1.25rem', background: '#2d5016', color: 'white', border: 'none',
borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit',
},
btnDanger: {
padding: '0.5rem 1.25rem', background: '#a33', color: 'white', border: 'none',
borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit',
},
btnOutline: {
padding: '0.5rem 1.25rem', background: 'white', color: '#2d5016', border: '1px solid #2d5016',
borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit',
},
status: { marginTop: '1rem', fontSize: '0.85rem', color: '#6b6b6b', fontStyle: 'italic' as const },
heading: { fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.4rem', color: '#1a3a0a', marginBottom: '1rem' },
subhead: { fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.1rem', color: '#2d5016', marginBottom: '0.75rem' },
};
return (
<div style={styles.wrapper}>
<h2 style={styles.heading}>Content Editor</h2>
<div style={styles.nav}>
<button style={styles.navBtn(view === 'sections')} onClick={() => { setView('sections'); fetchManifest(); }}>Sections</button>
<button style={styles.navBtn(view === 'nl-edit')} onClick={() => setView('nl-edit')}>Natural Language Edit</button>
<button style={styles.navBtn(view === 'create')} onClick={() => setView('create')}>New Section</button>
<a href="/" style={{ ...styles.navBtn(false), textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}>View Site &rarr;</a>
</div>
{/* ── Section List ── */}
{view === 'sections' && (
<div>
<h3 style={styles.subhead}>All Sections</h3>
{sections.length === 0 && <p>Loading sections...</p>}
{sections.map(s => (
<div key={s.repo_relative_path} style={styles.card} onClick={() => loadSection(s)}>
<div>
<strong>{sectionLabel(s)}</strong>
<span style={{ fontSize: '0.8rem', color: '#6b6b6b', marginLeft: '0.5rem' }}>({s.type})</span>
<div style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.2rem' }}>{s.repo_relative_path}</div>
</div>
<span style={styles.badge(s.visible)}>{s.visible ? 'Visible' : 'Hidden'}</span>
</div>
))}
</div>
)}
{/* ── JSON Editor ── */}
{view === 'edit' && selectedSection && (
<div>
<h3 style={styles.subhead}>Editing: {sectionLabel(selectedSection)}</h3>
<p style={{ fontSize: '0.8rem', color: '#999', marginBottom: '0.75rem' }}>{selectedSection.repo_relative_path}</p>
<textarea
style={styles.textarea}
value={sectionJson}
onChange={e => setSectionJson(e.target.value)}
/>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
<button style={styles.btn} onClick={saveSection} disabled={loading}>Save</button>
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); }}>Back</button>
</div>
</div>
)}
{/* ── Natural Language Edit ── */}
{view === 'nl-edit' && (
<div>
<h3 style={styles.subhead}>Describe Your Edit</h3>
<p style={{ fontSize: '0.9rem', color: '#6b6b6b', marginBottom: '1rem' }}>
Tell the AI what you want to change. For example: "Update the hero headline to say Grand Opening This Weekend"
or "Hide the promo banner" or "Add a new event on May 15th".
</p>
<input
style={styles.input}
placeholder="Describe what you want to change..."
value={nlMessage}
onChange={e => setNlMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submitNlEdit()}
/>
<button style={styles.btn} onClick={submitNlEdit} disabled={loading || !nlMessage.trim()}>
{loading ? 'Processing...' : 'Submit Edit'}
</button>
{proposalId && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#fefce8', border: '1px solid #eab308', borderRadius: '6px' }}>
<strong>Proposed change:</strong>
<p style={{ margin: '0.5rem 0' }}>{proposalSummary}</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button style={styles.btn} onClick={() => confirmProposal('yes')}>Yes, Apply</button>
<button style={styles.btnDanger} onClick={() => confirmProposal('no')}>No, Cancel</button>
</div>
</div>
)}
</div>
)}
{/* ── Create Section ── */}
{view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers} onCreated={() => { setView('sections'); fetchManifest(); }} />}
{status && <p style={styles.status}>{status}</p>}
</div>
);
}
// ── Create Section Sub-component ──
function CreateSection({ orchestratorUrl, headers, onCreated }: {
orchestratorUrl: string;
headers: Record<string, string>;
onCreated: () => void;
}) {
const [sectionType, setSectionType] = useState('text');
const [sectionId, setSectionId] = useState('');
const [formStatus, setFormStatus] = useState('');
const defaults: Record<string, unknown> = {
text: { type: 'text', id: '', heading: 'New Section', content: 'Content goes here.', order: 10, visible: true },
hero: { type: 'hero', id: '', headline: 'Headline', subheading: 'Subheading', ctaText: 'Learn More', ctaLink: '/', order: 0, visible: true },
about: { type: 'about', id: '', title: 'About', content: 'About content.', order: 10, visible: true },
features: { type: 'features', id: '', title: 'Features', items: [{ title: 'Feature 1', description: 'Description' }], order: 10, visible: true },
testimonials: { type: 'testimonials', id: '', title: 'Testimonials', items: [{ quote: 'Great service!', author: 'Customer', role: 'Client' }], order: 10, visible: true },
};
const handleCreate = async () => {
if (!sectionId.trim()) { setFormStatus('Please enter a section ID'); return; }
const slug = sectionId.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const data = { ...defaults[sectionType] as Record<string, unknown>, id: slug };
try {
const res = await fetch(`${orchestratorUrl}/api/edit/create-section`, {
method: 'POST',
headers,
body: JSON.stringify({ filename: slug, data }),
});
const result = await res.json();
if (res.ok) {
setFormStatus('Created!');
setTimeout(onCreated, 500);
} else {
setFormStatus(result.error || 'Failed');
}
} catch {
setFormStatus('Request failed');
}
};
const inputStyle = {
width: '100%', padding: '0.6rem 0.8rem', border: '1px solid #e5e0d8', borderRadius: '4px',
fontSize: '0.95rem', fontFamily: 'inherit', marginBottom: '0.75rem',
};
const selectStyle = { ...inputStyle, background: 'white' };
return (
<div>
<h3 style={{ fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.1rem', color: '#2d5016', marginBottom: '0.75rem' }}>Create New Section</h3>
<select style={selectStyle} value={sectionType} onChange={e => setSectionType(e.target.value)}>
<option value="text">Text / Banner</option>
<option value="hero">Hero</option>
<option value="about">About</option>
<option value="features">Features</option>
<option value="testimonials">Testimonials</option>
</select>
<input style={inputStyle} placeholder="Section ID (e.g. spring-promo)" value={sectionId} onChange={e => setSectionId(e.target.value)} />
<button style={{ padding: '0.5rem 1.25rem', background: '#2d5016', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500, fontFamily: 'inherit' }} onClick={handleCreate}>Create Section</button>
{formStatus && <p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: '#6b6b6b' }}>{formStatus}</p>}
</div>
);
}

View File

@@ -0,0 +1,29 @@
---
interface Props {
title: string;
content: string;
}
const { title, content } = Astro.props;
---
<section class="about">
<div class="container">
<h2>{title}</h2>
<p>{content}</p>
</div>
</section>
<style>
.about h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 1rem;
}
.about p {
max-width: 680px;
color: var(--color-text);
font-size: 1.05rem;
line-height: 1.85;
font-weight: 300;
}
</style>

View File

@@ -0,0 +1,112 @@
---
interface EventItem {
id: string;
title: string;
description?: string;
date: string;
time?: string;
location?: string;
}
interface Props {
events: EventItem[];
}
const { events } = Astro.props;
function formatDate(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
}
---
{events.length > 0 && (
<section class="events">
<div class="container">
<h2>Upcoming Events</h2>
<div class="events-list">
{events.map((event) => (
<div class="event-card">
<div class="event-date">
<span class="event-day">{new Date(event.date + 'T00:00:00').getDate()}</span>
<span class="event-month">{new Date(event.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short' })}</span>
</div>
<div class="event-info">
<h3>{event.title}</h3>
{event.description && <p>{event.description}</p>}
<div class="event-meta">
{event.time && <span>{event.time}</span>}
{event.location && <span>{event.location}</span>}
</div>
</div>
</div>
))}
</div>
</div>
</section>
)}
<style>
.events h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 2rem;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-card {
display: flex;
gap: 1.25rem;
padding: 1.25rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.event-date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 56px;
padding: 0.5rem;
background: var(--color-primary-light);
border-radius: 4px;
}
.event-day {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-primary-dark);
line-height: 1;
}
.event-month {
font-size: 0.75rem;
color: var(--color-primary);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.event-info h3 {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-text);
margin-bottom: 0.35rem;
}
.event-info p {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.6;
font-weight: 300;
margin-bottom: 0.5rem;
}
.event-meta {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--color-primary);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,57 @@
---
interface FeatureItem {
title: string;
description: string;
icon?: string;
}
interface Props {
title: string;
items: FeatureItem[];
}
const { title, items } = Astro.props;
---
<section class="features">
<div class="container">
<h2>{title}</h2>
<div class="features-grid">
{items.map((item) => (
<div class="feature-card">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
</div>
</div>
</section>
<style>
.features h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 2rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
.feature-card {
padding: 1.5rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.feature-card h3 {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.feature-card p {
font-size: 0.92rem;
color: var(--color-text-muted);
line-height: 1.65;
font-weight: 300;
}
</style>

View File

@@ -0,0 +1,61 @@
---
interface Props {
headline: string;
subheading?: string;
ctaText?: string;
ctaLink?: string;
primaryColor?: string;
}
const { headline, subheading, ctaText, ctaLink } = Astro.props;
---
<section class="hero">
<div class="container">
<h1>{headline}</h1>
{subheading && <p class="hero-sub">{subheading}</p>}
{ctaText && ctaLink && (
<a href={ctaLink} class="hero-cta">{ctaText}</a>
)}
</div>
</section>
<style>
.hero {
padding: 5rem 0 4rem;
text-align: center;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary), white 92%) 0%,
var(--color-bg) 100%
);
border-bottom: none;
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 3.2rem);
color: var(--color-primary-dark);
line-height: 1.15;
margin-bottom: 1rem;
}
.hero-sub {
font-size: 1.15rem;
color: var(--color-text-muted);
max-width: 560px;
margin: 0 auto 2rem;
font-weight: 300;
line-height: 1.8;
}
.hero-cta {
display: inline-block;
padding: 0.75rem 2rem;
background: var(--color-primary);
color: white;
text-decoration: none;
border-radius: 4px;
font-weight: 500;
font-size: 0.95rem;
transition: background 0.2s;
}
.hero-cta:hover {
background: var(--color-primary-dark);
}
</style>

View File

@@ -0,0 +1,65 @@
---
interface TestimonialItem {
quote: string;
author: string;
role?: string;
}
interface Props {
title: string;
items: TestimonialItem[];
}
const { title, items } = Astro.props;
---
<section class="testimonials">
<div class="container">
<h2>{title}</h2>
<div class="testimonials-grid">
{items.map((item) => (
<blockquote class="testimonial-card">
<p>"{item.quote}"</p>
<footer>
<strong>{item.author}</strong>
{item.role && <span>{item.role}</span>}
</footer>
</blockquote>
))}
</div>
</div>
</section>
<style>
.testimonials h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 2rem;
}
.testimonials-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
}
.testimonial-card {
padding: 1.5rem;
background: white;
border-left: 3px solid var(--color-primary);
border-radius: 0 6px 6px 0;
}
.testimonial-card p {
font-size: 0.95rem;
font-style: italic;
color: var(--color-text);
line-height: 1.7;
margin-bottom: 1rem;
font-weight: 300;
}
.testimonial-card footer strong {
display: block;
font-size: 0.9rem;
color: var(--color-primary-dark);
}
.testimonial-card footer span {
font-size: 0.8rem;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,41 @@
---
interface Props {
heading?: string;
content: string;
id: string;
}
const { heading, content, id } = Astro.props;
const isPromo = id.includes('promo') || id.includes('banner');
---
<section class:list={["text-section", { promo: isPromo }]}>
<div class="container">
{heading && <h2>{heading}</h2>}
<p>{content}</p>
</div>
</section>
<style>
.text-section h2 {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-primary-dark);
margin-bottom: 0.75rem;
}
.text-section p {
font-size: 1rem;
color: var(--color-text);
line-height: 1.8;
font-weight: 300;
max-width: 680px;
}
.promo {
background: color-mix(in srgb, var(--color-primary), white 90%);
border-left: 4px solid var(--color-primary);
border-radius: 0 6px 6px 0;
margin: 0 auto;
}
.promo .container {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,103 @@
---
interface Props {
title: string;
primaryColor?: string;
}
const { title, primaryColor = '#2d5016' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Source+Sans+3:wght@300;400;500;600&display=swap" rel="stylesheet" />
<style define:vars={{ primaryColor }}>
:root {
--color-primary: var(--primaryColor);
--color-primary-dark: color-mix(in srgb, var(--primaryColor), black 20%);
--color-primary-light: color-mix(in srgb, var(--primaryColor), white 85%);
--color-bg: #faf8f5;
--color-text: #2c2c2c;
--color-text-muted: #6b6b6b;
--color-border: #e5e0d8;
--font-display: 'DM Serif Display', Georgia, serif;
--font-body: 'Source Sans 3', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.7;
font-weight: 400;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 1.5rem;
}
.site-header {
padding: 1.25rem 0;
border-bottom: 1px solid var(--color-border);
background: white;
}
.site-header .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.site-logo {
font-family: var(--font-display);
font-size: 1.35rem;
color: var(--color-primary);
text-decoration: none;
}
.site-tagline {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 300;
letter-spacing: 0.03em;
}
.site-footer {
margin-top: 4rem;
padding: 2rem 0;
border-top: 1px solid var(--color-border);
text-align: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
section {
padding: 3.5rem 0;
}
section + section {
border-top: 1px solid var(--color-border);
}
</style>
</head>
<body>
<header class="site-header">
<div class="container">
<a href="/" class="site-logo"><slot name="logo">Dynamic Site</slot></a>
<span class="site-tagline"><slot name="tagline" /></span>
</div>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container">
<slot name="footer">© {new Date().getFullYear()}</slot>
</div>
</footer>
</body>
</html>

35
src/lib/site-bundle.ts Normal file
View File

@@ -0,0 +1,35 @@
import {
siteContextSchema,
eventsFileSchema,
sectionFileSchema,
type SiteContext,
type SectionFile,
type EventsFile,
} from '@dynamic-sites/shared';
export interface SiteBundle {
siteContext: SiteContext;
sections: SectionFile[];
events: EventsFile;
}
export function parseSiteBundle(
siteContextRaw: unknown,
eventsRaw: unknown,
sectionRaws: unknown[]
): SiteBundle {
const siteContext = siteContextSchema.parse(siteContextRaw);
const events = eventsFileSchema.parse(eventsRaw);
const sections: SectionFile[] = [];
for (const raw of sectionRaws) {
const result = sectionFileSchema.safeParse(raw);
if (result.success && result.data.visible) {
sections.push(result.data);
}
}
// Sort by order, then id as tiebreaker
sections.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
return { siteContext, sections, events };
}

39
src/lib/site-data.ts Normal file
View File

@@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseSiteBundle, type SiteBundle } from './site-bundle.ts';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const TTL = parseInt(process.env.SITE_DATA_TTL_MS || '2000', 10);
let cached: { data: SiteBundle; loadedAt: number } | null = null;
export function loadSiteData(): SiteBundle {
const now = Date.now();
if (cached && now - cached.loadedAt < TTL) {
return cached.data;
}
const siteContextRaw = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')
);
const eventsRaw = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'content/events.json'), 'utf-8')
);
const sectionsDir = path.join(REPO_ROOT, 'content/sections');
const sectionRaws: unknown[] = [];
if (fs.existsSync(sectionsDir)) {
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
try {
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')));
} catch {
// skip invalid files
}
}
}
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws);
cached = { data, loadedAt: now };
return data;
}

134
src/pages/editor.astro Normal file
View File

@@ -0,0 +1,134 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { loadSiteData } from '../lib/site-data.ts';
const { siteContext } = loadSiteData();
const secret = import.meta.env.EDITOR_SESSION_SECRET || process.env.EDITOR_SESSION_SECRET || 'dev-secret';
const sessionCookie = Astro.cookies.get('editor_session')?.value;
let isAuthed = sessionCookie === secret;
if (!isAuthed && Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const password = formData.get('password') as string;
const editSecret = import.meta.env.API_EDIT_SECRET || process.env.API_EDIT_SECRET || '';
if (password === editSecret) {
Astro.cookies.set('editor_session', secret, {
httpOnly: true,
sameSite: 'strict',
maxAge: 8 * 60 * 60,
path: '/',
});
isAuthed = true;
}
}
const orchestratorUrl = import.meta.env.PUBLIC_ORCHESTRATOR_URL || process.env.PUBLIC_ORCHESTRATOR_URL || 'http://localhost:3001';
const apiSecret = import.meta.env.API_EDIT_SECRET || process.env.API_EDIT_SECRET || '';
---
<BaseLayout title={`Editor — ${siteContext.businessName}`} primaryColor={siteContext.primaryColor}>
<Fragment slot="logo">{siteContext.businessName}</Fragment>
<Fragment slot="tagline">Content Editor</Fragment>
{!isAuthed ? (
<section class="login-section">
<div class="container">
<div class="login-box">
<h2>Editor Login</h2>
<p>Enter the site edit password to continue.</p>
<form method="POST">
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Log In</button>
</form>
</div>
</div>
</section>
) : (
<section class="editor-section">
<div class="container">
<div id="editor-root"
data-orchestrator-url={orchestratorUrl}
data-api-secret={apiSecret}
></div>
</div>
</section>
)}
<Fragment slot="footer">
&copy; {new Date().getFullYear()} {siteContext.businessName} &middot; Editor
</Fragment>
</BaseLayout>
<style>
.login-section {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
}
.login-box {
max-width: 360px;
padding: 2rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 8px;
text-align: center;
}
.login-box h2 {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--color-primary-dark);
margin-bottom: 0.5rem;
}
.login-box p {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 1.5rem;
}
.login-box input {
display: block;
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.95rem;
margin-bottom: 1rem;
font-family: var(--font-body);
}
.login-box button {
width: 100%;
padding: 0.65rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
font-family: var(--font-body);
font-weight: 500;
}
.login-box button:hover {
background: var(--color-primary-dark);
}
.editor-section {
padding: 2rem 0;
}
</style>
{isAuthed && (
<script>
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { VisualEditorIsland } from '../components/editor/VisualEditorIsland';
const el = document.getElementById('editor-root');
if (el) {
const root = createRoot(el);
root.render(createElement(VisualEditorIsland, {
orchestratorUrl: el.dataset.orchestratorUrl || '',
apiSecret: el.dataset.apiSecret || '',
}));
}
</script>
)}

43
src/pages/index.astro Normal file
View File

@@ -0,0 +1,43 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import HeroSection from '../components/sections/HeroSection.astro';
import AboutSection from '../components/sections/AboutSection.astro';
import FeaturesSection from '../components/sections/FeaturesSection.astro';
import TestimonialsSection from '../components/sections/TestimonialsSection.astro';
import TextSection from '../components/sections/TextSection.astro';
import EventsList from '../components/sections/EventsList.astro';
import { loadSiteData } from '../lib/site-data.ts';
const { siteContext, sections, events } = loadSiteData();
---
<BaseLayout title={siteContext.businessName} primaryColor={siteContext.primaryColor}>
<Fragment slot="logo">{siteContext.businessName}</Fragment>
<Fragment slot="tagline">{siteContext.tagline}</Fragment>
{sections.map((section) => {
switch (section.type) {
case 'hero':
return <HeroSection
headline={section.headline}
subheading={section.subheading}
ctaText={section.ctaText}
ctaLink={section.ctaLink}
/>;
case 'about':
return <AboutSection title={section.title} content={section.content} />;
case 'features':
return <FeaturesSection title={section.title} items={section.items} />;
case 'testimonials':
return <TestimonialsSection title={section.title} items={section.items} />;
case 'text':
return <TextSection heading={section.heading} content={section.content} id={section.id} />;
}
})}
<EventsList events={events.events} />
<Fragment slot="footer">
© {new Date().getFullYear()} {siteContext.businessName}
{siteContext.contactEmail && <span> · {siteContext.contactEmail}</span>}
</Fragment>
</BaseLayout>

17
tsconfig.base.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
}
}

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@shared/*": ["./shared/src/*"]
}
},
"include": ["src/**/*", "shared/src/**/*"]
}