Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ docs/
│ ├── architecture-tests.md ← catalog of every architecture gate
│ ├── editor-history.md ← patch-based undo/redo history
│ ├── react-compiler.md ← memoization rule, three exceptions, gates
│ ├── admin-i18n.md ← full-admin translations, extraction + locale persistence
│ └── use-async-resource.md ← canonical async load hook; when to use vs. not
├── deployment/ ← operator docs (running the thing)
Expand Down Expand Up @@ -179,6 +180,7 @@ Three categories, three voices:
| [reference/architecture-tests.md](reference/architecture-tests.md) | Catalog of every architecture gate test |
| [reference/editor-history.md](reference/editor-history.md) | Patch-based undo/redo history: `HistoryEntry`, `mutate*` helpers, coalescing |
| [reference/react-compiler.md](reference/react-compiler.md) | React Compiler memoization rule, three exceptions, enforcement gates |
| [reference/admin-i18n.md](reference/admin-i18n.md) | Full-admin English/Chinese catalogs, build-time extraction, switching, and persistence |
| [reference/use-async-resource.md](reference/use-async-resource.md) | `useAsyncResource` — canonical single-resource async load hook; when to use and when not to |

### Operations
Expand Down Expand Up @@ -221,4 +223,5 @@ Quick map from "where do I look for X?" to the canonical file:
| CSS value sanitiser | `src/core/css-sanitize/sanitiseCssValue.ts` |
| TypeBox helpers | `src/core/utils/typeboxHelpers.ts` |
| Error message extraction | `src/core/utils/errorMessage.ts` |
| Admin translations | `src/admin/i18n/` + `scripts/lib/adminI18n.ts` |
| Architecture gate tests | `src/__tests__/architecture/*.test.ts` |
1 change: 1 addition & 0 deletions docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The frontend is a single React 19 + Vite SPA mounted at `/admin`. Inside it, two
## TL;DR

- **Entry:** `src/admin/main.tsx` mounts `<Router><AdminRoutes /></Router><AdminContextMenuGuard />` with React 19 root-level error callbacks. `flushSync` forces the initial render synchronous to cut LCP.
- **Admin locale:** `src/admin/i18n/` plus the Vite pre-transform provide complete English and Simplified Chinese coverage across every workspace. Simplified Chinese is the default; the persisted switch is available pre-auth and in the account menu. See [`reference/admin-i18n.md`](reference/admin-i18n.md).
- **Router:** `src/admin/lib/routing/` — in-house router replacing `react-router-dom`. Ten workspace/page routes are wrapped in a per-route `<ErrorBoundary>` and `<Suspense>`, with root redirects plus a final `path="/admin/*"` catch-all redirecting unknown admin URLs to `/admin/dashboard` (login form when unauthenticated) instead of rendering an empty tree. Public-site 404s are NOT claimed — the publish pipeline's NotFound handling owns those.
- **Cold path:** entry chunk is tiny. `AuthenticatedAdmin` is `React.lazy` and only loads post-login. Each workspace page is wrapped in `prewarmedLazy(...)`: the active page fires its import at module evaluation; the remaining pages pre-warm via `requestIdleCallback` after first paint so subsequent nav is synchronous (no Suspense flicker).
- **Workspaces:** `dashboard`, `site` (the editor), `content`, `data`, `media`, `plugins`, `users`, `ai`, `account`, `pluginPage`. Capability-gated by `canAccessWorkspace`.
Expand Down
98 changes: 98 additions & 0 deletions docs/reference/admin-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Admin i18n

Simplified Chinese and English localization for the complete admin application.

The admin defaults to Simplified Chinese (`zh-CN`). An explicit language choice is persisted locally, updates `<html lang>`, and is available before and after authentication. Published-site language remains a separate setting.

---

## TL;DR

- `src/admin/i18n/catalog.ts` contains strongly typed, named messages used by authentication and application-level UI.
- English source literals in admin components are gettext-style message IDs. `scripts/lib/adminI18n.ts` extracts supported user-facing contexts and the Vite pre-transform injects English plus Simplified Chinese at build time.
- Simplified Chinese literal catalogs live in `src/admin/i18n/locales/zh-CN/`, split by workspace so the source remains reviewable.
- `src/admin/i18n/runtime.ts` selects the already bundled literal for the active locale. Each route chunk carries only its own translated strings; the entry chunk does not import the complete catalog.
- `I18nProvider` owns the locale, remounts its child tree after a language change, and synchronizes the document language.
- `instatic-admin-locale-v1` is TypeBox-validated. With no saved preference, the admin always starts in Simplified Chinese.
- `admin-i18n-coverage.test.ts` fails when a newly introduced admin message has no Simplified Chinese translation.

