Files
insertr/internal/content/injector.go
Joakim 33ba53fb50 feat: implement auto-injection of insertr.js with site-specific configuration
• Add /insertr.js endpoint to serve JavaScript library from API server
• Implement demo gate auto-injection for sites without existing gates
• Add dynamic site ID injection using per-demo configuration files
• Fix CORS middleware to support localhost origins on any port
• Update demo commands to use individual insertr.yaml configs
• Resolve content persistence issues by matching site IDs between injection and enhancement
• Enable complete edit/save workflow for demo sites with proper namespace isolation
2025-09-11 20:58:21 +02:00

506 lines
15 KiB
Go

package content
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
}