diff --git a/.changeset/website-pr-previews.md b/.changeset/website-pr-previews.md new file mode 100644 index 000000000..10a36a8a1 --- /dev/null +++ b/.changeset/website-pr-previews.md @@ -0,0 +1,5 @@ +--- +"nostream-website": patch +--- + +Add PR preview deployments: site changes are previewable at ema.tnsor.network/preview// and cleaned up on close. Requires the website-preview environment (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) to activate. diff --git a/.changeset/website-user-copy.md b/.changeset/website-user-copy.md new file mode 100644 index 000000000..ba18e9fe7 --- /dev/null +++ b/.changeset/website-user-copy.md @@ -0,0 +1,7 @@ +--- +"nostream-website": patch +--- + +Website copy now leads with what each feature does for the user; the technical detail (NIP numbers, protocol names, command flags) moved into (i) info tooltips on hover, focus or tap. + +Also ships PR previews: every PR touching the site builds under `/preview//` and deploys its own worker (`nostream-web-pr-`); the production router worker (new) proxies `ema.tnsor.network/preview//*` to it, so the preview URL stays on the production domain. Previews are deleted when the PR closes. The production deploy switches from the assets-only worker to the router on the next release — same assets, same domain, plus the preview route. diff --git a/.github/workflows/deploy-website-preview.yml b/.github/workflows/deploy-website-preview.yml new file mode 100644 index 000000000..a6d2c1237 --- /dev/null +++ b/.github/workflows/deploy-website-preview.yml @@ -0,0 +1,175 @@ +name: Deploy Website Preview + +# Per-PR preview of the marketing site at https://ema.tnsor.network/preview// +# (the production router worker proxies /preview/ to this PR's worker, +# nostream-web-pr-, served from its workers.dev URL). The preview is deleted +# when the PR closes (merged or not). +# +# Requires the `website-preview` GitHub environment to exist with +# CLOUDFLARE_API_TOKEN (Workers Scripts edit) and CLOUDFLARE_ACCOUNT_ID — +# one-time setup. While the environment or its secrets are absent, this +# workflow skips with a notice instead of failing the PR. + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - closed + paths: + - "website/site/**" + - "website/hero-animation/**" + - ".github/workflows/deploy-website-preview.yml" + +concurrency: + group: website-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + # Deploy credentials live in this GitHub environment: CLOUDFLARE_API_TOKEN + # secret (Workers Scripts scope) and CLOUDFLARE_ACCOUNT_ID variable. + environment: website-preview + # Guard the environment reference so a missing setup skips rather than fails: + # the job targets the environment only when it exists. + steps: + - name: Check preview environment is configured + id: guard + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + # Existence is the only check the runner's token can make: listing + # environment secrets needs permissions github.token does not carry, + # and probing them here once made every run skip with "lacks + # CLOUDFLARE_API_TOKEN" even after the secrets were set. + if gh api repos/${{ github.repository }}/environments/website-preview >/dev/null 2>&1; then + echo "skip=false" >> "$GITHUB_OUTPUT" + else + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::website-preview environment does not exist yet — PR preview skipped. Create it with CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID to enable previews." + fi + + - name: Checkout (for cleanup metadata) + if: steps.guard.outputs.skip == 'false' + uses: actions/checkout@v3 + + - uses: pnpm/action-setup@v4 + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + + - uses: actions/setup-node@v3 + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install workspace dependencies + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + run: pnpm install --frozen-lockfile + + - name: Build the site under the preview base + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + working-directory: website/site + env: + SITE_URL: https://ema.tnsor.network + SITE_BASE: /preview/${{ github.event.pull_request.number }}/ + run: | + pnpm run build + node scripts/apply-base.mjs + + - name: Mark the preview comment as rendering + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + env: + GH_TOKEN: ${{ github.token }} + URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ + WORKER_URL: https://nostream-web-pr-${{ github.event.pull_request.number }}.cameri.workers.dev/preview/${{ github.event.pull_request.number }}/ + MARKER: + run: | + # Flip the single preview comment to a "rendering" state before the + # deploy starts, so the link always exists but signals staleness. + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments?per_page=100" \ + --jq '.[] | select(.body | startswith(env.MARKER)) | .id' | tail -1) + BODY="${MARKER} + ⏳ Preview is rendering for the latest push — ${WORKER_URL} will serve the new build in about a minute." + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null + echo "marked comment $COMMENT_ID as rendering" + else + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "$BODY" >/dev/null + echo "created new preview comment (rendering)" + fi + + - name: Deploy the preview worker + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + working-directory: website/site + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx wrangler deploy \ + --config wrangler.preview.jsonc \ + --name nostream-web-pr-${{ github.event.pull_request.number }} + + - name: Link the preview on the PR + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + env: + GH_TOKEN: ${{ github.token }} + URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ + WORKER_URL: https://nostream-web-pr-${{ github.event.pull_request.number }}.cameri.workers.dev/preview/${{ github.event.pull_request.number }}/ + MARKER: + run: | + # One comment per PR, updated in place on every push: find the + # existing one by marker, edit it, or create it if absent. The + # direct workers.dev URL works as soon as this deploy lands; the + # ema.tnsor.network form starts working once the router ships + # (it deploys with this PR's merge). + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments?per_page=100" \ + --jq '.[] | select(.body | startswith(env.MARKER)) | .id' | tail -1) + BODY="${MARKER} + 🔍 Site preview for this PR (includes all changes on this branch): ${WORKER_URL} + + The ${URL} form serves the same build once this PR merges — the production router that proxies /preview/* ships with it. + + Updates with every push to this PR; deleted when the PR closes." + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null + echo "updated comment $COMMENT_ID" + else + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "$BODY" >/dev/null + echo "created new preview comment" + fi + + - name: Delete the preview worker + if: steps.guard.outputs.skip == 'false' && github.event.action == 'closed' + working-directory: website/site + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + # A missing worker exits non-zero; treat already-gone as success. + npx wrangler delete --config wrangler.preview.jsonc \ + --name nostream-web-pr-${{ github.event.pull_request.number }} \ + || echo "preview worker already absent" + + - name: Note the cleanup on the PR + if: steps.guard.outputs.skip == 'false' && github.event.action == 'closed' + env: + GH_TOKEN: ${{ github.token }} + MARKER: + run: | + # Update the SAME single preview comment instead of adding one. + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments?per_page=100" \ + --jq '.[] | select(.body | startswith(env.MARKER)) | .id' | tail -1) + BODY="${MARKER} + 🧹 Site preview for this PR was deleted (PR closed)." + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null + else + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "$BODY" >/dev/null || true + fi diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 0effa158f..ec0363792 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -55,4 +55,6 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - run: pnpm exec wrangler deploy + # The router worker: serves the production assets itself and proxies + # /preview/ to per-PR preview workers (deploy-website-preview.yml). + run: pnpm exec wrangler deploy --config wrangler.router.jsonc diff --git a/website/site/astro.config.mjs b/website/site/astro.config.mjs index fcf27132b..36d2b48e4 100644 --- a/website/site/astro.config.mjs +++ b/website/site/astro.config.mjs @@ -50,6 +50,8 @@ function rehypeRepoLinks() { export default defineConfig({ site: process.env.SITE_URL ?? 'https://nostream.dev', + // PR previews build under /preview/; empty when unset (production). + base: process.env.SITE_BASE || undefined, markdown: { rehypePlugins: [rehypeRepoLinks], shikiConfig: { theme: 'github-dark-default', wrap: false }, diff --git a/website/site/scripts/apply-base.mjs b/website/site/scripts/apply-base.mjs new file mode 100644 index 000000000..442b547cf --- /dev/null +++ b/website/site/scripts/apply-base.mjs @@ -0,0 +1,74 @@ +// Prefix root-absolute asset/link paths in built HTML with the site base. +// Astro's `base` rewrites astro-managed URLs; hand-written hrefs (nav links, +// icons, media) stay root-absolute, so this pass rewrites them for PR previews +// served under /preview//. +// Usage: SITE_BASE=/preview/42 node scripts/apply-base.mjs +import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs' +import { join } from 'node:path' + +const base = process.env.SITE_BASE +if (!base || base === '/') { + console.log('apply-base: SITE_BASE not set, nothing to do') + process.exit(0) +} +const norm = base.endsWith('/') ? base : `${base}/` + +const DIST = new URL('../dist/', import.meta.url).pathname + +const walk = (dir) => + readdirSync(dir).flatMap((name) => { + const p = join(dir, name) + if (statSync(p).isDirectory()) return walk(p) + return p + }) + +// HTML attributes that carry site-internal root paths. External (http(s):), +// protocol-relative (//), in-page (#) and already-based paths are skipped. +const ATTRS = ['href', 'src', 'poster', 'content'] +let changed = 0 + +for (const file of walk(DIST)) { + if (!/\.(html|css)$/.test(file)) continue + let html = readFileSync(file, 'utf8') + let out = html + if (file.endsWith('.html')) { + // Prefix each root-absolute path with the base; skip paths already based, + // protocol-relative URLs and pure-fragment links. + const prefix = norm.replace(/\/$/, '') // '/preview/99' + out = html + for (const attr of ATTRS) { + const re = new RegExp(`(${attr}=)"(/[^"]*)"`, 'gi') + out = out.replace(re, (m, a, path) => { + if (path.startsWith(prefix + '/') || path === prefix) return m + return `${a}"${prefix}${path}"` + }) + } + // srcset entries ("path 640w, path 1280w") and CSS url("/..."). + out = out.replace(/(srcset=")([^"]*)(")/gi, (m, a, list, z) => { + const parts = list.split(',').map((entry) => { + const [p, ...rest] = entry.trim().split(/\s+/) + if (!p || !p.startsWith('/') || p.startsWith(prefix + '/')) return entry.trim() + return [prefix + p, ...rest].join(' ') + }) + return `${a}${parts.join(', ')}${z}` + }) + out = out.replace(/url\("(\/[^"]*)"\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url("${prefix}${p}")`) + out = out.replace(/url\('(\/[^']*)'\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url('${prefix}${p}')`) + } else { + // CSS files: url(/...) only. + const prefix = norm.replace(/\/$/, '') + out = html.replace(/url\("(\/[^"]*)"\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url("${prefix}${p}")`) + out = out.replace(/url\('(\/[^']*)'\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url('${prefix}${p}')`) + out = out.replace(/url\((\/[^)]*)\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url(${prefix}${p})`) + } + if (out !== html) { + writeFileSync(file, out) + changed++ + } +} +console.log(`apply-base: rewrote ${changed} files under ${norm}`) diff --git a/website/site/src/components/InfoTip.astro b/website/site/src/components/InfoTip.astro new file mode 100644 index 000000000..a088e93f9 --- /dev/null +++ b/website/site/src/components/InfoTip.astro @@ -0,0 +1,132 @@ +--- +// Inline "(i)" affordance: the visible copy stays user-facing; the technical +// detail lives in a hover/focus/tap tooltip. Progressively enhanced — works +// without JS for hover and keyboard; the tap-open state uses the native +//
behavior so touch gets a reliable open/close with no scripts. +interface Props { + /** The technical detail shown inside the tooltip. */ + detail: string + /** Accessible name for the toggle, e.g. "rate limiting". */ + label?: string +} +const { detail, label = 'more detail' } = Astro.props +const id = `tip-${Astro.props['detail'] ? '' : ''}${Math.random().toString(36).slice(2, 10)}` +--- + +
+ + {detail} +
+ + + + diff --git a/website/site/src/components/PlanetBackdrop.astro b/website/site/src/components/PlanetBackdrop.astro index b9b4a31e5..a1674c7a2 100644 --- a/website/site/src/components/PlanetBackdrop.astro +++ b/website/site/src/components/PlanetBackdrop.astro @@ -11,7 +11,7 @@ const spawnChance = density === 'high' ? 0.94 : 0.85 ---