## Architecture

| Responsibility | Source of truth |
|---|---|
| Supported locales and named messages | `src/admin/i18n/catalog.ts` |
| Workspace literal catalogs | `src/admin/i18n/locales/zh-CN/*.ts` |
| Combined build-time catalog | `src/admin/i18n/literalCatalog.ts` |
| Extraction and Vite transformation | `scripts/lib/adminI18n.ts` |
| Runtime literal selection | `src/admin/i18n/runtime.ts` |
| React context and document synchronization | `src/admin/i18n/I18nProvider.tsx` |
| Persisted preference | `src/admin/i18n/localePreference.ts` |
| Pre-auth switch | `src/admin/i18n/LanguageSwitcher.tsx` |
| Authenticated switch | `src/admin/shared/AccountMenuButton/AccountMenuButton.tsx` |
| Coverage gate | `src/__tests__/architecture/admin-i18n-coverage.test.ts` |

Locale resolution is deliberately simple:

```text
validated localStorage preference
→ Simplified Chinese default
```

The language control remains available on the setup/login screen and in the authenticated account menu. Changing it persists the preference and remounts the admin subtree so module-level configuration getters and all rendered literals resolve against one locale.

## Two message forms

Use a named message for application infrastructure, messages shared across unrelated contexts, or interpolation that benefits from a semantic key:

```tsx
import { useI18n } from '@admin/i18n'

export function Example() {
const { t } = useI18n()
return <h1>{t('preauth.setup.title')}</h1>
}
```

Regular admin component copy stays readable in place:

```tsx
<Button aria-label="Open Settings">Settings</Button>
```

The build transform recognizes visible JSX text, accessible text attributes, render-time string branches, selected UI configuration properties such as `title`, `label`, and `description`, and user-message setters. It rewrites only messages present in the Chinese catalog, while the coverage gate ensures the recognized set is complete.

Parameterized template literals use positional placeholders in the literal catalog:

```ts
"{0} items": "{0} 项"
```

Run the report while migrating or reviewing copy:

```bash
bun scripts/admin-i18n-report.ts --missing
bun scripts/admin-i18n-report.ts --area=site --missing
```

## Adding or changing copy

1. Write the English UI copy in its component or named catalog.
2. Run `bun scripts/admin-i18n-report.ts --missing`.
3. Add each missing literal to the appropriate `src/admin/i18n/locales/zh-CN/` catalog.
4. Run the architecture gate and build. The production build is the authoritative transform check.

Do not translate user content, site names, plugin-provided labels, URLs, source code, CSS values, or internal identifiers. The extractor intentionally limits itself to known UI contexts rather than rewriting arbitrary string literals.

## Adding a locale

1. Add the locale tag and named catalog to `src/admin/i18n/catalog.ts`.
2. Extend `AdminLocalePreferenceSchema` in `localePreference.ts`.
3. Add a literal catalog for the locale and pass it to the build transform.
4. Add the locale to both language controls.
5. Cover persistence, named interpolation, literal transformation, and a rendered page in tests.

## Related

- `docs/editor.md` — admin SPA boot and provider placement.
- `docs/reference/persistence-keys.md` — client preference key catalog.
- `docs/reference/typebox-patterns.md` — persisted-boundary validation.
3 changes: 2 additions & 1 deletion docs/reference/architecture-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Catalog of every test in `src/__tests__/architecture/`. These are structural gat

## TL;DR

- 95 gate files across structural domains: SQL, JSON columns, migrations, CSS, icons, primitives, page tree, sandbox, agent, router, content storage, boundary validation, module size, AI, auth, error handling, etc.
- 96 gate files across structural domains: SQL, JSON columns, migrations, CSS, icons, primitives, page tree, sandbox, agent, router, admin localization, content storage, boundary validation, module size, AI, auth, error handling, etc.
- Naming convention: `<topic>.test.ts` (kebab-case) or `<group>-<topic>.test.ts`. A few legacy `task<N>-*` ids remain for live invariants; new gates should use topic names.
- Run them all: `bun test src/__tests__/architecture/`.
- Most are **import / source scans** — they parse the files in scope and assert / reject patterns. Some are unit-style (a small in-test database, a synthesized page tree).
Expand Down Expand Up @@ -121,6 +121,7 @@ See [docs/reference/ui-primitives.md](ui-primitives.md).
| `no-circular-dependencies.test.ts` | `madge` finds zero tsconfig-aware circular dependencies across `src` and `server`. |
| `canvas-aware-selectors.test.ts` | Canvas-related store selectors are subscribed correctly to canvas-state slices. |
| `admin-router-usage.test.ts` | Internal admin navigation uses `@admin/lib/routing`; raw `/admin` anchors and `react-router-dom` are banned. |
| `admin-i18n-coverage.test.ts` | Every extracted user-facing admin literal has a Simplified Chinese translation, and the compile-time transform preserves intentionally empty translations used for suffix removal. |
| `framework-typography-spacing.test.ts` | The site framework's typography / spacing tokens compile correctly. |
| `component-system-placement.test.ts` | Every VC insertion flow (toolbar picker, context menu) routes through `insertComponentRef`; Site Explorer must not expose a component-to-canvas drag source, and direct `insertNode`/`addNodeToVc` with `'base.visual-component-ref'` is forbidden in placement files. |
| `task414-wrap-to-container.test.ts` | Wrap-to-container action creates defaulted wrappers and preserves tree structure. |
Expand Down
1 change: 1 addition & 0 deletions docs/reference/persistence-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Catalog of every `localStorage` / `sessionStorage` key the admin app writes, and

