diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 01002a8..7992cee 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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) { diff --git a/internal/api/models.go b/internal/api/models.go index 0da8340..a0ddf10 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -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 { diff --git a/internal/engine/database_client.go b/internal/engine/database_client.go new file mode 100644 index 0000000..6018632 --- /dev/null +++ b/internal/engine/database_client.go @@ -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()) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..26787f8 --- /dev/null +++ b/internal/engine/engine.go @@ -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) +} diff --git a/internal/engine/id_generator.go b/internal/engine/id_generator.go new file mode 100644 index 0000000..601a5f5 --- /dev/null +++ b/internal/engine/id_generator.go @@ -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, ">") +} diff --git a/internal/engine/injector.go.backup b/internal/engine/injector.go.backup new file mode 100644 index 0000000..6829eef --- /dev/null +++ b/internal/engine/injector.go.backup @@ -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 := "
" + htmlContent + "
" + + // 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: +} + +// findHeadElement finds the 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 := `
+ +
` + + // 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(` +`, 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 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 +} diff --git a/internal/engine/markdown.go b/internal/engine/markdown.go new file mode 100644 index 0000000..273b16a --- /dev/null +++ b/internal/engine/markdown.go @@ -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(), //
instead of
+ html.WithHardWraps(), // Line breaks become
+ 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

tag, extract just the inner content + html = strings.TrimSpace(html) + + if strings.HasPrefix(html, "

") && strings.HasSuffix(html, "

") { + // Check if this is a single paragraph (no other

tags inside) + inner := html[3 : len(html)-4] // Remove

and

+ if !strings.Contains(inner, "

") { + // 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 +} diff --git a/internal/engine/types.go b/internal/engine/types.go new file mode 100644 index 0000000..199d314 --- /dev/null +++ b/internal/engine/types.go @@ -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 +} diff --git a/internal/engine/utils.go b/internal/engine/utils.go new file mode 100644 index 0000000..8e2408c --- /dev/null +++ b/internal/engine/utils.go @@ -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) +} diff --git a/lib/src/core/api-client.js b/lib/src/core/api-client.js index c375588..b9e405c 100644 --- a/lib/src/core/api-client.js +++ b/lib/src/core/api-client.js @@ -29,21 +29,22 @@ export class ApiClient { } - async createContent(contentId, content, type, elementContext = null) { + async createContent(contentId, content, type, htmlMarkup = null) { try { const payload = { value: content, - type: type + type: type, + file_path: this.getCurrentFilePath() // Always include file path for consistent ID generation }; if (contentId) { // Enhanced site - provide existing ID payload.id = contentId; - } else if (elementContext) { - // Non-enhanced site - provide context for backend ID generation - payload.element_context = elementContext; + } else if (htmlMarkup) { + // Non-enhanced site - provide HTML markup for unified engine ID generation + payload.html_markup = htmlMarkup; } 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}`, { @@ -283,4 +284,17 @@ export class ApiClient { 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(/^\//, ''); + } } \ No newline at end of file diff --git a/lib/src/core/editor.js b/lib/src/core/editor.js index 140d4c2..2ccc460 100644 --- a/lib/src/core/editor.js +++ b/lib/src/core/editor.js @@ -108,7 +108,7 @@ export class InsertrEditor { meta.contentId, // Use existing ID if available, null if new contentValue, contentType, - meta.elementContext + meta.htmlMarkup ); if (result) { diff --git a/lib/src/core/insertr.js b/lib/src/core/insertr.js index e2e8c4e..f0a2d89 100644 --- a/lib/src/core/insertr.js +++ b/lib/src/core/insertr.js @@ -106,220 +106,23 @@ export class InsertrCore { getElementMetadata(element) { const existingId = element.getAttribute('data-content-id'); - // Always provide both existing ID (if any) and element context - // Backend will use existing ID if provided, or generate new one from context + // Send HTML markup to server for unified ID generation return { contentId: existingId, // null if new content, existing ID if updating contentType: element.getAttribute('data-content-type') || this.detectContentType(element), element: element, - elementContext: this.extractElementContext(element) + htmlMarkup: element.outerHTML // Server will generate ID from this }; } - // Extract element context for backend ID generation - extractElementContext(element) { - return { - tag: element.tagName.toLowerCase(), - classes: Array.from(element.classList), - 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; + // Get current file path from URL for consistent ID generation + getCurrentFilePath() { + const path = window.location.pathname; + if (path === '/' || path === '') { + return 'index.html'; } - - return 'content'; - } - - // 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; + // Remove leading slash: "/about.html" → "about.html" + return path.replace(/^\//, ''); } // Detect content type for elements without data-content-type