First cut
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
32
.env.example
Normal 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
10
.gitignore
vendored
Normal 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
24
AGENTS.md
Normal 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
33
Dockerfile
Normal 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
144
README.md
@@ -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
10
astro.config.mjs
Normal 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
10
config/sms-sites.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sites": [
|
||||
{
|
||||
"allowedSenders": ["+15035550100"],
|
||||
"phoneNumber": "+15035550142",
|
||||
"repoRoot": ".",
|
||||
"siteId": "timber-and-grain"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
content/events.json
Normal file
28
content/events.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
content/sections/about.json
Normal file
8
content/sections/about.json
Normal 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
|
||||
}
|
||||
29
content/sections/features.json
Normal file
29
content/sections/features.json
Normal 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
|
||||
}
|
||||
11
content/sections/hero.json
Normal file
11
content/sections/hero.json
Normal 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
|
||||
}
|
||||
8
content/sections/promo-banner.json
Normal file
8
content/sections/promo-banner.json
Normal 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
|
||||
}
|
||||
24
content/sections/testimonials.json
Normal file
24
content/sections/testimonials.json
Normal 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
|
||||
}
|
||||
8
content/sections/text.json
Normal file
8
content/sections/text.json
Normal 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
52
docker-compose.yml
Normal 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
36
package.json
Normal 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
26
scripts/canonicalize.js
Normal 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.');
|
||||
41
scripts/check-canonical.js
Normal file
41
scripts/check-canonical.js
Normal 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.');
|
||||
}
|
||||
59
scripts/validate-content.js
Normal file
59
scripts/validate-content.js
Normal 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
26
server/Dockerfile
Normal 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
28
server/package.json
Normal 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
55
server/src/app.ts
Normal 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
161
server/src/db.ts
Normal 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
61
server/src/index.ts
Normal 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);
|
||||
});
|
||||
68
server/src/io/write-content.ts
Normal file
68
server/src/io/write-content.ts
Normal 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
172
server/src/llm/client.ts
Normal 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
18
server/src/logger.ts
Normal 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);
|
||||
}
|
||||
83
server/src/queue/edit-queue.ts
Normal file
83
server/src/queue/edit-queue.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
55
server/src/queue/manifest.ts
Normal file
55
server/src/queue/manifest.ts
Normal 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;
|
||||
}
|
||||
178
server/src/queue/process-edit-job.ts
Normal file
178
server/src/queue/process-edit-job.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
165
server/src/routes/api-edit.ts
Normal file
165
server/src/routes/api-edit.ts
Normal 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;
|
||||
}
|
||||
9
server/src/routes/health.ts
Normal file
9
server/src/routes/health.ts
Normal 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;
|
||||
}
|
||||
115
server/src/routes/webhook-sms.ts
Normal file
115
server/src/routes/webhook-sms.ts
Normal 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
30
server/src/sms/parse.ts
Normal 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
30
server/src/sms/reply.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
34
server/src/sms/templates.ts
Normal file
34
server/src/sms/templates.ts
Normal 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
14
server/tsconfig.json
Normal 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
14
shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
shared/src/canonical-json.ts
Normal file
20
shared/src/canonical-json.ts
Normal 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
7
shared/src/index.ts
Normal 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';
|
||||
42
shared/src/repo-validation.ts
Normal file
42
shared/src/repo-validation.ts
Normal 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
152
shared/src/schemas/index.ts
Normal 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
11
site-context.json
Normal 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."
|
||||
}
|
||||
343
src/components/editor/VisualEditorIsland.tsx
Normal file
343
src/components/editor/VisualEditorIsland.tsx
Normal 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 →</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>
|
||||
);
|
||||
}
|
||||
29
src/components/sections/AboutSection.astro
Normal file
29
src/components/sections/AboutSection.astro
Normal 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>
|
||||
112
src/components/sections/EventsList.astro
Normal file
112
src/components/sections/EventsList.astro
Normal 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>
|
||||
57
src/components/sections/FeaturesSection.astro
Normal file
57
src/components/sections/FeaturesSection.astro
Normal 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>
|
||||
61
src/components/sections/HeroSection.astro
Normal file
61
src/components/sections/HeroSection.astro
Normal 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>
|
||||
65
src/components/sections/TestimonialsSection.astro
Normal file
65
src/components/sections/TestimonialsSection.astro
Normal 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>
|
||||
41
src/components/sections/TextSection.astro
Normal file
41
src/components/sections/TextSection.astro
Normal 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>
|
||||
103
src/layouts/BaseLayout.astro
Normal file
103
src/layouts/BaseLayout.astro
Normal 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
35
src/lib/site-bundle.ts
Normal 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
39
src/lib/site-data.ts
Normal 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
134
src/pages/editor.astro
Normal 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">
|
||||
© {new Date().getFullYear()} {siteContext.businessName} · 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
43
src/pages/index.astro
Normal 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
17
tsconfig.base.json
Normal 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
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["./shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "shared/src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user