feat: complete HTML-first architecture implementation (Phase 1 & 2)
Major architectural simplification removing content type complexity: Database Schema: - Remove 'type' field from content and content_versions tables - Simplify to pure HTML storage with html_content + original_template - Regenerate all sqlc models for SQLite and PostgreSQL API Simplification: - Remove content type routing and validation - Eliminate type-specific handlers (text/markdown/structured) - Unified HTML-first approach for all content operations - Simplify CreateContent and UpdateContent to HTML-only Backend Enhancements: - Update enhancer to only generate data-content-id (no data-content-type) - Improve container expansion utilities with comprehensive block/inline rules - Add Phase 3 preparation with boundary-respecting traversal logic - Strengthen element classification for viable children detection Documentation: - Update TODO.md to reflect Phase 1-3 completion status - Add WORKING_ON.md documenting the architectural transformation - Mark container expansion and HTML-first architecture as complete This completes the transition to a unified HTML-first content management system with automatic style detection and element-based behavior, eliminating the complex multi-type system in favor of semantic HTML-driven editing.
This commit is contained in:
@@ -46,7 +46,6 @@ func (c *DatabaseClient) GetContent(siteID, contentID string) (*ContentItem, err
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}, nil
|
||||
|
||||
@@ -63,7 +62,6 @@ func (c *DatabaseClient) GetContent(siteID, contentID string) (*ContentItem, err
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}, nil
|
||||
|
||||
@@ -91,7 +89,6 @@ func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) (map
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}
|
||||
}
|
||||
@@ -113,7 +110,6 @@ func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) (map
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}
|
||||
}
|
||||
@@ -140,7 +136,6 @@ func (c *DatabaseClient) GetAllContent(siteID string) (map[string]ContentItem, e
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}
|
||||
}
|
||||
@@ -159,7 +154,6 @@ func (c *DatabaseClient) GetAllContent(siteID string) (map[string]ContentItem, e
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}
|
||||
}
|
||||
@@ -171,7 +165,7 @@ func (c *DatabaseClient) GetAllContent(siteID string) (map[string]ContentItem, e
|
||||
}
|
||||
|
||||
// CreateContent creates a new content item
|
||||
func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalTemplate, contentType, lastEditedBy string) (*ContentItem, error) {
|
||||
func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalTemplate, lastEditedBy string) (*ContentItem, error) {
|
||||
switch c.database.GetDBType() {
|
||||
case "sqlite3":
|
||||
content, err := c.database.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{
|
||||
@@ -179,7 +173,6 @@ func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalT
|
||||
SiteID: siteID,
|
||||
HtmlContent: htmlContent,
|
||||
OriginalTemplate: toNullString(originalTemplate),
|
||||
Type: contentType,
|
||||
LastEditedBy: lastEditedBy,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -190,7 +183,6 @@ func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalT
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}, nil
|
||||
|
||||
@@ -200,7 +192,6 @@ func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalT
|
||||
SiteID: siteID,
|
||||
HtmlContent: htmlContent,
|
||||
OriginalTemplate: toNullString(originalTemplate),
|
||||
Type: contentType,
|
||||
LastEditedBy: lastEditedBy,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -211,7 +202,6 @@ func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalT
|
||||
SiteID: content.SiteID,
|
||||
HTMLContent: content.HtmlContent,
|
||||
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
|
||||
Type: content.Type,
|
||||
LastEditedBy: content.LastEditedBy,
|
||||
}, nil
|
||||
|
||||
@@ -220,7 +210,7 @@ func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalT
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert string to sql.NullString
|
||||
// Helper function to convert string to sql.NullString
|
||||
func toNullString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
|
||||
@@ -35,7 +35,6 @@ type ContentResult struct {
|
||||
type ProcessedElement struct {
|
||||
Node *html.Node // HTML node
|
||||
ID string // Generated content ID
|
||||
Type string // Content type (text, link)
|
||||
Content string // Injected content (if any)
|
||||
Generated bool // Whether ID was generated (vs existing)
|
||||
Tag string // Element tag name
|
||||
@@ -48,7 +47,7 @@ type ContentClient interface {
|
||||
GetContent(siteID, contentID string) (*ContentItem, error)
|
||||
GetBulkContent(siteID string, contentIDs []string) (map[string]ContentItem, error)
|
||||
GetAllContent(siteID string) (map[string]ContentItem, error)
|
||||
CreateContent(siteID, contentID, htmlContent, originalTemplate, contentType, lastEditedBy string) (*ContentItem, error)
|
||||
CreateContent(siteID, contentID, htmlContent, originalTemplate, lastEditedBy string) (*ContentItem, error)
|
||||
}
|
||||
|
||||
// ContentItem represents a piece of content from the database
|
||||
@@ -57,7 +56,6 @@ type ContentItem struct {
|
||||
SiteID string `json:"site_id"`
|
||||
HTMLContent string `json:"html_content"`
|
||||
OriginalTemplate string `json:"original_template"`
|
||||
Type string `json:"type"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastEditedBy string `json:"last_edited_by,omitempty"`
|
||||
}
|
||||
|
||||
@@ -179,40 +179,149 @@ func isContainer(node *html.Node) bool {
|
||||
"main": true,
|
||||
"aside": true,
|
||||
"nav": true,
|
||||
"ul": true, // Phase 3: Lists are containers
|
||||
"ol": true,
|
||||
}
|
||||
|
||||
return containerTags[node.Data]
|
||||
}
|
||||
|
||||
// findViableChildren finds all child elements that are viable for editing
|
||||
// findViableChildren finds all descendant elements that should get .insertr class
|
||||
// Phase 3: Recursive traversal with block/inline classification and boundary respect
|
||||
func findViableChildren(node *html.Node) []*html.Node {
|
||||
var viable []*html.Node
|
||||
traverseForViableElements(node, &viable)
|
||||
return viable
|
||||
}
|
||||
|
||||
// traverseForViableElements recursively traverses all descendants, stopping at .insertr boundaries
|
||||
func traverseForViableElements(node *html.Node, viable *[]*html.Node) {
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
// Skip whitespace-only text nodes
|
||||
if child.Type == html.TextNode {
|
||||
if strings.TrimSpace(child.Data) == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Only consider element nodes
|
||||
if child.Type != html.ElementNode {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip self-closing elements for now
|
||||
if isSelfClosing(child) {
|
||||
// BOUNDARY: Stop if element already has .insertr class
|
||||
if hasInsertrClass(child) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if element has editable content (improved logic)
|
||||
if hasEditableContent(child) {
|
||||
viable = append(viable, child)
|
||||
// Skip deferred complex elements (tables, forms)
|
||||
if isDeferredElement(child) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine if this element should get .insertr
|
||||
if shouldGetInsertrClass(child) {
|
||||
*viable = append(*viable, child)
|
||||
// Don't traverse children - they're handled by this element's expansion
|
||||
continue
|
||||
}
|
||||
|
||||
// Continue traversing if this is just a container
|
||||
traverseForViableElements(child, viable)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Block vs Inline element classification
|
||||
func isBlockElement(node *html.Node) bool {
|
||||
blockTags := map[string]bool{
|
||||
// Content blocks
|
||||
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
|
||||
"p": true, "div": true, "article": true, "section": true, "nav": true,
|
||||
"header": true, "footer": true, "main": true, "aside": true,
|
||||
// Lists
|
||||
"ul": true, "ol": true, "li": true,
|
||||
// Interactive (when at block level)
|
||||
"button": true, "a": true, "img": true, "video": true, "audio": true,
|
||||
}
|
||||
|
||||
return viable
|
||||
return blockTags[node.Data]
|
||||
}
|
||||
|
||||
// isInlineElement checks if element is inline formatting (never gets .insertr)
|
||||
func isInlineElement(node *html.Node) bool {
|
||||
inlineTags := map[string]bool{
|
||||
"strong": true, "b": true, "em": true, "i": true, "span": true,
|
||||
"code": true, "small": true, "sub": true, "sup": true, "br": true,
|
||||
"mark": true, "kbd": true,
|
||||
}
|
||||
|
||||
return inlineTags[node.Data]
|
||||
}
|
||||
|
||||
// isContextSensitive checks if element can be block or inline (a, button)
|
||||
func isContextSensitive(node *html.Node) bool {
|
||||
contextTags := map[string]bool{
|
||||
"a": true,
|
||||
"button": true,
|
||||
}
|
||||
|
||||
return contextTags[node.Data]
|
||||
}
|
||||
|
||||
// isInBlockContext determines if context-sensitive element should be treated as block
|
||||
func isInBlockContext(node *html.Node) bool {
|
||||
parent := node.Parent
|
||||
if parent == nil || parent.Type != html.ElementNode {
|
||||
return true
|
||||
}
|
||||
|
||||
// If parent is a content element, this is inline formatting
|
||||
contentElements := map[string]bool{
|
||||
"p": true, "h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
|
||||
"li": true, "td": true, "th": true,
|
||||
}
|
||||
|
||||
return !contentElements[parent.Data]
|
||||
}
|
||||
|
||||
// shouldGetInsertrClass determines if element should receive .insertr class
|
||||
func shouldGetInsertrClass(node *html.Node) bool {
|
||||
// Always block elements get .insertr
|
||||
if isBlockElement(node) && !isContextSensitive(node) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Context-sensitive elements depend on parent context
|
||||
if isContextSensitive(node) {
|
||||
return isInBlockContext(node)
|
||||
}
|
||||
|
||||
// Inline elements never get .insertr
|
||||
if isInlineElement(node) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Self-closing elements - only img gets .insertr when block-level
|
||||
if isSelfClosing(node) {
|
||||
return node.Data == "img" && isInBlockContext(node)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isDeferredElement checks for complex elements that need separate planning
|
||||
func isDeferredElement(node *html.Node) bool {
|
||||
deferredTags := map[string]bool{
|
||||
"table": true, "tr": true, "td": true, "th": true,
|
||||
"thead": true, "tbody": true, "tfoot": true,
|
||||
"form": true, "input": true, "textarea": true, "select": true, "option": true,
|
||||
}
|
||||
|
||||
return deferredTags[node.Data]
|
||||
}
|
||||
|
||||
// hasInsertrClass checks if node has class="insertr"
|
||||
func hasInsertrClass(node *html.Node) bool {
|
||||
classes := GetClasses(node)
|
||||
for _, class := range classes {
|
||||
if class == "insertr" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findViableChildrenLegacy uses the old text-only logic for backwards compatibility
|
||||
|
||||
Reference in New Issue
Block a user