Refactor SUI Command Arguments and Enhance Default Template Handling
- Updated the `build` and `watch` commands to accept a single argument for `<sui>` and made the `<template>` argument optional, improving usability. - Introduced a default template assignment for the `agent` SUI, ensuring a more intuitive setup for users. - Enhanced error messages for command usage to provide clearer guidance on expected input format. - Added new OpenAPI file to the bindata, improving the framework's capabilities for API integration.
This commit is contained in:
parent
d3ed830ec1
commit
097fb73416
24 changed files with 5233 additions and 230 deletions
|
|
@ -23,8 +23,8 @@ var BuildCmd = &cobra.Command{
|
|||
Short: L("Build the template"),
|
||||
Long: L("Build the template"),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui build <sui> <template> [data]")))
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui build <sui> [template] [data]")))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +38,15 @@ var BuildCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
id := args[0]
|
||||
template := args[1]
|
||||
template := "default"
|
||||
if len(args) >= 2 {
|
||||
template = args[1]
|
||||
}
|
||||
|
||||
// For agent SUI, use "agent" as default template
|
||||
if id == "agent" && template == "default" {
|
||||
template = "agent"
|
||||
}
|
||||
|
||||
var sessionData map[string]interface{}
|
||||
err = jsoniter.UnmarshalFromString(strings.TrimPrefix(data, "::"), &sessionData)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ var WatchCmd = &cobra.Command{
|
|||
Short: L("Auto-build when the template file changes"),
|
||||
Long: L("Auto-build when the template file changes"),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui watch <sui> <template> [data]")))
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui watch <sui> [template] [data]")))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +52,15 @@ var WatchCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
id := args[0]
|
||||
template := args[1]
|
||||
template := "default"
|
||||
if len(args) >= 2 {
|
||||
template = args[1]
|
||||
}
|
||||
|
||||
// For agent SUI, use "agent" as default template
|
||||
if id == "agent" && template == "default" {
|
||||
template = "agent"
|
||||
}
|
||||
|
||||
var sessionData map[string]interface{}
|
||||
err = jsoniter.UnmarshalFromString(strings.TrimPrefix(data, "::"), &sessionData)
|
||||
|
|
|
|||
361
data/bindata.go
361
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -17,33 +17,11 @@ import (
|
|||
|
||||
// Guard is the OAuth guard middleware
|
||||
func (s *Service) Guard(c *gin.Context) {
|
||||
// Get the token from the request
|
||||
token := s.getAccessToken(c)
|
||||
|
||||
// Validate the token
|
||||
if token == "" {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrTokenMissing)
|
||||
c.Abort()
|
||||
return
|
||||
// Authenticate first (validates token and sets authorized info)
|
||||
if !s.Authenticate(c) {
|
||||
return // Authentication failed, response already sent
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
claims, err := s.VerifyToken(token)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Auto refresh the token
|
||||
if claims.ExpiresAt.Before(time.Now()) {
|
||||
s.tryAutoRefreshToken(c, claims)
|
||||
}
|
||||
|
||||
// Set Authorized Info in context
|
||||
sessionID := s.getSessionID(c)
|
||||
authorized.SetInfo(c, claims, sessionID, s.UserID)
|
||||
|
||||
// Check if ACL is enabled
|
||||
if acl.Global == nil || !acl.Global.Enabled() {
|
||||
return
|
||||
|
|
@ -66,6 +44,43 @@ func (s *Service) Guard(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Authenticate validates the token and sets authorized info in context
|
||||
// This method only performs authentication without ACL checks
|
||||
// Returns true if authentication succeeded, false otherwise
|
||||
func (s *Service) Authenticate(c *gin.Context) bool {
|
||||
// Get the token from the request
|
||||
token := s.getAccessToken(c)
|
||||
|
||||
// Validate the token
|
||||
if token == "" {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrTokenMissing)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
claims, err := s.VerifyToken(token)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
// Auto refresh the token
|
||||
if claims.ExpiresAt.Before(time.Now()) {
|
||||
s.tryAutoRefreshToken(c, claims)
|
||||
if c.IsAborted() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Set Authorized Info in context
|
||||
sessionID := s.getSessionID(c)
|
||||
authorized.SetInfo(c, claims, sessionID, s.UserID)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAuthorizedInfo gets authorized info from context
|
||||
// Deprecated: Use authorized.GetInfo(c) instead
|
||||
func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo {
|
||||
|
|
|
|||
106
sui/README.md
106
sui/README.md
|
|
@ -1,29 +1,103 @@
|
|||
# SUI
|
||||
# SUI - Simple User Interface
|
||||
|
||||
SUI is a full-stack web development tool that allows you to create web applications using HTML, CSS, and Typescript/JavaScript.
|
||||
SUI is a full-stack web development framework that allows you to create web applications using HTML, CSS, and TypeScript/JavaScript without complex build tools.
|
||||
|
||||
It is designed to be simple and easy to use. If you are familiar with HTML, CSS, and Typescript/JavaScript, you can start using SUI right away.
|
||||
## Features
|
||||
|
||||
No dependencies required, no build tools required, no complex frameworks required, just write your HTML, CSS, and TS/JS code, and you are good to go.
|
||||
- **Page as Component**: Every page is a component, unifying the development model
|
||||
- **Template Syntax**: Intuitive data binding, conditionals, and loops
|
||||
- **Backend Scripts**: Server-side logic with TypeScript
|
||||
- **Scoped Styles**: Automatic CSS scoping per component
|
||||
- **i18n Support**: Built-in internationalization
|
||||
- **Agent SUI**: Special configuration for AI Agent applications
|
||||
|
||||
## Demo Application
|
||||
## Quick Start
|
||||
|
||||
[https://github.com/YaoApp/yao-startup-webapp](https://github.com/YaoApp/yao-startup-webapp)
|
||||
### Directory Structure
|
||||
|
||||
## Commands
|
||||
```
|
||||
/templates/<template_name>/
|
||||
├── __document.html # Global document template
|
||||
├── __assets/ # Static assets
|
||||
├── __locales/ # Locale files
|
||||
└── <route>/ # Pages
|
||||
└── <page>/
|
||||
├── <page>.html # HTML template
|
||||
├── <page>.css # Styles
|
||||
├── <page>.ts # Frontend script
|
||||
├── <page>.json # Data configuration
|
||||
├── <page>.config # Page configuration
|
||||
└── <page>.backend.ts # Backend script
|
||||
```
|
||||
|
||||
- `yao sui watch` - Watch for changes in the templates directory and compile them into a single SUI file.
|
||||
- `yao sui build` - Compile the templates into a single SUI file.
|
||||
- `yao sui trans` - Generate the i18n translation files, and compile the templates. you can specify the translator automatically translate the text with `trans` attribute.
|
||||
### Basic Page
|
||||
|
||||
## Usage
|
||||
**`/home/home.html`**:
|
||||
|
||||
Comming soon...
|
||||
```html
|
||||
<div class="home">
|
||||
<h1>{{ title }}</h1>
|
||||
<p s:if="{{ showMessage }}">{{ message }}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## About SUI
|
||||
**`/home/home.json`**:
|
||||
|
||||
SUI is a part of the Yao project, which is a collection of tools for web development.
|
||||
```json
|
||||
{
|
||||
"title": "Welcome",
|
||||
"showMessage": true,
|
||||
"message": "Hello, World!"
|
||||
}
|
||||
```
|
||||
|
||||
SUI name comes from the chinese word "随" which means "follow" or "accompany", it's one of the sixteenth hexagrams of the I Ching, and you can think of SUI as a tool that follows you in your web development journey.
|
||||
### Commands
|
||||
|
||||
SUI name also comes from the word "SUI" which means "simple user interface", and you can think of SUI as a tool that makes it easy to create web applications.
|
||||
```bash
|
||||
# Build templates
|
||||
yao sui build <sui> [template]
|
||||
|
||||
# Watch for changes
|
||||
yao sui watch <sui> [template]
|
||||
|
||||
# Build Agent SUI
|
||||
yao sui build agent
|
||||
|
||||
# Watch Agent SUI
|
||||
yao sui watch agent
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Template Syntax](docs/template-syntax.md) - Data binding, conditionals, loops
|
||||
- [Components](docs/components.md) - Page as component, props, slots
|
||||
- [Backend Scripts](docs/backend-scripts.md) - Server-side logic
|
||||
- [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
|
||||
- [Agent SUI](docs/agent-sui.md) - AI Agent application setup
|
||||
|
||||
## Agent SUI
|
||||
|
||||
Agent SUI is designed for AI Agent applications with automatic page loading from assistants:
|
||||
|
||||
```
|
||||
<app>/
|
||||
├── agent/
|
||||
│ └── template/ # Agent SUI template
|
||||
│ ├── __document.html
|
||||
│ ├── __assets/
|
||||
│ └── pages/
|
||||
└── assistants/
|
||||
└── <name>/
|
||||
└── pages/ # Assistant pages
|
||||
```
|
||||
|
||||
Build with: `yao sui build agent`
|
||||
|
||||
See [Agent SUI Documentation](docs/agent-sui.md) for details.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import (
|
|||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/helper"
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"rogchap.com/v8go"
|
||||
)
|
||||
|
||||
|
|
@ -24,6 +26,7 @@ var Guards = map[string]func(c *Request) error{
|
|||
"query-jwt": guardQueryJWT, // Get JWT Token from query string "__tk"
|
||||
"cookie-jwt": guardCookieJWT, // Get JWT Token from cookie "__tk"
|
||||
"cookie-trace": guardCookieTrace, // Set sid cookie
|
||||
"oauth": guardOAuth, // OAuth 2.1 guard
|
||||
}
|
||||
|
||||
// JWT Bearer JWT
|
||||
|
|
@ -91,6 +94,34 @@ func guardCookieTrace(r *Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// OAuth 2.1 guard using openapi/oauth service
|
||||
// This guard only authenticates the user without ACL checks (suitable for page rendering)
|
||||
func guardOAuth(r *Request) error {
|
||||
if r.context == nil {
|
||||
return fmt.Errorf("Context is nil")
|
||||
}
|
||||
|
||||
if oauth.OAuth == nil {
|
||||
return fmt.Errorf("OAuth service not initialized")
|
||||
}
|
||||
|
||||
c := r.context
|
||||
|
||||
// Authenticate only (validates token and sets authorized info, no ACL check)
|
||||
if !oauth.OAuth.Authenticate(c) {
|
||||
return fmt.Errorf("Not authenticated")
|
||||
}
|
||||
|
||||
// Get authorized info from context
|
||||
info := authorized.GetInfo(c)
|
||||
if info != nil {
|
||||
r.Sid = info.SessionID
|
||||
r.Authorized = info.AuthorizedToMap()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JWT Bearer JWT
|
||||
func guardQueryJWT(r *Request) error {
|
||||
if r.context == nil {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/share"
|
||||
"github.com/yaoapp/yao/sui/core"
|
||||
"github.com/yaoapp/yao/sui/storages/agent"
|
||||
"github.com/yaoapp/yao/sui/storages/azure"
|
||||
"github.com/yaoapp/yao/sui/storages/local"
|
||||
)
|
||||
|
|
@ -29,6 +30,9 @@ func New(dsl *core.DSL) (core.SUI, error) {
|
|||
case "azure":
|
||||
return azure.New(dsl)
|
||||
|
||||
case "agent":
|
||||
return agent.New(dsl)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%s is not a valid driver", dsl.Storage.Driver)
|
||||
}
|
||||
|
|
@ -55,10 +59,55 @@ func Load(cfg config.Config) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Auto-load Agent SUI if /agent directory exists and has pages
|
||||
if err := loadAgentSUI(); err != nil {
|
||||
log.Warn("[sui] Failed to load agent SUI: %s", err.Error())
|
||||
}
|
||||
|
||||
buildRouteMatchers()
|
||||
return registerAPI()
|
||||
}
|
||||
|
||||
// loadAgentSUI automatically loads the agent SUI if /agent directory exists
|
||||
func loadAgentSUI() error {
|
||||
// Check if agent storage is available
|
||||
if !agent.Exists() {
|
||||
return nil // No agent directory, skip silently
|
||||
}
|
||||
|
||||
// Check if there are any assistant pages
|
||||
if !agent.HasAssistantPages() {
|
||||
log.Debug("[sui] Agent directory exists but no assistant pages found")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create agent DSL
|
||||
dsl := &core.DSL{
|
||||
ID: "agent",
|
||||
Name: "Agent",
|
||||
Storage: &core.Storage{
|
||||
Driver: "agent",
|
||||
},
|
||||
Public: &core.Public{
|
||||
Root: "/agents",
|
||||
Host: "/",
|
||||
Index: "/index",
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent SUI
|
||||
sui, err := agent.New(dsl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Register the agent SUI
|
||||
core.SUIs["agent"] = sui
|
||||
log.Info("[sui] Agent SUI loaded successfully (public root: /agents)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadFile(file string, id string) (core.SUI, error) {
|
||||
|
||||
dsl, err := core.Load(file, id)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,14 @@ func LibSUI() ([]byte, []byte, error) {
|
|||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Read openapi source code from bindata
|
||||
openapi, err := data.Read("libsui/openapi.ts")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Merge the source code
|
||||
source := fmt.Sprintf("%s\n%s\n%s\n%s", index, utils, yao, agent)
|
||||
source := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", index, utils, yao, agent, openapi)
|
||||
|
||||
// Build the source code
|
||||
js, sm, err := transform.TypeScriptWithSourceMap(string(source), api.TransformOptions{
|
||||
|
|
|
|||
|
|
@ -78,6 +78,12 @@ func (r *Request) NewData() Data {
|
|||
data["$locale"] = r.Locale
|
||||
data["$timezone"] = GetSystemTimezone()
|
||||
data["$direction"] = "ltr"
|
||||
|
||||
// Add authorized information if available
|
||||
if r.Authorized != nil {
|
||||
data["$auth"] = r.Authorized
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,12 @@ func (script *Script) Call(r *Request, method string, args ...any) (interface{},
|
|||
|
||||
// Set the sid
|
||||
ctx.Sid = r.Sid
|
||||
|
||||
// Set authorized information if available
|
||||
if r.Authorized != nil {
|
||||
ctx.WithAuthorized(r.Authorized)
|
||||
}
|
||||
|
||||
res, err := ctx.Call(method, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -126,6 +132,12 @@ func (script *Script) BeforeRender(r *Request, props map[string]interface{}) (Da
|
|||
|
||||
// Set the sid
|
||||
ctx.Sid = r.Sid
|
||||
|
||||
// Set authorized information if available
|
||||
if r.Authorized != nil {
|
||||
ctx.WithAuthorized(r.Authorized)
|
||||
}
|
||||
|
||||
res, err := ctx.Call("BeforeRender", r, props)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -278,19 +278,20 @@ type BuildOption struct {
|
|||
|
||||
// Request is the struct for the request
|
||||
type Request struct {
|
||||
Method string `json:"method"`
|
||||
AssetRoot string `json:"asset_root,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||
Query url.Values `json:"query,omitempty"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
Headers url.Values `json:"headers,omitempty"`
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
URL ReqeustURL `json:"url,omitempty"`
|
||||
Sid string `json:"sid,omitempty"`
|
||||
Theme any `json:"theme,omitempty"`
|
||||
Locale any `json:"locale,omitempty"`
|
||||
Script *Script `json:"-"`
|
||||
Method string `json:"method"`
|
||||
AssetRoot string `json:"asset_root,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||
Query url.Values `json:"query,omitempty"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
Headers url.Values `json:"headers,omitempty"`
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
URL ReqeustURL `json:"url,omitempty"`
|
||||
Sid string `json:"sid,omitempty"`
|
||||
Theme any `json:"theme,omitempty"`
|
||||
Locale any `json:"locale,omitempty"`
|
||||
Script *Script `json:"-"`
|
||||
Authorized map[string]interface{} `json:"authorized,omitempty"` // OAuth authorized information
|
||||
}
|
||||
|
||||
// RequestSource is the struct for the request
|
||||
|
|
|
|||
252
sui/docs/agent-sui.md
Normal file
252
sui/docs/agent-sui.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# Agent SUI
|
||||
|
||||
Agent SUI is a special SUI configuration designed for AI Agent applications. It automatically loads pages from the `/agent/template/` directory and individual assistant pages from `/assistants/<name>/pages/`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
<app>/
|
||||
├── agent/
|
||||
│ ├── agent.yao # Agent configuration
|
||||
│ └── 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)
|
||||
│ │ ├── css/
|
||||
│ │ ├── js/
|
||||
│ │ └── images/
|
||||
│ ├── pages/ # Global agent pages (login, error, etc.)
|
||||
│ │ └── login/
|
||||
│ │ └── login.html
|
||||
│ └── __locales/ # Internationalization
|
||||
│
|
||||
└── assistants/ # Assistants directory
|
||||
├── demo/ # Assistant: demo
|
||||
│ ├── assistant.yao # Assistant configuration
|
||||
│ └── pages/ # Assistant-specific pages
|
||||
│ ├── index/
|
||||
│ │ ├── index.html
|
||||
│ │ ├── index.css
|
||||
│ │ └── index.ts
|
||||
│ └── __assets/ # Optional assistant-specific assets
|
||||
│
|
||||
└── another/ # Assistant: another
|
||||
├── assistant.yao
|
||||
└── pages/
|
||||
└── settings/
|
||||
└── settings.html
|
||||
```
|
||||
|
||||
## Route Mapping
|
||||
|
||||
| File Path | Public URL |
|
||||
| -------------------------------------------------- | -------------------------- |
|
||||
| `/agent/template/pages/login/login.html` | `/agents/login` |
|
||||
| `/assistants/demo/pages/index/index.html` | `/agents/demo/index` |
|
||||
| `/assistants/another/pages/settings/settings.html` | `/agents/another/settings` |
|
||||
|
||||
## Asset Paths
|
||||
|
||||
- **Global assets**: `/agents/assets/...` → `/agent/template/__assets/...`
|
||||
- **Assistant assets**: `/agents/<assistant-id>/assets/...` → `/assistants/<assistant-id>/pages/__assets/...`
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Build Agent SUI
|
||||
yao sui build agent
|
||||
|
||||
# Watch Agent SUI for changes
|
||||
yao sui watch agent
|
||||
```
|
||||
|
||||
## Build Output
|
||||
|
||||
After running `yao sui build agent`, the following structure is generated:
|
||||
|
||||
```
|
||||
<app>/public/
|
||||
└── agents/ # Public root for Agent SUI
|
||||
├── assets/ # Static assets
|
||||
│ ├── libsui.min.js # SUI frontend SDK
|
||||
│ ├── libsui.min.js.map # Source map
|
||||
│ ├── css/ # From /agent/template/__assets/css/
|
||||
│ ├── js/ # From /agent/template/__assets/js/
|
||||
│ └── images/ # From /agent/template/__assets/images/
|
||||
│
|
||||
├── login.sui # Compiled page
|
||||
├── login.cfg # Page configuration
|
||||
│
|
||||
├── demo/ # Assistant: demo
|
||||
│ ├── index.sui # Compiled page
|
||||
│ └── index.cfg # Page configuration
|
||||
│
|
||||
└── another/ # Assistant: another
|
||||
├── settings.sui # Compiled page
|
||||
└── settings.cfg # Page configuration
|
||||
```
|
||||
|
||||
**File Types:**
|
||||
|
||||
| Extension | Description |
|
||||
| --------- | ------------------------------------------------------- |
|
||||
| `.sui` | Compiled HTML page (includes template, styles, scripts) |
|
||||
| `.cfg` | Page configuration (JSON format) |
|
||||
| `.jit` | JIT component (for dynamic loading) |
|
||||
|
||||
## Auto-Loading
|
||||
|
||||
Agent SUI is automatically loaded when:
|
||||
|
||||
1. The `/agent/template/` directory exists
|
||||
2. At least one assistant has a `pages/` directory
|
||||
|
||||
No additional configuration is required.
|
||||
|
||||
## Document Template
|
||||
|
||||
Create `/agent/template/__document.html`:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{{ $global.title }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/agents/assets/images/favicon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">{{ __page }}</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Global Data
|
||||
|
||||
Create `/agent/template/__data.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "AI Agent",
|
||||
"version": "1.0.0",
|
||||
"theme": "light"
|
||||
}
|
||||
```
|
||||
|
||||
## Example Assistant Page
|
||||
|
||||
**`/assistants/demo/pages/index/index.html`**:
|
||||
|
||||
```html
|
||||
<div id="demo-index" class="page">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="content">
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`/assistants/demo/pages/index/index.json`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Welcome",
|
||||
"description": "This is a demo page"
|
||||
}
|
||||
```
|
||||
|
||||
**`/assistants/demo/pages/index/index.css`**:
|
||||
|
||||
```css
|
||||
.page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
## Page Configuration
|
||||
|
||||
Create `<page>.config` for page settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Page Title",
|
||||
"guard": "bearer-jwt",
|
||||
"cache": 3600
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Scripts
|
||||
|
||||
Each page can have a backend script:
|
||||
|
||||
**`/assistants/demo/pages/index/index.backend.ts`**:
|
||||
|
||||
```typescript
|
||||
function BeforeRender(request: Request): Record<string, any> {
|
||||
return {
|
||||
user: Process("session.Get", "user"),
|
||||
data: Process("models.data.Get", {}),
|
||||
};
|
||||
}
|
||||
|
||||
function ApiGetData(request: Request): any {
|
||||
return Process("models.data.Get", {});
|
||||
}
|
||||
```
|
||||
|
||||
## Using Components
|
||||
|
||||
Pages can use other pages as components:
|
||||
|
||||
```html
|
||||
<import s:as="Header" s:from="/shared/header" />
|
||||
<import s:as="Footer" s:from="/shared/footer" />
|
||||
|
||||
<div class="page">
|
||||
<header title="Demo" />
|
||||
<main>
|
||||
<p>Content here</p>
|
||||
</main>
|
||||
<footer />
|
||||
</div>
|
||||
```
|
||||
|
||||
## Accessing in Templates
|
||||
|
||||
Use standard SUI template syntax:
|
||||
|
||||
```html
|
||||
<!-- Data binding -->
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<!-- Conditionals -->
|
||||
<div s:if="{{ isLoggedIn }}">Welcome!</div>
|
||||
|
||||
<!-- Loops -->
|
||||
<ul>
|
||||
<li s:for="{{ items }}" s:for-item="item">{{ item.name }}</li>
|
||||
</ul>
|
||||
|
||||
<!-- Events -->
|
||||
<button s:on-click="handleClick">Click Me</button>
|
||||
```
|
||||
|
||||
## Frontend Script
|
||||
|
||||
**`/assistants/demo/pages/index/index.ts`**:
|
||||
|
||||
```typescript
|
||||
function index(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.store = new __sui_store(component);
|
||||
|
||||
this.handleClick = async (event: Event) => {
|
||||
const data = await this.backend.ApiGetData();
|
||||
console.log(data);
|
||||
};
|
||||
}
|
||||
```
|
||||
328
sui/docs/backend-scripts.md
Normal file
328
sui/docs/backend-scripts.md
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
# Backend Scripts
|
||||
|
||||
Backend scripts provide server-side logic for SUI pages, including data fetching, API endpoints, and helper functions.
|
||||
|
||||
## File Naming
|
||||
|
||||
Backend scripts use the naming convention `<page>.backend.ts` or `<page>.backend.js`:
|
||||
|
||||
```
|
||||
/users/list/
|
||||
├── list.html
|
||||
├── list.css
|
||||
├── list.ts
|
||||
└── list.backend.ts # Backend script
|
||||
```
|
||||
|
||||
## BeforeRender
|
||||
|
||||
The `BeforeRender` function is called before the page is rendered:
|
||||
|
||||
```typescript
|
||||
function BeforeRender(
|
||||
request: Request,
|
||||
props?: Record<string, any>
|
||||
): Record<string, any> {
|
||||
return {
|
||||
user: Process("session.Get", "user"),
|
||||
items: Process("models.item.Get", { limit: 10 }),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- `request` - The HTTP request object
|
||||
- `props` - Props passed when used as a component (optional)
|
||||
|
||||
### Return Value
|
||||
|
||||
Return an object that will be merged with page data:
|
||||
|
||||
```typescript
|
||||
function BeforeRender(request: Request): Record<string, any> {
|
||||
const userId = request.query.userId;
|
||||
|
||||
return {
|
||||
user: Process("models.user.Find", userId),
|
||||
posts: Process("models.post.Get", {
|
||||
wheres: [{ column: "user_id", value: userId }],
|
||||
}),
|
||||
stats: {
|
||||
views: 100,
|
||||
likes: 50,
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Methods
|
||||
|
||||
Functions prefixed with `Api` are exposed as callable endpoints:
|
||||
|
||||
```typescript
|
||||
// Callable from frontend as: this.backend.ApiGetUsers()
|
||||
function ApiGetUsers(request: Request): any[] {
|
||||
return Process("models.user.Get", {});
|
||||
}
|
||||
|
||||
// Callable from frontend as: this.backend.ApiCreateUser(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)
|
||||
function ApiDeleteUser(id: string, request: Request): boolean {
|
||||
Process("models.user.Delete", id);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Calling from Frontend
|
||||
|
||||
```typescript
|
||||
function Page(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.loadUsers = async () => {
|
||||
const users = await this.backend.ApiGetUsers();
|
||||
console.log(users);
|
||||
};
|
||||
|
||||
this.createUser = async () => {
|
||||
const user = await this.backend.ApiCreateUser("John", "john@example.com");
|
||||
console.log("Created:", user);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Constants
|
||||
|
||||
Export constants to the frontend using `__sui_constants`:
|
||||
|
||||
```typescript
|
||||
const __sui_constants = {
|
||||
API_URL: "/api/v1",
|
||||
MAX_ITEMS: 100,
|
||||
SUPPORTED_FORMATS: ["jpg", "png", "gif"],
|
||||
CONFIG: {
|
||||
timeout: 5000,
|
||||
retries: 3,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Access in frontend:
|
||||
|
||||
```typescript
|
||||
function Page(component: HTMLElement) {
|
||||
console.log(this.constants.API_URL); // "/api/v1"
|
||||
console.log(this.constants.MAX_ITEMS); // 100
|
||||
}
|
||||
```
|
||||
|
||||
## Helpers
|
||||
|
||||
Export helper functions to the frontend using `__sui_helpers`:
|
||||
|
||||
```typescript
|
||||
const __sui_helpers = ["formatDate", "formatCurrency", "validateEmail"];
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, currency: string = "USD"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function validateEmail(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
```
|
||||
|
||||
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");
|
||||
}
|
||||
```
|
||||
|
||||
## Request Object
|
||||
|
||||
The request object contains:
|
||||
|
||||
```typescript
|
||||
interface Request {
|
||||
method: string; // HTTP method
|
||||
url: {
|
||||
path: string;
|
||||
host: string;
|
||||
domain: string;
|
||||
scheme: string;
|
||||
};
|
||||
query: Record<string, string>; // Query parameters
|
||||
params: Record<string, string>; // Route parameters
|
||||
payload: Record<string, any>; // POST body
|
||||
headers: Record<string, string>; // HTTP headers
|
||||
sid: string; // Session ID
|
||||
theme: string; // Current theme
|
||||
locale: string; // Current locale
|
||||
authorized?: Record<string, any>; // OAuth info (when guard is "oauth")
|
||||
}
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
```typescript
|
||||
function BeforeRender(request: Request): Record<string, any> {
|
||||
// Access query parameters
|
||||
const search = request.query.q;
|
||||
const page = parseInt(request.query.page) || 1;
|
||||
|
||||
// Access route parameters
|
||||
const userId = request.params.id;
|
||||
|
||||
// Access headers
|
||||
const authToken = request.headers["Authorization"];
|
||||
|
||||
// Access session
|
||||
const sessionId = request.sid;
|
||||
|
||||
return {
|
||||
search,
|
||||
page,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Process Calls
|
||||
|
||||
Use `Process()` to call Yao processes:
|
||||
|
||||
```typescript
|
||||
// Model operations
|
||||
const users = Process("models.user.Get", { limit: 10 });
|
||||
const user = Process("models.user.Find", userId);
|
||||
Process("models.user.Save", userId, { name: "Updated" });
|
||||
Process("models.user.Delete", userId);
|
||||
|
||||
// Custom scripts
|
||||
const result = Process("scripts.utils.calculate", arg1, arg2);
|
||||
|
||||
// Session
|
||||
const sessionUser = Process("session.Get", "user");
|
||||
Process("session.Set", "key", "value");
|
||||
|
||||
// Flows
|
||||
const output = Process("flows.myflow", input);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
function ApiUpdateUser(id: string, data: any, request: Request): any {
|
||||
try {
|
||||
const user = Process("models.user.Find", id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return Process("models.user.Save", id, data);
|
||||
} catch (error) {
|
||||
// Error will be returned to frontend
|
||||
throw new Error(`Failed to update user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
**`/users/profile/profile.backend.ts`**:
|
||||
|
||||
```typescript
|
||||
// Constants exported to frontend
|
||||
const __sui_constants = {
|
||||
MAX_BIO_LENGTH: 500,
|
||||
ALLOWED_AVATAR_TYPES: ["image/jpeg", "image/png"],
|
||||
};
|
||||
|
||||
// Helper functions exported to frontend
|
||||
const __sui_helpers = ["formatDate", "truncate"];
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
function truncate(text: string, length: number): string {
|
||||
if (text.length <= length) return text;
|
||||
return text.slice(0, length) + "...";
|
||||
}
|
||||
|
||||
// Called before page render
|
||||
function BeforeRender(request: Request): Record<string, any> {
|
||||
const userId = request.params.id;
|
||||
const user = Process("models.user.Find", userId);
|
||||
|
||||
if (!user) {
|
||||
return { error: "User not found" };
|
||||
}
|
||||
|
||||
const posts = Process("models.post.Get", {
|
||||
wheres: [{ column: "user_id", value: userId }],
|
||||
orders: [{ column: "created_at", option: "desc" }],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
posts,
|
||||
isOwner: request.sid === user.session_id,
|
||||
};
|
||||
}
|
||||
|
||||
// API: Get user posts
|
||||
function ApiGetPosts(userId: string, page: number, request: Request): any {
|
||||
return Process("models.post.Paginate", {
|
||||
wheres: [{ column: "user_id", value: userId }],
|
||||
orders: [{ column: "created_at", option: "desc" }],
|
||||
page,
|
||||
pageSize: 10,
|
||||
});
|
||||
}
|
||||
|
||||
// API: Update profile
|
||||
function ApiUpdateProfile(data: any, request: Request): any {
|
||||
const sessionUser = Process("session.Get", "user");
|
||||
if (!sessionUser) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
return Process("models.user.Save", sessionUser.id, {
|
||||
name: data.name,
|
||||
bio: data.bio?.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
// API: Upload avatar
|
||||
function ApiUploadAvatar(file: any, request: Request): any {
|
||||
const sessionUser = Process("session.Get", "user");
|
||||
if (!sessionUser) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const result = Process("fs.system.Upload", file);
|
||||
Process("models.user.Save", sessionUser.id, {
|
||||
avatar: result.path,
|
||||
});
|
||||
|
||||
return { avatar: result.path };
|
||||
}
|
||||
```
|
||||
352
sui/docs/components.md
Normal file
352
sui/docs/components.md
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# Components
|
||||
|
||||
In SUI, **every page is a component**. Any page can be embedded into another page using the `is` attribute.
|
||||
|
||||
## Core Concept
|
||||
|
||||
When a page is used as a component:
|
||||
|
||||
1. The page's HTML becomes the component template
|
||||
2. The page's CSS is automatically scoped
|
||||
3. The page's TypeScript becomes the component class
|
||||
4. The page's `backend.ts` provides server-side logic via `BeforeRender`
|
||||
|
||||
## Creating a Component
|
||||
|
||||
A component is just a page with a single root element:
|
||||
|
||||
**`/card/card.html`**:
|
||||
|
||||
```html
|
||||
<div class="card">
|
||||
<h3>{{ title }}</h3>
|
||||
<div class="card-body">
|
||||
<children></children>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`/card/card.css`**:
|
||||
|
||||
```css
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
```
|
||||
|
||||
**`/card/card.ts`**:
|
||||
|
||||
```typescript
|
||||
function card(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.store = new __sui_store(component);
|
||||
this.props = new __sui_props(component);
|
||||
}
|
||||
```
|
||||
|
||||
## Using Components
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Use the `is` attribute to embed a page as a component:
|
||||
|
||||
```html
|
||||
<div is="/card" title="My Card">
|
||||
<p>Card content goes here</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### With Import Alias
|
||||
|
||||
Use `<import>` for cleaner syntax:
|
||||
|
||||
```html
|
||||
<import s:as="Card" s:from="/card" />
|
||||
<import s:as="Button" s:from="/shared/button" />
|
||||
|
||||
<Card title="My Card">
|
||||
<p>Content</p>
|
||||
</Card>
|
||||
|
||||
<Button variant="primary">Click Me</Button>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
Props are passed as attributes:
|
||||
|
||||
```html
|
||||
<div
|
||||
is="/user-card"
|
||||
name="{{ user.name }}"
|
||||
email="{{ user.email }}"
|
||||
avatar="{{ user.avatar }}"
|
||||
role="admin"
|
||||
/>
|
||||
```
|
||||
|
||||
Access props in the component script:
|
||||
|
||||
```typescript
|
||||
function userCard(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.props = new __sui_props(component);
|
||||
|
||||
// Get single prop
|
||||
const name = this.props.Get("name");
|
||||
|
||||
// Get all props
|
||||
const allProps = this.props.List();
|
||||
// { name: "John", email: "john@example.com", avatar: "...", role: "admin" }
|
||||
}
|
||||
```
|
||||
|
||||
Access props in backend script:
|
||||
|
||||
```typescript
|
||||
function BeforeRender(
|
||||
request: Request,
|
||||
props: Record<string, any>
|
||||
): Record<string, any> {
|
||||
const userId = props.userId;
|
||||
return {
|
||||
user: Process("models.user.Find", userId),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Children and Slots
|
||||
|
||||
### Children
|
||||
|
||||
Use `<children></children>` to render child content:
|
||||
|
||||
**Component (`/panel/panel.html`)**:
|
||||
|
||||
```html
|
||||
<div class="panel">
|
||||
<div class="panel-header">{{ title }}</div>
|
||||
<div class="panel-body">
|
||||
<children></children>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```html
|
||||
<div is="/panel" title="Settings">
|
||||
<p>This content appears in the panel body</p>
|
||||
<button>Save</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Named Slots
|
||||
|
||||
Use `<slot name="xxx">` for multiple content areas:
|
||||
|
||||
**Component (`/modal/modal.html`)**:
|
||||
|
||||
```html
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<children></children>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```html
|
||||
<div is="/modal">
|
||||
<slot name="header">
|
||||
<h2>Confirmation</h2>
|
||||
</slot>
|
||||
|
||||
<p>Are you sure you want to proceed?</p>
|
||||
|
||||
<slot name="footer">
|
||||
<button>Cancel</button>
|
||||
<button>Confirm</button>
|
||||
</slot>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dynamic Components
|
||||
|
||||
### Variable Component Route
|
||||
|
||||
```html
|
||||
<div is="{{ '/widgets/' + widgetType }}" ...widgetProps></div>
|
||||
```
|
||||
|
||||
### Dynamic Tag
|
||||
|
||||
```html
|
||||
<dynamic route="/components/{{ componentName }}" />
|
||||
```
|
||||
|
||||
## Component Script
|
||||
|
||||
### Structure
|
||||
|
||||
```typescript
|
||||
function componentName(component: HTMLElement) {
|
||||
// Root element
|
||||
this.root = component;
|
||||
|
||||
// Data store (data-* attributes)
|
||||
this.store = new __sui_store(component);
|
||||
|
||||
// Props (passed attributes)
|
||||
this.props = new __sui_props(component);
|
||||
|
||||
// State management
|
||||
this.state = new __sui_state(this);
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Store API
|
||||
|
||||
```typescript
|
||||
// String data
|
||||
this.store.Get("key");
|
||||
this.store.Set("key", "value");
|
||||
|
||||
// JSON data
|
||||
this.store.GetJSON("items");
|
||||
this.store.SetJSON("items", [{ id: 1 }]);
|
||||
|
||||
// Component data (from BeforeRender)
|
||||
this.store.GetData();
|
||||
```
|
||||
|
||||
### Props API
|
||||
|
||||
```typescript
|
||||
// Get single prop
|
||||
const value = this.props.Get("propName");
|
||||
|
||||
// Get all props
|
||||
const props = this.props.List();
|
||||
```
|
||||
|
||||
### State API
|
||||
|
||||
```typescript
|
||||
// Set state (triggers watchers)
|
||||
this.state.Set("count", 10);
|
||||
|
||||
// Watch state changes
|
||||
this.watch = {
|
||||
count: (value, state) => {
|
||||
this.root.querySelector(".count").textContent = value;
|
||||
// state.stopPropagation(); // Prevent bubbling to parent
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Nested Components
|
||||
|
||||
Components can include other components:
|
||||
|
||||
```html
|
||||
<!-- /dashboard/dashboard.html -->
|
||||
<div class="dashboard">
|
||||
<div is="/shared/header" title="Dashboard" />
|
||||
|
||||
<div class="content">
|
||||
<div is="/dashboard/stats" data="{{ stats }}" />
|
||||
<div is="/dashboard/chart" type="line" data="{{ chartData }}" />
|
||||
</div>
|
||||
|
||||
<div is="/shared/footer" />
|
||||
</div>
|
||||
```
|
||||
|
||||
## Component Backend Script
|
||||
|
||||
**`/user-card/user-card.backend.ts`**:
|
||||
|
||||
```typescript
|
||||
function BeforeRender(
|
||||
request: Request,
|
||||
props: Record<string, any>
|
||||
): Record<string, any> {
|
||||
const userId = props.userId;
|
||||
|
||||
return {
|
||||
user: Process("models.user.Find", userId),
|
||||
permissions: Process("scripts.auth.GetPermissions", userId),
|
||||
};
|
||||
}
|
||||
|
||||
function ApiUpdateUser(userId: string, data: any, request: Request): any {
|
||||
return Process("models.user.Save", userId, data);
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Scoping
|
||||
|
||||
Component CSS is automatically scoped using namespace attributes:
|
||||
|
||||
**Original CSS**:
|
||||
|
||||
```css
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.card h3 {
|
||||
color: #333;
|
||||
}
|
||||
```
|
||||
|
||||
**Compiled CSS** (scoped):
|
||||
|
||||
```css
|
||||
[s:ns="ns_abc123"] .card {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
[s:ns="ns_abc123"] .card h3 {
|
||||
color: #333;
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
273
sui/docs/data-binding.md
Normal file
273
sui/docs/data-binding.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# Data Binding
|
||||
|
||||
SUI provides built-in variables and functions for accessing request data and executing server-side logic.
|
||||
|
||||
## Built-in Variables
|
||||
|
||||
### Request Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ---------- | -------------------- | ----------------------- |
|
||||
| `$payload` | POST request body | `{{ $payload.name }}` |
|
||||
| `$query` | URL query parameters | `{{ $query.search }}` |
|
||||
| `$param` | Route parameters | `{{ $param.id }}` |
|
||||
| `$cookie` | Request cookies | `{{ $cookie.session }}` |
|
||||
|
||||
### URL Variables
|
||||
|
||||
| Variable | Description | Example Value |
|
||||
| ------------- | ----------- | --------------------------- |
|
||||
| `$url.path` | URL path | `/users/123` |
|
||||
| `$url.host` | Full host | `example.com:8080` |
|
||||
| `$url.domain` | Domain only | `example.com` |
|
||||
| `$url.scheme` | Protocol | `https` |
|
||||
| `$url.url` | Full URL | `https://example.com/users` |
|
||||
|
||||
### Context Variables
|
||||
|
||||
| Variable | Description | Example Value |
|
||||
| ------------ | ------------------------------ | -------------------- |
|
||||
| `$theme` | Current theme | `light`, `dark` |
|
||||
| `$locale` | Current locale | `en-us`, `zh-cn` |
|
||||
| `$timezone` | System timezone | `Asia/Shanghai` |
|
||||
| `$direction` | Text direction | `ltr`, `rtl` |
|
||||
| `$global` | Global data from `__data.json` | `{ title: "App" }` |
|
||||
| `$auth` | OAuth authorized info (if guard is `oauth`) | `{ user_id: "123" }` |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Query Parameters
|
||||
|
||||
URL: `/search?q=hello&page=2`
|
||||
|
||||
```html
|
||||
<h1>Search: {{ $query.q }}</h1>
|
||||
<p>Page: {{ $query.page ?? 1 }}</p>
|
||||
```
|
||||
|
||||
### Route Parameters
|
||||
|
||||
Route: `/users/[id]/posts/[postId]`
|
||||
URL: `/users/123/posts/456`
|
||||
|
||||
```html
|
||||
<h1>User {{ $param.id }}</h1>
|
||||
<p>Post {{ $param.postId }}</p>
|
||||
```
|
||||
|
||||
### POST Payload
|
||||
|
||||
```html
|
||||
<form method="POST">
|
||||
<input name="email" value="{{ $payload.email }}" />
|
||||
<div s:if="{{ $payload.error }}">{{ $payload.error }}</div>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Theme and Locale
|
||||
|
||||
```html
|
||||
<html class="{{ $theme }}" lang="{{ $locale }}">
|
||||
<body dir="{{ $direction }}">
|
||||
<h1>{{ $global.title }}</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Data Configuration (`<page>.json`)
|
||||
|
||||
Define page data using JSON configuration:
|
||||
|
||||
### Static Data
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Page",
|
||||
"items": [
|
||||
{ "id": 1, "name": "Item 1" },
|
||||
{ "id": 2, "name": "Item 2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Process Calls
|
||||
|
||||
```json
|
||||
{
|
||||
"$users": "models.user.Get",
|
||||
"$settings": {
|
||||
"process": "models.settings.Find",
|
||||
"args": [1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keys starting with `$` trigger process calls. The result is available as the variable name (without `$`).
|
||||
|
||||
### Using Request Variables
|
||||
|
||||
```json
|
||||
{
|
||||
"$user": {
|
||||
"process": "models.user.Find",
|
||||
"args": ["$param.id"]
|
||||
},
|
||||
"searchQuery": "$query.q",
|
||||
"currentPath": "$url.path"
|
||||
}
|
||||
```
|
||||
|
||||
Available request variables in JSON config:
|
||||
|
||||
- `$query.<name>` - Query parameters
|
||||
- `$param.<name>` - Route parameters
|
||||
- `$payload.<name>` - POST payload
|
||||
- `$header.<name>` - Request headers
|
||||
- `$url.path` / `$url.host` / `$url.domain` / `$url.scheme`
|
||||
|
||||
Note: `$header` is only available in JSON configuration, not in HTML templates.
|
||||
|
||||
### Complex Example
|
||||
|
||||
```json
|
||||
{
|
||||
"pageTitle": "User Profile",
|
||||
"userId": "$param.id",
|
||||
"$user": {
|
||||
"process": "models.user.Find",
|
||||
"args": ["$param.id"]
|
||||
},
|
||||
"$posts": {
|
||||
"process": "models.post.Get",
|
||||
"args": [
|
||||
{
|
||||
"wheres": [{ "column": "user_id", "value": "$param.id" }],
|
||||
"limit": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
"isOwner": "$query.edit == 'true'"
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Functions
|
||||
|
||||
### P\_() - Process Call
|
||||
|
||||
Call a Yao process directly in templates:
|
||||
|
||||
```html
|
||||
<!-- Simple call -->
|
||||
<span>{{ P_('utils.formatDate', createdAt) }}</span>
|
||||
|
||||
<!-- With multiple arguments -->
|
||||
<span>{{ P_('utils.calculate', price, quantity, discount) }}</span>
|
||||
|
||||
<!-- In conditions -->
|
||||
<div s:if="{{ P_('auth.hasPermission', 'admin') }}">Admin Panel</div>
|
||||
```
|
||||
|
||||
### True() / False()
|
||||
|
||||
Check boolean values:
|
||||
|
||||
```html
|
||||
<div s:if="{{ True(user) }}">User exists</div>
|
||||
<div s:if="{{ False(error) }}">No error</div>
|
||||
|
||||
<!-- Equivalent to -->
|
||||
<div s:if="{{ user != null && user != false && user != 0 }}">User exists</div>
|
||||
```
|
||||
|
||||
### Empty()
|
||||
|
||||
Check if array or object is empty:
|
||||
|
||||
```html
|
||||
<div s:if="{{ Empty(items) }}">No items</div>
|
||||
<div s:if="{{ !Empty(items) }}">{{ items.length }} items found</div>
|
||||
|
||||
<!-- Works with objects too -->
|
||||
<div s:if="{{ Empty(settings) }}">No settings configured</div>
|
||||
```
|
||||
|
||||
## Global Data (`__data.json`)
|
||||
|
||||
Define global data available to all pages:
|
||||
|
||||
**`/templates/<template>/__data.json`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Application",
|
||||
"version": "1.0.0",
|
||||
"company": {
|
||||
"name": "ACME Inc",
|
||||
"email": "contact@acme.com"
|
||||
},
|
||||
"navigation": [
|
||||
{ "label": "Home", "href": "/" },
|
||||
{ "label": "About", "href": "/about" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Access in templates:
|
||||
|
||||
```html
|
||||
<title>{{ $global.title }}</title>
|
||||
<footer>© {{ $global.company.name }}</footer>
|
||||
|
||||
<nav>
|
||||
<a s:for="{{ $global.navigation }}" s:for-item="item" href="{{ item.href }}">
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</nav>
|
||||
```
|
||||
|
||||
## Backend Script Data
|
||||
|
||||
Data returned from `BeforeRender` is merged with page data:
|
||||
|
||||
**`<page>.backend.ts`**:
|
||||
|
||||
```typescript
|
||||
function BeforeRender(request: Request): Record<string, any> {
|
||||
return {
|
||||
user: Process("session.Get", "user"),
|
||||
notifications: Process("models.notification.Get", {
|
||||
wheres: [{ column: "read", value: false }],
|
||||
limit: 5,
|
||||
}),
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**`<page>.html`**:
|
||||
|
||||
```html
|
||||
<div s:if="{{ user }}">
|
||||
Welcome, {{ user.name }}!
|
||||
<span s:if="{{ !Empty(notifications) }}">
|
||||
{{ notifications.length }} new notifications
|
||||
</span>
|
||||
</div>
|
||||
<footer>Server time: {{ serverTime }}</footer>
|
||||
```
|
||||
|
||||
## Data Priority
|
||||
|
||||
When the same key exists in multiple sources, priority is:
|
||||
|
||||
1. **BeforeRender** (highest) - Backend script data
|
||||
2. **`<page>.json`** - Page data configuration
|
||||
3. **`__data.json`** (lowest) - Global data
|
||||
|
||||
```typescript
|
||||
// BeforeRender returns { title: "From Backend" }
|
||||
// page.json has { title: "From JSON" }
|
||||
// __data.json has { title: "Global Title" }
|
||||
|
||||
// Result: title = "From Backend"
|
||||
```
|
||||
407
sui/docs/event-handling.md
Normal file
407
sui/docs/event-handling.md
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
# Event Handling
|
||||
|
||||
SUI provides a declarative event binding system with state management and component communication.
|
||||
|
||||
## Event Binding
|
||||
|
||||
### Basic Events
|
||||
|
||||
Use `s:on-<event>` to bind events:
|
||||
|
||||
```html
|
||||
<button s:on-click="handleClick">Click Me</button>
|
||||
<input s:on-input="handleInput" />
|
||||
<form s:on-submit="handleSubmit">...</form>
|
||||
```
|
||||
|
||||
### Common Events
|
||||
|
||||
| Attribute | Event | Description |
|
||||
| ----------------- | ---------- | ------------------ |
|
||||
| `s:on-click` | click | Mouse click |
|
||||
| `s:on-dblclick` | dblclick | Double click |
|
||||
| `s:on-input` | input | Input value change |
|
||||
| `s:on-change` | change | Value changed |
|
||||
| `s:on-submit` | submit | Form submission |
|
||||
| `s:on-focus` | focus | Element focused |
|
||||
| `s:on-blur` | blur | Element lost focus |
|
||||
| `s:on-keydown` | keydown | Key pressed |
|
||||
| `s:on-keyup` | keyup | Key released |
|
||||
| `s:on-mouseenter` | mouseenter | Mouse entered |
|
||||
| `s:on-mouseleave` | mouseleave | Mouse left |
|
||||
|
||||
### Multiple Events
|
||||
|
||||
```html
|
||||
<input
|
||||
s:on-input="handleInput"
|
||||
s:on-focus="handleFocus"
|
||||
s:on-blur="handleBlur"
|
||||
s:on-keydown="handleKeydown"
|
||||
/>
|
||||
```
|
||||
|
||||
## Passing Data
|
||||
|
||||
### Data Attributes
|
||||
|
||||
Use `s:data-*` to pass string data:
|
||||
|
||||
```html
|
||||
<button
|
||||
s:on-click="deleteItem"
|
||||
s:data-id="{{ item.id }}"
|
||||
s:data-name="{{ item.name }}"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
|
||||
### JSON Data
|
||||
|
||||
Use `s:json-*` to pass complex data:
|
||||
|
||||
```html
|
||||
<button
|
||||
s:on-click="editItem"
|
||||
s:json-item="{{ item }}"
|
||||
s:json-options="{{ { confirm: true, redirect: '/list' } }}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
### Handler Signature
|
||||
|
||||
```typescript
|
||||
function Page(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### EventContext
|
||||
|
||||
```typescript
|
||||
interface EventContext {
|
||||
rootElement: HTMLElement; // Component root element
|
||||
targetElement: HTMLElement; // Element that triggered the event
|
||||
}
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```html
|
||||
<div class="item-list">
|
||||
<div s:for="{{ items }}" s:for-item="item">
|
||||
<span>{{ item.name }}</span>
|
||||
<button
|
||||
s:on-click="deleteItem"
|
||||
s:data-id="{{ item.id }}"
|
||||
s:json-item="{{ item }}"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```typescript
|
||||
function ItemList(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
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
|
||||
|
||||
if (confirm(`Delete ${item.name}?`)) {
|
||||
await this.backend.ApiDeleteItem(id);
|
||||
context.targetElement.closest(".item").remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### State Object
|
||||
|
||||
```typescript
|
||||
function Counter(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.state = new __sui_state(this);
|
||||
|
||||
// Initial state
|
||||
this.state.Set("count", 0);
|
||||
}
|
||||
```
|
||||
|
||||
### State Watchers
|
||||
|
||||
React to state changes with watchers:
|
||||
|
||||
```typescript
|
||||
function Counter(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.state = new __sui_state(this);
|
||||
|
||||
// Define watchers
|
||||
this.watch = {
|
||||
count: (value: number, state: State) => {
|
||||
this.root.querySelector(".count").textContent = value;
|
||||
},
|
||||
|
||||
items: (value: any[], state: State) => {
|
||||
this.renderItems(value);
|
||||
},
|
||||
};
|
||||
|
||||
this.increment = () => {
|
||||
const count = this.state.Get("count") || 0;
|
||||
this.state.Set("count", count + 1); // Triggers watcher
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Stop Propagation
|
||||
|
||||
Prevent state changes from bubbling to parent:
|
||||
|
||||
```typescript
|
||||
this.watch = {
|
||||
localState: (value: any, state: State) => {
|
||||
// Handle locally
|
||||
this.updateUI(value);
|
||||
|
||||
// Stop propagation to parent components
|
||||
state.stopPropagation();
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Store (Data Attributes)
|
||||
|
||||
Store manages `data-*` attributes on the component:
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
function Card(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.store = new __sui_store(component);
|
||||
|
||||
// Get/Set string values
|
||||
const id = this.store.Get("id");
|
||||
this.store.Set("id", "123");
|
||||
|
||||
// Get/Set JSON values
|
||||
const items = this.store.GetJSON("items");
|
||||
this.store.SetJSON("items", [{ id: 1 }, { id: 2 }]);
|
||||
}
|
||||
```
|
||||
|
||||
### Component Data
|
||||
|
||||
Get data from BeforeRender:
|
||||
|
||||
```typescript
|
||||
// Backend returns: { user: { name: "John" }, settings: {...} }
|
||||
const data = this.store.GetData();
|
||||
console.log(data.user.name); // "John"
|
||||
```
|
||||
|
||||
## Custom Events
|
||||
|
||||
### Emit Events
|
||||
|
||||
```typescript
|
||||
function ItemCard(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.selectItem = () => {
|
||||
const item = this.store.GetJSON("item");
|
||||
|
||||
// Emit custom event
|
||||
this.emit("item:selected", { item });
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Listen to Events
|
||||
|
||||
```typescript
|
||||
function ItemList(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
// Listen to child events
|
||||
this.root.addEventListener("item:selected", (e: CustomEvent) => {
|
||||
const { item } = e.detail;
|
||||
console.log("Selected:", item);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### State Change Events
|
||||
|
||||
Parent components can listen to state changes:
|
||||
|
||||
```typescript
|
||||
function Parent(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.root.addEventListener("state:change", (e: CustomEvent) => {
|
||||
const { key, value, target } = e.detail;
|
||||
console.log(`State ${key} changed to ${value} in`, target);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
### Form Submit
|
||||
|
||||
```html
|
||||
<form s:on-submit="handleSubmit">
|
||||
<input name="email" type="email" required />
|
||||
<input name="password" type="password" required />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
```typescript
|
||||
function LoginForm(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
|
||||
try {
|
||||
await this.backend.ApiLogin(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" />
|
||||
```
|
||||
|
||||
```typescript
|
||||
function Form(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.formData = {};
|
||||
|
||||
this.handleInput = (event: Event, data: any) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.formData[data.field] = input.value;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Events
|
||||
|
||||
```html
|
||||
<input s:on-keydown="handleKeydown" s:on-keyup="handleKeyup" />
|
||||
```
|
||||
|
||||
```typescript
|
||||
function Search(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
this.search();
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```html
|
||||
<div class="todo-app">
|
||||
<form s:on-submit="addTodo">
|
||||
<input
|
||||
name="title"
|
||||
placeholder="Add todo..."
|
||||
s:on-keydown="handleKeydown"
|
||||
/>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<ul class="todo-list">
|
||||
<li s:for="{{ todos }}" s:for-item="todo">
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
```typescript
|
||||
function TodoApp(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.state = new __sui_state(this);
|
||||
this.store = new __sui_store(component);
|
||||
|
||||
this.watch = {
|
||||
todos: (todos: any[]) => {
|
||||
this.render("todoList", { todos });
|
||||
},
|
||||
};
|
||||
|
||||
this.addTodo = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const input = form.querySelector("input") as HTMLInputElement;
|
||||
|
||||
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 = "";
|
||||
}
|
||||
};
|
||||
|
||||
this.toggleTodo = async (event: Event, data: any) => {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
await this.backend.ApiToggleTodo(data.id, checkbox.checked);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
```
|
||||
379
sui/docs/frontend-api.md
Normal file
379
sui/docs/frontend-api.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# Frontend API
|
||||
|
||||
SUI provides a rich frontend API for component interaction, backend calls, and rendering.
|
||||
|
||||
## Component Query
|
||||
|
||||
### $$() Function
|
||||
|
||||
Get a component instance by selector or element:
|
||||
|
||||
```typescript
|
||||
// By ID
|
||||
const card = $$("#my-card");
|
||||
|
||||
// By element
|
||||
const element = document.querySelector(".card");
|
||||
const card = $$(element);
|
||||
|
||||
// Access component methods
|
||||
card.toggle();
|
||||
card.state.Set("expanded", true);
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
```typescript
|
||||
const component = $$("#my-component");
|
||||
|
||||
// Find child component (returns __Query wrapper)
|
||||
const button = component.find("button");
|
||||
|
||||
// Query single element
|
||||
const title = component.query(".title"); // Returns Element
|
||||
|
||||
// Query all elements
|
||||
const items = component.queryAll(".item"); // Returns NodeList
|
||||
```
|
||||
|
||||
## Backend Calls
|
||||
|
||||
### Via Component
|
||||
|
||||
```typescript
|
||||
function Page(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
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");
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Call
|
||||
|
||||
```typescript
|
||||
// __sui_backend_call(route, headers, method, ...args)
|
||||
const result = await __sui_backend_call(
|
||||
"/users/list", // Page route
|
||||
{ "X-Custom-Header": "value" }, // Custom headers
|
||||
"ApiGetUsers", // Method name
|
||||
{ page: 1, limit: 10 } // Arguments
|
||||
);
|
||||
```
|
||||
|
||||
## Render API
|
||||
|
||||
### Render Target
|
||||
|
||||
Define render targets in HTML:
|
||||
|
||||
```html
|
||||
<div s:render="userList" class="user-list">
|
||||
<!-- Content will be replaced here -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Render Method
|
||||
|
||||
```typescript
|
||||
function Page(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.refreshUsers = async () => {
|
||||
const users = await this.backend.ApiGetUsers();
|
||||
|
||||
// Render with data
|
||||
await this.render("userList", { users });
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Render Options
|
||||
|
||||
```typescript
|
||||
await this.render("targetName", data, {
|
||||
replace: true, // Replace content (default: true)
|
||||
showLoader: true, // Show loading indicator
|
||||
withPageData: true, // Include page data in render context
|
||||
route: "/custom/route", // Use custom route for rendering
|
||||
});
|
||||
```
|
||||
|
||||
## Yao SDK (Legacy)
|
||||
|
||||
The `Yao` class provides HTTP client functionality:
|
||||
|
||||
```typescript
|
||||
const yao = new Yao();
|
||||
|
||||
// GET request
|
||||
const data = await yao.Get("/api/users", { page: 1 });
|
||||
|
||||
// POST request
|
||||
const result = await yao.Post("/api/users", { name: "John" });
|
||||
|
||||
// Download file
|
||||
await yao.Download("/api/export", { format: "csv" }, "export.csv");
|
||||
|
||||
// Token management
|
||||
const token = yao.Token();
|
||||
yao.SetCookie("key", "value", 30); // 30 days
|
||||
yao.DeleteCookie("key");
|
||||
```
|
||||
|
||||
## OpenAPI Client (Recommended)
|
||||
|
||||
The `OpenAPI` client provides a modern HTTP client with type safety and error handling.
|
||||
|
||||
### Initialization
|
||||
|
||||
```typescript
|
||||
const api = new OpenAPI({ baseURL: "/api" });
|
||||
```
|
||||
|
||||
### HTTP Methods
|
||||
|
||||
```typescript
|
||||
// GET
|
||||
const response = await api.Get<User[]>("/users");
|
||||
|
||||
// POST
|
||||
const response = await api.Post<User>("/users", {
|
||||
name: "John",
|
||||
email: "john@example.com",
|
||||
});
|
||||
|
||||
// PUT
|
||||
const response = await api.Put<User>("/users/123", {
|
||||
name: "John Updated",
|
||||
});
|
||||
|
||||
// DELETE
|
||||
const response = await api.Delete<void>("/users/123");
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
const response = await api.Get<User[]>("/users");
|
||||
|
||||
if (api.IsError(response)) {
|
||||
console.error(`Error: ${response.error.error_description}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const users = response.data;
|
||||
```
|
||||
|
||||
### Response Types
|
||||
|
||||
```typescript
|
||||
interface APIResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface APIError {
|
||||
error: {
|
||||
error: string;
|
||||
error_description: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## File API
|
||||
|
||||
### Initialization
|
||||
|
||||
```typescript
|
||||
const api = new OpenAPI({ baseURL: "/api" });
|
||||
const fileApi = new FileAPI(api);
|
||||
```
|
||||
|
||||
### Upload
|
||||
|
||||
```typescript
|
||||
const fileInput = document.querySelector<HTMLInputElement>("#file");
|
||||
const file = fileInput.files[0];
|
||||
|
||||
// Upload with progress
|
||||
const response = await fileApi.Upload(
|
||||
file,
|
||||
{
|
||||
path: "documents",
|
||||
groups: ["team-a"],
|
||||
compressImage: true,
|
||||
},
|
||||
(progress) => {
|
||||
console.log(`${progress.percentage}%`);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Upload Multiple
|
||||
|
||||
```typescript
|
||||
const responses = await fileApi.UploadMultiple(
|
||||
Array.from(fileInput.files),
|
||||
{ path: "uploads" },
|
||||
(fileIndex, progress) => {
|
||||
console.log(`File ${fileIndex}: ${progress.percentage}%`);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### File Operations
|
||||
|
||||
```typescript
|
||||
// List files
|
||||
const files = await fileApi.List({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
contentType: "image/*",
|
||||
orderBy: "created_at desc",
|
||||
});
|
||||
|
||||
// Get file info
|
||||
const info = await fileApi.Retrieve("file-id");
|
||||
|
||||
// Download
|
||||
const blob = await fileApi.Download("file-id");
|
||||
if (!api.IsError(blob)) {
|
||||
const url = URL.createObjectURL(blob.data);
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
// Delete
|
||||
await fileApi.Delete("file-id");
|
||||
|
||||
// Check existence
|
||||
const exists = await fileApi.Exists("file-id");
|
||||
```
|
||||
|
||||
### Utility Methods
|
||||
|
||||
```typescript
|
||||
// Format file size
|
||||
FileAPI.FormatSize(1024); // "1 KB"
|
||||
FileAPI.FormatSize(1048576); // "1 MB"
|
||||
|
||||
// Get extension
|
||||
FileAPI.GetExtension("doc.pdf"); // "pdf"
|
||||
|
||||
// Check type
|
||||
FileAPI.IsImage("image/png"); // true
|
||||
FileAPI.IsDocument("application/pdf"); // true
|
||||
```
|
||||
|
||||
## Cross-Origin Support
|
||||
|
||||
```typescript
|
||||
const api = new OpenAPI({ baseURL: "https://api.example.com" });
|
||||
|
||||
if (api.IsCrossOrigin()) {
|
||||
console.log("Cross-origin API");
|
||||
}
|
||||
|
||||
// Set CSRF token after login
|
||||
const loginResponse = await api.Post("/auth/login", credentials);
|
||||
if (!api.IsError(loginResponse) && loginResponse.data.csrf_token) {
|
||||
api.SetCSRFToken(loginResponse.data.csrf_token);
|
||||
}
|
||||
|
||||
// Clear tokens on logout
|
||||
api.ClearTokens();
|
||||
```
|
||||
|
||||
## Custom Events
|
||||
|
||||
### Emit
|
||||
|
||||
```typescript
|
||||
function Card(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.select = () => {
|
||||
this.emit("card:selected", { id: this.store.Get("id") });
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Listen
|
||||
|
||||
```typescript
|
||||
function CardList(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.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) => {
|
||||
const { key, value, target } = e.detail;
|
||||
console.log(`${key} = ${value}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
function UserDashboard(component: HTMLElement) {
|
||||
this.root = component;
|
||||
this.store = new __sui_store(component);
|
||||
this.state = new __sui_state(this);
|
||||
|
||||
// Initialize API
|
||||
const api = new OpenAPI({ baseURL: "/api" });
|
||||
const fileApi = new FileAPI(api);
|
||||
|
||||
// State watchers
|
||||
this.watch = {
|
||||
users: (users) => this.render("userList", { users }),
|
||||
loading: (loading) => {
|
||||
this.root.classList.toggle("loading", loading);
|
||||
},
|
||||
};
|
||||
|
||||
// Load users
|
||||
this.loadUsers = async () => {
|
||||
this.state.Set("loading", true);
|
||||
|
||||
const response = await api.Get<User[]>("/users");
|
||||
if (!api.IsError(response)) {
|
||||
this.state.Set("users", response.data);
|
||||
}
|
||||
|
||||
this.state.Set("loading", false);
|
||||
};
|
||||
|
||||
// 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();
|
||||
}
|
||||
```
|
||||
280
sui/docs/i18n.md
Normal file
280
sui/docs/i18n.md
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Internationalization (i18n)
|
||||
|
||||
SUI provides built-in support for internationalization with translation markers and locale files.
|
||||
|
||||
## Translation Markers
|
||||
|
||||
### Static Text
|
||||
|
||||
Use `s:trans` attribute for static text:
|
||||
|
||||
```html
|
||||
<span s:trans>Hello World</span>
|
||||
<button s:trans>Submit</button>
|
||||
<p s:trans>Welcome to our application</p>
|
||||
```
|
||||
|
||||
### In Expressions
|
||||
|
||||
Use `'::'` prefix in expressions:
|
||||
|
||||
```html
|
||||
<span>{{ '::Welcome' }}</span>
|
||||
<span>{{ '::Hello, ' + name }}</span>
|
||||
<p>{{ '::You have ' + count + ' messages' }}</p>
|
||||
```
|
||||
|
||||
### In Scripts
|
||||
|
||||
Use `__m()` function:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const message = __m("Welcome back");
|
||||
const greeting = __m("Hello, ") + userName;
|
||||
alert(__m("Are you sure?"));
|
||||
</script>
|
||||
```
|
||||
|
||||
## Locale Files
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
/templates/<template>/
|
||||
└── __locales/
|
||||
├── en-us/
|
||||
│ ├── home.yml
|
||||
│ └── users/list.yml
|
||||
└── zh-cn/
|
||||
├── home.yml
|
||||
└── users/list.yml
|
||||
```
|
||||
|
||||
### File Format
|
||||
|
||||
**`__locales/zh-cn/home.yml`**:
|
||||
|
||||
```yaml
|
||||
name: zh-cn
|
||||
direction: ltr
|
||||
timezone: +08:00
|
||||
formatter: scripts.locale
|
||||
|
||||
messages:
|
||||
Hello World: 你好世界
|
||||
Welcome: 欢迎
|
||||
"Hello, ": "你好,"
|
||||
Submit: 提交
|
||||
"You have %d messages": "你有 %d 条消息"
|
||||
|
||||
keys:
|
||||
page_title: 首页
|
||||
nav_home: 首页
|
||||
nav_about: 关于
|
||||
|
||||
script_messages:
|
||||
Welcome back: 欢迎回来
|
||||
"Are you sure?": "你确定吗?"
|
||||
```
|
||||
|
||||
### Sections
|
||||
|
||||
| Section | Description |
|
||||
| ----------------- | ----------------------------------- |
|
||||
| `name` | Locale identifier |
|
||||
| `direction` | Text direction (`ltr` or `rtl`) |
|
||||
| `timezone` | Timezone offset |
|
||||
| `formatter` | Custom formatter process |
|
||||
| `messages` | Translations for `s:trans` and `::` |
|
||||
| `keys` | Named translation keys |
|
||||
| `script_messages` | Translations for `__m()` |
|
||||
|
||||
## Using Translations
|
||||
|
||||
### HTML Templates
|
||||
|
||||
```html
|
||||
<!-- Static translation -->
|
||||
<h1 s:trans>Welcome to our site</h1>
|
||||
|
||||
<!-- Dynamic translation -->
|
||||
<p>{{ '::Hello, ' + user.name }}</p>
|
||||
|
||||
<!-- With variables -->
|
||||
<span>{{ '::You have ' + count + ' items' }}</span>
|
||||
```
|
||||
|
||||
### Named Keys
|
||||
|
||||
Named keys are used internally for translation lookup. The `keys` section in locale files provides named references for translations that can be used programmatically.
|
||||
|
||||
### Scripts
|
||||
|
||||
```typescript
|
||||
function Page(component: HTMLElement) {
|
||||
this.root = component;
|
||||
|
||||
this.showMessage = () => {
|
||||
const message = __m("Operation completed");
|
||||
alert(message);
|
||||
};
|
||||
|
||||
this.confirm = () => {
|
||||
return confirm(__m("Are you sure you want to delete?"));
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Locale Detection
|
||||
|
||||
SUI detects locale from:
|
||||
|
||||
1. Cookie (`locale` or `umi_locale`)
|
||||
2. Browser language
|
||||
3. Default (`en-us`)
|
||||
|
||||
### Access Current Locale
|
||||
|
||||
```html
|
||||
<html lang="{{ $locale }}">
|
||||
<body dir="{{ $direction }}">
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Custom Formatter
|
||||
|
||||
Define a custom formatter process:
|
||||
|
||||
```yaml
|
||||
# In locale file
|
||||
formatter: scripts.locale.format
|
||||
```
|
||||
|
||||
**`scripts/locale.js`**:
|
||||
|
||||
```javascript
|
||||
function format(text, args) {
|
||||
// Custom formatting logic
|
||||
return text.replace(/%d/g, () => args.shift());
|
||||
}
|
||||
```
|
||||
|
||||
## RTL Support
|
||||
|
||||
For right-to-left languages:
|
||||
|
||||
```yaml
|
||||
# __locales/ar/home.yml
|
||||
name: ar
|
||||
direction: rtl
|
||||
timezone: +03:00
|
||||
|
||||
messages:
|
||||
Hello: مرحبا
|
||||
```
|
||||
|
||||
```html
|
||||
<body dir="{{ $direction }}">
|
||||
<!-- Content automatically flows RTL -->
|
||||
</body>
|
||||
```
|
||||
|
||||
## Building Translations
|
||||
|
||||
### Generate Translation Files
|
||||
|
||||
```bash
|
||||
yao sui trans <sui> <template>
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
1. Scans all pages for translation markers
|
||||
2. Generates/updates locale files
|
||||
3. Builds the template
|
||||
|
||||
### Translation Workflow
|
||||
|
||||
1. Add `s:trans` or `::` markers to your HTML
|
||||
2. Run `yao sui trans` to extract strings
|
||||
3. Edit locale files to add translations
|
||||
4. Build with `yao sui build`
|
||||
|
||||
## Complete Example
|
||||
|
||||
**`/home/home.html`**:
|
||||
|
||||
```html
|
||||
<div class="home">
|
||||
<h1 s:trans>Welcome to our application</h1>
|
||||
|
||||
<p>{{ '::Hello, ' + user.name }}</p>
|
||||
|
||||
<div class="stats">
|
||||
<span>{{ '::You have ' + messageCount + ' messages' }}</span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<a href="/" s:trans>Home</a>
|
||||
<a href="/about" s:trans>About</a>
|
||||
<a href="/contact" s:trans>Contact</a>
|
||||
</nav>
|
||||
|
||||
<button s:on-click="showWelcome" s:trans>Show Welcome</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function home(component) {
|
||||
this.root = component;
|
||||
|
||||
this.showWelcome = () => {
|
||||
alert(__m("Welcome to our site!"));
|
||||
};
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**`__locales/zh-cn/home.yml`**:
|
||||
|
||||
```yaml
|
||||
name: zh-cn
|
||||
direction: ltr
|
||||
timezone: +08:00
|
||||
|
||||
messages:
|
||||
Welcome to our application: 欢迎使用我们的应用
|
||||
"Hello, ": "你好,"
|
||||
"You have ": "你有 "
|
||||
" messages": " 条消息"
|
||||
Home: 首页
|
||||
About: 关于
|
||||
Contact: 联系我们
|
||||
Show Welcome: 显示欢迎
|
||||
|
||||
script_messages:
|
||||
"Welcome to our site!": "欢迎来到我们的网站!"
|
||||
```
|
||||
|
||||
**`__locales/ja/home.yml`**:
|
||||
|
||||
```yaml
|
||||
name: ja
|
||||
direction: ltr
|
||||
timezone: +09:00
|
||||
|
||||
messages:
|
||||
Welcome to our application: アプリケーションへようこそ
|
||||
"Hello, ": "こんにちは、"
|
||||
"You have ": ""
|
||||
" messages": " 件のメッセージがあります"
|
||||
Home: ホーム
|
||||
About: について
|
||||
Contact: お問い合わせ
|
||||
Show Welcome: ようこそを表示
|
||||
|
||||
script_messages:
|
||||
"Welcome to our site!": "私たちのサイトへようこそ!"
|
||||
```
|
||||
292
sui/docs/template-syntax.md
Normal file
292
sui/docs/template-syntax.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Template Syntax
|
||||
|
||||
SUI uses a simple template syntax for data binding, conditional rendering, and list iteration.
|
||||
|
||||
## Data Interpolation
|
||||
|
||||
Use double curly braces `{{ }}` to output data:
|
||||
|
||||
```html
|
||||
<!-- Variable binding -->
|
||||
<span>{{ name }}</span>
|
||||
<span>{{ user.email }}</span>
|
||||
<span>{{ items[0].title }}</span>
|
||||
|
||||
<!-- Default values (null coalescing) -->
|
||||
<span>{{ title ?? 'Default Title' }}</span>
|
||||
<span>{{ user.name ?? 'Anonymous' }}</span>
|
||||
|
||||
<!-- Expressions -->
|
||||
<span>{{ price * quantity }}</span>
|
||||
<span>{{ firstName + ' ' + lastName }}</span>
|
||||
<span>{{ count > 0 ? 'Has items' : 'Empty' }}</span>
|
||||
```
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
### Basic If
|
||||
|
||||
```html
|
||||
<div s:if="{{ isActive }}">Active</div>
|
||||
<div s:if="{{ count > 0 }}">Has items</div>
|
||||
<div s:if="{{ user != null }}">Logged in</div>
|
||||
```
|
||||
|
||||
### If-Elif-Else
|
||||
|
||||
```html
|
||||
<div s:if="{{ status == 'active' }}">Active</div>
|
||||
<div s:elif="{{ status == 'pending' }}">Pending</div>
|
||||
<div s:elif="{{ status == 'suspended' }}">Suspended</div>
|
||||
<div s:else>Unknown</div>
|
||||
```
|
||||
|
||||
### Comparison Operators
|
||||
|
||||
| Operator | Description |
|
||||
| -------- | --------------------- |
|
||||
| `==` | Equal |
|
||||
| `!=` | Not equal |
|
||||
| `>` | Greater than |
|
||||
| `<` | Less than |
|
||||
| `>=` | Greater than or equal |
|
||||
| `<=` | Less than or equal |
|
||||
| `&&` | Logical AND |
|
||||
| `\|\|` | Logical OR |
|
||||
| `!` | Logical NOT |
|
||||
|
||||
### Examples
|
||||
|
||||
```html
|
||||
<!-- Multiple conditions -->
|
||||
<div s:if="{{ isAdmin && isActive }}">Admin Panel</div>
|
||||
<div s:if="{{ age >= 18 || hasPermission }}">Access Granted</div>
|
||||
|
||||
<!-- Negation -->
|
||||
<div s:if="{{ !isLoading }}">Content loaded</div>
|
||||
|
||||
<!-- Null checks -->
|
||||
<div s:if="{{ user != null && user.verified }}">Verified User</div>
|
||||
```
|
||||
|
||||
## List Rendering
|
||||
|
||||
### Basic Loop
|
||||
|
||||
```html
|
||||
<ul>
|
||||
<li s:for="{{ items }}" s:for-item="item">{{ item.name }}</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
### With Index
|
||||
|
||||
```html
|
||||
<ul>
|
||||
<li s:for="{{ items }}" s:for-item="item" s:for-index="index">
|
||||
{{ index + 1 }}. {{ item.name }}
|
||||
</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Nested Loops
|
||||
|
||||
```html
|
||||
<div s:for="{{ categories }}" s:for-item="category">
|
||||
<h3>{{ category.name }}</h3>
|
||||
<ul>
|
||||
<li s:for="{{ category.items }}" s:for-item="item">{{ item.title }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Loop with Conditional
|
||||
|
||||
```html
|
||||
<div s:for="{{ users }}" s:for-item="user" s:if="{{ user.active }}">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Object Iteration
|
||||
|
||||
```html
|
||||
<dl s:for="{{ settings }}" s:for-item="value" s:for-index="key">
|
||||
<dt>{{ key }}</dt>
|
||||
<dd>{{ value }}</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
## Variable Assignment
|
||||
|
||||
Use `<s:set>` to define variables:
|
||||
|
||||
```html
|
||||
<!-- Simple assignment -->
|
||||
<s:set name="total" value="{{ price * quantity }}" />
|
||||
<span>Total: {{ total }}</span>
|
||||
|
||||
<!-- Computed values -->
|
||||
<s:set name="fullName" value="{{ firstName + ' ' + lastName }}" />
|
||||
<s:set name="isExpensive" value="{{ price > 100 }}" />
|
||||
|
||||
<!-- From expressions -->
|
||||
<s:set name="discountedPrice" value="{{ price * (1 - discount / 100) }}" />
|
||||
```
|
||||
|
||||
## Attribute Binding
|
||||
|
||||
### Dynamic Attributes
|
||||
|
||||
```html
|
||||
<input value="{{ formData.email }}" />
|
||||
<a href="{{ '/user/' + userId }}">Profile</a>
|
||||
<img src="{{ imageUrl }}" alt="{{ imageAlt }}" />
|
||||
```
|
||||
|
||||
### Conditional Attributes
|
||||
|
||||
```html
|
||||
<!-- Attribute with condition -->
|
||||
<button s:attr-disabled="{{ !isValid }}">Submit</button>
|
||||
<input s:attr-readonly="{{ isLocked }}" />
|
||||
<div s:attr-hidden="{{ !showPanel }}">Panel</div>
|
||||
|
||||
<!-- Class binding -->
|
||||
<div class="base {{ isActive ? 'active' : '' }}">Content</div>
|
||||
```
|
||||
|
||||
### Spread Attributes
|
||||
|
||||
```html
|
||||
<!-- Spread object as attributes -->
|
||||
<div ...props></div>
|
||||
<input ...inputAttrs />
|
||||
```
|
||||
|
||||
## Raw HTML Output
|
||||
|
||||
By default, output is HTML-escaped. Use `s:raw` for raw HTML:
|
||||
|
||||
```html
|
||||
<!-- Escaped (safe) -->
|
||||
<div>{{ htmlContent }}</div>
|
||||
|
||||
<!-- Raw HTML (use with caution) -->
|
||||
<div s:raw="true">{{ htmlContent }}</div>
|
||||
```
|
||||
|
||||
## Expression Engine
|
||||
|
||||
SUI uses [Expr](https://expr-lang.org/) (v1.17) as the expression engine. Expr provides a powerful expression language with operators, functions, and more.
|
||||
|
||||
### SUI Custom Functions
|
||||
|
||||
| Function | Description | Example |
|
||||
| --------------- | ------------------------------ | --------------------------------- |
|
||||
| `P_(proc, ...)` | Call a Yao process | `{{ P_('models.user.Find', 1) }}` |
|
||||
| `True(value)` | Check if value is truthy | `{{ True(user) }}` |
|
||||
| `False(value)` | Check if value is falsy | `{{ False(error) }}` |
|
||||
| `Empty(value)` | Check if array/object is empty | `{{ Empty(items) }}` |
|
||||
|
||||
### Expr Built-in Functions
|
||||
|
||||
Expr provides many built-in functions. Here are commonly used ones:
|
||||
|
||||
| Function | Description | Example |
|
||||
| --------------------- | ------------------------------ | --------------------------------- |
|
||||
| `len(array)` | Get length of array/string/map | `{{ len(items) }}` |
|
||||
| `all(array, pred)` | Check if all elements match | `{{ all(users, .active) }}` |
|
||||
| `any(array, pred)` | Check if any element matches | `{{ any(items, .price > 100) }}` |
|
||||
| `one(array, pred)` | Check if exactly one matches | `{{ one(users, .admin) }}` |
|
||||
| `none(array, pred)` | Check if no elements match | `{{ none(items, .deleted) }}` |
|
||||
| `map(array, mapper)` | Transform array elements | `{{ map(users, .name) }}` |
|
||||
| `filter(array, pred)` | Filter array by predicate | `{{ filter(items, .active) }}` |
|
||||
| `find(array, pred)` | Find first matching element | `{{ find(users, .id == 1) }}` |
|
||||
| `count(array, pred)` | Count matching elements | `{{ count(items, .price > 50) }}` |
|
||||
| `sum(array)` | Sum of array elements | `{{ sum(prices) }}` |
|
||||
| `mean(array)` | Average of array elements | `{{ mean(scores) }}` |
|
||||
| `min(array)` | Minimum value | `{{ min(prices) }}` |
|
||||
| `max(array)` | Maximum value | `{{ max(scores) }}` |
|
||||
| `first(array)` | First element | `{{ first(items) }}` |
|
||||
| `last(array)` | Last element | `{{ last(items) }}` |
|
||||
| `take(array, n)` | Take first n elements | `{{ take(items, 5) }}` |
|
||||
| `keys(map)` | Get map keys | `{{ keys(settings) }}` |
|
||||
| `values(map)` | Get map values | `{{ values(settings) }}` |
|
||||
| `contains(a, b)` | Check if a contains b | `{{ contains(name, 'test') }}` |
|
||||
| `startsWith(s, pre)` | Check string prefix | `{{ startsWith(url, 'https') }}` |
|
||||
| `endsWith(s, suf)` | Check string suffix | `{{ endsWith(file, '.pdf') }}` |
|
||||
| `upper(s)` | Uppercase string | `{{ upper(name) }}` |
|
||||
| `lower(s)` | Lowercase string | `{{ lower(email) }}` |
|
||||
| `trim(s)` | Trim whitespace | `{{ trim(input) }}` |
|
||||
| `split(s, sep)` | Split string | `{{ split(tags, ',') }}` |
|
||||
| `join(array, sep)` | Join array to string | `{{ join(names, ', ') }}` |
|
||||
| `int(v)` | Convert to integer | `{{ int(value) }}` |
|
||||
| `float(v)` | Convert to float | `{{ float(value) }}` |
|
||||
| `string(v)` | Convert to string | `{{ string(count) }}` |
|
||||
| `now()` | Current time | `{{ now() }}` |
|
||||
| `date(s)` | Parse date string | `{{ date('2024-01-01') }}` |
|
||||
| `duration(s)` | Parse duration string | `{{ duration('1h30m') }}` |
|
||||
|
||||
For the complete list of built-in functions and operators, see the [Expr Language Definition](https://expr-lang.org/docs/language-definition).
|
||||
|
||||
### Examples
|
||||
|
||||
```html
|
||||
<!-- SUI custom functions -->
|
||||
<div s:if="{{ Empty(users) }}">No users found</div>
|
||||
<span>{{ P_('utils.formatDate', createdAt) }}</span>
|
||||
|
||||
<!-- Array operations -->
|
||||
<span>Total: {{ len(items) }} items</span>
|
||||
<span>Active: {{ count(users, .active) }}</span>
|
||||
<span>Sum: {{ sum(map(items, .price)) }}</span>
|
||||
|
||||
<!-- String operations -->
|
||||
<span>{{ upper(first(split(name, ' '))) }}</span>
|
||||
|
||||
<!-- Filtering -->
|
||||
<div s:for="{{ filter(items, .price > 100) }}" s:for-item="item">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
```
|
||||
|
||||
## String Operations
|
||||
|
||||
```html
|
||||
<!-- Concatenation -->
|
||||
<span>{{ 'Hello, ' + name + '!' }}</span>
|
||||
|
||||
<!-- Template literals (in expressions) -->
|
||||
<a href="{{ '/users/' + userId + '/edit' }}">Edit</a>
|
||||
```
|
||||
|
||||
## Arithmetic Operations
|
||||
|
||||
```html
|
||||
<!-- Basic math -->
|
||||
<span>{{ price * quantity }}</span>
|
||||
<span>{{ total / count }}</span>
|
||||
<span>{{ value + 10 }}</span>
|
||||
<span>{{ index - 1 }}</span>
|
||||
|
||||
<!-- Percentage -->
|
||||
<span>{{ (completed / total) * 100 }}%</span>
|
||||
```
|
||||
|
||||
## Comments
|
||||
|
||||
HTML comments are preserved in output:
|
||||
|
||||
```html
|
||||
<!-- This comment appears in output -->
|
||||
```
|
||||
|
||||
## Whitespace Control
|
||||
|
||||
SUI preserves whitespace by default. For minified output, use build options:
|
||||
|
||||
```bash
|
||||
yao sui build <sui> <template> # Minified (production)
|
||||
yao sui build <sui> <template> -D # Preserved (development, --debug)
|
||||
```
|
||||
807
sui/libsui/openapi.ts
Normal file
807
sui/libsui/openapi.ts
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
/**
|
||||
* SUI OpenAPI Client
|
||||
*
|
||||
* A lightweight HTTP client for Yao OpenAPI endpoints.
|
||||
* Adapted from CUI OpenAPI for use in SUI (browser-only, no build tools).
|
||||
*
|
||||
* Features:
|
||||
* - RESTful API methods (GET, POST, PUT, DELETE, Upload)
|
||||
* - Secure cookie authentication
|
||||
* - CSRF protection
|
||||
* - File upload with progress tracking
|
||||
* - Cross-origin support
|
||||
*
|
||||
* Usage:
|
||||
* const api = new OpenAPI({ baseURL: '/api' })
|
||||
* const response = await api.Get('/users')
|
||||
* if (api.IsError(response)) {
|
||||
* console.error(response.error)
|
||||
* } else {
|
||||
* console.log(response.data)
|
||||
* }
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface OpenAPIConfig {
|
||||
baseURL: string;
|
||||
timeout?: number;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: ErrorResponse;
|
||||
status: number;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
interface FileUploadOptions {
|
||||
uploaderID?: string;
|
||||
originalFilename?: string;
|
||||
groups?: string[];
|
||||
gzip?: boolean;
|
||||
compressImage?: boolean;
|
||||
compressSize?: number;
|
||||
path?: string;
|
||||
chunked?: boolean;
|
||||
chunkSize?: number;
|
||||
public?: boolean;
|
||||
share?: "private" | "team";
|
||||
}
|
||||
|
||||
interface FileListOptions {
|
||||
uploaderID?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
contentType?: string;
|
||||
name?: string;
|
||||
orderBy?: string;
|
||||
select?: string[];
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
file_id: string;
|
||||
user_path: string;
|
||||
path: string;
|
||||
bytes: number;
|
||||
created_at: number;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
status: string;
|
||||
url?: string;
|
||||
metadata?: Record<string, any>;
|
||||
uploader?: string;
|
||||
groups?: string[];
|
||||
public?: boolean;
|
||||
share?: "private" | "team";
|
||||
}
|
||||
|
||||
interface FileListResponse {
|
||||
data: FileInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface FileExistsResponse {
|
||||
exists: boolean;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
interface FileDeleteResponse {
|
||||
message: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
type UploadProgressCallback = (progress: {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}) => void;
|
||||
|
||||
// ============================================================================
|
||||
// OpenAPI Client
|
||||
// ============================================================================
|
||||
|
||||
class OpenAPI {
|
||||
private config: OpenAPIConfig;
|
||||
|
||||
constructor(config: OpenAPIConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
|
||||
const apiResponse: ApiResponse<T> = {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const jsonData = await response.json();
|
||||
|
||||
if (!response.ok && jsonData.error) {
|
||||
apiResponse.error = jsonData as ErrorResponse;
|
||||
} else if (response.ok) {
|
||||
apiResponse.data = jsonData as T;
|
||||
} else {
|
||||
apiResponse.error = {
|
||||
error: "http_error",
|
||||
error_description: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
} else if (response.ok) {
|
||||
const textData = await response.text();
|
||||
apiResponse.data = textData as unknown as T;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
apiResponse.error = {
|
||||
error: "http_error",
|
||||
error_description:
|
||||
errorText || `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
apiResponse.error = {
|
||||
error: "parse_error",
|
||||
error_description: `Failed to parse response: ${
|
||||
parseError instanceof Error ? parseError.message : "Unknown error"
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
return apiResponse;
|
||||
}
|
||||
|
||||
async Get<T = any>(
|
||||
path: string,
|
||||
query: Record<string, string> = {},
|
||||
headersInit: Record<string, string> = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers = { "Content-Type": "application/json", ...headersInit };
|
||||
this.addCSRFToken(headers);
|
||||
|
||||
const queryString = new URLSearchParams(query).toString();
|
||||
let url = `${this.config.baseURL}${path}`;
|
||||
if (queryString) {
|
||||
url += path.includes("?") ? `&${queryString}` : `?${queryString}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
async Post<T = any>(
|
||||
path: string,
|
||||
payload: any,
|
||||
headersInit: Record<string, string> = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers = { "Content-Type": "application/json", ...headersInit };
|
||||
this.addCSRFToken(headers);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}${path}`, {
|
||||
method: "POST",
|
||||
body: typeof payload === "object" ? JSON.stringify(payload) : payload,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
async Put<T = any>(
|
||||
path: string,
|
||||
payload: any,
|
||||
headersInit: Record<string, string> = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers = { "Content-Type": "application/json", ...headersInit };
|
||||
this.addCSRFToken(headers);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}${path}`, {
|
||||
method: "PUT",
|
||||
body: typeof payload === "object" ? JSON.stringify(payload) : payload,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
async Delete<T = any>(
|
||||
path: string,
|
||||
headersInit: Record<string, string> = {},
|
||||
payload?: any
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers = { "Content-Type": "application/json", ...headersInit };
|
||||
this.addCSRFToken(headers);
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
credentials: "include",
|
||||
};
|
||||
|
||||
if (payload !== undefined) {
|
||||
requestOptions.body = JSON.stringify(payload);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.config.baseURL}${path}`,
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
async Upload<T = any>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
headersInit: Record<string, string> = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers = { ...headersInit };
|
||||
this.addCSRFToken(headers);
|
||||
// Don't set Content-Type for FormData - browser sets it with boundary
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}${path}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
IsError<T>(
|
||||
response: ApiResponse<T>
|
||||
): response is ApiResponse<T> & { error: ErrorResponse } {
|
||||
return response.error !== undefined;
|
||||
}
|
||||
|
||||
GetData<T>(response: ApiResponse<T>): T | null {
|
||||
return response.data || null;
|
||||
}
|
||||
|
||||
SetCSRFToken(token: string): void {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem("csrf_token", token);
|
||||
}
|
||||
}
|
||||
|
||||
ClearTokens(): void {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem("csrf_token");
|
||||
localStorage.removeItem("xsrf_token");
|
||||
}
|
||||
}
|
||||
|
||||
IsCrossOrigin(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = new URL(this.config.baseURL, window.location.origin);
|
||||
return apiUrl.origin !== window.location.origin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
return this.config.baseURL;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Methods
|
||||
// ============================================================================
|
||||
|
||||
private addCSRFToken(headers: Record<string, string>): void {
|
||||
// Try cookies
|
||||
const cookieToken =
|
||||
this.getSecureCookie("__Host-csrf_token") ||
|
||||
this.getSecureCookie("__Secure-csrf_token") ||
|
||||
this.getSecureCookie("__Host-xsrf_token") ||
|
||||
this.getSecureCookie("__Secure-xsrf_token");
|
||||
|
||||
if (cookieToken) {
|
||||
headers["X-CSRF-Token"] = cookieToken;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try localStorage
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const storedToken =
|
||||
localStorage.getItem("csrf_token") ||
|
||||
localStorage.getItem("xsrf_token");
|
||||
if (storedToken) {
|
||||
headers["X-CSRF-Token"] = storedToken;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try meta tag
|
||||
if (typeof document !== "undefined") {
|
||||
const metaToken =
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content") ||
|
||||
document
|
||||
.querySelector('meta[name="xsrf-token"]')
|
||||
?.getAttribute("content");
|
||||
if (metaToken) {
|
||||
headers["X-CSRF-Token"] = metaToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSecureCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
|
||||
if (parts.length === 2) {
|
||||
const cookieValue = parts.pop()?.split(";").shift();
|
||||
return cookieValue ? decodeURIComponent(cookieValue) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File API
|
||||
// ============================================================================
|
||||
|
||||
class FileAPI {
|
||||
private api: OpenAPI;
|
||||
private defaultUploader: string;
|
||||
|
||||
constructor(api: OpenAPI, defaultUploader?: string) {
|
||||
this.api = api;
|
||||
this.defaultUploader = defaultUploader || "__yao.attachment";
|
||||
}
|
||||
|
||||
async Upload(
|
||||
file: File,
|
||||
options: FileUploadOptions = {},
|
||||
onProgress?: UploadProgressCallback
|
||||
): Promise<ApiResponse<FileInfo>> {
|
||||
const uploaderID = options.uploaderID || this.defaultUploader;
|
||||
|
||||
const shouldUseChunked =
|
||||
options.chunked || file.size > (options.chunkSize || 2 * 1024 * 1024);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
return this.uploadChunked(uploaderID, file, options, onProgress);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
if (options.originalFilename || file.name) {
|
||||
formData.append(
|
||||
"original_filename",
|
||||
options.originalFilename || file.name
|
||||
);
|
||||
}
|
||||
if (options.path) formData.append("path", options.path);
|
||||
if (options.groups?.length)
|
||||
formData.append("groups", options.groups.join(","));
|
||||
if (options.gzip) formData.append("gzip", "true");
|
||||
if (options.compressImage) formData.append("compress_image", "true");
|
||||
if (options.compressSize)
|
||||
formData.append("compress_size", options.compressSize.toString());
|
||||
if (options.public !== undefined)
|
||||
formData.append("public", options.public ? "true" : "false");
|
||||
if (options.share) formData.append("share", options.share);
|
||||
|
||||
if (onProgress) {
|
||||
return this.uploadWithProgress(uploaderID, formData, onProgress);
|
||||
}
|
||||
|
||||
return this.api.Upload<FileInfo>(`/file/${uploaderID}`, formData);
|
||||
}
|
||||
|
||||
async UploadMultiple(
|
||||
files: File[],
|
||||
options: FileUploadOptions = {},
|
||||
onProgress?: (
|
||||
fileIndex: number,
|
||||
progress: { loaded: number; total: number; percentage: number }
|
||||
) => void
|
||||
): Promise<ApiResponse<FileInfo>[]> {
|
||||
const uploadPromises = files.map((file, index) => {
|
||||
const progressCallback = onProgress
|
||||
? (progress: { loaded: number; total: number; percentage: number }) =>
|
||||
onProgress(index, progress)
|
||||
: undefined;
|
||||
return this.Upload(file, options, progressCallback);
|
||||
});
|
||||
|
||||
return Promise.all(uploadPromises);
|
||||
}
|
||||
|
||||
async List(
|
||||
options: FileListOptions = {}
|
||||
): Promise<ApiResponse<FileListResponse>> {
|
||||
const uploaderID = options.uploaderID || this.defaultUploader;
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (options.page) params.page = options.page.toString();
|
||||
if (options.pageSize) params.page_size = options.pageSize.toString();
|
||||
if (options.status) params.status = options.status;
|
||||
if (options.contentType) params.content_type = options.contentType;
|
||||
if (options.name) params.name = options.name;
|
||||
if (options.orderBy) params.order_by = options.orderBy;
|
||||
if (options.select?.length) params.select = options.select.join(",");
|
||||
|
||||
return this.api.Get<FileListResponse>(`/file/${uploaderID}`, params);
|
||||
}
|
||||
|
||||
async Retrieve(
|
||||
fileID: string,
|
||||
uploaderID?: string
|
||||
): Promise<ApiResponse<FileInfo>> {
|
||||
if (!fileID) throw new Error("File ID is required");
|
||||
const actualUploaderID = uploaderID || this.defaultUploader;
|
||||
return this.api.Get<FileInfo>(
|
||||
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}`
|
||||
);
|
||||
}
|
||||
|
||||
async Delete(
|
||||
fileID: string,
|
||||
uploaderID?: string
|
||||
): Promise<ApiResponse<FileDeleteResponse>> {
|
||||
if (!fileID) throw new Error("File ID is required");
|
||||
const actualUploaderID = uploaderID || this.defaultUploader;
|
||||
return this.api.Delete<FileDeleteResponse>(
|
||||
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}`
|
||||
);
|
||||
}
|
||||
|
||||
async Download(
|
||||
fileID: string,
|
||||
uploaderID?: string
|
||||
): Promise<ApiResponse<Blob>> {
|
||||
if (!fileID) throw new Error("File ID is required");
|
||||
const actualUploaderID = uploaderID || this.defaultUploader;
|
||||
const url = `${this.api.getBaseURL()}/file/${actualUploaderID}/${encodeURIComponent(
|
||||
fileID
|
||||
)}/content`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const blob = await response.blob();
|
||||
const apiResponse: ApiResponse<Blob> = {
|
||||
data: blob,
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
apiResponse.error = {
|
||||
error: "download_failed",
|
||||
error_description: `Download failed with status ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
return apiResponse;
|
||||
}
|
||||
|
||||
async Exists(
|
||||
fileID: string,
|
||||
uploaderID?: string
|
||||
): Promise<ApiResponse<FileExistsResponse>> {
|
||||
if (!fileID) throw new Error("File ID is required");
|
||||
const actualUploaderID = uploaderID || this.defaultUploader;
|
||||
return this.api.Get<FileExistsResponse>(
|
||||
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}/exists`
|
||||
);
|
||||
}
|
||||
|
||||
// Static utility methods
|
||||
static FormatSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
static GetExtension(filename: string): string {
|
||||
return filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
|
||||
}
|
||||
|
||||
static IsImage(contentType: string): boolean {
|
||||
return contentType.startsWith("image/");
|
||||
}
|
||||
|
||||
static IsDocument(contentType: string): boolean {
|
||||
const documentTypes = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
];
|
||||
return documentTypes.includes(contentType);
|
||||
}
|
||||
|
||||
// Private methods
|
||||
private async uploadChunked(
|
||||
uploaderID: string,
|
||||
file: File,
|
||||
options: FileUploadOptions = {},
|
||||
onProgress?: UploadProgressCallback
|
||||
): Promise<ApiResponse<FileInfo>> {
|
||||
const chunkSize = options.chunkSize || 2 * 1024 * 1024;
|
||||
const totalSize = file.size;
|
||||
const totalChunks = Math.ceil(totalSize / chunkSize);
|
||||
const fileUID = this.generateUID();
|
||||
|
||||
let lastResponse: ApiResponse<FileInfo> | null = null;
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * chunkSize;
|
||||
const end = Math.min(start + chunkSize - 1, totalSize - 1);
|
||||
const chunkBlob = file.slice(start, end + 1);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", chunkBlob);
|
||||
|
||||
if (chunkIndex === 0) {
|
||||
if (options.originalFilename || file.name) {
|
||||
formData.append(
|
||||
"original_filename",
|
||||
options.originalFilename || file.name
|
||||
);
|
||||
}
|
||||
if (options.path) formData.append("path", options.path);
|
||||
if (options.groups?.length)
|
||||
formData.append("groups", options.groups.join(","));
|
||||
if (options.gzip) formData.append("gzip", "true");
|
||||
if (options.compressImage) formData.append("compress_image", "true");
|
||||
if (options.compressSize)
|
||||
formData.append("compress_size", options.compressSize.toString());
|
||||
if (options.public !== undefined)
|
||||
formData.append("public", options.public ? "true" : "false");
|
||||
if (options.share) formData.append("share", options.share);
|
||||
}
|
||||
|
||||
const chunkResponse = await this.uploadChunk(
|
||||
uploaderID,
|
||||
formData,
|
||||
start,
|
||||
end,
|
||||
totalSize,
|
||||
fileUID
|
||||
);
|
||||
|
||||
if (this.api.IsError(chunkResponse)) {
|
||||
return chunkResponse;
|
||||
}
|
||||
|
||||
lastResponse = chunkResponse;
|
||||
|
||||
if (onProgress) {
|
||||
const loaded = end + 1;
|
||||
const percentage = Math.round((loaded / totalSize) * 100);
|
||||
onProgress({ loaded, total: totalSize, percentage });
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse!;
|
||||
}
|
||||
|
||||
private uploadChunk(
|
||||
uploaderID: string,
|
||||
formData: FormData,
|
||||
start: number,
|
||||
end: number,
|
||||
total: number,
|
||||
uid: string
|
||||
): Promise<ApiResponse<FileInfo>> {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
const apiResponse: ApiResponse<FileInfo> = {
|
||||
data: response.data || response,
|
||||
status: xhr.status,
|
||||
headers: new Headers(),
|
||||
};
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(apiResponse);
|
||||
} else {
|
||||
apiResponse.error = response.error || {
|
||||
error: "chunk_upload_failed",
|
||||
error_description: `Chunk upload failed with status ${xhr.status}`,
|
||||
};
|
||||
resolve(apiResponse);
|
||||
}
|
||||
} catch {
|
||||
resolve({
|
||||
status: xhr.status,
|
||||
headers: new Headers(),
|
||||
error: {
|
||||
error: "parse_error",
|
||||
error_description: "Failed to parse chunk response",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => {
|
||||
resolve({
|
||||
status: xhr.status || 0,
|
||||
headers: new Headers(),
|
||||
error: {
|
||||
error: "network_error",
|
||||
error_description: "Network error during chunk upload",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
xhr.open("POST", `${this.api.getBaseURL()}/file/${uploaderID}`);
|
||||
xhr.setRequestHeader("Content-Sync", "true");
|
||||
xhr.setRequestHeader("Content-Uid", uid);
|
||||
xhr.setRequestHeader("Content-Range", `bytes ${start}-${end}/${total}`);
|
||||
|
||||
const csrfToken = this.getCSRFToken();
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
|
||||
xhr.withCredentials = true;
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
private uploadWithProgress(
|
||||
uploaderID: string,
|
||||
formData: FormData,
|
||||
onProgress: UploadProgressCallback
|
||||
): Promise<ApiResponse<FileInfo>> {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percentage = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress({ loaded: event.loaded, total: event.total, percentage });
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
const apiResponse: ApiResponse<FileInfo> = {
|
||||
data: response.data || response,
|
||||
status: xhr.status,
|
||||
headers: new Headers(),
|
||||
};
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(apiResponse);
|
||||
} else {
|
||||
apiResponse.error = response.error || {
|
||||
error: "upload_failed",
|
||||
error_description: `Upload failed with status ${xhr.status}`,
|
||||
};
|
||||
resolve(apiResponse);
|
||||
}
|
||||
} catch {
|
||||
resolve({
|
||||
status: xhr.status,
|
||||
headers: new Headers(),
|
||||
error: {
|
||||
error: "parse_error",
|
||||
error_description: "Failed to parse response",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => {
|
||||
resolve({
|
||||
status: xhr.status || 0,
|
||||
headers: new Headers(),
|
||||
error: {
|
||||
error: "network_error",
|
||||
error_description: "Network error during upload",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
xhr.open("POST", `${this.api.getBaseURL()}/file/${uploaderID}`);
|
||||
|
||||
const csrfToken = this.getCSRFToken();
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
|
||||
xhr.withCredentials = true;
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
private getCSRFToken(): string | null {
|
||||
if (typeof document !== "undefined") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split("=");
|
||||
if (
|
||||
name === "__Host-csrf_token" ||
|
||||
name === "__Secure-csrf_token" ||
|
||||
name === "__Host-xsrf_token" ||
|
||||
name === "__Secure-xsrf_token"
|
||||
) {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
return (
|
||||
localStorage.getItem("csrf_token") || localStorage.getItem("xsrf_token")
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private generateUID(): string {
|
||||
// Simple unique ID generator (no external dependencies)
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Registration for SUI
|
||||
// ============================================================================
|
||||
|
||||
// Make available globally for SUI pages (no export, direct global assignment)
|
||||
(window as any).OpenAPI = OpenAPI;
|
||||
(window as any).FileAPI = FileAPI;
|
||||
228
sui/storages/agent/agent.go
Normal file
228
sui/storages/agent/agent.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/fs"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/sui/core"
|
||||
)
|
||||
|
||||
// New create a new agent sui storage
|
||||
func New(dsl *core.DSL) (*Agent, error) {
|
||||
// Use "app" filesystem which is rooted at application source directory
|
||||
appFS, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set default public settings for agent
|
||||
if dsl.Public == nil {
|
||||
dsl.Public = &core.Public{}
|
||||
}
|
||||
|
||||
if dsl.Public.Root == "" {
|
||||
dsl.Public.Root = "/agents"
|
||||
}
|
||||
|
||||
if dsl.Public.Host == "" {
|
||||
dsl.Public.Host = "/"
|
||||
}
|
||||
|
||||
if dsl.Public.Index == "" {
|
||||
dsl.Public.Index = "/index"
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
root: "/agent/template",
|
||||
assistantsRoot: "/assistants",
|
||||
fs: appFS,
|
||||
DSL: dsl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTemplates get the templates (returns single agent template)
|
||||
func (agent *Agent) GetTemplates() ([]core.ITemplate, error) {
|
||||
tmpl, err := agent.GetTemplate("agent")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []core.ITemplate{tmpl}, nil
|
||||
}
|
||||
|
||||
// GetTemplate get the template
|
||||
func (agent *Agent) GetTemplate(id string) (core.ITemplate, error) {
|
||||
if id != "agent" {
|
||||
return nil, fmt.Errorf("Agent storage only supports 'agent' template, got: %s", id)
|
||||
}
|
||||
|
||||
// Check if /agent directory exists
|
||||
if !agent.fs.IsDir(agent.root) {
|
||||
return nil, fmt.Errorf("Agent template directory not found: %s", agent.root)
|
||||
}
|
||||
|
||||
// Create agent template
|
||||
tmpl := &Template{
|
||||
Root: agent.root,
|
||||
agent: agent,
|
||||
Template: &core.Template{
|
||||
ID: "agent",
|
||||
Name: "Agent",
|
||||
Version: 1,
|
||||
Screenshots: []string{},
|
||||
Themes: []core.SelectOption{},
|
||||
},
|
||||
}
|
||||
|
||||
// Load template.json if exists
|
||||
configFile := filepath.Join(agent.root, "template.json")
|
||||
if agent.fs.IsFile(configFile) {
|
||||
configBytes, err := agent.fs.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = jsoniter.Unmarshal(configBytes, tmpl.Template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Load __document.html
|
||||
documentFile := filepath.Join(agent.root, "__document.html")
|
||||
if agent.fs.IsFile(documentFile) {
|
||||
documentBytes, err := agent.fs.ReadFile(documentFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl.Document = documentBytes
|
||||
}
|
||||
|
||||
// Load __data.json
|
||||
dataFile := filepath.Join(agent.root, "__data.json")
|
||||
if agent.fs.IsFile(dataFile) {
|
||||
dataBytes, err := agent.fs.ReadFile(dataFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl.GlobalData = dataBytes
|
||||
}
|
||||
|
||||
// Load build script
|
||||
err := tmpl.loadBuildScript()
|
||||
if err != nil {
|
||||
log.Warn("[Agent] Failed to load build script: %v", err)
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// UploadTemplate upload the template (not supported for agent)
|
||||
func (agent *Agent) UploadTemplate(src string, dst string) (core.ITemplate, error) {
|
||||
return nil, fmt.Errorf("UploadTemplate is not supported for agent storage")
|
||||
}
|
||||
|
||||
// PublicRootMatcher get the public root matcher
|
||||
func (agent *Agent) PublicRootMatcher() *core.Matcher {
|
||||
return &core.Matcher{Exact: agent.DSL.Public.Root}
|
||||
}
|
||||
|
||||
// Setting get the setting
|
||||
func (agent *Agent) Setting() (*core.Setting, error) {
|
||||
return &core.Setting{
|
||||
ID: agent.DSL.ID,
|
||||
Guard: agent.DSL.Guard,
|
||||
Option: map[string]interface{}{
|
||||
"disableCodeEditor": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicRoot get the public root
|
||||
func (agent *Agent) PublicRoot(data map[string]interface{}) (string, error) {
|
||||
return agent.DSL.Public.Root, nil
|
||||
}
|
||||
|
||||
// WithSid set the session id
|
||||
func (agent *Agent) WithSid(sid string) {
|
||||
agent.DSL.Sid = sid
|
||||
}
|
||||
|
||||
// getAssistants get all assistant directories that have pages
|
||||
func (agent *Agent) getAssistants() ([]string, error) {
|
||||
if !agent.fs.IsDir(agent.assistantsRoot) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
dirs, err := agent.fs.ReadDir(agent.assistantsRoot, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assistants := []string{}
|
||||
for _, dir := range dirs {
|
||||
if !agent.fs.IsDir(dir) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this assistant has a pages directory
|
||||
pagesDir := filepath.Join(dir, "pages")
|
||||
if agent.fs.IsDir(pagesDir) {
|
||||
name := filepath.Base(dir)
|
||||
assistants = append(assistants, name)
|
||||
}
|
||||
}
|
||||
|
||||
return assistants, nil
|
||||
}
|
||||
|
||||
// getAssistantPagesRoot get the pages root for an assistant
|
||||
func (agent *Agent) getAssistantPagesRoot(assistantID string) string {
|
||||
return filepath.Join(agent.assistantsRoot, assistantID, "pages")
|
||||
}
|
||||
|
||||
// Exists check if the agent storage is available
|
||||
func Exists() bool {
|
||||
appFS, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return appFS.IsDir("/agent/template")
|
||||
}
|
||||
|
||||
// HasAssistantPages check if any assistant has pages
|
||||
func HasAssistantPages() bool {
|
||||
appFS, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !appFS.IsDir("/assistants") {
|
||||
return false
|
||||
}
|
||||
|
||||
dirs, err := appFS.ReadDir("/assistants", false)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if !appFS.IsDir(dir) {
|
||||
continue
|
||||
}
|
||||
pagesDir := filepath.Join(dir, "pages")
|
||||
if appFS.IsDir(pagesDir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// log helper
|
||||
func init() {
|
||||
_ = log.Debug
|
||||
_ = strings.TrimPrefix
|
||||
}
|
||||
467
sui/storages/agent/page.go
Normal file
467
sui/storages/agent/page.go
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/application"
|
||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/sui/core"
|
||||
)
|
||||
|
||||
// Page wraps core.Page with agent-specific functionality
|
||||
type Page struct {
|
||||
*core.Page
|
||||
tmpl *Template
|
||||
assistantID string
|
||||
pagesRoot string
|
||||
}
|
||||
|
||||
// Load load the page content
|
||||
func (page *Page) Load() error {
|
||||
p := page.Page
|
||||
fs := page.tmpl.agent.fs
|
||||
|
||||
// Set document from template
|
||||
p.Document = page.tmpl.Document
|
||||
|
||||
// Read HTML
|
||||
htmlFile := filepath.Join(p.Path, p.Codes.HTML.File)
|
||||
if fs.IsFile(htmlFile) {
|
||||
content, err := fs.ReadFile(htmlFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Codes.HTML.Code = string(content)
|
||||
}
|
||||
|
||||
// Read CSS
|
||||
cssFile := filepath.Join(p.Path, p.Codes.CSS.File)
|
||||
if fs.IsFile(cssFile) {
|
||||
content, err := fs.ReadFile(cssFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Codes.CSS.Code = string(content)
|
||||
}
|
||||
|
||||
// Read JS
|
||||
jsFile := filepath.Join(p.Path, p.Codes.JS.File)
|
||||
if fs.IsFile(jsFile) {
|
||||
content, err := fs.ReadFile(jsFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Codes.JS.Code = string(content)
|
||||
}
|
||||
|
||||
// Read TS
|
||||
tsFile := filepath.Join(p.Path, p.Codes.TS.File)
|
||||
if fs.IsFile(tsFile) {
|
||||
content, err := fs.ReadFile(tsFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Codes.TS.Code = string(content)
|
||||
}
|
||||
|
||||
// Read DATA (JSON)
|
||||
dataFile := filepath.Join(p.Path, p.Codes.DATA.File)
|
||||
if fs.IsFile(dataFile) {
|
||||
content, err := fs.ReadFile(dataFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Codes.DATA.Code = string(content)
|
||||
}
|
||||
|
||||
// Read Config
|
||||
confFile := filepath.Join(p.Path, p.Codes.CONF.File)
|
||||
if fs.IsFile(confFile) {
|
||||
content, err := fs.ReadFile(confFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Codes.CONF.Code = string(content)
|
||||
|
||||
// Parse config
|
||||
var config core.PageConfig
|
||||
if err := jsoniter.Unmarshal(content, &config); err == nil {
|
||||
p.Config = &config
|
||||
}
|
||||
}
|
||||
|
||||
// Load backend script
|
||||
err := page.loadScript()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadScript load the backend script
|
||||
func (page *Page) loadScript() error {
|
||||
p := page.Page
|
||||
fs := page.tmpl.agent.fs
|
||||
|
||||
// Try .backend.ts first, then .backend.js
|
||||
tsFile := filepath.Join(p.Path, fmt.Sprintf("%s.backend.ts", p.Name))
|
||||
jsFile := filepath.Join(p.Path, fmt.Sprintf("%s.backend.js", p.Name))
|
||||
|
||||
var scriptFile string
|
||||
if fs.IsFile(tsFile) {
|
||||
scriptFile = tsFile
|
||||
} else if fs.IsFile(jsFile) {
|
||||
scriptFile = jsFile
|
||||
}
|
||||
|
||||
if scriptFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := fs.ReadFile(scriptFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
script, err := v8.MakeScript(content, scriptFile, 5*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.Script = &core.Script{Script: script}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get get the page info
|
||||
func (page *Page) Get() *core.Page {
|
||||
return page.Page
|
||||
}
|
||||
|
||||
// GetConfig get the page config
|
||||
func (page *Page) GetConfig() *core.PageConfig {
|
||||
p := page.Page
|
||||
if p.Config != nil {
|
||||
return p.Config
|
||||
}
|
||||
|
||||
// Try to load config if not loaded
|
||||
fs := page.tmpl.agent.fs
|
||||
confFile := filepath.Join(p.Path, p.Codes.CONF.File)
|
||||
if fs.IsFile(confFile) {
|
||||
content, err := fs.ReadFile(confFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var config core.PageConfig
|
||||
if err := jsoniter.Unmarshal(content, &config); err == nil {
|
||||
p.Config = &config
|
||||
return p.Config
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveTemp save the page temporarily (not supported for agent pages)
|
||||
func (page *Page) SaveTemp(request *core.RequestSource) error {
|
||||
return fmt.Errorf("SaveTemp is not supported for agent pages")
|
||||
}
|
||||
|
||||
// Save save the page (not supported for agent pages)
|
||||
func (page *Page) Save(request *core.RequestSource) error {
|
||||
return fmt.Errorf("Save is not supported for agent pages")
|
||||
}
|
||||
|
||||
// SaveAs save the page as (not supported for agent pages)
|
||||
func (page *Page) SaveAs(route string, setting *core.PageSetting) (core.IPage, error) {
|
||||
return nil, fmt.Errorf("SaveAs is not supported for agent pages")
|
||||
}
|
||||
|
||||
// Remove remove the page (not supported for agent pages)
|
||||
func (page *Page) Remove() error {
|
||||
return fmt.Errorf("Remove is not supported for agent pages")
|
||||
}
|
||||
|
||||
// SUI get the SUI interface
|
||||
func (page *Page) SUI() (core.SUI, error) {
|
||||
return page.tmpl.agent, nil
|
||||
}
|
||||
|
||||
// Sid get the session id
|
||||
func (page *Page) Sid() (string, error) {
|
||||
return page.tmpl.agent.DSL.Sid, nil
|
||||
}
|
||||
|
||||
// Template get the template
|
||||
func (page *Page) Template() core.ITemplate {
|
||||
return page.tmpl
|
||||
}
|
||||
|
||||
// AssetScript get the script
|
||||
func (page *Page) AssetScript() (*core.Asset, error) {
|
||||
fs := page.tmpl.agent.fs
|
||||
|
||||
// Try .ts first, then .js
|
||||
tsFile := filepath.Join(page.Path, page.Codes.TS.File)
|
||||
if fs.IsFile(tsFile) {
|
||||
tsCode, err := fs.ReadFile(tsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsCode, _, err := page.CompileTS(tsCode, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &core.Asset{
|
||||
Type: "text/javascript; charset=utf-8",
|
||||
Content: []byte(jsCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
jsFile := filepath.Join(page.Path, page.Codes.JS.File)
|
||||
if fs.IsFile(jsFile) {
|
||||
jsCode, err := fs.ReadFile(jsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsCode, _, err = page.CompileJS(jsCode, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &core.Asset{
|
||||
Type: "text/javascript; charset=utf-8",
|
||||
Content: jsCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s script not found", page.Route)
|
||||
}
|
||||
|
||||
// AssetStyle get the style
|
||||
func (page *Page) AssetStyle() (*core.Asset, error) {
|
||||
fs := page.tmpl.agent.fs
|
||||
|
||||
cssFile := filepath.Join(page.Path, page.Codes.CSS.File)
|
||||
if fs.IsFile(cssFile) {
|
||||
cssCode, err := fs.ReadFile(cssFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cssCode, err = page.CompileCSS(cssCode, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &core.Asset{
|
||||
Type: "text/css; charset=utf-8",
|
||||
Content: cssCode,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s style not found", page.Route)
|
||||
}
|
||||
|
||||
// Build build the page
|
||||
func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOption) ([]string, error) {
|
||||
ctx := core.NewBuildContext(globalCtx)
|
||||
root := option.PublicRoot
|
||||
if root == "" {
|
||||
root = page.tmpl.agent.DSL.Public.Root
|
||||
}
|
||||
|
||||
if option.AssetRoot == "" {
|
||||
option.AssetRoot = filepath.Join(root, "assets")
|
||||
}
|
||||
page.Root = root
|
||||
|
||||
// Load page if not loaded
|
||||
if page.Codes.HTML.Code == "" {
|
||||
if err := page.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
html, config, warnings, err := page.Page.Compile(ctx, option)
|
||||
if err != nil {
|
||||
return warnings, fmt.Errorf("Compile the page %s error: %s", page.Route, err.Error())
|
||||
}
|
||||
|
||||
// Save the html
|
||||
err = page.writeHTML([]byte(html), option.Data)
|
||||
if err != nil {
|
||||
return warnings, fmt.Errorf("Write the page %s error: %s", page.Route, err.Error())
|
||||
}
|
||||
|
||||
// Save the backend script file
|
||||
err = page.writeBackendScript(option.Data)
|
||||
if err != nil {
|
||||
return warnings, fmt.Errorf("Write the backend script file error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Save the config file
|
||||
err = page.writeConfig([]byte(config), option.Data)
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// publicFile get the public file path
|
||||
func (page *Page) publicFile(data map[string]interface{}) string {
|
||||
root, err := page.tmpl.agent.DSL.PublicRoot(data)
|
||||
if err != nil {
|
||||
log.Error("publicFile: Get the public root error: %s. use %s", err.Error(), page.tmpl.agent.DSL.Public.Root)
|
||||
root = page.tmpl.agent.DSL.Public.Root
|
||||
}
|
||||
return filepath.Join("/", "public", root, page.Route)
|
||||
}
|
||||
|
||||
// writeHTML write the html to file
|
||||
func (page *Page) writeHTML(html []byte, data map[string]interface{}) error {
|
||||
htmlFile := fmt.Sprintf("%s.sui", page.publicFile(data))
|
||||
htmlFileAbs := filepath.Join(application.App.Root(), htmlFile)
|
||||
dir := filepath.Dir(htmlFileAbs)
|
||||
if exist, _ := os.Stat(dir); exist == nil {
|
||||
os.MkdirAll(dir, os.ModePerm)
|
||||
}
|
||||
err := os.WriteFile(htmlFileAbs, html, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
core.RemoveCache(htmlFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeConfig write the config to file
|
||||
func (page *Page) writeConfig(config []byte, data map[string]interface{}) error {
|
||||
configFile := fmt.Sprintf("%s.cfg", page.publicFile(data))
|
||||
configFileAbs := filepath.Join(application.App.Root(), configFile)
|
||||
dir := filepath.Dir(configFileAbs)
|
||||
if exist, _ := os.Stat(dir); exist == nil {
|
||||
os.MkdirAll(dir, os.ModePerm)
|
||||
}
|
||||
err := os.WriteFile(configFileAbs, config, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backendScriptSource get the backend script source
|
||||
func (page *Page) backendScriptSource() (string, []byte, error) {
|
||||
fs := page.tmpl.agent.fs
|
||||
backendFile := filepath.Join(page.Path, fmt.Sprintf("%s.backend.ts", page.Name))
|
||||
if !fs.IsFile(backendFile) {
|
||||
backendFile = filepath.Join(page.Path, fmt.Sprintf("%s.backend.js", page.Name))
|
||||
}
|
||||
|
||||
if !fs.IsFile(backendFile) {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
source, err := fs.ReadFile(backendFile)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
source = []byte(fmt.Sprintf("%s\n%s", source, core.BackendScript(page.Route)))
|
||||
return backendFile, source, nil
|
||||
}
|
||||
|
||||
// writeBackendScript write the backend script to file
|
||||
func (page *Page) writeBackendScript(data map[string]interface{}) error {
|
||||
file, source, err := page.backendScriptSource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := filepath.Ext(file)
|
||||
scriptFile := fmt.Sprintf("%s.backend%s", page.publicFile(data), ext)
|
||||
scriptFileAbs := filepath.Join(application.App.Root(), scriptFile)
|
||||
dir := filepath.Dir(scriptFileAbs)
|
||||
if exist, _ := os.Stat(dir); exist == nil {
|
||||
os.MkdirAll(dir, os.ModePerm)
|
||||
}
|
||||
|
||||
err = os.WriteFile(scriptFileAbs, []byte(source), 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
core.RemoveCache(scriptFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildAsComponent build the page as component
|
||||
func (page *Page) BuildAsComponent(globalCtx *core.GlobalBuildContext, option *core.BuildOption) ([]string, error) {
|
||||
warnings := []string{}
|
||||
|
||||
if option.AssetRoot == "" {
|
||||
root, err := page.tmpl.agent.DSL.PublicRoot(option.Data)
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
option.AssetRoot = root
|
||||
}
|
||||
|
||||
// Load page if not loaded
|
||||
if page.Codes.HTML.Code == "" {
|
||||
if err := page.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// BuildAsComponent needs a parent selection, which is handled by the caller
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// Trans translate the page
|
||||
func (page *Page) Trans(globalCtx *core.GlobalBuildContext, option *core.BuildOption) ([]string, error) {
|
||||
warnings := []string{}
|
||||
ctx := core.NewBuildContext(globalCtx)
|
||||
|
||||
_, _, messages, err := page.Page.Compile(ctx, option)
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
|
||||
// Save translations - messages is []string, not map
|
||||
log.Debug("[Agent] Page %s translation messages: %v", page.Route, messages)
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// AssetRoot get the asset root for this page
|
||||
func (page *Page) AssetRoot() string {
|
||||
// If this is an assistant page, check for assistant-specific assets
|
||||
if page.assistantID != "" {
|
||||
assistantAssetsDir := filepath.Join(page.pagesRoot, "__assets")
|
||||
if page.tmpl.agent.fs.IsDir(assistantAssetsDir) {
|
||||
return fmt.Sprintf("/%s/assets", page.assistantID)
|
||||
}
|
||||
}
|
||||
|
||||
// Default to global agent assets
|
||||
return "/assets"
|
||||
}
|
||||
|
||||
// AssistantID get the assistant ID (empty for global agent pages)
|
||||
func (page *Page) AssistantID() string {
|
||||
return page.assistantID
|
||||
}
|
||||
690
sui/storages/agent/template.go
Normal file
690
sui/storages/agent/template.go
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/sui/core"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Template is the struct for the agent sui template
|
||||
type Template struct {
|
||||
Root string `json:"-"`
|
||||
agent *Agent
|
||||
locales []core.SelectOption
|
||||
loaded map[string]core.IPage
|
||||
*core.Template
|
||||
}
|
||||
|
||||
// Pages get the pages from both /agent/pages and /assistants/*/pages
|
||||
func (tmpl *Template) Pages() ([]core.IPage, error) {
|
||||
pages := []core.IPage{}
|
||||
|
||||
// 1. Get pages from /agent/pages (global agent pages like login, error, etc.)
|
||||
agentPagesDir := filepath.Join(tmpl.agent.root, "pages")
|
||||
if tmpl.agent.fs.IsDir(agentPagesDir) {
|
||||
agentPages, err := tmpl.getPagesFromDir(agentPagesDir, "")
|
||||
if err != nil {
|
||||
log.Error("[Agent] Failed to load agent pages: %v", err)
|
||||
} else {
|
||||
pages = append(pages, agentPages...)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get pages from each assistant's pages directory
|
||||
assistants, err := tmpl.agent.getAssistants()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, assistantID := range assistants {
|
||||
pagesDir := tmpl.agent.getAssistantPagesRoot(assistantID)
|
||||
assistantPages, err := tmpl.getPagesFromDir(pagesDir, assistantID)
|
||||
if err != nil {
|
||||
log.Error("[Agent] Failed to load pages for assistant %s: %v", assistantID, err)
|
||||
continue
|
||||
}
|
||||
pages = append(pages, assistantPages...)
|
||||
}
|
||||
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// getPagesFromDir get pages from a directory with optional route prefix
|
||||
func (tmpl *Template) getPagesFromDir(dir string, routePrefix string) ([]core.IPage, error) {
|
||||
exts := []string{"*.sui", "*.html", "*.htm", "*.page"}
|
||||
pages := []core.IPage{}
|
||||
|
||||
tmpl.agent.fs.Walk(dir, func(root, file string, isdir bool) error {
|
||||
name := filepath.Base(file)
|
||||
if isdir {
|
||||
if strings.HasPrefix(name, "__") || name == ".tmp" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "__") {
|
||||
return nil
|
||||
}
|
||||
|
||||
page, err := tmpl.getPageFrom(file, dir, routePrefix)
|
||||
if err != nil {
|
||||
log.Error("[Agent] Get page error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
pages = append(pages, page)
|
||||
return nil
|
||||
}, exts...)
|
||||
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// getPageFrom create a page from file
|
||||
func (tmpl *Template) getPageFrom(file, pagesRoot, assistantID string) (core.IPage, error) {
|
||||
route := tmpl.getPageRoute(file, pagesRoot, assistantID)
|
||||
return tmpl.getPage(route, file, pagesRoot, assistantID)
|
||||
}
|
||||
|
||||
// getPageRoute get the route for a page
|
||||
func (tmpl *Template) getPageRoute(file, pagesRoot, assistantID string) string {
|
||||
// Get relative path from pages root
|
||||
relPath := filepath.Dir(file[len(pagesRoot):])
|
||||
|
||||
// Add assistant prefix if this is an assistant page
|
||||
if assistantID != "" {
|
||||
return filepath.Join("/", assistantID, relPath)
|
||||
}
|
||||
|
||||
return relPath
|
||||
}
|
||||
|
||||
// getPage create a page object
|
||||
func (tmpl *Template) getPage(route, file, pagesRoot, assistantID string) (core.IPage, error) {
|
||||
path := filepath.Dir(file)
|
||||
name := tmpl.getPageBase(route)
|
||||
|
||||
return &Page{
|
||||
Page: &core.Page{
|
||||
Route: route,
|
||||
Path: path,
|
||||
Name: name,
|
||||
TemplateID: tmpl.ID,
|
||||
SuiID: tmpl.agent.DSL.ID,
|
||||
Codes: core.SourceCodes{
|
||||
HTML: core.Source{File: fmt.Sprintf("%s%s", name, filepath.Ext(file))},
|
||||
CSS: core.Source{File: fmt.Sprintf("%s.css", name)},
|
||||
JS: core.Source{File: fmt.Sprintf("%s.js", name)},
|
||||
DATA: core.Source{File: fmt.Sprintf("%s.json", name)},
|
||||
TS: core.Source{File: fmt.Sprintf("%s.ts", name)},
|
||||
LESS: core.Source{File: fmt.Sprintf("%s.less", name)},
|
||||
CONF: core.Source{File: fmt.Sprintf("%s.config", name)},
|
||||
},
|
||||
},
|
||||
tmpl: tmpl,
|
||||
assistantID: assistantID,
|
||||
pagesRoot: pagesRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (tmpl *Template) getPageBase(route string) string {
|
||||
return filepath.Base(route)
|
||||
}
|
||||
|
||||
// Page get a specific page by route
|
||||
func (tmpl *Template) Page(route string) (core.IPage, error) {
|
||||
// Parse the route to determine if it's an assistant page or agent page
|
||||
parts := strings.Split(strings.Trim(route, "/"), "/")
|
||||
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("Invalid route: %s", route)
|
||||
}
|
||||
|
||||
// Check if first part is an assistant ID
|
||||
assistants, err := tmpl.agent.getAssistants()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assistantID := ""
|
||||
pageRoute := route
|
||||
pagesRoot := filepath.Join(tmpl.agent.root, "pages")
|
||||
|
||||
for _, ast := range assistants {
|
||||
if parts[0] == ast {
|
||||
assistantID = ast
|
||||
pageRoute = "/" + strings.Join(parts[1:], "/")
|
||||
pagesRoot = tmpl.agent.getAssistantPagesRoot(assistantID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Find the page file
|
||||
pagePath := tmpl.getPagePath(pageRoute, pagesRoot)
|
||||
exts := []string{".sui", ".html", ".htm", ".page"}
|
||||
|
||||
for _, ext := range exts {
|
||||
file := fmt.Sprintf("%s%s", pagePath, ext)
|
||||
if tmpl.agent.fs.IsFile(file) {
|
||||
return tmpl.getPage(route, file, pagesRoot, assistantID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Page not found: %s", route)
|
||||
}
|
||||
|
||||
func (tmpl *Template) getPagePath(route, pagesRoot string) string {
|
||||
name := tmpl.getPageBase(route)
|
||||
return filepath.Join(pagesRoot, route, name)
|
||||
}
|
||||
|
||||
// PageExist check if page exists
|
||||
func (tmpl *Template) PageExist(route string) bool {
|
||||
_, err := tmpl.Page(route)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// RemovePage remove a page (not supported)
|
||||
func (tmpl *Template) RemovePage(route string) error {
|
||||
return fmt.Errorf("RemovePage is not supported for agent pages")
|
||||
}
|
||||
|
||||
// GetPageFromAsset get page from asset
|
||||
func (tmpl *Template) GetPageFromAsset(file string) (core.IPage, error) {
|
||||
route := filepath.Dir(file)
|
||||
return tmpl.Page(route)
|
||||
}
|
||||
|
||||
// CreateEmptyPage create an empty page (not supported)
|
||||
func (tmpl *Template) CreateEmptyPage(route string, setting *core.PageSetting) (core.IPage, error) {
|
||||
return nil, fmt.Errorf("CreateEmptyPage is not supported for agent pages")
|
||||
}
|
||||
|
||||
// CreatePage create a page from source (not supported for editing)
|
||||
func (tmpl *Template) CreatePage(source string) core.IPage {
|
||||
// This is used for rendering, we need to find the page by route
|
||||
page, err := tmpl.Page(source)
|
||||
if err != nil {
|
||||
log.Error("[Agent] CreatePage error: %v", err)
|
||||
return nil
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// GetRoot get the root path (returns agent root for assets, etc.)
|
||||
func (tmpl *Template) GetRoot() string {
|
||||
return tmpl.agent.root
|
||||
}
|
||||
|
||||
// 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
|
||||
agentFile := filepath.Join(tmpl.agent.root, "__assets", file)
|
||||
if tmpl.agent.fs.IsFile(agentFile) {
|
||||
return tmpl.readAsset(agentFile, width, height)
|
||||
}
|
||||
|
||||
// If not found and this is an assistant-specific request, check assistant assets
|
||||
// Format: /<assistant-id>/assets/...
|
||||
parts := strings.SplitN(strings.TrimPrefix(file, "/"), "/", 2)
|
||||
if len(parts) >= 2 {
|
||||
assistantID := parts[0]
|
||||
assetPath := parts[1]
|
||||
|
||||
// Check if this is a valid assistant
|
||||
assistants, _ := tmpl.agent.getAssistants()
|
||||
for _, ast := range assistants {
|
||||
if ast == assistantID {
|
||||
assistantAssetFile := filepath.Join(tmpl.agent.assistantsRoot, assistantID, "pages", "__assets", assetPath)
|
||||
if tmpl.agent.fs.IsFile(assistantAssetFile) {
|
||||
return tmpl.readAsset(assistantAssetFile, width, height)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Asset %s not found", file)
|
||||
}
|
||||
|
||||
// readAsset read asset from file
|
||||
func (tmpl *Template) readAsset(file string, width, height uint) (*core.Asset, error) {
|
||||
content, err := tmpl.agent.fs.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
typ, err := tmpl.agent.fs.MimeType(file)
|
||||
if err != nil {
|
||||
typ = "application/octet-stream"
|
||||
}
|
||||
|
||||
return &core.Asset{Type: typ, Content: content}, nil
|
||||
}
|
||||
|
||||
// Locales get the global locales
|
||||
func (tmpl *Template) Locales() []core.SelectOption {
|
||||
if tmpl.locales != nil {
|
||||
return tmpl.locales
|
||||
}
|
||||
|
||||
supportLocales := []core.SelectOption{}
|
||||
localeMap := map[string]bool{}
|
||||
|
||||
// Check __locales directory
|
||||
path := filepath.Join(tmpl.Root, "__locales")
|
||||
if !tmpl.agent.fs.IsDir(path) {
|
||||
return supportLocales
|
||||
}
|
||||
|
||||
dirs, err := tmpl.agent.fs.ReadDir(path, false)
|
||||
if err != nil {
|
||||
return supportLocales
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
locale := filepath.Base(dir)
|
||||
if localeMap[locale] {
|
||||
continue
|
||||
}
|
||||
label := language.Make(locale).String()
|
||||
localeMap[locale] = true
|
||||
supportLocales = append(supportLocales, core.SelectOption{
|
||||
Value: locale,
|
||||
Label: label,
|
||||
})
|
||||
}
|
||||
|
||||
tmpl.locales = supportLocales
|
||||
return tmpl.locales
|
||||
}
|
||||
|
||||
// Themes get the global themes
|
||||
func (tmpl *Template) Themes() []core.SelectOption {
|
||||
return tmpl.Template.Themes
|
||||
}
|
||||
|
||||
// Assets get the assets
|
||||
func (tmpl *Template) Assets() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Glob the files
|
||||
func (tmpl *Template) Glob(pattern string) ([]string, error) {
|
||||
path := filepath.Join(tmpl.Root, pattern)
|
||||
paths, err := tmpl.agent.fs.Glob(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routes := []string{}
|
||||
for _, p := range paths {
|
||||
routes = append(routes, strings.TrimPrefix(p, tmpl.Root))
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// GlobRoutes the files
|
||||
func (tmpl *Template) GlobRoutes(patterns []string, unique ...bool) ([]string, error) {
|
||||
routes := []string{}
|
||||
for _, pattern := range patterns {
|
||||
paths, err := tmpl.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if !tmpl.agent.fs.IsDir(filepath.Join(tmpl.Root, path)) {
|
||||
continue
|
||||
}
|
||||
routes = append(routes, path)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unique) > 0 && unique[0] {
|
||||
mapRoutes := map[string]bool{}
|
||||
for _, route := range routes {
|
||||
mapRoutes[route] = true
|
||||
}
|
||||
|
||||
routes = []string{}
|
||||
for route := range mapRoutes {
|
||||
routes = append(routes, route)
|
||||
}
|
||||
}
|
||||
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// Reload the template
|
||||
func (tmpl *Template) Reload() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PageTree gets the page tree
|
||||
func (tmpl *Template) PageTree(route string) ([]*core.PageTreeNode, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// MediaSearch search the asset
|
||||
func (tmpl *Template) MediaSearch(query url.Values, page int, pageSize int) (core.MediaSearchResult, error) {
|
||||
return core.MediaSearchResult{Data: []core.Media{}, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
// AssetUpload upload the asset (not supported)
|
||||
func (tmpl *Template) AssetUpload(reader io.Reader, name string) (string, error) {
|
||||
return "", fmt.Errorf("AssetUpload is not supported for agent template")
|
||||
}
|
||||
|
||||
// Block get the block (not supported)
|
||||
func (tmpl *Template) Block(name string) (core.IBlock, error) {
|
||||
return nil, fmt.Errorf("Block is not supported for agent template")
|
||||
}
|
||||
|
||||
// Blocks get the blocks (not supported)
|
||||
func (tmpl *Template) Blocks() ([]core.IBlock, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// BlockLayoutItems get block layout items
|
||||
func (tmpl *Template) BlockLayoutItems() (*core.BlockLayoutItems, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// BlockMedia get block media
|
||||
func (tmpl *Template) BlockMedia(id string) (*core.Asset, error) {
|
||||
return nil, fmt.Errorf("BlockMedia is not supported for agent template")
|
||||
}
|
||||
|
||||
// Component get the component (not supported)
|
||||
func (tmpl *Template) Component(name string) (core.IComponent, error) {
|
||||
return nil, fmt.Errorf("Component is not supported for agent template")
|
||||
}
|
||||
|
||||
// Components get the components (not supported)
|
||||
func (tmpl *Template) Components() ([]core.IComponent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SupportLocales get the support locales
|
||||
func (tmpl *Template) SupportLocales() []string {
|
||||
locales := tmpl.Locales()
|
||||
result := make([]string, len(locales))
|
||||
for i, locale := range locales {
|
||||
result[i] = locale.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ExecBeforeBuildScripts execute the before build scripts
|
||||
func (tmpl *Template) ExecBeforeBuildScripts() []core.TemplateScirptResult {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecAfterBuildScripts execute the after build scripts
|
||||
func (tmpl *Template) ExecAfterBuildScripts() []core.TemplateScirptResult {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecBuildCompleteScripts execute the build complete scripts
|
||||
func (tmpl *Template) ExecBuildCompleteScripts() []core.TemplateScirptResult {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build build the template
|
||||
func (tmpl *Template) Build(option *core.BuildOption) ([]string, error) {
|
||||
warnings := []string{}
|
||||
|
||||
// Execute before build scripts
|
||||
tmpl.ExecBeforeBuildScripts()
|
||||
|
||||
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
|
||||
if err != nil {
|
||||
log.Error("Build: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
|
||||
root = tmpl.agent.DSL.Public.Root
|
||||
}
|
||||
|
||||
if option.AssetRoot == "" {
|
||||
option.AssetRoot = filepath.Join(root, "assets")
|
||||
}
|
||||
option.PublicRoot = root
|
||||
|
||||
// Sync the assets
|
||||
if err = tmpl.SyncAssets(option); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
|
||||
// Get all pages
|
||||
pages, err := tmpl.Pages()
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
|
||||
// Build global context
|
||||
globalCtx := core.NewGlobalBuildContext(tmpl)
|
||||
|
||||
// Build each page
|
||||
tmpl.loaded = map[string]core.IPage{}
|
||||
for _, page := range pages {
|
||||
if err := page.Load(); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Failed to load page %s: %v", page.Get().Route, err))
|
||||
continue
|
||||
}
|
||||
|
||||
pageWarnings, err := page.Build(globalCtx, option)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Failed to build page %s: %v", page.Get().Route, err))
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, pageWarnings...)
|
||||
tmpl.loaded[page.Get().Route] = page
|
||||
}
|
||||
|
||||
// Add sui lib to the global
|
||||
err = tmpl.UpdateJSSDK(option)
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
|
||||
// Execute after build scripts
|
||||
tmpl.ExecAfterBuildScripts()
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// SyncAssets sync assets from template __assets to public
|
||||
func (tmpl *Template) SyncAssets(option *core.BuildOption) error {
|
||||
// Get source abs path
|
||||
sourceRoot := filepath.Join(tmpl.agent.fs.Root(), tmpl.Root, "__assets")
|
||||
if exist, _ := os.Stat(sourceRoot); exist == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get target abs path
|
||||
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
|
||||
if err != nil {
|
||||
log.Error("SyncAssets: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
|
||||
root = tmpl.agent.DSL.Public.Root
|
||||
}
|
||||
|
||||
targetRoot := filepath.Join(application.App.Root(), "public", root, "assets")
|
||||
if exist, _ := os.Stat(targetRoot); exist == nil {
|
||||
os.MkdirAll(targetRoot, os.ModePerm)
|
||||
}
|
||||
|
||||
// Copy the assets
|
||||
return tmpl.copyDir(sourceRoot, targetRoot)
|
||||
}
|
||||
|
||||
// copyDir copy directory recursively
|
||||
func (tmpl *Template) copyDir(src string, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(targetPath, os.ModePerm)
|
||||
}
|
||||
|
||||
// Copy file
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(targetPath, data, 0644)
|
||||
})
|
||||
}
|
||||
|
||||
// SyncAssetFile sync asset file
|
||||
func (tmpl *Template) SyncAssetFile(file string, option *core.BuildOption) error {
|
||||
sourceRoot := filepath.Join(tmpl.agent.fs.Root(), tmpl.Root, "__assets")
|
||||
if exist, _ := os.Stat(sourceRoot); exist == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
|
||||
if err != nil {
|
||||
log.Error("SyncAssetFile: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
|
||||
root = tmpl.agent.DSL.Public.Root
|
||||
}
|
||||
|
||||
targetRoot := filepath.Join(application.App.Root(), "public", root, "assets")
|
||||
sourceFile := filepath.Join(sourceRoot, file)
|
||||
targetFile := filepath.Join(targetRoot, file)
|
||||
|
||||
// Create the target directory
|
||||
dir := filepath.Dir(targetFile)
|
||||
if exist, _ := os.Stat(dir); exist == nil {
|
||||
os.MkdirAll(dir, os.ModePerm)
|
||||
}
|
||||
|
||||
// Copy file
|
||||
data, err := os.ReadFile(sourceFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(targetFile, data, 0644)
|
||||
}
|
||||
|
||||
// UpdateJSSDK update the JS SDK (libsui.min.js)
|
||||
func (tmpl *Template) UpdateJSSDK(option *core.BuildOption) error {
|
||||
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
|
||||
if err != nil {
|
||||
log.Error("UpdateJSSDK: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
|
||||
root = tmpl.agent.DSL.Public.Root
|
||||
}
|
||||
|
||||
targetRoot := filepath.Join(application.App.Root(), "public", root, "assets")
|
||||
if exist, _ := os.Stat(targetRoot); exist == nil {
|
||||
os.MkdirAll(targetRoot, os.ModePerm)
|
||||
}
|
||||
|
||||
// Get libsui source
|
||||
libsui, libsuiMap, err := core.LibSUI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write libsui.min.js
|
||||
file := filepath.Join(targetRoot, "libsui.min.js")
|
||||
err = os.WriteFile(file, libsui, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write libsui.min.js.map
|
||||
mapFile := filepath.Join(targetRoot, "libsui.min.js.map")
|
||||
err = os.WriteFile(mapFile, libsuiMap, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trans translate the template
|
||||
func (tmpl *Template) Trans(option *core.BuildOption) ([]string, error) {
|
||||
warnings := []string{}
|
||||
|
||||
// Get all pages
|
||||
pages, err := tmpl.Pages()
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
|
||||
// Build global context
|
||||
globalCtx := core.NewGlobalBuildContext(tmpl)
|
||||
|
||||
// Translate each page
|
||||
for _, page := range pages {
|
||||
if err := page.Load(); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Failed to load page %s: %v", page.Get().Route, err))
|
||||
continue
|
||||
}
|
||||
|
||||
pageWarnings, err := page.Trans(globalCtx, option)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Failed to translate page %s: %v", page.Get().Route, err))
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, pageWarnings...)
|
||||
}
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// loadBuildScript load the build script
|
||||
func (tmpl *Template) loadBuildScript() error {
|
||||
file, source, err := tmpl.backendScriptSource("__build.backend")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
script, err := v8.MakeScript(source, file, 5*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl.BuildScript = &core.Script{Script: script}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tmpl *Template) backendScriptSource(name string) (string, []byte, error) {
|
||||
path := filepath.Join(tmpl.Root, fmt.Sprintf("%s.ts", name))
|
||||
if !tmpl.agent.fs.IsFile(path) {
|
||||
path = filepath.Join(tmpl.Root, fmt.Sprintf("%s.js", name))
|
||||
}
|
||||
|
||||
if !tmpl.agent.fs.IsFile(path) {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
content, err := tmpl.agent.fs.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return path, content, nil
|
||||
}
|
||||
15
sui/storages/agent/types.go
Normal file
15
sui/storages/agent/types.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/fs"
|
||||
"github.com/yaoapp/yao/sui/core"
|
||||
)
|
||||
|
||||
// Agent is the struct for the agent sui storage
|
||||
// It extends local storage with special page loading from /assistants/<name>/pages/
|
||||
type Agent struct {
|
||||
root string // /agent
|
||||
assistantsRoot string // /assistants
|
||||
fs fs.FileSystem
|
||||
*core.DSL
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue