The /docs deploy from #3860 still returned HTTP 500 with
`EvalError: Code generation from strings disallowed for this context`
(captured via `wrangler tail`).
`next-mdx-remote/rsc` compiles MDX to JSX *at runtime* using
`new Function()` style code generation. Cloudflare Workers' security
sandbox bans all dynamic code generation from strings, even from inside
trusted code, so any worker invocation that touched the docs page
threw immediately.
Switch to a build-time markdown -> HTML pipeline:
- Drop `next-mdx-remote` and `gray-matter`. Add `marked` (pure-JS,
no eval).
- `web/scripts/generate-docs-content.mjs` now runs each markdown
source through `marked.parse()` (gfm enabled) at build time and
writes the resulting HTML strings into
`web/lib/docs-content.generated.ts`.
- `web/app/[locale]/docs/page.tsx` renders each section as
`<article className="docs-content" dangerouslySetInnerHTML={{ __html: section.html }} />`.
No MDX runtime, no JSX compilation at request time, just static HTML
injection.
- `web/app/globals.css` adds a `@layer components` block targeting
`.docs-content h1..h4, p, a, ul, ol, li, blockquote, code, pre, table,
thead, th, td, hr, strong`. Same shadcn-themed look that the dropped
`mdx-components.tsx` provided, applied via CSS instead of React
component overrides.
- `web/components/docs/mdx-components.tsx` removed.
We lose MDX features (JSX inside markdown), but the docs are pure
markdown anyway. `docs/` remains the SoT; the marketing site renders
identical content with no eval and no fs at runtime.
The first build of #3860 failed at type-check because the generated
`lib/docs-content.generated.ts` is gitignored (regenerated on every
build) and CI's `type-check` step runs before `build`. Two fixes:
- web-ci.yml: explicit `Generate docs content from repo-root docs/`
step right after `bun install` so format-check, lint, and type-check
all see the file.
- web/package.json: add `prepare` lifecycle script. `bun install`
invokes it automatically, so a fresh local checkout boots into a
working state too.
Build still re-runs the generator via prebuild, so docs/ edits land in
the bundle without an explicit dev action.
The worker deploy from #3859 returned HTTP 500 on /docs with
`Error: [unenv] fs.readFile is not implemented yet!` (captured via
`wrangler tail`). `loadDocSource` was calling `node:fs/promises`
`readFile` inside an RSC; even though the page is generated as SSG
(`●`), Cloudflare Workers' unenv shim does not implement filesystem
reads, so any code path that reaches the worker (cache miss, prerender
fallback) fails.
Move the read to a prebuild step that emits a TypeScript module:
- `web/scripts/generate-docs-content.mjs` reads each section's source
from `<repo-root>/docs/` and writes
`web/lib/docs-content.generated.ts` containing
`export const DOC_SOURCES: Record<string, string>`.
- `web/scripts/prepare-build.mjs` invokes the generator after the
cache prune, so every `bun run build` and `bunx opennextjs-cloudflare
build` regenerates the constant module from the live `docs/`.
- `web/lib/docs-source.ts` now reads `DOC_SOURCES[file]` synchronously
with no Node I/O.
- `web/app/[locale]/docs/page.tsx` drops the `Promise.all` since reads
are synchronous.
- `web/.gitignore` excludes the generated file (kept generated, not
source-of-truth).
Effect: the bundle ships every doc as a string literal. The worker has
no `fs.readFile` call to fail. `docs/` remains the only place an
editor needs to touch.
Replace the bespoke 16-section /docs page that pulled prose from
`messages/{locale}.json` with a build-time MDX renderer that reads the
canonical markdown in repo-root `docs/`. Each markdown file becomes
one section of the docs page, scrolled-to via the existing DocsShell
sidebar. Section data structure stays in `lib/docs-sections.ts` so the
sidebar / scroll-spy keeps working with no client changes.
Why this layout:
- One source of truth: `docs/guide/*.md`, `docs/reference/*.md`,
`docs/manifesto.md`. Edits land in one place; the website redeploys
pick them up automatically via the existing web-deploy workflow.
- Build-time only: `MDXRemote` is rendered inside an RSC and the page
is statically generated (`●` SSG). Cloudflare Workers serves the
rendered HTML; no MDX compiler runs at request time.
- next-intl unchanged for everything else: only the docs prose moves
out. `mobileHeader` and `searchPlaceholder` strings stay in
`messages/{locale}.json`; the 18 stale section keys are removed.
Files:
- web/lib/docs-sections.ts: 9 sections matching docs/ files, typed
`DocSection` with `{ id, title, file }`.
- web/lib/docs-source.ts: `loadDocSource(file)` reads
`<repo-root>/docs/<file>` at build time via `node:fs/promises`.
- web/components/docs/mdx-components.tsx: shadcn-styled overrides for
every markdown element (h1-h4, p, a, ul/ol/li, blockquote, code, pre,
table, hr, strong) so the rendered output matches the rest of the
site.
- web/app/[locale]/docs/page.tsx: rewritten as an async RSC that loads
every section's source in parallel and renders one MDXRemote per
section inside DocsShell.
- web/messages/{en,ja,ko,zh}.json: `docs` key trimmed from 20 entries
to 2 (mobileHeader, searchPlaceholder).
- web/package.json: + next-mdx-remote, + gray-matter.
Local verification: `bun run format:check`, `bun run lint`,
`bun run type-check`, `bun run build`, `bunx opennextjs-cloudflare
build` all pass; `/[locale]/docs` builds as static for all 4 locales
at 4.12 kB / 132 kB First Load.
The first auto-deploy from PR #3855 returned HTTP 500 on every page
with the runtime error `TypeError: components.ComponentMod.handler is
not a function`. Captured via `wrangler tail`.
Root cause: Next.js 16.2.6 was published 2026-05-07 19:01 UTC, *after*
@opennextjs/cloudflare 1.19.8 was published earlier the same day at
11:33 UTC. OpenNext 1.19.8's peerDependency declares
`next: '>=15.5.16 <16 || >=16.2.5'` — 16.2.6 falls inside the range
syntactically, but the route component module export shape changed in
that patch and OpenNext has not caught up yet.
Pin Next + eslint-config-next to 15.5.18 (latest 15.x LTS, the other
half of OpenNext's supported range). Revert the migration-only changes
that came with the 16 bump:
- eslint.config.mjs: `nextPlugin.configs["core-web-vitals"]` (v16
shape) -> `nextPlugin.flatConfig.coreWebVitals` (v15 shape).
- tsconfig.json: `jsx: "react-jsx"` -> `jsx: "preserve"` (Next 15
default).
- tsconfig.json: add `noUncheckedSideEffectImports: false` because
TypeScript 6 enabled this option under `strict` and Next 15's
bundled types do not declare ambient CSS modules (Next 16 does).
All other web/ deps stay at latest. lucide-react remains pinned at
0.577.0 from #3853 for the same brand-icon reason. Re-evaluate Next 16
when @opennextjs/cloudflare ships a release explicitly tested against
\>= 16.2.6.
"lucide-react": "0" is npm shorthand for >=0.0.0 <1.0.0, so the
dependency was still floating across all 0.x releases. A future
lockfile refresh could pull a newer 0.x that quietly changes brand-icon
inventory.
Pin exact 0.577.0 (no caret) so the lockfile cannot drift until we
explicitly migrate to a brand-icon library compatible with lucide-react
v1.x (which removed Github, X, etc.).
Identified by cubic.
Side effects from `bun run format` (prettier 3.8) and `bun run build`
(Next 16) that I missed in the prior commit:
- web/components/ui/badge.tsx, web/components/ui/button.tsx: prettier 3.8
inlines short interface `extends` lists onto a single line.
- web/tsconfig.json: Next 16's build step auto-modernizes tsconfig:
`jsx: "preserve"` → `jsx: "react-jsx"` and reformats `lib`/`paths`
arrays to multi-line. Build is identical either way; committing the
modernized shape so subsequent `next build` runs do not produce
spurious diffs.
Bumped via `bun update --latest` then resolved breakage from two
major-version jumps:
1. Next.js 15.5 → 16.2 + @next/eslint-plugin-next 16: dropped the
`flatConfig` namespace. Updated web/eslint.config.mjs to use
`nextPlugin.configs["core-web-vitals"]` per the new export shape.
2. lucide-react 0.553 → 1.x: lucide upstream removed all brand icons
(Github, etc.) — they are now expected to come from a separate brand
icon library. Pinned lucide-react at the last 0.x (0.577.0) for now;
migrating to a brand-icon library is tracked as a follow-up.
All other deps to latest:
- react/react-dom 19.2.4 → 19.2.6
- next-intl 4.8 → 4.11
- motion 12.35 → 12.38
- tailwind-merge 3.4 → 3.5
- geist 1.5 → 1.7
- @radix-ui/* unchanged (already latest within their ranges)
- @opennextjs/cloudflare 1.17 → 1.19.8
- @playwright/test 1.56 → 1.59
- @tailwindcss/postcss + tailwindcss 4.1 → 4.2.4
- @types/node 22 → 25.6
- @types/react 19 → 19.2.14
- eslint 9 → 10.3 (works because we now reference @next/eslint-plugin-next
configs directly, not eslint-config-next)
- eslint-plugin-prettier 5.5.4 → 5.5.5
- globals 16 → 17.6
- postcss 8.5.6 → 8.5.14
- prettier 3.6.2 → 3.8.3 (no formatting changes detected by --check)
- prettier-plugin-tailwindcss 0.6 → 0.8
- typescript 5.9.3 → 6.0.3
- typescript-eslint 8.56 → 8.59
- wrangler 4.71 → 4.90
Verified locally:
- bun install --frozen-lockfile: 685 packages, no errors
- bun run format:check: pass (no diffs after `bun run format`)
- bun run lint: pass
- bun run type-check: pass (TypeScript 6 + @types/node 25)
- bun run build: pass (Next 16 build, 21 static pages, all 4 locales)
- bunx opennextjs-cloudflare build: pass (.open-next/worker.js produced)
Note: Next 16 `build` log relabels the Middleware row to "Proxy
(Middleware)" — purely cosmetic, no behavior change.
Code-quality review flagged that web/AGENTS.md ANTI-PATTERNS section
asserts `.wrangler/` is gitignored, but the rule was missing from
web/.gitignore. Adding the rule alongside the other Cloudflare/Next
build-output rules so the doc and the file agree.
Imports the public marketing site previously living in
../oh-my-opencode-web. Independent of the npm plugin: own package.json,
bun.lock, tsconfig.json. Not included in the published package — root
files: array still only ships dist/, bin/, postinstall.mjs.
Stack:
- Next.js 15.5 App Router + RSC, deployed to Cloudflare Workers via
@opennextjs/cloudflare (build target .open-next/worker.js).
- Tailwind v4 + shadcn/ui primitives.
- next-intl with 4 locales (en/ja/ko/zh) under app/[locale]/.
- Playwright e2e tests under web/e2e/.
- Custom domains ohmyopenagent.com (primary) and ohmyopencode.org
(legacy alias) declared in web/wrangler.toml.
Source files were re-formatted via `bun run format` to bring them in
line with the existing .prettierrc (singleQuote: false). Functional code
unchanged.