Add sexpr.js runtime plan and comprehensive Ghost removal plan
Two planning documents for the next major architectural steps: - sexpr-js-runtime-plan: isomorphic JS s-expression runtime for client-side rendering, content-addressed component caching, and native hypermedia mutations - ghost-removal-plan: full Ghost CMS replacement covering content (Lexical→sexp), membership, newsletters, Stripe subscriptions, and media uploads Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
333
docs/ghost-removal-plan.md
Normal file
333
docs/ghost-removal-plan.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Ghost Removal Plan
|
||||
|
||||
**Replace Ghost CMS entirely with native infrastructure.**
|
||||
|
||||
---
|
||||
|
||||
## What Ghost Currently Provides
|
||||
|
||||
Ghost is deeply integrated across three major areas:
|
||||
|
||||
### 1. Content Management (blog service)
|
||||
- Post/page storage in Lexical JSON format
|
||||
- Author and tag entities with many-to-many relationships
|
||||
- WYSIWYG editing via Ghost Admin API
|
||||
- Media uploads (images, audio/video, files) via Ghost's upload endpoints
|
||||
- OEmbed lookups for embedded media
|
||||
- Content sync: Ghost → local DB via Content API + webhooks
|
||||
- ActivityPub publishing triggered after post sync
|
||||
|
||||
### 2. Membership & Subscriptions (account service)
|
||||
- **Members**: Ghost is the member store — users have `ghost_id`, synced bidirectionally
|
||||
- **Labels**: tagging/segmentation of members (M2M via `GhostLabel` / `UserLabel`)
|
||||
- **Newsletters**: newsletter entities with per-user subscription tracking (`GhostNewsletter` / `UserNewsletter` with `subscribed` flag)
|
||||
- **Tiers**: membership levels (free/paid) stored in `GhostTier`
|
||||
- **Subscriptions**: paid plans with Stripe integration — cadence, price, Stripe customer/subscription IDs stored in `GhostSubscription`
|
||||
- **Bidirectional sync**: Ghost → DB (`sync_all_membership_from_ghost`, `sync_single_member`) and DB → Ghost (`sync_member_to_ghost`)
|
||||
|
||||
### 3. Infrastructure
|
||||
- JWT token generation for Admin API (`ghost_admin_token.py`)
|
||||
- Webhook handlers for real-time sync (member, post, page, author, tag events)
|
||||
- Email campaign sending (newsletter selection on publish, `email_segment` parameter)
|
||||
- Stripe payment processing for paid subscriptions (handled entirely by Ghost)
|
||||
- Ghost Docker container (Node.js app alongside our Python stack)
|
||||
|
||||
### Environment Variables
|
||||
```
|
||||
GHOST_API_URL, GHOST_ADMIN_API_URL, GHOST_PUBLIC_URL
|
||||
GHOST_CONTENT_API_KEY, GHOST_ADMIN_API_KEY
|
||||
GHOST_WEBHOOK_SECRET
|
||||
```
|
||||
|
||||
### Ghost-Related Files
|
||||
```
|
||||
blog/bp/blog/ghost/ghost_sync.py # Content fetch & sync
|
||||
blog/bp/blog/ghost/ghost_posts.py # Post CRUD via Admin API
|
||||
blog/bp/blog/ghost/ghost_admin_token.py # JWT generation
|
||||
blog/bp/blog/ghost/lexical_validator.py # Lexical JSON validation
|
||||
blog/bp/blog/ghost/editor_api.py # Media upload proxy
|
||||
blog/bp/blog/ghost_db.py # Ghost DB client
|
||||
blog/bp/blog/web_hooks/routes.py # Webhook handlers
|
||||
shared/infrastructure/ghost_admin_token.py # JWT generation (shared copy)
|
||||
shared/models/ghost_content.py # Post, Author, Tag, junction tables
|
||||
shared/models/ghost_membership_entities.py # Label, Newsletter, Tier, Subscription
|
||||
account/services/ghost_membership.py # Membership sync service
|
||||
```
|
||||
|
||||
### Ghost-Related Database Tables
|
||||
```
|
||||
# Content
|
||||
posts (ghost_id, uuid, slug, title, status, lexical, html, ...)
|
||||
authors (ghost_id, slug, name, email, ...)
|
||||
tags (ghost_id, slug, name, ...)
|
||||
post_authors (post_id, author_id, sort_order)
|
||||
post_tags (post_id, tag_id, sort_order)
|
||||
|
||||
# Membership
|
||||
ghost_labels (ghost_id, name, slug)
|
||||
user_labels (user_id, label_id)
|
||||
ghost_newsletters (ghost_id, name, slug, description)
|
||||
user_newsletters (user_id, newsletter_id, subscribed)
|
||||
ghost_tiers (ghost_id, name, slug, type, visibility)
|
||||
ghost_subscriptions (ghost_id, user_id, status, cadence, price_amount,
|
||||
price_currency, stripe_customer_id, stripe_subscription_id,
|
||||
tier_id, raw)
|
||||
|
||||
# User model fields
|
||||
users.ghost_id, users.ghost_status, users.ghost_subscribed,
|
||||
users.ghost_note, users.ghost_raw, users.stripe_customer_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Problems
|
||||
|
||||
- **Two sources of truth** for content AND membership — constant sync overhead
|
||||
- Every edit round-trips through Ghost's API — we don't own the write path
|
||||
- Ghost sync is fragile (advisory locks, error recovery, partial sync states)
|
||||
- Lexical JSON is opaque — we validate but never truly control the format
|
||||
- Ghost is an entire Node.js application running alongside our Python stack
|
||||
- Stripe integration is locked inside Ghost — we can't customize payment flows
|
||||
- Newsletter/email is Ghost-native — no control over templates, scheduling, deliverability
|
||||
- Membership tiers are Ghost concepts that don't map cleanly to our cooperative model
|
||||
|
||||
---
|
||||
|
||||
## Target State
|
||||
|
||||
Everything Ghost does is handled natively by our services:
|
||||
|
||||
| Ghost Feature | Replacement |
|
||||
|---|---|
|
||||
| Post/page content | Sexp in `posts.body_sexp` column |
|
||||
| Lexical editor | WYSIWYG editor saving sexp directly to DB |
|
||||
| Media uploads | Direct upload to our storage (S3/local) — blog service endpoint |
|
||||
| Authors | Already in our DB — just drop `ghost_id` column |
|
||||
| Tags | Already in our DB — just drop `ghost_id` column |
|
||||
| Members | Already our `users` table — drop Ghost sync, Ghost fields |
|
||||
| Labels | Rename `ghost_labels` → `labels`, drop `ghost_id` |
|
||||
| Newsletters | Native newsletter service (see Phase 7 below) |
|
||||
| Tiers | Native membership tiers on `account` service |
|
||||
| Subscriptions | Direct Stripe integration on `orders` service (already has SumUp) |
|
||||
| Email sending | Transactional email service (Postmark/SES/SMTP) |
|
||||
| Webhooks | Not needed — we own the write path |
|
||||
| Ghost Docker container | Removed entirely |
|
||||
|
||||
---
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 1: Lexical → Sexp Converter
|
||||
|
||||
Write a one-time conversion script that transforms Lexical JSON into equivalent sexp.
|
||||
|
||||
| Lexical Node | S-expression |
|
||||
|---|---|
|
||||
| `paragraph` | `(p ...)` |
|
||||
| `heading` (level 1-6) | `(h1 ...)` ... `(h6 ...)` |
|
||||
| `text` (plain) | `"string"` |
|
||||
| `text` (bold) | `(strong "string")` |
|
||||
| `text` (italic) | `(em "string")` |
|
||||
| `text` (bold+italic) | `(strong (em "string"))` |
|
||||
| `text` (code) | `(code "string")` |
|
||||
| `link` | `(a :href "url" "text")` |
|
||||
| `list` (bullet) | `(ul (li ...) ...)` |
|
||||
| `list` (number) | `(ol (li ...) ...)` |
|
||||
| `quote` | `(blockquote ...)` |
|
||||
| `image` | `(use "image" :src "url" :alt "text" :caption "text")` |
|
||||
| `code-block` | `(pre (code :class "language-x" "..."))` |
|
||||
| `divider` | `(hr)` |
|
||||
| `embed` | `(use "embed" :url "..." :type "...")` |
|
||||
|
||||
Run against all existing posts, verify round-trip fidelity by rendering both versions and comparing HTML output.
|
||||
|
||||
### Phase 2: Schema Changes — Content
|
||||
|
||||
- Add `body_sexp` text column to `Post` model (or repurpose `lexical` column)
|
||||
- Keep all existing metadata columns (title, slug, status, published_at, feature_image, etc.)
|
||||
- Drop `ghost_id` from `Post`, `Author`, `Tag` tables (after full migration)
|
||||
- Drop `mobiledoc` column (legacy Ghost format, unused)
|
||||
|
||||
### Phase 3: Editor Integration
|
||||
|
||||
Update the WYSIWYG editor to save sexp instead of Lexical JSON:
|
||||
|
||||
- Editor toolbar actions produce sexp nodes
|
||||
- Save endpoint writes directly to our DB (no Ghost Admin API call)
|
||||
- Preview renders via the same sexp pipeline used for the public view
|
||||
- Draft/publish workflow stays the same — just a `status` column update
|
||||
|
||||
### Phase 4: Media Uploads
|
||||
|
||||
Replace Ghost's upload proxy with native endpoints on the blog service:
|
||||
|
||||
- `POST /admin/upload/image/` — accept image, store to S3/local, return URL
|
||||
- `POST /admin/upload/media/` — audio/video
|
||||
- `POST /admin/upload/file/` — generic files
|
||||
- `GET /admin/oembed/?url=...` — OEmbed lookup (call providers directly)
|
||||
|
||||
The editor already posts to proxy endpoints in `editor_api.py` — just retarget them to store directly rather than forwarding to Ghost.
|
||||
|
||||
### Phase 5: Rendering Pipeline
|
||||
|
||||
Update `post_data()` and related functions:
|
||||
|
||||
- Parse `body_sexp` through the sexp evaluator
|
||||
- Render to HTML via the existing `shared/sexp/html.py` pipeline
|
||||
- Components referenced in post content (`use "image-gallery"`, etc.) resolve from the component registry
|
||||
- Context variables (author data, related posts, etc.) passed as environment bindings
|
||||
|
||||
### Phase 6: Membership Decoupling
|
||||
|
||||
Migrate membership from Ghost to native account service:
|
||||
|
||||
**Labels → native labels:**
|
||||
- Rename `ghost_labels` → `labels`, drop `ghost_id` column
|
||||
- `user_labels` stays as-is
|
||||
- Admin UI manages labels directly (no Ghost sync)
|
||||
|
||||
**Tiers → native membership tiers:**
|
||||
- Rename `ghost_tiers` → `membership_tiers`, drop `ghost_id`
|
||||
- Add tier management to account admin UI
|
||||
- Tier assignment logic moves from Ghost webhook handler to account service
|
||||
|
||||
**User model cleanup:**
|
||||
- Drop: `ghost_id`, `ghost_status`, `ghost_subscribed`, `ghost_note`, `ghost_raw`
|
||||
- Keep: `stripe_customer_id` (needed for direct Stripe integration)
|
||||
- Add: `membership_tier_id` FK, `membership_status` enum (free/active/cancelled)
|
||||
|
||||
### Phase 7: Newsletter System
|
||||
|
||||
Replace Ghost's newsletter infrastructure with a native implementation:
|
||||
|
||||
**Newsletter model (replaces `ghost_newsletters`):**
|
||||
```
|
||||
newsletters (id, name, slug, description, from_email, reply_to, template_sexp, created_at)
|
||||
user_newsletters (user_id, newsletter_id, subscribed, subscribed_at, unsubscribed_at)
|
||||
```
|
||||
|
||||
**Email sending:**
|
||||
- Integrate a transactional email provider (Postmark, AWS SES, or direct SMTP)
|
||||
- Newsletter templates as sexp — rendered to HTML email via the same pipeline
|
||||
- Send endpoint on account or blog service: select newsletter, select segment (by label/tier), queue sends
|
||||
- Unsubscribe handling: tokenized unsubscribe links, one-click List-Unsubscribe header
|
||||
|
||||
**Post → email campaign:**
|
||||
- On publish, optionally select newsletter + segment (replaces Ghost's `?newsletter=slug&email_segment=...`)
|
||||
- Render post body sexp to email-safe HTML (inline styles, table layout for email clients)
|
||||
- Queue via background task (Celery or async worker)
|
||||
|
||||
**What we gain over Ghost:**
|
||||
- Email templates are sexp — same format as everything else
|
||||
- Full control over deliverability (SPF/DKIM/DMARC on our domain)
|
||||
- Segment by any user attribute, not just Ghost's limited filter syntax
|
||||
- Send analytics stored in our DB
|
||||
|
||||
### Phase 8: Subscription & Payment
|
||||
|
||||
Replace Ghost's Stripe integration with direct Stripe on the orders service:
|
||||
|
||||
**Current state:** Orders service already handles SumUp payments for marketplace/events. Adding Stripe for recurring subscriptions follows the same pattern.
|
||||
|
||||
**Implementation:**
|
||||
- Stripe Checkout for subscription creation (redirect flow, PCI compliant)
|
||||
- Stripe Webhooks for subscription lifecycle (created, updated, cancelled, payment_failed)
|
||||
- `subscriptions` table (replaces `ghost_subscriptions`):
|
||||
```
|
||||
subscriptions (id, user_id, tier_id, stripe_subscription_id, stripe_customer_id,
|
||||
status, cadence, price_amount, price_currency,
|
||||
current_period_start, current_period_end, cancelled_at)
|
||||
```
|
||||
- Customer portal: link to Stripe's hosted portal for card updates/cancellation
|
||||
- Webhook handler on orders service (same pattern as SumUp webhooks)
|
||||
|
||||
**What we gain:**
|
||||
- Unified payment handling (SumUp for one-off, Stripe for recurring)
|
||||
- Custom subscription logic (cooperative membership models, sliding scale, etc.)
|
||||
- Direct access to Stripe customer data without Ghost intermediary
|
||||
|
||||
### Phase 9: Remove Ghost
|
||||
|
||||
Delete all Ghost integration code:
|
||||
|
||||
| File/Directory | Action |
|
||||
|---|---|
|
||||
| `blog/bp/blog/ghost/` | Delete entire directory |
|
||||
| `blog/bp/blog/ghost_db.py` | Delete |
|
||||
| `blog/bp/blog/web_hooks/` | Delete |
|
||||
| `shared/infrastructure/ghost_admin_token.py` | Delete |
|
||||
| `account/services/ghost_membership.py` | Delete |
|
||||
| Ghost Docker service | Remove from docker-compose |
|
||||
| Ghost env vars | Remove all `GHOST_*` variables |
|
||||
| Ghost webhook blueprint registration | Remove from blog routes |
|
||||
| Startup sync (`sync_all_content_from_ghost`) | Remove from blog init |
|
||||
| Startup sync (`sync_all_membership_from_ghost`) | Remove from account init |
|
||||
| Advisory lock `900001` | Remove from blog init |
|
||||
|
||||
Rename models:
|
||||
- `ghost_content.py` → `content.py`
|
||||
- `ghost_membership_entities.py` → `membership.py`
|
||||
- Drop all `ghost_id` columns via Alembic migration
|
||||
|
||||
### Phase 10: Content-Addressable Caching (ties into sexpr.js)
|
||||
|
||||
Once posts are sexp and the JS client runtime exists:
|
||||
|
||||
- Hash post body → content address
|
||||
- Client caches post bodies in localStorage keyed by hash
|
||||
- Server sends manifest of slug → hash mappings
|
||||
- Unchanged posts served entirely from client cache
|
||||
- Only the data envelope (metadata, component params) travels on repeat visits
|
||||
|
||||
---
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
- `Post` model and all its metadata fields (minus ghost-specific ones)
|
||||
- URL structure (`/slug/`)
|
||||
- Tag, author, and tag group systems
|
||||
- Draft/publish workflow
|
||||
- Admin edit UI (updated to save sexp instead of Lexical)
|
||||
- RSS feeds (rendered from sexp → HTML)
|
||||
- Search indexing (extract text content from sexp)
|
||||
- ActivityPub federation (triggered on publish, same as now)
|
||||
- Alembic migrations (add/modify/drop columns)
|
||||
- OAuth2 auth system (already independent of Ghost)
|
||||
|
||||
---
|
||||
|
||||
## Ordering & Dependencies
|
||||
|
||||
```
|
||||
Phase 1-2 (Content schema) ──→ Phase 3 (Editor) ──→ Phase 5 (Rendering)
|
||||
Phase 4 (Uploads) ──┘
|
||||
Phase 6 (Membership) ──→ Phase 8 (Payments)
|
||||
Phase 7 (Newsletters) ── independent, needs email provider choice
|
||||
Phase 9 (Remove Ghost) ── after all above complete
|
||||
Phase 10 (Content-addressed) ── after sexpr.js runtime exists
|
||||
```
|
||||
|
||||
Phases 1-5 (content) and Phases 6-8 (membership/payments) can proceed in parallel — they touch different services.
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
- **Data safety**: Run Lexical → sexp converter in dry-run mode first, diff HTML output for every post
|
||||
- **Rollback**: Keep `lexical` column and Ghost running during transition, feature flag to switch renderers
|
||||
- **Editor UX**: Editor remains WYSIWYG — authors never see sexp syntax
|
||||
- **SEO continuity**: URLs don't change, HTML output structurally identical
|
||||
- **Email deliverability**: Set up SPF/DKIM/DMARC before sending first newsletter from our domain
|
||||
- **Payment migration**: Run Ghost Stripe and direct Stripe in parallel during transition, migrate active subscriptions via Stripe API (change the subscription's application)
|
||||
- **Membership data**: One-time migration script to clean User model fields, verified against Ghost export
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Stable sexp parser + evaluator (already built: `shared/sexp/`)
|
||||
- Component registry with post-relevant components: image, embed, gallery, code-block
|
||||
- Editor sexp serialization (new work)
|
||||
- Email provider account (Postmark/SES/SMTP)
|
||||
- Stripe account with recurring billing enabled (may already exist via Ghost)
|
||||
- Optional: sexpr.js client runtime for content-addressable caching (see `sexpr-js-runtime-plan.md`)
|
||||
757
docs/sexpr-js-runtime-plan.md
Normal file
757
docs/sexpr-js-runtime-plan.md
Normal file
@@ -0,0 +1,757 @@
|
||||
# sexpr.js — Development Plan
|
||||
|
||||
**An isomorphic S-expression runtime for the web.**
|
||||
**Code is data is DOM.**
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
Replace HTML as the document/wire format with S-expressions. A single JavaScript library runs on both server (Node/Deno/Bun) and client (browser). The server composes and sends S-expressions over HTTP or WebSocket. The client parses them and renders/mutates the DOM. Because S-expressions are homoiconic, hypermedia controls (fetching, swapping, transitions) are native to the format — not bolted on as special attributes.
|
||||
|
||||
The framework is not a Lisp. It is a document runtime that happens to use S-expression syntax because that syntax makes documents and commands interchangeable.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ sexpr.js (shared) │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌────────────────┐ │
|
||||
│ │ Parser / │ │ Component │ │ Mutation │ │
|
||||
│ │ Serializer │ │ Registry │ │ Engine │ │
|
||||
│ └───────────┘ └───────────┘ └────────────────┘ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌────────────────┐ │
|
||||
│ │ Style │ │ Event │ │ VTree Diff │ │
|
||||
│ │ Compiler │ │ System │ │ & Patch │ │
|
||||
│ └───────────┘ └───────────┘ └────────────────┘ │
|
||||
└─────────────┬───────────────────────────┬───────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌─────────▼─────────┐
|
||||
│ Server Adapter │ │ Client Adapter │
|
||||
│ │ │ │
|
||||
│ • renderToStr() │ │ • renderToDOM() │
|
||||
│ • diff on AST │ │ • mount/hydrate │
|
||||
│ • HTTP handler │ │ • WebSocket recv │
|
||||
│ • WS push │ │ • event dispatch │
|
||||
│ • SSR bootstrap │ │ • service worker │
|
||||
└──────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
The core is environment-agnostic. Thin adapters provide DOM APIs (client) or string serialization (server). Both sides share the parser, component system, style compiler, and mutation engine.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core Runtime (Weeks 1–4)
|
||||
|
||||
The foundation. A single ES module that works in any JS environment.
|
||||
|
||||
### 1.1 Parser & Serializer
|
||||
|
||||
**Parser** — tokenizer + recursive descent, producing an AST of plain JS objects.
|
||||
|
||||
- Atoms: strings (`"hello"`), numbers (`42`, `3.14`), booleans (`#t`, `#f`), symbols (`div`, `my-component`), keywords (`:class`, `:on-click`)
|
||||
- Lists: `(tag :attr "val" children...)`
|
||||
- Comments: `; line comment` and `#| block comment |#`
|
||||
- Quasiquote / unquote: `` ` `` and `,` for template interpolation on the server
|
||||
- Streaming parser variant for large documents (SAX-style)
|
||||
|
||||
**Serializer** — AST back to S-expression string. Round-trip fidelity. Pretty-printer with configurable indentation.
|
||||
|
||||
**Deliverables:**
|
||||
- `parse(string) → AST`
|
||||
- `serialize(AST) → string`
|
||||
- `prettyPrint(AST, opts) → string`
|
||||
- Streaming: `createParser()` returning a push-based parser
|
||||
- Comprehensive test suite (edge cases: nested strings, escapes, unicode, deeply nested structures)
|
||||
- Benchmark: parse speed vs JSON.parse for equivalent data
|
||||
|
||||
### 1.2 AST Representation
|
||||
|
||||
The AST should be cheap to construct, diff, and serialize. Plain objects, not classes:
|
||||
|
||||
```javascript
|
||||
// Atoms
|
||||
{ type: 'symbol', value: 'div' }
|
||||
{ type: 'keyword', value: 'class' }
|
||||
{ type: 'string', value: 'hello' }
|
||||
{ type: 'number', value: 42 }
|
||||
{ type: 'boolean', value: true }
|
||||
|
||||
// List (the fundamental structure)
|
||||
[head, ...rest] // plain arrays — cheap, diffable, JSON-compatible
|
||||
|
||||
// Element sugar (derived during render, not stored)
|
||||
// (div :class "box" (p "hi")) →
|
||||
// [sym('div'), kw('class'), str('box'), [sym('p'), str('hi')]]
|
||||
```
|
||||
|
||||
**Design decision:** ASTs are plain arrays and objects. No custom classes. This means they serialize to JSON trivially — enabling WebSocket transmission, IndexedDB caching, and worker postMessage without structured clone overhead.
|
||||
|
||||
### 1.3 Element Rendering
|
||||
|
||||
The core render function: AST → target output.
|
||||
|
||||
**Shared logic** (environment-agnostic):
|
||||
- Parse keyword attributes from element expressions
|
||||
- Resolve component references
|
||||
- Evaluate special forms (`if`, `each`, `list`, `let`, `slot`)
|
||||
- Compile inline styles
|
||||
|
||||
**Client adapter** — `renderToDOM(ast, env) → Node`:
|
||||
- Creates real DOM nodes via `document.createElement`
|
||||
- Handles SVG namespace detection
|
||||
- Registers event handlers
|
||||
- Returns live DOM node
|
||||
|
||||
**Server adapter** — `renderToString(ast, env) → string`:
|
||||
- Produces HTML string for initial page load (SEO, fast first paint)
|
||||
- Inserts hydration markers so the client can attach without full re-render
|
||||
- Escapes text content for safety
|
||||
|
||||
### 1.4 Style System
|
||||
|
||||
Styles as S-expressions, compiled to CSS strings. Isomorphic: the same style expressions produce CSS on server (injected into `<style>` tags in HTML) and client (injected into document).
|
||||
|
||||
```scheme
|
||||
(style ".card"
|
||||
:background "#1a1a2e"
|
||||
:border-radius "8px"
|
||||
:padding "1.5rem"
|
||||
:hover (:background "#2a2a3e") ; nested pseudo-classes
|
||||
:media "(max-width: 600px)"
|
||||
(:padding "1rem"))
|
||||
|
||||
(style "@keyframes fade-in"
|
||||
(from :opacity "0")
|
||||
(to :opacity "1"))
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Nested selectors (like Sass)
|
||||
- `@media`, `@keyframes`, `@container` as nested S-expressions
|
||||
- CSS variables as regular properties
|
||||
- Optional: scoped styles per component (auto-prefix class names)
|
||||
- Output: raw CSS string or `<style>` DOM node
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Hypermedia Engine (Weeks 5–8)
|
||||
|
||||
The mutation layer. This is where homoiconicity pays off.
|
||||
|
||||
### 2.1 Mutation Primitives
|
||||
|
||||
Since commands and content share the same syntax, the server can send either — or both — in a single response:
|
||||
|
||||
```scheme
|
||||
;; Content (renders to DOM)
|
||||
(div :class "card" (p "Hello"))
|
||||
|
||||
;; Command (mutates existing DOM)
|
||||
(swap! "#card-1" :inner (p "Updated"))
|
||||
|
||||
;; Compound (multiple mutations atomically)
|
||||
(batch!
|
||||
(swap! "#notifications" :append
|
||||
(div :class "toast" "Saved!"))
|
||||
(class! "#save-btn" :remove "loading")
|
||||
(transition! "#toast" :type "slide-in"))
|
||||
```
|
||||
|
||||
**Full primitive set:**
|
||||
|
||||
| Primitive | Purpose |
|
||||
|---|---|
|
||||
| `swap!` | Replace/insert content (`:inner`, `:outer`, `:before`, `:after`, `:prepend`, `:append`, `:delete`, `:morph`) |
|
||||
| `batch!` | Execute multiple mutations atomically |
|
||||
| `class!` | Add/remove/toggle CSS classes |
|
||||
| `attr!` | Set/remove attributes |
|
||||
| `style!` | Inline style manipulation |
|
||||
| `transition!` | CSS transitions and animations |
|
||||
| `wait!` | Delay between batched mutations |
|
||||
| `dispatch!` | Fire custom events |
|
||||
|
||||
### 2.2 Request/Response Cycle
|
||||
|
||||
The equivalent of HTMX's `hx-get`, `hx-post`, etc. — but as native S-expressions:
|
||||
|
||||
```scheme
|
||||
(request!
|
||||
:method "POST"
|
||||
:url "/api/todos"
|
||||
:target "#todo-list"
|
||||
:swap inner
|
||||
:include "#todo-form" ; serialize form data
|
||||
:indicator "#spinner" ; show during request
|
||||
:confirm "Are you sure?" ; browser confirm dialog
|
||||
:timeout 5000
|
||||
:retry 3
|
||||
:on-error (swap! "#errors" :inner
|
||||
(p :class "error" "Request failed")))
|
||||
```
|
||||
|
||||
**Client-side implementation:**
|
||||
1. Serialize form data (if `:include` specified)
|
||||
2. Show indicator
|
||||
3. `fetch()` with `Accept: text/x-sexpr` header
|
||||
4. Parse response as S-expression
|
||||
5. If response is a mutation command → execute it
|
||||
6. If response is content → wrap in `swap!` using `:target` and `:swap`
|
||||
7. Hide indicator
|
||||
8. Handle errors
|
||||
|
||||
**Server-side helpers:**
|
||||
```javascript
|
||||
// Server constructs response using the same library
|
||||
const { s, sym, kw, str } = require('sexpr');
|
||||
|
||||
app.post('/api/todos', (req, res) => {
|
||||
const todo = createTodo(req.body);
|
||||
res.type('text/x-sexpr').send(
|
||||
s.serialize(
|
||||
s.batch(
|
||||
s.swap('#todo-list', 'append', todoComponent(todo)),
|
||||
s.swap('#todo-count', 'inner', s.text(`${count} remaining`)),
|
||||
s.swap('#todo-input', 'attr', { value: '' })
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 WebSocket Channel
|
||||
|
||||
For real-time updates. The server pushes S-expression mutations over WebSocket:
|
||||
|
||||
```scheme
|
||||
;; Server → Client (push)
|
||||
(batch!
|
||||
(swap! "#user-42-status" :inner
|
||||
(span :class "online" "Online"))
|
||||
(swap! "#chat-messages" :append
|
||||
(div :class "message"
|
||||
(strong "Alice") " just joined")))
|
||||
```
|
||||
|
||||
**Protocol:**
|
||||
- Content-type negotiation: `text/x-sexpr` over HTTP, raw S-expr strings over WS
|
||||
- Client reconnects automatically with exponential backoff
|
||||
- Server can send any mutation at any time — the client just evaluates it
|
||||
- Optional: message IDs for acknowledgment, ordering guarantees
|
||||
|
||||
### 2.4 Event Binding
|
||||
|
||||
Declarative event binding that works both inline and as post-render setup:
|
||||
|
||||
```scheme
|
||||
;; Inline (in element definition)
|
||||
(button :on-click (request! :method "POST" :url "/api/like"
|
||||
:target "#like-count" :swap inner)
|
||||
"Like")
|
||||
|
||||
;; Declarative (standalone, for progressive enhancement)
|
||||
(on-event! "#search-input" "input"
|
||||
:debounce 300
|
||||
(request! :method "GET"
|
||||
:url (concat "/api/search?q=" (value event.target))
|
||||
:target "#results" :swap inner))
|
||||
|
||||
;; Keyboard shortcuts
|
||||
(on-event! "body" "keydown"
|
||||
:filter (= event.key "Escape")
|
||||
(class! "#modal" :remove "open"))
|
||||
```
|
||||
|
||||
**Event modifiers** (inspired by Vue/Svelte):
|
||||
- `:debounce 300` — debounce in ms
|
||||
- `:throttle 500` — throttle in ms
|
||||
- `:once` — fire once then unbind
|
||||
- `:prevent` — preventDefault
|
||||
- `:stop` — stopPropagation
|
||||
- `:filter (expr)` — conditional guard
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Component System (Weeks 9–12)
|
||||
|
||||
### 3.1 Component Definition & Instantiation
|
||||
|
||||
Components are parameterized S-expression templates. Not classes. Not functions. Data.
|
||||
|
||||
```scheme
|
||||
(component "todo-item" (id text done)
|
||||
(style ".todo-item" :display "flex" :align-items "center" :gap "0.75rem")
|
||||
(style ".todo-item.done .text" :text-decoration "line-through" :opacity "0.5")
|
||||
|
||||
(li :class (if done "todo-item done" "todo-item") :id (concat "todo-" id)
|
||||
(span :class "check"
|
||||
:on-click (request! :method "POST"
|
||||
:url (concat "/api/todos/" id "/toggle")
|
||||
:target (concat "#todo-" id) :swap outer)
|
||||
(if done "◉" "○"))
|
||||
(span :class "text" text)
|
||||
(button :class "delete"
|
||||
:on-click (request! :method "DELETE"
|
||||
:url (concat "/api/todos/" id)
|
||||
:target (concat "#todo-" id) :swap delete)
|
||||
"×")))
|
||||
|
||||
;; Usage
|
||||
(use "todo-item" :id "1" :text "Buy milk" :done #f)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Lexically scoped parameters
|
||||
- Styles co-located with component (auto-scoped or global, configurable)
|
||||
- Slots for content projection: `(slot)` for default, `(slot "header")` for named
|
||||
- Isomorphic: same component definition renders on server (to string) or client (to DOM)
|
||||
|
||||
### 3.2 Slots & Composition
|
||||
|
||||
```scheme
|
||||
(component "card" (title)
|
||||
(div :class "card"
|
||||
(div :class "card-header" (h3 title))
|
||||
(div :class "card-body" (slot)) ; default slot
|
||||
(div :class "card-footer" (slot "footer")))) ; named slot
|
||||
|
||||
(use "card" :title "My Card"
|
||||
(p "This goes in the default slot")
|
||||
(template :slot "footer"
|
||||
(button "OK") (button "Cancel")))
|
||||
```
|
||||
|
||||
### 3.3 Layouts & Pages
|
||||
|
||||
Higher-level composition for full pages:
|
||||
|
||||
```scheme
|
||||
(layout "main" ()
|
||||
(style "body" :font-family "var(--font)" :margin "0")
|
||||
(style ".layout" :display "grid" :grid-template-rows "auto 1fr auto" :min-height "100vh")
|
||||
|
||||
(div :class "layout"
|
||||
(header (use "nav-bar"))
|
||||
(main (slot))
|
||||
(footer (use "site-footer"))))
|
||||
|
||||
;; A page uses a layout
|
||||
(page "/about"
|
||||
:layout "main"
|
||||
:title "About Us"
|
||||
(section
|
||||
(h1 "About")
|
||||
(p "We replaced HTML with S-expressions.")))
|
||||
```
|
||||
|
||||
### 3.4 Server-Side Component Registry
|
||||
|
||||
On the server, components are registered globally and can be shared between routes:
|
||||
|
||||
```javascript
|
||||
const { registry, component, page, serve } = require('sexpr/server');
|
||||
|
||||
// Register components (can also load from .sexpr files)
|
||||
registry.loadDir('./components');
|
||||
|
||||
// Or inline
|
||||
registry.define('greeting', ['name'],
|
||||
s`(div :class "greeting" (h1 "Hello, " name))`
|
||||
);
|
||||
|
||||
// Route handler returns S-expressions
|
||||
app.get('/about', (req, res) => {
|
||||
res.sexpr(
|
||||
page('/about', { layout: 'main', title: 'About' },
|
||||
s`(section (h1 "About") (p "Hello world"))`)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Virtual Tree & Diffing (Weeks 13–16)
|
||||
|
||||
### 4.1 VTree Representation
|
||||
|
||||
The parsed AST already *is* the virtual tree — no separate representation needed. This is a direct benefit of homoiconicity. While React needs `createElement()` to build a virtual DOM from JSX, our AST is the VDOM.
|
||||
|
||||
```
|
||||
S-expression string → parse() → AST ≡ VTree
|
||||
│
|
||||
renderToDOM() │ diff()
|
||||
▼
|
||||
DOM / Patches
|
||||
```
|
||||
|
||||
### 4.2 Diff Algorithm
|
||||
|
||||
O(n) same-level comparison, similar to React's reconciliation but operating on S-expression ASTs:
|
||||
|
||||
```javascript
|
||||
// Both sides can call this
|
||||
const patches = diff(oldTree, newTree);
|
||||
|
||||
// Client applies to DOM
|
||||
applyPatches(rootNode, patches);
|
||||
|
||||
// Server serializes as mutation commands
|
||||
const mutations = patchesToSexpr(patches);
|
||||
// → (batch! (swap! "#el-3" :inner (p "new")) (attr! "#el-7" :set :class "active"))
|
||||
```
|
||||
|
||||
**Patch types:**
|
||||
- `REPLACE` — replace entire node
|
||||
- `PROPS` — update attributes
|
||||
- `TEXT` — update text content
|
||||
- `INSERT` — insert child at index
|
||||
- `REMOVE` — remove child at index
|
||||
- `REORDER` — reorder children (using `:key` hints)
|
||||
|
||||
**Key insight:** Because patches are also S-expressions, the server can compute a diff and send it as a `batch!` of mutations. The client doesn't need to diff at all — it just executes the mutations. This means the server does the expensive work and the client stays thin.
|
||||
|
||||
### 4.3 Keyed Reconciliation
|
||||
|
||||
For efficient list updates:
|
||||
|
||||
```scheme
|
||||
(each todos (lambda (t)
|
||||
(use "todo-item" :key (get t "id") :text (get t "text") :done (get t "done"))))
|
||||
```
|
||||
|
||||
The `:key` attribute enables the diff algorithm to match nodes across re-renders, minimizing DOM operations for list insertions, deletions, and reorderings.
|
||||
|
||||
### 4.4 Hydration
|
||||
|
||||
Server sends pre-rendered HTML (for SEO and fast first paint). Client attaches to existing DOM without re-rendering:
|
||||
|
||||
1. Server renders S-expression → HTML string with hydration markers
|
||||
2. Browser displays HTML immediately (fast first contentful paint)
|
||||
3. Client JS loads, parses the original S-expression source (embedded in a `<script type="text/x-sexpr">` tag)
|
||||
4. Client walks the existing DOM and attaches event handlers without rebuilding it
|
||||
5. Subsequent updates go through the normal S-expression channel
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Developer Experience (Weeks 17–20)
|
||||
|
||||
### 5.1 CLI Tool
|
||||
|
||||
```bash
|
||||
npx sexpr init my-app # scaffold project
|
||||
npx sexpr dev # dev server with hot reload
|
||||
npx sexpr build # production build
|
||||
npx sexpr serve # production server
|
||||
```
|
||||
|
||||
**Project structure:**
|
||||
```
|
||||
my-app/
|
||||
components/
|
||||
nav-bar.sexpr
|
||||
todo-item.sexpr
|
||||
card.sexpr
|
||||
pages/
|
||||
index.sexpr
|
||||
about.sexpr
|
||||
layouts/
|
||||
main.sexpr
|
||||
styles/
|
||||
theme.sexpr
|
||||
server.js
|
||||
sexpr.config.js
|
||||
```
|
||||
|
||||
### 5.2 `.sexpr` File Format
|
||||
|
||||
Single-file components with co-located styles, markup, and metadata:
|
||||
|
||||
```scheme
|
||||
; components/todo-item.sexpr
|
||||
|
||||
(meta
|
||||
:name "todo-item"
|
||||
:params (id text done)
|
||||
:description "A single todo list item with toggle and delete")
|
||||
|
||||
(style ".todo-item"
|
||||
:display "flex"
|
||||
:align-items "center"
|
||||
:gap "0.75rem")
|
||||
|
||||
(li :class (if done "todo-item done" "todo-item")
|
||||
:id (concat "todo-" id)
|
||||
:key id
|
||||
(span :class "check"
|
||||
:on-click (request! :method "POST"
|
||||
:url (concat "/api/todos/" id "/toggle")
|
||||
:target (concat "#todo-" id) :swap outer)
|
||||
(if done "◉" "○"))
|
||||
(span :class "text" text)
|
||||
(button :class "delete"
|
||||
:on-click (request! :method "DELETE"
|
||||
:url (concat "/api/todos/" id)
|
||||
:target (concat "#todo-" id) :swap delete)
|
||||
"×"))
|
||||
```
|
||||
|
||||
### 5.3 DevTools
|
||||
|
||||
**Browser extension** (or embedded panel):
|
||||
- AST inspector: visualize the S-expression tree alongside the DOM
|
||||
- Mutation log: every `swap!`, `class!`, `batch!` logged with timestamp
|
||||
- Network tab: S-expression request/response viewer (not raw text)
|
||||
- Component tree: hierarchical view of instantiated components
|
||||
- Time-travel: replay mutations forward/backward
|
||||
|
||||
**Server-side:**
|
||||
- Request logger showing S-expression responses
|
||||
- Component dependency graph
|
||||
- Hot reload: file watcher on `.sexpr` files, push updates via WebSocket
|
||||
|
||||
### 5.4 Editor Support
|
||||
|
||||
- **VS Code extension**: syntax highlighting for `.sexpr` files, bracket matching (parentheses), auto-indentation, component name completion, attribute completion for HTML tags
|
||||
- **Tree-sitter grammar**: for Neovim, Helix, Zed, etc.
|
||||
- **Prettier plugin**: auto-format `.sexpr` files
|
||||
- **LSP server**: go-to-definition for components, find-references, rename symbol
|
||||
|
||||
### 5.5 Error Handling
|
||||
|
||||
- **Parse errors**: line/column reporting with context (show the offending line)
|
||||
- **Render errors**: error boundaries like React — a component crash renders a fallback, not a blank page
|
||||
- **Network errors**: `:on-error` handler in `request!`, plus global error handler
|
||||
- **Dev mode**: verbose errors with suggestions; production mode: compact
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Ecosystem & Production (Weeks 21–28)
|
||||
|
||||
### 6.1 Middleware & Plugins
|
||||
|
||||
**Server middleware** (Express/Koa/Hono compatible):
|
||||
|
||||
```javascript
|
||||
const { sexprMiddleware } = require('sexpr/server');
|
||||
|
||||
app.use(sexprMiddleware({
|
||||
componentsDir: './components',
|
||||
layoutsDir: './layouts',
|
||||
hotReload: process.env.NODE_ENV !== 'production'
|
||||
}));
|
||||
|
||||
// Route handlers return S-expressions directly
|
||||
app.get('/', (req, res) => {
|
||||
res.sexpr(homePage(req.user));
|
||||
});
|
||||
```
|
||||
|
||||
**Plugin system** for extending the runtime:
|
||||
|
||||
```javascript
|
||||
// A plugin that adds a (markdown "...") special form
|
||||
sexpr.plugin('markdown', {
|
||||
transform(ast) { /* convert markdown to sexpr AST */ },
|
||||
serverRender(ast) { /* render to HTML string */ },
|
||||
clientRender(ast) { /* render to DOM */ }
|
||||
});
|
||||
```
|
||||
|
||||
### 6.2 Router
|
||||
|
||||
Client-side navigation without full page reloads:
|
||||
|
||||
```scheme
|
||||
(router
|
||||
(route "/" (use "home-page"))
|
||||
(route "/about" (use "about-page"))
|
||||
(route "/todos/:id" (use "todo-detail" :id params.id))
|
||||
(route "*" (use "not-found")))
|
||||
```
|
||||
|
||||
- Intercepts `<a>` clicks for internal links
|
||||
- `pushState` / `popState` navigation
|
||||
- Server-side: same route definitions, used for SSR
|
||||
- Prefetch: `(link :href "/about" :prefetch #t "About")`
|
||||
|
||||
### 6.3 Forms
|
||||
|
||||
Declarative form handling:
|
||||
|
||||
```scheme
|
||||
(form :action "/api/users" :method "POST"
|
||||
:target "#result" :swap inner
|
||||
:validate #t ; client-side validation
|
||||
:reset-on-success #t
|
||||
|
||||
(input :type "text" :name "username"
|
||||
:required #t
|
||||
:minlength 3
|
||||
:pattern "[a-zA-Z0-9]+"
|
||||
:error-message "Alphanumeric, 3+ chars")
|
||||
|
||||
(input :type "email" :name "email" :required #t)
|
||||
|
||||
(button :type "submit" "Create User"))
|
||||
```
|
||||
|
||||
The server validates identically — same validation rules expressed as S-expressions run on both sides.
|
||||
|
||||
### 6.4 Content-Type & MIME
|
||||
|
||||
Register `text/x-sexpr` as a proper MIME type:
|
||||
|
||||
- HTTP responses: `Content-Type: text/x-sexpr; charset=utf-8`
|
||||
- `Accept` header negotiation: client sends `Accept: text/x-sexpr, text/html;q=0.9`
|
||||
- Fallback: if client doesn't accept `text/x-sexpr`, server renders to HTML (graceful degradation)
|
||||
- File extension: `.sexpr`
|
||||
|
||||
### 6.5 Caching & Service Worker
|
||||
|
||||
- **Service worker**: caches S-expression responses, serves offline
|
||||
- **Incremental cache**: cache individual components, not whole pages
|
||||
- **ETag/304**: standard HTTP caching works because responses are text
|
||||
- **Compression**: S-expressions compress well with gzip/brotli (repetitive keywords)
|
||||
|
||||
### 6.6 Security
|
||||
|
||||
- **No `eval()`**: S-expressions are parsed, not evaluated as code. The runtime only understands the defined special forms.
|
||||
- **XSS prevention**: text content is always escaped when rendered to DOM (via `textContent`, not `innerHTML`). The `raw-html` escape hatch requires explicit opt-in.
|
||||
- **CSP compatible**: no inline scripts generated. Event handlers are registered via JS, not `onclick` attributes (move away from the v1 prototype approach).
|
||||
- **CSRF**: standard token-based approach, with `(meta :csrf-token "...")` in the page head.
|
||||
|
||||
### 6.7 Accessibility
|
||||
|
||||
- S-expressions map 1:1 to semantic HTML elements — `(nav ...)`, `(main ...)`, `(article ...)`, `(aside ...)` all render to their HTML equivalents
|
||||
- ARIA attributes work naturally: `:aria-label "Close" :aria-expanded #f :role "dialog"`
|
||||
- Focus management primitives: `(focus! "#element")`, `(trap-focus! "#modal")`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Testing & Documentation (Weeks 25–28, overlapping)
|
||||
|
||||
### 7.1 Testing Utilities
|
||||
|
||||
```javascript
|
||||
const { render, fireEvent, waitForMutation } = require('sexpr/test');
|
||||
|
||||
test('todo toggle', async () => {
|
||||
const tree = render('(use "todo-item" :id "1" :text "Buy milk" :done #f)');
|
||||
|
||||
expect(tree.query('.todo-item')).not.toHaveClass('done');
|
||||
|
||||
fireEvent.click(tree.query('.check'));
|
||||
await waitForMutation('#todo-1');
|
||||
|
||||
expect(tree.query('.todo-item')).toHaveClass('done');
|
||||
});
|
||||
```
|
||||
|
||||
- `render()` works in Node (using JSDOM) or browser
|
||||
- Snapshot testing: compare AST snapshots, not HTML string snapshots
|
||||
- Mutation assertions: `expectMutation(swap!(...))` to test server responses
|
||||
|
||||
### 7.2 Documentation Site
|
||||
|
||||
Built with sexpr.js itself (dogfooding). Includes:
|
||||
- Tutorial: build a todo app from scratch
|
||||
- API reference: every special form, mutation primitive, configuration option
|
||||
- Cookbook: common patterns (modals, infinite scroll, real-time chat, auth flows)
|
||||
- Interactive playground: edit S-expressions, see live DOM output
|
||||
- Migration guide: "coming from HTMX" and "coming from React"
|
||||
|
||||
---
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### Why Not JSON?
|
||||
|
||||
JSON could represent the same tree structure. But:
|
||||
- S-expressions are more compact for deeply nested structures (no commas, colons, or quotes on keys)
|
||||
- The syntax is its own DSL — `:keywords`, symbols, and lists feel natural for document description
|
||||
- Comments are supported (JSON has none)
|
||||
- Human-writeable: developers will author `.sexpr` files directly
|
||||
- Cultural signal: this is a Lisp-inspired project, and the syntax communicates the homoiconicity thesis immediately
|
||||
|
||||
### Why Not Compile to WebAssembly?
|
||||
|
||||
Tempting for parser performance, but:
|
||||
- JS engines already optimize parsing hot paths well
|
||||
- WASM has overhead for DOM interaction (must cross the JS boundary anyway)
|
||||
- Staying in pure JS means the library works everywhere JS does with zero build step
|
||||
- Future option: WASM parser module for very large documents
|
||||
|
||||
### Module Format
|
||||
|
||||
- ES modules (`.mjs`) as primary
|
||||
- CommonJS (`.cjs`) build for older Node.js
|
||||
- UMD build for `<script>` tag usage (the "drop it in and it works" story)
|
||||
- TypeScript type definitions (`.d.ts`) shipped alongside
|
||||
|
||||
### Bundle Size Target
|
||||
|
||||
- Core parser + renderer: **< 5KB** gzipped
|
||||
- With mutation engine: **< 8KB** gzipped
|
||||
- Full framework (router, forms, devtools hook): **< 15KB** gzipped
|
||||
|
||||
For comparison: HTMX is ~14KB, Alpine.js is ~15KB, Preact is ~3KB.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Target | Deliverable |
|
||||
|---|---|---|
|
||||
| **M1: Parser** | Week 2 | `parse()`, `serialize()`, full test suite, benchmarks |
|
||||
| **M2: Client renderer** | Week 4 | `renderToDOM()`, styles, events — the v1 prototype, cleaned up |
|
||||
| **M3: Server renderer** | Week 6 | `renderToString()`, Express middleware, SSR bootstrap |
|
||||
| **M4: Mutations** | Week 8 | `swap!`, `batch!`, `request!`, `class!` — full hypermedia engine |
|
||||
| **M5: WebSocket** | Week 10 | Real-time server push, reconnection, protocol spec |
|
||||
| **M6: Components** | Week 12 | Component system, slots, `.sexpr` file format, registry |
|
||||
| **M7: Diffing** | Week 16 | VTree diff, keyed reconciliation, hydration |
|
||||
| **M8: CLI & DX** | Week 20 | `sexpr init/dev/build`, hot reload, VS Code extension |
|
||||
| **M9: Ecosystem** | Week 24 | Router, forms, plugins, service worker caching |
|
||||
| **M10: Launch** | Week 28 | Docs site, npm publish, example apps, announcement |
|
||||
|
||||
---
|
||||
|
||||
## Example Apps (for launch)
|
||||
|
||||
1. **Todo MVC** — the classic benchmark, fully server-driven
|
||||
2. **Real-time chat** — WebSocket mutations, presence indicators
|
||||
3. **Dashboard** — data tables, charts, polling, search
|
||||
4. **Blog** — SSR, routing, SEO, markdown integration
|
||||
5. **E-commerce product page** — forms, validation, cart mutations
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Macro system?** Should the server support `defmacro`-style macros for transforming S-expressions before rendering? This adds power but also complexity and potential security concerns.
|
||||
|
||||
2. **TypeScript integration?** Should component params be typed? Could generate TS interfaces from `(meta :params ...)` declarations.
|
||||
|
||||
3. **Compilation?** An optional ahead-of-time compiler could pre-parse `.sexpr` files into JS AST constructors, eliminating parse time at runtime. Worth the complexity?
|
||||
|
||||
4. **CSS-in-sexpr or external stylesheets?** The current approach co-locates styles. Should there also be a way to import `.css` files directly, or should all styling go through the S-expression syntax?
|
||||
|
||||
5. **Interop with existing HTML?** Can you embed an S-expression island inside an existing HTML page (like Astro islands)? Useful for incremental adoption.
|
||||
|
||||
6. **Binary wire format?** A compact binary encoding of the AST (like MessagePack for S-expressions) could reduce bandwidth for large pages. Worth the complexity vs. gzip?
|
||||
|
||||
---
|
||||
|
||||
## Name Candidates
|
||||
|
||||
- **sexpr.js** — direct, memorable, says what it is
|
||||
- **sdom** — S-expression DOM
|
||||
- **paren** — the defining character
|
||||
- **lispr** — Lisp + render
|
||||
- **homoDOM** — homoiconic DOM
|
||||
|
||||
---
|
||||
|
||||
*The document is the program. The program is the document.*
|
||||
Reference in New Issue
Block a user