package engine
import (
"context"
"fmt"
"log"
"strings"
"github.com/insertr/insertr/internal/config"
"github.com/insertr/insertr/internal/db"
"golang.org/x/net/html"
"slices"
)
// 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) {
i.setAttribute(node, "data-content-id", contentID)
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 = slices.Delete(node.Attr, idx, 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
if slices.Contains(classes, 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) {
// 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 ""
}