refactor: consolidate database structure and move injector to engine

Database Structure Cleanup:
- Move all SQL files from ./db/ to ./internal/db/
- Update sqlc.yaml to use new paths (preserving schema+setup.sql hack)
- Consolidate database-related code in single directory
- Remove empty ./db/ directory

Injector Migration:
- Move injector.go from content package to engine package
- Update ContentClient interface to return map instead of slice for GetBulkContent
- Update database client implementation to match interface
- Remove injector dependency from enhancer (stub implementation)

Demo-Site Consolidation:
- Move demo-site to test-sites/demo-site for better organization
- Update build scripts to use new demo-site location
- Maintain all functionality while improving project structure

This continues the unified architecture consolidation by moving core content
processing logic to the engine and organizing related files properly.
This commit is contained in:
2025-09-16 15:39:25 +02:00
parent 27179dc943
commit d0ac3088b4
22 changed files with 4156 additions and 30 deletions

View File

@@ -62,7 +62,7 @@ func (c *DatabaseClient) GetContent(siteID, contentID string) (*ContentItem, err
}
// GetBulkContent retrieves multiple content items
func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) ([]*ContentItem, error) {
func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) (map[string]ContentItem, error) {
switch c.database.GetDBType() {
case "sqlite3":
contents, err := c.database.GetSQLiteQueries().GetBulkContent(context.Background(), sqlite.GetBulkContentParams{
@@ -73,9 +73,9 @@ func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) ([]*
return nil, err
}
items := make([]*ContentItem, len(contents))
for i, content := range contents {
items[i] = &ContentItem{
items := make(map[string]ContentItem)
for _, content := range contents {
items[content.ID] = ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,
@@ -94,9 +94,9 @@ func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) ([]*
return nil, err
}
items := make([]*ContentItem, len(contents))
for i, content := range contents {
items[i] = &ContentItem{
items := make(map[string]ContentItem)
for _, content := range contents {
items[content.ID] = ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,

505
internal/engine/injector.go Normal file
View File

@@ -0,0 +1,505 @@
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) {
// 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
}

View File

@@ -46,7 +46,7 @@ type ProcessedElement struct {
// This will be implemented by database clients
type ContentClient interface {
GetContent(siteID, contentID string) (*ContentItem, error)
GetBulkContent(siteID string, contentIDs []string) ([]*ContentItem, error)
GetBulkContent(siteID string, contentIDs []string) (map[string]ContentItem, error)
}
// ContentItem represents a piece of content from the database