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
76 changes: 0 additions & 76 deletions .github/workflows/deploy-docs.yml

This file was deleted.

81 changes: 29 additions & 52 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,28 @@ directory and use these settings:
| Root directory | `website` |
| Build command | `pnpm run build` |
| Build output directory | `out` |
| Node version | `20.19.0` or higher (set `NODE_VERSION` if needed) |
| Node version | `22` |

Set one environment variable so social/Open Graph image URLs resolve to your real
domain:

| Variable | Example |
|----------|---------|
| `NEXT_PUBLIC_SITE_URL` | `https://your-docs-domain.com` |
| `NEXT_PUBLIC_SITE_URL` | `https://openspec.dev` |

That's it. No Workers, adapters, or server runtime are required. (If you later
want server-side rendering on Cloudflare Workers instead, swap `output: 'export'`
in `next.config.mjs` for the `@opennextjs/cloudflare` adapter — but the static
path above is the simplest and is what this site is tuned for.)
The site itself needs no server runtime. A small routing Worker exposes the
separate Pages project at `openspec.dev/docs` while the Astro landing project
continues to own the rest of `openspec.dev`. It also routes the supporting
`/_next`, search, Open Graph, icon, and `llms` paths. Its source and Wrangler
configuration live in `cloudflare/router/`.

Cloudflare's Free plan cannot override the Host header or DNS origin in an
Origin Rule, so the routing Worker proxies these paths to
`openspec-docs.pages.dev` instead. Deploy routing changes from `website/` with:

```bash
npx wrangler deploy --config cloudflare/router/wrangler.jsonc
```

### Deploy with Wrangler (optional)

Expand Down Expand Up @@ -78,61 +87,28 @@ then:
- regenerates `content/docs/meta.json` and `content/docs/reference/meta.json`.

Because the docs are the source, the site cannot drift from them: every build
re-mirrors, and CI redeploys on a schedule (see below).
re-mirrors them before producing the static export.

## Automated deploys

`.github/workflows/deploy-docs.yml` rebuilds the mirror and deploys the static
export to Cloudflare Pages via Wrangler:

- on every push to `main` that touches `docs/**` or `website/**`,
- daily on a schedule (so docs merged elsewhere still go live),
- manually via the Actions tab,
- and as a build-only check on pull requests (never deploys).
The `openspec-docs` Cloudflare Pages project is connected directly to
`Fission-AI/OpenSpec`. Cloudflare rebuilds and deploys `main` when `docs/**` or
`website/**` changes, and creates preview deployments for pull requests.

Once the site changes, that's it — a `docs/*.md` edit merged to `main` re-mirrors
and redeploys with no manual step.

### One-time deploy setup (maintainer)

The workflow is ready, but auto-deploy stays dormant until these three are done.
Until then, docs still mirror correctly on build — they just don't reach
Cloudflare on their own.

1. **Create the Cloudflare Pages project** named `openspec-docs`, with its
production branch set to `main`. Once, via the dashboard or:

```bash
npx wrangler pages project create openspec-docs --production-branch main
```

