diff --git a/agent/README.md b/agent/README.md index 9f4abf38..74018c48 100644 --- a/agent/README.md +++ b/agent/README.md @@ -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 diff --git a/agent/docs/iframe.md b/agent/docs/iframe.md new file mode 100644 index 00000000..512f2d2f --- /dev/null +++ b/agent/docs/iframe.md @@ -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// +``` + +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` | - | 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 + + + + Result Page + + + +
+ + +``` + +### 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 diff --git a/agent/docs/mcp.md b/agent/docs/mcp.md index 9e0a0966..af33e977 100644 --- a/agent/docs/mcp.md +++ b/agent/docs/mcp.md @@ -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 diff --git a/agent/docs/pages.md b/agent/docs/pages.md index a0ffa475..58cb459d 100644 --- a/agent/docs/pages.md +++ b/agent/docs/pages.md @@ -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//`, 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) diff --git a/cmd/sui/watch.go b/cmd/sui/watch.go index 13bcf6c3..9c77a8ef 100644 --- a/cmd/sui/watch.go +++ b/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 diff --git a/sui/README.md b/sui/README.md index 373dafff..b1472aa0 100644 --- a/sui/README.md +++ b/sui/README.md @@ -18,16 +18,18 @@ SUI is a full-stack web development framework that allows you to create web appl ``` /templates// ├── __document.html # Global document template -├── __assets/ # Static assets +├── __data.json # Global data (accessible via $global) +├── __assets/ # Static assets (reference via @assets/) ├── __locales/ # Locale files -└── / # Pages - └── / - ├── .html # HTML template +└── pages/ # All pages go here + └── / # Route = folder name (can be nested) + ├── .html # HTML template (filename must match folder) ├── .css # Styles ├── .ts # Frontend script ├── .json # Data configuration ├── .config # Page configuration - └── .backend.ts # Backend script + ├── .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 ``` / ├── agent/ -│ └── template/ # Agent SUI template +│ └── template/ # Agent SUI template (shared) │ ├── __document.html +│ ├── __data.json │ ├── __assets/ -│ └── pages/ +│ └── pages/ # Global pages (401, 404, etc.) +│ └── / └── assistants/ └── / - └── pages/ # Assistant pages + └── pages/ # Assistant pages → /agents// + └── / ``` Build with: `yao sui build agent` diff --git a/sui/core/interfaces.go b/sui/core/interfaces.go index 7338d0d9..07c886d4 100644 --- a/sui/core/interfaces.go +++ b/sui/core/interfaces.go @@ -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 +} diff --git a/sui/docs/agent-sui.md b/sui/docs/agent-sui.md index 5085f68e..6207441a 100644 --- a/sui/docs/agent-sui.md +++ b/sui/docs/agent-sui.md @@ -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.) +│ └── / # Route = folder name +│ ├── .html +│ ├── .css +│ ├── .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 + └── / # Assistant + ├── package.yao # Assistant configuration + └── pages/ # Assistant pages → /agents// + └── / # Route = folder name (can be nested) + ├── .html + ├── .css + ├── .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. diff --git a/sui/docs/backend-scripts.md b/sui/docs/backend-scripts.md index a163d16b..a915e5dc 100644 --- a/sui/docs/backend-scripts.md +++ b/sui/docs/backend-scripts.md @@ -14,6 +14,12 @@ Backend scripts use the naming convention `.backend.ts` or `.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 { ## 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`**: diff --git a/sui/docs/components.md b/sui/docs/components.md index ec235354..befe4a2c 100644 --- a/sui/docs/components.md +++ b/sui/docs/components.md @@ -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 `` 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 diff --git a/sui/docs/data-binding.md b/sui/docs/data-binding.md index e64dcd81..7edb0764 100644 --- a/sui/docs/data-binding.md +++ b/sui/docs/data-binding.md @@ -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 diff --git a/sui/docs/event-handling.md b/sui/docs/event-handling.md index feb3c3d3..8313de9a 100644 --- a/sui/docs/event-handling.md +++ b/sui/docs/event-handling.md @@ -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 {
{{ item.name }} @@ -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 - + ``` ```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 = {}; + +self.HandleInput = (event: Event, data: EventData) => { + const input = event.target as HTMLInputElement; + formData[data.field] = input.value; +}; ``` ## Keyboard Events ```html - + ``` ```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
- + @@ -355,53 +350,51 @@ function Search(component: HTMLElement) {
  • {{ todo.title }} - +
  • ``` ```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); +}; ``` diff --git a/sui/docs/frontend-api.md b/sui/docs/frontend-api.md index 06901038..f0daf8ad 100644 --- a/sui/docs/frontend-api.md +++ b/sui/docs/frontend-api.md @@ -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("/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("/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: {} }); +}; ``` diff --git a/sui/docs/i18n.md b/sui/docs/i18n.md index d33ec269..9245a4ee 100644 --- a/sui/docs/i18n.md +++ b/sui/docs/i18n.md @@ -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: Contact - +
    ``` diff --git a/sui/docs/routing.md b/sui/docs/routing.md new file mode 100644 index 00000000..5f6826e6 --- /dev/null +++ b/sui/docs/routing.md @@ -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 +

    User ID: {{ $param.id }}

    +

    Category: {{ $param.category }}

    +``` + +**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 }}` | `

    {{ $param.id }}

    ` | +| `.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 +
    + +
    +``` + +**Frontend TypeScript**: + +```typescript +const pageEl = document.getElementById("page"); +const id = pageEl?.dataset.id; +``` diff --git a/sui/storages/agent/page.go b/sui/storages/agent/page.go index 2ce6e215..f45bdad5 100644 --- a/sui/storages/agent/page.go +++ b/sui/storages/agent/page.go @@ -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//.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 +} diff --git a/sui/storages/agent/template.go b/sui/storages/agent/template.go index 30484219..ac921480 100644 --- a/sui/storages/agent/template.go +++ b/sui/storages/agent/template.go @@ -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