Files
insertr/internal/content/enhancer.go
Joakim ef1d1083ce fix: systematic element matching bug in enhancement pipeline
- Problem: Element ID collisions between similar elements (logo h1 vs hero h1)
  causing content to be injected into wrong elements
- Root cause: Enhancer used naive tag+class matching instead of parser's
  sophisticated semantic analysis for element identification

Systematic solution:
- Enhanced parser architecture with exported utilities (GetClasses, ContainsClass)
- Added FindElementInDocument() with content-based semantic matching
- Replaced naive findAndInjectNodes() with parser-based element matching
- Removed code duplication between parser and enhancer packages

Backend improvements:
- Moved ID generation to backend for single source of truth
- Added ElementContext struct for frontend-backend communication
- Updated API handlers to support context-based content ID generation

Frontend improvements:
- Enhanced getElementMetadata() to extract semantic context
- Updated save flow to handle both enhanced and non-enhanced elements
- Improved API client to use backend-generated content IDs

Result:
- Unique content IDs: navbar-logo-200530 vs hero-title-a1de7b
- Precise element matching using content validation
- Single source of truth for DOM utilities in parser package
- Eliminated 40+ lines of duplicate code while fixing core bug
2025-09-11 14:14:57 +02:00

285 lines
8.0 KiB
Go