{shot.title}

-

{shot.body}

+

+ {shot.body} + {shot.tip && } +

) diff --git a/website/site/src/components/home/Hero.astro b/website/site/src/components/home/Hero.astro index d9ba89654..302de30d9 100644 --- a/website/site/src/components/home/Hero.astro +++ b/website/site/src/components/home/Hero.astro @@ -14,9 +14,9 @@ import { VERSION } from '../../data/site' aria-hidden="true"> diff --git a/website/site/src/components/home/Pillars.astro b/website/site/src/components/home/Pillars.astro index 623b53e4f..9c67c475b 100644 --- a/website/site/src/components/home/Pillars.astro +++ b/website/site/src/components/home/Pillars.astro @@ -1,37 +1,44 @@ --- +import InfoTip from '../InfoTip.astro' import { SUPPORTED_MIPS, SUPPORTED_NIPS } from '../../data/site' const pillars = [ { code: 'NIP', - title: 'Protocol breadth', - body: `${SUPPORTED_NIPS.length} NIPs implemented relay-side, from basic flow to auth, counts, search and encrypted payloads, plus Marmot MIPs ${SUPPORTED_MIPS[0]}–${SUPPORTED_MIPS[SUPPORTED_MIPS.length - 1]} for E2EE group messaging.`, + title: 'Speaks the whole protocol', + body: 'Works with the Nostr apps your people already use — chat, feeds, search and encrypted groups — with nothing to configure. ', + tip: `${SUPPORTED_NIPS.length} NIPs implemented relay-side, from basic flow to auth, counts, search and encrypted payloads, plus Marmot MIPs ${SUPPORTED_MIPS[0]}–${SUPPORTED_MIPS[SUPPORTED_MIPS.length - 1]} for E2EE group messaging.`, wide: true, }, { code: 'NET', - title: 'Tor & I2P built in', - body: 'Publish an .onion or .i2p address with a flag. nostream start --tor --i2p, then nostream info --tor-hostname.', + title: 'Reachable from anywhere', + body: 'Publish an .onion or .i2p address with a flag, so people on censored or privacy-first networks can still reach you.', + tip: 'nostream start --tor --i2p, then nostream info --tor-hostname.', }, { code: 'OPS', - title: 'Admin console', - body: 'Live metrics, NIP-66 network health, NIP-11 mismatch warnings and a validated settings editor.', + title: 'Stay on top of your relay', + body: 'See how your relay is doing at a glance, spot trouble before your users do, and change settings without fear of breaking things.', + tip: 'Live metrics, NIP-66 network health, NIP-11 mismatch warnings and a validated settings editor.', }, { code: 'LN', - title: 'Paid admission', - body: 'Charge to publish. Invoices via ZEBEDEE, Nodeless, OpenNode, LNbits or any LNURL-verify provider.', + title: 'Charge for admission', + body: 'Turn publish access into a service. Invoices settle over Lightning with the provider you already run.', + tip: 'Invoices via ZEBEDEE, Nodeless, OpenNode, LNbits or any LNURL-verify provider.', }, { code: 'LIM', - title: 'Limits that hold', - body: 'Rate limits, adaptive proof-of-work, kind and size rules. Clients get a rate-limited hint with a backoff.', + title: 'Keeps abusive traffic out', + body: 'Keeps spam and abusive traffic from slowing the relay for everyone else, so normal clients stay fast.', + tip: 'Rate limits, adaptive proof-of-work, kind and size rules. Clients get a rate-limited hint with a backoff.', }, { code: 'I/O', title: 'Your data, portable', - body: 'Import and export events as JSON or JSONL in batches. Back up, migrate, or seed a new relay.', + body: 'Take your events with you. Back up, migrate, or seed a new relay whenever you want.', + tip: 'Import and export events as JSON or JSONL in batches.', full: true, command: 'nostream export --output backup.jsonl --format jsonl', }, @@ -55,7 +62,10 @@ const pillars = [
{p.code}

{p.title}

-

{p.body}

+

+ {p.body} + {p.tip && } +

{p.command && ( @@ -103,7 +113,9 @@ const pillars = [ .cell { position: relative; - overflow: hidden; + /* No overflow: hidden — the InfoTip bubble opens upward and must be + able to escape the card; the .count watermark is anchored inside + (top/right offsets) so it cannot bleed anyway. */ min-height: 210px; padding: 26px; } @@ -147,6 +159,11 @@ const pillars = [ letter-spacing: -0.06em; -webkit-text-stroke: 1px var(--wire); pointer-events: none; + /* Clip the oversized watermark to the cell's inner box without clipping + the whole cell (InfoTip bubbles must be able to escape it). */ + max-width: calc(100% - 52px); + overflow: hidden; + white-space: nowrap; } .count span { diff --git a/website/site/worker/router.ts b/website/site/worker/router.ts new file mode 100644 index 000000000..963a4a6f9 --- /dev/null +++ b/website/site/worker/router.ts @@ -0,0 +1,45 @@ +// Production router for the website worker: +// - /preview//... → proxied to the per-PR preview worker +// `nostream-web-pr-` (its own workers.dev URL, assets-only), so a PR +// preview lives at https://ema.tnsor.network/preview// while the +// browser stays on the production domain. +// - anything else → this worker's own static assets (the production +// site build), exactly what the assets-only worker did before. +// Preview workers that do not exist (or are deleted after merge) yield 404. + +interface Env { + ASSETS: Fetcher +} + +const PREVIEW_RE = /^\/preview\/(\d+)(\/|$)/ +const PR_WORKER = (n: number | string) => `https://nostream-web-pr-${n}.cameri.workers.dev` + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + const m = PREVIEW_RE.exec(url.pathname) + if (m) { + const n = m[1] + const inner = new URL(request.url) + // The preview worker serves its build under the same /preview/ base, + // so pass the path through unchanged. + inner.hostname = new URL(PR_WORKER(n)).hostname + const proxied = new Request(inner.toString(), request) + const res = await fetch(proxied) + // A missing preview worker answers workers.dev's 404 page (not JSON); + // surface a friendlier one without caching it. + if (res.status === 404 || res.status === 530) { + return new Response( + `Preview #${n} is not available — it may have been cleaned up after merge.`, + { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } }, + ) + } + const headers = new Headers(res.headers) + // Previews must never be cached by the CDN or browsers. + headers.set('cache-control', 'no-store') + headers.set('x-preview-pr', n) + return new Response(res.body, { status: res.status, headers }) + } + return env.ASSETS.fetch(request) + }, +} diff --git a/website/site/wrangler.preview.jsonc b/website/site/wrangler.preview.jsonc new file mode 100644 index 000000000..e4d907d73 --- /dev/null +++ b/website/site/wrangler.preview.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nostream-web-pr-0", + "compatibility_date": "2026-10-01", + "workers_dev": true, + "assets": { + "directory": "./dist", + "html_handling": "auto-trailing-slash", + "not_found_handling": "single-page-application" + }, + "observability": { + "enabled": false + } +} diff --git a/website/site/wrangler.router.jsonc b/website/site/wrangler.router.jsonc new file mode 100644 index 000000000..077e8e3a9 --- /dev/null +++ b/website/site/wrangler.router.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nostream-website", + "main": "worker/router.ts", + "compatibility_date": "2026-10-01", + "routes": [ + { + "pattern": "ema.tnsor.network", + "custom_domain": true + } + ], + "assets": { + "directory": "./dist", + "html_handling": "auto-trailing-slash", + "not_found_handling": "single-page-application", + "binding": "ASSETS" + }, + "observability": { + "enabled": false + } +}