- Add backend container transformation in engine.go following syntactic sugar specification - Containers with .insertr get class removed and viable children get .insertr added - Remove incorrect frontend container expansion - frontend only finds enhanced elements - Fix StyleAwareEditor hasMultiPropertyElements runtime error - Add addClass/removeClass methods to ContentEngine for class manipulation - Update frontend to match HTML-first approach with no runtime container logic - Test verified: container <section class='insertr'> transforms to individual h1.insertr, p.insertr, button.insertr This completes the container expansion functionality per CLASSES.md: Developer convenience (one .insertr enables section editing) + granular control (individual element editing)
345 lines
9.7 KiB
Go
345 lines
9.7 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// AuthProvider represents authentication provider information
|
|
type AuthProvider struct {
|
|
Type string // "mock", "jwt", "authentik"
|
|
}
|
|
|
|
// ContentEngine is the unified content processing engine
|
|
type ContentEngine struct {
|
|
idGenerator *IDGenerator
|
|
client ContentClient
|
|
authProvider *AuthProvider
|
|
injector *Injector
|
|
}
|
|
|
|
// NewContentEngine creates a new content processing engine
|
|
func NewContentEngine(client ContentClient) *ContentEngine {
|
|
authProvider := &AuthProvider{Type: "mock"} // default
|
|
return &ContentEngine{
|
|
idGenerator: NewIDGenerator(),
|
|
client: client,
|
|
authProvider: authProvider,
|
|
injector: NewInjector(client, ""), // siteID will be set per operation
|
|
}
|
|
}
|
|
|
|
// NewContentEngineWithAuth creates a new content processing engine with auth config
|
|
func NewContentEngineWithAuth(client ContentClient, authProvider *AuthProvider) *ContentEngine {
|
|
if authProvider == nil {
|
|
authProvider = &AuthProvider{Type: "mock"}
|
|
}
|
|
return &ContentEngine{
|
|
idGenerator: NewIDGenerator(),
|
|
client: client,
|
|
authProvider: authProvider,
|
|
injector: NewInjectorWithAuth(client, "", authProvider), // siteID will be set per operation
|
|
}
|
|
}
|
|
|
|
// ProcessContent processes HTML content according to the specified mode
|
|
func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, error) {
|
|
// 1. Parse HTML
|
|
doc, err := html.Parse(strings.NewReader(string(input.HTML)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing HTML: %w", err)
|
|
}
|
|
|
|
// 2. Find insertr elements
|
|
elements := e.findInsertrElements(doc)
|
|
|
|
// 3. Generate IDs for elements
|
|
generatedIDs := make(map[string]string)
|
|
processedElements := make([]ProcessedElement, len(elements))
|
|
|
|
for i, elem := range elements {
|
|
// Generate structural ID (always deterministic)
|
|
id := e.idGenerator.Generate(elem.Node, input.FilePath)
|
|
|
|
// Database-first approach: Check if content already exists
|
|
existingContent, err := e.client.GetContent(input.SiteID, id)
|
|
contentExists := (err == nil && existingContent != nil)
|
|
|
|
generatedIDs[fmt.Sprintf("element_%d", i)] = id
|
|
|
|
processedElements[i] = ProcessedElement{
|
|
Node: elem.Node,
|
|
ID: id,
|
|
Generated: !contentExists, // Mark as generated only if new to database
|
|
Tag: elem.Node.Data,
|
|
Classes: GetClasses(elem.Node),
|
|
}
|
|
|
|
// Add/update content attributes to the node (only content-id now)
|
|
e.addContentAttributes(elem.Node, id)
|
|
|
|
// Store content only for truly new elements (database-first check)
|
|
if !contentExists && (input.Mode == Enhancement || input.Mode == ContentInjection) {
|
|
// Extract content and template from the unprocessed element
|
|
htmlContent := e.extractHTMLContent(elem.Node)
|
|
originalTemplate := e.extractOriginalTemplate(elem.Node)
|
|
|
|
// Store in database via content client
|
|
_, err := e.client.CreateContent(input.SiteID, id, htmlContent, originalTemplate, "system")
|
|
if err != nil {
|
|
// Log error but don't fail the enhancement - content just won't be stored
|
|
fmt.Printf("⚠️ Failed to store content for %s: %v\n", id, err)
|
|
} else {
|
|
fmt.Printf("✅ Created new content: %s (html)\n", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Inject content if required by mode
|
|
if input.Mode == Enhancement || input.Mode == ContentInjection {
|
|
err = e.injectContent(processedElements, input.SiteID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("injecting content: %w", err)
|
|
}
|
|
}
|
|
|
|
// 5. Inject editor assets for enhancement mode (development)
|
|
if input.Mode == Enhancement {
|
|
injector := NewInjectorWithAuth(e.client, input.SiteID, e.authProvider)
|
|
injector.InjectEditorAssets(doc, true, "")
|
|
}
|
|
|
|
return &ContentResult{
|
|
Document: doc,
|
|
Elements: processedElements,
|
|
GeneratedIDs: generatedIDs,
|
|
}, nil
|
|
}
|
|
|
|
// InsertrElement represents an insertr element found in HTML
|
|
type InsertrElement struct {
|
|
Node *html.Node
|
|
}
|
|
|
|
// findInsertrElements finds all elements with class="insertr" and applies container transformation
|
|
// This implements the "syntactic sugar transformation" from CLASSES.md:
|
|
// - Containers with .insertr get their .insertr class removed
|
|
// - Viable children of those containers get .insertr class added
|
|
// - Regular elements with .insertr are kept as-is
|
|
func (e *ContentEngine) findInsertrElements(doc *html.Node) []InsertrElement {
|
|
var elements []InsertrElement
|
|
var containersToTransform []*html.Node
|
|
|
|
// First pass: find all .insertr elements and identify containers
|
|
e.walkNodes(doc, func(n *html.Node) {
|
|
if n.Type == html.ElementNode && e.hasInsertrClass(n) {
|
|
if isContainer(n) {
|
|
// Container element - mark for transformation
|
|
containersToTransform = append(containersToTransform, n)
|
|
} else {
|
|
// Regular element - add directly
|
|
elements = append(elements, InsertrElement{
|
|
Node: n,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Second pass: transform containers (remove .insertr from container, add to children)
|
|
for _, container := range containersToTransform {
|
|
// Remove .insertr class from container
|
|
e.removeClass(container, "insertr")
|
|
|
|
// Find viable children and add .insertr class to them
|
|
viableChildren := FindViableChildren(container)
|
|
for _, child := range viableChildren {
|
|
e.addClass(child, "insertr")
|
|
elements = append(elements, InsertrElement{
|
|
Node: child,
|
|
})
|
|
}
|
|
}
|
|
|
|
return elements
|
|
}
|
|
|
|
// walkNodes walks through all nodes in the document
|
|
func (e *ContentEngine) walkNodes(n *html.Node, fn func(*html.Node)) {
|
|
fn(n)
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
e.walkNodes(c, fn)
|
|
}
|
|
}
|
|
|
|
// hasInsertrClass checks if node has class="insertr"
|
|
func (e *ContentEngine) hasInsertrClass(node *html.Node) bool {
|
|
classes := GetClasses(node)
|
|
for _, class := range classes {
|
|
if class == "insertr" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// addContentAttributes adds data-content-id attribute only
|
|
// HTML-first approach: no content-type attribute needed
|
|
func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string) {
|
|
// Add data-content-id attribute
|
|
e.setAttribute(node, "data-content-id", contentID)
|
|
}
|
|
|
|
// getAttribute gets an attribute value from an HTML node
|
|
func (e *ContentEngine) getAttribute(node *html.Node, key string) string {
|
|
for _, attr := range node.Attr {
|
|
if attr.Key == key {
|
|
return attr.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// setAttribute sets an attribute on an HTML node
|
|
func (e *ContentEngine) setAttribute(node *html.Node, key, value string) {
|
|
// Remove existing attribute if it exists
|
|
for i, attr := range node.Attr {
|
|
if attr.Key == key {
|
|
node.Attr[i].Val = value
|
|
return
|
|
}
|
|
}
|
|
// Add new attribute
|
|
node.Attr = append(node.Attr, html.Attribute{
|
|
Key: key,
|
|
Val: value,
|
|
})
|
|
}
|
|
|
|
// addClass safely adds a class to an HTML node
|
|
func (e *ContentEngine) addClass(node *html.Node, className string) {
|
|
var classAttr *html.Attribute
|
|
var classIndex int = -1
|
|
|
|
// Find existing class attribute
|
|
for idx, attr := range node.Attr {
|
|
if attr.Key == "class" {
|
|
classAttr = &attr
|
|
classIndex = idx
|
|
break
|
|
}
|
|
}
|
|
|
|
var classes []string
|
|
if classAttr != nil {
|
|
classes = strings.Fields(classAttr.Val)
|
|
}
|
|
|
|
// Check if class already exists
|
|
for _, class := range classes {
|
|
if class == className {
|
|
return // Class already exists
|
|
}
|
|
}
|
|
|
|
// Add new class
|
|
classes = append(classes, className)
|
|
newClassValue := strings.Join(classes, " ")
|
|
|
|
if classIndex >= 0 {
|
|
// Update existing class attribute
|
|
node.Attr[classIndex].Val = newClassValue
|
|
} else {
|
|
// Add new class attribute
|
|
node.Attr = append(node.Attr, html.Attribute{
|
|
Key: "class",
|
|
Val: newClassValue,
|
|
})
|
|
}
|
|
}
|
|
|
|
// removeClass safely removes a class from an HTML node
|
|
func (e *ContentEngine) removeClass(node *html.Node, className string) {
|
|
var classIndex int = -1
|
|
|
|
// Find existing class attribute
|
|
for idx, attr := range node.Attr {
|
|
if attr.Key == "class" {
|
|
classIndex = idx
|
|
break
|
|
}
|
|
}
|
|
|
|
if classIndex == -1 {
|
|
return // No class attribute found
|
|
}
|
|
|
|
// Parse existing classes
|
|
classes := strings.Fields(node.Attr[classIndex].Val)
|
|
|
|
// Filter out the target class
|
|
var newClasses []string
|
|
for _, class := range classes {
|
|
if class != className {
|
|
newClasses = append(newClasses, class)
|
|
}
|
|
}
|
|
|
|
// Update or remove class attribute
|
|
if len(newClasses) == 0 {
|
|
// Remove class attribute entirely if no classes remain
|
|
node.Attr = append(node.Attr[:classIndex], node.Attr[classIndex+1:]...)
|
|
} else {
|
|
// Update class attribute with remaining classes
|
|
node.Attr[classIndex].Val = strings.Join(newClasses, " ")
|
|
}
|
|
}
|
|
|
|
// injectContent injects content from database into elements
|
|
func (e *ContentEngine) injectContent(elements []ProcessedElement, siteID string) error {
|
|
for i := range elements {
|
|
elem := &elements[i]
|
|
|
|
// Try to get content from database
|
|
contentItem, err := e.client.GetContent(siteID, elem.ID)
|
|
if err != nil {
|
|
// Content not found is not an error - element just won't have injected content
|
|
continue
|
|
}
|
|
|
|
if contentItem != nil {
|
|
// Inject the content into the element
|
|
elem.Content = contentItem.HTMLContent
|
|
|
|
// Update injector siteID for this operation
|
|
e.injector.siteID = siteID
|
|
e.injector.injectHTMLContent(elem.Node, contentItem.HTMLContent)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractHTMLContent extracts the inner HTML content from a node
|
|
func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
|
|
var content strings.Builder
|
|
|
|
// Render all child nodes in order to preserve HTML structure
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if err := html.Render(&content, child); err == nil {
|
|
// All nodes (text and element) rendered in correct order
|
|
}
|
|
}
|
|
|
|
return strings.TrimSpace(content.String())
|
|
}
|
|
|
|
// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
|
|
func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
|
|
var buf strings.Builder
|
|
if err := html.Render(&buf, node); err != nil {
|
|
return ""
|
|
}
|
|
return buf.String()
|
|
}
|