package engine import ( "context" "fmt" "log" "strings" "github.com/insertr/insertr/internal/config" "github.com/insertr/insertr/internal/db" "golang.org/x/net/html" ) // Injector handles content injection into HTML elements type Injector struct { client db.ContentRepository siteID string authProvider *AuthProvider config *config.Config } // NewInjector creates a new content injector func NewInjector(client db.ContentRepository, siteID string, cfg *config.Config) *Injector { return &Injector{ client: client, siteID: siteID, authProvider: &AuthProvider{Type: "mock"}, // default config: cfg, } } // NewInjectorWithAuth creates a new content injector with auth provider func NewInjectorWithAuth(client db.ContentRepository, siteID string, authProvider *AuthProvider, cfg *config.Config) *Injector { if authProvider == nil { authProvider = &AuthProvider{Type: "mock"} } return &Injector{ client: client, siteID: siteID, authProvider: authProvider, config: cfg, } } // 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(context.Background(), 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 } // Direct HTML injection for all content types i.injectHTMLContent(element.Node, contentItem.HTMLContent) // 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(context.Background(), 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 } // Direct HTML injection for all content types i.injectHTMLContent(elem.Element.Node, contentItem.HTMLContent) } return nil } // 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 node", htmlContent, err) // Fallback: inject as text node i.clearNode(node) textNode := &html.Node{ Type: html.TextNode, Data: htmlContent, } node.AppendChild(textNode) 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) { SetAttribute(node, "data-content-id", contentID) 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 } // 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) { // Check if script is already injected if i.hasInsertrScript(doc) { return } // 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 CSS and script elements that load from our server with site configuration authProvider := "mock" if i.authProvider != nil { authProvider = i.authProvider.Type } // Generate configurable URLs for library assets cssURL := i.getLibraryURL("insertr.css") jsURL := i.getLibraryURL("insertr.js") apiURL := i.getAPIURL() insertrHTML := fmt.Sprintf(` `, cssURL, jsURL, i.siteID, apiURL, authProvider, i.isDebugMode()) // Parse and inject the CSS and script elements insertrDoc, err := html.Parse(strings.NewReader(insertrHTML)) if err != nil { log.Printf("Error parsing editor script HTML: %v", err) return } // Extract and inject all CSS and script elements if err := i.injectAllHeadElements(insertrDoc, headNode); err != nil { log.Printf("Error injecting CSS and script elements: %v", err) return } log.Printf("✅ Insertr.js library injected with site configuration") } // injectAllHeadElements finds and injects all head elements (link, script) from parsed HTML func (i *Injector) injectAllHeadElements(doc *html.Node, targetNode *html.Node) error { elements := i.findAllHeadElements(doc) for _, element := range elements { // Remove from original parent if element.Parent != nil { element.Parent.RemoveChild(element) } // Add to target node targetNode.AppendChild(element) } return nil } // findAllHeadElements recursively finds all link and script elements func (i *Injector) findAllHeadElements(node *html.Node) []*html.Node { var elements []*html.Node if node.Type == html.ElementNode && (node.Data == "script" || node.Data == "link") { elements = append(elements, node) } for child := node.FirstChild; child != nil; child = child.NextSibling { childElements := i.findAllHeadElements(child) elements = append(elements, childElements...) } return elements } // 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 } // hasInsertrScript checks if document already has insertr script injected // Uses data-insertr-injected attribute for reliable detection that works with: // - CDN URLs with version numbers (jsdelivr.net/npm/@insertr/lib@1.2.3/insertr.js) // - Minified versions (insertr.min.js) // - Query parameters (insertr.js?v=abc123) // - Different CDN domains (unpkg.com, cdn.example.com) func (i *Injector) hasInsertrScript(node *html.Node) bool { if node.Type == html.ElementNode && node.Data == "script" { for _, attr := range node.Attr { if attr.Key == "data-insertr-injected" { return true } } } for child := node.FirstChild; child != nil; child = child.NextSibling { if i.hasInsertrScript(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 } // getLibraryURL returns the appropriate URL for library assets (CSS/JS) func (i *Injector) getLibraryURL(filename string) string { if i.config == nil { // Fallback to localhost if no config return fmt.Sprintf("http://localhost:8080/%s", filename) } // Check if we should use CDN if i.config.Library.UseCDN && i.config.Library.CDNBaseURL != "" { // Production: Use CDN suffix := "" if i.config.Library.Minified && filename == "insertr.js" { suffix = ".min" } baseName := strings.TrimSuffix(filename, ".js") baseName = strings.TrimSuffix(baseName, ".css") return fmt.Sprintf("%s@%s/dist/%s%s", i.config.Library.CDNBaseURL, i.config.Library.Version, baseName, suffix+getFileExtension(filename)) } // Development: Use local server baseURL := i.config.Library.BaseURL if baseURL == "" { baseURL = fmt.Sprintf("http://localhost:%d", i.config.Server.Port) } suffix := "" if i.config.Library.Minified && filename == "insertr.js" { suffix = ".min" } baseName := strings.TrimSuffix(filename, ".js") baseName = strings.TrimSuffix(baseName, ".css") return fmt.Sprintf("%s/%s%s", baseURL, baseName, suffix+getFileExtension(filename)) } // getAPIURL returns the API endpoint URL func (i *Injector) getAPIURL() string { if i.config == nil { return "http://localhost:8080/api/content" } baseURL := i.config.Library.BaseURL if baseURL == "" { baseURL = fmt.Sprintf("http://localhost:%d", i.config.Server.Port) } return fmt.Sprintf("%s/api/content", baseURL) } // isDebugMode returns true if in development mode func (i *Injector) isDebugMode() bool { if i.config == nil { return true } return i.config.Auth.DevMode } // getFileExtension returns the file extension including the dot func getFileExtension(filename string) string { if strings.HasSuffix(filename, ".js") { return ".js" } if strings.HasSuffix(filename, ".css") { return ".css" } return "" }