Merge pull request #1409 from trheyi/main

Implement Multi-Directory Watching and Enhance Locale File Handling
This commit is contained in:
Max 2026-01-03 17:49:35 +08:00 committed by GitHub
commit 369d227bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1685 additions and 390 deletions

View file

@ -184,6 +184,7 @@ ctx.Send({
- [Models](docs/models.md) - Assistant-scoped data models
- [Search](docs/search.md) - Web, knowledge base, and database search
- [Pages](docs/pages.md) - Web UI for agents (SUI framework)
- [Iframe Integration](docs/iframe.md) - Iframe communication with CUI
- [Internationalization](docs/i18n.md) - Multi-language support
- [Testing](docs/testing.md) - Agent testing framework

300
agent/docs/iframe.md Normal file
View file

@ -0,0 +1,300 @@
# Iframe Integration
Agent Pages can be embedded in CUI via `/web/` routes. This document covers the iframe communication mechanism between embedded pages and the CUI host.
## Route Mapping
Pages are accessible via:
```
/web/<assistant-id>/<page-path>
```
Example:
| Page File | URL |
| -------------------------- | --------------------------------- |
| `pages/index/index.html` | `/web/my-assistant/index` |
| `pages/result/index.html` | `/web/my-assistant/result` |
| `pages/report/detail.html` | `/web/my-assistant/report/detail` |
## URL Parameters
CUI automatically injects context via URL parameters:
| Parameter | Value | Description |
| ---------- | ---------------------- | ------------- |
| `__theme` | `light` / `dark` | Current theme |
| `__locale` | `en-us`, `zh-cn`, etc. | User locale |
> **Note**: Authentication uses secure HTTP-only cookies, so `__token` parameter is not needed.
**Usage in page URL:**
```
/web/my-assistant/result?theme=__theme&locale=__locale
```
CUI replaces `__theme`, `__locale` with actual values before loading.
## Message Communication
### Receiving Setup Message
When the iframe loads, CUI sends a `setup` message:
```typescript
// In your page script
window.addEventListener("message", (e) => {
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
// Apply theme, set locale
document.documentElement.setAttribute("data-theme", theme);
}
});
```
### Sending Actions to CUI
Pages can trigger CUI actions via `postMessage` using the unified Action system:
```typescript
// Send action to parent CUI
window.parent.postMessage(
{
type: "action",
message: {
name: "notify.success",
payload: { message: "Operation completed" },
},
},
window.location.origin
);
```
### Action Types
#### Navigate
| Action | Description | Payload |
| --------------- | ------------------------------- | ------------------------------------------- |
| `navigate` | Open page in sidebar or new tab | `{ route, title?, icon?, query?, target? }` |
| `navigate.back` | Navigate back in history | - |
**Navigate Payload:**
| Field | Type | Required | Description |
| -------- | ------------------------ | -------- | ----------------------------------------------- |
| `route` | `string` | ✅ | Target route (`$dashboard/xxx`, `/xxx`, or URL) |
| `title` | `string` | - | Page title (shows title bar with back button) |
| `icon` | `string` | - | Tab icon (e.g., `material-folder`) |
| `query` | `Record<string, string>` | - | Query parameters |
| `target` | `'_self'` \| `'_blank'` | - | `_self` (sidebar) or `_blank` (new window) |
#### Notify
| Action | Description | Payload |
| ---------------- | ------------------------- | ------------------------------------------ |
| `notify.success` | Show success notification | `{ message, duration?, icon?, closable? }` |
| `notify.error` | Show error notification | `{ message, duration?, icon?, closable? }` |
| `notify.warning` | Show warning notification | `{ message, duration?, icon?, closable? }` |
| `notify.info` | Show info notification | `{ message, duration?, icon?, closable? }` |
#### App
| Action | Description |
| ----------------- | ------------------------ |
| `app.menu.reload` | Refresh application menu |
#### Modal
| Action | Description |
| ------------- | ----------------- |
| `modal.open` | Open modal dialog |
| `modal.close` | Close modal |
#### Table
| Action | Description |
| --------------- | -------------------- |
| `table.search` | Trigger table search |
| `table.refresh` | Refresh table data |
| `table.save` | Save table row |
| `table.delete` | Delete table row(s) |
#### Form
| Action | Description |
| ----------------- | --------------------- |
| `form.find` | Load form data by ID |
| `form.submit` | Submit form |
| `form.reset` | Reset form |
| `form.setFields` | Set form field values |
| `form.fullscreen` | Toggle fullscreen |
#### MCP (Client-side)
| Action | Description |
| ------------------- | ------------------ |
| `mcp.tool.call` | Execute MCP tool |
| `mcp.resource.read` | Read MCP resource |
| `mcp.resource.list` | List MCP resources |
| `mcp.prompt.get` | Get MCP prompt |
| `mcp.prompt.list` | List MCP prompts |
#### Event
| Action | Description |
| ------------ | ----------------- |
| `event.emit` | Emit custom event |
#### Confirm
| Action | Description |
| --------- | ------------------------ |
| `confirm` | Show confirmation dialog |
### Receiving Events from CUI
CUI can send messages to iframe via `web/sendMessage` event:
```typescript
// In your page script
window.addEventListener("message", (e) => {
const { type, message } = e.data;
switch (type) {
case "setup":
// Initial setup with theme, locale
break;
case "refresh":
// CUI requests page refresh
location.reload();
break;
case "data":
// CUI sends data update
handleDataUpdate(message);
break;
}
});
```
## Complete Example
### Page HTML (pages/result/index.html)
```html
<!DOCTYPE html>
<html>
<head>
<title>Result Page</title>
<script src="@assets/js/result.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
```
### Page Script (pages/result/result.ts)
```typescript
import { $Backend, Component, EventData } from "@yao/sui";
const self = this as Component;
// Helper: Send action to CUI parent
const sendAction = (name: string, payload?: any) => {
try {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
} catch (err) {
console.error("Failed to send action to parent:", err);
}
};
// Initialize message listener
function init() {
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
const { type, message } = e.data;
switch (type) {
case "setup":
// Apply theme, locale from CUI
document.documentElement.setAttribute("data-theme", message.theme);
break;
case "update":
// Handle data updates from CUI
console.log("Received update:", message);
break;
}
});
// Make helper available globally
(window as any).sendAction = sendAction;
}
init();
// Event handler: Show success notification
self.HandleSuccess = (event: Event, data: EventData) => {
sendAction("notify.success", { message: data.message || "Success!" });
};
// Event handler: Navigate to page
self.HandleNavigate = (event: Event, data: EventData) => {
sendAction("navigate", {
route: data.path,
title: data.title,
});
};
// Event handler: Close sidebar
self.HandleClose = () => {
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
};
// Event handler: Call backend and display result
self.HandleQuery = async (event: Event, data: EventData) => {
try {
const result = await $Backend().Call("Query", data.id);
console.log(result);
} catch (error: any) {
sendAction("notify.error", { message: error.message });
}
};
```
## Triggering from Hooks
Open page in sidebar from agent hooks:
```typescript
function Next(ctx: agent.Context, payload: agent.Payload): agent.Next {
// Open result page in sidebar
ctx.Send({
type: "action",
props: {
name: "navigate",
payload: {
route: `/agents/my-assistant/result`,
title: "Results",
query: { id: resultId },
},
},
});
return null;
}
```
See [Pages](pages.md) for more details on triggering pages from hooks.
## Security Notes
1. **Same-origin only**: Messages are only processed from same-origin iframes
2. **Secure cookies**: Authentication uses HTTP-only cookies, no token in URL
3. **Validate messages**: Always validate message structure before processing

View file

@ -68,6 +68,16 @@ Map Yao Processes directly to MCP tools:
}
```
**HTTP (REST API)**
```json
{
"transport": "http",
"url": "https://mcp.example.com/api",
"authorization_token": "$ENV.TOKEN"
}
```
**SSE (Server-Sent Events)**
```json

View file

@ -148,16 +148,65 @@ function ApiGetData(request: Request): any {
**`/assistants/my-assistant/pages/index/index.ts`**:
```typescript
function index(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
Frontend scripts can be written in two styles:
this.handleClick = async (event: Event) => {
const data = await this.backend.ApiGetData({ id: 1 });
console.log(data);
};
}
**Style 1: Direct Code (Simple Pages)**
```typescript
// Runs immediately when script loads
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#myForm") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Handle form submission
});
});
// Smooth scrolling for navigation
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
target?.scrollIntoView({ behavior: "smooth" });
});
});
```
**Style 2: Component Pattern (Interactive Pages)**
```typescript
import { $Backend, Component, EventData } from "@yao/sui";
const self = this as Component;
// Event handler bound to s:on-click="HandleClick"
self.HandleClick = async (event: Event, data: EventData) => {
const result = await $Backend().Call("GetData", data.id);
console.log(result);
};
// Form submission handler
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));
};
```
**Using Backend API:**
```typescript
import { $Backend, Yao } from "@yao/sui";
// Call backend method
const data = await $Backend().Call("MethodName", arg1, arg2);
// Direct API calls
const yao = new Yao();
const res = await yao.Get("/api/endpoint", { param: "value" });
await yao.Post("/api/endpoint", { data: "value" });
```
### 6. Build and Run
@ -495,23 +544,103 @@ ctx.Send({
## Frontend API
The SUI frontend SDK provides:
### Backend Calls
```typescript
// Backend calls
const data = await this.backend.ApiMethodName(payload);
import { $Backend, Yao } from "@yao/sui";
// State management
this.store.Set("key", value);
const value = this.store.Get("key");
// Call backend method defined in .backend.ts
const data = await $Backend().Call("MethodName", arg1, arg2);
// OpenAPI client (if using oauth guard)
const response = await OpenAPI.Get("/api/endpoint");
await OpenAPI.Post("/api/endpoint", data);
// Direct API calls
const yao = new Yao();
const res = await yao.Get("/api/endpoint", { query: "value" });
await yao.Post("/api/endpoint", { body: "data" });
```
### State Management
```typescript
import { Component } from "@yao/sui";
const self = this as Component;
// Store values (per component instance)
self.store.Set("key", value);
const value = self.store.Get("key");
```
### Parent Communication (Iframe)
```typescript
// Helper: Send action to CUI parent
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Usage
sendAction("notify.success", { message: "Done!" });
sendAction("navigate", {
route: "/agents/my-assistant/detail",
title: "Details",
});
// Receive messages from parent
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
const { type, message } = e.data;
if (type === "setup") {
document.documentElement.setAttribute("data-theme", message.theme);
}
});
```
## Iframe Communication
When pages are embedded in CUI via `/web/<assistant-id>/<page>`, they can communicate with the host:
### Receiving Context
```javascript
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
// Apply theme, set locale
document.documentElement.setAttribute("data-theme", theme);
}
});
```
### Sending Actions
```javascript
// Helper function
const sendAction = (name, payload) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Show notification
sendAction("notify.success", { message: "Done!" });
// Navigate to page
sendAction("navigate", {
route: "/agents/my-assistant/detail",
title: "Details",
});
```
See [Iframe Integration](iframe.md) for complete documentation.
## Related Documentation
- [Iframe Integration](iframe.md) - CUI iframe communication
- [SUI Template Syntax](../../sui/docs/template-syntax.md)
- [SUI Data Binding](../../sui/docs/data-binding.md)
- [SUI Components](../../sui/docs/components.md)

View file

@ -99,7 +99,20 @@ var WatchCmd = &cobra.Command{
return
}
go watch(root, func(event, name string) {
// Get all directories to watch
watchDirs := []string{root}
if watchDirsProvider, ok := tmpl.(core.IWatchDirs); ok {
watchDirs = []string{}
watchRoot := cfg.DataRoot
if watchDirsProvider.GetWatchRoot() == "app" {
watchRoot = cfg.Root
}
for _, dir := range watchDirsProvider.GetWatchDirs() {
watchDirs = append(watchDirs, filepath.Join(watchRoot, dir))
}
}
go watchMultiple(watchDirs, func(event, name string) {
if event == "WRITE" || event == "CREATE" || event == "RENAME" {
// @Todo build single page and sync single asset file to public
fmt.Print(color.WhiteString("Building... "))
@ -134,6 +147,15 @@ var WatchCmd = &cobra.Command{
fmt.Println(color.WhiteString("Public Root: /public%s", publicRoot))
fmt.Println(color.WhiteString(" Template: %s", tmpl.GetRoot()))
fmt.Println(color.WhiteString(" Session: %s", strings.TrimLeft(data, "::")))
fmt.Println(color.WhiteString("Watch Dirs:"))
for _, dir := range watchDirs {
// Show path relative to either app root or data root
displayDir := strings.TrimPrefix(dir, cfg.Root)
if displayDir == dir {
displayDir = strings.TrimPrefix(dir, cfg.DataRoot)
}
fmt.Println(color.WhiteString(" - %s", displayDir))
}
fmt.Println(color.WhiteString("-----------------------"))
fmt.Println(color.GreenString("Watching..."))
fmt.Println(color.GreenString("Press Ctrl+C to exit"))
@ -151,6 +173,116 @@ var WatchCmd = &cobra.Command{
},
}
func watchMultiple(roots []string, handler func(event string, name string), interrupt chan uint8) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer watcher.Close()
shutdown := make(chan bool, 1)
// Walk all root directories
watchedCount := 0
for _, root := range roots {
// Check if root exists
if _, err := os.Stat(root); os.IsNotExist(err) {
fmt.Println(color.YellowString("[Watch] Directory not found: %s", root))
continue
}
err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
if err != nil {
log.Warn("[Watch] Error accessing path %s: %v", path, err)
return nil // Skip this path and continue walking
}
if info.IsDir() {
if filepath.Base(path) == ".tmp" {
return filepath.SkipDir
}
err = watcher.Add(path)
if err != nil {
return err
}
watchedCount++
log.Info("[Watch] Watching: %s", path)
watched.Store(path, true)
}
return nil
})
if err != nil {
fmt.Println(color.YellowString("[Watch] Error walking root %s: %v", root, err))
}
}
fmt.Println(color.GreenString("[Watch] Total directories watched: %d", watchedCount))
go func() {
for {
select {
case <-shutdown:
log.Info("[Watch] handler exit")
return
case event, ok := <-watcher.Events:
if !ok {
interrupt <- 1
return
}
basname := filepath.Base(event.Name)
isdir := true
if strings.Contains(basname, ".") {
isdir = false
}
events := strings.Split(event.Op.String(), "|")
for _, eventType := range events {
// ADD / REMOVE Watching dir
if isdir {
switch eventType {
case "CREATE":
log.Info("[Watch] Watching: %s", event.Name)
watcher.Add(event.Name)
watched.Store(event.Name, true)
break
case "REMOVE":
log.Info("[Watch] Unwatching: %s", event.Name)
watcher.Remove(event.Name)
watched.Delete(event.Name)
break
}
continue
}
handler(eventType, event.Name)
log.Info("[Watch] %s %s", eventType, event.Name)
}
break
case err, ok := <-watcher.Errors:
if !ok {
interrupt <- 2
return
}
log.Error("[Watch] Error: %s", err.Error())
break
}
}
}()
for {
select {
case code := <-interrupt:
shutdown <- true
log.Info("[Watch] Exit(%d)", code)
fmt.Println(color.YellowString("[Watch] Exit(%d)", code))
return nil
}
}
}
func watch(root string, handler func(event string, name string), interrupt chan uint8) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
@ -160,6 +292,10 @@ func watch(root string, handler func(event string, name string), interrupt chan
shutdown := make(chan bool, 1)
err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
if err != nil {
log.Warn("[Watch] Error accessing path %s: %v", path, err)
return nil // Skip this path and continue walking
}
if info.IsDir() {
if filepath.Base(path) == ".tmp" {
return filepath.SkipDir

View file

@ -18,16 +18,18 @@ SUI is a full-stack web development framework that allows you to create web appl
```
/templates/<template_name>/
├── __document.html # Global document template
├── __assets/ # Static assets
├── __data.json # Global data (accessible via $global)
├── __assets/ # Static assets (reference via @assets/)
├── __locales/ # Locale files
└── <route>/ # Pages
└── <page>/
├── <page>.html # HTML template
└── pages/ # All pages go here
└── <page>/ # Route = folder name (can be nested)
├── <page>.html # HTML template (filename must match folder)
├── <page>.css # Styles
├── <page>.ts # Frontend script
├── <page>.json # Data configuration
├── <page>.config # Page configuration
└── <page>.backend.ts # Backend script
├── <page>.backend.ts # Backend script
└── __locales/ # Page-level locale files
```
### Basic Page
@ -75,7 +77,7 @@ yao sui watch agent
- [Data Binding](docs/data-binding.md) - Built-in variables and functions
- [Event Handling](docs/event-handling.md) - Event binding and state management
- [Internationalization](docs/i18n.md) - Translation and localization
- [Frontend API](docs/frontend-api.md) - Component query, backend calls, render API
- [Frontend API](docs/frontend-api.md) - Component query, backend calls, render API, CUI integration
- [Agent SUI](docs/agent-sui.md) - AI Agent application setup
## Agent SUI
@ -85,13 +87,16 @@ Agent SUI is designed for AI Agent applications with automatic page loading from
```
<app>/
├── agent/
│ └── template/ # Agent SUI template
│ └── template/ # Agent SUI template (shared)
│ ├── __document.html
│ ├── __data.json
│ ├── __assets/
│ └── pages/
│ └── pages/ # Global pages (401, 404, etc.)
│ └── <page>/
└── assistants/
└── <name>/
└── pages/ # Assistant pages
└── pages/ # Assistant pages → /agents/<name>/<route>
└── <page>/
```
Build with: `yao sui build agent`

View file

@ -118,3 +118,13 @@ type IComponent interface {
Load() error
Source() string
}
// IWatchDirs is an optional interface for templates that need to watch multiple directories
type IWatchDirs interface {
// GetWatchDirs returns all directories that should be watched for changes
// The returned paths are relative to the application source root (not data root)
GetWatchDirs() []string
// GetWatchRoot returns the root directory for watch paths
// Returns "app" for application source root, "data" for data root
GetWatchRoot() string
}

View file

@ -11,31 +11,28 @@ Agent SUI is a special SUI configuration designed for AI Agent applications. It
│ └── template/ # Agent SUI template directory
│ ├── template.json # Optional template configuration
│ ├── __document.html # Global document template
│ ├── __data.json # Global data
│ ├── __assets/ # Global assets (CSS, JS, images)
│ ├── __data.json # Global data (accessible via $global)
│ ├── __assets/ # Global assets (reference via @assets/)
│ │ ├── css/
│ │ ├── js/
│ │ └── images/
│ ├── pages/ # Global agent pages (login, error, etc.)
│ │ └── login/
│ │ └── login.html
│ └── __locales/ # Internationalization
│ ├── __locales/ # Global locale files
│ └── pages/ # Global pages (401, 404, login, etc.)
│ └── <page>/ # Route = folder name
│ ├── <page>.html
│ ├── <page>.css
│ ├── <page>.ts
│ └── __locales/ # Page-level locale files
└── assistants/ # Assistants directory
├── demo/ # Assistant: demo
│ ├── package.yao # Assistant configuration
│ └── pages/ # Assistant-specific pages
│ ├── index/
│ │ ├── index.html
│ │ ├── index.css
│ │ └── index.ts
│ └── __assets/ # Optional assistant-specific assets
└── another/ # Assistant: another
├── package.yao
└── pages/
└── settings/
└── settings.html
└── <name>/ # Assistant
├── package.yao # Assistant configuration
└── pages/ # Assistant pages → /agents/<name>/<route>
└── <page>/ # Route = folder name (can be nested)
├── <page>.html
├── <page>.css
├── <page>.ts
└── __locales/
```
## Route Mapping
@ -237,16 +234,94 @@ Use standard SUI template syntax:
## Frontend Script
Frontend scripts can be written in two styles:
### Direct Style (Simple Pages)
```typescript
// Runs immediately when script loads
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#myForm") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Handle submission
});
});
// Smooth scrolling
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
target?.scrollIntoView({ behavior: "smooth" });
});
});
```
### Component Style (Interactive Pages)
**`/assistants/demo/pages/index/index.ts`**:
```typescript
function index(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
import { $Backend, Component, EventData } from "@yao/sui";
this.handleClick = async (event: Event) => {
const data = await this.backend.ApiGetData();
console.log(data);
};
}
const self = this as Component;
// Event handler bound to s:on-click="HandleClick"
self.HandleClick = async (event: Event, data: EventData) => {
const result = await $Backend().Call("GetData", data.id);
console.log(result);
};
// Form submission
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));
};
```
## CUI Integration
When Agent SUI pages are embedded in CUI via `/web/` routes, they can communicate with the CUI host.
### Receiving Context
```typescript
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
document.documentElement.setAttribute("data-theme", theme);
}
});
```
### Sending Actions
```typescript
// Helper function
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Show notification
sendAction("notify.success", { message: "Done!" });
// Navigate
sendAction("navigate", {
route: "/agents/demo/detail",
title: "Details",
});
// Close sidebar
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
```
See [Frontend API - CUI Integration](frontend-api.md#cui-integration) for complete documentation.

View file

@ -14,6 +14,12 @@ Backend scripts use the naming convention `<page>.backend.ts` or `<page>.backend
└── list.backend.ts # Backend script
```
## Important Notes
> **⚠️ No ES Module Exports**: Backend scripts do NOT support ES Module `export` syntax. Simply define functions directly - they will be automatically available based on naming conventions.
> **⚠️ `$param` Not Available**: Unlike HTML templates, you cannot use `$param.id` directly in backend scripts. Route parameters must be accessed via the `request.params` object passed to your functions.
## BeforeRender
The `BeforeRender` function is called before the page is rendered:
@ -58,20 +64,20 @@ function BeforeRender(request: Request): Record<string, any> {
## API Methods
Functions prefixed with `Api` are exposed as callable endpoints:
Functions prefixed with `Api` are exposed as callable endpoints. The backend automatically adds the `Api` prefix, so frontend calls use the method name without the prefix:
```typescript
// Callable from frontend as: this.backend.ApiGetUsers()
// Callable from frontend as: $Backend().Call("GetUsers")
function ApiGetUsers(request: Request): any[] {
return Process("models.user.Get", {});
}
// Callable from frontend as: this.backend.ApiCreateUser(name, email)
// Callable from frontend as: $Backend().Call("CreateUser", name, email)
function ApiCreateUser(name: string, email: string, request: Request): any {
return Process("models.user.Create", { name, email });
}
// Callable from frontend as: this.backend.ApiDeleteUser(id)
// Callable from frontend as: $Backend().Call("DeleteUser", id)
function ApiDeleteUser(id: string, request: Request): boolean {
Process("models.user.Delete", id);
return true;
@ -81,19 +87,20 @@ function ApiDeleteUser(id: string, request: Request): boolean {
### Calling from Frontend
```typescript
function Page(component: HTMLElement) {
this.root = component;
import { $Backend, Component } from "@yao/sui";
this.loadUsers = async () => {
const users = await this.backend.ApiGetUsers();
console.log(users);
};
const self = this as Component;
this.createUser = async () => {
const user = await this.backend.ApiCreateUser("John", "john@example.com");
console.log("Created:", user);
};
}
self.LoadUsers = async () => {
// Call "ApiGetUsers" in backend script (without "Api" prefix)
const users = await $Backend().Call("GetUsers");
console.log(users);
};
self.CreateUser = async () => {
const user = await $Backend().Call("CreateUser", "John", "john@example.com");
console.log("Created:", user);
};
```
## Constants
@ -115,10 +122,12 @@ const __sui_constants = {
Access in frontend:
```typescript
function Page(component: HTMLElement) {
console.log(this.constants.API_URL); // "/api/v1"
console.log(this.constants.MAX_ITEMS); // 100
}
import { Component } from "@yao/sui";
const self = this as Component;
console.log(self.constants.API_URL); // "/api/v1"
console.log(self.constants.MAX_ITEMS); // 100
```
## Helpers
@ -147,11 +156,13 @@ function validateEmail(email: string): boolean {
Access in frontend:
```typescript
function Page(component: HTMLElement) {
const formatted = this.helpers.formatDate("2024-01-15");
const price = this.helpers.formatCurrency(99.99);
const isValid = this.helpers.validateEmail("test@example.com");
}
import { Component } from "@yao/sui";
const self = this as Component;
const formatted = self.helpers.formatDate("2024-01-15");
const price = self.helpers.formatCurrency(99.99);
const isValid = self.helpers.validateEmail("test@example.com");
```
## Request Object
@ -243,6 +254,78 @@ function ApiUpdateUser(id: string, data: any, request: Request): any {
}
```
## Data Binding Methods (Called from `.json`)
In addition to `Api` prefixed methods (for frontend calls) and `BeforeRender`, you can define methods that are called directly from the page's `.json` configuration using the `@MethodName` syntax.
### Naming Convention
| Call Source | Function Name | Example Call |
| ---------------------------- | --------------- | ------------------------------- |
| Frontend `$Backend().Call()` | `ApiMethodName` | `$Backend().Call("MethodName")` |
| `.json` data binding | `MethodName` | `"$data": "@MethodName"` |
| Before render | `BeforeRender` | Automatic |
### How It Works
When using `@MethodName` in `.json`, SUI calls the backend function with the **Request object appended as the last argument**:
```typescript
// In .json: "$record": "@GetRecord"
// SUI internally calls: GetRecord(request)
function GetRecord(request: Request): any {
// Access route parameters via request.params
const id = request.params.id;
return Process("models.record.Find", id);
}
```
### With Additional Arguments
You can also pass arguments from `.json`:
```json
{
"$items": {
"process": "@GetItems",
"args": ["category_a", 10]
}
}
```
```typescript
// SUI calls: GetItems("category_a", 10, request)
// Arguments from .json come first, request is appended last
function GetItems(category: string, limit: number, request: Request): any[] {
return Process("models.item.Get", {
wheres: [{ column: "category", value: category }],
limit: limit,
});
}
```
### Common Pitfall: Accessing Route Parameters
**Wrong** - `$param` is not available in backend scripts:
```typescript
function GetRecord(): any {
const id = $param.id; // ReferenceError: $param is not defined
return Process("models.record.Find", id);
}
```
**Correct** - Use `request.params`:
```typescript
function GetRecord(request: Request): any {
const id = request.params.id; // Works!
return Process("models.record.Find", id);
}
```
## Complete Example
**`/users/profile/profile.backend.ts`**:

View file

@ -43,11 +43,13 @@ A component is just a page with a single root element:
**`/card/card.ts`**:
```typescript
function card(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
this.props = new __sui_props(component);
}
import { Component } from "@yao/sui";
const self = this as Component;
// self.root - Root element
// self.store - Data store
// self.props - Props from attributes
```
## Using Components
@ -94,17 +96,16 @@ Props are passed as attributes:
Access props in the component script:
```typescript
function userCard(component: HTMLElement) {
this.root = component;
this.props = new __sui_props(component);
import { Component } from "@yao/sui";
// Get single prop
const name = this.props.Get("name");
const self = this as Component;
// Get all props
const allProps = this.props.List();
// { name: "John", email: "john@example.com", avatar: "...", role: "admin" }
}
// Get single prop
const name = self.props.Get("name");
// Get all props
const allProps = self.props.List();
// { name: "John", email: "john@example.com", avatar: "...", role: "admin" }
```
Access props in backend script:
@ -203,75 +204,68 @@ Use `<slot name="xxx">` for multiple content areas:
### Structure
```typescript
function componentName(component: HTMLElement) {
// Root element
this.root = component;
import { $Backend, Component, EventData } from "@yao/sui";
// Data store (data-* attributes)
this.store = new __sui_store(component);
const self = this as Component;
// Props (passed attributes)
this.props = new __sui_props(component);
// self.root - Root element (HTMLElement)
// self.store - Data store (data-* attributes)
// self.props - Props (passed attributes)
// self.state - State management
// State management
this.state = new __sui_state(this);
// State watchers
self.watch = {
propertyName: (value: any, state: any) => {
// React to state changes
},
};
// Backend API
this.backend = {
ApiMethod: async (...args) => {
/* ... */
},
};
// State watchers
this.watch = {
propertyName: (value, state) => {
// React to state changes
},
};
// Methods
this.handleClick = (event, data, context) => {
// Handle events
};
}
// Event handlers (bound to s:on-click="HandleClick")
self.HandleClick = async (event: Event, data: EventData) => {
const result = await $Backend().Call("Method", data.id);
// Handle result
};
```
### Store API
```typescript
import { Component } from "@yao/sui";
const self = this as Component;
// String data
this.store.Get("key");
this.store.Set("key", "value");
self.store.Get("key");
self.store.Set("key", "value");
// JSON data
this.store.GetJSON("items");
this.store.SetJSON("items", [{ id: 1 }]);
self.store.GetJSON("items");
self.store.SetJSON("items", [{ id: 1 }]);
// Component data (from BeforeRender)
this.store.GetData();
self.store.GetData();
```
### Props API
```typescript
// Get single prop
const value = this.props.Get("propName");
const value = self.props.Get("propName");
// Get all props
const props = this.props.List();
const props = self.props.List();
```
### State API
```typescript
// Set state (triggers watchers)
this.state.Set("count", 10);
self.state.Set("count", 10);
// Watch state changes
this.watch = {
count: (value, state) => {
this.root.querySelector(".count").textContent = value;
self.watch = {
count: (value: number, state: any) => {
self.root.querySelector(".count")!.textContent = String(value);
// state.stopPropagation(); // Prevent bubbling to parent
},
};
@ -346,7 +340,6 @@ Component CSS is automatically scoped using namespace attributes:
## Important Notes
1. **Single Root Element**: Components must have exactly one root element
2. **Route as Identifier**: The page route becomes the component name (e.g., `/card``card()`)
3. **Scoped Styles**: CSS is automatically scoped to prevent conflicts
4. **Recursive Prevention**: SUI detects and prevents recursive component inclusion
5. **Script Naming**: Function name is derived from the route path
2. **Scoped Styles**: CSS is automatically scoped to prevent conflicts
3. **Recursive Prevention**: SUI detects and prevents recursive component inclusion
4. **Component Pattern**: Use `const self = this as Component` to access component APIs

View file

@ -150,6 +150,44 @@ Note: `$header` is only available in JSON configuration, not in HTML templates.
}
```
### Calling Backend Script Methods
Use the `@MethodName` syntax to call functions defined in the page's `.backend.ts` file:
```json
{
"$record": "@GetRecord",
"$items": {
"process": "@GetItems",
"args": ["active", 20]
}
}
```
**Important**: The Request object is automatically appended as the **last argument** to the backend function.
**`page.backend.ts`**:
```typescript
// Called from .json as: "$record": "@GetRecord"
// Receives: (request)
function GetRecord(request: Request): any {
const id = request.params.id; // Access route params via request
return Process("models.record.Find", id);
}
// Called from .json as: { "process": "@GetItems", "args": ["active", 20] }
// Receives: ("active", 20, request)
function GetItems(status: string, limit: number, request: Request): any[] {
return Process("models.item.Get", {
wheres: [{ column: "status", value: status }],
limit: limit,
});
}
```
> **⚠️ Common Mistake**: You cannot use `$param.id` directly in backend scripts. The `$param`, `$query`, etc. variables are only available in HTML templates and `.json` configurations. In backend scripts, access these values via the `request` parameter: `request.params.id`, `request.query.search`, etc.
## Built-in Functions
### P\_() - Process Call

View file

@ -76,23 +76,21 @@ Use `s:json-*` to pass complex data:
### Handler Signature
```typescript
function Page(component: HTMLElement) {
this.root = component;
import { $Backend, Component, EventData } from "@yao/sui";
this.handleClick = (event: Event, data: any, context: EventContext) => {
// event - The DOM event
// data - Combined data from s:data-* and s:json-*
// context - Event context with element references
};
}
const self = this as Component;
self.HandleClick = (event: Event, data: EventData) => {
// event - The DOM event
// data - Combined data from s:data-* and s:json-*
};
```
### EventContext
### EventData
```typescript
interface EventContext {
rootElement: HTMLElement; // Component root element
targetElement: HTMLElement; // Element that triggered the event
interface EventData {
[key: string]: any; // Data from s:data-* and s:json-* attributes
}
```
@ -103,7 +101,7 @@ interface EventContext {
<div s:for="{{ items }}" s:for-item="item">
<span>{{ item.name }}</span>
<button
s:on-click="deleteItem"
s:on-click="DeleteItem"
s:data-id="{{ item.id }}"
s:json-item="{{ item }}"
>
@ -114,19 +112,19 @@ interface EventContext {
```
```typescript
function ItemList(component: HTMLElement) {
this.root = component;
import { $Backend, Component, EventData } from "@yao/sui";
this.deleteItem = async (event: Event, data: any, context: EventContext) => {
const id = data.id; // String from s:data-id
const item = data.item; // Object from s:json-item
const self = this as Component;
if (confirm(`Delete ${item.name}?`)) {
await this.backend.ApiDeleteItem(id);
context.targetElement.closest(".item").remove();
}
};
}
self.DeleteItem = async (event: Event, data: EventData) => {
const id = data.id; // String from s:data-id
const item = data.item; // Object from s:json-item
if (confirm(`Delete ${item.name}?`)) {
await $Backend().Call("DeleteItem", id);
(event.target as HTMLElement).closest(".item")?.remove();
}
};
```
## State Management
@ -134,13 +132,12 @@ function ItemList(component: HTMLElement) {
### State Object
```typescript
function Counter(component: HTMLElement) {
this.root = component;
this.state = new __sui_state(this);
import { Component } from "@yao/sui";
// Initial state
this.state.Set("count", 0);
}
const self = this as Component;
// Initial state
self.state.Set("count", 0);
```
### State Watchers
@ -148,26 +145,25 @@ function Counter(component: HTMLElement) {
React to state changes with watchers:
```typescript
function Counter(component: HTMLElement) {
this.root = component;
this.state = new __sui_state(this);
import { Component } from "@yao/sui";
// Define watchers
this.watch = {
count: (value: number, state: State) => {
this.root.querySelector(".count").textContent = value;
},
const self = this as Component;
items: (value: any[], state: State) => {
this.renderItems(value);
},
};
// Define watchers
self.watch = {
count: (value: number) => {
self.root.querySelector(".count")!.textContent = String(value);
},
this.increment = () => {
const count = this.state.Get("count") || 0;
this.state.Set("count", count + 1); // Triggers watcher
};
}
items: (value: any[]) => {
renderItems(value);
},
};
self.Increment = () => {
const count = self.state.Get("count") || 0;
self.state.Set("count", count + 1); // Triggers watcher
};
```
### Stop Propagation
@ -175,10 +171,10 @@ function Counter(component: HTMLElement) {
Prevent state changes from bubbling to parent:
```typescript
this.watch = {
localState: (value: any, state: State) => {
self.watch = {
localState: (value: any, state: any) => {
// Handle locally
this.updateUI(value);
updateUI(value);
// Stop propagation to parent components
state.stopPropagation();
@ -193,18 +189,17 @@ Store manages `data-*` attributes on the component:
### Basic Usage
```typescript
function Card(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
import { Component } from "@yao/sui";
// Get/Set string values
const id = this.store.Get("id");
this.store.Set("id", "123");
const self = this as Component;
// Get/Set JSON values
const items = this.store.GetJSON("items");
this.store.SetJSON("items", [{ id: 1 }, { id: 2 }]);
}
// Get/Set string values
const id = self.store.Get("id");
self.store.Set("id", "123");
// Get/Set JSON values
const items = self.store.GetJSON("items");
self.store.SetJSON("items", [{ id: 1 }, { id: 2 }]);
```
### Component Data
@ -213,7 +208,7 @@ Get data from BeforeRender:
```typescript
// Backend returns: { user: { name: "John" }, settings: {...} }
const data = this.store.GetData();
const data = self.store.GetData();
console.log(data.user.name); // "John"
```
@ -222,30 +217,30 @@ console.log(data.user.name); // "John"
### Emit Events
```typescript
function ItemCard(component: HTMLElement) {
this.root = component;
import { Component } from "@yao/sui";
this.selectItem = () => {
const item = this.store.GetJSON("item");
const self = this as Component;
// Emit custom event
this.emit("item:selected", { item });
};
}
self.SelectItem = () => {
const item = self.store.GetJSON("item");
// Emit custom event
self.emit("item:selected", { item });
};
```
### Listen to Events
```typescript
function ItemList(component: HTMLElement) {
this.root = component;
import { Component } from "@yao/sui";
// Listen to child events
this.root.addEventListener("item:selected", (e: CustomEvent) => {
const { item } = e.detail;
console.log("Selected:", item);
});
}
const self = this as Component;
// Listen to child events
self.root.addEventListener("item:selected", (e: CustomEvent) => {
const { item } = e.detail;
console.log("Selected:", item);
});
```
### State Change Events
@ -253,14 +248,14 @@ function ItemList(component: HTMLElement) {
Parent components can listen to state changes:
```typescript
function Parent(component: HTMLElement) {
this.root = component;
import { Component } from "@yao/sui";
this.root.addEventListener("state:change", (e: CustomEvent) => {
const { key, value, target } = e.detail;
console.log(`State ${key} changed to ${value} in`, target);
});
}
const self = this as Component;
self.root.addEventListener("state:change", (e: CustomEvent) => {
const { key, value, target } = e.detail;
console.log(`State ${key} changed to ${value} in`, target);
});
```
## Form Handling
@ -268,7 +263,7 @@ function Parent(component: HTMLElement) {
### Form Submit
```html
<form s:on-submit="handleSubmit">
<form s:on-submit="HandleSubmit">
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Login</button>
@ -276,77 +271,77 @@ function Parent(component: HTMLElement) {
```
```typescript
function LoginForm(component: HTMLElement) {
this.root = component;
import { $Backend, Component } from "@yao/sui";
this.handleSubmit = async (event: Event) => {
event.preventDefault();
const self = this as Component;
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
self.HandleSubmit = async (event: Event) => {
event.preventDefault();
const email = formData.get("email");
const password = formData.get("password");
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
try {
await this.backend.ApiLogin(email, password);
window.location.href = "/dashboard";
} catch (error) {
alert("Login failed");
}
};
}
const email = formData.get("email");
const password = formData.get("password");
try {
await $Backend().Call("Login", email, password);
window.location.href = "/dashboard";
} catch (error) {
alert("Login failed");
}
};
```
### Input Binding
```html
<input type="text" s:on-input="handleInput" s:data-field="name" />
<input type="text" s:on-input="HandleInput" s:data-field="name" />
```
```typescript
function Form(component: HTMLElement) {
this.root = component;
this.formData = {};
import { Component, EventData } from "@yao/sui";
this.handleInput = (event: Event, data: any) => {
const input = event.target as HTMLInputElement;
this.formData[data.field] = input.value;
};
}
const self = this as Component;
const formData: Record<string, string> = {};
self.HandleInput = (event: Event, data: EventData) => {
const input = event.target as HTMLInputElement;
formData[data.field] = input.value;
};
```
## Keyboard Events
```html
<input s:on-keydown="handleKeydown" s:on-keyup="handleKeyup" />
<input s:on-keydown="HandleKeydown" s:on-keyup="HandleKeyup" />
```
```typescript
function Search(component: HTMLElement) {
this.root = component;
import { Component } from "@yao/sui";
this.handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
this.search();
}
const self = this as Component;
if (event.key === "Escape") {
this.clear();
}
};
}
self.HandleKeydown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
search();
}
if (event.key === "Escape") {
clear();
}
};
```
## Complete Example
```html
<div class="todo-app">
<form s:on-submit="addTodo">
<form s:on-submit="AddTodo">
<input
name="title"
placeholder="Add todo..."
s:on-keydown="handleKeydown"
s:on-keydown="HandleKeydown"
/>
<button type="submit">Add</button>
</form>
@ -355,53 +350,51 @@ function Search(component: HTMLElement) {
<li s:for="{{ todos }}" s:for-item="todo">
<input
type="checkbox"
s:on-change="toggleTodo"
s:on-change="ToggleTodo"
s:data-id="{{ todo.id }}"
s:attr-checked="{{ todo.completed }}"
/>
<span class="{{ todo.completed ? 'completed' : '' }}">
{{ todo.title }}
</span>
<button s:on-click="deleteTodo" s:data-id="{{ todo.id }}">×</button>
<button s:on-click="DeleteTodo" s:data-id="{{ todo.id }}">×</button>
</li>
</ul>
</div>
```
```typescript
function TodoApp(component: HTMLElement) {
this.root = component;
this.state = new __sui_state(this);
this.store = new __sui_store(component);
import { $Backend, Component, EventData } from "@yao/sui";
this.watch = {
todos: (todos: any[]) => {
this.render("todoList", { todos });
},
};
const self = this as Component;
this.addTodo = async (event: Event) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const input = form.querySelector("input") as HTMLInputElement;
self.watch = {
todos: (todos: any[]) => {
self.render("todoList", { todos });
},
};
if (input.value.trim()) {
const todo = await this.backend.ApiAddTodo(input.value);
const todos = this.state.Get("todos") || [];
this.state.Set("todos", [...todos, todo]);
input.value = "";
}
};
self.AddTodo = async (event: Event) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const input = form.querySelector("input") as HTMLInputElement;
this.toggleTodo = async (event: Event, data: any) => {
const checkbox = event.target as HTMLInputElement;
await this.backend.ApiToggleTodo(data.id, checkbox.checked);
};
if (input.value.trim()) {
const todo = await $Backend().Call("AddTodo", input.value);
const todos = self.state.Get("todos") || [];
self.state.Set("todos", [...todos, todo]);
input.value = "";
}
};
this.deleteTodo = async (event: Event, data: any) => {
await this.backend.ApiDeleteTodo(data.id);
const todos = this.state.Get("todos").filter((t) => t.id !== data.id);
this.state.Set("todos", todos);
};
}
self.ToggleTodo = async (event: Event, data: EventData) => {
const checkbox = event.target as HTMLInputElement;
await $Backend().Call("ToggleTodo", data.id, checkbox.checked);
};
self.DeleteTodo = async (event: Event, data: EventData) => {
await $Backend().Call("DeleteTodo", data.id);
const todos = self.state.Get("todos").filter((t: any) => t.id !== data.id);
self.state.Set("todos", todos);
};
```

View file

@ -38,29 +38,28 @@ const items = component.queryAll(".item"); // Returns NodeList
## Backend Calls
### Via Component
### Via $Backend
The backend automatically adds the `Api` prefix to method names, so you call without the prefix:
```typescript
function Page(component: HTMLElement) {
this.root = component;
import { $Backend } from "@yao/sui";
this.loadData = async () => {
// Call backend API methods
const users = await this.backend.ApiGetUsers();
const user = await this.backend.ApiGetUser(123);
const result = await this.backend.ApiCreateUser("John", "john@example.com");
};
}
// Call backend API methods (backend functions are ApiGetUsers, ApiGetUser, ApiCreateUser)
const users = await $Backend().Call("GetUsers");
const user = await $Backend().Call("GetUser", 123);
const result = await $Backend().Call("CreateUser", "John", "john@example.com");
```
### Direct Call
```typescript
// __sui_backend_call(route, headers, method, ...args)
// Note: method name here also gets Api prefix added automatically
const result = await __sui_backend_call(
"/users/list", // Page route
{ "X-Custom-Header": "value" }, // Custom headers
"ApiGetUsers", // Method name
"GetUsers", // Method name (backend has ApiGetUsers)
{ page: 1, limit: 10 } // Arguments
);
```
@ -80,22 +79,22 @@ Define render targets in HTML:
### Render Method
```typescript
function Page(component: HTMLElement) {
this.root = component;
import { $Backend, Component } from "@yao/sui";
this.refreshUsers = async () => {
const users = await this.backend.ApiGetUsers();
const self = this as Component;
// Render with data
await this.render("userList", { users });
};
}
self.RefreshUsers = async () => {
const users = await $Backend().Call("GetUsers");
// Render with data
await self.render("userList", { users });
};
```
### Render Options
```typescript
await this.render("targetName", data, {
await self.render("targetName", data, {
replace: true, // Replace content (default: true)
showLoader: true, // Show loading indicator
withPageData: true, // Include page data in render context
@ -292,32 +291,32 @@ api.ClearTokens();
### Emit
```typescript
function Card(component: HTMLElement) {
this.root = component;
import { Component } from "@yao/sui";
this.select = () => {
this.emit("card:selected", { id: this.store.Get("id") });
};
}
const self = this as Component;
self.Select = () => {
self.emit("card:selected", { id: self.store.Get("id") });
};
```
### Listen
```typescript
function CardList(component: HTMLElement) {
this.root = component;
import { Component } from "@yao/sui";
this.root.addEventListener("card:selected", (e: CustomEvent) => {
console.log("Selected:", e.detail.id);
});
}
const self = this as Component;
self.root.addEventListener("card:selected", (e: CustomEvent) => {
console.log("Selected:", e.detail.id);
});
```
### State Change Events
```typescript
// Listen to child state changes
this.root.addEventListener("state:change", (e: CustomEvent) => {
self.root.addEventListener("state:change", (e: CustomEvent) => {
const { key, value, target } = e.detail;
console.log(`${key} = ${value}`);
});
@ -326,54 +325,200 @@ this.root.addEventListener("state:change", (e: CustomEvent) => {
## Complete Example
```typescript
function UserDashboard(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
this.state = new __sui_state(this);
import { $Backend, Component, EventData } from "@yao/sui";
// Initialize API
const api = new OpenAPI({ baseURL: "/api" });
const fileApi = new FileAPI(api);
const self = this as Component;
// State watchers
this.watch = {
users: (users) => this.render("userList", { users }),
loading: (loading) => {
this.root.classList.toggle("loading", loading);
},
};
// Initialize API
const api = new OpenAPI({ baseURL: "/api" });
const fileApi = new FileAPI(api);
// Load users
this.loadUsers = async () => {
this.state.Set("loading", true);
// State watchers
self.watch = {
users: (users: any[]) => self.render("userList", { users }),
loading: (loading: boolean) => {
self.root.classList.toggle("loading", loading);
},
};
const response = await api.Get<User[]>("/users");
if (!api.IsError(response)) {
this.state.Set("users", response.data);
}
// Load users
async function loadUsers() {
self.state.Set("loading", true);
this.state.Set("loading", false);
};
const response = await api.Get<User[]>("/users");
if (!api.IsError(response)) {
self.state.Set("users", response.data);
}
// Create user
this.createUser = async (event: Event, data: any) => {
const response = await this.backend.ApiCreateUser(data.name, data.email);
const users = this.state.Get("users");
this.state.Set("users", [...users, response]);
};
// Upload avatar
this.uploadAvatar = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files[0];
const response = await fileApi.Upload(file, { path: "avatars" });
if (!api.IsError(response)) {
this.emit("avatar:uploaded", { url: response.data.url });
}
};
// Initialize
this.loadUsers();
self.state.Set("loading", false);
}
// Create user
self.CreateUser = async (event: Event, data: EventData) => {
const response = await $Backend().Call("CreateUser", data.name, data.email);
const users = self.state.Get("users");
self.state.Set("users", [...users, response]);
};
// Upload avatar
self.UploadAvatar = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files![0];
const response = await fileApi.Upload(file, { path: "avatars" });
if (!api.IsError(response)) {
self.emit("avatar:uploaded", { url: response.data.url });
}
};
// Initialize
loadUsers();
```
## CUI Integration
When SUI pages are embedded in CUI via `/web/` routes, they can communicate with the CUI host.
### URL Parameters
CUI automatically replaces special parameter values:
| Value | Replaced With |
| ---------- | -------------------------------- |
| `__theme` | Current theme (`light` / `dark`) |
| `__locale` | Current locale (e.g., `en-us`) |
> **Note**: Authentication uses secure HTTP-only cookies, no token parameter needed.
### Receiving Messages from CUI
```typescript
window.addEventListener("message", (e) => {
// Only accept messages from same origin
if (e.origin !== window.location.origin) return;
const { type, message } = e.data;
switch (type) {
case "setup":
// Initial context from CUI
document.documentElement.setAttribute("data-theme", message.theme);
console.log("Locale:", message.locale);
break;
case "update":
// Data updates from CUI
handleUpdate(message);
break;
}
});
```
### Sending Actions to CUI
Use the unified Action system to trigger CUI operations:
```typescript
// Helper function
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Show notification
sendAction("notify.success", { message: "Operation completed!" });
sendAction("notify.error", { message: "Something went wrong" });
// Navigate to page
sendAction("navigate", {
route: "/agents/my-app/detail",
title: "Details",
query: { id: "123" },
});
// Open in new tab
sendAction("navigate", {
route: "/agents/my-app/report",
target: "_blank",
});
// Refresh menu
sendAction("app.menu.reload");
// Close sidebar
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
```
### Available Actions
| Category | Action | Description | Payload |
| -------- | ----------------- | ------------------------- | ------------------------------------------- |
| Navigate | `navigate` | Open page in sidebar/tab | `{ route, title?, icon?, query?, target? }` |
| | `navigate.back` | Go back in history | - |
| Notify | `notify.success` | Success notification | `{ message, duration?, closable? }` |
| | `notify.error` | Error notification | `{ message, duration?, closable? }` |
| | `notify.warning` | Warning notification | `{ message, duration?, closable? }` |
| | `notify.info` | Info notification | `{ message, duration?, closable? }` |
| App | `app.menu.reload` | Refresh application menu | - |
| Modal | `modal.open` | Open modal dialog | `{ ... }` |
| | `modal.close` | Close modal | - |
| Table | `table.search` | Trigger table search | `{ keywords }` |
| | `table.refresh` | Refresh table data | - |
| Form | `form.submit` | Submit form | - |
| | `form.reset` | Reset form | - |
| Event | `event.emit` | Emit custom event | `{ key, value }` |
| Confirm | `confirm` | Show confirmation dialog | `{ title, content }` |
### Complete Example
```typescript
import { $Backend, Component, EventData } from "@yao/sui";
const self = this as Component;
// Helper: Send action to CUI
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Initialize CUI communication
function init() {
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
document.documentElement.setAttribute("data-theme", theme);
}
});
(window as any).sendAction = sendAction;
}
init();
// Event handlers
self.HandleSave = async (event: Event, data: EventData) => {
try {
await $Backend().Call("Save", data);
sendAction("notify.success", { message: "Saved successfully!" });
} catch (error: any) {
sendAction("notify.error", { message: error.message });
}
};
self.HandleViewDetail = (event: Event, data: EventData) => {
sendAction("navigate", {
route: `/agents/my-app/detail`,
title: "Details",
query: { id: data.id },
});
};
self.HandleClose = () => {
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
};
```

View file

@ -112,27 +112,42 @@ Named keys are used internally for translation lookup. The `keys` section in loc
### Scripts
```typescript
function Page(component: HTMLElement) {
this.root = component;
import { Component } from "@yao/sui";
this.showMessage = () => {
const message = __m("Operation completed");
alert(message);
};
const self = this as Component;
this.confirm = () => {
return confirm(__m("Are you sure you want to delete?"));
};
}
self.ShowMessage = () => {
const message = __m("Operation completed");
alert(message);
};
self.Confirm = () => {
return confirm(__m("Are you sure you want to delete?"));
};
```
## Locale Detection
SUI detects locale from:
SUI detects locale from the `locale` HTTP cookie on the server side.
1. Cookie (`locale` or `umi_locale`)
2. Browser language
3. Default (`en-us`)
**Important:** `s:trans` translations are server-side rendered. This means:
1. The translation happens when the page is generated on the server
2. Changing locale via JavaScript only affects localStorage/client state
3. To apply locale changes to `s:trans` content, you must reload the page
```javascript
// To change locale and have s:trans reflect the change:
document.cookie = "locale=zh-CN;path=/;max-age=31536000";
location.reload(); // Required for server-side translations
```
**Cookie Priority:**
1. `locale` cookie (primary)
2. `umi_locale` cookie (fallback for CUI compatibility)
3. Browser language
4. Default (`en-us`)
### Access Current Locale
@ -223,17 +238,17 @@ This command:
<a href="/contact" s:trans>Contact</a>
</nav>
<button s:on-click="showWelcome" s:trans>Show Welcome</button>
<button s:on-click="ShowWelcome" s:trans>Show Welcome</button>
</div>
<script>
function home(component) {
this.root = component;
import { Component } from "@yao/sui";
this.showWelcome = () => {
alert(__m("Welcome to our site!"));
};
}
const self = this as Component;
self.ShowWelcome = () => {
alert(__m("Welcome to our site!"));
};
</script>
```

214
sui/docs/routing.md Normal file
View file

@ -0,0 +1,214 @@
# Routing
SUI supports file-system based routing with dynamic route parameters and URL rewriting.
## File-System Routing
Pages are organized in directories, with each directory containing a page's files:
```
/pages/
├── index/
│ ├── index.html
│ ├── index.css
│ └── index.ts
├── about/
│ ├── about.html
│ └── about.css
└── users/
├── users.html
└── [id]/ # Dynamic route
├── [id].html
├── [id].css
└── [id].ts
```
## Dynamic Routes
Use square brackets `[param]` to create dynamic route segments:
| Directory Structure | URL Pattern | Example URL |
| ------------------- | ---------------- | -------------------- |
| `/users/[id]/` | `/users/:id` | `/users/123` |
| `/posts/[slug]/` | `/posts/:slug` | `/posts/hello-world` |
| `/[category]/[id]/` | `/:category/:id` | `/electronics/456` |
### Accessing Route Parameters
**In HTML templates** - Use `$param`:
```html
<h1>User ID: {{ $param.id }}</h1>
<p>Category: {{ $param.category }}</p>
```
**In `.json` configuration**:
```json
{
"userId": "$param.id",
"$user": {
"process": "models.user.Find",
"args": ["$param.id"]
}
}
```
**In backend scripts** - Via Request object:
```typescript
function GetRecord(request: Request): any {
const id = request.params.id;
return Process("models.record.Find", id);
}
```
> **Note**: `$param` is NOT available as a global variable in backend scripts. You must access route parameters through the `request.params` object.
## URL Rewriting
SUI pages require URL rewriting to map clean URLs to `.sui` page files. Configure rewrite rules in `app.yao`:
```json
{
"public": {
"rewrite": [
{ "^\\/assets\\/(.*)$": "/assets/$1" },
{ "^\\/users\\/([^\\/]+)$": "/users/[id].sui" },
{ "^\\/(.*)$": "/$1.sui" }
]
}
}
```
### Rewrite Rule Syntax
Each rule is a JSON object with a regex pattern as the key and the target path as the value:
```json
{ "REGEX_PATTERN": "TARGET_PATH" }
```
- **REGEX_PATTERN**: A regular expression to match the incoming URL
- **TARGET_PATH**: The internal path to route to, can use capture groups (`$1`, `$2`, etc.)
### Rule Processing Order
Rules are processed **in order from top to bottom**. The first matching rule wins. Always place more specific rules before general ones.
### Common Patterns
#### Static Assets (Passthrough)
```json
{ "^\\/assets\\/(.*)$": "/assets/$1" }
```
Passes asset requests directly without modification.
#### Simple Dynamic Route
```json
{ "^\\/users\\/([^\\/]+)$": "/users/[id].sui" }
```
Maps `/users/123` to `/users/[id].sui`, making `123` available as `$param.id`.
#### Nested Dynamic Route
```json
{
"^\\/users\\/([^\\/]+)\\/posts\\/([^\\/]+)$": "/users/[id]/posts/[postId].sui"
}
```
Maps `/users/123/posts/456` to the nested page, with `$param.id = "123"` and `$param.postId = "456"`.
#### Catch-All for SUI Pages
```json
{ "^\\/(.*)$": "/$1.sui" }
```
Maps any URL to its corresponding `.sui` file. Place this **last** as a fallback.
#### Specific Page Override
```json
{ "^\\/dashboard\\/login(.*)$": "/dashboard/login.sui" },
{ "^\\/dashboard\\/(.*)$": "/dashboard/[id].sui" }
```
The login page is matched first (specific), then other dashboard pages use dynamic routing.
### Complete Example
```json
{
"public": {
"rewrite": [
// Static assets - passthrough
{ "^\\/assets\\/(.*)$": "/assets/$1" },
{ "^\\/images\\/(.*)$": "/images/$1" },
// Specific pages (before dynamic routes)
{ "^\\/blog\\/new$": "/blog/new.sui" },
{ "^\\/blog\\/([^\\/]+)\\/edit$": "/blog/[id]/edit.sui" },
// Dynamic routes
{ "^\\/blog\\/([^\\/]+)$": "/blog/[id].sui" },
{
"^\\/users\\/([^\\/]+)\\/posts\\/([^\\/]+)$": "/users/[id]/posts/[postId].sui"
},
{ "^\\/users\\/([^\\/]+)$": "/users/[id].sui" },
// Fallback - must be last
{ "^\\/(.*)$": "/$1.sui" }
]
}
}
```
### Regex Tips
| Pattern | Matches | Description |
| ----------- | ------------------ | ------------------------------------ |
| `([^\\/]+)` | Any segment | Matches characters until next `/` |
| `(.*)` | Everything | Matches any characters including `/` |
| `(\\d+)` | Numbers only | Matches numeric IDs |
| `([a-z-]+)` | Lowercase + hyphen | Matches slugs like `hello-world` |
### Debugging Rewrite Rules
1. Check the server logs for route matching information
2. Ensure regex escaping is correct (double backslashes in JSON: `\\/` for `/`)
3. Test specific URLs to verify capture groups work correctly
4. Remember that the `.sui` extension is internal - users access pages without it
## Route Parameters in Different Contexts
| Context | Access Method | Example |
| --------------- | ------------------- | ------------------------------- |
| HTML Template | `{{ $param.id }}` | `<h1>{{ $param.id }}</h1>` |
| `.json` Config | `"$param.id"` | `"userId": "$param.id"` |
| Backend Script | `request.params.id` | `const id = request.params.id;` |
| Frontend Script | Read from DOM | `document.body.dataset.id` |
### Frontend Access Pattern
Since frontend scripts run in the browser, route params aren't directly available. Pass them via data attributes:
**HTML**:
```html
<div id="page" data-id="{{ $param.id }}">
<!-- content -->
</div>
```
**Frontend TypeScript**:
```typescript
const pageEl = document.getElementById("page");
const id = pageEl?.dataset.id;
```

View file

@ -11,6 +11,7 @@ import (
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/sui/core"
"gopkg.in/yaml.v3"
)
// Page wraps core.Page with agent-specific functionality
@ -315,6 +316,13 @@ func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOp
return warnings, err
}
// Write locale files from page's __locales directory
err = page.writeLocaleFiles(option.Data)
if err != nil {
log.Warn("[Agent] Write locale files error: %s", err.Error())
// Don't fail the build for locale errors
}
return warnings, nil
}
@ -465,3 +473,116 @@ func (page *Page) AssetRoot() string {
func (page *Page) AssistantID() string {
return page.assistantID
}
// writeLocaleFiles writes locale files from page's __locales directory to public
func (page *Page) writeLocaleFiles(data map[string]interface{}) error {
fs := page.tmpl.agent.fs
// Check if page has __locales directory
localesDir := filepath.Join(page.Path, "__locales")
if !fs.IsDir(localesDir) {
return nil
}
// Get the public root
root, err := page.tmpl.agent.DSL.PublicRoot(data)
if err != nil {
log.Error("writeLocaleFiles: Get the public root error: %s. use %s", err.Error(), page.tmpl.agent.DSL.Public.Root)
root = page.tmpl.agent.DSL.Public.Root
}
// Read all locale files in __locales directory
files, err := fs.ReadDir(localesDir, false)
if err != nil {
return err
}
for _, file := range files {
// Skip directories
if fs.IsDir(file) {
continue
}
// Only process .yml files
if filepath.Ext(file) != ".yml" {
continue
}
// Get locale name (e.g., "zh-cn" from "zh-cn.yml")
localeName := filepath.Base(file)
localeName = localeName[:len(localeName)-4] // Remove .yml extension
// Read the locale file
content, err := fs.ReadFile(file)
if err != nil {
log.Error("[Agent] Read locale file error: %s", err.Error())
continue
}
// Parse the locale file
var localeData map[string]interface{}
err = yaml.Unmarshal(content, &localeData)
if err != nil {
log.Error("[Agent] Parse locale file error: %s", err.Error())
continue
}
// Convert to the format expected by core.Locale
locale := core.Locale{
Name: localeName,
Keys: map[string]string{},
Messages: map[string]string{},
ScriptMessages: map[string]string{},
}
// Extract messages
if messages, ok := localeData["messages"].(map[string]interface{}); ok {
for k, v := range messages {
if strVal, ok := v.(string); ok {
locale.Messages[k] = strVal
}
}
}
// Extract script_messages
if scriptMessages, ok := localeData["script_messages"].(map[string]interface{}); ok {
for k, v := range scriptMessages {
if strVal, ok := v.(string); ok {
locale.ScriptMessages[k] = strVal
}
}
}
// Extract timezone and direction
if tz, ok := localeData["timezone"].(string); ok {
locale.Timezone = tz
}
if dir, ok := localeData["direction"].(string); ok {
locale.Direction = dir
}
// Write to public/.locales/<locale>/<route>.yml
// page.Route may contain path like /expense/test, so we need to create nested directories
targetFile := filepath.Join(application.App.Root(), "public", root, ".locales", localeName, fmt.Sprintf("%s.yml", page.Route))
targetDir := filepath.Dir(targetFile)
if exist, _ := os.Stat(targetDir); exist == nil {
os.MkdirAll(targetDir, os.ModePerm)
}
localeContent, err := yaml.Marshal(locale)
if err != nil {
log.Error("[Agent] Marshal locale error: %s", err.Error())
continue
}
err = os.WriteFile(targetFile, localeContent, 0644)
if err != nil {
log.Error("[Agent] Write locale file error: %s", err.Error())
continue
}
log.Info("[Agent] Wrote locale file: %s", targetFile)
}
return nil
}

View file

@ -226,6 +226,33 @@ func (tmpl *Template) GetRoot() string {
return tmpl.agent.root
}
// GetWatchDirs returns all directories that should be watched for changes
// This implements the core.IWatchDirs interface
func (tmpl *Template) GetWatchDirs() []string {
dirs := []string{}
// 1. Add the main agent template directory
dirs = append(dirs, tmpl.agent.root)
// 2. Add each assistant's pages directory
assistants, err := tmpl.agent.getAssistants()
if err != nil {
return dirs
}
for _, assistantID := range assistants {
pagesDir := tmpl.agent.getAssistantPagesRoot(assistantID)
dirs = append(dirs, pagesDir)
}
return dirs
}
// GetWatchRoot returns "app" to indicate paths are relative to application source root
func (tmpl *Template) GetWatchRoot() string {
return "app"
}
// Asset get the asset (check agent assets first, then assistant assets)
func (tmpl *Template) Asset(file string, width, height uint) (*core.Asset, error) {
// First check in agent assets