package content
import (
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/net/html"
"github.com/insertr/insertr/internal/parser"
)
// Enhancer combines parsing and content injection
type Enhancer struct {
parser *parser.Parser
injector *Injector
}
// NewEnhancer creates a new HTML enhancer
func NewEnhancer(client ContentClient, siteID string) *Enhancer {
return &Enhancer{
parser: parser.New(),
injector: NewInjector(client, siteID),
}
}
// EnhanceFile processes an HTML file and injects content
func (e *Enhancer) EnhanceFile(inputPath, outputPath string) error {
// Use parser to get elements from file
result, err := e.parser.ParseDirectory(filepath.Dir(inputPath))
if err != nil {
return fmt.Errorf("parsing file: %w", err)
}
// Filter elements for this specific file
var fileElements []parser.Element
inputBaseName := filepath.Base(inputPath)
for _, elem := range result.Elements {
elemBaseName := filepath.Base(elem.FilePath)
if elemBaseName == inputBaseName {
fileElements = append(fileElements, elem)
}
}
if len(fileElements) == 0 {
// No insertr elements found, copy file as-is
return e.copyFile(inputPath, outputPath)
}
// Read and parse HTML for modification
htmlContent, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("reading file %s: %w", inputPath, err)
}
doc, err := html.Parse(strings.NewReader(string(htmlContent)))
if err != nil {
return fmt.Errorf("parsing HTML: %w", err)
}
// Find and inject content for each element
for _, elem := range fileElements {
// Find the node in the parsed document
// Note: This is a simplified approach - in production we'd need more robust node matching
if err := e.injectElementContent(doc, elem); err != nil {
fmt.Printf("⚠️ Warning: failed to inject content for %s: %v\n", elem.ContentID, err)
}
}
// Inject editor assets for development
libraryScript := GetLibraryScript(false) // Use non-minified for development debugging
e.injector.InjectEditorAssets(doc, true, libraryScript)
// Write enhanced HTML
if err := e.writeHTML(doc, outputPath); err != nil {
return fmt.Errorf("writing enhanced HTML: %w", err)
}
fmt.Printf("✅ Enhanced: %s → %s (%d elements)\n",
filepath.Base(inputPath),
filepath.Base(outputPath),
len(fileElements))
return nil
}
// injectElementContent finds and injects content for a specific element
func (e *Enhancer) injectElementContent(doc *html.Node, elem parser.Element) error {
// Fetch content from database
contentItem, err := e.injector.client.GetContent(e.injector.siteID, elem.ContentID)
if err != nil {
return fmt.Errorf("fetching content: %w", err)
}
// Find nodes with insertr class and inject content
e.findAndInjectNodes(doc, elem, contentItem)
return nil
}
// findAndInjectNodes finds the specific node for this element and injects content
func (e *Enhancer) findAndInjectNodes(rootNode *html.Node, elem parser.Element, contentItem *ContentItem) {
// Use parser-based element matching to find the correct specific node
targetNode := e.findNodeInDocument(rootNode, elem)
if targetNode == nil {
// Element not found - this is normal for elements without content in database
return
}
// Inject content attributes for the correctly matched node
e.injector.addContentAttributes(targetNode, elem.ContentID, string(elem.Type))
// Inject content if available
if contentItem != nil {
switch elem.Type {
case parser.ContentText:
e.injector.injectTextContent(targetNode, contentItem.Value)
case parser.ContentMarkdown:
e.injector.injectMarkdownContent(targetNode, contentItem.Value)
case parser.ContentLink:
e.injector.injectLinkContent(targetNode, contentItem.Value)
}
}
}
// Helper functions are now provided by the parser package
// EnhanceDirectory processes all HTML files in a directory
func (e *Enhancer) EnhanceDirectory(inputDir, outputDir string) error {
// Create output directory
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
// Walk input directory
return filepath.Walk(inputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Calculate relative path and output path
relPath, err := filepath.Rel(inputDir, path)
if err != nil {
return err
}
outputPath := filepath.Join(outputDir, relPath)
// Handle directories
if info.IsDir() {
return os.MkdirAll(outputPath, info.Mode())
}
// Handle HTML files
if strings.HasSuffix(strings.ToLower(path), ".html") {
return e.EnhanceFile(path, outputPath)
}
// Copy other files as-is
return e.copyFile(path, outputPath)
})
}
// copyFile copies a file from src to dst
func (e *Enhancer) copyFile(src, dst string) error {
// Create directory for destination
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
// Read source
data, err := os.ReadFile(src)
if err != nil {
return err
}
// Write destination
return os.WriteFile(dst, data, 0644)
}
// writeHTML writes an HTML document to a file
func (e *Enhancer) writeHTML(doc *html.Node, outputPath string) error {
// Create directory for output
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return err
}
// Create output file
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
// Write HTML
return html.Render(file, doc)
}
// EnhanceInPlace performs in-place enhancement of static site files
func (e *Enhancer) EnhanceInPlace(sitePath string, siteID string) error {
// Update the injector with the correct siteID
e.injector.siteID = siteID
// Use existing parser logic to discover elements
result, err := e.parser.ParseDirectory(sitePath)
if err != nil {
return fmt.Errorf("parsing directory: %w", err)
}
if len(result.Elements) == 0 {
fmt.Printf("📄 No insertr elements found in %s\n", sitePath)
return nil
}
// Group elements by file for efficient processing
fileElements := make(map[string][]parser.Element)
for _, elem := range result.Elements {
fileElements[elem.FilePath] = append(fileElements[elem.FilePath], elem)
}
// Process each file in-place
enhancedCount := 0
for filePath, elements := range fileElements {
if err := e.enhanceFileInPlace(filePath, elements); err != nil {
fmt.Printf("⚠️ Failed to enhance %s: %v\n", filepath.Base(filePath), err)
} else {
enhancedCount++
}
}
fmt.Printf("✅ Enhanced %d files with %d elements in site %s\n",
enhancedCount, len(result.Elements), siteID)
return nil
}
// enhanceFileInPlace modifies an HTML file in-place with database content
func (e *Enhancer) enhanceFileInPlace(filePath string, elements []parser.Element) error {
// Read original file
htmlContent, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("reading file: %w", err)
}
// Parse HTML
doc, err := html.Parse(strings.NewReader(string(htmlContent)))
if err != nil {
return fmt.Errorf("parsing HTML: %w", err)
}
// Convert parser elements to injector format with content IDs
elementIDs := make([]ElementWithID, 0, len(elements))
for _, elem := range elements {
// Find the corresponding node in the parsed document
node := e.findNodeInDocument(doc, elem)
if node != nil {
elementIDs = append(elementIDs, ElementWithID{
Element: &Element{
Node: node,
Type: string(elem.Type),
Tag: elem.Tag,
},
ContentID: elem.ContentID,
})
}
}
// Use existing bulk injection logic for efficiency
if len(elementIDs) > 0 {
if err := e.injector.InjectBulkContent(elementIDs); err != nil {
return fmt.Errorf("injecting content: %w", err)
}
}
// Write enhanced HTML back to the same file (in-place update)
return e.writeHTML(doc, filePath)
}
// findNodeInDocument finds a specific node in the HTML document tree using parser utilities
func (e *Enhancer) findNodeInDocument(doc *html.Node, elem parser.Element) *html.Node {
// Use parser's sophisticated matching logic
return parser.FindElementInDocument(doc, elem)
}
// All element matching functions are now provided by the parser package