🏗️ **Major Architecture Refactoring: Separate CLI + Server → Unified Binary** **Key Changes:** ✅ **Unified Binary**: Single 'insertr' binary with subcommands (enhance, serve) ✅ **Preserved Database Architecture**: Maintained sophisticated sqlc multi-DB setup ✅ **Smart Configuration**: Viper + YAML config with CLI flag precedence ✅ **Updated Build System**: Unified justfile, Air, and npm scripts **Command Structure:** - `insertr enhance [input-dir]` - Build-time content injection - `insertr serve` - HTTP API server (dev + production modes) - `insertr --config insertr.yaml` - YAML configuration support **Architecture Benefits:** - **Shared Database Layer**: Single source of truth for content models - **Flexible Workflows**: Local DB for dev, remote API for production - **Simple Deployment**: One binary for all use cases - **Better UX**: Consistent configuration across build and runtime **Preserved Features:** - Multi-database support (SQLite + PostgreSQL) - sqlc code generation and type safety - Version control system with rollback - Professional API endpoints - Content enhancement pipeline **Development Workflow:** - `just dev` - Full-stack development (API server + demo site) - `just serve` - API server only - `just enhance` - Build-time content injection - `air` - Hot reload unified binary **Migration:** Consolidated insertr-cli/ and insertr-server/ → unified root structure
237 lines
6.3 KiB
Go
237 lines
6.3 KiB
Go
package content
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// Injector handles content injection into HTML elements
|
|
type Injector struct {
|
|
client ContentClient
|
|
siteID string
|
|
}
|
|
|
|
// NewInjector creates a new content injector
|
|
func NewInjector(client ContentClient, siteID string) *Injector {
|
|
return &Injector{
|
|
client: client,
|
|
siteID: siteID,
|
|
}
|
|
}
|
|
|
|
// 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 (for now, just as text)
|
|
func (i *Injector) injectMarkdownContent(node *html.Node, content string) {
|
|
// For now, treat markdown as text content
|
|
// TODO: Implement markdown to HTML conversion
|
|
i.injectTextContent(node, content)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
func (i *Injector) InjectEditorAssets(doc *html.Node, isDevelopment bool, libraryScript string) {
|
|
// TODO: Implement script injection strategy when we have CDN hosting
|
|
// For now, script injection is disabled since HTML files should include their own script tags
|
|
// Future options:
|
|
// 1. Inject CDN script tag: <script src="https://cdn.jsdelivr.net/npm/@insertr/lib@1.0.0/dist/insertr.js"></script>
|
|
// 2. Inject local script tag for development: <script src="/insertr/insertr.js"></script>
|
|
// 3. Continue with inline injection for certain use cases
|
|
|
|
// Currently disabled to avoid duplicate scripts
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|