@rubar/lavish-publish-cf 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/explainer-image.png +0 -0
- package/package.json +58 -0
- package/scripts/install.sh +168 -0
- package/skill/SKILL.md +128 -0
- package/skill/references/cloudflare.md +99 -0
- package/skill/references/design-md.md +56 -0
- package/skill/references/manage.md +68 -0
- package/skill/references/themes.md +77 -0
- package/worker/CLAUDE.md +64 -0
- package/worker/README.md +222 -0
- package/worker/cli/index.js +592 -0
- package/worker/src/index.ts +1374 -0
- package/worker/tsconfig.json +19 -0
- package/worker/wrangler.toml +16 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Manage existing CF pages
|
|
2
|
+
|
|
3
|
+
Load when `/publish` is invoked with `list`, `manage`, or `pages`, or when the user says "list my published pages", "manage my pages", etc.
|
|
4
|
+
|
|
5
|
+
This flow operates only on **Cloudflare-published** pages — local `.lavish/` files aren't tracked anywhere and aren't part of this list.
|
|
6
|
+
|
|
7
|
+
## Step 1 — gather
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
publish-cf list-mine
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
One line per page: slug, URL, title, expires, comments, source. `list-mine` does **not** include password presence — fetch that per-slug via `publish-cf get <slug>` (returns `has_password`). Run the `get` calls in parallel.
|
|
14
|
+
|
|
15
|
+
**Classify each entry** while parsing:
|
|
16
|
+
|
|
17
|
+
- **Live** — `get` returns 200. URL is on the configured prod `api_base`.
|
|
18
|
+
- **Dead — localhost** — URL uses `localhost` / `127.0.0.1`. Stale dev entries; the production CLI cannot reach them. Don't `get` against prod — they 404 noisily.
|
|
19
|
+
- **Dead — expired** — `expires_at` is in the past, or `get` returns 404 with no localhost URL. KV TTL has already evicted them server-side; the local `keys.json` entry is orphaned.
|
|
20
|
+
|
|
21
|
+
If `list-mine` returns "no pages saved on this machine", say so and exit.
|
|
22
|
+
|
|
23
|
+
## Step 2 — render
|
|
24
|
+
|
|
25
|
+
Print a compact one-row-per-page summary in chat as plain text (never a markdown table — the CLI doesn't render them). Group into a **Live** section and a **Dead** section so the action buckets in step 3 make sense. Format roughly as:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
<slug> <title-or-"Untitled"> pw:yes/no comments:on/off expires:<human> <url>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If there are more than ~15 live pages, build a `.lavish/` HTML file and open it with `lavish-axi` instead (per global CLAUDE.md, long structured output goes to lavish).
|
|
32
|
+
|
|
33
|
+
## Step 3 — pick the next action
|
|
34
|
+
|
|
35
|
+
`AskUserQuestion` is capped at 4 options, so **don't try to enumerate every page as an option** — it won't fit. Instead, ask one bucket-style question and rely on the auto-`Other` slot for free-text slug entry. Header: `"Pick"`. Compose options from the relevant ones below (pick 2–4 that apply to the current state):
|
|
36
|
+
|
|
37
|
+
- **"Act on a specific page"** — tell the user to type the slug in `Other`, or if they pick this option re-prompt with `AskUserQuestion` to capture the slug. Either way, jump to step 4 with that slug.
|
|
38
|
+
- **"Clean up N dead entries"** — only include if there are dead entries. Removes them from local `keys.json` (no API calls — they're already gone server-side). See "Cleanup dead entries" below.
|
|
39
|
+
- **"Delete all live pages"** — only include if the user has clearly signaled bulk-cleanup intent in this session. Otherwise omit; bulk deletion is destructive and shouldn't be a one-click default.
|
|
40
|
+
- **"Done"** — always include.
|
|
41
|
+
|
|
42
|
+
If the user typed a slug into `Other`, treat that as "act on this specific page".
|
|
43
|
+
|
|
44
|
+
## Step 4 — pick an action on the picked page
|
|
45
|
+
|
|
46
|
+
`AskUserQuestion` with header `"Action"` and these options (drop ones that don't apply — e.g. "Toggle password" branches differently based on `has_password`):
|
|
47
|
+
|
|
48
|
+
- **Delete** — destructive. Confirm with a second `AskUserQuestion` (`"Delete <slug>? Yes / No"`), then `publish-cf delete <slug>`.
|
|
49
|
+
- **Toggle comments** — flip current state via `publish-cf comments toggle <slug> --on` or `--off`.
|
|
50
|
+
- **Toggle password** — branch on `has_password`:
|
|
51
|
+
- Off → on: generate (`openssl rand -base64 12 | tr -d '=+/' | cut -c1-12`), run `publish-cf password <slug> --set "<pw>"`, share the new password back in the reply.
|
|
52
|
+
- On → ask sub-question `"Password: replace / remove"`. Replace → generate new pw, `--set <newpw>`, share it back. Remove → `publish-cf password <slug> --clear`.
|
|
53
|
+
- **Open** — `open <url>`.
|
|
54
|
+
- **Back** — return to step 3.
|
|
55
|
+
|
|
56
|
+
## Cleanup dead entries
|
|
57
|
+
|
|
58
|
+
`publish-cf delete <slug>` calls the worker API first and only removes the local key on a 200 response. For localhost and KV-expired entries that path fails, so the orphaned key persists. To clean them up locally without an API call, edit `~/.publish-cloudflare/keys.json` directly.
|
|
59
|
+
|
|
60
|
+
The snippet below backs up `keys.json` and then removes the dead slugs in one command — the backup is part of the chain so it cannot be skipped. Edit the `SLUG1,SLUG2` list before running, and report the printed backup path back to the user in the final reply so they can roll back if needed.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cp ~/.publish-cloudflare/keys.json ~/.publish-cloudflare/keys.json.backup-$(date +%s) && node -e "const fs=require('fs'),p=process.env.HOME+'/.publish-cloudflare/keys.json',k=JSON.parse(fs.readFileSync(p));const dead=['SLUG1','SLUG2'];const ok=k.owner_keys||k;dead.forEach(s=>delete ok[s]);fs.writeFileSync(p,JSON.stringify(k,null,2));console.log('removed',dead.length,'dead entries; backup at',p+'.backup-*')"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Step 5 — loop
|
|
67
|
+
|
|
68
|
+
After any action other than Done, return to step 1 (re-fetch — the list may have changed). Exit when the user picks Done.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Theme reference
|
|
2
|
+
|
|
3
|
+
Per-theme content fit, markup conventions, and the placeholder-replacement checklist. Load this once when building a page.
|
|
4
|
+
|
|
5
|
+
## Picking the theme shell
|
|
6
|
+
|
|
7
|
+
Six shells live under `~/.lavish-themes/tier{1,2}/<slug>.html` (installed via [lavish-themes](https://github.com/natekettles/lavish-themes)):
|
|
8
|
+
|
|
9
|
+
- **Tier 1** (vendored CSS, fully self-contained, no external deps): `latex`, `terminal`, `water`.
|
|
10
|
+
- **Tier 2** (Google Fonts via `<link>` — still self-contained otherwise): `swiss`, `handwritten`, `zine`.
|
|
11
|
+
|
|
12
|
+
All six render light by default. None should be modified — read the shell, replace content, leave styling intact.
|
|
13
|
+
|
|
14
|
+
## Per-theme cues
|
|
15
|
+
|
|
16
|
+
### latex (Tier 1)
|
|
17
|
+
|
|
18
|
+
- **Fit**: research-feeling briefs, papers, citation-heavy writing, anything that should read like a working paper.
|
|
19
|
+
- **Markup**: `<header>` with `<h1>`, optional `<p class="author">` and `<p class="abstract">`. Body in `<main>` with numbered `<h2>` sections.
|
|
20
|
+
|
|
21
|
+
### terminal (Tier 1)
|
|
22
|
+
|
|
23
|
+
- **Fit**: runbooks, postmortems, RFCs, CLI documentation, anything that should read like a developer note. Mono everywhere.
|
|
24
|
+
- **Markup**: wrap content in `<div class="container">`. Keep `<body class="terminal">`.
|
|
25
|
+
|
|
26
|
+
### water (Tier 1)
|
|
27
|
+
|
|
28
|
+
- **Fit**: the neutral default. Generic reports, briefs, neutral product writing. Classless — just paste content into `<main>` and it looks correct.
|
|
29
|
+
- **Markup**: classless. Drop content into `<main>`. Do not add wrapper divs unless the content genuinely needs them.
|
|
30
|
+
|
|
31
|
+
### swiss (Tier 2)
|
|
32
|
+
|
|
33
|
+
- **Fit**: decisive product/strategy briefs, memos, opinionated writing. Modernist grid, red accent.
|
|
34
|
+
- **Markup**: `<h1>` inside `<header class="masthead">`, plus a `.meta` block. Keep the inline `<style>` block exactly as-is — replace only the content inside `<header>`, `<main>`, `<aside>`, etc.
|
|
35
|
+
|
|
36
|
+
### handwritten (Tier 2)
|
|
37
|
+
|
|
38
|
+
- **Fit**: personal notes, letters, journal-feeling pieces. Looser, more human.
|
|
39
|
+
- **Markup**: `<h1>` in the header. Keep the inline `<style>` block exactly as-is.
|
|
40
|
+
|
|
41
|
+
### zine (Tier 2)
|
|
42
|
+
|
|
43
|
+
- **Fit**: loud manifestos, launch announcements, marketing-flavored pieces.
|
|
44
|
+
- **Markup**: `<h1>` inside `<header class="cover">` (note the `<br>` line breaks in the sample). Keep the inline `<style>` block exactly as-is.
|
|
45
|
+
|
|
46
|
+
For Tier 2 themes (swiss / handwritten / zine), **do not redesign**. The inline styling is load-bearing for the look. Replace text content only.
|
|
47
|
+
|
|
48
|
+
## Placeholder-replacement checklist
|
|
49
|
+
|
|
50
|
+
Every shell ships with sample copy that must not survive into the published page. Walk through this list every time:
|
|
51
|
+
|
|
52
|
+
1. **`<title>` tag** — replace with the page's real title. The `publish-cf` CLI auto-extracts this and uses it as the wrapper title for comments-on pages, so a stale `<title>` becomes a visible bug.
|
|
53
|
+
2. **Masthead heading** (per theme):
|
|
54
|
+
- **latex**: `<h1>` inside the `<header>` block.
|
|
55
|
+
- **terminal**: `<h1>` near the top of `<div class="container">`.
|
|
56
|
+
- **water**: `<h1>` near the top of `<main>`.
|
|
57
|
+
- **swiss**: `<h1>` inside `<header class="masthead">`, **plus** the `.meta` block underneath.
|
|
58
|
+
- **handwritten**: `<h1>` in the header.
|
|
59
|
+
- **zine**: `<h1>` inside `<header class="cover">`.
|
|
60
|
+
3. **Body content** — replace every paragraph, list item, blockquote, etc. The shells ship with placeholder essays; none of it should leak.
|
|
61
|
+
|
|
62
|
+
### Verify before saving
|
|
63
|
+
|
|
64
|
+
After replacing, run this check on the file:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
grep -E '— sample|The Quiet Architecture' <path>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
It must return nothing. If it returns hits, you missed placeholder copy.
|
|
71
|
+
|
|
72
|
+
## When in doubt
|
|
73
|
+
|
|
74
|
+
- Pick `water` as the neutral fallback — it's classless and forgiving.
|
|
75
|
+
- Pick `swiss` when the prose is opinionated and benefits from a strong masthead.
|
|
76
|
+
- Pick `terminal` when the content is technical and reads naturally as monospace.
|
|
77
|
+
- Avoid `zine` and `handwritten` unless the prompt explicitly suggests their tone — they are loud choices that shape how the content reads.
|
package/worker/CLAUDE.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
Self-hosted clone of htmlship.com running on Cloudflare Workers + KV. Two halves:
|
|
8
|
+
|
|
9
|
+
- **Worker** (`src/index.ts`) — single-file TypeScript Worker. All routing, storage, rendering, comments, and auth live here.
|
|
10
|
+
- **CLI** (`cli/index.js`) — zero-dep Node client (`publish-cf`) that talks to the deployed Worker. Stores per-slug `owner_key` and API base under `~/.publish-cloudflare/`.
|
|
11
|
+
|
|
12
|
+
Both files are intentionally single-file. Don't split them up "for organization" — the design constraint is keeping the Worker deployable as one module and the CLI installable without `npm install`.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
Run these from the **repo root** (where `package.json` now lives). Wrangler is invoked with `--config worker/wrangler.toml`.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm run dev # wrangler dev — local Worker on http://localhost:8787 (in-memory KV)
|
|
20
|
+
npm run deploy # wrangler deploy to Cloudflare
|
|
21
|
+
npm run tail # wrangler tail — stream live logs from prod
|
|
22
|
+
npm run types # wrangler types — regenerate worker types
|
|
23
|
+
|
|
24
|
+
# CLI against local dev server
|
|
25
|
+
PUBLISH_CF_API=http://localhost:8787 node worker/cli/index.js publish foo.html
|
|
26
|
+
PUBLISH_CF_API=http://localhost:8787 node worker/cli/index.js list-mine
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`PUBLISH_CF_API` overrides `~/.publish-cloudflare/config.json`. There is no test suite, no linter, and no build step (Wrangler bundles the TS directly).
|
|
30
|
+
|
|
31
|
+
## Architecture notes
|
|
32
|
+
|
|
33
|
+
**Routing**: All paths are handled by a single `fetch` export at the bottom of `src/index.ts` using regex matches. Order matters — `/v/:slug/comments` and `/v/:slug/raw` must be matched before `/v/:slug`. When adding routes, add them in the same `fetch` handler, not via any framework.
|
|
34
|
+
|
|
35
|
+
**Two CSPs, one wrapper-iframe split**:
|
|
36
|
+
|
|
37
|
+
- `/v/:slug` serves a **wrapper page** (comment sidebar + UI JS) under `WRAPPER_CSP` (allows `'unsafe-inline'` scripts so the comment UI can run).
|
|
38
|
+
- `/v/:slug/raw` serves the **user's artifact** under the strict `PAGE_CSP` (`script-src 'none'`), embedded in a same-origin iframe.
|
|
39
|
+
- The wrapper never executes anything from the artifact. If you touch CSP or the wrapper HTML, preserve this boundary — the strict CSP on `/raw` is the only thing keeping user-supplied HTML from running scripts.
|
|
40
|
+
|
|
41
|
+
**KV layout** (`PAGES` namespace):
|
|
42
|
+
|
|
43
|
+
- `page:<slug>` → `PageRecord` (html, hashes, expiry)
|
|
44
|
+
- `comment:<slug>:<id>` → one comment per key, listed via `PAGES.list({ prefix: "comment:<slug>:" })`. One-key-per-comment is deliberate, to avoid lost writes from concurrent commenters.
|
|
45
|
+
|
|
46
|
+
**Auth model**:
|
|
47
|
+
|
|
48
|
+
- `owner_key` (CLI side, plaintext `ws_…`) and `password` (viewer side, plaintext) are both stored on the server as SHA-256 hex hashes only. Compare with `timingSafeEqualHex`.
|
|
49
|
+
- Viewer cookie `hp_<slug>=ok` (1h) gates `/v/:slug`, `/v/:slug/raw`, and comment read/write for password-protected pages.
|
|
50
|
+
- `X-Owner-Key` header gates PATCH/DELETE on pages and comments.
|
|
51
|
+
|
|
52
|
+
**Comment anchoring**: text-quote selector (quote + ~32 chars prefix/suffix). Comments whose anchor no longer matches after a republish are flagged `orphaned` rather than deleted. Anchor matching logic is in the wrapper page's inline JS (inside `wrapperHtml`), not server-side.
|
|
53
|
+
|
|
54
|
+
## Conventions
|
|
55
|
+
|
|
56
|
+
- The Worker has no dependencies beyond `@cloudflare/workers-types`. Don't add runtime deps — use Web Crypto, `fetch`, `Response`, KV directly.
|
|
57
|
+
- The CLI has zero runtime deps. Don't add any. It calls the API with plain `fetch` and writes JSON to `~/.publish-cloudflare/` with mode `0600`.
|
|
58
|
+
- Limits (`MAX_HTML_BYTES`, `MAX_EXPIRES_MINUTES`, etc.) are declared as top-level consts in `src/index.ts`. Change them there, not inline.
|
|
59
|
+
- `wrangler.toml` has a real KV namespace id committed (`15dcacac…`). That's intentional — this is a personal worker, not a template.
|
|
60
|
+
|
|
61
|
+
## When asked to change behavior
|
|
62
|
+
|
|
63
|
+
- API surface is documented in `README.md` and is meant to mirror htmlship. If you change request/response shape, update the README table.
|
|
64
|
+
- Skill at `~/.claude/skills/publish-cloudflare/SKILL.md` wraps the CLI for Claude Code use. If you rename a CLI command or change its flags, that skill likely needs updating too.
|
package/worker/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# worker — Cloudflare Worker + CLI
|
|
2
|
+
|
|
3
|
+
The self-hosted side of [`lavish-publish-cf`](../README.md): a Cloudflare Worker (with KV) plus a Node CLI that talks to it. API-compatible with [htmlship.com](https://htmlship.com) — same shape, no third-party dependency, runs on your own Cloudflare account.
|
|
4
|
+
|
|
5
|
+
- **Worker**: `src/index.ts` — single-file TypeScript Worker, KV-backed.
|
|
6
|
+
- **CLI**: `cli/index.js` — `publish-cf` command, mirrors htmlship's commands.
|
|
7
|
+
|
|
8
|
+
For the high-level overview (skill integration, install flow), see the [top-level README](../README.md).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Deploy (one-time setup)
|
|
13
|
+
|
|
14
|
+
You need a Cloudflare account with Workers enabled (free tier is plenty).
|
|
15
|
+
|
|
16
|
+
Run these from the **repo root** (where `package.json` lives).
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 1. Install deps
|
|
20
|
+
npm install
|
|
21
|
+
|
|
22
|
+
# 2. Authenticate Wrangler with your Cloudflare account
|
|
23
|
+
npx wrangler login
|
|
24
|
+
|
|
25
|
+
# 3. Create the KV namespace and copy the returned id
|
|
26
|
+
npx wrangler kv namespace create PAGES
|
|
27
|
+
# → outputs something like:
|
|
28
|
+
# { binding = "PAGES", id = "abc123…" }
|
|
29
|
+
|
|
30
|
+
# 4. Paste the id into worker/wrangler.toml (replace REPLACE_WITH_KV_NAMESPACE_ID)
|
|
31
|
+
|
|
32
|
+
# 5. Deploy
|
|
33
|
+
npm run deploy
|
|
34
|
+
# → outputs the live URL, e.g. https://publish-cloudflare.<account>.workers.dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
After deploy, point the CLI at it:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
publish-cf config set --api-base https://publish-cloudflare.<account>.workers.dev
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
(Or set `PUBLISH_CF_API` in your shell profile.)
|
|
44
|
+
|
|
45
|
+
### Optional: custom view domain
|
|
46
|
+
|
|
47
|
+
If you want pages served from `view.example.com` instead of the worker subdomain:
|
|
48
|
+
|
|
49
|
+
1. Add a Worker route or custom domain in the Cloudflare dashboard.
|
|
50
|
+
2. In `wrangler.toml`, set:
|
|
51
|
+
```toml
|
|
52
|
+
[vars]
|
|
53
|
+
VIEW_BASE_URL = "https://view.example.com"
|
|
54
|
+
```
|
|
55
|
+
3. Redeploy.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Install the CLI
|
|
60
|
+
|
|
61
|
+
The fastest path is npm:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install -g @rubar/lavish-publish-cf
|
|
65
|
+
publish-cf --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or from a checkout: `npm install -g .` from the repo root. Or invoke directly without install:
|
|
69
|
+
`node <repo>/worker/cli/index.js …`, or `npx @rubar/lavish-publish-cf …`.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## CLI usage
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
publish-cf publish report.html
|
|
77
|
+
publish-cf publish report.html --password "demo-pass"
|
|
78
|
+
publish-cf publish report.html --title "Demo" --expires-in 60
|
|
79
|
+
publish-cf publish report.html --no-comments # disable the comment sidebar
|
|
80
|
+
cat report.html | publish-cf publish -
|
|
81
|
+
|
|
82
|
+
publish-cf get <slug>
|
|
83
|
+
publish-cf update <slug> report.html
|
|
84
|
+
publish-cf delete <slug>
|
|
85
|
+
publish-cf list-mine
|
|
86
|
+
|
|
87
|
+
publish-cf comments <slug> # list open comments
|
|
88
|
+
publish-cf comments <slug> --format json --status all # machine-readable
|
|
89
|
+
publish-cf comments resolve <slug> <id> --note "..." # owner-only
|
|
90
|
+
publish-cf comments delete <slug> <id> --yes # owner-only
|
|
91
|
+
publish-cf comments toggle <slug> --on | --off # flip the page-level switch
|
|
92
|
+
|
|
93
|
+
publish-cf config show
|
|
94
|
+
publish-cf config set --api-base https://...
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
State lives in `~/.publish-cloudflare/`:
|
|
98
|
+
|
|
99
|
+
- `config.json` — `{ "api_base": "https://…" }`
|
|
100
|
+
- `keys.json` — per slug: `owner_key`, `source_path`, `comments_enabled`, `title`, expiry, URL. `source_path` is read by `/address-comments` so it can find the local file without asking.
|
|
101
|
+
|
|
102
|
+
`PUBLISH_CF_API` env var overrides the config file.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## API reference
|
|
107
|
+
|
|
108
|
+
Base path: `/api/v1`
|
|
109
|
+
|
|
110
|
+
| Method | Path | Auth | Purpose |
|
|
111
|
+
| -------- | ------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
112
|
+
| `POST` | `/api/v1/pages` | none | Create page. Body: `{html, title?, password?, expires_in?, comments_enabled?}` (minutes for `expires_in`). Returns `{slug, url, owner_key, expires_at, size_bytes, comments_enabled}`. |
|
|
113
|
+
| `GET` | `/api/v1/pages/:slug` | none | Metadata only (no html). |
|
|
114
|
+
| `PATCH` | `/api/v1/pages/:slug` | `X-Owner-Key` | Update `html`, `title`, and/or `comments_enabled`. |
|
|
115
|
+
| `DELETE` | `/api/v1/pages/:slug` | `X-Owner-Key` | Delete page. |
|
|
116
|
+
| `GET` | `/v/:slug` | cookie if password-protected | Render the wrapper page (comment sidebar + iframe of the artifact). If `comments_enabled === false`, serves the artifact directly under strict CSP (same content as `/v/:slug/raw`). |
|
|
117
|
+
| `POST` | `/v/:slug` | n/a | Submit password (form-urlencoded `password=…`), sets `hp_<slug>` cookie. |
|
|
118
|
+
| `GET` | `/v/:slug/comments?status=open\|all` | viewer (cookie if password-protected) | List comments on the page. Returns `403 comments_disabled` if the page has comments off. |
|
|
119
|
+
| `POST` | `/v/:slug/comments` | viewer (cookie if password-protected) | Add a comment. Body: `{body, author, anchor?}`. Returns `403 comments_disabled` if comments are off. |
|
|
120
|
+
| `PATCH` | `/v/:slug/comments/:id` | `X-Owner-Key` | Resolve / unresolve a comment. Body: `{status, resolution_note?}`. |
|
|
121
|
+
| `DELETE` | `/v/:slug/comments/:id` | `X-Owner-Key` | Delete a comment. |
|
|
122
|
+
| `GET` | `/healthz` | none | Liveness probe. |
|
|
123
|
+
| `GET` | `/` | none | Landing page. |
|
|
124
|
+
|
|
125
|
+
### Limits
|
|
126
|
+
|
|
127
|
+
- `html` ≤ 5 MB (KV cap is 25 MB; we leave headroom).
|
|
128
|
+
- `expires_in` ≤ 30 days (43,200 minutes).
|
|
129
|
+
- Slug = 8 lowercase alphanumeric chars. 5 retries on collision.
|
|
130
|
+
- `owner_key` = `ws_` + 32 random alphanumerics. Stored as SHA-256 hash on the server.
|
|
131
|
+
- Passwords stored as SHA-256 hash. Cookie `hp_<slug>=ok` valid for 1 hour.
|
|
132
|
+
|
|
133
|
+
### Content security
|
|
134
|
+
|
|
135
|
+
The artifact itself is served at `/v/:slug/raw` with a strict CSP:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Content-Security-Policy: default-src 'self' data: blob: https:; script-src 'none'; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: blob: https:; font-src 'self' data: https:; frame-ancestors 'none'; base-uri 'none';
|
|
139
|
+
X-Content-Type-Options: nosniff
|
|
140
|
+
Referrer-Policy: no-referrer
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Inline `<script>` tags in the artifact are blocked. External CDN scripts are blocked. Inline CSS, images, fonts, and HTTPS subresources are allowed.
|
|
144
|
+
|
|
145
|
+
The wrapper at `/v/:slug` ships its own JS for the comment UI, so it carries a slightly looser policy: `script-src 'self' 'unsafe-inline'` plus `frame-src 'self'` so it can host the iframe. The wrapper never executes anything from the artifact — the iframe (`/v/:slug/raw`) keeps the strict CSP above.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Inline comments
|
|
150
|
+
|
|
151
|
+
`/v/:slug` serves a thin **wrapper page** (comment sidebar + same-origin iframe). The iframe loads the artifact at `/v/:slug/raw` under the existing strict CSP, so the artifact itself stays untouched. Viewers select text inside the iframe, type a comment, and pick a display name — the name is stored in their browser `localStorage` (`pcf_name`); there are no accounts.
|
|
152
|
+
|
|
153
|
+
Anchors use a **text-quote selector** (selected phrase plus ~32 chars of prefix/suffix), so comments survive a republish as long as the anchored phrase (or a near-match) still appears in the new HTML. Comments whose anchor no longer matches are flagged `orphaned` in the sidebar and in the JSON.
|
|
154
|
+
|
|
155
|
+
Comment reads and writes are gated by the same viewer cookie as the page itself — password-protected pages get password-protected comments. Resolve and delete require `X-Owner-Key` and are intended to be driven from the CLI.
|
|
156
|
+
|
|
157
|
+
The headline workflow: a reviewer drops comments, the owner asks Claude to address them, and Claude runs:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
publish-cf comments <slug> --format json --status open
|
|
161
|
+
# Claude edits the local HTML to address each anchored comment
|
|
162
|
+
publish-cf update <slug> <local-file>
|
|
163
|
+
# For each addressed comment:
|
|
164
|
+
publish-cf comments resolve <slug> <id> --note "<short summary>"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Same URL throughout — the reviewer's tab just shows resolved comments on refresh.
|
|
168
|
+
|
|
169
|
+
### Storage
|
|
170
|
+
|
|
171
|
+
KV keys in the `PAGES` namespace:
|
|
172
|
+
|
|
173
|
+
- `page:<slug>` — the artifact record (html, owner_key hash, password hash, expiry, etc.)
|
|
174
|
+
- `comment:<slug>:<id>` — one comment per key (avoids lost writes from concurrent commenters; listed via `PAGES.list({ prefix: \`comment:${slug}:\` })`)
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## vs htmlship.com
|
|
179
|
+
|
|
180
|
+
| | htmlship.com | publish-cloudflare |
|
|
181
|
+
| --------------- | ----------------------- | ------------------------------------ |
|
|
182
|
+
| Hosted by | htmlship | You (Cloudflare) |
|
|
183
|
+
| Cost | Free tier with limits | Free tier (CF Workers: 100k req/day) |
|
|
184
|
+
| CLI | `npx htmlship` | `publish-cf` |
|
|
185
|
+
| Owner key store | `~/.htmlship/keys.json` | `~/.publish-cloudflare/keys.json` |
|
|
186
|
+
| API surface | identical shape | identical shape |
|
|
187
|
+
| CSP | strict, no scripts | strict, no scripts (same policy) |
|
|
188
|
+
| Custom domain | depends on plan | yes, via Cloudflare |
|
|
189
|
+
| Passwords | yes | yes |
|
|
190
|
+
| Expiry | yes | yes (≤ 30 days) |
|
|
191
|
+
| Vendor lock-in | yes | none — your worker, your KV |
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Local development
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
npx wrangler dev --local
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Uses an in-memory KV emulator on `http://localhost:8787`. No Cloudflare auth required for `--local`. Point the CLI at it:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
PUBLISH_CF_API=http://localhost:8787 node worker/cli/index.js publish foo.html
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Project layout
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
<repo-root>
|
|
213
|
+
├── package.json # npm publishable (@rubar/lavish-publish-cf)
|
|
214
|
+
└── worker/
|
|
215
|
+
├── README.md
|
|
216
|
+
├── tsconfig.json
|
|
217
|
+
├── wrangler.toml
|
|
218
|
+
├── src/
|
|
219
|
+
│ └── index.ts # Worker (single file)
|
|
220
|
+
└── cli/
|
|
221
|
+
└── index.js # CLI client (zero-dep Node)
|
|
222
|
+
```
|