Imported from saileshkushwaha/forge-assistant (
clients/docs/AGENTS.md). Install upstream withnpx skills add saileshkushwaha/forge-assistant --skill docs. Copyright stays with the author.
clients/docs: Agent Guidance
Applies to all code under clients/docs/. Subordinate to clients/AGENTS.md and the root AGENTS.md.
@forgeai/docs is the public docs site: an SSR Next.js app (standalone output) serving www.forge.ai/docs. It is a member of the root bun workspace.
URL rules: everything public lives under /docs
Ingress only routes /docs/* to this app. Every URL the app emits publicly MUST be /docs-prefixed:
- Pages are authored under
src/app/docs/with nobasePath. - The search API is
/docs/api/search, markdown mirrors are/docs/<path>.md(index:/docs/index.md), the agent index is/docs/llms.txt, the sitemap is/docs/sitemap.xml, and assets are served frompublic/docs/. - Next's build assets are served under the prefix too:
assetPrefix: "/docs"innext.config.tsplus the firstbeforeFilesrewrite (/docs/_next/*→/_next/*). Without both halves, stylesheets and scripts resolve to/_next/*, which ingress routes to a different backend.verify-parity.tsasserts asset subresources load. - The only exception is
/api/health: GKE BackendConfig health checks hit the pod directly and bypass ingress path rules. - Canonical URLs are absolute
https://www.forge.ai<path>viacreateMetadata(src/lib/metadata.ts). Never change a page'spath:value; URLs did not change in the migration, and native clients link the legal paths (/docs/privacy-policy,/docs/forge-terms-of-use,/docs/prohibited-use) directly. - Links to non-docs Forge surfaces (signup, login, the assistant app) are cross-app now: absolute URLs from
src/lib/routes.ts.
The %5Fmd folder encoding trick
The markdown mirror route lives at src/app/docs/%5Fmd/[[...slug]]/route.ts and serves /docs/_md. A folder literally named _md is private to the app router (underscore prefix) and would be excluded from routing; %5F is the URL-encoded _, which registers the public route. The filesystem walkers (sitemap route discovery, the markdown/search generators) treat %5F-encoded names as private the same as a literal underscore prefix, and the attribution proxy excludes the /docs/_md URL path. Do not "fix" the folder name.
Authoring content
- One
page.tsxper route. Each exportsmetadata = createMetadata({...})and renders a content component fromsrc/app/docs/_components/. - Assets go in
public/docs/as WebP under 200 KB, with intrinsicwidth/heightdeclared onnext/image(all images areunoptimized, so declared dimensions are what prevents layout shift). OG images are PNG for scraper compatibility;public/docs/og.pngis the default OG image viacreateMetadata. - The sitemap, search index, markdown mirrors, and
llms.txtall derive from the filesystem tree at build time; adding or moving a page needs no list updates. Redirect-only stub pages must be added toREDIRECT_STUB_ROUTESinsrc/lib/discover-docs-routes.tsso the sitemap skips them. scripts/platform-route-snapshot.jsonis the Phase 1 route-parity baseline;bun scripts/verify-parity.ts <base-url>checks the tree and a running instance against it.
Embedding a how-to video
A page whose topic has a walkthrough video offers it as an alternative to reading, via <DocsVideo> (_components/docs-video.tsx) placed immediately after the page's lede and before the first <section>.
The card is a facade: it ships a self-hosted poster and swaps in the YouTube player only once someone presses play, so a page nobody watches costs no third-party script, no YouTube cookie, and no request to Google. Its markup is a real link to the watch URL that JavaScript upgrades in place, which is what carries the video into the Markdown mirrors, llms.txt, and the search index — an iframe leaves nothing behind in any of them.
Adding one:
-
Grab the poster. YouTube's frame is 1280x720; keep the declared dimensions in
src/lib/docs/videos.tsin step if you ever source one elsewhere.curl -sfL "https://i.ytimg.com/vi/<VIDEO_ID>/maxresdefault.jpg" \ | cwebp -q 78 -resize 1280 0 -o public/docs/video-<slug>.webp -- - -
Add the entry to
DOCS_VIDEOSinsrc/lib/docs/videos.ts. Title, description, runtime, and upload date feed both the card and itsVideoObjectstructured data, so take the title andlengthSeconds/uploadDatefrom the watch page rather than retyping them. -
Render
<DocsVideo video="<slug>" />in the page's content component. Referencing the same slug from a second page is free and keeps one copy of the metadata.
src/lib/docs/videos.test.ts fails if an entry has no committed poster, so a forgotten step 1 does not reach a deploy.
Colors inside the card that sit on the poster (the play button) must avoid bg-white and text-emerald-*: docs-theme.css remaps both to theme surfaces inside .docs-shell, which is right for cards and wrong for chrome over a photograph that does not change with the theme.
Generated artifacts
| Command | Output |
|---|---|
bun run docs:search:index |
public/docs/search-index.json |
bun run generate:agent-markdown |
generated/md/**, generated/md/docs-index.json, public/docs/llms.txt |
Both run automatically via predev/prebuild. Both drop decoration before converting: <script> (JSON-LD), <iframe>, anything aria-hidden="true", and alt="" images, so structured-data payloads and arrow glyphs stay out of agent-facing prose. All outputs are gitignored. Next standalone output omits public/ and generated/, so the Dockerfile copies both into the runtime image explicitly.
Theme contract
- Dark mode is stamped on
<html>as BOTH the.darkclass (docs-theme.css selectors) anddata-theme="dark"(design-librarytokens.csscustom variant). Any code that changes the theme must set both. - Storage key precedence when reading:
device:theme(the assistant SPA's key) first, thenforge_theme(the shared platform key), then system preference. The platform-onlyvelvetvalue counts as dark. - The theme picker writes BOTH keys (
forge_themeanddevice:theme); the pre-hydration bootstrap insrc/app/layout.tsxanddocs-theme-picker.tsximplement the contract. The apps share the www.forge.ai origin, so do not rename either key.
Attribution contract (src/proxy.ts)
- The proxy emits one
page_viewJSON line to stdout per real page load. The BigQuery log sink and dbt (stg_marketing_events__page_views) parse the exact field names (source,event,vid,path,referrer,timestamp,utm_*, click IDs,utm_resolution). Do NOT change field names or the single-line JSON shape. - The
forge_vidcookie is 90-day HttpOnly,Domain=.forge.aiwhen the Host header ends withforge.ai. Read the Host header directly;nextUrl.hostnamereturns the bind address behind the GKE load balancer. - Prefetch suppression (
Next-Router-Prefetch,Next-Router-Segment-Prefetch,Purpose/Sec-Purpose) is load-bearing: dbt carries a scrubber for a historical phantom-prefetch bug.skipMiddlewareUrlNormalize: trueinnext.config.tskeeps those headers visible to the proxy; do not remove it. - This app's Kubernetes container name is
docs. Page_view lines only reach BigQuery after the Phase 2 platform Terraform change extends the pageview sink filter (currentlycontainer_name="nextjs") to containerdocs.
Content ownership: this tree is canonical
src/app/docs/ is the only source of the public docs. The platform repo's docs tree (forge-assistant-platform/web/src/app/(marketing)/docs) was deleted by "Docs migration Phase 4" (platform #9705), so there is no upstream to sync from and no follow-up copy to keep in step. Copy edits, new pages, and factual corrections land here directly.
The rules that governed the migration still constrain the content itself:
- The root
AGENTS.md"Generic Examples" rule applies (this repo is public): no real names in docs copy. The migration replaced the personal names it found with that rule's placeholders, mapping each original to one placeholder consistently so a page still reads as being about the same person. Keep new copy on placeholder personas. - Pages are authored against this app's import paths (
@/app/docs/),/docs-prefixed WebP asset paths, and cross-app links absolutized tohttps://www.forge.ai/....
Behavior ported from the platform app
Behavior ported from the platform app that intentionally differs:
- Attribution referrer/click-id classification is stricter than the platform emitter: empty click-id params (e.g. a bare
?gclid=) emit no paid attribution, referrer domains match at hostname boundaries (exact host or dot-suffix, never substring), andcopilot.bing.comclassifies as GEO. The emitted JSON key set is unchanged. - Search extraction/ranking adds element-boundary spacing during text extraction, indexes standalone headings unconditionally as their own chunks with level-aware scoping, and returns matched-term snippets.
- Tailwind has no class-keyed dark variant. The pre-hydration bootstrap stamps both
.darkanddata-theme="dark"on<html>;dark:utilities key offdata-theme(the design-librarytokens.csscustom variant) whiledocs-theme.cssselectors key off.dark. - The mobile nav drawer is refactored around a shared
NavPanelShell(_components/nav-panel-shell.tsx) with a ref-counted body-scroll lock (_components/body-scroll-lock.ts) and single-owner Cmd/Ctrl+K registration (DocsSearch registerShortcut).docs-nav.tsx,releases-nav.tsx, anddocs-nav-context.tsxtherefore keep this app's shell structure; edit nav item data inside it rather than restoring the per-drawer copies the port replaced. - Release anchor/month formatting is centralized in
src/lib/releases-server.ts(releaseAnchor,monthLabel) and shared byreleases-content.tsxandreleases-nav.tsxso sidebar links always match article IDs; the platform version keeps local copies of these helpers.releases-content.tsxis hand-merged during syncs. - The TOC peek urchin is a hand-maintained trimmed port,
_components/peek-character.tsx, not a byte-identical copy of the platform'sAnimatedAvatar+ full character catalog: it inlines only the urchin body, curious eyes, and teal color, drops the streaming morph, and suppresses all animation (including blink/twitch) underprefers-reduced-motion: reduce, where the platform passesignoreReducedMotion. Sync procedure: the SVG path data and transform math must matchpackages/avatar-catalogandassistant/src/avatar/svg-compositor.ts; re-copy the urchin/curious/teal definitions when the catalog changes.table-of-contents.tsxis hand-merged during syncs: mirror the platform structure but keep the localPeekCharacterimport in place ofAnimatedAvatar.
Deferred items and known divergences from the platform app
- The React compiler is off (
reactCompilerunset; the platform app enables it). Turning it on requiresbabel-plugin-react-compiler. /docs/releasesis the onlyforce-dynamicroute. It fetches the public releases API at request time (revalidate: 60, 10 s timeout, fail-soft to an empty list).RELEASES_API_URLoverrides the base URL;DJANGO_INTERNAL_URLsupports in-cluster fetch without a code change.- Search is lexical only; the platform's unreachable embeddings mode was dropped during the port.