| Key | Owner | Source-of-truth file |
|-------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------|
| `instatic-admin-locale-v1` | Explicit admin UI locale selected with the language switcher; absent means Simplified Chinese | `src/admin/i18n/localePreference.ts` → `ADMIN_LOCALE_STORAGE_KEY` |
| `instatic-editor-prefs` | All editor preferences (auto-save, hover-preview, admin theme, UI text size, density, layers options) — see [docs/features/editor-preferences.md](../features/editor-preferences.md) | `src/admin/pages/site/preferences/editorPreferences.ts` → `EDITOR_PREFS_KEY` |
| `instatic-editor-layout-v2` | Per-workspace sidebar widths + open states (site / content / data / media) and floating panel positions | `src/admin/state/workspaceLayoutStorage.ts` → `EDITOR_LAYOUT_STORAGE_KEY` |
| `instatic-clipboard-v1` | The editor clipboard (copy / cut / paste of layer subtrees) | `src/admin/pages/site/store/clipboard/clipboardStorage.ts` → `CLIPBOARD_STORAGE_KEY` |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"jscpd": "^4.0.9",
"knip": "^6.11.0",
"madge": "^8.0.0",
"magic-string": "0.30.21",
"playwright-core": "^1.60.0",
"react-doctor": "^0.2.14",
"secretlint": "^12.3.1",
Expand Down
50 changes: 50 additions & 0 deletions scripts/admin-i18n-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { readFile } from 'node:fs/promises'
import { extractAdminMessages } from './lib/adminI18n'
import { adminLiteralZhCN } from '../src/admin/i18n/literalCatalog'

const requestedArea = Bun.argv.find((argument) => argument.startsWith('--area='))?.slice(7)
const missingOnly = Bun.argv.includes('--missing')
const occurrences = []

function areaFor(filePath: string): string {
const pageMatch = filePath.match(/^src\/admin\/pages\/([^/]+)\//)
if (pageMatch?.[1]) return pageMatch[1]
if (filePath.startsWith('src/admin/modals/')) return 'modals'
if (filePath.startsWith('src/admin/spotlight/')) return 'spotlight'
return 'shared'
}

const glob = new Bun.Glob('src/admin/**/*.{ts,tsx}')
for await (const filePath of glob.scan({ cwd: process.cwd(), onlyFiles: true })) {
if (
filePath.includes('/__tests__/') ||
filePath.includes('/i18n/') ||
filePath.endsWith('.test.ts') ||
filePath.endsWith('.test.tsx')
) {
continue
}
const area = areaFor(filePath)
if (requestedArea && requestedArea !== area) continue
const source = await readFile(filePath, 'utf8')
occurrences.push(...extractAdminMessages(source, filePath).map((item) => ({ ...item, area })))
}

const messages = new Map<string, { area: string; references: string[] }>()
for (const item of occurrences) {
const current = messages.get(item.message)
const reference = `${item.filePath}:${item.line}`
if (current) {
if (!current.references.includes(reference)) current.references.push(reference)
} else {
messages.set(item.message, { area: item.area, references: [reference] })
}
}

const rows = [...messages.entries()].sort(([left], [right]) => left.localeCompare(right))
for (const [message, details] of rows) {
if (missingOnly && message in adminLiteralZhCN) continue
process.stdout.write(`${JSON.stringify(message)}\t${details.area}\t${details.references.join(',')}\n`)
}
const missingCount = rows.filter(([message]) => !(message in adminLiteralZhCN)).length
process.stderr.write(`${rows.length} unique messages, ${missingCount} missing across ${occurrences.length} occurrences\n`)
Loading