Guidance for AI agents (Claude Code, Codex, Cursor, etc.) working in this repository.
ObjectOS — the commercial runtime environment for ObjectStack applications (Cloud & Enterprise editions). This public repository is the product's front door: the marketing + docs site under apps/docs (content in content/docs/), the issue tracker, and the trademark policy. Product source is developed privately and does not live here.
All marketing copy, UI strings, and documentation are authored in English first. Every other locale (zh-Hans, ja, de, es, fr, ko) is a translation derived from English. zh-Hant is derived one step further — it is generated from zh-Hans by apps/docs/scripts/gen-zh-hant.mjs, never translated and never hand-written (see Traditional Chinese).
- Edit English, and only English. Translations are generated, not authored — a PR that hand-edits a locale file is rejected by CI (see Translation workflow).
- If a typo or wording change only appears in a translation, fix English. The translation is re-derived from it.
- Translations are derived artifacts; treat them like generated code that happens to be checked in — because they now are.
Type-checks and unit tests verify code correctness, not feature correctness. For any UI change in apps/docs, run the dev server and exercise the change in a browser before reporting done. If a tool can't drive a browser, say so explicitly rather than guessing.
Stack: Next.js 16 App Router + Fumadocs UI 16 + Fumadocs MDX. Many UI affordances (theme toggle, search modal, language switcher, sidebar, link rendering) come from Fumadocs components, not custom code. Check node_modules/fumadocs-ui before assuming something is broken in app code.
| Concern | File |
|---|---|
| Locale list & default | apps/docs/lib/i18n.ts |
| Language display names | apps/docs/app/[lang]/layout.tsx (LANGUAGE_NAMES) |
| Locale detection / URL rewrite | apps/docs/middleware.ts |
| Header + logo | apps/docs/lib/layout.shared.tsx |
| Docs MDX content | content/docs/**/*.mdx |
| Sidebar structure / grouping | content/docs/**/meta.json |
- Supported locales, as BCP 47 tags —
apps/docs/lib/i18n.tsis the authority:en(default),zh-Hans,zh-Hant,ja,de,es,fr,ko. - ⛔ There is no
cnlocale. It is a legacy code thatmiddleware.ts308-redirects tozh-Hans. A file namedfoo.cn.mdxwould never render and would raise no error — it is simply ignored, which is the worst possible failure mode for a translation. Simplified Chinese iszh-Hans. - Default locale has no prefix (
/docs/...); other locales are prefixed (/zh-Hans/docs/...). This is set byhideLocale: 'default-locale'inlib/i18n.ts. - MDX translations: add a sibling file with the locale tag next to the English
.mdx—foo.zh-Hans.mdx,foo.ja.mdx, and so on. Fumadocs auto-falls-back to English when a translation is missing — you can ship translations incrementally without breaking links. - Language display names:
LANGUAGE_NAMESinapps/docs/app/[lang]/layout.tsx. Adding a locale toi18n.tswithout a name here shows the raw tag in the switcher. - Sidebar titles:
meta.jsontitlefields render in all locales unless a per-locale sibling exists. To localize sidebar labels, addmeta.<locale>.json(e.g.meta.zh-Hans.json, Fumadocs convention) — don't translate inside the English file.
Folder meta.json files declare a section's title, page order, and defaultOpen: false to make the group collapsible and collapsed by default. The root content/docs/meta.json references folders by name ("deploy", "build", …), not by the "...deploy" spread + ---Deploy--- separator pattern (the old pattern produced a flat ~50-item sidebar).
zh-Hant is the one locale with no translation pass behind it. Simplified and
Traditional Chinese are one language in two orthographies, so the Traditional
pages are produced from the Simplified ones by an orthographic conversion
(OpenCC s2twp, the same preset www.objectos.ai runs) and committed:
pnpm --filter @objectos/docs gen:zh-hant # rewrite the Traditional set
pnpm --filter @objectos/docs gen:zh-hant --check # what CI runs- ⛔ Never hand-edit a
*.zh-Hant.mdxormeta.zh-Hant.jsonfile, and never translate one from English. Fix the English source; the Simplified page is re-derived from it and the Traditional page from that. CI runs--checkon every PR and fails on any byte of drift. - Retiring a page or its Simplified sibling takes the Traditional file with it — re-run the generator, which prunes what it no longer produces.
- Coverage tracks Simplified exactly (62 of 79 pages today). The 17 without a Simplified sibling have no Traditional one either, so they are never advertised in the sitemap or an hreflang cluster — they simply render English.
- Committed output, not on-the-fly conversion, because
lib/seo.tstells a real translation from an English fallback by the presence of a locale-suffixed file. A conversion done while rendering produces no such file, and the locale would be invisible to search engines while looking correct in a browser.
You edit English. You do not edit translations. Every *.<locale>.mdx file is a derived artifact, refreshed by a separate periodic pass under docs/TRANSLATION.md. .github/scripts/check-translation-ownership.mjs rejects any PR that mixes the two — hand-maintained siblings used to be 86% of the diff in a typical docs PR, which is the cost this split removes.
When the English source changes:
- Edit the English
.mdx; verify it renders. That is the whole task. - Leave the locale siblings alone. They are stale now, the freshness gate says so on your PR, and the next pass fixes them. Stale is reported, not blocking — English landing on its own is the design, not an oversight.
- Retiring or renaming a page is the exception: delete its locale siblings in the same PR. An orphaned translation blocks the gate, and a translation of a page that was rewritten to assert something different is worse than none — a missing translation renders correct English, a stale one renders content the English source no longer claims.
- Never hand-write the
translation:frontmatter block. Onlycheck-translations.mjs --stampwrites it; a hand-typed sha is a lie the gate cannot catch.
Status at any time:
node .github/scripts/check-translations.mjs # report + gate
node .github/scripts/check-translations.mjs --worklist # what the next pass will do- Don't reintroduce the
---Section---+"...folder"flat sidebar pattern. - Don't set
alt="ObjectOS"on the logo image when the adjacent text already says "ObjectOS" — screen readers read it twice. Usealt=""+aria-hidden. - Don't add translation-only strings or files. If it doesn't have an English source, it shouldn't exist yet.
- Don't write a
.cn.mdxsibling. That locale does not exist; the file is ignored silently. Use.zh-Hans.mdx. - Don't hand-write a
.zh-Hant.mdxsibling either. It is generated from the.zh-Hans.mdxfile and CI fails on any hand edit. - Don't hand-edit a
*.<locale>.mdxfile, and don't "just fix" one while you're in there. Fix the English source instead.
From apps/docs/:
npm run dev— dev server on http://localhost:3000npm run type-check—fumadocs-mdx && next typegen && tsc --noEmitnpm run build— production build
content/docs/ sits at the repo root, outside the apps/docs package, but both
build and type-check genuinely consume it (source.config.ts points fumadocs at
../../content/docs). Turbo's default hash only covers the package's own directory, so
those tasks used to replay a cached green for a content-only change — and because the
cache is shared across worktrees in a multi-agent container, the replayed logs could come
from a sibling agent's tree. turbo.json now names the dependency explicitly:
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/content/docs/**"]$TURBO_ROOT$ is anchored to the repo root by turbo itself. Prefer it over a hand-counted
../../ — both work today, but a relative glob encodes the package's depth, and if the
package ever moves the glob silently stops matching.
Verify the hash, don't trust the config. A wrong inputs glob is not an error: turbo
exits 0, matches nothing, and the task silently goes back to the stale hash. So when you
change these globs, confirm the hash actually moves — edit any file under content/docs/,
then revert it, and check the hash changes and comes back:
turbo run type-check --filter=@objectos/docs --dry=json | jq -r '.tasks[0].hash'Belt and braces: turbo run build --force ignores the cache entirely. Reach for it if you
are verifying a content change on a turbo older than 2.4 (before $TURBO_ROOT$ existed,
where the glob above matches nothing), or any time a >>> FULL TURBO on a content PR
looks wrong. --force is the escape hatch, not the routine path — the hash is supposed to
tell the truth on its own.