Merge pull request #1409 from trheyi/main
Implement Multi-Directory Watching and Enhance Locale File Handling
This commit is contained in:
commit
369d227bf2
17 changed files with 1685 additions and 390 deletions
|
|
@ -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
300
agent/docs/iframe.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
138
cmd/sui/watch.go
138
cmd/sui/watch.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`**:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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: {} });
|
||||
};
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
214
sui/docs/routing.md
Normal 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;
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue