yao/sui/core/build.go

581 lines
14 KiB
Go

package core
import (
"bufio"
"fmt"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/kun/log"
"golang.org/x/net/html"
)
var slotRe = regexp.MustCompile(`\[\{([^\}]+)\}\]`)
var cssRe = regexp.MustCompile(`([\.a-z0-9A-Z-:# ]+)\{`)
var langFuncRe = regexp.MustCompile(`L\s*\(\s*["'](.*?)["']\s*\)`)
var langAttrRe = regexp.MustCompile(`'::(.*?)'`)
// Build build the page
func (page *Page) Build(ctx *BuildContext, option *BuildOption) (*goquery.Document, []string, error) {
// Create the context if not exists
if ctx == nil {
ctx = NewBuildContext(nil)
}
// Push the current page onto the stack and increment the visit counter
ctx.stack = append(ctx.stack, page.Route)
ctx.visited[page.Route]++
defer func() {
ctx.stack = ctx.stack[:len(ctx.stack)-1] // Pop the stack
ctx.visited[page.Route]--
}()
// Check for recursive calls
if ctx.visited[page.Route] > 1 {
return nil, ctx.warnings, fmt.Errorf("recursive build detected for page %s", page.Route)
}
ctx.sequence++
html, err := page.BuildHTML(option)
if err != nil {
ctx.warnings = append(ctx.warnings, err.Error())
}
doc, err := NewDocumentString(html)
if err != nil {
return nil, ctx.warnings, err
}
err = page.buildComponents(doc, ctx, option)
if err != nil {
return nil, ctx.warnings, err
}
// Scripts
namespace := Namespace(page.Name, ctx.sequence, option.ScriptMinify)
scripts, err := page.BuildScripts(ctx, option, "__page", namespace)
if err != nil {
return nil, ctx.warnings, err
}
// Styles
styles, err := page.BuildStyles(ctx, option, "__page", namespace)
if err != nil {
return nil, ctx.warnings, err
}
// Append the scripts and styles
ctx.scripts = append(ctx.scripts, scripts...)
ctx.styles = append(ctx.styles, styles...)
return doc, ctx.warnings, err
}
// BuildAsComponent build the page as component
func (page *Page) BuildAsComponent(sel *goquery.Selection, ctx *BuildContext, option *BuildOption) (string, error) {
if page.parent == nil {
return "", fmt.Errorf("The parent page is not set")
}
// Push the current page onto the stack and increment the visit counter
ctx.stack = append(ctx.stack, page.Route)
ctx.visited[page.Route]++
defer func() {
ctx.stack = ctx.stack[:len(ctx.stack)-1] // Pop the stack
ctx.visited[page.Route]--
}()
// Check for recursive calls
if ctx.visited[page.Route] > 1 {
return "", fmt.Errorf("recursive build detected for page %s", page.Route)
}
name, exists := sel.Attr("is")
if !exists {
return "", fmt.Errorf("The component tag must have an is attribute")
}
namespace := Namespace(name, ctx.sequence, option.ScriptMinify)
component := ComponentName(name, option.ScriptMinify)
attrs := []html.Attribute{
{Key: "s:ns", Val: namespace},
{Key: "s:cn", Val: component},
{Key: "s:ready", Val: component + "()"},
}
ctx.sequence++
var opt = *option
opt.IgnoreDocument = true
html, err := page.BuildHTML(&opt)
if err != nil {
return "", err
}
doc, err := NewDocumentStringWithWrapper(html)
if err != nil {
return "", err
}
body := doc.Selection.Find("body")
if body.Length() > 1 {
body.SetHtml("<div>" + html + "</div>")
}
// Scripts
scripts, err := page.BuildScripts(ctx, &opt, component, namespace)
if err != nil {
return "", err
}
// Append the scripts
ctx.scripts = append(ctx.scripts, scripts...)
// Pass the component props
first := body.Children().First()
page.copyProps(ctx, sel, first, attrs...)
page.copySlots(ctx, sel, first)
page.buildComponents(doc, ctx, &opt)
data := Data{"$props": page.Attrs}
data.ReplaceSelectionUse(slotRe, first)
html, err = body.Html()
if err != nil {
return "", err
}
sel.ReplaceWithHtml(html)
return html, nil
}
func (page *Page) copySlots(ctx *BuildContext, from *goquery.Selection, to *goquery.Selection) error {
slots := from.Find("slot")
if slots.Length() == 0 {
return nil
}
for i := 0; i < slots.Length(); i++ {
slot := slots.Eq(i)
name, has := slot.Attr("name")
if !has {
continue
}
// Get the slot
slotSel := to.Find(fmt.Sprintf("slot[name='%s']", name))
if slotSel.Length() == 0 {
continue
}
// Copy the slot
html, err := slot.Html()
if err != nil {
ctx.warnings = append(ctx.warnings, err.Error())
setError(slotSel, err)
continue
}
slotSel.SetHtml(html)
}
return nil
}
func (page *Page) copyProps(ctx *BuildContext, from *goquery.Selection, to *goquery.Selection, extra ...html.Attribute) error {
attrs := from.Get(0).Attr
prefix := "s:prop"
if page.Attrs == nil {
page.Attrs = map[string]string{}
}
for _, attr := range attrs {
if strings.HasPrefix(attr.Key, "s:") || attr.Key == "is" || attr.Key == "parsed" {
continue
}
if strings.HasPrefix(attr.Key, "...$props") {
data := Data{"$props": page.parent.Attrs}
val, err := data.Exec(fmt.Sprintf("{{ %s }}", attr.Key[3:]))
if err != nil {
ctx.warnings = append(ctx.warnings, err.Error())
setError(to, err)
continue
}
switch value := val.(type) {
case map[string]string:
for key, value := range value {
page.Attrs[key] = value
key = fmt.Sprintf("%s:%s", prefix, key)
to.SetAttr(key, value)
}
}
continue
}
val := attr.Val
if strings.HasPrefix(attr.Key, `...\$props`) {
val = fmt.Sprintf("{{ $props.%s }}", attr.Key[9:])
}
if strings.HasPrefix(attr.Key, "...") {
val = attr.Key[3:]
}
page.Attrs[attr.Key] = val
key := fmt.Sprintf("%s:%s", prefix, attr.Key)
to.SetAttr(key, val)
}
if len(extra) > 0 {
for _, attr := range extra {
to.SetAttr(attr.Key, attr.Val)
}
}
return nil
}
func (page *Page) buildComponents(doc *goquery.Document, ctx *BuildContext, option *BuildOption) error {
sui := SUIs[page.SuiID]
if sui == nil {
return fmt.Errorf("SUI %s not found", page.SuiID)
}
public := sui.GetPublic()
tmpl, err := sui.GetTemplate(page.TemplateID)
if err != nil {
return err
}
doc.Find("*").Each(func(i int, sel *goquery.Selection) {
// Get the translation
name, has := sel.Attr("is")
if !has {
return
}
// Slot tag
tagName := sel.Get(0).Data
if tagName == "slot" {
return
}
// Check if Just-In-Time Component ( "is" has variable )
if ctx.isJitComponent(name) {
sel.SetAttr("s:jit", "true")
sel.SetAttr("s:root", public.Root)
ctx.addJitComponent(name)
return
}
sel.SetAttr("parsed", "true")
ipage, err := tmpl.Page(name)
if err != nil {
setError(sel, err)
log.Warn("Page %s/%s/%s: %s", page.SuiID, page.TemplateID, page.Route, err.Error())
return
}
err = ipage.Load()
if err != nil {
setError(sel, err)
log.Warn("Page %s/%s/%s: %s", page.SuiID, page.TemplateID, page.Route, err.Error())
return
}
component := ipage.Get()
component.parent = page
_, err = component.BuildAsComponent(sel, ctx, option)
if err != nil {
setError(sel, err)
log.Warn("Page %s/%s/%s: %s", page.SuiID, page.TemplateID, page.Route, err.Error())
return
}
return
})
return err
}
// BuildStyles build the styles for the page
func (page *Page) BuildStyles(ctx *BuildContext, option *BuildOption, component string, namespace string) ([]StyleNode, error) {
styles := []StyleNode{}
if page.Codes.CSS.Code == "" {
return styles, nil
}
if _, has := ctx.styleUnique[component]; has {
return styles, nil
}
ctx.styleUnique[component] = true
code := page.Codes.CSS.Code
// Replace the assets
if !option.IgnoreAssetRoot {
code = AssetsRe.ReplaceAllStringFunc(code, func(match string) string {
return strings.ReplaceAll(match, "@assets", option.AssetRoot)
})
}
if option.ComponentName != "" {
code = cssRe.ReplaceAllStringFunc(code, func(css string) string {
return fmt.Sprintf("[s\\:cn=%s] %s", option.ComponentName, css)
})
res, err := page.CompileCSS([]byte(code), option.StyleMinify)
if err != nil {
return styles, err
}
styles = append(styles, StyleNode{
Namespace: namespace,
Component: component,
Source: string(res),
Parent: "head",
Attrs: []html.Attribute{
{Key: "rel", Val: "stylesheet"},
{Key: "type", Val: "text/css"},
},
})
return styles, nil
}
res, err := page.CompileCSS([]byte(code), option.StyleMinify)
if err != nil {
return styles, err
}
styles = append(styles, StyleNode{
Namespace: namespace,
Component: component,
Parent: "head",
Source: string(res),
Attrs: []html.Attribute{
{Key: "rel", Val: "stylesheet"},
{Key: "type", Val: "text/css"},
},
})
return styles, nil
}
// BuildScripts build the scripts for the page
func (page *Page) BuildScripts(ctx *BuildContext, option *BuildOption, component string, namespace string) ([]ScriptNode, error) {
scripts := []ScriptNode{}
if page.Codes.JS.Code == "" && page.Codes.TS.Code == "" {
return scripts, nil
}
if _, has := ctx.scriptUnique[component]; has {
return scripts, nil
}
ctx.scriptUnique[component] = true
var err error = nil
var imports []string = nil
var source []byte = nil
if page.Codes.TS.Code != "" {
source, imports, err = page.CompileTS([]byte(page.Codes.TS.Code), option.ScriptMinify)
if err != nil {
return nil, err
}
} else if page.Codes.JS.Code != "" {
source, imports, err = page.CompileJS([]byte(page.Codes.JS.Code), option.ScriptMinify)
if err != nil {
return nil, err
}
}
// Add the script
if imports != nil {
for _, src := range imports {
scripts = append(scripts, ScriptNode{
Namespace: namespace,
Component: component,
Parent: "head",
Attrs: []html.Attribute{
{Key: "src", Val: fmt.Sprintf("%s/%s", option.AssetRoot, src)},
{Key: "type", Val: "text/javascript"},
}},
)
}
}
// Replace the assets
if !option.IgnoreAssetRoot && source != nil {
source = AssetsRe.ReplaceAllFunc(source, func(match []byte) []byte {
return []byte(strings.ReplaceAll(string(match), "@assets", option.AssetRoot))
})
code := string(source)
parent := "body"
if component != "__page" {
parent = "head"
code = fmt.Sprintf("function %s(){\n%s\n}\n", component, addTabToEachLine(code))
}
scripts = append(scripts, ScriptNode{
Namespace: namespace,
Component: component,
Source: code,
Parent: parent,
Attrs: []html.Attribute{
{Key: "type", Val: "text/javascript"},
},
})
}
return scripts, nil
}
// BuildHTML build the html
func (page *Page) BuildHTML(option *BuildOption) (string, error) {
html := string(page.Codes.HTML.Code)
if option.WithWrapper {
html = fmt.Sprintf("<body>%s</body>", html)
}
if !option.IgnoreDocument {
html = string(page.Document)
if page.Codes.HTML.Code != "" {
html = strings.Replace(html, "{{ __page }}", page.Codes.HTML.Code, 1)
}
}
if !option.IgnoreAssetRoot {
html = strings.ReplaceAll(html, "@assets", option.AssetRoot)
}
res, err := page.CompileHTML([]byte(html), false)
if err != nil {
return "", err
}
return string(res), nil
}
func setError(sel *goquery.Selection, err error) {
html := `<div style="color:red; margin:10px 0px; font-size: 12px; font-family: monospace; padding: 10px; border: 1px solid red; background-color: #f8d7da;">%s</div>`
sel.SetHtml(fmt.Sprintf(html, err.Error()))
}
func addTabToEachLine(input string, prefix ...string) string {
var lines []string
space := " "
if len(prefix) > 0 {
space = prefix[0]
}
scanner := bufio.NewScanner(strings.NewReader(input))
for scanner.Scan() {
line := scanner.Text()
lineWithTab := space + line
lines = append(lines, lineWithTab)
}
return strings.Join(lines, "\n")
}
func getScriptTranslation(code string, namespace string) []Translation {
translations := []Translation{}
matches := langFuncRe.FindAllStringSubmatch(code, -1)
for i, match := range matches {
translations = append(translations, Translation{
Key: fmt.Sprintf("%s_script_%d", namespace, i),
Message: match[1],
Type: "script",
})
}
return translations
}
func getNodeTranslation(sel *goquery.Selection, index int, namespace string) []Translation {
translations := []Translation{}
nodeType := sel.Get(0).Type
switch nodeType {
case html.ElementNode:
// Get the translation
if typ, has := sel.Attr("s:trans"); has {
typ = strings.TrimSpace(typ)
if typ == "" {
typ = "html"
}
key := fmt.Sprintf("%s_index_%d", namespace, index)
translations = append(translations, Translation{
Key: key,
Message: strings.TrimSpace(sel.Text()),
Type: typ,
})
sel.SetAttr("s:trans-node", key)
sel.RemoveAttr("s:trans")
}
// Attributes
keys := map[string][]string{}
has := false
for i, attr := range sel.Get(0).Attr {
keys[attr.Key] = []string{}
// value="::attr"
if strings.HasPrefix(attr.Val, "::") {
key := fmt.Sprintf("%s_index_attr_%d_%d", namespace, index, i)
translations = append(translations, Translation{
Key: fmt.Sprintf("%s_index_attr_%d_%d", namespace, index, i),
Message: attr.Val[2:],
Name: attr.Key,
Type: "attr",
})
keys[attr.Key] = append(keys[attr.Key], key)
has = true
}
// value="{{ 'key': '::value' }}"
matches := langAttrRe.FindAllStringSubmatch(attr.Val, -1)
if len(matches) > 0 {
for j, match := range matches {
key := fmt.Sprintf("%s_index_attr_%d_%d_%d", namespace, index, i, j)
translations = append(translations, Translation{
Key: fmt.Sprintf("%s_index_attr_%d_%d_%d", namespace, index, i, j),
Message: match[1],
Name: attr.Key,
Type: "attr",
})
keys[attr.Key] = append(keys[attr.Key], key)
has = true
}
}
}
if has {
raw, err := jsoniter.Marshal(keys)
if err != nil {
fmt.Println(color.RedString(err.Error()))
break
}
sel.SetAttr("s:trans-attrs", string(raw))
sel.RemoveAttr("s:trans")
}
case html.TextNode:
if strings.HasPrefix(sel.Text(), "::") {
key := fmt.Sprintf("%s_index_%d", namespace, index)
translations = append(translations, Translation{
Key: fmt.Sprintf("%s_index_%d", namespace, index),
Message: strings.TrimSpace(sel.Text()[2:]),
Type: "text",
})
sel.SetAttr("s:trans-node", key)
sel.RemoveAttr("s:trans")
}
}
return translations
}