Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/website-pr-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream-website": patch
---

Add PR preview deployments: site changes are previewable at ema.tnsor.network/preview/<PR>/ and cleaned up on close. Requires the website-preview environment (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) to activate.
7 changes: 7 additions & 0 deletions .changeset/website-user-copy.md
Original file line number Diff line number Diff line change
@@ -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/<n>/` and deploys its own worker (`nostream-web-pr-<n>`); the production router worker (new) proxies `ema.tnsor.network/preview/<n>/*` 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.
175 changes: 175 additions & 0 deletions .github/workflows/deploy-website-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: Deploy Website Preview

# Per-PR preview of the marketing site at https://ema.tnsor.network/preview/<n>/
# (the production router worker proxies /preview/<n> to this PR's worker,
# nostream-web-pr-<n>, 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: <!-- site-preview-comment -->
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: <!-- site-preview-comment -->
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: <!-- site-preview-comment -->
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
4 changes: 3 additions & 1 deletion .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n> to per-PR preview workers (deploy-website-preview.yml).
run: pnpm exec wrangler deploy --config wrangler.router.jsonc
2 changes: 2 additions & 0 deletions website/site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ function rehypeRepoLinks() {

export default defineConfig({
site: process.env.SITE_URL ?? 'https://nostream.dev',
// PR previews build under /preview/<n>; empty when unset (production).
base: process.env.SITE_BASE || undefined,
markdown: {
rehypePlugins: [rehypeRepoLinks],
shikiConfig: { theme: 'github-dark-default', wrap: false },
Expand Down
74 changes: 74 additions & 0 deletions website/site/scripts/apply-base.mjs
Original file line number Diff line number Diff line change
@@ -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/<n>/.
// 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}`)
Loading
Loading