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
This commit is contained in:
2025-09-11 14:14:57 +02:00
parent f73e21ce6e
commit ef1d1083ce
12 changed files with 575 additions and 314 deletions

View File

@@ -17,6 +17,8 @@ import (
"github.com/insertr/insertr/internal/db"
"github.com/insertr/insertr/internal/db/postgresql"
"github.com/insertr/insertr/internal/db/sqlite"
"github.com/insertr/insertr/internal/parser"
"golang.org/x/net/html"
)
// ContentHandler handles all content-related HTTP requests
@@ -232,6 +234,16 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
siteID = "default" // final fallback
}
// Determine content ID - use provided ID or generate from element context
contentID := req.ID
if contentID == "" {
if req.ElementContext == nil {
http.Error(w, "Either ID or element_context required", http.StatusBadRequest)
return
}
contentID = h.generateContentID(req.ElementContext)
}
// Extract user from request using authentication service
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if authErr != nil {
@@ -246,7 +258,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
switch h.database.GetDBType() {
case "sqlite3":
content, err = h.database.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{
ID: req.ID,
ID: contentID,
SiteID: siteID,
Value: req.Value,
Type: req.Type,
@@ -254,7 +266,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
})
case "postgresql":
content, err = h.database.GetPostgreSQLQueries().CreateContent(context.Background(), postgresql.CreateContentParams{
ID: req.ID,
ID: contentID,
SiteID: siteID,
Value: req.Value,
Type: req.Type,
@@ -728,3 +740,41 @@ func (h *ContentHandler) versionMatches(version interface{}, contentID, siteID s
}
return false
}
// generateContentID creates a content ID from element context using the parser
func (h *ContentHandler) generateContentID(ctx *ElementContext) string {
// Create virtual node for existing parser ID generation
virtualNode := &html.Node{
Type: html.ElementNode,
Data: ctx.Tag,
Attr: []html.Attribute{
{Key: "class", Val: strings.Join(ctx.Classes, " ")},
},
}
// Add parent context as a virtual parent node if provided
if ctx.ParentContext != "" && ctx.ParentContext != "content" {
parentNode := &html.Node{
Type: html.ElementNode,
Data: "section",
Attr: []html.Attribute{
{Key: "class", Val: ctx.ParentContext},
},
}
parentNode.AppendChild(virtualNode)
virtualNode.Parent = parentNode
}
// Add text content for hash generation
if ctx.OriginalContent != "" {
textNode := &html.Node{
Type: html.TextNode,
Data: ctx.OriginalContent,
}
virtualNode.AppendChild(textNode)
}
// Use existing parser ID generator
idGenerator := parser.NewIDGenerator()
return idGenerator.Generate(virtualNode)
}

View File

@@ -31,13 +31,23 @@ type ContentVersionsResponse struct {
Versions []ContentVersion `json:"versions"`
}
// Element context for backend ID generation
type ElementContext struct {
Tag string `json:"tag"`
Classes []string `json:"classes"`
OriginalContent string `json:"original_content"`
ParentContext string `json:"parent_context"`
Purpose string `json:"purpose"`
}
// Request models
type CreateContentRequest struct {
ID string `json:"id"`
SiteID string `json:"site_id,omitempty"`
Value string `json:"value"`
Type string `json:"type"`
CreatedBy string `json:"created_by,omitempty"`
ID string `json:"id,omitempty"` // For enhanced sites
ElementContext *ElementContext `json:"element_context,omitempty"` // For non-enhanced sites
SiteID string `json:"site_id,omitempty"`
Value string `json:"value"`
Type string `json:"type"`
CreatedBy string `json:"created_by,omitempty"`
}
type UpdateContentRequest struct {