Skip to content

Commit eef2d02

Browse files
committed
Initial extraction of Write from Subscript
0 parents  commit eef2d02

58 files changed

Lines changed: 9336 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: pnpm/action-setup@v4
15+
with:
16+
version: 10
17+
18+
- uses: actions/setup-node@v4
19+
with:
20+
node-version: 22
21+
cache: pnpm
22+
cache-dependency-path: pnpm-lock.yaml
23+
24+
- name: Install dependencies
25+
run: pnpm install --frozen-lockfile
26+
27+
- name: Lint
28+
run: pnpm run lint
29+
30+
- name: Test
31+
run: pnpm run test

.gitignore

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Dependencies
2+
node_modules
3+
4+
# Build output
5+
dist
6+
dist-embed
7+
dist-ssr
8+
*.local
9+
10+
# Env files
11+
.env
12+
.env*.local
13+
14+
# Wrangler
15+
.wrangler
16+
17+
# Editor directories and files
18+
.vscode/*
19+
!.vscode/extensions.json
20+
.idea
21+
.DS_Store
22+
*.suo
23+
*.ntvs*
24+
*.njsproj
25+
*.sln
26+
*.sw?
27+
28+
# Logs
29+
logs
30+
*.log
31+
npm-debug.log*
32+
yarn-debug.log*
33+
yarn-error.log*
34+
pnpm-debug.log*
35+
36+
tmp

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Subscript
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Write
2+
3+
Write is a free, open-source, local-first screenwriting app. No account, no server, no lock-in: your script lives in your browser, syncs peer-to-peer when you collaborate, and every edit is cryptographically signed so you always know who wrote what.
4+
5+
Write is a project of **[Subscript](https://subscript.to)** — a home for screenwriters to get their scripts read, reviewed, and into festivals and contests. Write is Subscript's answer to "where do I actually write the thing?" It's free and open source because the tool you draft in shouldn't be a subscription. If you want feedback on a finished draft, a home for your script, or to submit to contests, that's what [subscript.to](https://subscript.to) is for.
6+
7+
The hosted version lives at **[write.subscript.to](https://write.subscript.to)** — open it and start typing, no signup required.
8+
9+
## What it does
10+
11+
- **Local-first**: your project is stored in IndexedDB in your own browser. Nothing is uploaded unless you choose to share it.
12+
- **Peer-to-peer collaboration**: share a link and collaborators sync directly with you over WebRTC ([Yjs](https://yjs.dev/) + [y-webrtc](https://github.com/yjs/y-webrtc)). There's no central server that stores your document.
13+
- **Signed provenance**: every edit and annotation is signed with a device-local Ed25519 key, so you can always verify who wrote or commented on a passage.
14+
- **No account required**: a self-issued, device-bound identity is created automatically. Give your device a name and start writing.
15+
- **Screenplay-native editing**: scene headings, action, dialogue, and the rest of standard screenplay format, with Final Draft (FDX) import and export.
16+
17+
## Developing
18+
19+
If you want to develop or run this locally, you can! This project uses TypeScript, React, Vite, and can be optionally deployed (for very cheap) to Cloudflare Workers.
20+
Install dependencies and start the dev server with:
21+
22+
```sh
23+
pnpm install
24+
pnpm dev
25+
```
26+
27+
This starts the app at `http://localhost:1430`. By default it connects to the public STUN server included below, so peer-to-peer collaboration works out of the box; run your own [signaling worker](./signaling) if you want to self-host that piece too.
28+
29+
### Scripts
30+
31+
| Command | What it does |
32+
| --- | --- |
33+
| `pnpm dev` | Start the Vite dev server |
34+
| `pnpm build` | Type-check and build a static production bundle to `app/dist` |
35+
| `pnpm test` | Run the unit test suite (Vitest) |
36+
| `pnpm lint` | Type-check and run Biome |
37+
| `pnpm signal` | Run the signaling worker locally with `wrangler dev` |
38+
39+
## Configuration
40+
41+
Copy `app/.env.example` to `app/.env.local` and adjust as needed:
42+
43+
| Variable | Purpose | Default |
44+
| --- | --- | --- |
45+
| `VITE_HOMEPAGE` | Where the wordmark links | `https://subscript.to` |
46+
| `VITE_SIGNALING_URL` | WebRTC signaling endpoint | `ws://localhost:8787` (local `wrangler dev`) |
47+
| `VITE_STUN_URL` | STUN server for NAT traversal | `stun:stun.l.google.com:19302` |
48+
| `VITE_TURN_URL` / `VITE_TURN_USERNAME` / `VITE_TURN_CREDENTIAL` | Optional TURN relay for peers behind restrictive NATs | unset |
49+
50+
## Embedding Write in another page
51+
52+
Besides the standalone site, Write can be built as a drop-in component for a page you serve yourself — e.g. a Subscript page with its own `<head>` (analytics, meta tags, etc.) that just wants the editor somewhere in the body. Build it with:
53+
54+
```sh
55+
pnpm --dir app build:embed
56+
```
57+
58+
This produces a single dependency-free ES module at `app/dist-embed/subscript-write.js` (React and friends bundled in, styles injected at runtime, nothing else required on the page). Two ways to use it:
59+
60+
**As a custom element** — importing the module registers `<subscript-write>`:
61+
62+
```html
63+
<script type="module" src="/assets/subscript-write.js"></script>
64+
<subscript-write
65+
style="display:block; height:100vh"
66+
homepage="https://subscript.to"
67+
signaling-url="wss://your-signaling-worker"
68+
idp-config-url="/write/identity/config"
69+
></subscript-write>
70+
```
71+
72+
Attributes map to the config options below (kebab-case); the element mounts on connect and unmounts on disconnect.
73+
74+
**Programmatically**, from your own script:
75+
76+
```js
77+
import { mount } from "/assets/subscript-write.js";
78+
79+
const editor = mount(document.getElementById("editor-slot"), {
80+
homepage: "https://subscript.to",
81+
signalingUrl: "wss://your-signaling-worker",
82+
idpConfigUrl: "/write/identity/config",
83+
});
84+
85+
// later, if the slot is torn down:
86+
editor.unmount();
87+
```
88+
89+
`mount(element, config?)` returns `{ unmount }`. All config is optional and falls back to the same defaults as the standalone build (public STUN, subscript.to homepage, no IDP). See [`app/src/lib/config.ts`](./app/src/lib/config.ts) for the full `WriteConfig` shape — it's the same options as the `VITE_*` env vars below, just provided at runtime instead of build time, since an embedded bundle has no build-time env vars of its own.
90+
91+
## Self-hosting
92+
93+
Everything Write depends on can be run by you:
94+
95+
- **The app** is a static site. Build it with `pnpm build` and serve `app/dist` from anywhere, or deploy it to Cloudflare Workers with `pnpm --dir app deploy` (see [`app/wrangler.jsonc`](./app/wrangler.jsonc)).
96+
- **Signaling** is a small, stateless Cloudflare Worker (see [`signaling/`](./signaling)) that only helps two browsers find each other over WebRTC — it never sees your document. Deploy your own with `pnpm --dir signaling deploy` and point `VITE_SIGNALING_URL` at it.
97+
- **STUN** defaults to Google's free public STUN server, which is enough for most direct peer connections and requires no setup.
98+
- **TURN** is optional and only needed for peers behind especially restrictive NATs/firewalls that can't connect directly. Point `VITE_TURN_URL` (plus username/credential) at any standard TURN server (e.g. [coturn](https://github.com/coturn/coturn)) if you need it.
99+
100+
None of these components store your screenplay. Document content only ever lives in participants' browsers and travels directly between them.
101+
102+
## Deploying
103+
104+
The canonical [write.subscript.to](https://write.subscript.to) deployment is built and published by [`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml) on every push to `main`, via GitHub Pages with a custom domain (see [`app/public/CNAME`](./app/public/CNAME)).
105+
106+
To deploy your own fork to Cloudflare instead:
107+
108+
```sh
109+
cd app
110+
pnpm install
111+
pnpm run deploy:dry-run # sanity check
112+
pnpm run deploy
113+
```
114+
115+
Wrangler uses [`wrangler.jsonc`](./app/wrangler.jsonc) and serves the built Vite output from `dist/` with SPA fallback enabled.
116+
117+
## Identity assertions
118+
119+
By default, Write creates a self-issued Ed25519 assertion bound to a device-local key. It's cryptographically verifiable but marked `self-issued` — it identifies a device, not an account.
120+
121+
To wire Write up to your own account system, set `VITE_EDITOR_IDP_CONFIG_URL` to a JSON endpoint that returns `issuer`, `audience`, `token_endpoint`, and `jwks_uri`. Its token endpoint must accept the device's public JWK plus a one-time nonce and return an RS256 assertion with a matching `cnf.jwk` claim. Write verifies issuer, audience, nonce, expiry, signing key, and device-key binding before trusting the result. External providers must use HTTPS in production; `http://localhost`, `http://127.0.0.1`, and `http://[::1]` are allowed in development only.
122+
123+
## Project layout
124+
125+
```
126+
write/
127+
├── app/ # The React + Vite screenplay editor (deployed as a static site / Cloudflare Worker)
128+
└── signaling/ # Standalone Cloudflare Worker for y-webrtc peer discovery
129+
```
130+
131+
## License
132+
133+
[MIT](./LICENSE) — do what you like with it. If you build something on top of Write, we'd love to hear about it: [subscript.to](https://subscript.to).

app/.env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Homepage link behind the wordmark. Defaults to https://subscript.to when unset.
2+
VITE_HOMEPAGE=https://subscript.to
3+
4+
# WebRTC signaling endpoint. Defaults to the public y-webrtc relay so Write
5+
# works out of the box. Point this at your own deployed Cloudflare Worker
6+
# (see ../signaling) or `wrangler dev`'s local URL if you'd rather self-host.
7+
VITE_SIGNALING_URL=wss://y-webrtc-eu.fly.dev
8+
9+
# STUN is used to discover a public IP for direct peer connections. The Google
10+
# STUN server below is free and requires no setup; swap in your own if you'd
11+
# rather not depend on Google's infrastructure.
12+
VITE_STUN_URL=stun:stun.l.google.com:19302
13+
14+
# Optional TURN relay for peers that can't connect directly (symmetric NATs,
15+
# restrictive firewalls). Leave unset to skip TURN entirely.
16+
# VITE_TURN_URL=turns:turn.example.com:5349
17+
# VITE_TURN_USERNAME=replace-me
18+
# VITE_TURN_CREDENTIAL=replace-me

app/index.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" href="/favicon.ico" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<meta name="theme-color" content="#131923" />
8+
<meta
9+
name="description"
10+
content="Write is a free, open-source, local-first screenwriting app with peer-to-peer collaboration, signed edit provenance, and no account required. A project of Subscript (https://subscript.to)."
11+
/>
12+
<link rel="preconnect" href="https://fonts.googleapis.com" />
13+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700&display=swap" rel="stylesheet" />
15+
<title>Subscript Write</title>
16+
<body>
17+
<div id="root"></div>
18+
<script type="module" src="/src/main.tsx"></script>
19+
</body>
20+
</html>

app/package.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "@subscript-write/app",
3+
"private": true,
4+
"version": "0.1.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite --host 0.0.0.0 --port 1430 --strictPort",
8+
"build": "tsc -b && vite build",
9+
"build:embed": "tsc -b && vite build --config vite.embed.config.ts",
10+
"lint": "tsc -b && biome check src vite.config.ts vite.embed.config.ts",
11+
"test": "vitest run",
12+
"cf:dev": "pnpm run build && wrangler dev",
13+
"deploy:dry-run": "pnpm run build && wrangler deploy --dry-run",
14+
"deploy": "pnpm run build && wrangler deploy"
15+
},
16+
"dependencies": {
17+
"@heroicons/react": "^2.2.0",
18+
"@radix-ui/react-dialog": "^1.1.15",
19+
"@radix-ui/react-dropdown-menu": "^2.1.16",
20+
"@radix-ui/react-tooltip": "^1.2.8",
21+
"@vitejs/plugin-react": "^6.0.1",
22+
"dompurify": "3.3.2",
23+
"react": "^19.2.4",
24+
"react-dom": "^19.2.4",
25+
"y-indexeddb": "^9.0.12",
26+
"y-webrtc": "^10.3.0",
27+
"yjs": "^13.6.30"
28+
},
29+
"devDependencies": {
30+
"@cloudflare/workers-types": "^5.20260730.1",
31+
"@types/node": "^24.12.2",
32+
"@types/react": "^19.2.14",
33+
"@types/react-dom": "^19.2.3",
34+
"esbuild": "^0.27.0",
35+
"jsdom": "^26.1.0",
36+
"typescript": "^5.9.3",
37+
"vite": "^8.0.4",
38+
"vitest": "^3.2.4",
39+
"wrangler": "^4.12.0"
40+
}
41+
}

app/public/CNAME

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
write.subscript.to

app/public/favicon.ico

6.54 KB
Binary file not shown.

0 commit comments

Comments
 (0)