feat(site): serve the V2 docs from services/site with SolidStart (#62229)

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
Adam
2026-11-05 23:17:30 -06:00
committed by GitHub
Unverified
parent f30b35f1f6
commit 9ced0469bb
175 changed files with 1844 additions and 1339 deletions
-1
View File
@@ -10,5 +10,4 @@ packages/client/src/effect/api/** linguist-generated
packages/core/src/**/*.txt text eol=lf
packages/httpapi-codegen/test/generated/*.ts text eol=lf
# The site tests compare source text and humans.txt byte for byte, so Windows checkouts keep LF.
services/www/** text=auto eol=lf
services/site/** text=auto eol=lf
+42
View File
@@ -0,0 +1,42 @@
name: check-docs
# Builds the V2 docs on their own (services/site/src/docs/script/build.ts): compiles every MDX page,
# prerenders it, builds search, checks every link and that pages load nothing from outside /v2. The
# site's deploy runs the same step, so a broken page fails here instead of blocking the deploy.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- services/site/**
- packages/protocol/openapi.json
- packages/cli/src/config/**
- .github/workflows/check-docs.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
docs:
name: docs build
if: ${{ !github.event.pull_request.draft }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
defaults:
run:
working-directory: services/site
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
# The API reference and the config schema the pages link (/v2/openapi.json, /v2/cli.json).
- name: Generate
run: bun run generate
- name: Build docs
run: bun src/docs/script/build.ts --mode dev --out "$RUNNER_TEMP/docs"
-37
View File
@@ -1,37 +0,0 @@
name: deploy-www
on:
push:
branches:
- dev
- production
workflow_dispatch:
concurrency:
group: deploy-www-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: blacksmith-4vcpu-ubuntu-2404
environment: ${{ github.ref_name }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: services/www
run: bun run build
env:
CLOUDFLARE_ENV: ${{ github.ref_name == 'production' && 'production' || 'dev' }}
- name: Deploy
working-directory: services/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -105,7 +105,7 @@ Most of these rarely reproduce on an idle local machine.
Run from `packages/app`:
- [ ] Run in built mode, as CI does: `bun run test:e2e:built -- e2e/regression/<file>.spec.ts`. Dev mode serves unbundled modules, with the API on another origin (`playwright.config.ts:19-21`).
- [ ] Use a free port when another worktree or agent may hold 3000: `PLAYWRIGHT_PORT=4310 bun run test:e2e:built -- ...`. Built runs never reuse a server, and `serve.ts` fails on a busy port. Stay below 49152 on Windows: it reserves ports from there up and hands them out for outgoing connections (`services/www/src/docs/content/cli/web.mdx:43`, `packages/client/test/fixture/service-fixture.ts:108-109`).
- [ ] Use a free port when another worktree or agent may hold 3000: `PLAYWRIGHT_PORT=4310 bun run test:e2e:built -- ...`. Built runs never reuse a server, and `serve.ts` fails on a busy port. Stay below 49152 on Windows: it reserves ports from there up and hands them out for outgoing connections (`services/site/src/docs/content/cli/web.mdx:43`, `packages/client/test/fixture/service-fixture.ts:108-109`).
- [ ] To repeat without rebuilding:
- build once with `VITE_OPENCODE_TEST_FIXTURES=1 bun run build`;
- keep `bun e2e/utils/serve.ts --host 127.0.0.1 --port 4310` running;
+1 -1
View File
@@ -43,7 +43,7 @@ For web development, run the backend and app in separate terminals. Other interf
bun dev serve --port 4096
bun run dev:web
bun run dev:desktop
bun run dev:www
bun run dev:site
```
### Packages
+23 -34
View File
@@ -987,20 +987,39 @@
"zod": "catalog:",
},
"devDependencies": {
"@babel/core": "7.29.7",
"@cloudflare/build-output-utils": "0.8.5",
"@cloudflare/config": "0.23.0",
"@mdx-js/mdx": "3.1.1",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/estree-jsx": "1.0.5",
"@types/hast": "3.0.5",
"@types/mdast": "4.0.4",
"@types/react": "19.3.0",
"@types/three": "0.180.0",
"@typescript/native": "catalog:",
"@webgpu/types": "0.1.54",
"babel-preset-solid": "1.9.15",
"cf": "1.0.0-beta.12",
"estree-util-visit": "2.0.0",
"github-slugger": "2.0.0",
"hast-util-to-text": "4.0.2",
"mdast-util-mdx-jsx": "3.2.0",
"miniflare": "5.20260930.0-alpha",
"pagefind": "1.5.2",
"playwright-core": "1.59.1",
"remark-gfm": "4.0.1",
"remark-smartypants": "3.0.3",
"satori": "0.63.2",
"sharp": "0.34.5",
"shiki": "catalog:",
"typescript": "catalog:",
"unist-util-visit": "5.1.0",
"vfile": "6.0.3",
"vite-plugin-solid": "2.11.14",
"wawoff2": "2.0.1",
"yaml": "2.9.1",
},
},
"services/update": {
@@ -1020,22 +1039,6 @@
"wrangler": "4.110.0",
},
},
"services/www": {
"name": "@opencode/www",
"dependencies": {
"@astrojs/mdx": "7.0.5",
"astro": "catalog:",
},
"devDependencies": {
"@astrojs/check": "0.9.6",
"@astrojs/cloudflare": "14.1.4",
"@astrojs/markdown-remark": "7.3.2",
"@types/bun": "catalog:",
"pagefind": "1.5.2",
"typescript": "catalog:",
"wrangler": "4.110.0",
},
},
},
"trustedDependencies": [
"electron",
@@ -1197,8 +1200,6 @@
"@astrojs/cloudflare": ["@astrojs/cloudflare@14.1.4", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/underscore-redirects": "1.0.3", "@cloudflare/vite-plugin": "^1.39.0", "piccolore": "^0.1.3", "vite": "^8.0.13" }, "peerDependencies": { "astro": "^7.0.0", "wrangler": "^4.83.0" } }, "sha512-Zyo1E/5/dmegmKODbwUzOd67euNd6oKcbllhAwe3uFFprA7mIFNkpF6yBBa78vIkpMNuU13MkBJHCLd+yJSuEA=="],
"@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],
"@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.5.2", "", { "optionalDependencies": { "@astrojs/compiler-binding-android-arm64": "0.5.2", "@astrojs/compiler-binding-darwin-arm64": "0.5.2", "@astrojs/compiler-binding-darwin-x64": "0.5.2", "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.2", "@astrojs/compiler-binding-linux-arm64-musl": "0.5.2", "@astrojs/compiler-binding-linux-x64-gnu": "0.5.2", "@astrojs/compiler-binding-linux-x64-musl": "0.5.2", "@astrojs/compiler-binding-wasm32-wasi": "0.5.2", "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.2", "@astrojs/compiler-binding-win32-x64-msvc": "0.5.2" } }, "sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g=="],
"@astrojs/compiler-binding-android-arm64": ["@astrojs/compiler-binding-android-arm64@0.5.2", "", { "os": "android", "cpu": "arm64" }, "sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ=="],
@@ -2045,8 +2046,6 @@
"@opencode/web": ["@opencode/web@workspace:packages/web"],
"@opencode/www": ["@opencode/www@workspace:services/www"],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="],
@@ -4929,7 +4928,7 @@
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
"yaml-language-server": ["yaml-language-server@1.23.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-i18n": "^4.2.0", "prettier": "^3.8.1", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A=="],
@@ -4993,6 +4992,8 @@
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"@astrojs/yaml2ts/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -5133,7 +5134,7 @@
"@opencode/update/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode/www/@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="],
"@opencode/update/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
@@ -5557,8 +5558,6 @@
"@astrojs/compiler-binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@astrojs/markdown-remark/@astrojs/internal-helpers/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
"@astrojs/markdown-satteri/@astrojs/internal-helpers/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
"@astrojs/markdown-satteri/@astrojs/internal-helpers/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
@@ -5673,10 +5672,6 @@
"@opencode/stories/@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
"@opencode/www/@astrojs/check/@astrojs/language-server": ["@astrojs/language-server@2.16.14", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.4", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.16", "volar-service-css": "0.0.71", "volar-service-emmet": "0.0.71", "volar-service-html": "0.0.71", "volar-service-prettier": "0.0.71", "volar-service-typescript": "0.0.71", "volar-service-typescript-twoslash-queries": "0.0.71", "volar-service-yaml": "0.0.71", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "./bin/nodeServer.js" } }, "sha512-YPXkBu6N4d1sT09pvBmIDGZay+1MemV551FSgdEM3aZRDzbkxd2H7Cvf8MsVJLVB1mIEyTf1XbMN/30gp7s46w=="],
"@opencode/www/@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentui/solid/babel-preset-solid/babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.11", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-k7TkOv7uVYnrq/bYyVvG5cWOC9IObAGfmyW3aMkG8l2sq2qt/5THkUBja69PMLEGGi8G+fXB2Fh7eS4OwZRBoQ=="],
@@ -5727,8 +5722,6 @@
"astro/@astrojs/internal-helpers/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"astro/@astrojs/internal-helpers/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
"astro/@astrojs/markdown-satteri/satteri": ["satteri@0.10.5", "", { "dependencies": { "@types/estree-jsx": "^1.0.5", "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3" }, "optionalDependencies": { "@bruits/satteri-darwin-arm64": "0.10.5", "@bruits/satteri-darwin-x64": "0.10.5", "@bruits/satteri-linux-arm64-gnu": "0.10.5", "@bruits/satteri-linux-arm64-musl": "0.10.5", "@bruits/satteri-linux-x64-gnu": "0.10.5", "@bruits/satteri-linux-x64-musl": "0.10.5", "@bruits/satteri-wasm32-wasi": "0.10.5", "@bruits/satteri-win32-arm64-msvc": "0.10.5", "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ=="],
"astro/@clack/prompts/@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
@@ -6325,10 +6318,6 @@
"@opencode/stories/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
"@opencode/www/@astrojs/check/@astrojs/language-server/vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"@opencode/www/@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"@opentui/solid/babel-preset-solid/babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
-1
View File
@@ -16,7 +16,6 @@
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:site": "bun run --cwd services/site dev",
"dev:www": "bun run --cwd services/www dev",
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-plugins/sgconfig.yml && bun script/sdk-docs.ts",
"lint:changed": "bun script/lint-changed.ts",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
+1 -1
View File
@@ -18,7 +18,7 @@ const content = await format(
if (process.argv.includes("--check")) {
if ((await Bun.file(target).text()) !== content) {
console.error("Generated CLI config schema is stale. Run `bun run generate` from services/www.")
console.error("Generated CLI config schema is stale. Run `bun run generate` from services/site.")
process.exit(1)
}
process.exit(0)
+1 -1
View File
@@ -143,7 +143,7 @@ secret into configuration.
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
the repository, its source is `services/www/src/docs/content/migrate-v1.mdx`.
the repository, its source is `services/site/src/docs/content/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
+1 -1
View File
@@ -4,6 +4,6 @@ import { $ } from "bun"
await $`bun run generate`.cwd("packages/protocol")
await $`bun run generate`.cwd("services/www")
await $`bun run generate`.cwd("services/site")
await $`./script/format.ts`
+1 -2
View File
@@ -5,8 +5,7 @@ and clients live in `packages/`.
| Service | Purpose | Deployment |
| -------------------------- | -------------------------------------- | -------------------------------- |
| [`site`](./site) | The opencode.ai website | `deploy-site.yml`, `cf` |
| [`www`](./www) | V2 documentation at `/v2` | `deploy-www.yml`, Wrangler |
| [`site`](./site) | The opencode.ai website and V2 docs | `deploy-site.yml`, `cf` |
| [`updates`](./updates) | Release metadata and update selection | `release.yml`, Wrangler |
| [`files`](./files) | Public file distribution at `/files/*` | `deploy-files.yml`, Wrangler |
| [`changelog`](./changelog) | Release notes at `/changelog` | `deploy-changelog.yml`, Wrangler |
+4 -1
View File
@@ -33,9 +33,12 @@ Thumbs.db
# Written by script/build-stamp.ts (bun run build / dev)
/src/lib/build-stamp.json
# Written by `bun run generate` (packages/protocol/openapi.json and packages/schema/script/config-schema.ts)
# Written by `bun run generate` (packages/protocol/openapi.json, packages/schema/script/config-schema.ts and
# packages/cli/script/schema.ts)
/public/openapi.json
/public/config.json
/public/v2/openapi.json
/public/v2/cli.json
# Written by script/github-snapshot.ts (bun run build)
/src/lib/github-snapshot.json
+2 -1
View File
@@ -21,7 +21,8 @@ Read the design-system rules in `DESIGN.md` (also shown at `/dev/components#rule
## Boundaries
- This service is only the website, deployed as its own Worker with `cf` (`cloudflare.config.ts`, `script/build.ts`); the V2 docs at `/v2*` are `services/www`. Never add Wrangler or SST here.
- This service is the website and the V2 docs, deployed as its own Worker with `cf` (`cloudflare.config.ts`, `script/build.ts`). Never add Wrangler or SST here.
- The V2 docs at `/v2` are a self-contained feature in `src/docs/` (read `src/docs/AGENTS.md`). They render their own pages with their own styles and script, never the site's, and the site's styles and scripts never load on them.
- Old URLs kept for outside links live in `src/lib/legacy.ts`, each commented with why it exists and who still uses it; nothing may link to them. Short links (`/discord`, `/feishu`, `/desktop-feedback`) are ordinary routes.
- The site also serves fixed-URL machine files: `/config.json` and `/openapi.json` (written by `bun run generate`), `/tui.json` (`public/`), and `/oauth/opencode/client.json` and `/.well-known/apple-app-site-association` (`src/lib/meta.ts`). Their bytes and headers are a contract with clients.
- Heavy media (video, audio, zips, 3D models) is never a Worker asset: link it with `mediaUrl()` (`src/lib/media.ts`), which points into the files bucket behind `/files`.
+4 -1
View File
@@ -46,6 +46,9 @@ Specification tree in `.cloudflare/output`; `bun run deploy:dev` builds and runs
`cf deploy --prebuilt --mode dev`. `cloudflare.config.ts` defines the Worker (`opencode-site-dev` on
dev.opencode.ai, `opencode-site-production` on opencode.ai), its bindings and its routes. CI deploys on
pushes to `dev` and `production` (`.github/workflows/deploy-site.yml`). Production takes no routes until
`productionOwnsRoot` is switched on. The V2 docs at `/v2*` are a separate Worker (`services/www`).
`productionOwnsRoot` is switched on.
The V2 docs at `/v2` are part of this app (`src/docs/`, see `src/docs/AGENTS.md`). `script/build.ts` prerenders
them into the Worker's assets after `vite build`.
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
+14 -4
View File
@@ -2,12 +2,15 @@ import { bindings, defineConfig, triggers } from "cf/config"
// The opencode.ai website (SolidStart). `--mode dev` deploys opencode-site-dev on dev.opencode.ai;
// any other mode deploys opencode-site-production on opencode.ai. It is the catch-all: more specific
// routes on the zone (the V2 docs at /v2* in services/www, Console, inference, changelog, update,
// files, stats, …) keep beating it.
// routes on the zone (Console, inference, changelog, update, files, stats, …) keep beating it. It
// also serves the V2 docs at /v2 (src/docs).
//
// Production takes no routes until the go-live flips this switch (the `production` branch is shared
// with every other service, so an unrelated push must not take the domain).
const productionOwnsRoot = false
// Until then, production's /v2* can move to this Worker on its own: flip this once the route has
// been moved over from the old docs Worker (opencode-www-production), so deploys keep it.
const productionOwnsDocs = false
export default defineConfig(({ mode }) => {
const dev = mode === "dev"
@@ -20,7 +23,12 @@ export default defineConfig(({ mode }) => {
compatibilityDate: "2026-07-25",
// The Nitro server is built with node compatibility (vite.config.ts).
compatibilityFlags: ["nodejs_compat"],
assets: { htmlHandling: "drop-trailing-slash" },
assets: {
htmlHandling: "drop-trailing-slash",
// Docs pages answer with HTML or Markdown from one URL, so the Worker picks the asset
// (src/docs/middleware.ts). Search's files are plain assets.
runWorkerFirst: ["/v2/docs", "/v2/docs/*", "!/v2/docs/pagefind/*"],
},
env: {
ASSETS: bindings.assets(),
STAGE: bindings.text(dev ? "dev" : "production"),
@@ -31,7 +39,9 @@ export default defineConfig(({ mode }) => {
triggers.fetch({ pattern: host, zone: "opencode.ai" }),
triggers.fetch({ pattern: `${host}/*`, zone: "opencode.ai" }),
]
: [],
: productionOwnsDocs
? [triggers.fetch({ pattern: `${host}/v2*`, zone: "opencode.ai" })]
: [],
workersDev: false,
previewUrls: false,
// Workers Logs: requests, errors and console output, searchable in the Cloudflare dashboard.
+21 -2
View File
@@ -15,7 +15,7 @@
"build": "bun script/build.ts",
"deploy:dev": "bun script/build.ts --mode dev && cf deploy --prebuilt --mode dev",
"deploy:production": "bun script/build.ts --mode production && cf deploy --prebuilt --mode production",
"generate": "cp ../../packages/protocol/openapi.json public/openapi.json && bun ../../packages/schema/script/config-schema.ts public/config.json",
"generate": "cp ../../packages/protocol/openapi.json public/openapi.json && bun ../../packages/schema/script/config-schema.ts public/config.json && cp ../../packages/protocol/openapi.json public/v2/openapi.json && bun --cwd ../../packages/cli script/schema.ts ../../services/site/public/v2/cli.json",
"typecheck": "bun check --no-script && bun --cwd textjs check --no-script",
"typecheck:tsc": "tsc --noEmit && tsc --noEmit -p textjs",
"test": "bun test --only-failures"
@@ -43,20 +43,39 @@
"zod": "catalog:"
},
"devDependencies": {
"@babel/core": "7.29.7",
"@cloudflare/build-output-utils": "0.8.5",
"@cloudflare/config": "0.23.0",
"@mdx-js/mdx": "3.1.1",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/estree-jsx": "1.0.5",
"@types/hast": "3.0.5",
"@types/mdast": "4.0.4",
"@types/react": "19.3.0",
"@types/three": "0.180.0",
"@typescript/native": "catalog:",
"@webgpu/types": "0.1.54",
"babel-preset-solid": "1.9.15",
"cf": "1.0.0-beta.12",
"estree-util-visit": "2.0.0",
"github-slugger": "2.0.0",
"hast-util-to-text": "4.0.2",
"mdast-util-mdx-jsx": "3.2.0",
"miniflare": "5.20260930.0-alpha",
"pagefind": "1.5.2",
"playwright-core": "1.59.1",
"remark-gfm": "4.0.1",
"remark-smartypants": "3.0.3",
"satori": "0.63.2",
"sharp": "0.34.5",
"shiki": "catalog:",
"typescript": "catalog:",
"wawoff2": "2.0.1"
"unist-util-visit": "5.1.0",
"vfile": "6.0.3",
"vite-plugin-solid": "2.11.14",
"wawoff2": "2.0.1",
"yaml": "2.9.1"
},
"engines": {
"node": ">=24"
+3
View File
@@ -15,6 +15,9 @@
# Yoga's WebAssembly, named by a hash of its bytes (textjs/script/build.ts).
/textjs/yoga-*
Cache-Control: public, max-age=31536000, immutable
# The V2 docs' stylesheet, script and fonts, named by a hash of their contents (src/docs/script/build.ts).
/v2/_build/*
Cache-Control: public, max-age=31536000, immutable
# Link preview cards, named by a hash of the PNG (script/og.ts).
/og/*
Cache-Control: public, max-age=31536000, immutable

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 613 B

After

Width:  |  Height:  |  Size: 613 B

+6 -1
View File
@@ -34,6 +34,8 @@ await requireSnapshot()
await Bun.$`bun ./script/og.ts`.cwd(root).env(env)
await Bun.$`bun ./textjs/script/build.ts`.cwd(root).env(env)
await Bun.$`vite build`.cwd(root).env(env)
// The V2 docs at /v2: prerendered pages, Markdown twins and search (src/docs/script/build.ts).
await Bun.$`bun ./src/docs/script/build.ts --mode ${mode}`.cwd(root).env(env)
await Bun.$`bun ./script/check-server-bundle.ts`.cwd(root).env(env)
await Bun.$`bun ./script/check-client-bundle.ts`.cwd(root).env(env)
@@ -59,7 +61,10 @@ await cp(path.join(root, ".output", "public"), assets, {
})
const shipped = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: assets, dot: true }))
// Every opencode.json and V1 TUI config names these as its $schema; /openapi.json is the protocol's.
const fixed = ["config.json", "tui.json", "openapi.json"].filter((file) => !shipped.includes(file))
// The V2 config names /v2/cli.json, and the docs link /v2/openapi.json.
const fixed = ["config.json", "tui.json", "openapi.json", "v2/cli.json", "v2/openapi.json"].filter(
(file) => !shipped.includes(file),
)
if (fixed.length) throw new Error(`fixed-URL files missing from Worker assets: ${fixed.join(", ")}`)
await writeAssets({ root, sourceDirectory: assets })
+9 -5
View File
@@ -15,14 +15,15 @@ import { readBuildOutput } from "@cloudflare/build-output-utils"
import { Miniflare, Response } from "miniflare"
const root = path.resolve(import.meta.dir, "..")
// Pages with a status must answer exactly that; /v2/docs/ (the docs, when this build has them, else
// a 404) anything but a 5xx.
const pages = [
// Pages with a status must answer exactly that, others anything but a 5xx. The docs page is asked for
// as a browser asks and, at a second URL, as an agent asks (its Markdown twin, src/docs/middleware.ts).
const pages: { path: string; status?: number; accept?: string }[] = [
{ path: "/", status: 200 },
{ path: "/ja", status: 200 },
{ path: "/desktop", status: 200 },
{ path: "/check-worker-start-404", status: 404 },
{ path: "/v2/docs/" },
{ path: "/v2/docs/", status: 200 },
{ path: "/v2/docs/config/", status: 200, accept: "text/markdown" },
]
const started = performance.now()
@@ -82,7 +83,10 @@ const results = await Promise.resolve()
.then(async (url) => {
const answers = []
for (const page of pages) {
const actual = await fetch(new URL(page.path, url), { headers: { accept: "text/html" }, redirect: "manual" })
const actual = await fetch(new URL(page.path, url), {
headers: { accept: page.accept ?? "text/html" },
redirect: "manual",
})
.then(async (response) => (await response.arrayBuffer(), response.status))
.catch(() => 0)
answers.push({ ...page, actual })
+1 -1
View File
@@ -8,7 +8,7 @@ import { readdirSync } from "node:fs"
import path from "node:path"
const app = path.resolve(import.meta.dir, "..")
const content = path.resolve(app, "../../../services/www/src/docs/content")
const content = path.resolve(app, "src/docs/content")
const sprite = await Bun.file(path.resolve(app, "../../ui/src/components/file-icons/sprite.svg")).text()
const walk = (dir: string): string[] =>
+3 -3
View File
@@ -1,4 +1,4 @@
# Builds the site's OpenTUI Mono files from the docs' copies (services/www/src/docs/assets/fonts,
# Builds the site's OpenTUI Mono files from the docs' copies (src/docs/assets/fonts,
# the same files as anomalyco/opentui packages/web/public/fonts). Two files per style, split by
# unicode-range like Inter: "latin" (Latin, punctuation, arrows, shapes and box drawing, what the
# site sets in mono) and "rest" (every other character the font has, only fetched when a page uses
@@ -10,7 +10,7 @@
# are kept.
#
# OpenTUI Mono is licensed under the SIL Open Font License 1.1 (src/asset/fonts/OpenTUIMono-OFL.txt).
# Run from packages/console/app:
# Run from services/site:
# uvx --from 'fonttools[woff]==4.60.1' python script/opentui-mono.py
import json
from pathlib import Path
@@ -18,7 +18,7 @@ from pathlib import Path
from fontTools import subset
from fontTools.ttLib import TTFont
source = Path("../../../services/www/src/docs/assets/fonts")
source = Path("src/docs/assets/fonts")
out = Path("src/asset/fonts")
styles = {
"regular": "OpenTUIMono-Regular",
@@ -6,7 +6,7 @@ import { docsInstalls } from "~/lib/docs-content"
/**
* Every docs code block type at once, in Vercel square (the chosen code style), whatever the
* kitchen sink's switchers say: docs-variants.css styles [data-slot="code-gallery"] as option 6.
* Content is from the docs (services/www/src/docs/content), with models reading Claude Opus 5.5.
* Content is from the docs (src/docs/content), with models reading Claude Opus 5.5.
*/
export function DocsCodeGallery(props: { reference?: boolean }) {
return (
+42
View File
@@ -0,0 +1,42 @@
# Documentation feature guide
## Structure
- This folder is the V2 docs at `/v2` on the site: content, components, styles, build and serving. Keep docs code here; the site's shared files only plug it in (`vite.config.ts`, `src/middleware.ts`, `script/build.ts`, `cloudflare.config.ts`).
- Content lives in `content/` as MDX. A file's path is its URL: `content/cli/keybinds.mdx` is `/v2/docs/cli/keybinds/`. Other repositories read some of these files by path, so don't move them without checking.
- `lib/navigation.ts` is the source of truth for header sections and sidebar ordering.
- `frontmatter.ts` is the frontmatter schema; a page that doesn't match it fails the build.
- `config.ts` holds the mount path (`/v2/`). Build URLs from it (`docsHref`), never from the site's base. MDX reads it as `import.meta.env.BASE_URL`.
- Keep internal Markdown links docs-root-relative, for example `/config`; `remark-links.ts` adds `/v2/docs`.
- Components used in MDX are registered in `document.tsx`. Named slots work as in Astro: `<div slot="go">` inside a component becomes that slot's content (`remark-slots.ts`, `components/Slot.tsx`).
## Rendering
- Pages are plain HTML. They never hydrate and never load the site's styles, fonts or scripts; the site's never load the docs'. A page links `styles/global.css` and the one browser script, `client.ts`.
- Everything a page loads lives under `/v2` (the stylesheet, script and fonts are built to `/v2/_build`), because production can route `/v2` to this Worker before the rest of the domain. The build fails otherwise.
- Interactive pieces (search, copy buttons, tabs, the status link) are delegated listeners in `client.ts`, keyed on `data-*` attributes the components render. Don't add Solid state or effects to docs components; they only run on the server.
- The theme script is inline in `layouts/DocsLayout.tsx` so pages never flash. Navigation is ordinary page loads, made instant by Speculation Rules and cross-document view transitions.
- `vite.ts` compiles MDX the way Astro's MDX integration did (GitHub-flavoured Markdown, smart punctuation, Shiki with the github-light and github-dark themes, GitHub-style heading ids), so the markup and `global.css` stay unchanged.
## Build and serving
- `script/build.ts` runs after the site's `vite build` (from `script/build.ts` at the site root): it builds the docs' stylesheet and script, prerenders every page, every content page's Markdown twin (`/v2/docs/config.md`), `llms.txt` and the sitemap into `.output/public`, builds the Pagefind index, and checks every link and that nothing loads from outside `/v2`. `--mode dev` makes `llms.txt` and the sitemap list dev.opencode.ai.
- Pull requests that touch the site run the same build on its own (`.github/workflows/check-docs.yml`): `bun run generate`, then `bun src/docs/script/build.ts --out <dir>`.
- The Worker never renders docs. `middleware.ts` serves the prerendered files: docs URLs reach it first (`runWorkerFirst` in `cloudflare.config.ts`), so a page's URL answers `Accept: text/markdown` with its twin. `vite dev` has no prerendered files, so the development server renders on request.
## Writing Style
- Keep prose sections brief and focused on one idea. Prefer one to three sentences over large paragraphs.
- Interleave explanations with concrete code, configuration, command, or output examples so pages do not become walls of text.
- Put the relevant example immediately after the text that introduces it, following `content/build/plugins/cli.mdx` as the reference pattern.
- Every subsection that explains syntax, fields, or an API concept must include its own minimal example. A larger example earlier on the page does not count.
- Split long explanations with meaningful headings and examples rather than accumulating caveats in one paragraph.
- Lead with the common task and working example; place edge cases and supporting details afterward.
- Do not stack several prose paragraphs without a visual break. After introducing a concept, use an example, list, table, or task-oriented subheading before covering the next concern.
- Use bullets or tables for independent rules, options, and constraints. Do not hide reference material in narrative paragraphs.
- Organize workflow documentation in the order readers perform it, with a working example at each major step.
## Validation
- Run `bun typecheck` and `bun run build` from `services/site` after changes. The build fails on invalid frontmatter, MDX errors and broken links.
- Check desktop and mobile layouts, light and dark, when changing navigation or shared styles.
+180
View File
@@ -0,0 +1,180 @@
// The docs pages' only script: search, copy buttons, tabs and the status link. Pages are plain HTML
// (no hydration); everything here is delegated from the document, so it needs no per-element setup.
// The theme toggle is in the layout's head script, so it works before this loads.
type PagefindResult = { url: string; excerpt: string; meta: { title: string } }
type Pagefind = { search(query: string): Promise<{ results: { data(): Promise<PagefindResult> }[] }> }
let pagefind: Promise<Pagefind> | undefined
// Pagefind is loaded from its built file on first search. Through `Function` because Vite would wrap
// a plain import() in its preload helper, a second script on every page for a file it can't preload.
const load = new Function("url", "return import(url)") as (url: string) => Promise<Pagefind>
const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]")
const input = document.querySelector<HTMLInputElement>("[data-search-input]")
const results = document.querySelector<HTMLElement>("[data-search-results]")
function openSearch() {
dialog?.showModal()
input?.focus()
}
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
openSearch()
}
})
input?.addEventListener("input", async () => {
if (!dialog || !results) return
const query = input.value.trim()
if (!query) {
results.innerHTML = "<p>Start typing to search the documentation.</p>"
return
}
const path = dialog.dataset.pagefindPath
if (!path) return
pagefind ??= load(path)
const search = await (await pagefind).search(query)
const pages = await Promise.all(search.results.slice(0, 8).map((result) => result.data()))
const base = dialog.dataset.baseUrl ?? "/"
results.replaceChildren(
...pages.map((page) => {
const link = document.createElement("a")
link.href = page.url.startsWith(base) ? page.url : `${base.replace(/\/$/, "")}${page.url}`
const title = document.createElement("strong")
title.textContent = page.meta.title
const excerpt = document.createElement("span")
excerpt.innerHTML = page.excerpt
link.append(title, excerpt)
return link
}),
)
if (!pages.length) results.innerHTML = "<p>No results found.</p>"
})
document.addEventListener("click", async (event) => {
if (!(event.target instanceof Element)) return
if (event.target.closest("[data-search-open]")) return openSearch()
const code = event.target.closest<HTMLButtonElement>("[data-copy-code]")
if (code) {
await navigator.clipboard.writeText(code.dataset.code ?? "")
code.textContent = "Copied"
window.setTimeout(() => (code.textContent = "Copy"), 1500)
return
}
const tab = event.target.closest<HTMLButtonElement>("[data-code-tab]")
if (tab) return selectCodeTab(tab)
const plan = event.target.closest<HTMLButtonElement>("[data-plan-tab]")
const plans = plan?.closest<HTMLElement>("[data-plan-tabs]")
if (plan?.dataset.planTab && plans) return selectSyncedPlan(plans, plan.dataset.planTab)
const markdown = event.target.closest<HTMLButtonElement>("[data-copy-markdown]")
if (markdown) {
markdown.dataset.copied = "true"
markdown.setAttribute("aria-label", "Copied")
window.setTimeout(() => {
delete markdown.dataset.copied
markdown.setAttribute("aria-label", "Copy page as Markdown")
}, 1500)
const response = await fetch(window.location.href, { headers: { Accept: "text/markdown" } })
if (!response.ok) return
await navigator.clipboard.writeText(await response.text())
}
})
// Arrow keys, Home and End move between tabs.
document.addEventListener("keydown", (event) => {
if (!(event.target instanceof HTMLButtonElement)) return
const tabs = event.target.closest<HTMLElement>("[data-code-tabs], [data-plan-tabs]")
if (!tabs || !event.target.matches("[data-code-tab], [data-plan-tab]")) return
const buttons = [...tabs.querySelectorAll<HTMLButtonElement>("[data-code-tab], [data-plan-tab]")]
const selected = buttons.indexOf(event.target)
const next =
event.key === "Home"
? buttons[0]
: event.key === "End"
? buttons.at(-1)
: event.key === "ArrowRight"
? buttons[(selected + 1) % buttons.length]
: event.key === "ArrowLeft"
? buttons[(selected - 1 + buttons.length) % buttons.length]
: undefined
if (!next) return
event.preventDefault()
if (next.dataset.planTab) selectSyncedPlan(tabs, next.dataset.planTab)
else selectCodeTab(next)
next.focus()
})
function selectCodeTab(button: HTMLButtonElement) {
const tabs = button.closest<HTMLElement>("[data-code-tabs]")
if (!tabs) return
const buttons = [...tabs.querySelectorAll<HTMLButtonElement>("[data-code-tab]")]
const selected = buttons.indexOf(button)
buttons.forEach((item, index) => {
item.setAttribute("aria-selected", String(index === selected))
item.tabIndex = index === selected ? 0 : -1
})
tabs.querySelectorAll<HTMLElement>("[data-code-panel]").forEach((panel, index) => (panel.hidden = index !== selected))
}
function selectPlan(tabs: HTMLElement, plan: string) {
tabs.querySelectorAll<HTMLButtonElement>("[data-plan-tab]").forEach((button) => {
const selected = button.dataset.planTab === plan
button.setAttribute("aria-selected", String(selected))
button.tabIndex = selected ? 0 : -1
})
tabs.querySelectorAll<HTMLElement>("[data-plan-panel]").forEach((panel) => {
panel.hidden = panel.dataset.planPanel !== plan
})
}
// Plan tabs with the same sync key switch together, and the choice is remembered.
function selectSyncedPlan(source: HTMLElement, plan: string) {
const syncKey = source.dataset.syncKey
if (!syncKey) return
document.querySelectorAll<HTMLElement>("[data-plan-tabs]").forEach((tabs) => {
if (tabs.dataset.syncKey === syncKey) selectPlan(tabs, plan)
})
localStorage.setItem(`docs-plan-tabs:${syncKey}`, plan)
}
document.querySelectorAll<HTMLElement>("[data-plan-tabs]").forEach((tabs) => {
const plan = tabs.dataset.syncKey && localStorage.getItem(`docs-plan-tabs:${tabs.dataset.syncKey}`)
if (plan) selectPlan(tabs, plan)
})
// The status page's summary. Each page is a full load, so the last answer is kept for a minute in
// sessionStorage: the next page shows it at once instead of fetching again.
const labels = {
operational: "All systems operational",
degraded: "Degraded performance",
outage: "Service disruption",
}
type Status = keyof typeof labels
function showStatus(status: Status) {
const link = document.querySelector<HTMLAnchorElement>(".system-status")
if (!link) return
link.dataset.status = status
link.textContent = labels[status]
}
const cached = (() => {
const value = JSON.parse(sessionStorage.getItem("docs-status") ?? "null") as { status: Status; at: number } | null
return value && value.status in labels ? value : undefined
})()
if (cached) showStatus(cached.status)
if (!cached || Date.now() - cached.at > 60_000)
fetch("/status/api/summary", { signal: AbortSignal.timeout(10_000) })
.then((response) => (response.ok ? response.json() : undefined))
.then((body: unknown) => {
const status = body && typeof body === "object" && "status" in body ? body.status : undefined
if (status !== "operational" && status !== "degraded" && status !== "outage") return
sessionStorage.setItem("docs-status", JSON.stringify({ status, at: Date.now() }))
showStatus(status)
})
.catch(() => undefined)
@@ -0,0 +1,12 @@
import type { JSX } from "solid-js"
export function Callout(props: { type?: "note" | "tip" | "warning"; title?: string; children?: JSX.Element }) {
const type = props.type ?? "note"
const title = props.title ?? (type === "tip" ? "Tip" : type === "warning" ? "Warning" : "Note")
return (
<aside class={`callout callout-${type}`} aria-label={title}>
<strong>{title}</strong>
<div>{props.children}</div>
</aside>
)
}
@@ -0,0 +1,12 @@
import type { JSX } from "solid-js"
import { base } from "../config"
export function Card(props: { title: string; href: string; children?: JSX.Element }) {
const href = props.href.startsWith("/") ? `${base}docs${props.href}` : props.href
return (
<a class="docs-card" href={href}>
<strong>{props.title}</strong>
<div>{props.children}</div>
</a>
)
}
@@ -0,0 +1,9 @@
import type { JSX } from "solid-js"
export function CardGroup(props: { cols?: number; children?: JSX.Element }) {
return (
<div class="docs-card-group" style={`--card-columns: ${props.cols ?? 2}`}>
{props.children}
</div>
)
}
@@ -0,0 +1,13 @@
// The copy button works through client.ts.
export function CodeBlock(props: { code: string }) {
return (
<div class="docs-code-block">
<pre>
<code>{props.code}</code>
</pre>
<button type="button" data-copy-code data-code={props.code}>
Copy
</button>
</div>
)
}
@@ -0,0 +1,40 @@
import { For } from "solid-js"
import { CodeBlock } from "./CodeBlock"
// The tabs switch through client.ts.
export function CodeTabs(props: { id: string; label: string; tabs: { label: string; code: string }[] }) {
return (
<div class="docs-code-tabs" data-code-tabs>
<div class="docs-code-tabs-list" role="tablist" aria-label={props.label}>
<For each={props.tabs}>
{(tab, index) => (
<button
type="button"
role="tab"
id={`${props.id}-tab-${index()}`}
aria-controls={`${props.id}-panel-${index()}`}
aria-selected={index() === 0 ? "true" : "false"}
tabindex={index() === 0 ? 0 : -1}
data-code-tab
>
{tab.label}
</button>
)}
</For>
</div>
<For each={props.tabs}>
{(tab, index) => (
<div
role="tabpanel"
id={`${props.id}-panel-${index()}`}
aria-labelledby={`${props.id}-tab-${index()}`}
hidden={index() !== 0}
data-code-panel
>
<CodeBlock code={tab.code} />
</div>
)}
</For>
</div>
)
}
@@ -0,0 +1,12 @@
// The status comes in through client.ts.
export function DocsFooter() {
return (
<footer class="site-footer">
<div class="footer-inner">
<a class="system-status" href="/status" data-status="unknown">
System status
</a>
</div>
</footer>
)
}
@@ -0,0 +1,97 @@
import { For, Show } from "solid-js"
import { base } from "../config"
import { docsHref, docsSections, getDocsSection } from "../lib/navigation"
// Search, the theme toggle and the ⌘K shortcut work through the layout's head script and client.ts.
export function DocsHeader(props: { currentSlug: string }) {
const currentSection = getDocsSection(props.currentSlug)
const searchEnabled = import.meta.env.PROD
return (
<>
<header class="site-header">
<div class="header-inner">
<a class="brand" href={docsHref("index")} aria-label="OpenCode docs home">
<img class="logo-dark" src={`${base}assets/logo-dark.svg`} alt="OpenCode" />
<img class="logo-light" src={`${base}assets/logo-light.svg`} alt="OpenCode" />
</a>
<nav aria-label="Documentation sections">
<For each={docsSections}>
{(section) => (
<a
href={docsHref(section.landingSlug)}
aria-current={currentSection.key === section.key ? "page" : undefined}
>
{section.title}
</a>
)}
</For>
<a href="/docs">V1</a>
</nav>
<div class="header-spacer"></div>
<Show when={searchEnabled}>
<button class="search-trigger" type="button" data-search-open aria-label="Search documentation">
<span>Search</span>
<kbd>⌘K</kbd>
</button>
</Show>
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle color theme">
<svg
class="theme-icon-light"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
<svg
class="theme-icon-dark"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z" />
</svg>
</button>
<a class="github-link" href="https://github.com/anomalyco/opencode" aria-label="OpenCode on GitHub">
GitHub
</a>
</div>
</header>
<Show when={searchEnabled}>
<dialog
class="search-dialog"
data-search-dialog
data-pagefind-path={`${base}docs/pagefind/pagefind.js`}
data-base-url={`${base}docs/`}
aria-label="Search documentation"
>
<form method="dialog" class="search-bar">
<span aria-hidden="true">&gt;</span>
<input
data-search-input
type="search"
placeholder="Search docs"
aria-label="Search docs"
autocomplete="off"
/>
<button type="submit" aria-label="Close search">
Esc
</button>
</form>
<div class="search-results" data-search-results>
<p>Start typing to search the documentation.</p>
</div>
</dialog>
</Show>
</>
)
}
@@ -0,0 +1,29 @@
import { For, Show } from "solid-js"
import { docsHref, type DocsNavGroup } from "../lib/navigation"
export function DocsSidebar(props: { groups: DocsNavGroup[]; currentSlug: string }) {
return (
<nav class="docs-sidebar" aria-label="Documentation">
<For each={props.groups}>
{(group) => (
<section>
<Show when={group.title}>
<h2>{group.title}</h2>
</Show>
<ul>
<For each={group.items}>
{(item) => (
<li>
<a href={docsHref(item.slug)} aria-current={item.slug === props.currentSlug ? "page" : undefined}>
{item.title}
</a>
</li>
)}
</For>
</ul>
</section>
)}
</For>
</nav>
)
}
@@ -0,0 +1,22 @@
import { For, Show } from "solid-js"
import type { DocsHeading } from "../rehype-headings"
export function DocsTableOfContents(props: { headings: DocsHeading[] }) {
const headings = props.headings.filter((heading) => heading.depth === 2 || heading.depth === 3)
return (
<Show when={headings.length > 0}>
<nav class="docs-toc" aria-label="On this page">
<h2>On this page</h2>
<ul>
<For each={headings}>
{(heading) => (
<li class={heading.depth === 3 ? "nested" : undefined}>
<a href={`#${heading.slug}`}>{heading.text}</a>
</li>
)}
</For>
</ul>
</nav>
</Show>
)
}
@@ -0,0 +1,224 @@
import { For, Show } from "solid-js"
import openapi from "../../../../../packages/protocol/openapi.json"
import { base } from "../config"
import { openApiSchemaLabel, type OpenApiOperation, type OpenApiSchema } from "../lib/openapi"
import { OpenApiSchemaView } from "./OpenApiSchema"
const methods = ["get", "post", "put", "patch", "delete", "options", "head"] as const
type Method = (typeof methods)[number]
const paths = openapi.paths as unknown as Record<string, Partial<Record<Method, OpenApiOperation>>>
const operations = Object.entries(paths).flatMap(([path, item]) =>
methods.flatMap((method) => {
const operation = item[method]
if (!operation) return []
return [
{ ...operation, path, method, id: operation.operationId ?? `${method}-${path}`.replace(/[^a-z0-9]+/gi, "-") },
]
}),
)
const groups = [...new Set(operations.map((operation) => operation.tags?.[0] ?? "other"))].map((tag) => ({
tag,
id: `tag-${tag.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`,
description: openapi.tags.find((item) => item.name === tag)?.description,
operations: operations.filter((operation) => (operation.tags?.[0] ?? "other") === tag),
}))
const schemas = Object.entries(openapi.components.schemas) as [string, OpenApiSchema][]
export function OpenApiReference() {
return (
<div class="api-reference">
<nav class="api-navigation" aria-label="API endpoints">
<strong>API reference</strong>
<a class="api-navigation-overview" href="#overview">
Overview
</a>
<For each={groups}>
{(group) => (
<details class="api-navigation-group">
<summary>
<span>{group.tag}</span>
<small>{group.operations.length}</small>
</summary>
<For each={group.operations}>
{(operation) => (
<a href={`#${operation.id}`}>
<span class={`api-navigation-method api-navigation-method-${operation.method}`}>
{operation.method}
</span>
<span>{operation.summary}</span>
</a>
)}
</For>
</details>
)}
</For>
<a class="api-navigation-overview" href="#schemas">
<span>Schemas</span>
<small>{schemas.length}</small>
</a>
</nav>
<article class="api-content" id="overview" data-pagefind-body data-pagefind-meta="title:API">
<header class="api-intro">
<p class="api-eyebrow">OpenAPI {openapi.openapi}</p>
<h1>HTTP API</h1>
<p>{openapi.info.description}</p>
<div class="api-meta">
<span>{operations.length} operations</span>
<span>{schemas.length} schemas</span>
<a href={`${base}openapi.json`}>OpenAPI JSON</a>
</div>
</header>
<For each={groups}>
{(group) => (
<section class="api-group" id={group.id}>
<header>
<p class="api-eyebrow">Resource</p>
<h2>{group.tag}</h2>
<Show when={group.description}>
<p>{group.description}</p>
</Show>
</header>
<div class="api-operations">
<For each={group.operations}>{(operation) => <Operation operation={operation} />}</For>
</div>
</section>
)}
</For>
<section class="api-group api-schemas" id="schemas">
<header>
<p class="api-eyebrow">Components</p>
<h2>Schemas</h2>
<p>Reusable data types referenced by API operations.</p>
</header>
<div class="api-operations">
<For each={schemas}>
{([name, schema]) => (
<details class="api-schema" id={`schema-${name}`}>
<summary>
<code>{name}</code>
<span>{openApiSchemaLabel(schema)}</span>
</summary>
<div class="api-schema-body">
<OpenApiSchemaView schema={schema} />
</div>
</details>
)}
</For>
</div>
</section>
</article>
</div>
)
}
function Operation(props: { operation: (typeof operations)[number] }) {
const operation = props.operation
return (
<details class="api-operation" id={operation.id}>
<summary>
<span class={`api-method api-method-${operation.method}`}>{operation.method}</span>
<code>{operation.path}</code>
<span>{operation.summary}</span>
</summary>
<div class="api-operation-body">
<Show when={operation.description}>
<p>{operation.description}</p>
</Show>
<p class="api-operation-id">
<span>Operation ID</span>
<code>{operation.operationId}</code>
</p>
<Show when={operation.parameters?.length}>
<section class="api-block">
<h3>Parameters</h3>
<div class="api-table-scroll">
<table>
<thead>
<tr>
<th>Name</th>
<th>Location</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<For each={operation.parameters}>
{(parameter) => (
<tr>
<td>
<code>{parameter.name}</code>
<Show when={parameter.required}>
<span class="api-required">required</span>
</Show>
</td>
<td>{parameter.in}</td>
<td>
<OpenApiSchemaView schema={parameter.schema} />
</td>
<td>{parameter.description ?? <span class="api-muted">No description</span>}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
</Show>
<Show when={operation.requestBody}>
{(body) => (
<section class="api-block">
<h3>
Request body{" "}
<Show when={body().required}>
<span class="api-required">required</span>
</Show>
</h3>
<Show when={body().description}>
<p>{body().description}</p>
</Show>
<div class="api-content-schemas">
<For each={Object.entries(body().content ?? {})}>
{([mediaType, content]) => (
<div>
<code class="api-media-type">{mediaType}</code>
<OpenApiSchemaView schema={content.schema} />
</div>
)}
</For>
</div>
</section>
)}
</Show>
<section class="api-block">
<h3>Responses</h3>
<div class="api-responses">
<For each={Object.entries(operation.responses ?? {})}>
{([status, response]) => (
<div class="api-response">
<div class="api-response-heading">
<code class={status.startsWith("2") ? "api-status api-status-success" : "api-status"}>
{status}
</code>
<span>{response.description ?? "Response"}</span>
</div>
<For each={Object.entries(response.content ?? {})}>
{([mediaType, content]) => (
<div class="api-response-schema">
<code class="api-media-type">{mediaType}</code>
<OpenApiSchemaView schema={content.schema} />
</div>
)}
</For>
</div>
)}
</For>
</div>
</section>
</div>
</details>
)
}
@@ -0,0 +1,114 @@
import { For, Show } from "solid-js"
import { formatOpenApiValue, openApiSchemaLabel, type OpenApiSchema } from "../lib/openapi"
export function OpenApiSchemaView(props: { schema?: OpenApiSchema; required?: boolean }) {
const schema = props.schema
const reference = schema?.$ref?.split("/").at(-1)
const variants = schema?.anyOf ?? schema?.oneOf ?? schema?.allOf
const variantOperator = schema?.allOf ? "and" : "or"
const constraints = schema
? [
schema.pattern && `pattern ${schema.pattern}`,
schema.minimum !== undefined && `min ${schema.minimum}`,
schema.maximum !== undefined && `max ${schema.maximum}`,
schema.exclusiveMinimum !== undefined && `> ${schema.exclusiveMinimum}`,
schema.exclusiveMaximum !== undefined && `< ${schema.exclusiveMaximum}`,
schema.minLength !== undefined && `min length ${schema.minLength}`,
schema.maxLength !== undefined && `max length ${schema.maxLength}`,
schema.minItems !== undefined && `min items ${schema.minItems}`,
schema.maxItems !== undefined && `max items ${schema.maxItems}`,
].filter((value): value is string => typeof value === "string")
: []
if (reference)
return (
<a class="api-schema-link" href={`#schema-${reference}`}>
{reference}
</a>
)
return (
<div class="api-schema-node">
<div class="api-schema-heading">
<code>{openApiSchemaLabel(schema)}</code>
<Show when={props.required}>
<span class="api-required">required</span>
</Show>
<Show when={schema?.deprecated}>
<span class="api-deprecated">deprecated</span>
</Show>
</div>
<Show when={schema?.description}>
<p>{schema?.description}</p>
</Show>
<Show when={schema?.enum}>
{(values) => (
<div class="api-schema-values">
<span>Values</span>
<code>{values().map(formatOpenApiValue).join(" | ")}</code>
</div>
)}
</Show>
<Show when={schema?.const !== undefined}>
<div class="api-schema-values">
<span>Constant</span>
<code>{formatOpenApiValue(schema?.const)}</code>
</div>
</Show>
<Show when={schema?.default !== undefined}>
<div class="api-schema-values">
<span>Default</span>
<code>{formatOpenApiValue(schema?.default)}</code>
</div>
</Show>
<Show when={constraints.length > 0}>
<div class="api-schema-constraints">{constraints.join(", ")}</div>
</Show>
<Show when={variants}>
{(items) => (
<div class="api-schema-variants">
<For each={items()}>
{(variant, index) => (
<div>
<Show when={index() > 0}>
<span class="api-schema-operator">{variantOperator}</span>
</Show>
<OpenApiSchemaView schema={variant} />
</div>
)}
</For>
</div>
)}
</Show>
<Show when={schema?.type === "array" && schema.items}>
{(items) => (
<div class="api-schema-items">
<span>Items</span>
<OpenApiSchemaView schema={items()} />
</div>
)}
</Show>
<Show when={schema?.properties}>
{(properties) => (
<div class="api-schema-properties">
<For each={Object.entries(properties())}>
{([name, property]) => (
<div class="api-schema-property">
<code>{name}</code>
<OpenApiSchemaView schema={property} required={schema?.required?.includes(name)} />
</div>
)}
</For>
</div>
)}
</Show>
<Show when={typeof schema?.additionalProperties === "object" && schema.additionalProperties}>
{(additional) => (
<div class="api-schema-items">
<span>Additional properties</span>
<OpenApiSchemaView schema={additional() as OpenApiSchema} />
</div>
)}
</Show>
</div>
)
}
@@ -0,0 +1,47 @@
import { For, type JSX } from "solid-js"
import { SlotScope } from "./Slot"
const plans = [
{ id: "go", label: "Go" },
{ id: "go-plus", label: "Go Plus" },
{ id: "go-teams", label: "Go Teams" },
] as const
// One panel per plan, from the `slot="go"`, `slot="go-plus"` and `slot="go-teams"` children. The tabs
// switch, and stay in step across every PlanTabs with the same syncKey, through client.ts.
export function PlanTabs(props: { id: string; label: string; syncKey: string; children?: JSX.Element }) {
return (
<div class="docs-plan-tabs" data-plan-tabs data-sync-key={props.syncKey}>
<div class="docs-plan-tabs-list" role="tablist" aria-label={props.label}>
<For each={plans}>
{(plan, index) => (
<button
type="button"
role="tab"
id={`${props.id}-tab-${plan.id}`}
aria-controls={`${props.id}-panel-${plan.id}`}
aria-selected={index() === 0 ? "true" : "false"}
tabindex={index() === 0 ? 0 : -1}
data-plan-tab={plan.id}
>
{plan.label}
</button>
)}
</For>
</div>
<For each={plans}>
{(plan, index) => (
<div
role="tabpanel"
id={`${props.id}-panel-${plan.id}`}
aria-labelledby={`${props.id}-tab-${plan.id}`}
hidden={index() !== 0}
data-plan-panel={plan.id}
>
<SlotScope name={plan.id}>{props.children}</SlotScope>
</div>
)}
</For>
</div>
)
}
@@ -0,0 +1,15 @@
import { createContext, Show, useContext, type JSX } from "solid-js"
// Named slots for MDX components (remark-slots.ts turns `<div slot="go">` into `<Slot name="go">`).
// A component renders its children inside `<SlotScope name="go">` to show only that slot's content.
// Outside any scope, a slot shows its content.
const SlotName = createContext<string>()
export function SlotScope(props: { name: string; children: JSX.Element }) {
return <SlotName.Provider value={props.name}>{props.children}</SlotName.Provider>
}
export function Slot(props: { name: string; children: JSX.Element }) {
const scope = useContext(SlotName)
return <Show when={scope === undefined || scope === props.name}>{props.children}</Show>
}
+3
View File
@@ -0,0 +1,3 @@
// Where the docs are mounted on the site. Docs code builds its URLs from this, and MDX content reads it
// as `import.meta.env.BASE_URL` (vite.ts), as it did when the docs were their own app.
export const base = "/v2/"
+20
View File
@@ -0,0 +1,20 @@
import type { Component } from "solid-js"
import type { Frontmatter } from "./frontmatter"
import { docsSlug } from "./lib/navigation"
import type { DocsHeading } from "./rehype-headings"
/** A compiled content page (vite.ts): its content component, frontmatter, headings and Markdown twin. */
export type DocsModule = {
default: Component<{ components?: Record<string, Component<any>> }>
frontmatter: Frontmatter
headings: DocsHeading[]
markdown: string
}
// Loaded on demand: the Worker serves the prerendered pages, so it never evaluates these at start-up.
const modules = import.meta.glob<DocsModule>("./content/**/*.mdx")
/** Every content page's loader, by slug (`index`, `config`, `cli`, `build/plugins`, …). */
export const docsContent = new Map(
Object.entries(modules).map(([file, load]) => [docsSlug(file.slice("./content/".length)), load]),
)

Some files were not shown because too many files have changed in this diff Show More