- Migrate from inline CSS to external insertr.css with cascade layer architecture
- Add CSS CDN serving capability (ServeInsertrCSS handler and /insertr.css route)
- Implement hybrid approach: @layer insertr for modern browsers + html body selectors for legacy browsers
- Remove scattered inline CSS from JavaScript modules for better maintainability
- Solve form element spacing conflicts with aggressive site CSS resets like '* {margin:0; padding:0}'
- Enable proper CSS caching and separation of concerns
532 lines
16 KiB
Go
532 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
|
|
}
|
|
|
|
// 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) {
|
|
// 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
|
|
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-mock-auth="true" data-debug="true"></script>`, i.siteID)
|
|
|
|
// 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
|
|
}
|