sui-development

star 1

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.

YaoApp By YaoApp schedule Updated 4/5/2026

name: sui-development description: 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.

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

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

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

Conditionals

<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

<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

<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

{{ 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)

{
  "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:

// 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:

<!-- 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:

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

Named Slots

<!-- 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):

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):

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

<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

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

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

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

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

<div s:render="userList" class="user-list"></div>
self.RefreshUsers = async () => {
  const users = await $Backend().Call("GetUsers");
  await self.render("userList", { users });
};

OpenAPI Client

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:

{
  "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:

{
  "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):

{
  "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:

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):

{
  "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)

{
  "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:

<!-- 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() -->
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.

WrongT() at top level:

const LABEL = T("Hello"); // ✗ — T() not available yet, returns undefined or throws

CorrectT() inside a function:

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):

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:

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 /styleguidestyleguide.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::

# __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:
    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:

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

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

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

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

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.

Wrongs:trans on a parent that contains child tags:

<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:

<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:

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

Correct:

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

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

Build Commands

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:
    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:

# __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 lang="{{ $locale }}">

Client-Side Locale Switching

Set the locale cookie (lowercase!) and reload:

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

yao sui build agent
yao sui watch agent

CUI Integration

// 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):

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

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

// 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:

<div s:for="{{ items }}" s:for-item="item">
  {{ item.name }}
  <button s:on-click="Delete" s:data-id="{{ item.id }}">×</button>
</div>
self.Delete = async (e: Event, data: EventData) => {
  await $Backend().Call("DeleteItem", data.id);
  (e.target as HTMLElement).closest("div")?.remove();
};

Form submission:

<form s:on-submit="Submit">
  <input name="email" type="email" required />
  <button type="submit">Submit</button>
</form>
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
Install via CLI
npx skills add https://github.com/YaoApp/yao-init --skill sui-development
Repository Details
star Stars 1
call_split Forks 4
navigation Branch main
article Path SKILL.md
More from Creator