- Rebuild JavaScript library with delayed control panel initialization - Update server assets to include latest UI behavior changes - Ensure built assets reflect invisible UI for regular visitors The control panel now only appears after gate activation, maintaining the invisible CMS principle for end users.
551 lines
16 KiB
Go
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
|
|
}
|