git commit -m "feat: initial release of Strata framework v0.1.0
- Static compiler with STRC pattern (Static Template Resolution with
Compartmentalized Layers)
- Template syntax: { } interpolation, { s-for }, { s-if/s-elif/s-else
}
- File types: .strata, .compiler.sts, .service.sts, .api.sts, .sts,
.scss
- CLI tools: strata dev, strata build, strata g (generators)
- create-strata scaffolding CLI with Pokemon API example
- Dev server with WebSocket HMR (Hot Module Replacement)
- Documentation: README, ARCHITECTURE, CHANGELOG, CONTRIBUTING,
LICENSE
This commit is contained in:
304
compiler/internal/ast/nodes.go
Normal file
304
compiler/internal/ast/nodes.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package ast
|
||||
|
||||
// Node is the base interface for all AST nodes
|
||||
type Node interface {
|
||||
NodeType() string
|
||||
}
|
||||
|
||||
// StrataFile represents a parsed .strata file
|
||||
type StrataFile struct {
|
||||
Template *TemplateNode
|
||||
Script *ScriptNode
|
||||
Style *StyleNode
|
||||
}
|
||||
|
||||
func (s *StrataFile) NodeType() string { return "StrataFile" }
|
||||
|
||||
// TemplateNode represents the <template> block
|
||||
type TemplateNode struct {
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (t *TemplateNode) NodeType() string { return "Template" }
|
||||
|
||||
// ScriptNode represents the <script> block
|
||||
type ScriptNode struct {
|
||||
Content string
|
||||
Lang string // "ts" or "js"
|
||||
}
|
||||
|
||||
func (s *ScriptNode) NodeType() string { return "Script" }
|
||||
|
||||
// StyleNode represents the <style> block
|
||||
type StyleNode struct {
|
||||
Content string
|
||||
Lang string // "css", "scss", "sass", "less"
|
||||
Scoped bool
|
||||
}
|
||||
|
||||
func (s *StyleNode) NodeType() string { return "Style" }
|
||||
|
||||
// ElementNode represents an HTML element
|
||||
type ElementNode struct {
|
||||
Tag string
|
||||
Attributes map[string]string
|
||||
Directives []Directive
|
||||
Children []Node
|
||||
IsComponent bool
|
||||
}
|
||||
|
||||
func (e *ElementNode) NodeType() string { return "Element" }
|
||||
|
||||
// TextNode represents text content
|
||||
type TextNode struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
func (t *TextNode) NodeType() string { return "Text" }
|
||||
|
||||
// InterpolationNode represents { expression } variable binding
|
||||
type InterpolationNode struct {
|
||||
Expression string
|
||||
}
|
||||
|
||||
func (i *InterpolationNode) NodeType() string { return "Interpolation" }
|
||||
|
||||
// ForBlockNode represents { s-for item in items } ... { /s-for }
|
||||
type ForBlockNode struct {
|
||||
Item string // Loop variable name (e.g., "item")
|
||||
Index string // Optional index variable (e.g., "i" in "item, i")
|
||||
Iterable string // Expression to iterate (e.g., "items")
|
||||
Children []Node // Content inside the for block
|
||||
}
|
||||
|
||||
func (f *ForBlockNode) NodeType() string { return "ForBlock" }
|
||||
|
||||
// IfBlockNode represents { s-if } ... { s-elif } ... { s-else } ... { /s-if }
|
||||
type IfBlockNode struct {
|
||||
Condition string // The if condition
|
||||
Children []Node // Content when condition is true
|
||||
ElseIf []*ElseIfBranch
|
||||
Else []Node // Content for else branch
|
||||
}
|
||||
|
||||
func (i *IfBlockNode) NodeType() string { return "IfBlock" }
|
||||
|
||||
// ElseIfBranch represents an elif branch in conditional
|
||||
type ElseIfBranch struct {
|
||||
Condition string
|
||||
Children []Node
|
||||
}
|
||||
|
||||
// ImportNode represents { s-imp "@alias/file" }
|
||||
type ImportNode struct {
|
||||
Path string // Import path (e.g., "@components/Button")
|
||||
Alias string // Optional alias for the import
|
||||
}
|
||||
|
||||
func (i *ImportNode) NodeType() string { return "Import" }
|
||||
|
||||
// DirectiveType represents the type of directive
|
||||
type DirectiveType int
|
||||
|
||||
const (
|
||||
DirectiveConditional DirectiveType = iota // s-if, s-else-if, s-else
|
||||
DirectiveLoop // s-for
|
||||
DirectiveModel // s-model
|
||||
DirectiveEvent // s-on:event, @event
|
||||
DirectiveBind // :attr
|
||||
DirectiveClient // s-client:load, s-client:visible, etc.
|
||||
DirectiveFetch // s-fetch
|
||||
DirectiveRef // s-ref
|
||||
DirectiveHTML // s-html
|
||||
DirectiveStatic // s-static
|
||||
)
|
||||
|
||||
// Directive represents a Strata directive
|
||||
type Directive struct {
|
||||
Type DirectiveType
|
||||
Name string // Original attribute name
|
||||
Value string // Attribute value
|
||||
Arg string // Argument (e.g., "click" in @click)
|
||||
Modifiers []string // Modifiers (e.g., "prevent" in @click.prevent)
|
||||
}
|
||||
|
||||
// ComponentNode represents a component usage
|
||||
type ComponentNode struct {
|
||||
Name string
|
||||
Props map[string]string
|
||||
Directives []Directive
|
||||
Slots map[string][]Node
|
||||
}
|
||||
|
||||
func (c *ComponentNode) NodeType() string { return "Component" }
|
||||
|
||||
// SlotNode represents a <slot> element
|
||||
type SlotNode struct {
|
||||
Name string // default or named
|
||||
Fallback []Node
|
||||
}
|
||||
|
||||
func (s *SlotNode) NodeType() string { return "Slot" }
|
||||
|
||||
// ImportDeclaration represents an import statement
|
||||
type ImportDeclaration struct {
|
||||
Default string // Default import name
|
||||
Named []string // Named imports
|
||||
Source string // Import source path
|
||||
}
|
||||
|
||||
// ComponentDefinition represents a component's parsed definition
|
||||
type ComponentDefinition struct {
|
||||
Name string
|
||||
Props []PropDefinition
|
||||
Imports []ImportDeclaration
|
||||
Setup string // The setup function body
|
||||
}
|
||||
|
||||
// PropDefinition represents a component prop
|
||||
type PropDefinition struct {
|
||||
Name string
|
||||
Type string
|
||||
Required bool
|
||||
Default string
|
||||
}
|
||||
|
||||
// StoreDefinition represents a parsed store
|
||||
type StoreDefinition struct {
|
||||
Name string
|
||||
State map[string]string // Field name -> type
|
||||
Actions []ActionDefinition
|
||||
Encrypt []string // Fields to encrypt
|
||||
Persist []string // Fields to persist
|
||||
Shared []string // Fields to share across tabs
|
||||
}
|
||||
|
||||
// ActionDefinition represents a store action
|
||||
type ActionDefinition struct {
|
||||
Name string
|
||||
Params []string
|
||||
Body string
|
||||
Async bool
|
||||
}
|
||||
|
||||
// ToHTML converts a TemplateNode to HTML string
|
||||
func (t *TemplateNode) ToHTML() string {
|
||||
var result string
|
||||
for _, child := range t.Children {
|
||||
result += nodeToHTML(child)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// nodeToHTML converts any AST node to HTML
|
||||
func nodeToHTML(node Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *ElementNode:
|
||||
return elementToHTML(n)
|
||||
case *TextNode:
|
||||
return n.Content
|
||||
case *InterpolationNode:
|
||||
// For dev mode, output a placeholder that will be filled by JS
|
||||
return `<span data-strata-bind="` + n.Expression + `"></span>`
|
||||
case *ForBlockNode:
|
||||
return forBlockToHTML(n)
|
||||
case *IfBlockNode:
|
||||
return ifBlockToHTML(n)
|
||||
case *ImportNode:
|
||||
// Imports are handled at compile time, not rendered
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// forBlockToHTML converts a ForBlockNode to HTML with data attributes
|
||||
func forBlockToHTML(f *ForBlockNode) string {
|
||||
html := `<template data-strata-for="` + f.Item
|
||||
if f.Index != "" {
|
||||
html += `, ` + f.Index
|
||||
}
|
||||
html += ` in ` + f.Iterable + `">`
|
||||
for _, child := range f.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
return html
|
||||
}
|
||||
|
||||
// ifBlockToHTML converts an IfBlockNode to HTML with data attributes
|
||||
func ifBlockToHTML(i *IfBlockNode) string {
|
||||
html := `<template data-strata-if="` + i.Condition + `">`
|
||||
for _, child := range i.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
|
||||
// Handle elif branches
|
||||
for _, elif := range i.ElseIf {
|
||||
html += `<template data-strata-elif="` + elif.Condition + `">`
|
||||
for _, child := range elif.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
}
|
||||
|
||||
// Handle else branch
|
||||
if len(i.Else) > 0 {
|
||||
html += `<template data-strata-else>`
|
||||
for _, child := range i.Else {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// elementToHTML converts an ElementNode to HTML
|
||||
func elementToHTML(el *ElementNode) string {
|
||||
if el == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
html := "<" + el.Tag
|
||||
|
||||
// Add attributes
|
||||
for name, value := range el.Attributes {
|
||||
if value != "" {
|
||||
html += ` ` + name + `="` + value + `"`
|
||||
} else {
|
||||
html += ` ` + name
|
||||
}
|
||||
}
|
||||
|
||||
// Add directive attributes for dev mode debugging
|
||||
for _, dir := range el.Directives {
|
||||
html += ` data-strata-` + dir.Name + `="` + dir.Value + `"`
|
||||
}
|
||||
|
||||
html += ">"
|
||||
|
||||
// Add children
|
||||
for _, child := range el.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
|
||||
// Close tag (unless void element)
|
||||
voidElements := map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true,
|
||||
"embed": true, "hr": true, "img": true, "input": true,
|
||||
"link": true, "meta": true, "param": true, "source": true,
|
||||
"track": true, "wbr": true,
|
||||
}
|
||||
|
||||
if !voidElements[el.Tag] {
|
||||
html += "</" + el.Tag + ">"
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
710
compiler/internal/compiler/static.go
Normal file
710
compiler/internal/compiler/static.go
Normal file
@@ -0,0 +1,710 @@
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CarGDev/strata/internal/ast"
|
||||
"github.com/CarGDev/strata/internal/parser"
|
||||
)
|
||||
|
||||
// StaticCompiler compiles .strata files to pure HTML at build time
|
||||
type StaticCompiler struct {
|
||||
projectDir string
|
||||
cache map[string]interface{}
|
||||
}
|
||||
|
||||
// NewStaticCompiler creates a new static compiler
|
||||
func NewStaticCompiler(projectDir string) *StaticCompiler {
|
||||
return &StaticCompiler{
|
||||
projectDir: projectDir,
|
||||
cache: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// CompileModule compiles a module (page or component) to static HTML
|
||||
type CompiledModule struct {
|
||||
Name string
|
||||
Route string
|
||||
HTML string
|
||||
CSS string
|
||||
Scope map[string]interface{}
|
||||
}
|
||||
|
||||
// CompilePage compiles a page directory to static HTML
|
||||
func (c *StaticCompiler) CompilePage(pageDir string) (*CompiledModule, error) {
|
||||
// Find .strata file
|
||||
pageName := filepath.Base(pageDir)
|
||||
strataPath := filepath.Join(pageDir, pageName+".strata")
|
||||
|
||||
// Read and parse .strata template
|
||||
strataContent, err := os.ReadFile(strataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read template: %w", err)
|
||||
}
|
||||
|
||||
p := parser.NewStrataParser(string(strataContent))
|
||||
file, err := p.Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
// Load and execute .compiler.sts to get scope
|
||||
scope, err := c.loadCompilerScope(pageDir, pageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load compiler scope: %w", err)
|
||||
}
|
||||
|
||||
// Resolve template with scope - output clean HTML
|
||||
html := ""
|
||||
if file.Template != nil {
|
||||
html = c.resolveTemplate(file.Template, scope)
|
||||
}
|
||||
|
||||
// Load styles
|
||||
css := ""
|
||||
scssPath := filepath.Join(pageDir, pageName+".scss")
|
||||
if scssContent, err := os.ReadFile(scssPath); err == nil {
|
||||
css = string(scssContent)
|
||||
}
|
||||
|
||||
// Calculate route
|
||||
route := "/" + pageName
|
||||
if pageName == "index" {
|
||||
route = "/"
|
||||
}
|
||||
|
||||
return &CompiledModule{
|
||||
Name: pageName,
|
||||
Route: route,
|
||||
HTML: html,
|
||||
CSS: css,
|
||||
Scope: scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadCompilerScope loads and executes .compiler.sts to get template scope
|
||||
func (c *StaticCompiler) loadCompilerScope(dir, name string) (map[string]interface{}, error) {
|
||||
compilerPath := filepath.Join(dir, name+".compiler.sts")
|
||||
scope := make(map[string]interface{})
|
||||
|
||||
content, err := os.ReadFile(compilerPath)
|
||||
if err != nil {
|
||||
return scope, nil // No compiler file is OK
|
||||
}
|
||||
|
||||
// Parse the TypeScript/JavaScript exports
|
||||
// This is a simplified parser - in production, use a proper JS engine
|
||||
scope = c.parseCompilerExports(string(content))
|
||||
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// parseCompilerExports extracts exported values from .compiler.sts
|
||||
// This is a simplified implementation - production would use V8/QuickJS
|
||||
func (c *StaticCompiler) parseCompilerExports(content string) map[string]interface{} {
|
||||
scope := make(map[string]interface{})
|
||||
|
||||
// First pass: find all export const declarations
|
||||
exportRegex := regexp.MustCompile(`export\s+const\s+(\w+)\s*=\s*`)
|
||||
exportMatches := exportRegex.FindAllStringSubmatchIndex(content, -1)
|
||||
|
||||
for _, match := range exportMatches {
|
||||
if len(match) >= 4 {
|
||||
name := content[match[2]:match[3]]
|
||||
valueStart := match[1]
|
||||
|
||||
// Get the value - could be simple or complex (array/object)
|
||||
afterEquals := content[valueStart:]
|
||||
|
||||
// Check what type of value follows
|
||||
firstChar := ""
|
||||
for _, ch := range afterEquals {
|
||||
if ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r' {
|
||||
firstChar = string(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
switch firstChar {
|
||||
case "[":
|
||||
// Array - need to find matching bracket
|
||||
idx := strings.Index(afterEquals, "[")
|
||||
arrayStr := c.extractBalancedBrackets(afterEquals[idx:], '[', ']')
|
||||
if arrayStr != "" {
|
||||
value = c.parseValue(arrayStr)
|
||||
}
|
||||
case "{":
|
||||
// Object - need to find matching bracket
|
||||
idx := strings.Index(afterEquals, "{")
|
||||
objStr := c.extractBalancedBrackets(afterEquals[idx:], '{', '}')
|
||||
if objStr != "" {
|
||||
value = c.parseValue(objStr)
|
||||
}
|
||||
default:
|
||||
// Simple value - find until semicolon or newline
|
||||
endIdx := strings.Index(afterEquals, ";")
|
||||
if endIdx == -1 {
|
||||
endIdx = strings.Index(afterEquals, "\n")
|
||||
}
|
||||
if endIdx > 0 {
|
||||
value = c.parseValue(strings.TrimSpace(afterEquals[:endIdx]))
|
||||
}
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
scope[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match: export const name = await fetchFunction();
|
||||
// For async values, we need to execute the fetch
|
||||
asyncRegex := regexp.MustCompile(`export\s+const\s+(\w+)\s*=\s*await\s+(\w+)\s*\(\s*\)`)
|
||||
asyncMatches := asyncRegex.FindAllStringSubmatch(content, -1)
|
||||
|
||||
for _, match := range asyncMatches {
|
||||
if len(match) >= 3 {
|
||||
name := match[1]
|
||||
funcName := match[2]
|
||||
|
||||
// Check for known fetch functions
|
||||
if strings.Contains(funcName, "fetch") || strings.Contains(funcName, "Fetch") {
|
||||
// Look for the URL in service file or inline
|
||||
if data := c.executeFetch(content, funcName); data != nil {
|
||||
scope[name] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
// parseValue parses a JavaScript value to Go
|
||||
func (c *StaticCompiler) parseValue(value string) interface{} {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Boolean
|
||||
if value == "true" {
|
||||
return true
|
||||
}
|
||||
if value == "false" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Null
|
||||
if value == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Number
|
||||
if matched, _ := regexp.MatchString(`^-?\d+(\.\d+)?$`, value); matched {
|
||||
var num float64
|
||||
fmt.Sscanf(value, "%f", &num)
|
||||
return num
|
||||
}
|
||||
|
||||
// String (single or double quotes)
|
||||
if (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) ||
|
||||
(strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
|
||||
// Array
|
||||
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
||||
var arr []interface{}
|
||||
// Try to parse as JSON
|
||||
if err := json.Unmarshal([]byte(value), &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
// Simplified array parsing for objects
|
||||
return c.parseArray(value)
|
||||
}
|
||||
|
||||
// Object
|
||||
if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value), &obj); err == nil {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// parseArray parses a JavaScript array
|
||||
func (c *StaticCompiler) parseArray(value string) []interface{} {
|
||||
var result []interface{}
|
||||
|
||||
// Remove brackets
|
||||
inner := strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
|
||||
inner = strings.TrimSpace(inner)
|
||||
|
||||
if inner == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// Convert JS syntax to JSON
|
||||
jsonStr := c.jsToJSON(value)
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &result); err == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// jsToJSON converts JavaScript object/array literals to valid JSON
|
||||
func (c *StaticCompiler) jsToJSON(value string) string {
|
||||
result := value
|
||||
|
||||
// Replace single quotes with double quotes (but not inside strings)
|
||||
// This is a simplified approach - handles most common cases
|
||||
var buf strings.Builder
|
||||
inString := false
|
||||
stringChar := rune(0)
|
||||
prevChar := rune(0)
|
||||
|
||||
for _, ch := range result {
|
||||
if !inString {
|
||||
if ch == '\'' {
|
||||
buf.WriteRune('"')
|
||||
inString = true
|
||||
stringChar = '\''
|
||||
} else if ch == '"' {
|
||||
buf.WriteRune(ch)
|
||||
inString = true
|
||||
stringChar = '"'
|
||||
} else {
|
||||
buf.WriteRune(ch)
|
||||
}
|
||||
} else {
|
||||
if ch == stringChar && prevChar != '\\' {
|
||||
buf.WriteRune('"')
|
||||
inString = false
|
||||
} else {
|
||||
buf.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
prevChar = ch
|
||||
}
|
||||
result = buf.String()
|
||||
|
||||
// Add quotes around unquoted object keys
|
||||
// Match: word followed by colon (but not inside strings or URLs)
|
||||
// Pattern: find keys at start of object or after comma/brace
|
||||
keyRegex := regexp.MustCompile(`([{,\s])(\w+)\s*:`)
|
||||
result = keyRegex.ReplaceAllString(result, `$1"$2":`)
|
||||
|
||||
// Handle arrays of strings like ['grass', 'poison']
|
||||
// Already handled by the single quote conversion above
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// executeFetch executes a fetch operation at build time or parses local function
|
||||
func (c *StaticCompiler) executeFetch(content, funcName string) interface{} {
|
||||
// First, look for a local function definition that returns data
|
||||
// Pattern: async function funcName() { ... return [...] }
|
||||
// or: function funcName() { ... return [...] }
|
||||
funcRegex := regexp.MustCompile(`(?:async\s+)?function\s+` + funcName + `\s*\([^)]*\)\s*\{`)
|
||||
if funcRegex.MatchString(content) {
|
||||
// Find the function body and look for return statement with array/object
|
||||
funcStart := funcRegex.FindStringIndex(content)
|
||||
if funcStart != nil {
|
||||
// Find the return statement with array literal
|
||||
remainder := content[funcStart[1]:]
|
||||
|
||||
// Find "return [" and then match brackets
|
||||
returnIdx := strings.Index(remainder, "return")
|
||||
if returnIdx != -1 {
|
||||
afterReturn := strings.TrimSpace(remainder[returnIdx+6:])
|
||||
if len(afterReturn) > 0 && afterReturn[0] == '[' {
|
||||
// Find the matching closing bracket
|
||||
arrayStr := c.extractBalancedBrackets(afterReturn, '[', ']')
|
||||
if arrayStr != "" {
|
||||
return c.parseValue(arrayStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for API URL patterns for actual network fetch
|
||||
urlRegex := regexp.MustCompile(`(?:fetch|call)\s*\(\s*['"]([^'"]+)['"]`)
|
||||
matches := urlRegex.FindStringSubmatch(content)
|
||||
|
||||
if len(matches) >= 2 {
|
||||
url := matches[1]
|
||||
return c.fetchURL(url)
|
||||
}
|
||||
|
||||
// Only fetch from network if no local data was found
|
||||
// and there's explicit fetch call syntax
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractBalancedBrackets extracts content between balanced brackets
|
||||
func (c *StaticCompiler) extractBalancedBrackets(content string, open, close rune) string {
|
||||
if len(content) == 0 || rune(content[0]) != open {
|
||||
return ""
|
||||
}
|
||||
|
||||
depth := 0
|
||||
inString := false
|
||||
stringChar := rune(0)
|
||||
|
||||
for i, ch := range content {
|
||||
// Handle string literals
|
||||
if !inString && (ch == '\'' || ch == '"' || ch == '`') {
|
||||
inString = true
|
||||
stringChar = ch
|
||||
} else if inString && ch == stringChar {
|
||||
// Check if escaped
|
||||
if i > 0 && content[i-1] != '\\' {
|
||||
inString = false
|
||||
}
|
||||
}
|
||||
|
||||
if !inString {
|
||||
if ch == open {
|
||||
depth++
|
||||
} else if ch == close {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return content[:i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchURL fetches data from a URL at build time
|
||||
func (c *StaticCompiler) fetchURL(url string) interface{} {
|
||||
// Check cache first
|
||||
if cached, ok := c.cache[url]; ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Printf(" Warning: Failed to fetch %s: %v\n", url, err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
c.cache[url] = data
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// resolveTemplate resolves a template with the given scope to clean HTML
|
||||
func (c *StaticCompiler) resolveTemplate(template *ast.TemplateNode, scope map[string]interface{}) string {
|
||||
var result strings.Builder
|
||||
|
||||
for _, child := range template.Children {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// resolveNode resolves a single AST node with the given scope
|
||||
func (c *StaticCompiler) resolveNode(node ast.Node, scope map[string]interface{}) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *ast.ElementNode:
|
||||
return c.resolveElement(n, scope)
|
||||
|
||||
case *ast.TextNode:
|
||||
return n.Content
|
||||
|
||||
case *ast.InterpolationNode:
|
||||
// Resolve the expression and output the value directly
|
||||
value := c.resolveExpression(n.Expression, scope)
|
||||
return fmt.Sprintf("%v", value)
|
||||
|
||||
case *ast.ForBlockNode:
|
||||
return c.resolveForBlock(n, scope)
|
||||
|
||||
case *ast.IfBlockNode:
|
||||
return c.resolveIfBlock(n, scope)
|
||||
|
||||
case *ast.ImportNode:
|
||||
// Imports don't produce output
|
||||
return ""
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// resolveElement resolves an element node
|
||||
func (c *StaticCompiler) resolveElement(el *ast.ElementNode, scope map[string]interface{}) string {
|
||||
var html strings.Builder
|
||||
|
||||
html.WriteString("<")
|
||||
html.WriteString(el.Tag)
|
||||
|
||||
// Add attributes (skip strata directives in output)
|
||||
for name, value := range el.Attributes {
|
||||
// Skip strata-specific attributes in final output
|
||||
if strings.HasPrefix(name, "s-") || strings.HasPrefix(name, "@") {
|
||||
continue
|
||||
}
|
||||
if value != "" {
|
||||
html.WriteString(fmt.Sprintf(` %s="%s"`, name, value))
|
||||
} else {
|
||||
html.WriteString(" " + name)
|
||||
}
|
||||
}
|
||||
|
||||
html.WriteString(">")
|
||||
|
||||
// Resolve children
|
||||
for _, child := range el.Children {
|
||||
html.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
|
||||
// Close tag (unless void element)
|
||||
voidElements := map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true,
|
||||
"embed": true, "hr": true, "img": true, "input": true,
|
||||
"link": true, "meta": true, "param": true, "source": true,
|
||||
"track": true, "wbr": true,
|
||||
}
|
||||
|
||||
if !voidElements[el.Tag] {
|
||||
html.WriteString("</")
|
||||
html.WriteString(el.Tag)
|
||||
html.WriteString(">")
|
||||
}
|
||||
|
||||
return html.String()
|
||||
}
|
||||
|
||||
// resolveForBlock resolves a for loop at build time
|
||||
func (c *StaticCompiler) resolveForBlock(f *ast.ForBlockNode, scope map[string]interface{}) string {
|
||||
var result strings.Builder
|
||||
|
||||
// Get the iterable from scope
|
||||
iterable := c.resolveExpression(f.Iterable, scope)
|
||||
|
||||
// Convert to slice
|
||||
var items []interface{}
|
||||
switch v := iterable.(type) {
|
||||
case []interface{}:
|
||||
items = v
|
||||
case []map[string]interface{}:
|
||||
for _, item := range v {
|
||||
items = append(items, item)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// Check if it has a "results" key (common in APIs like PokeAPI)
|
||||
if results, ok := v["results"]; ok {
|
||||
if arr, ok := results.([]interface{}); ok {
|
||||
items = arr
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Not iterable
|
||||
return ""
|
||||
}
|
||||
|
||||
// Iterate and resolve children
|
||||
for i, item := range items {
|
||||
// Create new scope with loop variables
|
||||
loopScope := make(map[string]interface{})
|
||||
for k, v := range scope {
|
||||
loopScope[k] = v
|
||||
}
|
||||
loopScope[f.Item] = item
|
||||
if f.Index != "" {
|
||||
loopScope[f.Index] = i
|
||||
}
|
||||
|
||||
// Resolve children in loop scope
|
||||
for _, child := range f.Children {
|
||||
result.WriteString(c.resolveNode(child, loopScope))
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// resolveIfBlock resolves a conditional at build time
|
||||
func (c *StaticCompiler) resolveIfBlock(i *ast.IfBlockNode, scope map[string]interface{}) string {
|
||||
// Evaluate the condition
|
||||
if c.evaluateCondition(i.Condition, scope) {
|
||||
var result strings.Builder
|
||||
for _, child := range i.Children {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// Check elif branches
|
||||
for _, elif := range i.ElseIf {
|
||||
if c.evaluateCondition(elif.Condition, scope) {
|
||||
var result strings.Builder
|
||||
for _, child := range elif.Children {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Else branch
|
||||
if len(i.Else) > 0 {
|
||||
var result strings.Builder
|
||||
for _, child := range i.Else {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveExpression resolves an expression against the scope
|
||||
func (c *StaticCompiler) resolveExpression(expr string, scope map[string]interface{}) interface{} {
|
||||
expr = strings.TrimSpace(expr)
|
||||
|
||||
// Direct variable lookup
|
||||
if val, ok := scope[expr]; ok {
|
||||
return val
|
||||
}
|
||||
|
||||
// Property access (e.g., "item.name")
|
||||
if strings.Contains(expr, ".") {
|
||||
parts := strings.Split(expr, ".")
|
||||
current := scope[parts[0]]
|
||||
|
||||
for i := 1; i < len(parts) && current != nil; i++ {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
current = v[parts[i]]
|
||||
default:
|
||||
return expr
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Return the expression as-is if not found
|
||||
return expr
|
||||
}
|
||||
|
||||
// evaluateCondition evaluates a condition expression
|
||||
func (c *StaticCompiler) evaluateCondition(condition string, scope map[string]interface{}) bool {
|
||||
condition = strings.TrimSpace(condition)
|
||||
|
||||
// Simple boolean variable
|
||||
if val, ok := scope[condition]; ok {
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v != ""
|
||||
case int, int64, float64:
|
||||
return v != 0
|
||||
case []interface{}:
|
||||
return len(v) > 0
|
||||
case nil:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Negation
|
||||
if strings.HasPrefix(condition, "!") {
|
||||
return !c.evaluateCondition(condition[1:], scope)
|
||||
}
|
||||
|
||||
// Comparison: ==, !=, >, <, >=, <=
|
||||
for _, op := range []string{"===", "==", "!==", "!=", ">=", "<=", ">", "<"} {
|
||||
if strings.Contains(condition, op) {
|
||||
parts := strings.SplitN(condition, op, 2)
|
||||
if len(parts) == 2 {
|
||||
left := c.resolveExpression(strings.TrimSpace(parts[0]), scope)
|
||||
right := c.resolveExpression(strings.TrimSpace(parts[1]), scope)
|
||||
return c.compare(left, right, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Property access that resolves to truthy
|
||||
val := c.resolveExpression(condition, scope)
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v != "" && v != condition // Not found returns expression itself
|
||||
case nil:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// compare compares two values with the given operator
|
||||
func (c *StaticCompiler) compare(left, right interface{}, op string) bool {
|
||||
switch op {
|
||||
case "==", "===":
|
||||
return fmt.Sprintf("%v", left) == fmt.Sprintf("%v", right)
|
||||
case "!=", "!==":
|
||||
return fmt.Sprintf("%v", left) != fmt.Sprintf("%v", right)
|
||||
case ">":
|
||||
return toFloat(left) > toFloat(right)
|
||||
case "<":
|
||||
return toFloat(left) < toFloat(right)
|
||||
case ">=":
|
||||
return toFloat(left) >= toFloat(right)
|
||||
case "<=":
|
||||
return toFloat(left) <= toFloat(right)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
case string:
|
||||
var f float64
|
||||
fmt.Sscanf(n, "%f", &f)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
164
compiler/internal/generator/html.go
Normal file
164
compiler/internal/generator/html.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// HTMLGenerator generates index.html only in dist folder
|
||||
type HTMLGenerator struct {
|
||||
projectDir string
|
||||
distDir string
|
||||
injector *ScriptInjector
|
||||
encryptionKey []byte
|
||||
}
|
||||
|
||||
// NewHTMLGenerator creates a new HTML generator
|
||||
func NewHTMLGenerator(projectDir, distDir string) *HTMLGenerator {
|
||||
return &HTMLGenerator{
|
||||
projectDir: projectDir,
|
||||
distDir: distDir,
|
||||
injector: NewScriptInjector(projectDir),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates the index.html in dist folder
|
||||
func (hg *HTMLGenerator) Generate(config *BuildConfig) error {
|
||||
// Load injected scripts
|
||||
if err := hg.injector.LoadScripts(); err != nil {
|
||||
return fmt.Errorf("failed to load injected scripts: %w", err)
|
||||
}
|
||||
|
||||
// Generate encryption key at build time
|
||||
hg.encryptionKey = make([]byte, 32)
|
||||
if _, err := rand.Read(hg.encryptionKey); err != nil {
|
||||
return fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Create dist directory
|
||||
if err := os.MkdirAll(hg.distDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dist directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate HTML
|
||||
html, err := hg.buildHTML(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Inject scripts
|
||||
html = hg.injector.InjectIntoHTML(html)
|
||||
|
||||
// Write to dist
|
||||
indexPath := filepath.Join(hg.distDir, "index.html")
|
||||
if err := os.WriteFile(indexPath, []byte(html), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write index.html: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildConfig holds build configuration
|
||||
type BuildConfig struct {
|
||||
Title string
|
||||
Description string
|
||||
BaseURL string
|
||||
APIBaseURL string
|
||||
DevMode bool
|
||||
Assets AssetManifest
|
||||
}
|
||||
|
||||
// AssetManifest tracks generated assets
|
||||
type AssetManifest struct {
|
||||
JS []string
|
||||
CSS []string
|
||||
}
|
||||
|
||||
func (hg *HTMLGenerator) buildHTML(config *BuildConfig) (string, error) {
|
||||
tmpl := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<title>{{.Title}}</title>
|
||||
<base href="{{.BaseURL}}">
|
||||
|
||||
{{range .Assets.CSS}}
|
||||
<link rel="stylesheet" href="{{.}}">
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
// Strata config (build-time injected)
|
||||
window.__STRATA_CONFIG__ = {
|
||||
apiBaseUrl: "{{.APIBaseURL}}",
|
||||
devMode: {{.DevMode}},
|
||||
encryptionKey: [{{.EncryptionKeyArray}}]
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- Strata Runtime -->
|
||||
<script type="module" src="/assets/js/runtime.js"></script>
|
||||
|
||||
{{range .Assets.JS}}
|
||||
<script type="module" src="{{.}}"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Convert encryption key to array format for JS
|
||||
keyArray := make([]string, len(hg.encryptionKey))
|
||||
for i, b := range hg.encryptionKey {
|
||||
keyArray[i] = fmt.Sprintf("%d", b)
|
||||
}
|
||||
keyArrayStr := ""
|
||||
for i, k := range keyArray {
|
||||
if i > 0 {
|
||||
keyArrayStr += ","
|
||||
}
|
||||
keyArrayStr += k
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Title string
|
||||
Description string
|
||||
BaseURL string
|
||||
APIBaseURL string
|
||||
DevMode bool
|
||||
Assets AssetManifest
|
||||
EncryptionKeyArray string
|
||||
}{
|
||||
Title: config.Title,
|
||||
Description: config.Description,
|
||||
BaseURL: config.BaseURL,
|
||||
APIBaseURL: config.APIBaseURL,
|
||||
DevMode: config.DevMode,
|
||||
Assets: config.Assets,
|
||||
EncryptionKeyArray: keyArrayStr,
|
||||
}
|
||||
|
||||
t, err := template.New("html").Parse(tmpl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// GetEncryptionKeyHex returns the encryption key as hex for debugging
|
||||
func (hg *HTMLGenerator) GetEncryptionKeyHex() string {
|
||||
return hex.EncodeToString(hg.encryptionKey)
|
||||
}
|
||||
181
compiler/internal/generator/injector.go
Normal file
181
compiler/internal/generator/injector.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InjectedScript represents a script file to be injected
|
||||
type InjectedScript struct {
|
||||
Name string
|
||||
Content string
|
||||
Position string // "head" or "body"
|
||||
Priority int // Lower = earlier injection
|
||||
}
|
||||
|
||||
// ScriptInjector handles the injectedscripts/ directory
|
||||
type ScriptInjector struct {
|
||||
scriptsDir string
|
||||
scripts []InjectedScript
|
||||
}
|
||||
|
||||
// NewScriptInjector creates a new script injector
|
||||
func NewScriptInjector(projectDir string) *ScriptInjector {
|
||||
return &ScriptInjector{
|
||||
scriptsDir: filepath.Join(projectDir, "src", "injectedscripts"),
|
||||
scripts: make([]InjectedScript, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadScripts loads all scripts from injectedscripts/ directory
|
||||
func (si *ScriptInjector) LoadScripts() error {
|
||||
if _, err := os.Stat(si.scriptsDir); os.IsNotExist(err) {
|
||||
return nil // No injectedscripts directory, that's fine
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(si.scriptsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".js") {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(si.scriptsDir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
script := si.parseScript(name, string(content))
|
||||
si.scripts = append(si.scripts, script)
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
sort.Slice(si.scripts, func(i, j int) bool {
|
||||
return si.scripts[i].Priority < si.scripts[j].Priority
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseScript parses a script file and extracts metadata from comments
|
||||
func (si *ScriptInjector) parseScript(name string, content string) InjectedScript {
|
||||
script := InjectedScript{
|
||||
Name: strings.TrimSuffix(name, ".js"),
|
||||
Content: content,
|
||||
Position: "head", // default to head
|
||||
Priority: 100, // default priority
|
||||
}
|
||||
|
||||
// Parse special comments at the top
|
||||
// /* @position: body */
|
||||
// /* @priority: 10 */
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "/*") && !strings.HasPrefix(line, "//") {
|
||||
break
|
||||
}
|
||||
|
||||
if strings.Contains(line, "@position:") {
|
||||
if strings.Contains(line, "body") {
|
||||
script.Position = "body"
|
||||
} else {
|
||||
script.Position = "head"
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(line, "@priority:") {
|
||||
// Extract priority number
|
||||
parts := strings.Split(line, "@priority:")
|
||||
if len(parts) > 1 {
|
||||
priority := strings.TrimSpace(parts[1])
|
||||
priority = strings.TrimSuffix(priority, "*/")
|
||||
priority = strings.TrimSpace(priority)
|
||||
var p int
|
||||
if _, err := parseIntFromString(priority, &p); err == nil {
|
||||
script.Priority = p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return script
|
||||
}
|
||||
|
||||
// GetHeadScripts returns scripts for <head>
|
||||
func (si *ScriptInjector) GetHeadScripts() []InjectedScript {
|
||||
var result []InjectedScript
|
||||
for _, s := range si.scripts {
|
||||
if s.Position == "head" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetBodyScripts returns scripts for end of <body>
|
||||
func (si *ScriptInjector) GetBodyScripts() []InjectedScript {
|
||||
var result []InjectedScript
|
||||
for _, s := range si.scripts {
|
||||
if s.Position == "body" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// InjectIntoHTML injects scripts into HTML content
|
||||
func (si *ScriptInjector) InjectIntoHTML(html string) string {
|
||||
// Inject head scripts before </head>
|
||||
headScripts := si.GetHeadScripts()
|
||||
if len(headScripts) > 0 {
|
||||
var headContent strings.Builder
|
||||
for _, s := range headScripts {
|
||||
headContent.WriteString("<!-- Injected: ")
|
||||
headContent.WriteString(s.Name)
|
||||
headContent.WriteString(" -->\n")
|
||||
headContent.WriteString(s.Content)
|
||||
headContent.WriteString("\n")
|
||||
}
|
||||
html = strings.Replace(html, "</head>", headContent.String()+"</head>", 1)
|
||||
}
|
||||
|
||||
// Inject body scripts before </body>
|
||||
bodyScripts := si.GetBodyScripts()
|
||||
if len(bodyScripts) > 0 {
|
||||
var bodyContent strings.Builder
|
||||
for _, s := range bodyScripts {
|
||||
bodyContent.WriteString("<!-- Injected: ")
|
||||
bodyContent.WriteString(s.Name)
|
||||
bodyContent.WriteString(" -->\n")
|
||||
bodyContent.WriteString(s.Content)
|
||||
bodyContent.WriteString("\n")
|
||||
}
|
||||
html = strings.Replace(html, "</body>", bodyContent.String()+"</body>", 1)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
func parseIntFromString(s string, target *int) (int, error) {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
*target = n
|
||||
return n, nil
|
||||
}
|
||||
688
compiler/internal/parser/strata.go
Normal file
688
compiler/internal/parser/strata.go
Normal file
@@ -0,0 +1,688 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/CarGDev/strata/internal/ast"
|
||||
)
|
||||
|
||||
// StrataParser parses .strata files
|
||||
type StrataParser struct {
|
||||
source string
|
||||
pos int
|
||||
template *ast.TemplateNode
|
||||
script *ast.ScriptNode
|
||||
style *ast.StyleNode
|
||||
}
|
||||
|
||||
// NewStrataParser creates a new parser
|
||||
func NewStrataParser(source string) *StrataParser {
|
||||
return &StrataParser{
|
||||
source: source,
|
||||
pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse parses a .strata file into AST nodes
|
||||
func (p *StrataParser) Parse() (*ast.StrataFile, error) {
|
||||
file := &ast.StrataFile{}
|
||||
|
||||
// Extract <template>, <script>, and <style> blocks
|
||||
templateContent := p.extractBlock("template")
|
||||
scriptContent := p.extractBlock("script")
|
||||
styleContent := p.extractBlock("style")
|
||||
|
||||
// Parse template
|
||||
if templateContent != "" {
|
||||
template, err := p.parseTemplate(templateContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Template = template
|
||||
}
|
||||
|
||||
// Parse script
|
||||
if scriptContent != "" {
|
||||
script, err := p.parseScript(scriptContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Script = script
|
||||
}
|
||||
|
||||
// Parse style
|
||||
if styleContent != "" {
|
||||
style, err := p.parseStyle(styleContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Style = style
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// extractBlock extracts content between <tag> and </tag>
|
||||
func (p *StrataParser) extractBlock(tag string) string {
|
||||
// Match opening tag with optional attributes
|
||||
openPattern := regexp.MustCompile(`<` + tag + `(?:\s+[^>]*)?>`)
|
||||
closePattern := regexp.MustCompile(`</` + tag + `>`)
|
||||
|
||||
openMatch := openPattern.FindStringIndex(p.source)
|
||||
if openMatch == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
closeMatch := closePattern.FindStringIndex(p.source[openMatch[1]:])
|
||||
if closeMatch == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract content between tags
|
||||
start := openMatch[1]
|
||||
end := openMatch[1] + closeMatch[0]
|
||||
|
||||
return strings.TrimSpace(p.source[start:end])
|
||||
}
|
||||
|
||||
// parseTemplate parses the template content
|
||||
func (p *StrataParser) parseTemplate(content string) (*ast.TemplateNode, error) {
|
||||
template := &ast.TemplateNode{
|
||||
Children: make([]ast.Node, 0),
|
||||
}
|
||||
|
||||
// Parse HTML with Strata directives
|
||||
nodes, err := p.parseHTML(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template.Children = nodes
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// parseHTML parses HTML content into AST nodes
|
||||
func (p *StrataParser) parseHTML(content string) ([]ast.Node, error) {
|
||||
nodes := make([]ast.Node, 0)
|
||||
|
||||
// Simple tokenization - in production, use proper HTML parser
|
||||
pos := 0
|
||||
for pos < len(content) {
|
||||
// Skip whitespace
|
||||
for pos < len(content) && isWhitespace(content[pos]) {
|
||||
pos++
|
||||
}
|
||||
|
||||
if pos >= len(content) {
|
||||
break
|
||||
}
|
||||
|
||||
// Strata block directive { s-* } or variable interpolation { var }
|
||||
if content[pos] == '{' && pos+1 < len(content) && content[pos+1] != '{' {
|
||||
// Find closing }
|
||||
end := strings.Index(content[pos:], "}")
|
||||
if end != -1 {
|
||||
inner := strings.TrimSpace(content[pos+1 : pos+end])
|
||||
|
||||
// Check for block directives
|
||||
if strings.HasPrefix(inner, "s-for ") {
|
||||
// Parse { s-for item in items } ... { /s-for }
|
||||
node, newPos, err := p.parseForBlock(content, pos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
pos = newPos
|
||||
continue
|
||||
} else if strings.HasPrefix(inner, "s-if ") {
|
||||
// Parse { s-if cond } ... { /s-if }
|
||||
node, newPos, err := p.parseIfBlock(content, pos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
pos = newPos
|
||||
continue
|
||||
} else if strings.HasPrefix(inner, "s-imp ") {
|
||||
// Parse { s-imp "@alias/file" }
|
||||
importPath := strings.TrimSpace(inner[6:])
|
||||
importPath = strings.Trim(importPath, `"'`)
|
||||
nodes = append(nodes, &ast.ImportNode{
|
||||
Path: importPath,
|
||||
})
|
||||
pos = pos + end + 1
|
||||
continue
|
||||
} else if strings.HasPrefix(inner, "/s-") || strings.HasPrefix(inner, "s-elif") || inner == "s-else" {
|
||||
// Closing or branch tags - handled by parent parser
|
||||
break
|
||||
} else {
|
||||
// Variable interpolation { var }
|
||||
nodes = append(nodes, &ast.InterpolationNode{
|
||||
Expression: inner,
|
||||
})
|
||||
pos = pos + end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy {{ }} interpolation (for backwards compatibility)
|
||||
if pos+1 < len(content) && content[pos:pos+2] == "{{" {
|
||||
end := strings.Index(content[pos:], "}}")
|
||||
if end != -1 {
|
||||
expr := strings.TrimSpace(content[pos+2 : pos+end])
|
||||
nodes = append(nodes, &ast.InterpolationNode{
|
||||
Expression: expr,
|
||||
})
|
||||
pos = pos + end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// HTML Element
|
||||
if content[pos] == '<' {
|
||||
// Comment
|
||||
if pos+4 < len(content) && content[pos:pos+4] == "<!--" {
|
||||
end := strings.Index(content[pos:], "-->")
|
||||
if end != -1 {
|
||||
pos = pos + end + 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Closing tag
|
||||
if pos+2 < len(content) && content[pos+1] == '/' {
|
||||
end := strings.Index(content[pos:], ">")
|
||||
if end != -1 {
|
||||
pos = pos + end + 1
|
||||
}
|
||||
break // Return to parent
|
||||
}
|
||||
|
||||
// Opening tag
|
||||
node, newPos, err := p.parseElement(content, pos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node != nil {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
pos = newPos
|
||||
continue
|
||||
}
|
||||
|
||||
// Text content
|
||||
textEnd := strings.IndexAny(content[pos:], "<{")
|
||||
if textEnd == -1 {
|
||||
textEnd = len(content) - pos
|
||||
}
|
||||
text := strings.TrimSpace(content[pos : pos+textEnd])
|
||||
if text != "" {
|
||||
nodes = append(nodes, &ast.TextNode{Content: text})
|
||||
}
|
||||
pos = pos + textEnd
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// parseForBlock parses { s-for item in items } ... { /s-for }
|
||||
func (p *StrataParser) parseForBlock(content string, pos int) (*ast.ForBlockNode, int, error) {
|
||||
// Find opening tag end
|
||||
openEnd := strings.Index(content[pos:], "}")
|
||||
if openEnd == -1 {
|
||||
return nil, pos, nil
|
||||
}
|
||||
|
||||
// Parse the for expression: "s-for item in items" or "s-for item, i in items"
|
||||
inner := strings.TrimSpace(content[pos+1 : pos+openEnd])
|
||||
inner = strings.TrimPrefix(inner, "s-for ")
|
||||
inner = strings.TrimSpace(inner)
|
||||
|
||||
// Split by " in "
|
||||
parts := strings.SplitN(inner, " in ", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, pos + openEnd + 1, nil
|
||||
}
|
||||
|
||||
forNode := &ast.ForBlockNode{
|
||||
Iterable: strings.TrimSpace(parts[1]),
|
||||
}
|
||||
|
||||
// Parse item and optional index: "item" or "item, i"
|
||||
itemPart := strings.TrimSpace(parts[0])
|
||||
if strings.Contains(itemPart, ",") {
|
||||
itemParts := strings.SplitN(itemPart, ",", 2)
|
||||
forNode.Item = strings.TrimSpace(itemParts[0])
|
||||
forNode.Index = strings.TrimSpace(itemParts[1])
|
||||
} else {
|
||||
forNode.Item = itemPart
|
||||
}
|
||||
|
||||
// Find content between opening and closing tags
|
||||
startPos := pos + openEnd + 1
|
||||
closeTag := "{ /s-for }"
|
||||
closeTagAlt := "{/s-for}"
|
||||
|
||||
// Search for closing tag
|
||||
closePos := strings.Index(content[startPos:], closeTag)
|
||||
if closePos == -1 {
|
||||
closePos = strings.Index(content[startPos:], closeTagAlt)
|
||||
if closePos == -1 {
|
||||
// Try more lenient matching
|
||||
closePos = p.findClosingTag(content[startPos:], "/s-for")
|
||||
}
|
||||
}
|
||||
|
||||
if closePos == -1 {
|
||||
return nil, pos + openEnd + 1, nil
|
||||
}
|
||||
|
||||
// Parse children
|
||||
childContent := content[startPos : startPos+closePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
forNode.Children = children
|
||||
|
||||
// Calculate new position after closing tag
|
||||
newPos := startPos + closePos
|
||||
// Skip past the closing tag
|
||||
closeEnd := strings.Index(content[newPos:], "}")
|
||||
if closeEnd != -1 {
|
||||
newPos = newPos + closeEnd + 1
|
||||
}
|
||||
|
||||
return forNode, newPos, nil
|
||||
}
|
||||
|
||||
// parseIfBlock parses { s-if cond } ... { s-elif cond } ... { s-else } ... { /s-if }
|
||||
func (p *StrataParser) parseIfBlock(content string, pos int) (*ast.IfBlockNode, int, error) {
|
||||
// Find opening tag end
|
||||
openEnd := strings.Index(content[pos:], "}")
|
||||
if openEnd == -1 {
|
||||
return nil, pos, nil
|
||||
}
|
||||
|
||||
// Parse the if condition
|
||||
inner := strings.TrimSpace(content[pos+1 : pos+openEnd])
|
||||
condition := strings.TrimPrefix(inner, "s-if ")
|
||||
condition = strings.TrimSpace(condition)
|
||||
|
||||
ifNode := &ast.IfBlockNode{
|
||||
Condition: condition,
|
||||
ElseIf: make([]*ast.ElseIfBranch, 0),
|
||||
}
|
||||
|
||||
// Find content and branches
|
||||
startPos := pos + openEnd + 1
|
||||
currentPos := startPos
|
||||
|
||||
// Parse until we hit /s-if, collecting elif and else branches
|
||||
for currentPos < len(content) {
|
||||
// Look for next branch or closing tag
|
||||
nextBrace := strings.Index(content[currentPos:], "{")
|
||||
if nextBrace == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
bracePos := currentPos + nextBrace
|
||||
braceEnd := strings.Index(content[bracePos:], "}")
|
||||
if braceEnd == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
braceContent := strings.TrimSpace(content[bracePos+1 : bracePos+braceEnd])
|
||||
|
||||
if strings.HasPrefix(braceContent, "/s-if") {
|
||||
// Closing tag - parse content up to here
|
||||
childContent := content[startPos:bracePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
|
||||
// Add children to appropriate branch
|
||||
if len(ifNode.ElseIf) == 0 && len(ifNode.Else) == 0 {
|
||||
ifNode.Children = children
|
||||
}
|
||||
|
||||
return ifNode, bracePos + braceEnd + 1, nil
|
||||
} else if strings.HasPrefix(braceContent, "s-elif ") {
|
||||
// Parse content for previous branch
|
||||
childContent := content[startPos:bracePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
|
||||
if len(ifNode.ElseIf) == 0 {
|
||||
ifNode.Children = children
|
||||
} else {
|
||||
ifNode.ElseIf[len(ifNode.ElseIf)-1].Children = children
|
||||
}
|
||||
|
||||
// Add new elif branch
|
||||
elifCond := strings.TrimPrefix(braceContent, "s-elif ")
|
||||
elifCond = strings.TrimSpace(elifCond)
|
||||
ifNode.ElseIf = append(ifNode.ElseIf, &ast.ElseIfBranch{
|
||||
Condition: elifCond,
|
||||
})
|
||||
|
||||
startPos = bracePos + braceEnd + 1
|
||||
currentPos = startPos
|
||||
continue
|
||||
} else if braceContent == "s-else" {
|
||||
// Parse content for previous branch
|
||||
childContent := content[startPos:bracePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
|
||||
if len(ifNode.ElseIf) == 0 {
|
||||
ifNode.Children = children
|
||||
} else {
|
||||
ifNode.ElseIf[len(ifNode.ElseIf)-1].Children = children
|
||||
}
|
||||
|
||||
// Find closing tag and parse else content
|
||||
elseStart := bracePos + braceEnd + 1
|
||||
closePos := p.findClosingTag(content[elseStart:], "/s-if")
|
||||
if closePos != -1 {
|
||||
elseContent := content[elseStart : elseStart+closePos]
|
||||
elseChildren, err := p.parseHTML(elseContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
ifNode.Else = elseChildren
|
||||
|
||||
// Skip to after closing tag
|
||||
closeEnd := strings.Index(content[elseStart+closePos:], "}")
|
||||
if closeEnd != -1 {
|
||||
return ifNode, elseStart + closePos + closeEnd + 1, nil
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
currentPos = bracePos + braceEnd + 1
|
||||
}
|
||||
|
||||
return ifNode, currentPos, nil
|
||||
}
|
||||
|
||||
// findClosingTag finds position of { /tag } allowing for whitespace
|
||||
func (p *StrataParser) findClosingTag(content string, tag string) int {
|
||||
pos := 0
|
||||
for pos < len(content) {
|
||||
bracePos := strings.Index(content[pos:], "{")
|
||||
if bracePos == -1 {
|
||||
return -1
|
||||
}
|
||||
bracePos += pos
|
||||
|
||||
braceEnd := strings.Index(content[bracePos:], "}")
|
||||
if braceEnd == -1 {
|
||||
return -1
|
||||
}
|
||||
|
||||
inner := strings.TrimSpace(content[bracePos+1 : bracePos+braceEnd])
|
||||
if inner == tag || strings.TrimSpace(inner) == tag {
|
||||
return bracePos
|
||||
}
|
||||
|
||||
pos = bracePos + braceEnd + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseElement parses an HTML element
|
||||
func (p *StrataParser) parseElement(content string, pos int) (ast.Node, int, error) {
|
||||
// Find tag name
|
||||
tagStart := pos + 1
|
||||
tagEnd := tagStart
|
||||
for tagEnd < len(content) && !isWhitespace(content[tagEnd]) && content[tagEnd] != '>' && content[tagEnd] != '/' {
|
||||
tagEnd++
|
||||
}
|
||||
|
||||
tagName := content[tagStart:tagEnd]
|
||||
|
||||
element := &ast.ElementNode{
|
||||
Tag: tagName,
|
||||
Attributes: make(map[string]string),
|
||||
Directives: make([]ast.Directive, 0),
|
||||
Children: make([]ast.Node, 0),
|
||||
}
|
||||
|
||||
// Parse attributes
|
||||
attrPos := tagEnd
|
||||
for attrPos < len(content) && content[attrPos] != '>' && content[attrPos] != '/' {
|
||||
// Skip whitespace
|
||||
for attrPos < len(content) && isWhitespace(content[attrPos]) {
|
||||
attrPos++
|
||||
}
|
||||
|
||||
if attrPos >= len(content) || content[attrPos] == '>' || content[attrPos] == '/' {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse attribute name
|
||||
nameStart := attrPos
|
||||
for attrPos < len(content) && !isWhitespace(content[attrPos]) && content[attrPos] != '=' && content[attrPos] != '>' {
|
||||
attrPos++
|
||||
}
|
||||
attrName := content[nameStart:attrPos]
|
||||
|
||||
// Parse attribute value
|
||||
attrValue := ""
|
||||
for attrPos < len(content) && isWhitespace(content[attrPos]) {
|
||||
attrPos++
|
||||
}
|
||||
if attrPos < len(content) && content[attrPos] == '=' {
|
||||
attrPos++
|
||||
for attrPos < len(content) && isWhitespace(content[attrPos]) {
|
||||
attrPos++
|
||||
}
|
||||
if attrPos < len(content) && (content[attrPos] == '"' || content[attrPos] == '\'') {
|
||||
quote := content[attrPos]
|
||||
attrPos++
|
||||
valueStart := attrPos
|
||||
for attrPos < len(content) && content[attrPos] != quote {
|
||||
attrPos++
|
||||
}
|
||||
attrValue = content[valueStart:attrPos]
|
||||
attrPos++
|
||||
}
|
||||
}
|
||||
|
||||
// Check for directives
|
||||
if directive := p.parseDirective(attrName, attrValue); directive != nil {
|
||||
element.Directives = append(element.Directives, *directive)
|
||||
} else {
|
||||
element.Attributes[attrName] = attrValue
|
||||
}
|
||||
}
|
||||
|
||||
// Self-closing tag
|
||||
if attrPos < len(content) && content[attrPos] == '/' {
|
||||
attrPos++
|
||||
for attrPos < len(content) && content[attrPos] != '>' {
|
||||
attrPos++
|
||||
}
|
||||
return element, attrPos + 1, nil
|
||||
}
|
||||
|
||||
// Find closing >
|
||||
for attrPos < len(content) && content[attrPos] != '>' {
|
||||
attrPos++
|
||||
}
|
||||
attrPos++ // Skip >
|
||||
|
||||
// Void elements (no children)
|
||||
voidElements := map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true,
|
||||
"embed": true, "hr": true, "img": true, "input": true,
|
||||
"link": true, "meta": true, "param": true, "source": true,
|
||||
"track": true, "wbr": true,
|
||||
}
|
||||
|
||||
if voidElements[tagName] {
|
||||
return element, attrPos, nil
|
||||
}
|
||||
|
||||
// Parse children
|
||||
childContent := ""
|
||||
depth := 1
|
||||
childStart := attrPos
|
||||
for attrPos < len(content) && depth > 0 {
|
||||
if attrPos+1 < len(content) && content[attrPos] == '<' {
|
||||
if content[attrPos+1] == '/' {
|
||||
// Closing tag
|
||||
closeEnd := strings.Index(content[attrPos:], ">")
|
||||
if closeEnd != -1 {
|
||||
closeTag := content[attrPos+2 : attrPos+closeEnd]
|
||||
closeTag = strings.TrimSpace(closeTag)
|
||||
if closeTag == tagName {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
childContent = content[childStart:attrPos]
|
||||
attrPos = attrPos + closeEnd + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if content[attrPos+1] != '!' {
|
||||
// Opening tag - check if same tag
|
||||
openEnd := attrPos + 1
|
||||
for openEnd < len(content) && !isWhitespace(content[openEnd]) && content[openEnd] != '>' && content[openEnd] != '/' {
|
||||
openEnd++
|
||||
}
|
||||
openTag := content[attrPos+1 : openEnd]
|
||||
if openTag == tagName {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
}
|
||||
attrPos++
|
||||
}
|
||||
|
||||
// Parse children
|
||||
if childContent != "" {
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, attrPos, err
|
||||
}
|
||||
element.Children = children
|
||||
}
|
||||
|
||||
return element, attrPos, nil
|
||||
}
|
||||
|
||||
// parseDirective parses Strata directives
|
||||
func (p *StrataParser) parseDirective(name, value string) *ast.Directive {
|
||||
directive := &ast.Directive{
|
||||
Name: name,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
// s-if, s-else-if, s-else
|
||||
if name == "s-if" || name == "s-else-if" || name == "s-else" {
|
||||
directive.Type = ast.DirectiveConditional
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-for
|
||||
if name == "s-for" {
|
||||
directive.Type = ast.DirectiveLoop
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-model (two-way binding)
|
||||
if name == "s-model" {
|
||||
directive.Type = ast.DirectiveModel
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-on:event or @event (event handlers)
|
||||
if strings.HasPrefix(name, "s-on:") || strings.HasPrefix(name, "@") {
|
||||
directive.Type = ast.DirectiveEvent
|
||||
if strings.HasPrefix(name, "@") {
|
||||
directive.Arg = name[1:]
|
||||
} else {
|
||||
directive.Arg = name[5:]
|
||||
}
|
||||
return directive
|
||||
}
|
||||
|
||||
// :attr (attribute binding)
|
||||
if strings.HasPrefix(name, ":") {
|
||||
directive.Type = ast.DirectiveBind
|
||||
directive.Arg = name[1:]
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-client:* (hydration strategies)
|
||||
if strings.HasPrefix(name, "s-client:") {
|
||||
directive.Type = ast.DirectiveClient
|
||||
directive.Arg = name[9:]
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-fetch (data fetching)
|
||||
if name == "s-fetch" {
|
||||
directive.Type = ast.DirectiveFetch
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-ref
|
||||
if name == "s-ref" {
|
||||
directive.Type = ast.DirectiveRef
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-html (raw HTML)
|
||||
if name == "s-html" {
|
||||
directive.Type = ast.DirectiveHTML
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-static (no JS)
|
||||
if name == "s-static" {
|
||||
directive.Type = ast.DirectiveStatic
|
||||
return directive
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseScript parses the script content
|
||||
func (p *StrataParser) parseScript(content string) (*ast.ScriptNode, error) {
|
||||
return &ast.ScriptNode{
|
||||
Content: content,
|
||||
Lang: "ts", // Default to TypeScript
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseStyle parses the style content
|
||||
func (p *StrataParser) parseStyle(content string) (*ast.StyleNode, error) {
|
||||
// Check for lang attribute in original source
|
||||
langPattern := regexp.MustCompile(`<style\s+lang="([^"]+)"`)
|
||||
matches := langPattern.FindStringSubmatch(p.source)
|
||||
lang := "css"
|
||||
if len(matches) > 1 {
|
||||
lang = matches[1]
|
||||
}
|
||||
|
||||
return &ast.StyleNode{
|
||||
Content: content,
|
||||
Lang: lang,
|
||||
Scoped: strings.Contains(p.source, "<style scoped") || strings.Contains(p.source, "<style lang=\"scss\" scoped"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isWhitespace(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
}
|
||||
922
compiler/internal/server/dev.go
Normal file
922
compiler/internal/server/dev.go
Normal file
@@ -0,0 +1,922 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/CarGDev/strata/internal/ast"
|
||||
"github.com/CarGDev/strata/internal/compiler"
|
||||
"github.com/CarGDev/strata/internal/parser"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// DevServer handles development server with HMR
|
||||
type DevServer struct {
|
||||
port int
|
||||
projectDir string
|
||||
distDir string
|
||||
clients map[*websocket.Conn]bool
|
||||
mu sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
compiler *compiler.StaticCompiler
|
||||
}
|
||||
|
||||
// NewDevServer creates a new dev server
|
||||
func NewDevServer(port int, projectDir string) *DevServer {
|
||||
return &DevServer{
|
||||
port: port,
|
||||
projectDir: projectDir,
|
||||
distDir: filepath.Join(projectDir, ".strata", "dev"),
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins in dev
|
||||
},
|
||||
},
|
||||
compiler: compiler.NewStaticCompiler(projectDir),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the dev server
|
||||
func (s *DevServer) Start() error {
|
||||
// Create dev output directory
|
||||
if err := os.MkdirAll(s.distDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate initial files
|
||||
if err := s.generateDevFiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set up HTTP handlers
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// WebSocket for HMR
|
||||
mux.HandleFunc("/__strata_hmr", s.handleHMR)
|
||||
|
||||
// API proxy (if configured)
|
||||
mux.HandleFunc("/api/", s.handleAPIProxy)
|
||||
|
||||
// Static files from .strata/dev
|
||||
mux.HandleFunc("/", s.handleStatic)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
fmt.Printf("\n Strata Dev Server\n")
|
||||
fmt.Printf(" ─────────────────────────────\n")
|
||||
fmt.Printf(" Local: http://localhost:%d\n", s.port)
|
||||
fmt.Printf(" Network: http://%s:%d\n", getLocalIP(), s.port)
|
||||
fmt.Printf("\n Watching for changes...\n\n")
|
||||
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
// handleStatic serves static files
|
||||
func (s *DevServer) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
|
||||
// Try .strata/dev first
|
||||
filePath := filepath.Join(s.distDir, path)
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
http.ServeFile(w, r, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Try public folder
|
||||
publicPath := filepath.Join(s.projectDir, "public", path)
|
||||
if _, err := os.Stat(publicPath); err == nil {
|
||||
http.ServeFile(w, r, publicPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Try src/assets
|
||||
assetPath := filepath.Join(s.projectDir, "src", "assets", path)
|
||||
if _, err := os.Stat(assetPath); err == nil {
|
||||
http.ServeFile(w, r, assetPath)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback - serve index.html
|
||||
indexPath := filepath.Join(s.distDir, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// handleHMR handles WebSocket connections for HMR
|
||||
func (s *DevServer) handleHMR(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.clients[conn] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.clients, conn)
|
||||
s.mu.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Keep connection alive
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPIProxy proxies API requests
|
||||
func (s *DevServer) handleAPIProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Read API base URL from strataconfig.ts
|
||||
// For now, return 501
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
w.Write([]byte(`{"error": "API proxy not configured"}`))
|
||||
}
|
||||
|
||||
// Rebuild regenerates all dev files (called on file changes)
|
||||
func (s *DevServer) Rebuild() error {
|
||||
return s.generateDevFiles()
|
||||
}
|
||||
|
||||
// NotifyChange sends HMR update to all clients
|
||||
func (s *DevServer) NotifyChange(changeType string, path string) {
|
||||
message := map[string]string{
|
||||
"type": changeType,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(message)
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for client := range s.clients {
|
||||
err := client.WriteMessage(websocket.TextMessage, data)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
delete(s.clients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateDevFiles generates initial dev files
|
||||
func (s *DevServer) generateDevFiles() error {
|
||||
// Generate index.html with HMR client
|
||||
html := s.generateDevHTML()
|
||||
indexPath := filepath.Join(s.distDir, "index.html")
|
||||
if err := os.WriteFile(indexPath, []byte(html), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate runtime.js
|
||||
runtime := s.generateDevRuntime()
|
||||
runtimePath := filepath.Join(s.distDir, "assets", "js", "runtime.js")
|
||||
if err := os.MkdirAll(filepath.Dir(runtimePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(runtimePath, []byte(runtime), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy and process source files
|
||||
return s.processSourceFiles()
|
||||
}
|
||||
|
||||
func (s *DevServer) generateDevHTML() string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Strata | Code Faster</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
window.__STRATA_CONFIG__ = {
|
||||
devMode: true,
|
||||
apiBaseUrl: 'http://localhost:8080'
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
background-color: #0a0a0a;
|
||||
color: #ffffff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ascii-art {
|
||||
white-space: pre;
|
||||
line-height: 1.2;
|
||||
font-size: clamp(0.5rem, 1.5vw, 1rem);
|
||||
color: #3b82f6;
|
||||
text-shadow: 0 0 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.heart-btn {
|
||||
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.heart-btn:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.floating-heart {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
animation: floatUp 1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes floatUp {
|
||||
0% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
100% { transform: translateY(-100px) scale(1.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.strata-error {
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
color: #c00;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module" src="/assets/js/runtime.js"></script>
|
||||
<script type="module" src="/assets/js/app.js"></script>
|
||||
|
||||
<!-- HMR Client -->
|
||||
<script>
|
||||
(function() {
|
||||
const ws = new WebSocket('ws://' + location.host + '/__strata_hmr');
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[Strata HMR]', data.type, data.path);
|
||||
|
||||
if (data.type === 'reload') {
|
||||
location.reload();
|
||||
} else if (data.type === 'css') {
|
||||
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
links.forEach(link => {
|
||||
const url = new URL(link.href);
|
||||
url.searchParams.set('t', Date.now());
|
||||
link.href = url.toString();
|
||||
});
|
||||
} else if (data.type === 'component') {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
console.log('[Strata HMR] Disconnected. Attempting reconnect...');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func (s *DevServer) generateDevRuntime() string {
|
||||
return `// Strata Runtime (Dev Mode)
|
||||
console.log('[Strata] Runtime loaded');
|
||||
|
||||
class Strata {
|
||||
constructor() {
|
||||
this._tabId = 'tab_' + Math.random().toString(36).slice(2, 10);
|
||||
this._stores = new Map();
|
||||
this._cache = new Map();
|
||||
}
|
||||
|
||||
get tabId() {
|
||||
return this._tabId;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('[Strata] Initialized with tabId:', this._tabId);
|
||||
window.__STRATA__ = this;
|
||||
}
|
||||
|
||||
// Simple fetch with caching
|
||||
async fetch(url, options = {}) {
|
||||
const cacheKey = url + JSON.stringify(options);
|
||||
|
||||
if (options.cache !== 'none' && this._cache.has(cacheKey)) {
|
||||
const cached = this._cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < 300000) { // 5 min
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
this._cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
// Broadcast to other tabs (simplified for dev)
|
||||
broadcast(event, data) {
|
||||
console.log('[Strata] Broadcast:', event, data);
|
||||
window.dispatchEvent(new CustomEvent('strata:' + event, { detail: data }));
|
||||
}
|
||||
|
||||
onBroadcast(event, handler) {
|
||||
window.addEventListener('strata:' + event, (e) => handler(e.detail));
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
const strata = new Strata();
|
||||
strata.init();
|
||||
|
||||
export { strata };
|
||||
export default strata;
|
||||
`
|
||||
}
|
||||
|
||||
func (s *DevServer) processSourceFiles() error {
|
||||
// Process .strata files in src/pages
|
||||
pagesDir := filepath.Join(s.projectDir, "src", "pages")
|
||||
if _, err := os.Stat(pagesDir); err != nil {
|
||||
return nil // No pages directory
|
||||
}
|
||||
|
||||
// Find all pages - both directory structure (pages/name/name.strata) and flat (pages/name.strata)
|
||||
var pages []PageInfo
|
||||
processedDirs := make(map[string]bool)
|
||||
|
||||
err := filepath.Walk(pagesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for directory-based pages first (pages/name/name.strata)
|
||||
if info.IsDir() && path != pagesDir {
|
||||
dirName := filepath.Base(path)
|
||||
strataFile := filepath.Join(path, dirName+".strata")
|
||||
compilerFile := filepath.Join(path, dirName+".compiler.sts")
|
||||
|
||||
// If this is a page directory with .strata file
|
||||
if _, err := os.Stat(strataFile); err == nil {
|
||||
processedDirs[path] = true
|
||||
|
||||
// Check if it has a .compiler.sts file (new structure)
|
||||
if _, err := os.Stat(compilerFile); err == nil {
|
||||
// Use static compiler for new structure
|
||||
pageInfo, err := s.compilePageDirectory(path)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to compile %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
pages = append(pages, pageInfo)
|
||||
} else {
|
||||
// Fallback to old compilation for legacy structure
|
||||
pageInfo, err := s.compilePage(strataFile)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to compile %s: %v", strataFile, err)
|
||||
return nil
|
||||
}
|
||||
pages = append(pages, pageInfo)
|
||||
}
|
||||
return filepath.SkipDir // Don't descend further
|
||||
}
|
||||
}
|
||||
|
||||
// Handle flat structure (pages/name.strata)
|
||||
if !info.IsDir() && strings.HasSuffix(path, ".strata") {
|
||||
// Skip if parent dir was already processed as a page directory
|
||||
if processedDirs[filepath.Dir(path)] {
|
||||
return nil
|
||||
}
|
||||
|
||||
pageInfo, err := s.compilePage(path)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to compile %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
pages = append(pages, pageInfo)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate app.js from compiled pages
|
||||
appJS := s.generateAppJSFromPages(pages)
|
||||
appPath := filepath.Join(s.distDir, "assets", "js", "app.js")
|
||||
if err := os.WriteFile(appPath, []byte(appJS), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate combined CSS
|
||||
cssPath := filepath.Join(s.distDir, "assets", "css", "app.css")
|
||||
if err := os.MkdirAll(filepath.Dir(cssPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
css := s.generateCSSFromPages(pages)
|
||||
if err := os.WriteFile(cssPath, []byte(css), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// compilePageDirectory compiles a page from directory structure using static compiler
|
||||
func (s *DevServer) compilePageDirectory(pageDir string) (PageInfo, error) {
|
||||
pageName := filepath.Base(pageDir)
|
||||
|
||||
// Use the static compiler
|
||||
module, err := s.compiler.CompilePage(pageDir)
|
||||
if err != nil {
|
||||
return PageInfo{}, err
|
||||
}
|
||||
|
||||
// Read service file for runtime logic (if exists)
|
||||
serviceContent := ""
|
||||
servicePath := filepath.Join(pageDir, pageName+".service.sts")
|
||||
if content, err := os.ReadFile(servicePath); err == nil {
|
||||
serviceContent = string(content)
|
||||
}
|
||||
|
||||
// Calculate route from directory path
|
||||
relPath, _ := filepath.Rel(filepath.Join(s.projectDir, "src", "pages"), pageDir)
|
||||
route := "/" + relPath
|
||||
if pageName == "index" || relPath == "index" {
|
||||
route = "/"
|
||||
}
|
||||
|
||||
return PageInfo{
|
||||
Name: pageName,
|
||||
Path: pageDir,
|
||||
HTML: module.HTML,
|
||||
Style: module.CSS,
|
||||
Route: route,
|
||||
Compiler: "", // Already resolved in HTML
|
||||
Service: serviceContent,
|
||||
Imports: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PageInfo holds compiled page information
|
||||
type PageInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
HTML string
|
||||
Compiler string // .compiler.sts - variable definitions
|
||||
Service string // .service.sts - business logic
|
||||
Script string // Legacy .sts support
|
||||
Style string
|
||||
Route string
|
||||
Imports []string // Imported components
|
||||
}
|
||||
|
||||
// compilePage compiles a single .strata file and its associated files
|
||||
// File structure:
|
||||
// - page.strata - HTML template
|
||||
// - page.compiler.sts - variable definitions (like Angular component)
|
||||
// - page.service.sts - business logic (like Angular service)
|
||||
// - page.scss - styles
|
||||
func (s *DevServer) compilePage(strataPath string) (PageInfo, error) {
|
||||
info := PageInfo{
|
||||
Path: strataPath,
|
||||
Imports: make([]string, 0),
|
||||
}
|
||||
|
||||
// Get base name without extension
|
||||
baseName := strings.TrimSuffix(filepath.Base(strataPath), ".strata")
|
||||
info.Name = baseName
|
||||
|
||||
// Calculate route from file path
|
||||
relPath, _ := filepath.Rel(filepath.Join(s.projectDir, "src", "pages"), strataPath)
|
||||
route := "/" + strings.TrimSuffix(relPath, ".strata")
|
||||
if strings.HasSuffix(route, "/index") {
|
||||
route = strings.TrimSuffix(route, "/index")
|
||||
if route == "" {
|
||||
route = "/"
|
||||
}
|
||||
}
|
||||
info.Route = route
|
||||
|
||||
// Read and parse .strata file
|
||||
strataContent, err := os.ReadFile(strataPath)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
p := parser.NewStrataParser(string(strataContent))
|
||||
file, err := p.Parse()
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
// Generate HTML from template
|
||||
if file.Template != nil {
|
||||
info.HTML = s.generateHTMLFromTemplate(file.Template)
|
||||
|
||||
// Extract imports from parsed template
|
||||
info.Imports = s.extractImports(file.Template)
|
||||
}
|
||||
|
||||
// Check for .compiler.sts file (variable definitions - Angular-like component)
|
||||
compilerPath := strings.TrimSuffix(strataPath, ".strata") + ".compiler.sts"
|
||||
if compilerContent, err := os.ReadFile(compilerPath); err == nil {
|
||||
info.Compiler = string(compilerContent)
|
||||
}
|
||||
|
||||
// Check for .service.sts file (business logic - Angular-like service)
|
||||
servicePath := strings.TrimSuffix(strataPath, ".strata") + ".service.sts"
|
||||
if serviceContent, err := os.ReadFile(servicePath); err == nil {
|
||||
info.Service = string(serviceContent)
|
||||
}
|
||||
|
||||
// Legacy: Check for .sts file (combined script)
|
||||
stsPath := strings.TrimSuffix(strataPath, ".strata") + ".sts"
|
||||
if stsContent, err := os.ReadFile(stsPath); err == nil {
|
||||
info.Script = string(stsContent)
|
||||
} else if file.Script != nil {
|
||||
info.Script = file.Script.Content
|
||||
}
|
||||
|
||||
// Check for associated .scss file (styles)
|
||||
scssPath := strings.TrimSuffix(strataPath, ".strata") + ".scss"
|
||||
if scssContent, err := os.ReadFile(scssPath); err == nil {
|
||||
info.Style = string(scssContent)
|
||||
} else if file.Style != nil {
|
||||
info.Style = file.Style.Content
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// extractImports extracts import paths from a template's ImportNodes
|
||||
func (s *DevServer) extractImports(template interface{}) []string {
|
||||
imports := make([]string, 0)
|
||||
|
||||
if t, ok := template.(*ast.TemplateNode); ok {
|
||||
for _, child := range t.Children {
|
||||
if imp, ok := child.(*ast.ImportNode); ok {
|
||||
imports = append(imports, imp.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
|
||||
// generateHTMLFromTemplate converts AST template to HTML string
|
||||
func (s *DevServer) generateHTMLFromTemplate(template interface{}) string {
|
||||
if t, ok := template.(interface{ ToHTML() string }); ok {
|
||||
return t.ToHTML()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateAppJSFromPages creates the app.js from compiled pages
|
||||
func (s *DevServer) generateAppJSFromPages(pages []PageInfo) string {
|
||||
if len(pages) == 0 {
|
||||
return s.generateAppJS() // Fallback to default
|
||||
}
|
||||
|
||||
var js strings.Builder
|
||||
js.WriteString(`// Strata App - Static Compilation Output
|
||||
// All templates resolved at build time - no Strata syntax in runtime HTML
|
||||
import { strata } from './runtime.js';
|
||||
|
||||
`)
|
||||
|
||||
// Generate page modules
|
||||
for i, page := range pages {
|
||||
// Escape the HTML for JavaScript
|
||||
escapedHTML := strings.ReplaceAll(page.HTML, "`", "\\`")
|
||||
escapedHTML = strings.ReplaceAll(escapedHTML, "${", "\\${")
|
||||
|
||||
// Check if this is a statically compiled page (Compiler is empty means already resolved)
|
||||
// Pages can still have Service for runtime interactivity
|
||||
isStaticCompiled := page.Compiler == ""
|
||||
|
||||
if isStaticCompiled {
|
||||
// Statically compiled page - HTML is already resolved
|
||||
// May have service file for runtime interactivity
|
||||
if page.Service != "" {
|
||||
// Extract the mount function from service file
|
||||
escapedService := strings.ReplaceAll(page.Service, "`", "\\`")
|
||||
escapedService = strings.ReplaceAll(escapedService, "${", "\\${")
|
||||
|
||||
js.WriteString(fmt.Sprintf(`// Page: %s (statically compiled with runtime service)
|
||||
const page%dService = (function() {
|
||||
%s
|
||||
})();
|
||||
|
||||
const page%d = {
|
||||
name: "%s",
|
||||
route: "%s",
|
||||
render() {
|
||||
return `+"`%s`"+`;
|
||||
},
|
||||
mount() {
|
||||
console.log('[Strata] Static page mounted:', this.name);
|
||||
// Execute service mount if defined
|
||||
if (page%dService && page%dService.mount) {
|
||||
page%dService.mount();
|
||||
} else if (page%dService && page%dService.default && page%dService.default.mount) {
|
||||
page%dService.default.mount();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
`, page.Name, i, escapedService, i, page.Name, page.Route, escapedHTML, i, i, i, i, i, i, i))
|
||||
} else {
|
||||
// Pure static page - no runtime code
|
||||
js.WriteString(fmt.Sprintf(`// Page: %s (statically compiled)
|
||||
const page%d = {
|
||||
name: "%s",
|
||||
route: "%s",
|
||||
render() {
|
||||
return `+"`%s`"+`;
|
||||
},
|
||||
mount() {
|
||||
console.log('[Strata] Static page mounted:', this.name);
|
||||
}
|
||||
};
|
||||
|
||||
`, page.Name, i, page.Name, page.Route, escapedHTML))
|
||||
}
|
||||
} else {
|
||||
// Legacy page with runtime compilation
|
||||
compilerContent := page.Compiler
|
||||
if compilerContent == "" && page.Script != "" {
|
||||
compilerContent = page.Script
|
||||
}
|
||||
if compilerContent == "" {
|
||||
compilerContent = "// No compiler defined\nreturn {};"
|
||||
}
|
||||
|
||||
serviceContent := page.Service
|
||||
if serviceContent == "" {
|
||||
serviceContent = "// No service defined\nreturn {};"
|
||||
}
|
||||
|
||||
js.WriteString(fmt.Sprintf(`// Page: %s (runtime)
|
||||
const page%dCompiler = (function() {
|
||||
%s
|
||||
})();
|
||||
|
||||
const page%dService = (function() {
|
||||
%s
|
||||
})();
|
||||
|
||||
const page%d = {
|
||||
name: "%s",
|
||||
route: "%s",
|
||||
compiler: page%dCompiler,
|
||||
service: page%dService,
|
||||
render() {
|
||||
return `+"`%s`"+`;
|
||||
},
|
||||
mount() {
|
||||
const state = this.compiler.state || this.compiler.default || {};
|
||||
const methods = this.service.methods || this.service.default || {};
|
||||
|
||||
// Bind event handlers
|
||||
document.querySelectorAll('[data-strata-\\@click]').forEach(el => {
|
||||
const handler = el.getAttribute('data-strata-@click');
|
||||
if (methods[handler]) {
|
||||
el.addEventListener('click', (e) => methods[handler].call({ state, methods }, e));
|
||||
}
|
||||
});
|
||||
|
||||
// Bind interpolation placeholders
|
||||
document.querySelectorAll('[data-strata-bind]').forEach(el => {
|
||||
const expr = el.getAttribute('data-strata-bind');
|
||||
const value = expr.split('.').reduce((o, k) => o && o[k], state);
|
||||
if (value !== undefined) el.textContent = value;
|
||||
});
|
||||
|
||||
console.log('[Strata] Page mounted:', this.name);
|
||||
}
|
||||
};
|
||||
|
||||
`, page.Name, i, compilerContent, i, serviceContent,
|
||||
i, page.Name, page.Route, i, i, escapedHTML))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate pages map
|
||||
js.WriteString(`// Pages registry
|
||||
const pages = {
|
||||
`)
|
||||
for i, page := range pages {
|
||||
js.WriteString(fmt.Sprintf(` "%s": page%d,
|
||||
`, page.Route, i))
|
||||
}
|
||||
js.WriteString(`};
|
||||
|
||||
// Router
|
||||
function getRoute() {
|
||||
const path = window.location.pathname || '/';
|
||||
return path === '' ? '/' : path;
|
||||
}
|
||||
|
||||
// Mount application
|
||||
async function mount() {
|
||||
const app = document.getElementById('app');
|
||||
const route = getRoute();
|
||||
const page = pages[route] || pages['/'];
|
||||
|
||||
if (!page) {
|
||||
app.innerHTML = '<div class="strata-error">Page not found: ' + route + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Render HTML
|
||||
app.innerHTML = page.render({});
|
||||
|
||||
// Mount page (bind events, etc)
|
||||
page.mount();
|
||||
|
||||
console.log('[Strata] Mounted:', page.name, 'at', route);
|
||||
} catch (error) {
|
||||
app.innerHTML = '<div class="strata-error">' + error.message + '</div>';
|
||||
console.error('[Strata] Mount error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
mount();
|
||||
|
||||
// Handle navigation
|
||||
window.addEventListener('popstate', mount);
|
||||
`)
|
||||
|
||||
return js.String()
|
||||
}
|
||||
|
||||
// generateCSSFromPages combines all page styles
|
||||
func (s *DevServer) generateCSSFromPages(pages []PageInfo) string {
|
||||
var css strings.Builder
|
||||
|
||||
// Add global styles first
|
||||
globalPath := filepath.Join(s.projectDir, "src", "assets", "styles", "global.scss")
|
||||
if globalContent, err := os.ReadFile(globalPath); err == nil {
|
||||
css.WriteString("/* Global styles */\n")
|
||||
css.WriteString(string(globalContent))
|
||||
css.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Add variables
|
||||
varsPath := filepath.Join(s.projectDir, "src", "assets", "styles", "_variables.scss")
|
||||
if varsContent, err := os.ReadFile(varsPath); err == nil {
|
||||
css.WriteString("/* Variables */\n")
|
||||
css.WriteString(string(varsContent))
|
||||
css.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Add page styles
|
||||
for _, page := range pages {
|
||||
if page.Style != "" {
|
||||
css.WriteString(fmt.Sprintf("/* Page: %s */\n", page.Name))
|
||||
css.WriteString(page.Style)
|
||||
css.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return css.String()
|
||||
}
|
||||
|
||||
func (s *DevServer) generateAppJS() string {
|
||||
return `// Strata App (Dev Mode)
|
||||
import { strata } from './runtime.js';
|
||||
|
||||
async function mount() {
|
||||
const app = document.getElementById('app');
|
||||
|
||||
try {
|
||||
app.innerHTML = ` + "`" + `
|
||||
<!-- Header / Branding -->
|
||||
<div class="mb-8 text-center animate-pulse">
|
||||
<div class="ascii-art mb-4">
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ███████╗████████╗██████╗ █████╗ ████████╗ █████╗ ║
|
||||
║ ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ ║
|
||||
║ ███████╗ ██║ ██████╔╝███████║ ██║ ███████║ ║
|
||||
║ ╚════██║ ██║ ██╔══██╗██╔══██║ ██║ ██╔══██║ ║
|
||||
║ ███████║ ██║ ██║ ██║██║ ██║ ██║ ██║ ██║ ║
|
||||
║ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ║
|
||||
║ ║
|
||||
║ Static Template Rendering Architecture ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-4xl font-bold mb-2">Welcome to <span class="gradient-text">Strata</span></h1>
|
||||
<p class="text-gray-400 text-sm md:text-base">Code Faster. Load Quick. Deploy ASAP.</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Interaction -->
|
||||
<div class="relative flex flex-col items-center">
|
||||
<button id="heartBtn" class="heart-btn bg-white/5 hover:bg-white/10 border border-white/10 rounded-full p-8 mb-4 backdrop-blur-sm shadow-2xl group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-red-500 group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<span id="counter" class="text-5xl font-bold tabular-nums">0</span>
|
||||
<p class="text-gray-500 mt-2 uppercase tracking-widest text-xs">Hearts Captured</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab ID Badge -->
|
||||
<div class="fixed top-4 right-4 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-400">
|
||||
Tab: <code class="text-blue-400">${strata.tabId}</code>
|
||||
</div>
|
||||
|
||||
<!-- Background Decoration -->
|
||||
<div class="fixed bottom-4 text-gray-700 text-[10px] uppercase tracking-tighter opacity-50">
|
||||
STRATA_ENGINE_V1.0 // FAST_PATH_ENABLED // HMR_ACTIVE
|
||||
</div>
|
||||
` + "`" + `;
|
||||
|
||||
// Initialize heart button interaction
|
||||
const btn = document.getElementById('heartBtn');
|
||||
const counterEl = document.getElementById('counter');
|
||||
let count = 0;
|
||||
|
||||
function createHeart(x, y) {
|
||||
const heart = document.createElement('div');
|
||||
heart.className = 'floating-heart text-red-500';
|
||||
heart.innerHTML = '<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>';
|
||||
heart.style.left = x + 'px';
|
||||
heart.style.top = y + 'px';
|
||||
document.body.appendChild(heart);
|
||||
setTimeout(() => heart.remove(), 1000);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
count++;
|
||||
counterEl.innerText = count;
|
||||
createHeart(e.clientX - 12, e.clientY - 12);
|
||||
counterEl.style.transform = 'scale(1.1)';
|
||||
setTimeout(() => counterEl.style.transform = 'scale(1)', 100);
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space' || e.code === 'Enter') btn.click();
|
||||
});
|
||||
|
||||
console.log('[Strata] App mounted with tabId:', strata.tabId);
|
||||
} catch (error) {
|
||||
app.innerHTML = '<div class="strata-error">' + error.message + '</div>';
|
||||
console.error('[Strata] Mount error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
mount();
|
||||
`
|
||||
}
|
||||
|
||||
func getLocalIP() string {
|
||||
// Simplified - return localhost for now
|
||||
return "localhost"
|
||||
}
|
||||
170
compiler/internal/watcher/watcher.go
Normal file
170
compiler/internal/watcher/watcher.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeStrata FileType = ".strata"
|
||||
FileTypeSTS FileType = ".sts"
|
||||
FileTypeSCSS FileType = ".scss"
|
||||
FileTypeTS FileType = ".ts"
|
||||
FileTypeConfig FileType = "strataconfig.ts"
|
||||
)
|
||||
|
||||
type ChangeEvent struct {
|
||||
Path string
|
||||
Type FileType
|
||||
Op fsnotify.Op
|
||||
IsConfig bool
|
||||
}
|
||||
|
||||
type Watcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
srcDir string
|
||||
onChange func(ChangeEvent)
|
||||
debounce time.Duration
|
||||
pending map[string]ChangeEvent
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func New(srcDir string, onChange func(ChangeEvent)) (*Watcher, error) {
|
||||
fsWatcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
watcher: fsWatcher,
|
||||
srcDir: srcDir,
|
||||
onChange: onChange,
|
||||
debounce: 50 * time.Millisecond,
|
||||
pending: make(map[string]ChangeEvent),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) Start() error {
|
||||
// Add src directory recursively
|
||||
err := filepath.Walk(w.srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return w.watcher.Add(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Watch strataconfig.ts in root
|
||||
configPath := filepath.Join(filepath.Dir(w.srcDir), "strataconfig.ts")
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
w.watcher.Add(filepath.Dir(configPath))
|
||||
}
|
||||
|
||||
go w.run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Watcher) run() {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.handleEvent(event)
|
||||
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("Watcher error: %v", err)
|
||||
|
||||
case <-w.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
// Skip non-relevant events
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file type
|
||||
fileType := w.getFileType(event.Name)
|
||||
if fileType == "" {
|
||||
return
|
||||
}
|
||||
|
||||
isConfig := strings.HasSuffix(event.Name, "strataconfig.ts")
|
||||
|
||||
change := ChangeEvent{
|
||||
Path: event.Name,
|
||||
Type: fileType,
|
||||
Op: event.Op,
|
||||
IsConfig: isConfig,
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.pending[event.Name] = change
|
||||
|
||||
// Reset debounce timer
|
||||
if w.timer != nil {
|
||||
w.timer.Stop()
|
||||
}
|
||||
w.timer = time.AfterFunc(w.debounce, w.flush)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *Watcher) flush() {
|
||||
w.mu.Lock()
|
||||
pending := w.pending
|
||||
w.pending = make(map[string]ChangeEvent)
|
||||
w.mu.Unlock()
|
||||
|
||||
for _, event := range pending {
|
||||
w.onChange(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) getFileType(path string) FileType {
|
||||
ext := filepath.Ext(path)
|
||||
switch ext {
|
||||
case ".strata":
|
||||
return FileTypeStrata
|
||||
case ".sts":
|
||||
return FileTypeSTS
|
||||
case ".scss":
|
||||
return FileTypeSCSS
|
||||
case ".ts":
|
||||
if strings.HasSuffix(path, "strataconfig.ts") {
|
||||
return FileTypeConfig
|
||||
}
|
||||
return FileTypeTS
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (w *Watcher) Stop() {
|
||||
close(w.stopCh)
|
||||
w.watcher.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user