(Non-interactive CI can't create it on the fly, so this must exist first.)

2. **Add two repository secrets** (Settings → Secrets and variables → Actions):

| Secret | Where to get it |
|--------|-----------------|
| `CLOUDFLARE_API_TOKEN` | Cloudflare dashboard → My Profile → API Tokens → "Edit Cloudflare Pages" template |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare dashboard → Workers & Pages → Account ID |

Optional: set a repository **variable** `DOCS_SITE_URL` to the site's public URL
(used for Open Graph / sitemap absolute links). Without it, the build falls
back to `https://openspec.dev`, so this is not required.

3. **Merge this to `main`.** GitHub Actions only runs the `push`-to-`main` and
scheduled triggers from workflows on the default branch, so the automation
activates when the PR merges.

To smoke-test before merging: run the workflow by hand from the **Actions** tab
(**workflow_dispatch**) once the project and secrets exist.
No GitHub Actions workflow, deployment secrets, or repository variables are
required for the Git-connected Pages project. Cloudflare reports production and
preview build statuses directly to GitHub.

### Landing page — a maintainer decision
### Landing page

The current [openspec.dev](https://openspec.dev) landing page is a separate Astro
site. This site ships its own Fumadocs landing page at `app/(home)/page.tsx`
(the only hand-authored page here; everything under `/docs` is mirrored). Whether
to keep this landing page, port the Astro one into it, or point Pages only at
`/docs` is a maintainer call — nothing else in this pipeline depends on it.
The current [openspec.dev](https://openspec.dev) landing page remains in the
separate Astro project. The routing Worker sends only documentation-owned paths
to this Pages project, so its Fumadocs landing page at `app/(home)/page.tsx` is
built but is not served at the public root. The projects can be consolidated
later without changing the mirrored documentation workflow.

## Project structure

Expand All @@ -152,6 +128,7 @@ website/
│ ├── source.ts # Fumadocs content source + sidebar icons
│ └── layout.shared.tsx # shared nav/header options
├── components/ # MDX components, search dialog, root provider
├── cloudflare/router/ # Worker that mounts this site on openspec.dev/docs
├── next.config.mjs # static export config
└── source.config.ts # Fumadocs MDX collection config
```
Expand Down
87 changes: 87 additions & 0 deletions website/cloudflare/router/worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
addEventListener('fetch', (event) => {
event.respondWith(proxyDocs(event.request));
});

const ALLOWED_METHODS = new Set(['GET', 'HEAD']);
const FORWARDED_REQUEST_HEADERS = [
'accept',
'accept-encoding',
'accept-language',
'cache-control',
'if-match',
'if-modified-since',
'if-none-match',
'if-unmodified-since',
'range',
'user-agent',
];

async function proxyDocs(request) {
const incoming = new URL(request.url);

if (!isDocsRoute(incoming.pathname)) {
return fetch(request);
}

if (!ALLOWED_METHODS.has(request.method)) {
return new Response(null, {
status: 405,
headers: { allow: 'GET, HEAD' },
});
}

const upstream = new URL(
incoming.pathname + incoming.search,
'https://openspec-docs.pages.dev',
);

const headers = new Headers();
for (const name of FORWARDED_REQUEST_HEADERS) {
const value = request.headers.get(name);
if (value !== null) {
headers.set(name, value);
}
}

const init = {
method: request.method,
headers,
redirect: 'manual',
};

const response = await fetch(upstream.toString(), init);
const responseHeaders = new Headers(response.headers);
const location = responseHeaders.get('location');

if (location) {
const redirected = new URL(location, upstream);
if (redirected.hostname === 'openspec-docs.pages.dev') {
redirected.protocol = incoming.protocol;
redirected.host = incoming.host;
responseHeaders.set('location', redirected.toString());
}
}

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}

function isDocsRoute(pathname) {
return (
pathname === '/docs' ||
pathname.startsWith('/docs/') ||
pathname.startsWith('/_next/') ||
pathname === '/api/search' ||
pathname === '/api/search/' ||
pathname === '/og/docs' ||
pathname.startsWith('/og/docs/') ||
Comment on lines +79 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether /og/docs (without trailing slash) is expected to be served by the Worker
rg -n 'og/docs' website/cloudflare/router/

Repository: Fission-AI/OpenSpec

Length of output: 424


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== wrangler.jsonc =="
cat -n website/cloudflare/router/wrangler.jsonc

echo
echo "== worker.js around isDocsRoute =="
sed -n '60,100p' website/cloudflare/router/worker.js

echo
echo "== any other og/docs references =="
rg -n --context 2 'og/docs' website/cloudflare/router/

Repository: Fission-AI/OpenSpec

Length of output: 2890


🌐 Web query:

Cloudflare Workers route pattern "/path/*" match exact "/path" without trailing slash

💡 Result:

In Cloudflare Workers, a route pattern with a trailing asterisk, such as /path/*, does not match an exact path without a trailing slash (e.g., /path) [1]. The wildcard operator (*) in Cloudflare route patterns matches zero or more characters [1]. For the pattern /path/* to trigger, there must be content after the /path/ prefix. Consequently: - /path/* matches /path/ and any path starting with /path/ (e.g., /path/something). - /path/* does not match the exact path /path because the wildcard expects a subsequent character (or a trailing slash) to satisfy the pattern [1]. To match both the exact path /path and any sub-paths under it, you must define two separate route patterns: 1. /path 2. /path/* Cloudflare routing is deterministic: if multiple patterns match, the system selects the most specific one [1][2]. By explicitly defining both, you ensure all variations of that path are covered [1].

Citations:


Add openspec.dev/og/docs for the bare path. openspec.dev/og/docs/* doesn’t cover /og/docs, so pathname === '/og/docs' in website/cloudflare/router/worker.js is unreachable unless website/cloudflare/router/wrangler.jsonc adds the exact route.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/cloudflare/router/worker.js` around lines 79 - 80, The router
condition for the exact “/og/docs” path requires a matching exact deployment
route. Update website/cloudflare/router/wrangler.jsonc to add
“openspec.dev/og/docs” alongside the existing wildcard route, preserving the
“/og/docs/*” coverage.

pathname === '/llms.txt' ||
pathname === '/llms-full.txt' ||
pathname === '/llms.mdx/docs' ||
pathname.startsWith('/llms.mdx/docs/') ||
pathname === '/icon.svg'
);
}
16 changes: 16 additions & 0 deletions website/cloudflare/router/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "openspec-docs-router",
"main": "worker.js",
"compatibility_date": "2026-07-10",
"routes": [
{ "pattern": "openspec.dev/docs*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/docs/*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/_next/*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/api/search*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/og/docs/*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/llms*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/llms.mdx/docs/*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/icon.svg*", "zone_name": "openspec.dev" }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]
}
Loading