Files
insertr/internal/engine/injector.go
Joakim 968e64a57e refactor: Complete UI cleanup and simplify editor architecture
- Remove complex style preservation system from editor
- Simplify markdown conversion back to straightforward approach
- Remove StyleContext class and style-aware conversion methods
- Switch content type from 'html' back to 'markdown' for consistency
- Clean up editor workflow to focus on core markdown editing
- Remove ~500 lines of unnecessary style complexity

This completes the UI unification cleanup by removing the overly complex
style preservation system that was making the editor harder to maintain.
2025-09-19 16:15:56 +02:00

551 lines
16 KiB
Go

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
authProvider *AuthProvider
}
// NewInjector creates a new content injector
func NewInjector(client ContentClient, siteID string) *Injector {
return &Injector{
client: client,
siteID: siteID,
mdProcessor: NewMarkdownProcessor(),
authProvider: &AuthProvider{Type: "mock"}, // default
}
}
// NewInjectorWithAuth creates a new content injector with auth provider
func NewInjectorWithAuth(client ContentClient, siteID string, authProvider *AuthProvider) *Injector {
if authProvider == nil {
authProvider = &AuthProvider{Type: "mock"}
}
return &Injector{
client: client,
siteID: siteID,
mdProcessor: NewMarkdownProcessor(),
authProvider: authProvider,
}
}
// 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) {
// 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
}
insertrHTML := fmt.Sprintf(`<link rel="stylesheet" href="http://localhost:8080/insertr.css" data-insertr-injected="true">
<script src="http://localhost:8080/insertr.js" data-insertr-injected="true" data-site-id="%s" data-api-endpoint="http://localhost:8080/api/content" data-auth-provider="%s" data-debug="true"></script>`, i.siteID, authProvider)
// 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")
}
// 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
}
// 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 <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
}