diff --git a/internal/engine/collection.go b/internal/engine/collection.go
new file mode 100644
index 0000000..94dcaf7
--- /dev/null
+++ b/internal/engine/collection.go
@@ -0,0 +1,443 @@
+package engine
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/insertr/insertr/internal/db"
+ "golang.org/x/net/html"
+)
+
+// CollectionElement represents an insertr-add collection element found in HTML
+type CollectionElement struct {
+ Node *html.Node
+}
+
+// hasInsertrAddClass checks if node has class="insertr-add" (collection)
+func (e *ContentEngine) hasInsertrAddClass(node *html.Node) bool {
+ classes := GetClasses(node)
+ return ContainsClass(classes, "insertr-add")
+}
+
+// processCollection handles collection detection, persistence and reconstruction
+func (e *ContentEngine) processCollection(collectionNode *html.Node, collectionID, siteID string) error {
+ // 1. Check if collection exists in database
+ existingCollection, err := e.client.GetCollection(context.Background(), siteID, collectionID)
+ collectionExists := (err == nil && existingCollection != nil)
+
+ if !collectionExists {
+ // 2. New collection: extract container HTML and create collection record
+ containerHTML := e.extractOriginalTemplate(collectionNode)
+
+ _, err := e.client.CreateCollection(context.Background(), siteID, collectionID, containerHTML, "system")
+ if err != nil {
+ return fmt.Errorf("failed to create collection %s: %w", collectionID, err)
+ }
+
+ // 3. Extract templates and store initial items from existing children
+ err = e.extractAndStoreTemplatesAndItems(collectionNode, collectionID, siteID)
+ if err != nil {
+ return fmt.Errorf("failed to extract templates and items for collection %s: %w", collectionID, err)
+ }
+
+ fmt.Printf("✅ Created new collection: %s with templates and initial items\n", collectionID)
+ } else {
+ // 4. Existing collection: Always reconstruct from database (database is source of truth)
+ err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
+ if err != nil {
+ return fmt.Errorf("failed to reconstruct collection %s: %w", collectionID, err)
+ }
+
+ // Optional: Show item count for feedback
+ existingItems, _ := e.client.GetCollectionItems(context.Background(), siteID, collectionID)
+ fmt.Printf("✅ Reconstructed collection: %s from database (%d items)\n", collectionID, len(existingItems))
+ }
+
+ return nil
+}
+
+// extractAndStoreTemplatesAndItems extracts templates and stores initial items from existing collection children
+func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.Node, collectionID, siteID string) error {
+ var templateIDs []int
+ templateCount := 0
+
+ // Walk through direct children of the collection
+ for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
+ if child.Type == html.ElementNode {
+ templateCount++
+ }
+ }
+
+ // If no templates found, create a default template
+ if templateCount == 0 {
+ _, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, "default", "
New item
", true)
+ if err != nil {
+ return fmt.Errorf("failed to create default template: %w", err)
+ }
+ fmt.Printf("✅ Created default template for collection %s\n", collectionID)
+ return nil
+ }
+
+ // Create templates for each unique child structure
+ templateIndex := 0
+ for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
+ if child.Type == html.ElementNode {
+ templateName := fmt.Sprintf("template_%d", templateIndex+1)
+ templateHTML := e.extractCleanTemplate(child)
+ isDefault := templateIndex == 0
+
+ template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault)
+ if err != nil {
+ return fmt.Errorf("failed to create template %s: %w", templateName, err)
+ }
+ templateIDs = append(templateIDs, template.TemplateID)
+ templateIndex++
+ fmt.Printf("✅ Created template '%s' for collection %s\n", templateName, collectionID)
+ }
+ }
+
+ // Store original children as initial collection items (database-first approach)
+ err := e.storeChildrenAsCollectionItems(collectionNode, collectionID, siteID, templateIDs)
+ if err != nil {
+ return fmt.Errorf("failed to store initial collection items: %w", err)
+ }
+
+ // Clear HTML children and reconstruct from database (ensures consistency)
+ err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
+ if err != nil {
+ return fmt.Errorf("failed to reconstruct initial collection items: %w", err)
+ }
+
+ return nil
+}
+
+// reconstructCollectionItems rebuilds collection items from database and adds them to DOM
+func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, collectionID, siteID string) error {
+ // Get all items for this collection from database
+ items, err := e.client.GetCollectionItems(context.Background(), siteID, collectionID)
+ if err != nil {
+ return fmt.Errorf("failed to get collection items: %w", err)
+ }
+
+ // Get templates for this collection
+ templates, err := e.client.GetCollectionTemplates(context.Background(), siteID, collectionID)
+ if err != nil {
+ return fmt.Errorf("failed to get collection templates: %w", err)
+ }
+
+ // Build template lookup for efficiency
+ templateLookup := make(map[int]*db.CollectionTemplateItem)
+ for _, template := range templates {
+ templateLookup[template.TemplateID] = &template
+ }
+
+ // Clear existing children from the collection node
+ for child := collectionNode.FirstChild; child != nil; {
+ next := child.NextSibling
+ collectionNode.RemoveChild(child)
+ child = next
+ }
+
+ // Reconstruct items in order from database
+ for _, item := range items {
+ _, exists := templateLookup[item.TemplateID]
+ if !exists {
+ fmt.Printf("⚠️ Template %d not found for item %s, skipping\n", item.TemplateID, item.ItemID)
+ continue
+ }
+
+ // Parse the stored structural template HTML
+ structuralDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
+ if err != nil {
+ fmt.Printf("⚠️ Failed to parse structural template for item %s: %v\n", item.ItemID, err)
+ continue
+ }
+
+ // Find the body and extract its children (stored as complete structure)
+ var structuralChild *html.Node
+ e.walkNodes(structuralDoc, func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Data == "body" {
+ // Get the first element child of body
+ for child := n.FirstChild; child != nil; child = child.NextSibling {
+ if child.Type == html.ElementNode {
+ structuralChild = child
+ break
+ }
+ }
+ }
+ })
+
+ if structuralChild != nil {
+ // Remove from its current parent before adding to collection
+ if structuralChild.Parent != nil {
+ structuralChild.Parent.RemoveChild(structuralChild)
+ }
+
+ // Add hydrated structural elements directly to collection (stored HTML has complete structure)
+ // The structural template already contains hydrated content from database
+
+ // Inject data-item-id attribute for collection item identification
+ if structuralChild.Type == html.ElementNode {
+ SetAttribute(structuralChild, "data-item-id", item.ItemID)
+ }
+
+ collectionNode.AppendChild(structuralChild)
+ }
+ }
+
+ fmt.Printf("✅ Reconstructed %d items for collection %s\n", len(items), collectionID)
+ return nil
+}
+
+// processChildElementsAsContent processes .insertr elements within a collection child and stores them as individual content
+func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, siteID, itemID string) ([]ContentEntry, error) {
+ var contentEntries []ContentEntry
+
+ // Walk through the child element and find .insertr elements
+ e.walkNodes(childElement, func(n *html.Node) {
+ if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
+ // Generate content ID for this .insertr element
+ contentID := e.idGenerator.Generate(n, "collection-item")
+
+ // Extract the content
+ htmlContent := e.extractHTMLContent(n)
+ template := e.extractCleanTemplate(n)
+
+ // Store content entry
+ contentEntries = append(contentEntries, ContentEntry{
+ ID: contentID,
+ SiteID: siteID,
+ HTMLContent: htmlContent,
+ Template: template,
+ })
+
+ // Set the data-content-id attribute
+ SetAttribute(n, "data-content-id", contentID)
+
+ // Clear content - this will be hydrated during reconstruction
+ for child := n.FirstChild; child != nil; {
+ next := child.NextSibling
+ n.RemoveChild(child)
+ child = next
+ }
+ }
+ })
+
+ return contentEntries, nil
+}
+
+// generateStructuralTemplateFromChild creates a structural template with placeholders for content
+func (e *ContentEngine) generateStructuralTemplateFromChild(childElement *html.Node, contentEntries []ContentEntry) (string, error) {
+ // Clone the child to avoid modifying the original
+ clonedChild := e.cloneNode(childElement)
+
+ // Walk through and replace .insertr content with data-content-id attributes
+ entryIndex := 0
+ e.walkNodes(clonedChild, func(n *html.Node) {
+ if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
+ if entryIndex < len(contentEntries) {
+ // Set the data-content-id attribute
+ SetAttribute(n, "data-content-id", contentEntries[entryIndex].ID)
+
+ // Clear content - this will be hydrated during reconstruction
+ for child := n.FirstChild; child != nil; {
+ next := child.NextSibling
+ n.RemoveChild(child)
+ child = next
+ }
+
+ entryIndex++
+ }
+ }
+ })
+
+ // Generate HTML for the structural template
+ var buf strings.Builder
+ if err := html.Render(&buf, clonedChild); err != nil {
+ return "", fmt.Errorf("failed to render structural template: %w", err)
+ }
+
+ return buf.String(), nil
+}
+
+// createVirtualElementFromTemplate creates a virtual DOM element from template HTML
+func (e *ContentEngine) createVirtualElementFromTemplate(templateHTML string) (*html.Node, error) {
+ // Parse template HTML into a virtual DOM
+ templateDoc, err := html.Parse(strings.NewReader(templateHTML))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse template HTML: %w", err)
+ }
+
+ // Find the first element in the body
+ var templateElement *html.Node
+ e.walkNodes(templateDoc, func(n *html.Node) {
+ if templateElement == nil && n.Type == html.ElementNode && n.Data != "html" && n.Data != "head" && n.Data != "body" {
+ templateElement = n
+ }
+ })
+
+ if templateElement == nil {
+ return nil, fmt.Errorf("no valid element found in template HTML")
+ }
+
+ return templateElement, nil
+}
+
+// CreateCollectionItemFromTemplate creates a collection item using the unified engine approach
+func (e *ContentEngine) CreateCollectionItemFromTemplate(
+ siteID, collectionID string,
+ templateID int,
+ templateHTML string,
+ lastEditedBy string,
+) (*db.CollectionItemWithTemplate, error) {
+ // Create virtual element from template for ID generation
+ virtualElement, err := e.createVirtualElementFromTemplate(templateHTML)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create virtual element: %w", err)
+ }
+
+ // Generate unique item ID using unified generator with collection context
+ itemID := e.idGenerator.Generate(virtualElement, "collection-item")
+
+ // Process any .insertr elements within the template and store as content
+ contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to process child elements: %w", err)
+ }
+
+ // Store individual content entries in content table
+ for _, entry := range contentEntries {
+ _, err := e.client.CreateContent(context.Background(), entry.SiteID, entry.ID, entry.HTMLContent, entry.Template, lastEditedBy)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create content entry %s: %w", entry.ID, err)
+ }
+ }
+
+ // Generate structural template for the collection item
+ structuralTemplate, err := e.generateStructuralTemplateFromChild(virtualElement, contentEntries)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate structural template: %w", err)
+ }
+
+ // Create collection item with structural template
+ collectionItem, err := e.client.CreateCollectionItem(context.Background(),
+ siteID, collectionID, itemID, templateID, structuralTemplate, 0, lastEditedBy,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create collection item: %w", err)
+ }
+
+ return collectionItem, nil
+}
+
+// storeChildrenAsCollectionItems stores HTML children as collection items in database
+func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node, collectionID, siteID string, templateIDs []int) error {
+ var childElements []*html.Node
+
+ // Walk through direct children of the collection
+ for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
+ if child.Type == html.ElementNode {
+ childElements = append(childElements, child)
+ }
+ }
+
+ if len(childElements) == 0 {
+ fmt.Printf("ℹ️ No children found to store as collection items for %s\n", collectionID)
+ return nil
+ }
+
+ // Store each child as a collection item
+ for i, childElement := range childElements {
+ // Use corresponding template ID, or default to first template
+ templateID := templateIDs[0] // Default to first template
+ if i < len(templateIDs) {
+ templateID = templateIDs[i]
+ }
+
+ // Generate item ID using unified generator with collection context
+ itemID := e.idGenerator.Generate(childElement, "collection-item")
+
+ // Process any .insertr elements within this child and store as content
+ contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID)
+ if err != nil {
+ return fmt.Errorf("failed to process child elements: %w", err)
+ }
+
+ // Store individual content entries in content table
+ for _, entry := range contentEntries {
+ _, err := e.client.CreateContent(context.Background(), entry.SiteID, entry.ID, entry.HTMLContent, entry.Template, "system")
+ if err != nil {
+ return fmt.Errorf("failed to create content entry %s: %w", entry.ID, err)
+ }
+ }
+
+ // Generate structural template for this collection item
+ structuralTemplate, err := e.generateStructuralTemplateFromChild(childElement, contentEntries)
+ if err != nil {
+ return fmt.Errorf("failed to generate structural template: %w", err)
+ }
+
+ // Store structural template in collection_items (content lives in content table)
+ _, err = e.client.CreateCollectionItem(context.Background(), siteID, collectionID, itemID, templateID, structuralTemplate, i+1, "system")
+ if err != nil {
+ return fmt.Errorf("failed to create collection item %s: %w", itemID, err)
+ }
+
+ fmt.Printf("✅ Stored initial collection item: %s (template %d) with %d content entries\n", itemID, templateID, len(contentEntries))
+ }
+
+ return nil
+}
+
+// collectionProcessor handles collection-specific processing logic
+type collectionProcessor struct {
+ engine *ContentEngine
+}
+
+// newCollectionProcessor creates a collection processor
+func (e *ContentEngine) newCollectionProcessor() *collectionProcessor {
+ return &collectionProcessor{engine: e}
+}
+
+// process handles the full collection processing workflow
+func (cp *collectionProcessor) process(collectionNode *html.Node, collectionID, siteID string) error {
+ return cp.engine.processCollection(collectionNode, collectionID, siteID)
+}
+
+// extractAndStoreTemplatesAndItems delegates to engine method
+func (cp *collectionProcessor) extractAndStoreTemplatesAndItems(collectionNode *html.Node, collectionID, siteID string) error {
+ return cp.engine.extractAndStoreTemplatesAndItems(collectionNode, collectionID, siteID)
+}
+
+// reconstructItems delegates to engine method
+func (cp *collectionProcessor) reconstructItems(collectionNode *html.Node, collectionID, siteID string) error {
+ return cp.engine.reconstructCollectionItems(collectionNode, collectionID, siteID)
+}
+
+// cloneNode creates a deep copy of an HTML node
+func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
+ cloned := &html.Node{
+ Type: node.Type,
+ Data: node.Data,
+ DataAtom: node.DataAtom,
+ Namespace: node.Namespace,
+ }
+
+ // Clone attributes
+ for _, attr := range node.Attr {
+ cloned.Attr = append(cloned.Attr, html.Attribute{
+ Namespace: attr.Namespace,
+ Key: attr.Key,
+ Val: attr.Val,
+ })
+ }
+
+ // Clone children recursively
+ for child := node.FirstChild; child != nil; child = child.NextSibling {
+ clonedChild := e.cloneNode(child)
+ cloned.AppendChild(clonedChild)
+ }
+
+ return cloned
+}
diff --git a/internal/engine/content.go b/internal/engine/content.go
new file mode 100644
index 0000000..e4f74eb
--- /dev/null
+++ b/internal/engine/content.go
@@ -0,0 +1,144 @@
+package engine
+
+import (
+ "slices"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+// addContentAttributes adds data-content-id attribute only
+func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string) {
+ // Add data-content-id attribute
+ SetAttribute(node, "data-content-id", contentID)
+}
+
+// injectContent injects content from database into elements
+func (e *ContentEngine) injectContent(elements []ProcessedElement, siteID string) error {
+ for i := range elements {
+ elem := &elements[i]
+
+ // Get content from database by ID
+ contentItem, err := e.client.GetContent(nil, siteID, elem.ID)
+ if err != nil {
+ // Content not found - skip silently (enhancement mode should not fail on missing content)
+ continue
+ }
+
+ if contentItem != nil {
+ // Inject the content into the element
+ elem.Content = contentItem.HTMLContent
+
+ // Update injector siteID for this operation
+ // HACK: I do not like this. Injector refactor?
+ e.injector.siteID = siteID
+ e.injector.injectHTMLContent(elem.Node, contentItem.HTMLContent)
+ }
+ }
+ return nil
+}
+
+// extractHTMLContent extracts the inner HTML content from a node
+func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
+ var content strings.Builder
+
+ // Render all child nodes in order to preserve HTML structure
+ for child := node.FirstChild; child != nil; child = child.NextSibling {
+ if err := html.Render(&content, child); err == nil {
+ // All nodes (text and element) rendered in correct order
+ }
+ }
+
+ return strings.TrimSpace(content.String())
+}
+
+// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
+func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
+ var buf strings.Builder
+ if err := html.Render(&buf, node); err != nil {
+ return ""
+ }
+ return buf.String()
+}
+
+// extractCleanTemplate extracts a clean template without data-content-id attributes and with placeholder content. Used for collection template variants.
+func (e *ContentEngine) extractCleanTemplate(node *html.Node) string {
+ // Clone the node to avoid modifying the original
+ clonedNode := e.cloneNode(node)
+
+ // Remove all data-content-id attributes and replace content with placeholders
+ e.walkNodes(clonedNode, func(n *html.Node) {
+ if n.Type == html.ElementNode {
+ // Remove data-content-id attribute
+ e.removeAttribute(n, "data-content-id")
+
+ // If this is an .insertr element, replace content with placeholder
+ if e.hasClass(n, "insertr") {
+ placeholderText := e.getPlaceholderForElement(n.Data)
+ // Clear existing children and add placeholder text
+ for child := n.FirstChild; child != nil; {
+ next := child.NextSibling
+ n.RemoveChild(child)
+ child = next
+ }
+ n.AppendChild(&html.Node{
+ Type: html.TextNode,
+ Data: placeholderText,
+ })
+ }
+ }
+ })
+
+ var buf strings.Builder
+ if err := html.Render(&buf, clonedNode); err != nil {
+ return ""
+ }
+ return buf.String()
+}
+
+// removeAttribute removes an attribute from an HTML node
+func (e *ContentEngine) removeAttribute(n *html.Node, key string) {
+ for i, attr := range n.Attr {
+ if attr.Key == key {
+ n.Attr = slices.Delete(n.Attr, i, i+1)
+ break
+ }
+ }
+}
+
+// hasClass checks if a node has a specific class
+func (e *ContentEngine) hasClass(n *html.Node, className string) bool {
+ for _, attr := range n.Attr {
+ if attr.Key == "class" {
+ classes := strings.Fields(attr.Val)
+ if slices.Contains(classes, className) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// getPlaceholderForElement returns appropriate placeholder text for different element types
+func (e *ContentEngine) getPlaceholderForElement(elementType string) string {
+ placeholders := map[string]string{
+ "h1": "Heading 1",
+ "h2": "Heading 2",
+ "h3": "Heading 3",
+ "h4": "Heading 4",
+ "h5": "Heading 5",
+ "h6": "Heading 6",
+ "p": "Paragraph text",
+ "span": "Text",
+ "div": "Content block",
+ "button": "Button",
+ "a": "Link text",
+ "li": "List item",
+ "blockquote": "Quote text",
+ }
+
+ if placeholder, exists := placeholders[elementType]; exists {
+ return placeholder
+ }
+ return "Enter content..."
+}
diff --git a/internal/engine/discovery.go b/internal/engine/discovery.go
new file mode 100644
index 0000000..bf230e5
--- /dev/null
+++ b/internal/engine/discovery.go
@@ -0,0 +1,84 @@
+package engine
+
+import (
+ "golang.org/x/net/html"
+)
+
+// InsertrElement represents an insertr element found in HTML
+type InsertrElement struct {
+ Node *html.Node
+}
+
+// findEditableElements finds all editable elements (.insertr and .insertr-add)
+func (e *ContentEngine) findEditableElements(doc *html.Node) ([]InsertrElement, []CollectionElement) {
+ // Phase 1: Pure discovery
+ insertrElements, collectionElements, containers := e.discoverElements(doc)
+
+ // Phase 2: Container expansion (separate concern)
+ expandedElements := e.expandContainers(containers)
+ insertrElements = append(insertrElements, expandedElements...)
+
+ return insertrElements, collectionElements
+}
+
+// discoverElements performs pure element discovery without transformation
+func (e *ContentEngine) discoverElements(doc *html.Node) ([]InsertrElement, []CollectionElement, []*html.Node) {
+ var insertrElements []InsertrElement
+ var collectionElements []CollectionElement
+ var containersToTransform []*html.Node
+
+ // Walk the document and categorize elements
+ e.walkNodes(doc, func(n *html.Node) {
+ if n.Type == html.ElementNode {
+ if hasInsertrClass(n) {
+ if isContainer(n) {
+ // Container element - mark for transformation
+ containersToTransform = append(containersToTransform, n)
+ } else {
+ // Regular element - add directly
+ insertrElements = append(insertrElements, InsertrElement{
+ Node: n,
+ })
+ }
+ }
+ if e.hasInsertrAddClass(n) {
+ // Collection element - add directly (no container transformation for collections)
+ collectionElements = append(collectionElements, CollectionElement{
+ Node: n,
+ })
+ }
+ }
+ })
+
+ return insertrElements, collectionElements, containersToTransform
+}
+
+// expandContainers transforms container elements by removing .insertr from containers
+// and adding .insertr to their viable children
+func (e *ContentEngine) expandContainers(containers []*html.Node) []InsertrElement {
+ var expandedElements []InsertrElement
+
+ for _, container := range containers {
+ // Remove .insertr class from container
+ RemoveClass(container, "insertr")
+
+ // Find viable children and add .insertr class to them
+ viableChildren := FindViableChildren(container)
+ for _, child := range viableChildren {
+ AddClass(child, "insertr")
+ expandedElements = append(expandedElements, InsertrElement{
+ Node: child,
+ })
+ }
+ }
+
+ return expandedElements
+}
+
+// walkNodes walks through all nodes in the document
+func (e *ContentEngine) walkNodes(n *html.Node, fn func(*html.Node)) {
+ fn(n)
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ e.walkNodes(c, fn)
+ }
+}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 59fd7ac..0491f1d 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -7,7 +7,6 @@ import (
"github.com/insertr/insertr/internal/db"
"golang.org/x/net/html"
- "slices"
)
// AuthProvider represents authentication provider information
@@ -141,629 +140,3 @@ func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, erro
GeneratedIDs: generatedIDs,
}, nil
}
-
-// InsertrElement represents an insertr element found in HTML
-type InsertrElement struct {
- Node *html.Node
-}
-
-// CollectionElement represents an insertr-add collection element found in HTML
-type CollectionElement struct {
- Node *html.Node
-}
-
-// findEditableElements finds all editable elements (.insertr and .insertr-add)
-func (e *ContentEngine) findEditableElements(doc *html.Node) ([]InsertrElement, []CollectionElement) {
- var insertrElements []InsertrElement
- var collectionElements []CollectionElement
- var containersToTransform []*html.Node
-
- // First pass: find all .insertr and .insertr-add elements
- e.walkNodes(doc, func(n *html.Node) {
- if n.Type == html.ElementNode {
- if hasInsertrClass(n) {
- if isContainer(n) {
- // Container element - mark for transformation
- containersToTransform = append(containersToTransform, n)
- } else {
- // Regular element - add directly
- insertrElements = append(insertrElements, InsertrElement{
- Node: n,
- })
- }
- }
- if e.hasInsertrAddClass(n) {
- // Collection element - add directly (no container transformation for collections)
- collectionElements = append(collectionElements, CollectionElement{
- Node: n,
- })
- }
- }
- })
-
- // Second pass: transform .insertr containers (remove .insertr from container, add to children)
- for _, container := range containersToTransform {
- // Remove .insertr class from container
- e.removeClass(container, "insertr")
-
- // Find viable children and add .insertr class to them
- viableChildren := FindViableChildren(container)
- for _, child := range viableChildren {
- AddClass(child, "insertr")
- insertrElements = append(insertrElements, InsertrElement{
- Node: child,
- })
- }
- }
-
- return insertrElements, collectionElements
-}
-
-// walkNodes walks through all nodes in the document
-func (e *ContentEngine) walkNodes(n *html.Node, fn func(*html.Node)) {
- fn(n)
- for c := n.FirstChild; c != nil; c = c.NextSibling {
- e.walkNodes(c, fn)
- }
-}
-
-// hasInsertrAddClass checks if node has class="insertr-add" (collection)
-func (e *ContentEngine) hasInsertrAddClass(node *html.Node) bool {
- classes := GetClasses(node)
- return slices.Contains(classes, "insertr-add")
-}
-
-// addContentAttributes adds data-content-id attribute only
-func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string) {
- // Add data-content-id attribute
- SetAttribute(node, "data-content-id", contentID)
-}
-
-// removeClass safely removes a class from an HTML node
-func (e *ContentEngine) removeClass(node *html.Node, className string) {
- var classIndex int = -1
-
- // Find existing class attribute
- for idx, attr := range node.Attr {
- if attr.Key == "class" {
- classIndex = idx
- break
- }
- }
-
- if classIndex == -1 {
- return // No class attribute found
- }
-
- // Parse existing classes
- classes := strings.Fields(node.Attr[classIndex].Val)
-
- // Filter out the target class
- var newClasses []string
- for _, class := range classes {
- if class != className {
- newClasses = append(newClasses, class)
- }
- }
-
- // Update or remove class attribute
- if len(newClasses) == 0 {
- // Remove class attribute entirely if no classes remain
- node.Attr = slices.Delete(node.Attr, classIndex, classIndex+1)
- } else {
- // Update class attribute with remaining classes
- node.Attr[classIndex].Val = strings.Join(newClasses, " ")
- }
-}
-
-// injectContent injects content from database into elements
-func (e *ContentEngine) injectContent(elements []ProcessedElement, siteID string) error {
- for i := range elements {
- elem := &elements[i]
-
- // Try to get content from database
- contentItem, err := e.client.GetContent(context.Background(), siteID, elem.ID)
- if err != nil {
- // Content not found is not an error - element just won't have injected content
- continue
- }
-
- if contentItem != nil {
- // Inject the content into the element
- elem.Content = contentItem.HTMLContent
-
- // Update injector siteID for this operation
- // HACK: I do not like this. Injector refactor?
- e.injector.siteID = siteID
- e.injector.injectHTMLContent(elem.Node, contentItem.HTMLContent)
- }
- }
- return nil
-}
-
-// extractHTMLContent extracts the inner HTML content from a node
-func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
- var content strings.Builder
-
- // Render all child nodes in order to preserve HTML structure
- for child := node.FirstChild; child != nil; child = child.NextSibling {
- if err := html.Render(&content, child); err == nil {
- // All nodes (text and element) rendered in correct order
- }
- }
-
- return strings.TrimSpace(content.String())
-}
-
-// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
-// HACK: Rename
-func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
- var buf strings.Builder
- if err := html.Render(&buf, node); err != nil {
- return ""
- }
- return buf.String()
-}
-
-// extractCleanTemplate extracts a clean template without data-content-id attributes and with placeholder content. Used for collection template variants.
-func (e *ContentEngine) extractCleanTemplate(node *html.Node) string {
- // Clone the node to avoid modifying the original
- clonedNode := e.cloneNode(node)
-
- // Remove all data-content-id attributes and replace content with placeholders
- e.walkNodes(clonedNode, func(n *html.Node) {
- if n.Type == html.ElementNode {
- // Remove data-content-id attribute
- e.removeAttribute(n, "data-content-id")
-
- // If this is an .insertr element, replace content with placeholder
- if e.hasClass(n, "insertr") {
- placeholderText := e.getPlaceholderForElement(n.Data)
- // Clear existing children and add placeholder text
- for child := n.FirstChild; child != nil; {
- next := child.NextSibling
- n.RemoveChild(child)
- child = next
- }
- n.AppendChild(&html.Node{
- Type: html.TextNode,
- Data: placeholderText,
- })
- }
- }
- })
-
- var buf strings.Builder
- if err := html.Render(&buf, clonedNode); err != nil {
- return ""
- }
- return buf.String()
-}
-
-// removeAttribute removes an attribute from an HTML node
-func (e *ContentEngine) removeAttribute(n *html.Node, key string) {
- for i, attr := range n.Attr {
- if attr.Key == key {
- n.Attr = slices.Delete(n.Attr, i, i+1)
- break
- }
- }
-}
-
-// hasClass checks if an HTML node has a specific class
-func (e *ContentEngine) hasClass(n *html.Node, className string) bool {
- for _, attr := range n.Attr {
- if attr.Key == "class" {
- classes := strings.Fields(attr.Val)
- if slices.Contains(classes, className) {
- return true
- }
- }
- }
- return false
-}
-
-// getPlaceholderForElement returns appropriate placeholder text for an element type
-func (e *ContentEngine) getPlaceholderForElement(elementType string) string {
- placeholders := map[string]string{
- "blockquote": "Enter your quote here...",
- "cite": "Enter author name...",
- "h1": "Enter heading...",
- "h2": "Enter heading...",
- "h3": "Enter heading...",
- "p": "Enter text...",
- "span": "Enter text...",
- "div": "Enter content...",
- "a": "Enter link text...",
- }
-
- if placeholder, exists := placeholders[elementType]; exists {
- return placeholder
- }
- return "Enter content..."
-}
-
-// processCollection handles collection detection, persistence and reconstruction
-func (e *ContentEngine) processCollection(collectionNode *html.Node, collectionID, siteID string) error {
- // 1. Check if collection exists in database
- existingCollection, err := e.client.GetCollection(context.Background(), siteID, collectionID)
- collectionExists := (err == nil && existingCollection != nil)
-
- if !collectionExists {
- // 2. New collection: extract container HTML and create collection record
- containerHTML := e.extractOriginalTemplate(collectionNode)
-
- _, err := e.client.CreateCollection(context.Background(), siteID, collectionID, containerHTML, "system")
- if err != nil {
- return fmt.Errorf("failed to create collection %s: %w", collectionID, err)
- }
-
- // 3. Extract templates and store initial items from existing children
- err = e.extractAndStoreTemplatesAndItems(collectionNode, collectionID, siteID)
- if err != nil {
- return fmt.Errorf("failed to extract templates and items for collection %s: %w", collectionID, err)
- }
-
- fmt.Printf("✅ Created new collection: %s with templates and initial items\n", collectionID)
- } else {
- // 4. Existing collection: Always reconstruct from database (database is source of truth)
- err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
- if err != nil {
- return fmt.Errorf("failed to reconstruct collection %s: %w", collectionID, err)
- }
-
- // Get final item count for logging
- existingItems, _ := e.client.GetCollectionItems(context.Background(), siteID, collectionID)
- fmt.Printf("✅ Reconstructed collection: %s from database (%d items)\n", collectionID, len(existingItems))
- }
-
- return nil
-}
-
-// extractAndStoreTemplatesAndItems extracts templates and stores initial items from existing collection children
-func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.Node, collectionID, siteID string) error {
- // Find existing children elements to use as templates
- var templateElements []*html.Node
-
- // Walk through direct children of the collection
- for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
- if child.Type == html.ElementNode {
- templateElements = append(templateElements, child)
- }
- }
-
- if len(templateElements) == 0 {
- // No existing children - create a default empty template
- _, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, "default", "New item
", true)
- if err != nil {
- return fmt.Errorf("failed to create default template: %w", err)
- }
- fmt.Printf("✅ Created default template for collection %s\n", collectionID)
- return nil
- }
-
- // Extract templates from existing children and store them
- var templateIDs []int
- for i, templateElement := range templateElements {
- templateHTML := e.extractCleanTemplate(templateElement)
- templateName := fmt.Sprintf("template-%d", i+1)
- isDefault := (i == 0) // First template is default
-
- template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault)
- if err != nil {
- return fmt.Errorf("failed to create template %s: %w", templateName, err)
- }
-
- templateIDs = append(templateIDs, template.TemplateID)
- fmt.Printf("✅ Created template '%s' for collection %s\n", templateName, collectionID)
- }
-
- // Store original children as initial collection items (database-first approach)
- err := e.storeChildrenAsCollectionItems(collectionNode, collectionID, siteID, templateIDs)
- if err != nil {
- return fmt.Errorf("failed to store initial collection items: %w", err)
- }
-
- // Reconstruct items from database to ensure proper data-item-id injection
- err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
- if err != nil {
- return fmt.Errorf("failed to reconstruct initial collection items: %w", err)
- }
-
- return nil
-}
-
-// reconstructCollectionItems rebuilds collection items from database and adds them to DOM
-func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, collectionID, siteID string) error {
- // Get all items for this collection from database
- items, err := e.client.GetCollectionItems(context.Background(), siteID, collectionID)
- if err != nil {
- return fmt.Errorf("failed to get collection items: %w", err)
- }
-
- // Get templates for this collection
- templates, err := e.client.GetCollectionTemplates(context.Background(), siteID, collectionID)
- if err != nil {
- return fmt.Errorf("failed to get collection templates: %w", err)
- }
-
- // Create a template map for quick lookup
- templateMap := make(map[int]string)
- for _, template := range templates {
- templateMap[template.TemplateID] = template.HTMLTemplate
- }
-
- // Clear existing children (they will be replaced with database items)
- for child := collectionNode.FirstChild; child != nil; {
- next := child.NextSibling
- collectionNode.RemoveChild(child)
- child = next
- }
-
- // Add items from database in position order using unified .insertr approach
- for _, item := range items {
- // Parse the stored structural HTML with content IDs (no template needed for reconstruction)
- structuralDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
- if err != nil {
- fmt.Printf("⚠️ Failed to parse stored HTML for %s: %v\n", item.ItemID, err)
- continue
- }
-
- var structuralBody *html.Node
- e.walkNodes(structuralDoc, func(n *html.Node) {
- if n.Type == html.ElementNode && n.Data == "body" {
- structuralBody = n
- }
- })
-
- if structuralBody != nil {
- // Process each .insertr element using Injector pattern (unified approach)
- injector := NewInjector(e.client, siteID, nil)
-
- // Walk through structural elements and hydrate with content from content table
- e.walkNodes(structuralBody, func(n *html.Node) {
- if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
- // Get content ID from data attribute
- contentID := GetAttribute(n, "data-content-id")
- if contentID != "" {
- // Use Injector to hydrate content (unified .insertr approach)
- element := &Element{Node: n, Type: "html"}
- err := injector.InjectContent(element, contentID)
- if err != nil {
- fmt.Printf("⚠️ Failed to inject content for %s: %v\n", contentID, err)
- }
- }
- }
- })
-
- // Add hydrated structural elements directly to collection (stored HTML has complete structure)
- for structuralChild := structuralBody.FirstChild; structuralChild != nil; {
- next := structuralChild.NextSibling
- structuralBody.RemoveChild(structuralChild)
-
- // Inject data-item-id attribute for collection item identification
- if structuralChild.Type == html.ElementNode {
- SetAttribute(structuralChild, "data-item-id", item.ItemID)
- }
-
- collectionNode.AppendChild(structuralChild)
- structuralChild = next
- }
- }
- }
-
- fmt.Printf("✅ Reconstructed %d items for collection %s\n", len(items), collectionID)
- return nil
-}
-
-// processChildElementsAsContent processes .insertr elements within a collection child and stores them as individual content
-func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, siteID, itemID string) ([]ContentEntry, error) {
- var contentEntries []ContentEntry
- elementIndex := 0
-
- // Walk through child element to find .insertr elements
- e.walkNodes(childElement, func(n *html.Node) {
- if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
- // Use core IDGenerator for unified ID generation (like individual .insertr elements)
- contentID := e.idGenerator.Generate(n, "collection-item")
-
- // Extract actual content from the element
- actualContent := ExtractTextContent(n)
-
- // Store as individual content entry (unified .insertr approach)
- _, err := e.client.CreateContent(context.Background(), siteID, contentID, actualContent, "", "system")
- if err != nil {
- fmt.Printf("⚠️ Failed to create content %s: %v\n", contentID, err)
- return
- }
-
- // Add to content entries for structural template generation
- contentEntries = append(contentEntries, ContentEntry{
- ID: contentID,
- SiteID: siteID,
- HTMLContent: actualContent,
- Template: e.extractOriginalTemplate(n),
- })
-
- elementIndex++
- }
- })
-
- return contentEntries, nil
-}
-
-// generateStructuralTemplateFromChild creates structure-only HTML from child element with content IDs
-func (e *ContentEngine) generateStructuralTemplateFromChild(childElement *html.Node, contentEntries []ContentEntry) (string, error) {
- // Clone the child element to avoid modifying original
- clonedChild := e.cloneNode(childElement)
- entryIndex := 0
-
- // Walk through cloned element and replace content with content IDs
- e.walkNodes(clonedChild, func(n *html.Node) {
- if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
- if entryIndex < len(contentEntries) {
- // Set the data-content-id attribute
- SetAttribute(n, "data-content-id", contentEntries[entryIndex].ID)
-
- // Clear content - this will be hydrated during reconstruction
- for child := n.FirstChild; child != nil; {
- next := child.NextSibling
- n.RemoveChild(child)
- child = next
- }
-
- entryIndex++
- }
- }
- })
-
- // Render the complete structural template including container
- var sb strings.Builder
- html.Render(&sb, clonedChild)
-
- return sb.String(), nil
-}
-
-// createVirtualElementFromTemplate creates a virtual DOM element from template HTML for API usage
-// This allows API path to use the same structure extraction as enhancement path
-func (e *ContentEngine) createVirtualElementFromTemplate(templateHTML string) (*html.Node, error) {
- // Parse the template HTML
- templateDoc, err := html.Parse(strings.NewReader(templateHTML))
- if err != nil {
- return nil, fmt.Errorf("failed to parse template HTML: %w", err)
- }
-
- // Find the body element and extract the template structure
- var templateBody *html.Node
- e.walkNodes(templateDoc, func(n *html.Node) {
- if n.Type == html.ElementNode && n.Data == "body" {
- templateBody = n
- }
- })
-
- if templateBody != nil && templateBody.FirstChild != nil {
- // Return the first child of body (the actual template element)
- return templateBody.FirstChild, nil
- }
-
- return nil, fmt.Errorf("template does not contain valid structure")
-}
-
-// CreateCollectionItemFromTemplate creates a collection item using the unified engine approach
-// This replaces TemplateProcessor with engine-native functionality
-func (e *ContentEngine) CreateCollectionItemFromTemplate(
- siteID, collectionID string,
- templateID int,
- templateHTML string,
- lastEditedBy string,
-) (*db.CollectionItemWithTemplate, error) {
- // Create virtual element from template (like enhancement path)
- virtualElement, err := e.createVirtualElementFromTemplate(templateHTML)
- if err != nil {
- return nil, fmt.Errorf("failed to create virtual element: %w", err)
- }
-
- // Generate unique item ID using unified generator with collection context
- itemID := e.idGenerator.Generate(virtualElement, "collection-item")
- if err != nil {
- return nil, fmt.Errorf("failed to create virtual element: %w", err)
- }
-
- // Process .insertr elements and create content entries (unified approach)
- contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
- if err != nil {
- return nil, fmt.Errorf("failed to process content entries: %w", err)
- }
-
- // Generate structural template using unified engine method
- structuralTemplate, err := e.generateStructuralTemplateFromChild(virtualElement, contentEntries)
- if err != nil {
- return nil, fmt.Errorf("failed to generate structural template: %w", err)
- }
-
- // Create collection item with structural template
- collectionItem, err := e.client.CreateCollectionItem(context.Background(),
- siteID, collectionID, itemID, templateID, structuralTemplate, 0, lastEditedBy,
- )
- if err != nil {
- return nil, fmt.Errorf("failed to create collection item: %w", err)
- }
-
- return collectionItem, nil
-}
-
-// cloneNode creates a deep copy of an HTML node
-func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
- cloned := &html.Node{
- Type: node.Type,
- Data: node.Data,
- DataAtom: node.DataAtom,
- Namespace: node.Namespace,
- }
-
- // Clone attributes
- for _, attr := range node.Attr {
- cloned.Attr = append(cloned.Attr, html.Attribute{
- Namespace: attr.Namespace,
- Key: attr.Key,
- Val: attr.Val,
- })
- }
-
- // Clone children recursively
- for child := node.FirstChild; child != nil; child = child.NextSibling {
- clonedChild := e.cloneNode(child)
- cloned.AppendChild(clonedChild)
- }
-
- return cloned
-}
-
-// storeChildrenAsCollectionItems stores HTML children as collection items in database
-func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node, collectionID, siteID string, templateIDs []int) error {
- // Find existing children elements to store as items
- var childElements []*html.Node
-
- // Walk through direct children of the collection
- for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
- if child.Type == html.ElementNode {
- childElements = append(childElements, child)
- }
- }
-
- if len(childElements) == 0 {
- fmt.Printf("ℹ️ No children found to store as collection items for %s\n", collectionID)
- return nil
- }
-
- // Store each child using unified .insertr approach (content table + structural template)
- for i, childElement := range childElements {
- // Generate item ID using unified generator with collection context
- itemID := e.idGenerator.Generate(childElement, "collection-item")
-
- // Process .insertr elements within this child (unified approach)
- contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID)
- if err != nil {
- return fmt.Errorf("failed to process content for item %s: %w", itemID, err)
- }
-
- // Generate structural template with content IDs (no actual content)
- structuralTemplate, err := e.generateStructuralTemplateFromChild(childElement, contentEntries)
- if err != nil {
- return fmt.Errorf("failed to generate structural template for item %s: %w", itemID, err)
- }
-
- // Use appropriate template ID (cycle through available templates)
- templateID := templateIDs[i%len(templateIDs)]
-
- // Store structural template in collection_items (content lives in content table)
- _, err = e.client.CreateCollectionItem(context.Background(), siteID, collectionID, itemID, templateID, structuralTemplate, i+1, "system")
- if err != nil {
- return fmt.Errorf("failed to create collection item %s: %w", itemID, err)
- }
-
- fmt.Printf("✅ Stored initial collection item: %s (template %d) with %d content entries\n", itemID, templateID, len(contentEntries))
- }
-
- return nil
-}