# Sui Development

> Guide for developing web applications using Yao SUI framework. Use when building SUI pages, components, templates, handling data binding, event handling, backend scripts, routing, i18n, or integrating with CUI. Trigger when user mentions SUI, Yao web development, page templates, or frontend/backend integration in Yao applications.

- Skill: `yaoapp/sui-development` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add yaoapp/sui-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yaoapp/sui-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: YaoApp (https://skillmd.com/u/yaoapp)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yaoapp/sui-development

---


# SUI Development Guide

SUI (Simple UI) is Yao's built-in web framework for building server-rendered pages with reactive components.

## Core Concepts

1. **File-based routing** - Directory structure defines URL routes
2. **Page = Component** - Every page can be used as a component
3. **Server-side rendering** - Templates rendered on server with data binding
4. **Progressive enhancement** - Frontend scripts add interactivity

## Directory Structure

```
/suis/<sui>/templates/<template>/
├── __document.html       # Global document wrapper
├── __data.json           # Global data ($global)
├── __assets/             # Static assets
├── __locales/            # i18n files
└── pages/
    └── <page>/           # Route = folder name
        ├── <page>.html   # Template
        ├── <page>.css    # Styles (auto-scoped)
        ├── <page>.ts     # Frontend script
        ├── <page>.json   # Page data config
        ├── <page>.config # Page settings (guard, cache, SEO)
        └── <page>.backend.ts  # Server-side logic
```

## Build Commands

```bash
yao sui build <sui> <template>      # Build for production
yao sui build <sui> <template> -D   # Development mode
yao sui watch <sui> <template>      # Watch mode
yao sui trans <sui> <template>      # Extract i18n strings
```

---

# Template Syntax

## Data Interpolation

```html
{{ name }}                    <!-- Variable -->
{{ user.email }}              <!-- Nested -->
{{ title ?? 'Default' }}      <!-- Default value -->
{{ price * quantity }}        <!-- Expression -->
{{ count > 0 ? 'Yes' : 'No' }} <!-- Ternary -->
```

## Conditionals

```html
<div s:if="{{ isActive }}">Active</div>
<div s:elif="{{ isPending }}">Pending</div>
<div s:else>Unknown</div>

<!-- Operators: ==, !=, >, <, >=, <=, &&, ||, ! -->
<div s:if="{{ isAdmin && isActive }}">Admin Panel</div>
```

## Loops

```html
<li s:for="{{ items }}" s:for-item="item" s:for-index="i">
  {{ i + 1 }}. {{ item.name }}
</li>

<!-- With conditional -->
<div s:for="{{ users }}" s:for-item="user" s:if="{{ user.active }}">
  {{ user.name }}
</div>
```

## Attribute Binding

```html
<a href="{{ '/user/' + id }}">Link</a>
<button s:attr-disabled="{{ !valid }}">Submit</button>
<div class="base {{ isActive ? 'active' : '' }}">Content</div>
<div ...props></div>  <!-- Spread -->
```

## Built-in Variables

| Variable   | Description              | Example               |
| ---------- | ------------------------ | --------------------- |
| `$param`   | Route parameters         | `{{ $param.id }}`     |
| `$query`   | URL query params         | `{{ $query.search }}` |
| `$payload` | POST body                | `{{ $payload.name }}` |
| `$global`  | Global data              | `{{ $global.title }}` |
| `$theme`   | Current theme            | `{{ $theme }}`        |
| `$locale`  | Current locale           | `{{ $locale }}`       |
| `$auth`    | OAuth info (when guarded)| `{{ $auth.user_id }}` |

## Built-in Functions

```html
{{ P_('models.user.Find', id) }}   <!-- Call process -->
{{ True(user) }}                    <!-- Truthy check -->
{{ Empty(items) }}                  <!-- Empty check -->
{{ len(items) }}                    <!-- Array length -->
{{ filter(items, .active) }}        <!-- Filter array -->
```

---

# Data Binding

## Page Data (`<page>.json`)

```json
{
  "title": "Static value",
  "$users": "models.user.Get",
  "$user": {
    "process": "models.user.Find",
    "args": ["$param.id"]
  },
  "$items": {
    "process": "@GetItems",
    "args": ["active", 10]
  }
}
```

- Keys with `$` prefix call processes
- `@MethodName` calls backend script functions
- Use `$param`, `$query`, `$payload` in args

## Backend Script (`<page>.backend.ts`)

There are three types of backend functions, each with different auth mechanisms:

```typescript
// Type declarations for auth
interface AuthorizedInfo {
  user_id?: string;
  email?: string;
  team_id?: string;
  owner_id?: string;
}
declare function Authorized(): AuthorizedInfo | null;

// 1. BeforeRender — called before page render
//    Auth: request.authorized (request is the first arg)
function BeforeRender(request: Request): Record<string, any> {
  const id = request.params.id;  // NOT $param.id
  return {
    user: Process("models.user.Find", id),
    items: Process("models.item.Get", { limit: 10 }),
  };
}

// 2. ApiXxx — called via $Backend().Call("GetUsers")
//    Auth: Authorized() global function (request is NOT passed)
//    Args: only what frontend sends
function ApiGetUsers(): any[] {
  const auth = Authorized();
  if (!auth?.user_id) throw new Error("unauthorized");
  return Process("models.user.Get", {
    wheres: [{ column: "user_id", value: auth.user_id }],
  });
}

// 3. @FuncName — called from .json data binding
//    Auth: request.authorized (request is appended as last arg by SUI core)
function GetRecord(request: Request): any {
  return Process("models.record.Find", request.params.id);
}
```

> **Important**: `$param` is NOT available in backend scripts. Use `request.params`.
>
> **CRITICAL**: `ApiXxx` functions called via `$Backend().Call` do NOT receive a `request`
> parameter. Use the `Authorized()` global function for user identity. See
> `references/backend-api.md` for the full explanation and Go runtime source references.

---

# Components

Every page is a component. Use `is` attribute or `<import>` to embed:

```html
<!-- Using is attribute -->
<div is="/card" title="My Card">
  <p>Content</p>
</div>

<!-- Using import -->
<import s:as="Card" s:from="/card" />
<Card title="My Card"><p>Content</p></Card>
```

## Component Structure

**`/card/card.html`**:

```html
<div class="card">
  <h3>{{ title }}</h3>
  <div class="body"><children></children></div>
</div>
```

## Named Slots

```html
<!-- Component -->
<div class="modal">
  <div class="header"><slot name="header"></slot></div>
  <div class="body"><children></children></div>
  <div class="footer"><slot name="footer"></slot></div>
</div>

<!-- Usage -->
<div is="/modal">
  <slot name="header"><h2>Title</h2></slot>
  <p>Body content</p>
  <slot name="footer"><button>OK</button></slot>
</div>
```

## Props Access

**Frontend** (`card.ts`):

```typescript
import { Component } from "@yao/sui";
const self = this as Component;

const title = self.props.Get("title");
const allProps = self.props.List();
```

**Backend** (`card.backend.ts`):

```typescript
function BeforeRender(request: Request, props: Record<string, any>): Record<string, any> {
  const userId = props.userId;
  return { user: Process("models.user.Find", userId) };
}
```

---

# Event Handling

## Event Binding

```html
<button s:on-click="HandleClick">Click</button>
<input s:on-input="HandleInput" />
<form s:on-submit="HandleSubmit">...</form>

<!-- With data -->
<button s:on-click="DeleteItem" s:data-id="{{ item.id }}" s:json-item="{{ item }}">
  Delete
</button>
```

## Event Handler

```typescript
import { $Backend, Component, EventData } from "@yao/sui";

const self = this as Component;

self.DeleteItem = async (event: Event, data: EventData) => {
  const id = data.id;           // From s:data-id
  const item = data.item;       // From s:json-item
  
  await $Backend().Call("DeleteItem", id);
  (event.target as HTMLElement).closest(".item")?.remove();
};

self.HandleSubmit = async (event: Event) => {
  event.preventDefault();
  const form = event.target as HTMLFormElement;
  const formData = new FormData(form);
  await $Backend().Call("Submit", Object.fromEntries(formData));
};
```

## State Management

```typescript
const self = this as Component;

// Set state
self.state.Set("count", 0);

// Watch changes
self.watch = {
  count: (value: number) => {
    self.root.querySelector(".count")!.textContent = String(value);
  },
};

// Trigger update
self.Increment = () => {
  const count = self.state.Get("count") || 0;
  self.state.Set("count", count + 1);
};
```

---

# Frontend API

## Backend Calls

```typescript
import { $Backend } from "@yao/sui";

// Calls ApiGetUsers() in backend script — no request param is passed
const users = await $Backend().Call("GetUsers");

// Calls ApiGetUser(123) — args are passed positionally
const user = await $Backend().Call("GetUser", 123);

// Calls ApiCreateItem("Widget", 9.99) in backend script
// Backend uses Authorized() to get user identity, NOT request param
const item = await $Backend().Call("CreateItem", "Widget", 9.99);
```

> **Note**: `$Backend().Call` sends only your explicit arguments to the backend `ApiXxx`
> function. Authentication info is injected into the V8 context by the guard system and
> accessed via the `Authorized()` global function — not via a `request` parameter.

## Component Query

```typescript
const card = $$("#my-card");        // Get component by ID
card.toggle();                       // Call method
card.state.Set("expanded", true);   // Set state

const button = component.find("button");   // Find child
const title = component.query(".title");   // Query element
```

## Render API

```html
<div s:render="userList" class="user-list"></div>
```

```typescript
self.RefreshUsers = async () => {
  const users = await $Backend().Call("GetUsers");
  await self.render("userList", { users });
};
```

## OpenAPI Client

```typescript
const api = new OpenAPI({ baseURL: "/api" });

const response = await api.Get<User[]>("/users");
if (!api.IsError(response)) {
  console.log(response.data);
}

await api.Post<User>("/users", { name: "John" });
```

---

# Page Configuration

**`<page>.config`**:

```json
{
  "title": "Page Title",
  "guard": "oauth:/login",
  "cache": 3600,
  "dataCache": 300,
  "api": {
    "defaultGuard": "oauth",
    "guards": {
      "PublicMethod": "-"
    }
  },
  "seo": {
    "title": "SEO Title",
    "description": "Meta description"
  }
}
```

## Guards

| Guard        | Description                   |
| ------------ | ----------------------------- |
| `oauth`      | OAuth 2.1 (recommended)       |
| `bearer-jwt` | Bearer token JWT              |
| `cookie-jwt` | Cookie-based JWT              |
| `-`          | Public (no auth)              |

## Guard Scope (CRITICAL — Two Separate Systems)

The `guard` and `api.defaultGuard` fields serve **different purposes**:

| Config Field | Protects | When It Runs | Sets Auth For |
|---|---|---|---|
| `"guard"` | **Page render** (HTML response) | When browser loads the page | `request.authorized`, `$auth` in templates |
| `"api.defaultGuard"` | **`$Backend().Call` API calls** | When frontend calls `$Backend().Call(...)` | `Authorized()` global function in V8 |

**You MUST set both** if your page has backend API methods that need authentication:

```json
{
  "guard": "oauth:/login",
  "api": { "defaultGuard": "oauth" }
}
```

**Common mistake**: Only setting `"guard"` but not `"api"`. The page renders correctly
(user sees the page), but clicking a button that calls `$Backend().Call("CreateItem")`
fails with `unauthorized` because `apiGuard()` has no config and skips authentication.

The guard value supports an optional redirect suffix separated by `:`:
- `"oauth"` — returns 403 JSON error if not authenticated
- `"oauth:/login"` — redirects to `/login` if not authenticated (for page guard only)

## Content Negotiation — Markdown Output

SUI pages can serve raw Markdown (or other formats) instead of HTML when requested with
a `.md` URL suffix or `Accept: text/markdown` header. This lets AI agents consume the
same URLs humans visit, but receive structured text instead of rendered HTML.

**How it works:**

1. Request comes in: `GET /docs/en-us/getting-started/what-is-yao-agents.md`
2. Middleware detects `.md` suffix → strips it, sets `content_type=markdown` on the context
3. SUI loads the page, parses config, finds `"markdown"` handler
4. Calls the handler (backend.ts method or Yao process) → returns raw text
5. Response: `200 text/markdown; charset=utf-8` with the raw content

**Config — using a backend.ts method (preferred):**

```json
{
  "title": "Documentation",
  "guard": "-",
  "markdown": {
    "method": "Markdown"
  }
}
```

The `method` calls a function exported from the page's own `backend.ts`. The function
receives the same `request` object as `Content` / `Catalog` / `BeforeRender`:

```typescript
function Markdown(request: any): string {
  const id = request.params?.id || "";
  // ... parse route, read file ...
  return fs.ReadFile(mdxPath);  // return raw markdown string
}
```

**Config — using a global Yao process (fallback):**

```json
{
  "markdown": {
    "process": "scripts.docs.RawMarkdown",
    "in": ["$param.id"]
  }
}
```

`in` supports `$param.*`, `$query.*`, `$header.*` placeholders (same as `http.yao`).

**Rules:**

- If config has no `markdown` field → `.md` requests return 404
- `method` takes priority over `process` when both are set
- Handler must return a `string` (or `[]byte`)
- Normal HTML requests (no `.md` suffix, no `Accept: text/markdown`) are unaffected
- The same pattern can be extended for other formats (e.g. `"json"` field in the future)

---

# Routing

## Dynamic Routes

Use `[param]` in folder names:

| Structure               | URL              | Access                    |
| ----------------------- | ---------------- | ------------------------- |
| `/users/[id]/`          | `/users/123`     | `{{ $param.id }}`         |
| `/[category]/[id]/`     | `/news/456`      | `{{ $param.category }}`   |

## URL Rewriting (`app.yao`)

```json
{
  "public": {
    "rewrite": [
      { "^\\/assets\\/(.*)$": "/assets/$1" },
      { "^\\/users\\/([^\\/]+)$": "/users/[id].sui" },
      { "^\\/(.*)$": "/$1.sui" }
    ]
  }
}
```

---

# Internationalization

## How It Works (Build Pipeline)

SUI i18n is **server-side rendered**. The build process:

1. Scans HTML for `s:trans` elements and `'::text'` / `__m("text")` markers
2. Extracts text, assigns auto-generated keys like `trans_<route>_1`, `trans_<route>_2`, ...
   (route slashes become underscores: route `features/hero` → key prefix `trans_features_hero`)
3. For each key, looks up translations via two mechanisms (in order):
   a. **`keys:`** — direct mapping `trans_<route>_N: translated text` (primary, used at runtime)
   b. **`messages:`** — original text → translated text lookup (used during build to populate `keys:`)
4. Writes compiled locale files to `public/.locales/<locale>/<route>.yml` with resolved `keys:` map
   (the `messages:` block is **stripped** from compiled output — only `keys:` survives)
5. At request time, reads the `locale` cookie, loads the compiled locale, and replaces text

**The `locale` cookie must use lowercase values: `zh-cn`, `en-us`, `ja`, etc.**

## Translation Markers

Three ways to mark translatable content:

```html
<!-- 1. s:trans attribute (most common) — wraps the element's text content -->
<span s:trans>Hello World</span>
<p s:trans>This entire paragraph will be translated.</p>
<button s:trans>Submit</button>

<!-- 2. Inline translation in template expressions -->
<span>{{ '::Welcome' }}</span>

<!-- 3. In frontend scripts — use __m() or T() -->
```

```typescript
const message = __m("Welcome back");
const label = T("Settings");
```

> **Important**: `T()` and `__m()` can only be called **inside functions**, not at module top-level.
> The SUI translation runtime is not initialized when top-level constants are evaluated.
>
> **Wrong** — `T()` at top level:
>
> ```typescript
> const LABEL = T("Hello"); // ✗ — T() not available yet, returns undefined or throws
> ```
>
> **Correct** — `T()` inside a function:
>
> ```typescript
> function init() {
>   const label = T("Hello"); // ✓ — literal string, called at runtime
> }
> ```

> **CRITICAL**: `T()` arguments MUST be **string literals**, not variables.
> The build system uses a regex (`transFuncRe`) to statically scan `.ts`/`.js` source for
> `T("...")` and `__m("...")` calls. Only literal string arguments are detected and registered
> as `ScriptMessages` in the build output. Variable arguments are invisible to the scanner,
> so the translation will be missing at runtime.
>
> The build pipeline for script translations:
> 1. `BuildScripts` reads the `.ts` file source
> 2. `translateScript` regex-matches `T("literal")` → creates `Translation{Type: "script"}`
> 3. `MergeTranslations` looks up the literal in `messages:` from the source locale YAML to get the translated value, then stores it in `ScriptMessages`
> 4. `writeLocaleFiles` clears `Messages` but keeps `ScriptMessages` in the build output (`public/.locales/`)
> 5. At runtime, `parser.Locale()` reads the build output → `ScriptMessages` is injected into `__sui_locale`
> 6. `T("literal")` at runtime looks up `__sui_locale["literal"]` and returns the translation
>
> **Wrong** — variable argument (translation silently missing):
>
> ```typescript
> const DOWNLOADS = { macos: { labelKey: "Download for macOS", url: "..." } };
> function init() {
>   const info = DOWNLOADS["macos"];
>   label.textContent = T(info.labelKey); // ✗ — build cannot detect the string
> }
> ```
>
> **Correct** — string literal arguments:
>
> ```typescript
> function downloadLabel(os: string): string {
>   if (os === "windows") return T("Download for Windows");
>   if (os === "linux") return T("Download for Linux");
>   return T("Download for macOS");
> }
> ```
>
> **Also required**: The English source string (e.g. `"Download for macOS"`) must exist as a
> key in the `messages:` section of the **page-level** source locale file (e.g.
> `__locales/zh-cn/features.yml`) with its translation as the value. This is how
> `MergeTranslations` resolves the translated text for `ScriptMessages`.

> **Important**: `s:trans` captures the **trimmed text content** of the element as the lookup key.
> The key in `messages:` must match **exactly** (case-sensitive, whitespace-trimmed).

## Locale File Structure

Locale files live under the template root `__locales/` directory, organized by locale:

```
__locales/
├── en-us/
│   ├── __global.yml        # Shared translations (header, footer, etc.)
│   ├── index.yml           # Page-specific: /index
│   ├── features.yml        # Page-specific: /features (parent page itself)
│   ├── features/           # Sub-component locale files
│   │   ├── hero.yml        # For component at /features/hero
│   │   ├── agents.yml      # For component at /features/agents
│   │   └── cta.yml         # For component at /features/cta
│   └── styleguide.yml      # Page-specific: /styleguide
├── zh-cn/
│   ├── __global.yml
│   ├── index.yml
│   ├── features.yml
│   ├── features/
│   │   ├── hero.yml
│   │   ├── agents.yml
│   │   └── cta.yml
│   └── styleguide.yml
├── zh-tw/
│   └── ...
└── ja/
    └── ...
```

**CRITICAL rules:**

- Directory names MUST be **lowercase**: `zh-cn`, `en-us`, `ja` (not `zh-CN`)
- Page locale file name = page route: route `/styleguide` → `styleguide.yml`
- `__global.yml` is merged into every page's locale at build time
- **Sub-component translations MUST go in route-matching files** (see next section)
- Do NOT put locale files inside component source directories (e.g. ~~`features/hero/__locales/`~~)

## Sub-Component Locale Files (CRITICAL)

When a page includes sub-components via `is="/features/hero"`, each sub-component gets its
own translation key prefix based on its route. The build process uses **prefix filtering** to
match keys to locale files:

- Parent page `/features` → key prefix `trans_features_` → reads `__locales/<lang>/features.yml`
- Sub-component `/features/hero` → key prefix `trans_features_hero_` → reads `__locales/<lang>/features/hero.yml`
- Sub-component `/features/agents` → key prefix `trans_features_agents_` → reads `__locales/<lang>/features/agents.yml`

**The parent page's `MergeTranslations` only processes keys matching its own prefix.**
Sub-component keys (e.g. `trans_features_hero_*`) do NOT match the parent prefix (`trans_features_*`),
so they are resolved from their own locale files via `Merge(compLocale)`.

**Wrong** — putting all translations in the parent file only:

```
__locales/zh-cn/
└── features.yml          # Has messages for hero, agents, etc.
                          # ✗ — sub-component keys won't be resolved!
```

**Correct** — one locale file per route (parent + each sub-component):

```
__locales/zh-cn/
├── features.yml          # Translations for features.html itself (if any)
└── features/
    ├── hero.yml          # Translations for features/hero component
    ├── agents.yml        # Translations for features/agents component
    ├── technical.yml     # Translations for features/technical component
    └── cta.yml           # Translations for features/cta component
```

Each sub-component file needs both `keys:` and `messages:`:

```yaml
# __locales/zh-cn/features/hero.yml
keys:
    trans__features_hero_1: AI 团队
    trans__features_hero_2: 一应俱全
messages:
  "Your AI Team": "AI 团队"
  "Ready for Action": "一应俱全"
```

### Managing `keys:` (CRITICAL — Learned the Hard Way)

**NEVER clear `keys:` to `{}` and expect `yao sui trans` to regenerate them.**
`yao sui trans` only updates *existing* keys — it does NOT create new keys from scratch.

The `keys:` are generated during `yao sui build`, which scans HTML `s:trans` tags, assigns
sequential numbers, and resolves translations from `messages:`. The result is written to
**compiled output** at `public/.locales/<lang>/<route>.yml`, NOT back to source files.

**When you add new `s:trans` content to an existing component:**

1. Ensure the new text has corresponding `messages:` entries in the locale file
2. Run `yao sui build` — it generates compiled locale with the new keys
3. Check compiled output: `cat public/.locales/<lang>/<route>/component.yml`
4. Copy the new `keys:` block from compiled output back into the source locale file
5. Run `yao sui build` again to finalize

**If you accidentally cleared `keys: {}`:**

1. Run `yao sui build` — the compiled output still generates correct keys from `messages:`
2. Extract keys from compiled output:
   ```bash
   cat public/.locales/zh-cn/features/hero.yml | grep "trans__features_hero_"
   ```
3. Paste the keys block back into `__locales/zh-cn/features/hero.yml`
4. Rebuild

**Why this matters**: At render time, SUI runtime uses `keys:` from the **source locale files**
(merged during build). If source `keys:` is empty, the compiled output gets empty keys too
(for that locale), and translations silently fall back to English.

### How `keys:` and `messages:` Work Together (Source Code Reference)

The build pipeline (`sui/core/locale.go`) resolves translations in this order:

1. **`MergeTranslations`**: For each extracted `s:trans` text, finds the matching `messages:` entry
   and writes the translated value into `keys:` (e.g. `trans_features_hero_1: 翻译值`)
2. **`ParseKeys`**: If a `keys:` value points to another `messages:` key (indirect reference),
   resolves it to the final translated text
3. **`Merge(compLocale)`**: Merges sub-component locale data (their `keys:`) into the parent page locale

At **render time** (`sui/core/parser.go`), the `transNode` function checks:
1. First: `keys[key]` — if found and differs from source text, use it
2. Then: `messages[sourceText]` — fallback lookup
3. Otherwise: return original text unchanged

Since compiled output **strips `messages:`**, runtime effectively relies on `keys:` only.

## Locale File Format

**`__locales/zh-cn/__global.yml`** — shared across all pages:

```yaml
direction: ltr
timezone: "+08:00"
messages:
  Home: 首页
  Features: 特性
  "Get Started": 开始使用
```

**`__locales/zh-cn/styleguide.yml`** — page-specific:

```yaml
messages:
  "Toggle Theme": 切换主题
  "Used for buttons, links, and interactive elements.": 用于按钮、链接等交互元素。
```

**`__locales/en-us/__global.yml`** — default locale (key = value):

```yaml
messages:
  Home: Home
  Features: Features
  "Get Started": Get Started
```

### Format Details

| Field | Purpose | Required |
| ----- | ------- | -------- |
| `messages:` | Map of original text → translated text. Key = exact text from `s:trans` element | Yes |
| `direction:` | Text direction: `ltr` or `rtl` | No |
| `timezone:` | Timezone offset string | No |
| `keys:` | Auto-generated by `yao sui trans` — maps `trans_<route>_N` to translated text. Do NOT edit manually | No |
| `script_messages:` | Translations for `__m()` / `T()` calls in `.ts` scripts | No |

> **`messages:` key matching**: The key must be the exact trimmed text content from the HTML.
> For `<span s:trans>Hello World</span>`, the key is `Hello World`.

### Mixed HTML Content — Use Separate Elements

`s:trans` captures the **full innerHTML** of the element. If an element contains child HTML tags
(like `<strong>`, `<a>`, `<code>`), the entire innerHTML becomes the key, which is fragile and
often fails to match. **Always split mixed content into separate translatable elements.**

**Wrong** — `s:trans` on a parent that contains child tags:

```html
<li s:trans><strong>Primary</strong> — Main page action (CTA)</li>
<!-- key becomes: "<strong>Primary</strong> — Main page action (CTA)" -->
<!-- This will NOT match in the locale file -->

<p s:trans>Click <a href="#">here</a> to continue.</p>
<!-- key becomes: "Click <a href=\"#\">here</a> to continue." — won't match -->
```

**Correct** — put `s:trans` only on leaf elements containing pure text:

```html
<li><strong>Primary</strong> — <span s:trans>Main page action (CTA)</span></li>
<!-- key: "Main page action (CTA)" ✓ -->

<p><span s:trans>Click</span> <a href="#" s:trans>here</a> <span s:trans>to continue.</span></p>
<!-- keys: "Click", "here", "to continue." ✓ -->
```

**Rule of thumb**: if an element contains any child HTML tags, do NOT put `s:trans` on it.
Instead, wrap each translatable text segment in its own `<span s:trans>` or put `s:trans`
on the child element directly. Non-translatable content (variable names, CSS class names,
code tokens) stays outside `s:trans` elements.

### YAML Value Quoting

YAML values that contain colons (`:`) MUST be quoted, otherwise the YAML parser treats
the colon as a nested mapping delimiter and the entire file fails to parse.

**Wrong**:

```yaml
messages:
  "Header bar, floating panels.": 顶栏。配合 backdrop-filter: blur 使用。
  # ← YAML parser error: "mapping values are not allowed here"
```

**Correct**:

```yaml
messages:
  "Header bar, floating panels.": "顶栏。配合 backdrop-filter: blur 使用。"
  # ← value is quoted, colon inside is safe
```

**Always quote values that contain**: `:`, `#`, `{`, `}`, `[`, `]`, `,`, `&`, `*`, `?`, `|`, `-`, `<`, `>`, `=`, `!`, `%`, `@`, `` ` ``.

## Build Commands

```bash
yao sui trans <sui> <template>              # Extract strings, generate/update locale scaffolds, then build
yao sui trans <sui> <template> -l zh-cn,ja  # Only specific locales
yao sui build <sui> <template>              # Build only (compiles translations into public/)
```

`yao sui trans` scans all pages, extracts `s:trans` / `__m()` text, merges with existing
`messages:` in locale files, generates `keys:` mappings, and then triggers a full build.
After adding new locale files, **always run `yao sui trans` first** (not just `build`)
to ensure `keys:` are properly generated from `messages:`.

## Workflow: Adding i18n to a New Page

1. Add `s:trans` attributes to all translatable text in HTML
2. Create locale files matching the page route structure:
   - `__locales/<lang>/<page>.yml` for the page itself
   - `__locales/<lang>/<page>/<component>.yml` for each sub-component
3. Fill in `messages:` with `"English text": "翻译"` pairs (set `keys: {}` initially)
4. Run `yao sui build <sui> <template>` to compile — this generates correct `keys:` in compiled output
5. Extract keys from compiled output and write them back to source locale files:
   ```bash
   cat public/.locales/zh-cn/<page>/<component>.yml | grep "trans__"
   ```
6. Paste the `keys:` block into `__locales/zh-cn/<page>/<component>.yml` (replacing `keys: {}`)
7. Run `yao sui build` again to finalize
8. Verify: switch language in browser, confirm all text is translated

## Workflow: Adding New Translatable Content to an Existing Page

1. Add new `s:trans` elements in the component HTML
2. Add corresponding `messages:` entries to the locale files (all languages)
3. Run `yao sui build` — compiled output will contain new keys (e.g. `trans__features_hero_21` onwards)
4. Extract the **full** keys block from compiled output and replace source `keys:` with it
5. Rebuild — translations will now work correctly

**Do NOT** clear `keys:` to `{}` expecting `yao sui trans` to regenerate.
**Do NOT** rely on `yao sui trans` alone to add new keys — it only updates existing ones.

> **PITFALL — New `s:trans` elements not translated after build**
>
> When you add a **new** `s:trans` element to an existing component HTML, the build will
> auto-assign the next sequential key (e.g. `trans__features_cta_4`). However, the new key
> only appears in the **compiled** output (`public/.locales/`), not in the source locale
> file (`__locales/`). If the source `keys:` block does not contain this new key with its
> translated value, the compiled output will fall back to the English original.
>
> **Symptom**: New `s:trans` text stays in English while other text on the page translates fine.
>
> **Fix**: After adding new `s:trans` elements:
> 1. Add the English → translated mapping to `messages:` in all locale files
> 2. Run `yao sui build` once to generate the new key numbers
> 3. Check the compiled output `public/.locales/<lang>/<route>.yml` to find the new key
>    (e.g. `trans__features_cta_4: Other platforms & architectures →`)
> 4. Copy the new key back to the source `keys:` in `__locales/<lang>/<route>.yml`,
>    replacing the English value with the correct translation
> 5. Rebuild
>
> **Example**: Adding `<a s:trans>Other platforms & architectures →</a>` to `cta.html`
> that already had 3 translated elements:
> ```yaml
> # __locales/zh-cn/features/cta.yml — BEFORE (broken)
> keys:
>     trans__features_cta_1: 你的 AI 团队待命中
>     trans__features_cta_2: 下载即用，几分钟上手。
>     trans__features_cta_3: 下载
> # key _4 is missing → "Other platforms & architectures →" stays in English
>
> # __locales/zh-cn/features/cta.yml — AFTER (fixed)
> keys:
>     trans__features_cta_1: 你的 AI 团队待命中
>     trans__features_cta_2: 下载即用，几分钟上手。
>     trans__features_cta_3: 下载
>     trans__features_cta_4: 其他平台与架构 →
> ```

## Dynamic `<html lang>`

Use the built-in `$locale` variable to set the document language dynamically:

```html
<html lang="{{ $locale }}">
```

## Client-Side Locale Switching

Set the `locale` cookie (lowercase!) and reload:

```javascript
document.cookie = "locale=zh-cn;path=/;max-age=31536000";
window.location.reload();
```

SUI reads the `locale` cookie on each request to determine which compiled locale file to use.

---

# Agent SUI

Special configuration for AI Agent apps. Loads from `/agent/template/` and `/assistants/<name>/pages/`.

## Route Mapping

| File Path                                    | Public URL               |
| -------------------------------------------- | ------------------------ |
| `/agent/template/pages/login/login.html`     | `/agents/login`          |
| `/assistants/demo/pages/index/index.html`    | `/agents/demo/index`     |

## Build

```bash
yao sui build agent
yao sui watch agent
```

## CUI Integration

```typescript
// Receive context from CUI
window.addEventListener("message", (e) => {
  if (e.data.type === "setup") {
    const { theme, locale } = e.data.message;
    document.documentElement.setAttribute("data-theme", theme);
  }
});

// Send actions to CUI
const sendAction = (name: string, payload?: any) => {
  window.parent.postMessage(
    { type: "action", message: { name, payload } },
    window.location.origin
  );
};

sendAction("notify.success", { message: "Done!" });
sendAction("navigate", { route: "/agents/demo/detail", title: "Details" });
```

---

# Quick Reference

## Common Patterns

**Protected page with API** (always set both guards):

```json
// page.config — BOTH "guard" AND "api.defaultGuard" are required
{ "guard": "oauth:/login", "api": { "defaultGuard": "oauth" } }
```

**Backend script with auth** (`ApiXxx` uses `Authorized()`, NOT `request`):

```typescript
// page.backend.ts
declare function Authorized(): { user_id?: string; team_id?: string } | null;

function ApiDeleteItem(id: string) {
  const auth = Authorized();
  if (!auth?.user_id) throw new Error("unauthorized");
  Process("models.item.Delete", id);
  return { success: true };
}
```

**List with delete**:

```html
<div s:for="{{ items }}" s:for-item="item">
  {{ item.name }}
  <button s:on-click="Delete" s:data-id="{{ item.id }}">×</button>
</div>
```

```typescript
self.Delete = async (e: Event, data: EventData) => {
  await $Backend().Call("DeleteItem", data.id);
  (e.target as HTMLElement).closest("div")?.remove();
};
```

**Form submission**:

```html
<form s:on-submit="Submit">
  <input name="email" type="email" required />
  <button type="submit">Submit</button>
</form>
```

```typescript
self.Submit = async (e: Event) => {
  e.preventDefault();
  const form = e.target as HTMLFormElement;
  await $Backend().Call("Save", Object.fromEntries(new FormData(form)));
};
```

## File Reference

| File                | Purpose                     |
| ------------------- | --------------------------- |
| `<page>.html`       | Template                    |
| `<page>.css`        | Styles (auto-scoped)        |
| `<page>.ts`         | Frontend script             |
| `<page>.json`       | Data configuration          |
| `<page>.config`     | Page settings               |
| `<page>.backend.ts` | Server-side logic           |
| `__document.html`   | Global wrapper              |
| `__data.json`       | Global data                 |
| `__locales/`        | i18n translations           |
| `__assets/`         | Static files                |

