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"
|
||||||
"github.com/insertr/insertr/internal/db/postgresql"
|
"github.com/insertr/insertr/internal/db/postgresql"
|
||||||
"github.com/insertr/insertr/internal/db/sqlite"
|
"github.com/insertr/insertr/internal/db/sqlite"
|
||||||
"github.com/insertr/insertr/internal/parser"
|
"github.com/insertr/insertr/internal/engine"
|
||||||
"golang.org/x/net/html"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContentHandler handles all content-related HTTP requests
|
// ContentHandler handles all content-related HTTP requests
|
||||||
@@ -28,14 +27,19 @@ type ContentHandler struct {
|
|||||||
database *db.Database
|
database *db.Database
|
||||||
authService *auth.AuthService
|
authService *auth.AuthService
|
||||||
siteManager *content.SiteManager
|
siteManager *content.SiteManager
|
||||||
|
engine *engine.ContentEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContentHandler creates a new content handler
|
// NewContentHandler creates a new content handler
|
||||||
func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler {
|
func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler {
|
||||||
|
// Create database client for engine
|
||||||
|
dbClient := engine.NewDatabaseClient(database)
|
||||||
|
|
||||||
return &ContentHandler{
|
return &ContentHandler{
|
||||||
database: database,
|
database: database,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
siteManager: nil, // Will be set via SetSiteManager
|
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
|
siteID = "default" // final fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine content ID - use provided ID or generate from element context
|
// Generate content ID using the unified engine
|
||||||
contentID := req.ID
|
if req.HTMLMarkup == "" {
|
||||||
if contentID == "" {
|
http.Error(w, "html_markup is required", http.StatusBadRequest)
|
||||||
if req.ElementContext == nil {
|
return
|
||||||
http.Error(w, "Either ID or element_context required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
contentID = h.generateContentID(req.ElementContext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Extract user from request using authentication service
|
||||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||||
if authErr != nil {
|
if authErr != nil {
|
||||||
@@ -681,44 +700,7 @@ func (h *ContentHandler) versionMatches(version interface{}, contentID, siteID s
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateContentID creates a content ID from element context using the parser
|
// generateContentID function removed - using unified ContentEngine instead
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeInsertrJS handles GET /insertr.js - serves the insertr JavaScript library
|
// ServeInsertrJS handles GET /insertr.js - serves the insertr JavaScript library
|
||||||
func (h *ContentHandler) ServeInsertrJS(w http.ResponseWriter, r *http.Request) {
|
func (h *ContentHandler) ServeInsertrJS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ type ElementContext struct {
|
|||||||
|
|
||||||
// Request models
|
// Request models
|
||||||
type CreateContentRequest struct {
|
type CreateContentRequest struct {
|
||||||
ID string `json:"id,omitempty"` // For enhanced sites
|
HTMLMarkup string `json:"html_markup"` // HTML markup of the element
|
||||||
ElementContext *ElementContext `json:"element_context,omitempty"` // For non-enhanced sites
|
FilePath string `json:"file_path"` // File path for consistent ID generation
|
||||||
SiteID string `json:"site_id,omitempty"`
|
Value string `json:"value"` // Content value
|
||||||
Value string `json:"value"`
|
Type string `json:"type"` // Content type
|
||||||
Type string `json:"type"`
|
SiteID string `json:"site_id,omitempty"` // Site identifier
|
||||||
CreatedBy string `json:"created_by,omitempty"`
|
CreatedBy string `json:"created_by,omitempty"` // User who created the content
|
||||||
}
|
}
|
||||||
|
|
||||||
type RollbackContentRequest struct {
|
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)
|
||||||
|
}
|
||||||
@@ -29,21 +29,22 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async createContent(contentId, content, type, elementContext = null) {
|
async createContent(contentId, content, type, htmlMarkup = null) {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
value: content,
|
value: content,
|
||||||
type: type
|
type: type,
|
||||||
|
file_path: this.getCurrentFilePath() // Always include file path for consistent ID generation
|
||||||
};
|
};
|
||||||
|
|
||||||
if (contentId) {
|
if (contentId) {
|
||||||
// Enhanced site - provide existing ID
|
// Enhanced site - provide existing ID
|
||||||
payload.id = contentId;
|
payload.id = contentId;
|
||||||
} else if (elementContext) {
|
} else if (htmlMarkup) {
|
||||||
// Non-enhanced site - provide context for backend ID generation
|
// Non-enhanced site - provide HTML markup for unified engine ID generation
|
||||||
payload.element_context = elementContext;
|
payload.html_markup = htmlMarkup;
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Either contentId or elementContext must be provided');
|
throw new Error('Either contentId or htmlMarkup must be provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
|
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
|
||||||
@@ -283,4 +284,17 @@ export class ApiClient {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current file path from URL for consistent ID generation
|
||||||
|
* @returns {string} File path like "index.html", "about.html"
|
||||||
|
*/
|
||||||
|
getCurrentFilePath() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path === '/' || path === '') {
|
||||||
|
return 'index.html';
|
||||||
|
}
|
||||||
|
// Remove leading slash: "/about.html" → "about.html"
|
||||||
|
return path.replace(/^\//, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ export class InsertrEditor {
|
|||||||
meta.contentId, // Use existing ID if available, null if new
|
meta.contentId, // Use existing ID if available, null if new
|
||||||
contentValue,
|
contentValue,
|
||||||
contentType,
|
contentType,
|
||||||
meta.elementContext
|
meta.htmlMarkup
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
|
|||||||
@@ -106,220 +106,23 @@ export class InsertrCore {
|
|||||||
getElementMetadata(element) {
|
getElementMetadata(element) {
|
||||||
const existingId = element.getAttribute('data-content-id');
|
const existingId = element.getAttribute('data-content-id');
|
||||||
|
|
||||||
// Always provide both existing ID (if any) and element context
|
// Send HTML markup to server for unified ID generation
|
||||||
// Backend will use existing ID if provided, or generate new one from context
|
|
||||||
return {
|
return {
|
||||||
contentId: existingId, // null if new content, existing ID if updating
|
contentId: existingId, // null if new content, existing ID if updating
|
||||||
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
|
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
|
||||||
element: element,
|
element: element,
|
||||||
elementContext: this.extractElementContext(element)
|
htmlMarkup: element.outerHTML // Server will generate ID from this
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract element context for backend ID generation
|
// Get current file path from URL for consistent ID generation
|
||||||
extractElementContext(element) {
|
getCurrentFilePath() {
|
||||||
return {
|
const path = window.location.pathname;
|
||||||
tag: element.tagName.toLowerCase(),
|
if (path === '/' || path === '') {
|
||||||
classes: Array.from(element.classList),
|
return 'index.html';
|
||||||
original_content: element.textContent.trim(),
|
|
||||||
parent_context: this.getSemanticContext(element),
|
|
||||||
purpose: this.getPurpose(element)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate deterministic ID using same algorithm as CLI parser
|
|
||||||
generateTempId(element) {
|
|
||||||
return this.generateDeterministicId(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate deterministic content ID (matches CLI parser algorithm)
|
|
||||||
generateDeterministicId(element) {
|
|
||||||
const context = this.getSemanticContext(element);
|
|
||||||
const purpose = this.getPurpose(element);
|
|
||||||
const contentHash = this.getContentHash(element);
|
|
||||||
|
|
||||||
return this.createBaseId(context, purpose, contentHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get semantic context from parent elements (matches CLI algorithm)
|
|
||||||
getSemanticContext(element) {
|
|
||||||
let parent = element.parentElement;
|
|
||||||
|
|
||||||
while (parent && parent.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const classList = Array.from(parent.classList);
|
|
||||||
|
|
||||||
// Check for common semantic section classes
|
|
||||||
const semanticClasses = ['hero', 'services', 'nav', 'navbar', 'footer', 'about', 'contact', 'testimonial'];
|
|
||||||
for (const semanticClass of semanticClasses) {
|
|
||||||
if (classList.includes(semanticClass)) {
|
|
||||||
return semanticClass;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for semantic HTML elements
|
|
||||||
const tag = parent.tagName.toLowerCase();
|
|
||||||
if (['nav', 'header', 'footer', 'main', 'aside'].includes(tag)) {
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
}
|
||||||
|
// Remove leading slash: "/about.html" → "about.html"
|
||||||
return 'content';
|
return path.replace(/^\//, '');
|
||||||
}
|
|
||||||
|
|
||||||
// Get purpose/role of the element (matches CLI algorithm)
|
|
||||||
getPurpose(element) {
|
|
||||||
const tag = element.tagName.toLowerCase();
|
|
||||||
const classList = Array.from(element.classList);
|
|
||||||
|
|
||||||
// Check for specific CSS classes that indicate purpose
|
|
||||||
for (const className of classList) {
|
|
||||||
if (className.includes('title')) return 'title';
|
|
||||||
if (className.includes('headline')) return 'headline';
|
|
||||||
if (className.includes('description')) return 'description';
|
|
||||||
if (className.includes('subtitle')) return 'subtitle';
|
|
||||||
if (className.includes('cta')) return 'cta';
|
|
||||||
if (className.includes('button')) return 'button';
|
|
||||||
if (className.includes('logo')) return 'logo';
|
|
||||||
if (className.includes('lead')) return 'lead';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infer purpose from HTML tag
|
|
||||||
switch (tag) {
|
|
||||||
case 'h1':
|
|
||||||
return 'title';
|
|
||||||
case 'h2':
|
|
||||||
return 'subtitle';
|
|
||||||
case 'h3':
|
|
||||||
case 'h4':
|
|
||||||
case 'h5':
|
|
||||||
case 'h6':
|
|
||||||
return 'heading';
|
|
||||||
case 'p':
|
|
||||||
return 'text';
|
|
||||||
case 'a':
|
|
||||||
return 'link';
|
|
||||||
case 'button':
|
|
||||||
return 'button';
|
|
||||||
default:
|
|
||||||
return 'content';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate content hash (matches CLI algorithm)
|
|
||||||
getContentHash(element) {
|
|
||||||
const text = element.textContent.trim();
|
|
||||||
|
|
||||||
// Simple SHA-1 implementation for consistent hashing
|
|
||||||
return this.sha1(text).substring(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple SHA-1 implementation (matches Go crypto/sha1)
|
|
||||||
sha1(str) {
|
|
||||||
// Convert string to UTF-8 bytes
|
|
||||||
const utf8Bytes = new TextEncoder().encode(str);
|
|
||||||
|
|
||||||
// SHA-1 implementation
|
|
||||||
const h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
|
||||||
const messageLength = utf8Bytes.length;
|
|
||||||
|
|
||||||
// Pre-processing: adding padding bits
|
|
||||||
const paddedMessage = new Uint8Array(Math.ceil((messageLength + 9) / 64) * 64);
|
|
||||||
paddedMessage.set(utf8Bytes);
|
|
||||||
paddedMessage[messageLength] = 0x80;
|
|
||||||
|
|
||||||
// Append original length in bits as 64-bit big-endian integer
|
|
||||||
const bitLength = messageLength * 8;
|
|
||||||
const view = new DataView(paddedMessage.buffer);
|
|
||||||
view.setUint32(paddedMessage.length - 4, bitLength, false); // big-endian
|
|
||||||
|
|
||||||
// Process message in 512-bit chunks
|
|
||||||
for (let chunk = 0; chunk < paddedMessage.length; chunk += 64) {
|
|
||||||
const w = new Array(80);
|
|
||||||
|
|
||||||
// Break chunk into sixteen 32-bit words
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
w[i] = view.getUint32(chunk + i * 4, false); // big-endian
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extend the words
|
|
||||||
for (let i = 16; i < 80; i++) {
|
|
||||||
w[i] = this.leftRotate(w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize hash value for this chunk
|
|
||||||
let [a, b, c, d, e] = h;
|
|
||||||
|
|
||||||
// Main loop
|
|
||||||
for (let i = 0; i < 80; i++) {
|
|
||||||
let f, k;
|
|
||||||
if (i < 20) {
|
|
||||||
f = (b & c) | ((~b) & d);
|
|
||||||
k = 0x5A827999;
|
|
||||||
} else if (i < 40) {
|
|
||||||
f = b ^ c ^ d;
|
|
||||||
k = 0x6ED9EBA1;
|
|
||||||
} else if (i < 60) {
|
|
||||||
f = (b & c) | (b & d) | (c & d);
|
|
||||||
k = 0x8F1BBCDC;
|
|
||||||
} else {
|
|
||||||
f = b ^ c ^ d;
|
|
||||||
k = 0xCA62C1D6;
|
|
||||||
}
|
|
||||||
|
|
||||||
const temp = (this.leftRotate(a, 5) + f + e + k + w[i]) >>> 0;
|
|
||||||
e = d;
|
|
||||||
d = c;
|
|
||||||
c = this.leftRotate(b, 30);
|
|
||||||
b = a;
|
|
||||||
a = temp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this chunk's hash to result
|
|
||||||
h[0] = (h[0] + a) >>> 0;
|
|
||||||
h[1] = (h[1] + b) >>> 0;
|
|
||||||
h[2] = (h[2] + c) >>> 0;
|
|
||||||
h[3] = (h[3] + d) >>> 0;
|
|
||||||
h[4] = (h[4] + e) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Produce the final hash value as a 160-bit hex string
|
|
||||||
return h.map(x => x.toString(16).padStart(8, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left rotate function for SHA-1
|
|
||||||
leftRotate(value, amount) {
|
|
||||||
return ((value << amount) | (value >>> (32 - amount))) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create base ID from components (matches CLI algorithm)
|
|
||||||
createBaseId(context, purpose, contentHash) {
|
|
||||||
const parts = [];
|
|
||||||
|
|
||||||
// Add context if meaningful
|
|
||||||
if (context !== 'content') {
|
|
||||||
parts.push(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add purpose
|
|
||||||
parts.push(purpose);
|
|
||||||
|
|
||||||
// Always add content hash for uniqueness
|
|
||||||
parts.push(contentHash);
|
|
||||||
|
|
||||||
let baseId = parts.join('-');
|
|
||||||
|
|
||||||
// Clean up the ID
|
|
||||||
baseId = baseId.replace(/-+/g, '-');
|
|
||||||
baseId = baseId.replace(/^-+|-+$/g, '');
|
|
||||||
|
|
||||||
// Ensure it's not empty
|
|
||||||
if (!baseId) {
|
|
||||||
baseId = `content-${contentHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect content type for elements without data-content-type
|
// Detect content type for elements without data-content-type
|
||||||
|
|||||||
Reference in New Issue
Block a user