mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 01:56:21 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e2eef182 | ||
|
|
282b644cde | ||
|
|
4fee1bcf3c | ||
|
|
da850f18da | ||
|
|
9a716609c6 | ||
|
|
917051a881 | ||
|
|
55f490b898 | ||
|
|
b190b778d4 |
@@ -27,7 +27,6 @@ jobs:
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: canary # Bun 1.4 until its stable release is published
|
||||
bun-version: 1.4.0
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: canary
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-V1G3RuuwtlVx166oDVCqQ9v7ELWRr5JDYitcCMitbNA=",
|
||||
"aarch64-linux": "sha256-nm/N60X7kdkNIa2oCgqyNp9ewrBrWvr6ES0qhxzypQc=",
|
||||
"aarch64-darwin": "sha256-7UQtp4KrtxuT6MMrz7YYcJcF9/b5ECobkSwzI+yLe0Q=",
|
||||
"x86_64-darwin": "sha256-jrn0GOa7qDskowio97bvhl7rsideXqHVbygSi3M1cf0="
|
||||
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
|
||||
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
|
||||
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
|
||||
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,6 +1101,9 @@ export function createData(config: CreateDataInput) {
|
||||
get(sessionID: string) {
|
||||
return store.session.info[sessionID]
|
||||
},
|
||||
creating(sessionID: string) {
|
||||
return creating.has(sessionID)
|
||||
},
|
||||
remember(info: SessionInfo) {
|
||||
setStore("session", "info", info.id, reconcile(info))
|
||||
sync.complete(`session:${info.id}`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "bun:test"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
@@ -72,6 +72,37 @@ test("revalidates after an event overtakes an active session read", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
if (!request.url.endsWith("/api/session")) throw new Error(`Unexpected request: ${request.url}`)
|
||||
await release.promise
|
||||
return Response.json({ data: session(0) })
|
||||
},
|
||||
})
|
||||
const event: CreateDataInput["event"] = {
|
||||
on: () => () => {},
|
||||
listen: () => () => {},
|
||||
}
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
const created = setup.data.session.create({ id: "ses_refresh", location: { directory: "/project" } })
|
||||
expect(setup.data.session.creating(created.id)).toBe(true)
|
||||
release.resolve()
|
||||
await created.request
|
||||
expect(setup.data.session.creating(created.id)).toBe(false)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function wait(check: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export * as LocationActivity from "./location-activity.js"
|
||||
|
||||
import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const isSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
export class Service extends Context.Service<Service, {}>()("@opencode/LocationActivity") {}
|
||||
|
||||
export function layer(options: { readonly timeToLive?: Duration.Input; readonly sweepInterval?: Duration.Input } = {}) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const clock = yield* Clock.Clock
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
|
||||
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
|
||||
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
|
||||
const touch = (ref: Location.Ref) =>
|
||||
Effect.sync(() => {
|
||||
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
|
||||
})
|
||||
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (!isSessionEvent(event)) return Effect.void
|
||||
const location = event.location
|
||||
if (!location) return Effect.void
|
||||
return RcMap.has(locations.rcMap, location).pipe(
|
||||
Effect.flatMap((active) => (active ? touch(location) : Effect.void)),
|
||||
)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
const cached = new Set(refs.map(key))
|
||||
yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const id of entries.keys()) {
|
||||
if (!cached.has(id)) entries.delete(id)
|
||||
}
|
||||
const now = clock.currentTimeMillisUnsafe()
|
||||
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(entry) => {
|
||||
entries.delete(key(entry.ref))
|
||||
return Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
}).pipe(Effect.forever, Effect.forkScoped)
|
||||
|
||||
return Service.of({})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [Bus.node, LocationServiceMap.node],
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { Agent } from "./agent.js"
|
||||
@@ -147,7 +147,7 @@ export function buildLocationServiceMap(
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) },
|
||||
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
|
||||
),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
|
||||
@@ -130,7 +130,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 `packages/www/content/docs/migrate-v1.mdx`.
|
||||
the repository, its source is `packages/www/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
|
||||
|
||||
@@ -3,14 +3,16 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { DateTime, Deferred, Duration, Effect, Equal, Fiber, Hash, Layer, LayerMap, RcMap, Schema, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
@@ -21,6 +23,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
@@ -41,8 +44,55 @@ const itWithSdk = testEffect(
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const activityLocations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref) =>
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
|
||||
}),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
)
|
||||
const itWithActivity = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]),
|
||||
[[LocationServiceMap.node, activityLocations]],
|
||||
),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
itWithActivity.effect("refreshes lifetime from Session events only", () =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const sessionID = Session.ID.make("ses_location_activity")
|
||||
const read = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
|
||||
|
||||
yield* read
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
yield* bus.publish(Catalog.Event.Updated, {}, { location: ref })
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
|
||||
yield* read
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID }, { location: ref })
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, { location: ref })
|
||||
yield* TestClock.adjust("1 minute")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([ref])
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retries a location after its missing directory is recreated", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
@@ -62,6 +63,7 @@ const applicationServiceNodes = [
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
LocationServiceMap.node,
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
] as const
|
||||
const applicationServices = LayerNode.group(applicationServiceNodes)
|
||||
|
||||
@@ -486,6 +486,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
if (route.data.type !== "session") return
|
||||
const session = data.session.get(route.data.sessionID)
|
||||
if (!session) return
|
||||
if (data.session.creating(session.id)) return
|
||||
if (session.location.workspaceID !== undefined || terminalEnvironment.variables === undefined) return
|
||||
void client.api.session
|
||||
.environment({ sessionID: session.id, variables: terminalEnvironment.variables })
|
||||
|
||||
@@ -956,7 +956,7 @@ export function Autocomplete(props: {
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
{" " + option().description?.trimStart()}
|
||||
{" " + option().description?.replace(/\s+/g, " ").trim()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
|
||||
type TabsState = {
|
||||
tabs: SessionTab[]
|
||||
// Read only long enough to remove the former client-owned state from persisted tab files.
|
||||
unread?: Record<string, unknown>
|
||||
// Kept empty for rollback compatibility with clients that still read this field.
|
||||
unread: Record<string, unknown>
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
@@ -43,7 +43,7 @@ type ScrollAnchor = {
|
||||
screenY: number
|
||||
}
|
||||
|
||||
const empty = (): TabsState => ({ tabs: [] })
|
||||
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
||||
|
||||
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
||||
const TAB_PREFETCH_DELAY = 300
|
||||
@@ -128,6 +128,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
|
||||
}, []),
|
||||
unread: {},
|
||||
})
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const newTab = createMemo((open = false) => {
|
||||
@@ -219,7 +220,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
update((draft) => {
|
||||
const next = normalize(draft)
|
||||
draft.tabs = next.tabs
|
||||
delete draft.unread
|
||||
draft.unread = next.unread
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -265,10 +265,10 @@ test("stores session tabs for the current working directory by default", async (
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
await wait(() => Bun.file(file).size > 0)
|
||||
const stored = await Bun.file(file).json()
|
||||
expect(stored.global).toEqual({ tabs: [] })
|
||||
expect(stored.global).toEqual({ tabs: [], unread: {} })
|
||||
expect(Object.keys(stored.cwd)).toEqual([directory])
|
||||
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
|
||||
expect(stored.cwd[directory]).not.toHaveProperty("unread")
|
||||
expect(stored.cwd[directory].unread).toEqual({})
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
@@ -336,14 +336,14 @@ test("acknowledges viewed sessions even when tabs are disabled", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("purges legacy persisted unread records", async () => {
|
||||
test("empties legacy persisted unread records for rollback compatibility", async () => {
|
||||
const setup = await renderSessionTabs("first", { persisted: ["first"] })
|
||||
try {
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
// Normalize rewrites the active scope; the legacy record must not survive it.
|
||||
// Normalize rewrites the active scope; legacy values must not survive, but older clients require the field.
|
||||
await wait(async () => {
|
||||
const stored = await Bun.file(file).json()
|
||||
return !("unread" in stored.cwd[directory])
|
||||
return Object.keys(stored.cwd[directory].unread).length === 0
|
||||
})
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.blume/
|
||||
.astro/
|
||||
.wrangler/
|
||||
dist/
|
||||
node_modules/
|
||||
.blume-verify/
|
||||
|
||||
+8
-12
@@ -1,22 +1,18 @@
|
||||
# Website and documentation guide
|
||||
# Website guide
|
||||
|
||||
## Structure
|
||||
|
||||
- This package owns the `opencode.ai` website.
|
||||
- Add custom marketing routes as Astro files under `pages/`.
|
||||
- The whole project is mounted at `/v2/` by `deployment.base`, and documentation lives under `content/docs/` at `/v2/docs` through `basePath` in `blume.config.ts`.
|
||||
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
|
||||
- Use parenthesized content folders for sidebar groups that must not add a URL segment. Keep ungrouped top-level pages directly under `content/docs/`.
|
||||
- Put static files in `public/` and reference them with root-relative paths.
|
||||
- The API reference is generated from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
|
||||
- Keep documentation aligned with the current packages.
|
||||
- This is a standard Astro website with a fully owned frontend.
|
||||
- Only `base` in `astro.config.ts` should know the deployment subpath. Use `import.meta.env.BASE_URL` everywhere else.
|
||||
- The documentation feature is self-contained under `src/docs/` and renders through the thin route in `src/pages/docs/`.
|
||||
- Do not add a documentation frontend framework; this package owns the UI directly.
|
||||
- Keep the installer route dynamic and `public/openapi.json` generated from `packages/protocol/openapi.json`.
|
||||
|
||||
## Local development
|
||||
|
||||
- Run `bun dev` from this package and preview the site at `http://localhost:3000/v2/`.
|
||||
- Verify both custom marketing routes and documentation routes after changing shared navigation or layout code.
|
||||
- Run `bun dev` from this package and use the local URL printed by Astro.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `bun typecheck`, `bun validate`, and `bun run build` from this package after documentation or configuration changes.
|
||||
- Run `bun typecheck`, `bun run check:generated`, and `bun run build` from this package after changes.
|
||||
- Treat validation and build errors as blockers.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# OpenCode website
|
||||
|
||||
The OpenCode V2 website, powered by Blume and deployed with Wrangler at `https://opencode.ai/v2/`. Blume mounts the documentation at `/v2/docs`, and `https://v2.opencode.ai` redirects to the same deployment.
|
||||
The standard Astro website for OpenCode. The only deployment-specific path is the `base` setting in `astro.config.ts`; application code uses `import.meta.env.BASE_URL`.
|
||||
|
||||
Wrangler deploys the site through Blume's Cloudflare server adapter. Documentation pages are prerendered, while custom dynamic routes and endpoints can run in the Worker. Production uses `opencode-www` at `opencode.ai/v2/`; dev uses `opencode-www-dev` at `dev.opencode.ai/v2/`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
|
||||
- `src/pages/` owns website routes.
|
||||
- `src/docs/` is the self-contained documentation feature rendered under `/docs`.
|
||||
- `/install` proxies the current installer.
|
||||
- `/openapi.json` serves the generated OpenAPI specification.
|
||||
|
||||
The `deploy-www` GitHub workflow deploys the `dev` branch to the dev Worker and the `v2` branch to the production Worker.
|
||||
The deployment currently sets `base: "/v2"`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -14,12 +17,12 @@ From this directory, run:
|
||||
bun dev
|
||||
```
|
||||
|
||||
The site opens at `http://localhost:3000/v2/`; documentation is available at `http://localhost:3000/v2/docs`.
|
||||
The local URL includes the base configured in `astro.config.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bun typecheck
|
||||
bun validate
|
||||
bun run check:generated
|
||||
bun run build
|
||||
```
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import cloudflare from "@astrojs/cloudflare"
|
||||
import mdx from "@astrojs/mdx"
|
||||
import { unified } from "@astrojs/markdown-remark"
|
||||
import { defineConfig } from "astro/config"
|
||||
import remarkDocsLinks from "./src/docs/remark-links"
|
||||
|
||||
const base = "/v2/"
|
||||
|
||||
export default defineConfig({
|
||||
site: process.env.CLOUDFLARE_ENV === "production" ? "https://opencode.ai" : "https://dev.opencode.ai",
|
||||
base,
|
||||
output: "server",
|
||||
adapter: cloudflare({ imageService: "passthrough" }),
|
||||
integrations: [mdx()],
|
||||
markdown: {
|
||||
processor: unified({ remarkPlugins: [[remarkDocsLinks, { base }]] }),
|
||||
shikiConfig: {
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
transformers: [
|
||||
{
|
||||
name: "code-block-title",
|
||||
root(root) {
|
||||
const title = this.options.meta?.__raw?.match(/title="([^"]+)"/)?.[1]
|
||||
if (!title) return
|
||||
const pre = root.children[0]
|
||||
if (!pre || pre.type !== "element") return
|
||||
root.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "figure",
|
||||
properties: { className: ["astro-code-figure"] },
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "figcaption",
|
||||
properties: { className: ["astro-code-title"] },
|
||||
children: [{ type: "text", value: title }],
|
||||
},
|
||||
pre,
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
import { defineConfig } from "blume"
|
||||
|
||||
export default defineConfig({
|
||||
title: "OpenCode",
|
||||
description: "The open source AI coding agent.",
|
||||
basePath: "/docs",
|
||||
logo: {
|
||||
image: {
|
||||
light: "/assets/logo-light.svg",
|
||||
dark: "/assets/logo-dark.svg",
|
||||
alt: "OpenCode",
|
||||
},
|
||||
text: "",
|
||||
href: "/",
|
||||
},
|
||||
content: {
|
||||
root: "content/docs",
|
||||
},
|
||||
github: {
|
||||
owner: "anomalyco",
|
||||
repo: "opencode",
|
||||
branch: "v2",
|
||||
dir: "packages/www",
|
||||
},
|
||||
theme: {
|
||||
background: { dark: "#000000" },
|
||||
fonts: {
|
||||
body: {
|
||||
name: "OpenTUI Mono",
|
||||
fallback: "mono",
|
||||
variants: [
|
||||
{ src: "public/fonts/OpenTUIMono-Regular.woff2", weight: 400 },
|
||||
{ src: "public/fonts/OpenTUIMono-Bold.woff2", weight: 700 },
|
||||
{ src: "public/fonts/OpenTUIMono-Italic.woff2", weight: 400, style: "italic" },
|
||||
{ src: "public/fonts/OpenTUIMono-BoldItalic.woff2", weight: 700, style: "italic" },
|
||||
],
|
||||
},
|
||||
display: {
|
||||
name: "OpenTUI Mono",
|
||||
fallback: "mono",
|
||||
variants: [
|
||||
{ src: "public/fonts/OpenTUIMono-Regular.woff2", weight: 400 },
|
||||
{ src: "public/fonts/OpenTUIMono-Bold.woff2", weight: 700 },
|
||||
{ src: "public/fonts/OpenTUIMono-Italic.woff2", weight: 400, style: "italic" },
|
||||
{ src: "public/fonts/OpenTUIMono-BoldItalic.woff2", weight: 700, style: "italic" },
|
||||
],
|
||||
},
|
||||
mono: {
|
||||
name: "OpenTUI Mono",
|
||||
fallback: "mono",
|
||||
variants: [
|
||||
{ src: "public/fonts/OpenTUIMono-Regular.woff2", weight: 400 },
|
||||
{ src: "public/fonts/OpenTUIMono-Bold.woff2", weight: 700 },
|
||||
{ src: "public/fonts/OpenTUIMono-Italic.woff2", weight: 400, style: "italic" },
|
||||
{ src: "public/fonts/OpenTUIMono-BoldItalic.woff2", weight: 700, style: "italic" },
|
||||
],
|
||||
},
|
||||
},
|
||||
mode: "dark",
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{ label: "Docs", path: "/" },
|
||||
{ label: "CLI", path: "/cli" },
|
||||
{ label: "Build", path: "/build" },
|
||||
{ label: "API", path: "/api" },
|
||||
],
|
||||
},
|
||||
markdown: {
|
||||
code: {
|
||||
icons: false,
|
||||
},
|
||||
},
|
||||
openapi: {
|
||||
enabled: true,
|
||||
route: "/api",
|
||||
spec: "./openapi.json",
|
||||
},
|
||||
seo: {
|
||||
og: {
|
||||
fonts: [{ name: "IBM Plex Mono", weight: [400, 600] }],
|
||||
logo: "public/assets/logo-dark.svg",
|
||||
palette: {
|
||||
accent: "#b7b1b1",
|
||||
background: "#131010",
|
||||
border: "#343030",
|
||||
foreground: "#f1ecec",
|
||||
muted: "#b7b1b1",
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment: {
|
||||
adapter: "cloudflare",
|
||||
base: "/v2/",
|
||||
output: "server",
|
||||
site: process.env.BLUME_ENV === "dev" ? "https://dev.opencode.ai" : "https://opencode.ai",
|
||||
},
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineComponents } from "blume"
|
||||
import Breadcrumbs from "./components/Breadcrumbs.astro"
|
||||
import ThemeTokens from "./snippets/generated/theme-tokens.mdx"
|
||||
|
||||
export default defineComponents({
|
||||
layout: {
|
||||
Breadcrumbs,
|
||||
},
|
||||
mdx: {
|
||||
ThemeTokens,
|
||||
},
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
---
|
||||
---
|
||||
@@ -1,24 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Configure",
|
||||
pages: [
|
||||
"lsp",
|
||||
"agents",
|
||||
"models",
|
||||
"skills",
|
||||
"themes",
|
||||
"commands",
|
||||
"providers",
|
||||
"snapshots",
|
||||
"compaction",
|
||||
"formatters",
|
||||
"references",
|
||||
"attachments",
|
||||
"mcp-servers",
|
||||
"permissions",
|
||||
"instructions",
|
||||
"sharing",
|
||||
"warming",
|
||||
],
|
||||
})
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Build",
|
||||
pages: ["sdk", "index", "client", "plugins"],
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Configure",
|
||||
pages: ["theme", "plugins", "keybinds"],
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Intro",
|
||||
pages: ["index", "config", "configure", "providers"],
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
pages: ["cli", "build", "index", "config", "configure", "migrate-v1", "troubleshooting"],
|
||||
})
|
||||
+12
-12
@@ -4,25 +4,25 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run generate && blume dev --host --port 3000",
|
||||
"build": "bun run generate && blume build && bun script/prepare-cloudflare.ts",
|
||||
"dev": "bun run generate && astro dev --host --port 3000",
|
||||
"build": "bun run generate && astro build && bun script/pagefind.ts && bun run validate && bun script/prepare-cloudflare.ts",
|
||||
"deploy": "wrangler deploy --config dist/server/wrangler.json",
|
||||
"generate": "bun script/generate-theme-tokens.ts && bun script/generate-openapi.ts",
|
||||
"check:generated": "bun script/generate-theme-tokens.ts --check && bun script/generate-openapi.ts --check",
|
||||
"typecheck": "blume check",
|
||||
"validate": "blume validate",
|
||||
"doctor": "blume doctor"
|
||||
"generate": "bun script/generate-openapi.ts",
|
||||
"check:generated": "bun script/generate-openapi.ts --check",
|
||||
"validate": "bun script/validate-links.ts",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"blume": "1.5.1"
|
||||
"@astrojs/mdx": "7.0.5",
|
||||
"astro": "7.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/cloudflare": "14.1.4",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@astrojs/check": "0.9.6",
|
||||
"@astrojs/markdown-remark": "7.2.4",
|
||||
"@types/bun": "catalog:",
|
||||
"astro": "7.1.3",
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"pagefind": "1.5.2",
|
||||
"typescript": "catalog:",
|
||||
"wrangler": "4.110.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
export const prerender = false
|
||||
|
||||
return Astro.redirect(`${import.meta.env.BASE_URL}docs`, 308)
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
|
||||
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Schema, SchemaAST } from "effect"
|
||||
import { format } from "prettier"
|
||||
import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
|
||||
|
||||
const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
|
||||
const root = requireObject(ThemeDefinition.ast)
|
||||
const hue = requireObject(requireField(root, "hue").type)
|
||||
const hueNames = hue.propertySignatures.map((field) => String(field.name))
|
||||
const hueSteps = requireObject(requireField(hue, hueNames[0]).type).propertySignatures.map((field) =>
|
||||
String(field.name),
|
||||
)
|
||||
const contexts = root.propertySignatures
|
||||
.map((field) => String(field.name))
|
||||
.filter((name) => name.startsWith("@context:"))
|
||||
const tokens = root.propertySignatures
|
||||
.filter((field) => {
|
||||
const name = String(field.name)
|
||||
return name !== "hue" && name !== "categorical" && !name.startsWith("@context:")
|
||||
})
|
||||
.flatMap((field) => tokenPaths(field.type, String(field.name)))
|
||||
const groups = Map.groupBy(tokens, (token) =>
|
||||
token
|
||||
.split(".")
|
||||
.slice(0, token.split(".").length > 2 ? 2 : 1)
|
||||
.join("."),
|
||||
)
|
||||
const table = [...groups]
|
||||
.map(([group, values]) => `| \`${group}\` | ${values.map((value) => `\`${value}\``).join("<br />")} |`)
|
||||
.join("\n")
|
||||
const example = {
|
||||
version: 2,
|
||||
light: {
|
||||
hue: {
|
||||
accent: "$hue.purple",
|
||||
interactive: "$hue.purple",
|
||||
},
|
||||
text: {
|
||||
default: "$hue.neutral.900",
|
||||
},
|
||||
background: {
|
||||
default: "#fafafa",
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
mergeMode: true,
|
||||
text: {
|
||||
default: "$hue.neutral.100",
|
||||
},
|
||||
background: {
|
||||
default: "#101014",
|
||||
},
|
||||
},
|
||||
} satisfies ThemeDocument
|
||||
Schema.decodeUnknownSync(ThemeDocument)(example)
|
||||
const output = await format(
|
||||
`{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
|
||||
|
||||
\`\`\`json title="my-theme.json"
|
||||
${JSON.stringify(example, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Token reference
|
||||
|
||||
This reference is generated from the Effect schema in
|
||||
\`@opencode-ai/theme/tui\`. Changes to the runtime schema update
|
||||
this section through \`bun run generate\`.
|
||||
|
||||
### Hue tokens
|
||||
|
||||
Every hue is a ${hueSteps.length}-step scale. Define a scale with all of these
|
||||
steps, or alias it to another hue with a value such as \`$hue.blue\`.
|
||||
|
||||
| | Values |
|
||||
| --- | --- |
|
||||
| Hues | ${hueNames.map((name) => `\`${name}\``).join(", ")} |
|
||||
| Steps | ${hueSteps.map((step) => `\`${step}\``).join(", ")} |
|
||||
|
||||
Reference a hue color as \`$hue.<name>.<step>\`, for example
|
||||
\`$hue.interactive.500\`.
|
||||
|
||||
### Semantic tokens
|
||||
|
||||
Semantic values can reference another token by prefixing its path with \`$\`,
|
||||
for example \`$text.default\`. Stateful tokens inherit their \`default\`
|
||||
value when a state is omitted.
|
||||
|
||||
| Group | Tokens |
|
||||
| --- | --- |
|
||||
${table}
|
||||
|
||||
### Contexts
|
||||
|
||||
${contexts.map((context) => `\`${context}\``).join(" and ")} accept partial
|
||||
overrides of the semantic tokens above. Components apply these contexts to
|
||||
surfaces that need different contrast without changing the base theme.
|
||||
`,
|
||||
{ parser: "mdx", printWidth: 120, semi: false },
|
||||
)
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = await Bun.file(target).text()
|
||||
if (current === output) process.exit(0)
|
||||
console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/www.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Bun.write(target, output)
|
||||
|
||||
function requireObject(ast: SchemaAST.AST): SchemaAST.Objects {
|
||||
if (SchemaAST.isObjects(ast)) return ast
|
||||
if (SchemaAST.isUnion(ast)) {
|
||||
const object = ast.types.map(findObject).find((value) => value !== undefined)
|
||||
if (object) return object
|
||||
}
|
||||
throw new Error(`Expected an object schema, received ${ast._tag}`)
|
||||
}
|
||||
|
||||
function findObject(ast: SchemaAST.AST): SchemaAST.Objects | undefined {
|
||||
if (SchemaAST.isObjects(ast)) return ast
|
||||
if (SchemaAST.isUnion(ast)) return ast.types.map(findObject).find((value) => value !== undefined)
|
||||
if (SchemaAST.isSuspend(ast)) return findObject(ast.thunk())
|
||||
}
|
||||
|
||||
function requireField(ast: SchemaAST.Objects, name: string) {
|
||||
const field = ast.propertySignatures.find((field) => String(field.name) === name)
|
||||
if (field) return field
|
||||
throw new Error(`Theme schema field not found: ${name}`)
|
||||
}
|
||||
|
||||
function tokenPaths(ast: SchemaAST.AST, prefix: string): string[] {
|
||||
const object = findObject(ast)
|
||||
if (!object || object.propertySignatures.length === 0) return [prefix]
|
||||
return object.propertySignatures.flatMap((field) => tokenPaths(field.type, `${prefix}.${String(field.name)}`))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import config from "../astro.config"
|
||||
|
||||
const base = config.base?.replace(/\/$/, "") ?? ""
|
||||
|
||||
await Bun.$`pagefind --site ${`dist/client${base}/docs`}`
|
||||
@@ -0,0 +1,39 @@
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../dist/client")
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.html").scan({ cwd: root, absolute: true }))
|
||||
const failures = (
|
||||
await Promise.all(
|
||||
files.flatMap(async (file) => {
|
||||
const html = await Bun.file(file).text()
|
||||
const page = `https://opencode.local/${path.relative(root, file).replace(/index\.html$/, "")}`
|
||||
return Promise.all(
|
||||
Array.from(html.matchAll(/href="([^"]+)"/g), async (match) => {
|
||||
const url = new URL(match[1], page)
|
||||
if (url.origin !== "https://opencode.local") return
|
||||
|
||||
const targetPath = path.join(root, decodeURIComponent(url.pathname))
|
||||
const target = (
|
||||
await Promise.all(
|
||||
[targetPath, path.join(targetPath, "index.html"), `${targetPath}.html`].map(async (candidate) =>
|
||||
(await Bun.file(candidate).exists()) ? candidate : undefined,
|
||||
),
|
||||
)
|
||||
).find((candidate) => candidate !== undefined)
|
||||
if (!target) return `${path.relative(root, file)}: missing ${url.pathname}`
|
||||
if (!url.hash || !target.endsWith(".html")) return
|
||||
|
||||
const id = decodeURIComponent(url.hash.slice(1))
|
||||
if ((await Bun.file(target).text()).includes(`id="${id}"`)) return
|
||||
return `${path.relative(root, file)}: missing ${url.pathname}${url.hash}`
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat(2)
|
||||
.filter((failure) => failure !== undefined)
|
||||
|
||||
if (failures.length === 0) process.exit(0)
|
||||
console.error([...new Set(failures)].sort().join("\n"))
|
||||
process.exit(1)
|
||||
@@ -1,79 +0,0 @@
|
||||
{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
|
||||
|
||||
```json title="my-theme.json"
|
||||
{
|
||||
"version": 2,
|
||||
"light": {
|
||||
"hue": {
|
||||
"accent": "$hue.purple",
|
||||
"interactive": "$hue.purple"
|
||||
},
|
||||
"text": {
|
||||
"default": "$hue.neutral.900"
|
||||
},
|
||||
"background": {
|
||||
"default": "#fafafa"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"mergeMode": true,
|
||||
"text": {
|
||||
"default": "$hue.neutral.100"
|
||||
},
|
||||
"background": {
|
||||
"default": "#101014"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Token reference
|
||||
|
||||
This reference is generated from the Effect schema in
|
||||
`@opencode-ai/theme/tui`. Changes to the runtime schema update
|
||||
this section through `bun run generate`.
|
||||
|
||||
### Hue tokens
|
||||
|
||||
Every hue is a 9-step scale. Define a scale with all of these
|
||||
steps, or alias it to another hue with a value such as `$hue.blue`.
|
||||
|
||||
| | Values |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Hues | `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `accent`, `interactive`, `neutral` |
|
||||
| Steps | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` |
|
||||
|
||||
Reference a hue color as `$hue.<name>.<step>`, for example
|
||||
`$hue.interactive.500`.
|
||||
|
||||
### Semantic tokens
|
||||
|
||||
Semantic values can reference another token by prefixing its path with `$`,
|
||||
for example `$text.default`. Stateful tokens inherit their `default`
|
||||
value when a state is omitted.
|
||||
|
||||
| Group | Tokens |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `text` | `text.default`<br />`text.subdued` |
|
||||
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.secondary.default`<br />`text.action.secondary.$hovered`<br />`text.action.secondary.$focused`<br />`text.action.secondary.$pressed`<br />`text.action.secondary.$selected`<br />`text.action.secondary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
|
||||
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
|
||||
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
|
||||
| `background` | `background.default` |
|
||||
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
|
||||
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.secondary.default`<br />`background.action.secondary.$hovered`<br />`background.action.secondary.$focused`<br />`background.action.secondary.$pressed`<br />`background.action.secondary.$selected`<br />`background.action.secondary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
|
||||
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
|
||||
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
|
||||
| `border` | `border.default` |
|
||||
| `scrollbar` | `scrollbar.default` |
|
||||
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
|
||||
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
|
||||
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
|
||||
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
|
||||
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
|
||||
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
|
||||
|
||||
### Contexts
|
||||
|
||||
`@context:elevated` and `@context:overlay` accept partial
|
||||
overrides of the semantic tokens above. Components apply these contexts to
|
||||
surfaces that need different contrast without changing the base theme.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineCollection } from "astro:content"
|
||||
import { glob } from "astro/loaders"
|
||||
import { z } from "astro/zod"
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
loader: glob({
|
||||
base: "./src/docs/content",
|
||||
pattern: "**/*.{md,mdx}",
|
||||
}),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
tableOfContents: z
|
||||
.union([
|
||||
z.boolean(),
|
||||
z.object({
|
||||
minHeadingLevel: z.number().int().min(1).max(6).optional(),
|
||||
maxHeadingLevel: z.number().int().min(1).max(6).optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Documentation feature guide
|
||||
|
||||
## Structure
|
||||
|
||||
- This folder owns the documentation data model, content, frontend, and styling.
|
||||
- Documentation content lives in `content/` and is loaded by `../content.config.ts`.
|
||||
- `lib/navigation.ts` is the source of truth for header sections and sidebar ordering.
|
||||
- The thin route entrypoints live in `../pages/docs/`; keep docs implementation details here.
|
||||
- Do not add a documentation frontend framework; this folder owns the UI directly.
|
||||
- Keep internal Markdown links docs-root-relative, for example `/config`; `remark-links.ts` applies the site and docs base paths.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `bun typecheck` and `bun run build` from `packages/www` after changes.
|
||||
- Check desktop and mobile layouts when changing navigation or shared styles.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
interface Props {
|
||||
type?: "note" | "tip" | "warning"
|
||||
title?: string
|
||||
}
|
||||
|
||||
const type = Astro.props.type ?? "note"
|
||||
const title = Astro.props.title ?? (type === "tip" ? "Tip" : type === "warning" ? "Warning" : "Note")
|
||||
---
|
||||
|
||||
<aside class={`callout callout-${type}`} aria-label={title}>
|
||||
<strong>{title}</strong>
|
||||
<div><slot /></div>
|
||||
</aside>
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const href = Astro.props.href.startsWith("/")
|
||||
? `${import.meta.env.BASE_URL}docs${Astro.props.href}`
|
||||
: Astro.props.href
|
||||
---
|
||||
|
||||
<a class="docs-card" href={href}>
|
||||
<strong>{Astro.props.title}</strong>
|
||||
<div><slot /></div>
|
||||
</a>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
interface Props {
|
||||
cols?: number
|
||||
}
|
||||
---
|
||||
|
||||
<div class="docs-card-group" style={`--card-columns: ${Astro.props.cols ?? 2}`}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
code: string
|
||||
}
|
||||
---
|
||||
|
||||
<div class="docs-code-block">
|
||||
<pre><code>{Astro.props.code}</code></pre>
|
||||
<button type="button" data-copy-code data-code={Astro.props.code}>Copy</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("click", async (event) => {
|
||||
const button = event.target
|
||||
if (!(button instanceof HTMLButtonElement) || !button.matches("[data-copy-code]")) return
|
||||
await navigator.clipboard.writeText(button.dataset.code ?? "")
|
||||
button.textContent = "Copied"
|
||||
window.setTimeout(() => (button.textContent = "Copy"), 1500)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
import { docsHref, docsSections } from "../lib/navigation"
|
||||
|
||||
interface Props {
|
||||
currentSlug: string
|
||||
}
|
||||
|
||||
const currentSlug = Astro.props.currentSlug
|
||||
const searchEnabled = import.meta.env.PROD
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<a class="brand" href={import.meta.env.BASE_URL} aria-label="OpenCode home">
|
||||
<img src={`${import.meta.env.BASE_URL}assets/logo-dark.svg`} alt="OpenCode" />
|
||||
</a>
|
||||
<nav aria-label="Documentation sections">
|
||||
{
|
||||
docsSections.map((section) => (
|
||||
<a href={docsHref(section.landingSlug)} aria-current={currentSlug === section.key || currentSlug.startsWith(`${section.key}/`) || (section.key === "docs" && !currentSlug.includes("/") && currentSlug !== "api") ? "page" : undefined}>
|
||||
{section.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
<div class="header-spacer"></div>
|
||||
{
|
||||
searchEnabled && (
|
||||
<button class="search-trigger" type="button" data-search-open aria-label="Search documentation">
|
||||
<span>Search</span><kbd>⌘K</kbd>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<a class="github-link" href="https://github.com/anomalyco/opencode" aria-label="OpenCode on GitHub">GitHub</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{
|
||||
searchEnabled && (
|
||||
<dialog
|
||||
class="search-dialog"
|
||||
data-search-dialog
|
||||
data-pagefind-path={`${import.meta.env.BASE_URL}docs/pagefind/pagefind.js`}
|
||||
data-base-url={`${import.meta.env.BASE_URL}docs/`}
|
||||
aria-label="Search documentation"
|
||||
>
|
||||
<form method="dialog" class="search-bar">
|
||||
<span aria-hidden="true">></span>
|
||||
<input data-search-input type="search" placeholder="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>
|
||||
)
|
||||
}
|
||||
|
||||
<script is:inline>
|
||||
let pagefind
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault()
|
||||
const dialog = document.querySelector("[data-search-dialog]")
|
||||
dialog?.showModal()
|
||||
dialog?.querySelector("[data-search-input]")?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const setupSearch = () => {
|
||||
const dialog = document.querySelector("[data-search-dialog]")
|
||||
const input = document.querySelector("[data-search-input]")
|
||||
const results = document.querySelector("[data-search-results]")
|
||||
const trigger = document.querySelector("[data-search-open]")
|
||||
if (trigger && !trigger.dataset.searchReady) {
|
||||
trigger.dataset.searchReady = "true"
|
||||
trigger.addEventListener("click", () => {
|
||||
dialog?.showModal()
|
||||
input?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
if (input && results && !input.dataset.searchReady) input.addEventListener("input", async () => {
|
||||
input.dataset.searchReady = "true"
|
||||
const query = input.value.trim()
|
||||
if (!query) {
|
||||
results.innerHTML = "<p>Start typing to search the documentation.</p>"
|
||||
return
|
||||
}
|
||||
|
||||
const pagefindPath = dialog?.dataset.pagefindPath
|
||||
if (!pagefindPath) return
|
||||
const api = pagefind ?? (pagefind = await import(pagefindPath))
|
||||
const search = await api.search(query)
|
||||
const pages = await Promise.all(search.results.slice(0, 8).map((result) => result.data()))
|
||||
results.replaceChildren(
|
||||
...pages.map((page) => {
|
||||
const link = document.createElement("a")
|
||||
const base = dialog.dataset.baseUrl ?? "/"
|
||||
link.href = page.url.startsWith(base)
|
||||
? page.url
|
||||
: `${dialog.dataset.baseUrl?.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>"
|
||||
})
|
||||
}
|
||||
|
||||
setupSearch()
|
||||
document.addEventListener("astro:page-load", setupSearch)
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import { docsHref, type DocsNavGroup } from "../lib/navigation"
|
||||
|
||||
interface Props {
|
||||
groups: DocsNavGroup[]
|
||||
currentSlug: string
|
||||
}
|
||||
---
|
||||
|
||||
<nav class="docs-sidebar" aria-label="Documentation">
|
||||
{
|
||||
Astro.props.groups.map((group) => (
|
||||
<section>
|
||||
{group.title && <h2>{group.title}</h2>}
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li>
|
||||
<a href={docsHref(item.slug)} aria-current={item.slug === Astro.props.currentSlug ? "page" : undefined}>
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import type { MarkdownHeading } from "astro"
|
||||
|
||||
interface Props {
|
||||
headings: MarkdownHeading[]
|
||||
}
|
||||
|
||||
const headings = Astro.props.headings.filter((heading) => heading.depth === 2 || heading.depth === 3)
|
||||
---
|
||||
|
||||
{
|
||||
headings.length > 0 && (
|
||||
<nav class="docs-toc" aria-label="On this page">
|
||||
<h2>On this page</h2>
|
||||
<ul>
|
||||
{headings.map((heading) => (
|
||||
<li class={heading.depth === 3 ? "nested" : undefined}>
|
||||
<a href={`#${heading.slug}`}>{heading.text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
import type { MarkdownHeading } from "astro"
|
||||
import { ClientRouter } from "astro:transitions"
|
||||
import DocsHeader from "../components/DocsHeader.astro"
|
||||
import DocsSidebar from "../components/DocsSidebar.astro"
|
||||
import DocsTableOfContents from "../components/DocsTableOfContents.astro"
|
||||
import { getDocsSection } from "../lib/navigation"
|
||||
import "../styles/global.css"
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
currentSlug: string
|
||||
headings: MarkdownHeading[]
|
||||
showTableOfContents?: boolean
|
||||
}
|
||||
|
||||
const section = getDocsSection(Astro.props.currentSlug)
|
||||
const description = Astro.props.description ?? `${Astro.props.title} documentation for OpenCode.`
|
||||
const canonical = new URL(Astro.url.pathname, "https://opencode.ai").href
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<ClientRouter fallback="swap" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="icon" type="image/svg+xml" href={`${import.meta.env.BASE_URL}favicon.svg`} />
|
||||
<title>{Astro.props.title} | OpenCode</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#docs-content">Skip to content</a>
|
||||
<DocsHeader currentSlug={Astro.props.currentSlug} />
|
||||
<details class="mobile-navigation">
|
||||
<summary>{section.title} navigation</summary>
|
||||
<DocsSidebar groups={section.groups} currentSlug={Astro.props.currentSlug} />
|
||||
</details>
|
||||
<div class="docs-frame">
|
||||
<aside class="sidebar-column">
|
||||
<DocsSidebar groups={section.groups} currentSlug={Astro.props.currentSlug} />
|
||||
</aside>
|
||||
<main id="docs-content">
|
||||
<article class="prose" data-pagefind-body data-pagefind-meta={`title:${Astro.props.title}`}>
|
||||
<h1>{Astro.props.title}</h1>
|
||||
<slot />
|
||||
</article>
|
||||
</main>
|
||||
<aside class="toc-column">
|
||||
{Astro.props.showTableOfContents !== false && <DocsTableOfContents headings={Astro.props.headings} />}
|
||||
</aside>
|
||||
</div>
|
||||
<script is:inline>
|
||||
const addHeadingAnchors = () => {
|
||||
for (const heading of document.querySelectorAll(".prose :is(h2, h3)[id]")) {
|
||||
if (heading.querySelector(".heading-anchor")) continue
|
||||
const anchor = document.createElement("a")
|
||||
anchor.className = "heading-anchor"
|
||||
anchor.href = `#${heading.id}`
|
||||
anchor.setAttribute("aria-label", `Link to ${heading.textContent ?? "section"}`)
|
||||
anchor.textContent = "#"
|
||||
heading.append(anchor)
|
||||
}
|
||||
}
|
||||
|
||||
addHeadingAnchors()
|
||||
document.addEventListener("astro:page-load", addHeadingAnchors)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,124 @@
|
||||
export interface DocsNavItem {
|
||||
title: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface DocsNavGroup {
|
||||
title?: string
|
||||
items: DocsNavItem[]
|
||||
}
|
||||
|
||||
export interface DocsSection {
|
||||
key: "docs" | "cli" | "build" | "api"
|
||||
title: string
|
||||
landingSlug: string
|
||||
groups: DocsNavGroup[]
|
||||
}
|
||||
|
||||
export const docsSections: DocsSection[] = [
|
||||
{
|
||||
key: "docs",
|
||||
title: "Docs",
|
||||
landingSlug: "index",
|
||||
groups: [
|
||||
{
|
||||
items: [
|
||||
{ title: "Intro", slug: "index" },
|
||||
{ title: "Config", slug: "config" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Configure",
|
||||
items: [
|
||||
{ title: "LSP", slug: "lsp" },
|
||||
{ title: "Agents", slug: "agents" },
|
||||
{ title: "Models", slug: "models" },
|
||||
{ title: "Skills", slug: "skills" },
|
||||
{ title: "Themes", slug: "themes" },
|
||||
{ title: "Commands", slug: "commands" },
|
||||
{ title: "Providers", slug: "providers" },
|
||||
{ title: "Snapshots", slug: "snapshots" },
|
||||
{ title: "Compaction", slug: "compaction" },
|
||||
{ title: "Formatters", slug: "formatters" },
|
||||
{ title: "References", slug: "references" },
|
||||
{ title: "Attachments", slug: "attachments" },
|
||||
{ title: "MCP servers", slug: "mcp-servers" },
|
||||
{ title: "Permissions", slug: "permissions" },
|
||||
{ title: "Instructions", slug: "instructions" },
|
||||
{ title: "Session sharing", slug: "sharing" },
|
||||
{ title: "Session warming", slug: "warming" },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ title: "Migrate from V1", slug: "migrate-v1" },
|
||||
{ title: "Troubleshooting", slug: "troubleshooting" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cli",
|
||||
title: "CLI",
|
||||
landingSlug: "cli/index",
|
||||
groups: [
|
||||
{
|
||||
title: "Intro",
|
||||
items: [
|
||||
{ title: "Intro", slug: "cli/index" },
|
||||
{ title: "Config", slug: "cli/config" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Configure",
|
||||
items: [
|
||||
{ title: "Theme", slug: "cli/theme" },
|
||||
{ title: "Plugins", slug: "cli/plugins" },
|
||||
{ title: "Keybinds", slug: "cli/keybinds" },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [{ title: "Providers", slug: "cli/providers" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "build",
|
||||
title: "Build",
|
||||
landingSlug: "build/index",
|
||||
groups: [
|
||||
{
|
||||
title: "Build",
|
||||
items: [
|
||||
{ title: "SDK", slug: "build/sdk" },
|
||||
{ title: "Build", slug: "build/index" },
|
||||
{ title: "Client", slug: "build/client" },
|
||||
{ title: "Plugins", slug: "build/plugins" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
title: "API",
|
||||
landingSlug: "api",
|
||||
groups: [
|
||||
{
|
||||
title: "API",
|
||||
items: [{ title: "Overview", slug: "api" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function docsHref(slug: string, anchor?: string) {
|
||||
const path = slug === "index" ? "" : `${slug.replace(/\/index$/, "")}/`
|
||||
return `${import.meta.env.BASE_URL}docs/${path}${anchor ? `#${anchor}` : ""}`
|
||||
}
|
||||
|
||||
export function getDocsSection(slug: string) {
|
||||
return (
|
||||
docsSections.find((section) => section.groups.some((group) => group.items.some((item) => item.slug === slug))) ??
|
||||
docsSections[0]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface MarkdownNode {
|
||||
type: string
|
||||
url?: string
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
export default function remarkDocsLinks(options: { base: string }) {
|
||||
const docsBase = `${options.base.replace(/\/$/, "")}/docs`
|
||||
|
||||
return (tree: MarkdownNode) => {
|
||||
const visit = (node: MarkdownNode) => {
|
||||
if (node.type === "link" && node.url?.startsWith("/")) node.url = `${docsBase}${node.url}`
|
||||
node.children?.forEach(visit)
|
||||
}
|
||||
|
||||
visit(tree)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
@font-face {
|
||||
font-family: "OpenTUI Mono";
|
||||
src: url("../assets/fonts/OpenTUIMono-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "OpenTUI Mono";
|
||||
src: url("../assets/fonts/OpenTUIMono-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "OpenTUI Mono";
|
||||
src: url("../assets/fonts/OpenTUIMono-Italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "OpenTUI Mono";
|
||||
src: url("../assets/fonts/OpenTUIMono-BoldItalic.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #000;
|
||||
--foreground: #ededed;
|
||||
--muted: #929292;
|
||||
--border: #303030;
|
||||
--surface: #101010;
|
||||
--surface-hover: #181818;
|
||||
--link: #4d8eff;
|
||||
--header-height: 3.875rem;
|
||||
--frame-width: 76rem;
|
||||
--content-width: 44rem;
|
||||
--gutter: 1.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: "OpenTUI Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: -4rem;
|
||||
left: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--background) 82%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
width: min(100%, var(--frame-width));
|
||||
height: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
display: block;
|
||||
width: 7.75rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.site-header nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.site-header nav a {
|
||||
padding-block: 0.25rem;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-header nav a:hover,
|
||||
.site-header nav a[aria-current="page"] {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.site-header nav a[aria-current="page"] {
|
||||
border-bottom: 1px solid;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-trigger {
|
||||
display: inline-flex;
|
||||
min-width: 9.5rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-trigger:hover {
|
||||
border-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.search-trigger kbd {
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.docs-frame {
|
||||
display: grid;
|
||||
width: min(100%, var(--frame-width));
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
margin-inline: auto;
|
||||
grid-template-columns: 15rem minmax(0, var(--content-width)) minmax(12rem, 1fr);
|
||||
}
|
||||
|
||||
.sidebar-column {
|
||||
padding: 2.25rem 2rem 3rem var(--gutter);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.docs-sidebar section + section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.docs-sidebar h2,
|
||||
.docs-toc h2 {
|
||||
margin: 0 0 0.625rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.docs-sidebar ul,
|
||||
.docs-toc ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.docs-sidebar li + li,
|
||||
.docs-toc li + li {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.docs-sidebar a,
|
||||
.docs-toc a {
|
||||
display: block;
|
||||
padding-block: 0.125rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.docs-sidebar a:hover,
|
||||
.docs-sidebar a[aria-current="page"],
|
||||
.docs-toc a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.docs-sidebar a[aria-current="page"] {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
main {
|
||||
min-width: 0;
|
||||
padding: 3rem clamp(2rem, 4vw, 4rem) 5rem;
|
||||
}
|
||||
|
||||
.toc-column {
|
||||
padding: 3rem var(--gutter) 3rem 2rem;
|
||||
}
|
||||
|
||||
.docs-toc {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + 2rem);
|
||||
}
|
||||
|
||||
.docs-toc .nested {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.mobile-navigation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prose {
|
||||
width: 100%;
|
||||
max-width: var(--content-width);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
color: var(--foreground);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
margin: 0 0 3rem;
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin: 3rem 0 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
margin: 2.25rem 0 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
margin: 2rem 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.prose :is(h2, h3)[id] {
|
||||
scroll-margin-top: calc(var(--header-height) + 1rem);
|
||||
}
|
||||
|
||||
.heading-anchor {
|
||||
margin-left: 0.75ch;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.prose :is(h2, h3):hover .heading-anchor,
|
||||
.heading-anchor:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.prose p,
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.prose li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.prose code {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.prose :not(pre) > code {
|
||||
padding: 0.0625rem 0.4ch;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
max-width: 100%;
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--background) !important;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose pre span {
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
|
||||
.astro-code-figure {
|
||||
margin: 0 0 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.astro-code-title {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.astro-code-figure pre {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin: 0 0 1.5rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--muted);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
margin: 3rem 0 1.5rem;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.prose th,
|
||||
.prose td {
|
||||
padding: 0.5rem 1rem 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.callout > strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.callout > div > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.callout-tip {
|
||||
border-color: #28503a;
|
||||
background: #0b1b11;
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-color: #62471f;
|
||||
background: #1c1408;
|
||||
}
|
||||
|
||||
.docs-card-group {
|
||||
display: grid;
|
||||
margin-bottom: 1.5rem;
|
||||
grid-template-columns: repeat(var(--card-columns), minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.docs-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.docs-card:hover {
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.docs-card strong {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.docs-card div {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.docs-card div > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.docs-code-block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docs-code-block button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.search-dialog {
|
||||
width: min(42rem, calc(100% - 2rem));
|
||||
max-height: min(36rem, calc(100vh - 4rem));
|
||||
margin: 10vh auto auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--background) 92%, transparent);
|
||||
color: var(--foreground);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.search-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.72);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-height: 28rem;
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-results > p {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-results a {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.search-results a:hover {
|
||||
background: var(--surface-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.search-results strong,
|
||||
.search-results span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-results span {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.docs-frame {
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.toc-column {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
:root {
|
||||
--gutter: 1rem;
|
||||
}
|
||||
|
||||
.site-header nav,
|
||||
.github-link,
|
||||
.search-trigger span,
|
||||
.search-trigger kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-trigger {
|
||||
min-width: 2.25rem;
|
||||
}
|
||||
|
||||
.search-trigger::before {
|
||||
content: ">";
|
||||
}
|
||||
|
||||
.mobile-navigation {
|
||||
display: block;
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: var(--header-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.mobile-navigation summary {
|
||||
padding: 0.75rem var(--gutter);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobile-navigation .docs-sidebar {
|
||||
max-height: calc(100vh - 8rem);
|
||||
padding: 0.75rem var(--gutter) 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.docs-frame {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-column {
|
||||
display: none;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem var(--gutter) 4rem;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.docs-card-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import { getCollection, render, type CollectionEntry } from "astro:content"
|
||||
import Callout from "../../docs/components/Callout.astro"
|
||||
import Card from "../../docs/components/Card.astro"
|
||||
import CardGroup from "../../docs/components/CardGroup.astro"
|
||||
import CodeBlock from "../../docs/components/CodeBlock.astro"
|
||||
import DocsLayout from "../../docs/layouts/DocsLayout.astro"
|
||||
|
||||
interface Props {
|
||||
entry: CollectionEntry<"docs">
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return (await getCollection("docs")).map((entry) => ({
|
||||
params: {
|
||||
slug: entry.id === "index" ? undefined : entry.id.replace(/\/index$/, ""),
|
||||
},
|
||||
props: { entry },
|
||||
}))
|
||||
}
|
||||
|
||||
export const prerender = true
|
||||
|
||||
const entry = Astro.props.entry
|
||||
const rendered = await render(entry)
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
currentSlug={entry.id}
|
||||
headings={rendered.headings}
|
||||
showTableOfContents={entry.data.tableOfContents !== false}
|
||||
>
|
||||
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock }} />
|
||||
</DocsLayout>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import DocsLayout from "../../../docs/layouts/DocsLayout.astro"
|
||||
|
||||
export const prerender = true
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
title="API"
|
||||
description="OpenCode HTTP API reference and OpenAPI specification."
|
||||
currentSlug="api"
|
||||
headings={[]}
|
||||
showTableOfContents={false}
|
||||
>
|
||||
<p>
|
||||
OpenCode exposes an HTTP API from its server. Use <code>opencode2 api</code> for authenticated local requests, or
|
||||
build against the generated OpenAPI specification.
|
||||
</p>
|
||||
<p><a href={`${import.meta.env.BASE_URL}openapi.json`}>View the OpenAPI specification</a>.</p>
|
||||
</DocsLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
export const prerender = true
|
||||
const destination = `${import.meta.env.BASE_URL}docs`
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="refresh" content={`0; url=${destination}`} />
|
||||
<link rel="canonical" href={destination} />
|
||||
<title>OpenCode Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<a href={destination}>Continue to the OpenCode docs</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,114 +0,0 @@
|
||||
:root {
|
||||
--blume-content-width: 650px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--blume-foreground: #ffffff;
|
||||
}
|
||||
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
[data-blume-doc-grid] {
|
||||
width: 100%;
|
||||
max-width: 1152px;
|
||||
}
|
||||
|
||||
[data-blume-header] {
|
||||
width: 100%;
|
||||
max-width: 1152px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
[data-blume-doc-grid] {
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
[data-blume-doc-grid]:has(> aside[aria-label="On this page"]) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 730px) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
[data-blume-toc] > p:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-blume-page-actions] > details:has([data-open-in]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is([data-blume-nav-drawer], [data-blume-toc]) {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
:is([data-blume-nav-drawer], [data-blume-toc])::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
[data-install-code] pre code {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prose pre[data-language] {
|
||||
padding-top: 2.75rem;
|
||||
}
|
||||
|
||||
.prose pre[data-language]::before {
|
||||
border-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.prose pre > code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prose {
|
||||
color: var(--blume-foreground);
|
||||
}
|
||||
|
||||
/* Match opentui.com/docs: bold headings and bold header nav links */
|
||||
.prose :is(h1, h2, h3, h4) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-blume-header] nav[aria-label="Sections"] a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prose a:not(.blume-heading-anchor, [data-blume-card]) {
|
||||
color: var(--blume-accent);
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".blume/.astro/types.d.ts", ".blume/src/env.d.ts", "**/*"],
|
||||
"compilerOptions": {
|
||||
"types": ["bun"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user