feat: implement unified content engine to eliminate ID generation inconsistencies
- Create internal/engine module as single source of truth for content processing - Consolidate 4 separate ID generation systems into one unified engine - Update API handlers to use engine for consistent server-side ID generation - Remove frontend client-side ID generation, delegate to server engine - Ensure identical HTML markup + file path produces identical content IDs - Resolve content persistence failures caused by ID fragmentation between manual editing and enhancement processes
This commit is contained in:
@@ -19,8 +19,7 @@ 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"
|
||||
"github.com/insertr/insertr/internal/engine"
|
||||
)
|
||||
|
||||
// ContentHandler handles all content-related HTTP requests
|
||||
@@ -28,14 +27,19 @@ type ContentHandler struct {
|
||||
database *db.Database
|
||||
authService *auth.AuthService
|
||||
siteManager *content.SiteManager
|
||||
engine *engine.ContentEngine
|
||||
}
|
||||
|
||||
// NewContentHandler creates a new content handler
|
||||
func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler {
|
||||
// Create database client for engine
|
||||
dbClient := engine.NewDatabaseClient(database)
|
||||
|
||||
return &ContentHandler{
|
||||
database: database,
|
||||
authService: authService,
|
||||
siteManager: nil, // Will be set via SetSiteManager
|
||||
engine: engine.NewContentEngine(dbClient),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,16 +240,31 @@ 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)
|
||||
// Generate content ID using the unified engine
|
||||
if req.HTMLMarkup == "" {
|
||||
http.Error(w, "html_markup is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, engineErr := h.engine.ProcessContent(engine.ContentInput{
|
||||
HTML: []byte(req.HTMLMarkup),
|
||||
FilePath: req.FilePath,
|
||||
SiteID: siteID,
|
||||
Mode: engine.IDGeneration,
|
||||
})
|
||||
if engineErr != nil {
|
||||
http.Error(w, fmt.Sprintf("ID generation failed: %v", engineErr), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(result.Elements) == 0 {
|
||||
http.Error(w, "No insertr elements found in HTML markup", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the ID generated by the engine for the first element
|
||||
contentID := result.Elements[0].ID
|
||||
|
||||
// Extract user from request using authentication service
|
||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||
if authErr != nil {
|
||||
@@ -681,44 +700,7 @@ 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
|
||||
// For API-generated IDs, use a placeholder filePath since we don't have file context
|
||||
idGenerator := parser.NewIDGenerator()
|
||||
return idGenerator.Generate(virtualNode, "api-generated")
|
||||
}
|
||||
// generateContentID function removed - using unified ContentEngine instead
|
||||
|
||||
// ServeInsertrJS handles GET /insertr.js - serves the insertr JavaScript library
|
||||
func (h *ContentHandler) ServeInsertrJS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -42,12 +42,12 @@ type ElementContext struct {
|
||||
|
||||
// Request models
|
||||
type CreateContentRequest struct {
|
||||
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"`
|
||||
HTMLMarkup string `json:"html_markup"` // HTML markup of the element
|
||||
FilePath string `json:"file_path"` // File path for consistent ID generation
|
||||
Value string `json:"value"` // Content value
|
||||
Type string `json:"type"` // Content type
|
||||
SiteID string `json:"site_id,omitempty"` // Site identifier
|
||||
CreatedBy string `json:"created_by,omitempty"` // User who created the content
|
||||
}
|
||||
|
||||
type RollbackContentRequest struct {
|
||||
|
||||
112
internal/engine/database_client.go
Normal file
112
internal/engine/database_client.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/insertr/insertr/internal/db"
|
||||
"github.com/insertr/insertr/internal/db/postgresql"
|
||||
"github.com/insertr/insertr/internal/db/sqlite"
|
||||
)
|
||||
|
||||
// DatabaseClient implements ContentClient interface using the database
|
||||
type DatabaseClient struct {
|
||||
database *db.Database
|
||||
}
|
||||
|
||||
// NewDatabaseClient creates a new database client
|
||||
func NewDatabaseClient(database *db.Database) *DatabaseClient {
|
||||
return &DatabaseClient{
|
||||
database: database,
|
||||
}
|
||||
}
|
||||
|
||||
// GetContent retrieves a single content item
|
||||
func (c *DatabaseClient) GetContent(siteID, contentID string) (*ContentItem, error) {
|
||||
switch c.database.GetDBType() {
|
||||
case "sqlite3":
|
||||
content, err := c.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ContentItem{
|
||||
ID: content.ID,
|
||||
SiteID: content.SiteID,
|
||||
Value: content.Value,
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}, nil
|
||||
|
||||
case "postgresql":
|
||||
content, err := c.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ContentItem{
|
||||
ID: content.ID,
|
||||
SiteID: content.SiteID,
|
||||
Value: content.Value,
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", c.database.GetDBType())
|
||||
}
|
||||
}
|
||||
|
||||
// GetBulkContent retrieves multiple content items
|
||||
func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) ([]*ContentItem, error) {
|
||||
switch c.database.GetDBType() {
|
||||
case "sqlite3":
|
||||
contents, err := c.database.GetSQLiteQueries().GetBulkContent(context.Background(), sqlite.GetBulkContentParams{
|
||||
SiteID: siteID,
|
||||
Ids: contentIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]*ContentItem, len(contents))
|
||||
for i, content := range contents {
|
||||
items[i] = &ContentItem{
|
||||
ID: content.ID,
|
||||
SiteID: content.SiteID,
|
||||
Value: content.Value,
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
|
||||
case "postgresql":
|
||||
contents, err := c.database.GetPostgreSQLQueries().GetBulkContent(context.Background(), postgresql.GetBulkContentParams{
|
||||
SiteID: siteID,
|
||||
Ids: contentIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]*ContentItem, len(contents))
|
||||
for i, content := range contents {
|
||||
items[i] = &ContentItem{
|
||||
ID: content.ID,
|
||||
SiteID: content.SiteID,
|
||||
Value: content.Value,
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", c.database.GetDBType())
|
||||
}
|
||||
}
|
||||
190
internal/engine/engine.go
Normal file
190
internal/engine/engine.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ContentEngine is the unified content processing engine
|
||||
type ContentEngine struct {
|
||||
idGenerator *IDGenerator
|
||||
client ContentClient
|
||||
}
|
||||
|
||||
// NewContentEngine creates a new content processing engine
|
||||
func NewContentEngine(client ContentClient) *ContentEngine {
|
||||
return &ContentEngine{
|
||||
idGenerator: NewIDGenerator(),
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ID using the same algorithm as the parser
|
||||
id := e.idGenerator.Generate(elem.Node, input.FilePath)
|
||||
generatedIDs[fmt.Sprintf("element_%d", i)] = id
|
||||
|
||||
processedElements[i] = ProcessedElement{
|
||||
Node: elem.Node,
|
||||
ID: id,
|
||||
Type: elem.Type,
|
||||
Generated: true,
|
||||
Tag: elem.Node.Data,
|
||||
Classes: GetClasses(elem.Node),
|
||||
}
|
||||
|
||||
// Add content attributes to the node
|
||||
e.addContentAttributes(elem.Node, id, elem.Type)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
return &ContentResult{
|
||||
Document: doc,
|
||||
Elements: processedElements,
|
||||
GeneratedIDs: generatedIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InsertrElement represents an insertr element found in HTML
|
||||
type InsertrElement struct {
|
||||
Node *html.Node
|
||||
Type string
|
||||
}
|
||||
|
||||
// findInsertrElements finds all elements with class="insertr"
|
||||
func (e *ContentEngine) findInsertrElements(doc *html.Node) []InsertrElement {
|
||||
var elements []InsertrElement
|
||||
e.walkNodes(doc, func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && e.hasInsertrClass(n) {
|
||||
elementType := e.determineContentType(n)
|
||||
elements = append(elements, InsertrElement{
|
||||
Node: n,
|
||||
Type: elementType,
|
||||
})
|
||||
}
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
// determineContentType determines the content type based on element
|
||||
func (e *ContentEngine) determineContentType(node *html.Node) string {
|
||||
tag := strings.ToLower(node.Data)
|
||||
|
||||
switch tag {
|
||||
case "a", "button":
|
||||
return "link"
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
return "text"
|
||||
case "p", "div", "section", "article", "span":
|
||||
return "markdown"
|
||||
default:
|
||||
return "text"
|
||||
}
|
||||
}
|
||||
|
||||
// addContentAttributes adds data-content-id and data-content-type attributes
|
||||
func (e *ContentEngine) addContentAttributes(node *html.Node, contentID, contentType string) {
|
||||
// Add data-content-id attribute
|
||||
e.setAttribute(node, "data-content-id", contentID)
|
||||
// Add data-content-type attribute
|
||||
e.setAttribute(node, "data-content-type", contentType)
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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.Value
|
||||
e.injectContentIntoNode(elem.Node, contentItem.Value, contentItem.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectContentIntoNode injects content value into an HTML node
|
||||
func (e *ContentEngine) injectContentIntoNode(node *html.Node, content, contentType string) {
|
||||
// Clear existing text content
|
||||
for child := node.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
if child.Type == html.TextNode {
|
||||
node.RemoveChild(child)
|
||||
}
|
||||
child = next
|
||||
}
|
||||
|
||||
// Add new text content
|
||||
textNode := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
node.AppendChild(textNode)
|
||||
}
|
||||
133
internal/engine/id_generator.go
Normal file
133
internal/engine/id_generator.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// IDGenerator generates unique content IDs for elements using lightweight hierarchical approach
|
||||
type IDGenerator struct {
|
||||
usedIDs map[string]bool
|
||||
elementCounts map[string]int // Track counts per file+type for indexing
|
||||
}
|
||||
|
||||
// NewIDGenerator creates a new ID generator
|
||||
func NewIDGenerator() *IDGenerator {
|
||||
return &IDGenerator{
|
||||
usedIDs: make(map[string]bool),
|
||||
elementCounts: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates a content ID for an HTML element using lightweight hierarchical approach
|
||||
func (g *IDGenerator) Generate(node *html.Node, filePath string) string {
|
||||
// 1. File context (minimal)
|
||||
fileName := g.getFileName(filePath)
|
||||
|
||||
// 2. Element identity (lightweight)
|
||||
tag := strings.ToLower(node.Data)
|
||||
primaryClass := g.getPrimaryClass(node)
|
||||
|
||||
// 3. Position context (simple)
|
||||
elementKey := g.getElementKey(fileName, tag, primaryClass)
|
||||
index := g.getElementIndex(elementKey)
|
||||
|
||||
// 4. Build readable prefix
|
||||
prefix := g.buildPrefix(fileName, tag, primaryClass, index)
|
||||
|
||||
// 5. Add collision-resistant suffix
|
||||
signature := g.createSignature(node, filePath)
|
||||
hash := sha256.Sum256([]byte(signature))
|
||||
suffix := hex.EncodeToString(hash[:3])
|
||||
|
||||
finalID := fmt.Sprintf("%s-%s", prefix, suffix)
|
||||
|
||||
// Ensure uniqueness (should be guaranteed by hash, but safety check)
|
||||
g.usedIDs[finalID] = true
|
||||
|
||||
return finalID
|
||||
}
|
||||
|
||||
// getFileName extracts filename without extension for ID prefix
|
||||
func (g *IDGenerator) getFileName(filePath string) string {
|
||||
base := filepath.Base(filePath)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
|
||||
// getPrimaryClass returns the first meaningful (non-insertr) CSS class
|
||||
func (g *IDGenerator) getPrimaryClass(node *html.Node) string {
|
||||
classes := GetClasses(node)
|
||||
for _, class := range classes {
|
||||
if class != "insertr" && class != "" {
|
||||
return class
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getElementKey creates a key for tracking element counts
|
||||
func (g *IDGenerator) getElementKey(fileName, tag, primaryClass string) string {
|
||||
if primaryClass != "" {
|
||||
return fmt.Sprintf("%s-%s", fileName, primaryClass)
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", fileName, tag)
|
||||
}
|
||||
|
||||
// getElementIndex returns the position index for this element type in the file
|
||||
func (g *IDGenerator) getElementIndex(elementKey string) int {
|
||||
g.elementCounts[elementKey]++
|
||||
return g.elementCounts[elementKey]
|
||||
}
|
||||
|
||||
// buildPrefix creates human-readable prefix for the ID
|
||||
func (g *IDGenerator) buildPrefix(fileName, tag, primaryClass string, index int) string {
|
||||
var parts []string
|
||||
parts = append(parts, fileName)
|
||||
|
||||
if primaryClass != "" {
|
||||
parts = append(parts, primaryClass)
|
||||
} else {
|
||||
parts = append(parts, tag)
|
||||
}
|
||||
|
||||
// Only add index if it's not the first element of this type
|
||||
if index > 1 {
|
||||
parts = append(parts, fmt.Sprintf("%d", index))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
// createSignature creates a unique signature for collision resistance
|
||||
func (g *IDGenerator) createSignature(node *html.Node, filePath string) string {
|
||||
// Minimal signature for uniqueness
|
||||
tag := node.Data
|
||||
classes := strings.Join(GetClasses(node), " ")
|
||||
domPath := g.getSimpleDOMPath(node)
|
||||
|
||||
return fmt.Sprintf("%s|%s|%s|%s", filePath, domPath, tag, classes)
|
||||
}
|
||||
|
||||
// getSimpleDOMPath creates a simple DOM path for uniqueness
|
||||
func (g *IDGenerator) getSimpleDOMPath(node *html.Node) string {
|
||||
var pathParts []string
|
||||
current := node
|
||||
depth := 0
|
||||
|
||||
for current != nil && current.Type == html.ElementNode && depth < 5 {
|
||||
part := current.Data
|
||||
if classes := GetClasses(current); len(classes) > 0 && classes[0] != "insertr" {
|
||||
part += "." + classes[0]
|
||||
}
|
||||
pathParts = append([]string{part}, pathParts...)
|
||||
current = current.Parent
|
||||
depth++
|
||||
}
|
||||
|
||||
return strings.Join(pathParts, ">")
|
||||
}
|
||||
505
internal/engine/injector.go.backup
Normal file
505
internal/engine/injector.go.backup
Normal file
@@ -0,0 +1,505 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Injector handles content injection into HTML elements
|
||||
type Injector struct {
|
||||
client ContentClient
|
||||
siteID string
|
||||
mdProcessor *MarkdownProcessor
|
||||
}
|
||||
|
||||
// NewInjector creates a new content injector
|
||||
func NewInjector(client ContentClient, siteID string) *Injector {
|
||||
return &Injector{
|
||||
client: client,
|
||||
siteID: siteID,
|
||||
mdProcessor: NewMarkdownProcessor(),
|
||||
}
|
||||
}
|
||||
|
||||
// InjectContent replaces element content with database values and adds content IDs
|
||||
func (i *Injector) InjectContent(element *Element, contentID string) error {
|
||||
// Fetch content from database/API
|
||||
contentItem, err := i.client.GetContent(i.siteID, contentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching content for %s: %w", contentID, err)
|
||||
}
|
||||
|
||||
// If no content found, keep original content but add data attributes
|
||||
if contentItem == nil {
|
||||
i.AddContentAttributes(element.Node, contentID, element.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace element content based on type
|
||||
switch element.Type {
|
||||
case "text":
|
||||
i.injectTextContent(element.Node, contentItem.Value)
|
||||
case "markdown":
|
||||
i.injectMarkdownContent(element.Node, contentItem.Value)
|
||||
case "link":
|
||||
i.injectLinkContent(element.Node, contentItem.Value)
|
||||
default:
|
||||
i.injectTextContent(element.Node, contentItem.Value)
|
||||
}
|
||||
|
||||
// Add data attributes for editor functionality
|
||||
i.AddContentAttributes(element.Node, contentID, element.Type)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InjectBulkContent efficiently injects multiple content items
|
||||
func (i *Injector) InjectBulkContent(elements []ElementWithID) error {
|
||||
// Extract content IDs for bulk fetch
|
||||
contentIDs := make([]string, len(elements))
|
||||
for idx, elem := range elements {
|
||||
contentIDs[idx] = elem.ContentID
|
||||
}
|
||||
|
||||
// Bulk fetch content
|
||||
contentMap, err := i.client.GetBulkContent(i.siteID, contentIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bulk fetching content: %w", err)
|
||||
}
|
||||
|
||||
// Inject each element
|
||||
for _, elem := range elements {
|
||||
contentItem, exists := contentMap[elem.ContentID]
|
||||
|
||||
// Add content attributes regardless
|
||||
i.AddContentAttributes(elem.Element.Node, elem.ContentID, elem.Element.Type)
|
||||
|
||||
if !exists {
|
||||
// Keep original content if not found in database
|
||||
continue
|
||||
}
|
||||
|
||||
// Replace content based on type
|
||||
switch elem.Element.Type {
|
||||
case "text":
|
||||
i.injectTextContent(elem.Element.Node, contentItem.Value)
|
||||
case "markdown":
|
||||
i.injectMarkdownContent(elem.Element.Node, contentItem.Value)
|
||||
case "link":
|
||||
i.injectLinkContent(elem.Element.Node, contentItem.Value)
|
||||
default:
|
||||
i.injectTextContent(elem.Element.Node, contentItem.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectTextContent replaces text content in an element
|
||||
func (i *Injector) injectTextContent(node *html.Node, content string) {
|
||||
// Remove all child nodes
|
||||
for child := node.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
node.RemoveChild(child)
|
||||
child = next
|
||||
}
|
||||
|
||||
// Add new text content
|
||||
textNode := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: content,
|
||||
}
|
||||
node.AppendChild(textNode)
|
||||
}
|
||||
|
||||
// injectMarkdownContent handles markdown content - converts markdown to HTML
|
||||
func (i *Injector) injectMarkdownContent(node *html.Node, content string) {
|
||||
if content == "" {
|
||||
i.injectTextContent(node, "")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert markdown to HTML using server processor
|
||||
htmlContent, err := i.mdProcessor.ToHTML(content)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Markdown conversion failed for content '%s': %v, falling back to text", content, err)
|
||||
i.injectTextContent(node, content)
|
||||
return
|
||||
}
|
||||
|
||||
// Inject the HTML content
|
||||
i.injectHTMLContent(node, htmlContent)
|
||||
}
|
||||
|
||||
// injectLinkContent handles link/button content with URL extraction
|
||||
func (i *Injector) injectLinkContent(node *html.Node, content string) {
|
||||
// For now, just inject the text content
|
||||
// TODO: Parse content for URL and text components
|
||||
i.injectTextContent(node, content)
|
||||
}
|
||||
|
||||
// injectHTMLContent safely injects HTML content into a DOM node
|
||||
// Preserves the original element and only replaces its content
|
||||
func (i *Injector) injectHTMLContent(node *html.Node, htmlContent string) {
|
||||
// Clear existing content but preserve the element itself
|
||||
i.clearNode(node)
|
||||
|
||||
if htmlContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap content for safe parsing
|
||||
wrappedHTML := "<div>" + htmlContent + "</div>"
|
||||
|
||||
// Parse HTML string
|
||||
doc, err := html.Parse(strings.NewReader(wrappedHTML))
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse HTML content '%s': %v, falling back to text", htmlContent, err)
|
||||
i.injectTextContent(node, htmlContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the wrapper div and move its children to target node
|
||||
wrapper := i.findElementByTag(doc, "div")
|
||||
if wrapper == nil {
|
||||
log.Printf("Could not find wrapper div in parsed HTML")
|
||||
return
|
||||
}
|
||||
|
||||
// Move parsed nodes to target element (preserving original element)
|
||||
for child := wrapper.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
wrapper.RemoveChild(child)
|
||||
node.AppendChild(child)
|
||||
child = next
|
||||
}
|
||||
}
|
||||
|
||||
// clearNode removes all child nodes from a given node
|
||||
func (i *Injector) clearNode(node *html.Node) {
|
||||
for child := node.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
node.RemoveChild(child)
|
||||
child = next
|
||||
}
|
||||
}
|
||||
|
||||
// findElementByTag finds the first element with the specified tag name
|
||||
func (i *Injector) findElementByTag(node *html.Node, tag string) *html.Node {
|
||||
if node.Type == html.ElementNode && node.Data == tag {
|
||||
return node
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if found := i.findElementByTag(child, tag); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddContentAttributes adds necessary data attributes and insertr class for editor functionality
|
||||
func (i *Injector) AddContentAttributes(node *html.Node, contentID string, contentType string) {
|
||||
i.setAttribute(node, "data-content-id", contentID)
|
||||
i.setAttribute(node, "data-content-type", contentType)
|
||||
i.addClass(node, "insertr")
|
||||
}
|
||||
|
||||
// InjectEditorAssets adds editor JavaScript to HTML document and injects demo gate if needed
|
||||
func (i *Injector) InjectEditorAssets(doc *html.Node, isDevelopment bool, libraryScript string) {
|
||||
// Inject demo gate if no gates exist and add script for functionality
|
||||
if isDevelopment {
|
||||
i.InjectDemoGateIfNeeded(doc)
|
||||
i.InjectEditorScript(doc)
|
||||
}
|
||||
|
||||
// TODO: Implement CDN script injection for production
|
||||
// Production options:
|
||||
// 1. Inject CDN script tag: <script src="https://cdn.jsdelivr.net/npm/@insertr/lib@1.0.0/dist/insertr.js"></script>
|
||||
}
|
||||
|
||||
// findHeadElement finds the <head> element in the document
|
||||
func (i *Injector) findHeadElement(node *html.Node) *html.Node {
|
||||
if node.Type == html.ElementNode && node.Data == "head" {
|
||||
return node
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if result := i.findHeadElement(child); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAttribute safely sets an attribute on an HTML node
|
||||
func (i *Injector) setAttribute(node *html.Node, key, value string) {
|
||||
// Remove existing attribute if present
|
||||
for idx, attr := range node.Attr {
|
||||
if attr.Key == key {
|
||||
node.Attr = append(node.Attr[:idx], node.Attr[idx+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Add new attribute
|
||||
node.Attr = append(node.Attr, html.Attribute{
|
||||
Key: key,
|
||||
Val: value,
|
||||
})
|
||||
}
|
||||
|
||||
// addClass safely adds a class to an HTML node
|
||||
func (i *Injector) 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Element represents a parsed HTML element with metadata
|
||||
type Element struct {
|
||||
Node *html.Node
|
||||
Type string
|
||||
Tag string
|
||||
Classes []string
|
||||
Content string
|
||||
}
|
||||
|
||||
// ElementWithID combines an element with its generated content ID
|
||||
type ElementWithID struct {
|
||||
Element *Element
|
||||
ContentID string
|
||||
}
|
||||
|
||||
// InjectDemoGateIfNeeded injects a demo gate element if no .insertr-gate elements exist
|
||||
func (i *Injector) InjectDemoGateIfNeeded(doc *html.Node) {
|
||||
// Check if any .insertr-gate elements already exist
|
||||
if i.hasInsertrGate(doc) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the body element
|
||||
bodyNode := i.findBodyElement(doc)
|
||||
if bodyNode == nil {
|
||||
log.Printf("Warning: Could not find body element to inject demo gate")
|
||||
return
|
||||
}
|
||||
|
||||
// Create demo gate HTML structure
|
||||
gateHTML := `<div class="insertr-demo-gate" style="position: fixed; top: 20px; right: 20px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
<button class="insertr-gate insertr-demo-gate-btn" style="background: #4f46e5; color: white; border: none; padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); transition: all 0.2s ease; display: flex; align-items: center; gap: 8px; user-select: none;" onmouseover="this.style.background='#4338ca'; this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 16px rgba(79, 70, 229, 0.4)'" onmouseout="this.style.background='#4f46e5'; this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(79, 70, 229, 0.3)'">
|
||||
<span style="font-size: 16px;">✏️</span>
|
||||
<span>Edit Site</span>
|
||||
</button>
|
||||
</div>`
|
||||
|
||||
// Parse the gate HTML and inject it into the body
|
||||
gateDoc, err := html.Parse(strings.NewReader(gateHTML))
|
||||
if err != nil {
|
||||
log.Printf("Error parsing demo gate HTML: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract and inject the gate element
|
||||
if gateDiv := i.extractElementByClass(gateDoc, "insertr-demo-gate"); gateDiv != nil {
|
||||
if gateDiv.Parent != nil {
|
||||
gateDiv.Parent.RemoveChild(gateDiv)
|
||||
}
|
||||
bodyNode.AppendChild(gateDiv)
|
||||
log.Printf("✅ Demo gate injected: Edit button added to top-right corner")
|
||||
}
|
||||
}
|
||||
|
||||
// InjectEditorScript injects the insertr.js library and initialization script
|
||||
func (i *Injector) InjectEditorScript(doc *html.Node) {
|
||||
// Find the head element for the script tag
|
||||
headNode := i.findHeadElement(doc)
|
||||
if headNode == nil {
|
||||
log.Printf("Warning: Could not find head element to inject editor script")
|
||||
return
|
||||
}
|
||||
|
||||
// Create script element that loads insertr.js from our server
|
||||
scriptHTML := fmt.Sprintf(`<script src="http://localhost:8080/insertr.js"></script>
|
||||
<script type="text/javascript">
|
||||
// Initialize insertr for demo sites
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof window.Insertr !== 'undefined') {
|
||||
console.log('✅ Insertr library loaded successfully');
|
||||
|
||||
// The library has auto-initialization, but we can force initialization
|
||||
// with our demo configuration
|
||||
window.Insertr.init({
|
||||
siteId: '%s',
|
||||
apiEndpoint: 'http://localhost:8080/api/content',
|
||||
mockAuth: true, // Use mock authentication for demos
|
||||
debug: true
|
||||
});
|
||||
|
||||
console.log('✅ Insertr initialized for demo site with config:', {
|
||||
siteId: '%s',
|
||||
apiEndpoint: 'http://localhost:8080/api/content',
|
||||
mockAuth: true
|
||||
});
|
||||
} else {
|
||||
console.error('❌ Insertr library failed to load');
|
||||
|
||||
// Fallback for demo gates if library fails
|
||||
const gates = document.querySelectorAll('.insertr-gate');
|
||||
gates.forEach(gate => {
|
||||
gate.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
alert('🚧 Insertr library not loaded\\n\\nPlease run "just build-lib" to build the library first.');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>`, i.siteID, i.siteID)
|
||||
|
||||
// Parse and inject the script
|
||||
scriptDoc, err := html.Parse(strings.NewReader(scriptHTML))
|
||||
if err != nil {
|
||||
log.Printf("Error parsing editor script HTML: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract and inject all script elements
|
||||
if err := i.injectAllScriptElements(scriptDoc, headNode); err != nil {
|
||||
log.Printf("Error injecting script elements: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ Insertr.js library and initialization script injected")
|
||||
}
|
||||
|
||||
// injectAllScriptElements finds and injects all script elements from parsed HTML
|
||||
func (i *Injector) injectAllScriptElements(doc *html.Node, targetNode *html.Node) error {
|
||||
scripts := i.findAllScriptElements(doc)
|
||||
|
||||
for _, script := range scripts {
|
||||
// Remove from original parent
|
||||
if script.Parent != nil {
|
||||
script.Parent.RemoveChild(script)
|
||||
}
|
||||
// Add to target node
|
||||
targetNode.AppendChild(script)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findAllScriptElements recursively finds all script elements
|
||||
func (i *Injector) findAllScriptElements(node *html.Node) []*html.Node {
|
||||
var scripts []*html.Node
|
||||
|
||||
if node.Type == html.ElementNode && node.Data == "script" {
|
||||
scripts = append(scripts, node)
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
childScripts := i.findAllScriptElements(child)
|
||||
scripts = append(scripts, childScripts...)
|
||||
}
|
||||
|
||||
return scripts
|
||||
}
|
||||
|
||||
// hasInsertrGate checks if document has .insertr-gate elements
|
||||
func (i *Injector) hasInsertrGate(node *html.Node) bool {
|
||||
if node.Type == html.ElementNode {
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Key == "class" && strings.Contains(attr.Val, "insertr-gate") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if i.hasInsertrGate(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findBodyElement finds the <body> element
|
||||
func (i *Injector) findBodyElement(node *html.Node) *html.Node {
|
||||
if node.Type == html.ElementNode && node.Data == "body" {
|
||||
return node
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if result := i.findBodyElement(child); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractElementByClass finds element with specific class
|
||||
func (i *Injector) extractElementByClass(node *html.Node, className string) *html.Node {
|
||||
if node.Type == html.ElementNode {
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Key == "class" && strings.Contains(attr.Val, className) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if result := i.extractElementByClass(child, className); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractElementByTag finds element with specific tag
|
||||
func (i *Injector) extractElementByTag(node *html.Node, tagName string) *html.Node {
|
||||
if node.Type == html.ElementNode && node.Data == tagName {
|
||||
return node
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if result := i.extractElementByTag(child, tagName); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
76
internal/engine/markdown.go
Normal file
76
internal/engine/markdown.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// MarkdownProcessor handles minimal markdown processing
|
||||
// Supports only: **bold**, *italic*, and [link](url)
|
||||
type MarkdownProcessor struct {
|
||||
parser goldmark.Markdown
|
||||
}
|
||||
|
||||
// NewMarkdownProcessor creates a new markdown processor with minimal configuration
|
||||
func NewMarkdownProcessor() *MarkdownProcessor {
|
||||
// Configure goldmark to only support basic inline formatting
|
||||
md := goldmark.New(
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithInlineParsers(
|
||||
// Bold (**text**) and italic (*text*) - same parser handles both
|
||||
util.Prioritized(parser.NewEmphasisParser(), 500),
|
||||
|
||||
// Links [text](url)
|
||||
util.Prioritized(parser.NewLinkParser(), 600),
|
||||
),
|
||||
// Disable all block parsers except paragraph (no headings, lists, etc.)
|
||||
parser.WithBlockParsers(
|
||||
util.Prioritized(parser.NewParagraphParser(), 200),
|
||||
),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithXHTML(), // <br /> instead of <br>
|
||||
html.WithHardWraps(), // Line breaks become <br />
|
||||
html.WithUnsafe(), // Allow existing HTML to pass through
|
||||
),
|
||||
)
|
||||
|
||||
return &MarkdownProcessor{parser: md}
|
||||
}
|
||||
|
||||
// ToHTML converts markdown string to HTML
|
||||
func (mp *MarkdownProcessor) ToHTML(markdown string) (string, error) {
|
||||
if markdown == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := mp.parser.Convert([]byte(markdown), &buf); err != nil {
|
||||
log.Printf("Markdown conversion failed: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
html := buf.String()
|
||||
|
||||
// Clean up goldmark's paragraph wrapping for inline content
|
||||
// If content is wrapped in a single <p> tag, extract just the inner content
|
||||
html = strings.TrimSpace(html)
|
||||
|
||||
if strings.HasPrefix(html, "<p>") && strings.HasSuffix(html, "</p>") {
|
||||
// Check if this is a single paragraph (no other <p> tags inside)
|
||||
inner := html[3 : len(html)-4] // Remove <p> and </p>
|
||||
if !strings.Contains(inner, "<p>") {
|
||||
// Single paragraph - return just the inner content for inline injection
|
||||
return inner, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple paragraphs or other block content - return as-is
|
||||
return html, nil
|
||||
}
|
||||
59
internal/engine/types.go
Normal file
59
internal/engine/types.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ProcessMode defines how the engine should process content
|
||||
type ProcessMode int
|
||||
|
||||
const (
|
||||
// Enhancement mode: Parse + Generate IDs + Inject content + Add editor assets
|
||||
Enhancement ProcessMode = iota
|
||||
// IDGeneration mode: Parse + Generate IDs only (for API)
|
||||
IDGeneration
|
||||
// ContentInjection mode: Parse + Generate IDs + Inject content only
|
||||
ContentInjection
|
||||
)
|
||||
|
||||
// ContentInput represents input to the content engine
|
||||
type ContentInput struct {
|
||||
HTML []byte // Raw HTML or markup
|
||||
FilePath string // File context (e.g., "index.html")
|
||||
SiteID string // Site identifier
|
||||
Mode ProcessMode // Processing mode
|
||||
}
|
||||
|
||||
// ContentResult represents the result of content processing
|
||||
type ContentResult struct {
|
||||
Document *html.Node // Processed HTML document
|
||||
Elements []ProcessedElement // All processed elements
|
||||
GeneratedIDs map[string]string // Map of element positions to generated IDs
|
||||
}
|
||||
|
||||
// ProcessedElement represents an element that has been processed
|
||||
type ProcessedElement struct {
|
||||
Node *html.Node // HTML node
|
||||
ID string // Generated content ID
|
||||
Type string // Content type (text, markdown, link)
|
||||
Content string // Injected content (if any)
|
||||
Generated bool // Whether ID was generated (vs existing)
|
||||
Tag string // Element tag name
|
||||
Classes []string // Element CSS classes
|
||||
}
|
||||
|
||||
// ContentClient interface for accessing content data
|
||||
// This will be implemented by database clients
|
||||
type ContentClient interface {
|
||||
GetContent(siteID, contentID string) (*ContentItem, error)
|
||||
GetBulkContent(siteID string, contentIDs []string) ([]*ContentItem, error)
|
||||
}
|
||||
|
||||
// ContentItem represents a piece of content from the database
|
||||
type ContentItem struct {
|
||||
ID string
|
||||
SiteID string
|
||||
Value string
|
||||
Type string
|
||||
LastEditedBy string
|
||||
}
|
||||
285
internal/engine/utils.go
Normal file
285
internal/engine/utils.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// GetClasses extracts CSS classes from an HTML node
|
||||
func GetClasses(node *html.Node) []string {
|
||||
classAttr := getAttribute(node, "class")
|
||||
if classAttr == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
classes := strings.Fields(classAttr)
|
||||
return classes
|
||||
}
|
||||
|
||||
// ContainsClass checks if a class list contains a specific class
|
||||
func ContainsClass(classes []string, target string) bool {
|
||||
for _, class := range classes {
|
||||
if class == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getAttribute gets an attribute value from an HTML node
|
||||
func getAttribute(node *html.Node, key string) string {
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Key == key {
|
||||
return attr.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTextContent gets the text content from an HTML node
|
||||
func extractTextContent(node *html.Node) string {
|
||||
var text strings.Builder
|
||||
extractTextRecursive(node, &text)
|
||||
return strings.TrimSpace(text.String())
|
||||
}
|
||||
|
||||
// extractTextRecursive recursively extracts text from node and children
|
||||
func extractTextRecursive(node *html.Node, text *strings.Builder) {
|
||||
if node.Type == html.TextNode {
|
||||
text.WriteString(node.Data)
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
// Skip script and style elements
|
||||
if child.Type == html.ElementNode &&
|
||||
(child.Data == "script" || child.Data == "style") {
|
||||
continue
|
||||
}
|
||||
extractTextRecursive(child, text)
|
||||
}
|
||||
}
|
||||
|
||||
// hasOnlyTextContent checks if a node contains only text content (no nested HTML elements)
|
||||
// DEPRECATED: Use hasEditableContent for more sophisticated detection
|
||||
func hasOnlyTextContent(node *html.Node) bool {
|
||||
if node.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
switch child.Type {
|
||||
case html.ElementNode:
|
||||
// Found a nested HTML element - not text-only
|
||||
return false
|
||||
case html.TextNode:
|
||||
// Text nodes are fine, continue checking
|
||||
continue
|
||||
default:
|
||||
// Comments, etc. - continue checking
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Inline formatting elements that are safe for editing
|
||||
var inlineFormattingTags = map[string]bool{
|
||||
"strong": true,
|
||||
"b": true,
|
||||
"em": true,
|
||||
"i": true,
|
||||
"span": true,
|
||||
"code": true,
|
||||
"small": true,
|
||||
"sub": true,
|
||||
"sup": true,
|
||||
"a": true, // Links within content are fine
|
||||
}
|
||||
|
||||
// Elements that should NOT be nested within editable content
|
||||
var blockingElements = map[string]bool{
|
||||
"button": true, // Buttons shouldn't be nested in paragraphs
|
||||
"input": true,
|
||||
"select": true,
|
||||
"textarea": true,
|
||||
"img": true,
|
||||
"video": true,
|
||||
"audio": true,
|
||||
"canvas": true,
|
||||
"svg": true,
|
||||
"iframe": true,
|
||||
"object": true,
|
||||
"embed": true,
|
||||
"div": true, // Nested divs usually indicate complex structure
|
||||
"section": true, // Block-level semantic elements
|
||||
"article": true,
|
||||
"header": true,
|
||||
"footer": true,
|
||||
"nav": true,
|
||||
"aside": true,
|
||||
"main": true,
|
||||
"form": true,
|
||||
"table": true,
|
||||
"ul": true,
|
||||
"ol": true,
|
||||
"dl": true,
|
||||
}
|
||||
|
||||
// hasEditableContent checks if a node contains content that can be safely edited
|
||||
// This includes text and safe inline formatting elements
|
||||
func hasEditableContent(node *html.Node) bool {
|
||||
if node.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
|
||||
return hasOnlyTextAndSafeFormatting(node)
|
||||
}
|
||||
|
||||
// hasOnlyTextAndSafeFormatting recursively checks if content is safe for editing
|
||||
func hasOnlyTextAndSafeFormatting(node *html.Node) bool {
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
switch child.Type {
|
||||
case html.TextNode:
|
||||
continue // Text is always safe
|
||||
case html.ElementNode:
|
||||
// Check if it's a blocking element
|
||||
if blockingElements[child.Data] {
|
||||
return false
|
||||
}
|
||||
// Allow safe inline formatting
|
||||
if inlineFormattingTags[child.Data] {
|
||||
// Recursively validate the formatting element
|
||||
if !hasOnlyTextAndSafeFormatting(child) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Unknown/unsafe element
|
||||
return false
|
||||
default:
|
||||
continue // Comments, whitespace, etc.
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isContainer checks if a tag is typically used as a container element
|
||||
func isContainer(node *html.Node) bool {
|
||||
if node.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
|
||||
containerTags := map[string]bool{
|
||||
"div": true,
|
||||
"section": true,
|
||||
"article": true,
|
||||
"header": true,
|
||||
"footer": true,
|
||||
"main": true,
|
||||
"aside": true,
|
||||
"nav": true,
|
||||
}
|
||||
|
||||
return containerTags[node.Data]
|
||||
}
|
||||
|
||||
// findViableChildren finds all child elements that are viable for editing
|
||||
func findViableChildren(node *html.Node) []*html.Node {
|
||||
var viable []*html.Node
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
// Skip whitespace-only text nodes
|
||||
if child.Type == html.TextNode {
|
||||
if strings.TrimSpace(child.Data) == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Only consider element nodes
|
||||
if child.Type != html.ElementNode {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip self-closing elements for now
|
||||
if isSelfClosing(child) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if element has editable content (improved logic)
|
||||
if hasEditableContent(child) {
|
||||
viable = append(viable, child)
|
||||
}
|
||||
}
|
||||
|
||||
return viable
|
||||
}
|
||||
|
||||
// findViableChildrenLegacy uses the old text-only logic for backwards compatibility
|
||||
func findViableChildrenLegacy(node *html.Node) []*html.Node {
|
||||
var viable []*html.Node
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.TextNode {
|
||||
if strings.TrimSpace(child.Data) == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if child.Type != html.ElementNode {
|
||||
continue
|
||||
}
|
||||
|
||||
if isSelfClosing(child) {
|
||||
continue
|
||||
}
|
||||
|
||||
if hasOnlyTextContent(child) {
|
||||
viable = append(viable, child)
|
||||
}
|
||||
}
|
||||
|
||||
return viable
|
||||
}
|
||||
|
||||
// isSelfClosing checks if an element is typically self-closing
|
||||
func isSelfClosing(node *html.Node) bool {
|
||||
if node.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
|
||||
selfClosingTags := map[string]bool{
|
||||
"img": true,
|
||||
"input": true,
|
||||
"br": true,
|
||||
"hr": true,
|
||||
"meta": true,
|
||||
"link": true,
|
||||
"area": true,
|
||||
"base": true,
|
||||
"col": true,
|
||||
"embed": true,
|
||||
"source": true,
|
||||
"track": true,
|
||||
"wbr": true,
|
||||
}
|
||||
|
||||
return selfClosingTags[node.Data]
|
||||
}
|
||||
|
||||
// Note: FindElementInDocument functions removed - will be reimplemented in engine if needed
|
||||
|
||||
// GetAttribute gets an attribute value from an HTML node (exported version)
|
||||
func GetAttribute(node *html.Node, key string) string {
|
||||
return getAttribute(node, key)
|
||||
}
|
||||
|
||||
// HasEditableContent checks if a node has editable content (exported version)
|
||||
func HasEditableContent(node *html.Node) bool {
|
||||
return hasEditableContent(node)
|
||||
}
|
||||
|
||||
// FindViableChildren finds viable children for editing (exported version)
|
||||
func FindViableChildren(node *html.Node) []*html.Node {
|
||||
return findViableChildren(node)
|
||||
}
|
||||
Reference in New Issue
Block a user