feat: implement minimal server-first markdown processing

Backend implementation:
- Add goldmark dependency for markdown processing
- Create MarkdownProcessor with minimal config (bold, italic, links only)
- Update content injector with HTML injection capabilities
- Add injectHTMLContent() for safe DOM manipulation
- Server now converts **bold**, *italic*, [links](url) to HTML during enhancement

Frontend alignment:
- Restrict marked.js to match server capabilities
- Disable unsupported features (headings, lists, code blocks, tables)
- Update turndown rules to prevent unsupported markdown generation
- Frontend editor preview now matches server output exactly

Server as source of truth:
- Build-time markdown→HTML conversion during enhancement
- Zero runtime overhead for end users
- Consistent formatting between editor preview and final output
- Raw markdown stored in database, HTML served to visitors

Tested features:
- **bold** → <strong>bold</strong> 
- *italic* → <em>italic</em> 
- [text](url) → <a href="url">text</a> 
This commit is contained in:
2025-09-11 16:43:40 +02:00
parent 3db1340cce
commit 350c3f6160
6 changed files with 256 additions and 30 deletions

View File

@@ -2,6 +2,7 @@ package content
import (
"fmt"
"log"
"strings"
"golang.org/x/net/html"
@@ -9,15 +10,17 @@ import (
// Injector handles content injection into HTML elements
type Injector struct {
client ContentClient
siteID string
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,
client: client,
siteID: siteID,
mdProcessor: NewMarkdownProcessor(),
}
}
@@ -31,7 +34,7 @@ func (i *Injector) InjectContent(element *Element, contentID string) error {
// If no content found, keep original content but add data attributes
if contentItem == nil {
i.addContentAttributes(element.Node, contentID, element.Type)
i.AddContentAttributes(element.Node, contentID, element.Type)
return nil
}
@@ -48,7 +51,7 @@ func (i *Injector) InjectContent(element *Element, contentID string) error {
}
// Add data attributes for editor functionality
i.addContentAttributes(element.Node, contentID, element.Type)
i.AddContentAttributes(element.Node, contentID, element.Type)
return nil
}
@@ -72,7 +75,7 @@ func (i *Injector) InjectBulkContent(elements []ElementWithID) error {
contentItem, exists := contentMap[elem.ContentID]
// Add content attributes regardless
i.addContentAttributes(elem.Element.Node, elem.ContentID, elem.Element.Type)
i.AddContentAttributes(elem.Element.Node, elem.ContentID, elem.Element.Type)
if !exists {
// Keep original content if not found in database
@@ -112,11 +115,23 @@ func (i *Injector) injectTextContent(node *html.Node, content string) {
node.AppendChild(textNode)
}
// injectMarkdownContent handles markdown content (for now, just as text)
// injectMarkdownContent handles markdown content - converts markdown to HTML
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)
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
@@ -126,8 +141,68 @@ func (i *Injector) injectLinkContent(node *html.Node, content string) {
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) {
// injectHTMLContent safely injects HTML content into a DOM node
func (i *Injector) injectHTMLContent(node *html.Node, htmlContent string) {
// Clear existing content
i.clearNode(node)
if htmlContent == "" {
return
}
// Wrap content to create valid HTML document for 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
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")