refactor: extract HTMLCleaner interface and consolidate constants

- Add interfaces.HTMLCleaner interface for better testability
- Add CleanAndTrim() method to simplify repeated pattern
- Move item/lesson type constants to models/item_types.go
- Update all exporters to use interface instead of concrete type
- Replace strings.TrimSpace(CleanHTML(...)) with CleanAndTrim()
This commit is contained in:
2026-09-21 09:46:16 +02:00
parent adb60ca7e5
commit 74906ee6a3
8 changed files with 103 additions and 66 deletions
+2 -3
View File
@@ -13,7 +13,6 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
) )
// Font sizes for DOCX document headings (in half-points, so "32" = 16pt). // Font sizes for DOCX document headings (in half-points, so "32" = 16pt).
@@ -28,7 +27,7 @@ const (
// using the go-docx package. // using the go-docx package.
type DocxExporter struct { type DocxExporter struct {
// htmlCleaner is used to convert HTML content to plain text // htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner htmlCleaner interfaces.HTMLCleaner
// logger is used for logging warnings and errors // logger is used for logging warnings and errors
logger interfaces.Logger logger interfaces.Logger
} }
@@ -42,7 +41,7 @@ type DocxExporter struct {
// //
// Returns: // Returns:
// - An implementation of the Exporter interface for DOCX format // - An implementation of the Exporter interface for DOCX format
func NewDocxExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { func NewDocxExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
return &DocxExporter{ return &DocxExporter{
htmlCleaner: htmlCleaner, htmlCleaner: htmlCleaner,
logger: logger, logger: logger,
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"strings" "strings"
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/services"
) )
// Format constants for supported export formats. // Format constants for supported export formats.
@@ -24,7 +23,7 @@ const (
// It creates appropriate exporter instances based on the requested format. // It creates appropriate exporter instances based on the requested format.
type Factory struct { type Factory struct {
// htmlCleaner is used by exporters to convert HTML content to plain text // htmlCleaner is used by exporters to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner htmlCleaner interfaces.HTMLCleaner
// logger is used by exporters for logging // logger is used by exporters for logging
logger interfaces.Logger logger interfaces.Logger
} }
@@ -39,7 +38,7 @@ type Factory struct {
// //
// Returns: // Returns:
// - An implementation of the ExporterFactory interface // - An implementation of the ExporterFactory interface
func NewFactory(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory { func NewFactory(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory {
return &Factory{ return &Factory{
htmlCleaner: htmlCleaner, htmlCleaner: htmlCleaner,
logger: logger, logger: logger,
+2 -3
View File
@@ -9,7 +9,6 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
) )
//go:embed html_styles.css //go:embed html_styles.css
@@ -22,7 +21,7 @@ var htmlTemplate string
// It converts Articulate Rise course data into a structured HTML document using templates. // It converts Articulate Rise course data into a structured HTML document using templates.
type HTMLExporter struct { type HTMLExporter struct {
// htmlCleaner is used to convert HTML content to plain text when needed // htmlCleaner is used to convert HTML content to plain text when needed
htmlCleaner *services.HTMLCleaner htmlCleaner interfaces.HTMLCleaner
// tmpl holds the parsed HTML template // tmpl holds the parsed HTML template
tmpl *template.Template tmpl *template.Template
// logger is used for logging warnings and errors // logger is used for logging warnings and errors
@@ -39,7 +38,7 @@ type HTMLExporter struct {
// //
// Returns: // Returns:
// - An implementation of the Exporter interface for HTML format // - An implementation of the Exporter interface for HTML format
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { func NewHTMLExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
// Parse the template with custom functions // Parse the template with custom functions
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"safeHTML": func(s string) template.HTML { "safeHTML": func(s string) template.HTML {
+8 -22
View File
@@ -6,24 +6,10 @@ import (
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
) )
// Item type constants.
const (
itemTypeText = "text"
itemTypeList = "list"
itemTypeKnowledgeCheck = "knowledgecheck"
itemTypeMultimedia = "multimedia"
itemTypeImage = "image"
itemTypeInteractive = "interactive"
itemTypeDivider = "divider"
)
// lessonTypeSection identifies a lesson that acts as a section header.
const lessonTypeSection = "section"
// templateData represents the data structure passed to the HTML template. // templateData represents the data structure passed to the HTML template.
type templateData struct { type templateData struct {
Course models.CourseInfo Course models.CourseInfo
@@ -61,7 +47,7 @@ type templateSubItem struct {
} }
// prepareTemplateData converts a Course model into template-friendly data. // prepareTemplateData converts a Course model into template-friendly data.
func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleaner) *templateData { func prepareTemplateData(course *models.Course, htmlCleaner interfaces.HTMLCleaner) *templateData {
data := &templateData{ data := &templateData{
Course: course.Course, Course: course.Course,
ShareID: course.ShareID, ShareID: course.ShareID,
@@ -77,7 +63,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane
Description: lesson.Description, Description: lesson.Description,
} }
if lesson.Type != lessonTypeSection { if lesson.Type != models.LessonTypeSection {
lessonCounter++ lessonCounter++
section.Number = lessonCounter section.Number = lessonCounter
section.Items = prepareItems(lesson.Items, htmlCleaner) section.Items = prepareItems(lesson.Items, htmlCleaner)
@@ -90,7 +76,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane
} }
// prepareItems converts model Items to template Items. // prepareItems converts model Items to template Items.
func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []templateItem { func prepareItems(items []models.Item, htmlCleaner interfaces.HTMLCleaner) []templateItem {
result := make([]templateItem, 0, len(items)) result := make([]templateItem, 0, len(items))
for _, item := range items { for _, item := range items {
@@ -100,9 +86,9 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp
} }
// Set type title for unknown items // Set type title for unknown items
if tItem.Type != itemTypeText && tItem.Type != itemTypeList && tItem.Type != itemTypeKnowledgeCheck && if tItem.Type != models.ItemTypeText && tItem.Type != models.ItemTypeList && tItem.Type != models.ItemTypeKnowledgeCheck &&
tItem.Type != itemTypeMultimedia && tItem.Type != itemTypeImage && tItem.Type != itemTypeInteractive && tItem.Type != models.ItemTypeMultimedia && tItem.Type != models.ItemTypeImage && tItem.Type != models.ItemTypeInteractive &&
tItem.Type != itemTypeDivider { tItem.Type != models.ItemTypeDivider {
caser := cases.Title(language.English) caser := cases.Title(language.English)
tItem.TypeTitle = caser.String(item.Type) tItem.TypeTitle = caser.String(item.Type)
} }
@@ -120,7 +106,7 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp
} }
// Clean HTML for list items // Clean HTML for list items
if tItem.Type == itemTypeList && subItem.Paragraph != "" { if tItem.Type == models.ItemTypeList && subItem.Paragraph != "" {
tSubItem.CleanText = htmlCleaner.CleanHTML(subItem.Paragraph) tSubItem.CleanText = htmlCleaner.CleanHTML(subItem.Paragraph)
} }
+22 -35
View File
@@ -11,14 +11,13 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
) )
// MarkdownExporter implements the Exporter interface for Markdown format. // MarkdownExporter implements the Exporter interface for Markdown format.
// It converts Articulate Rise course data into a structured Markdown document. // It converts Articulate Rise course data into a structured Markdown document.
type MarkdownExporter struct { type MarkdownExporter struct {
// htmlCleaner is used to convert HTML content to plain text // htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner htmlCleaner interfaces.HTMLCleaner
// logger is used for logging warnings and errors // logger is used for logging warnings and errors
logger interfaces.Logger logger interfaces.Logger
} }
@@ -32,7 +31,7 @@ type MarkdownExporter struct {
// //
// Returns: // Returns:
// - An implementation of the Exporter interface for Markdown format // - An implementation of the Exporter interface for Markdown format
func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
return &MarkdownExporter{ return &MarkdownExporter{
htmlCleaner: htmlCleaner, htmlCleaner: htmlCleaner,
logger: logger, logger: logger,
@@ -47,8 +46,7 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title)) fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title))
if course.Course.Description != "" { if course.Course.Description != "" {
desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(course.Course.Description)) if desc := e.htmlCleaner.CleanAndTrim(course.Course.Description); desc != "" {
if desc != "" {
fmt.Fprintf(&buf, "%s\n\n", desc) fmt.Fprintf(&buf, "%s\n\n", desc)
} }
} }
@@ -66,7 +64,7 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
// Process lessons // Process lessons
lessonCounter := 0 lessonCounter := 0
for _, lesson := range course.Course.Lessons { for _, lesson := range course.Course.Lessons {
if lesson.Type == lessonTypeSection { if lesson.Type == models.LessonTypeSection {
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title)) fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title))
continue continue
} }
@@ -75,8 +73,7 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title)) fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title))
if lesson.Description != "" { if lesson.Description != "" {
desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(lesson.Description)) if desc := e.htmlCleaner.CleanAndTrim(lesson.Description); desc != "" {
if desc != "" {
fmt.Fprintf(&buf, "%s\n\n", desc) fmt.Fprintf(&buf, "%s\n\n", desc)
} }
} }
@@ -143,19 +140,19 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
itemType := strings.ToLower(item.Type) itemType := strings.ToLower(item.Type)
switch itemType { switch itemType {
case itemTypeText: case models.ItemTypeText:
e.processTextItem(buf, item, headingPrefix) e.processTextItem(buf, item, headingPrefix)
case itemTypeList: case models.ItemTypeList:
e.processListItem(buf, item) e.processListItem(buf, item)
case itemTypeMultimedia: case models.ItemTypeMultimedia:
e.processMultimediaItem(buf, item, headingPrefix) e.processMultimediaItem(buf, item, headingPrefix)
case itemTypeImage: case models.ItemTypeImage:
e.processImageItem(buf, item, headingPrefix) e.processImageItem(buf, item, headingPrefix)
case itemTypeKnowledgeCheck: case models.ItemTypeKnowledgeCheck:
e.processKnowledgeCheckItem(buf, item, headingPrefix) e.processKnowledgeCheckItem(buf, item, headingPrefix)
case itemTypeInteractive: case models.ItemTypeInteractive:
e.processInteractiveItem(buf, item, headingPrefix) e.processInteractiveItem(buf, item, headingPrefix)
case itemTypeDivider: case models.ItemTypeDivider:
e.processDividerItem(buf) e.processDividerItem(buf)
default: default:
e.processUnknownItem(buf, item, headingPrefix) e.processUnknownItem(buf, item, headingPrefix)
@@ -166,14 +163,12 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) { func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
for _, subItem := range item.Items { for _, subItem := range item.Items {
if subItem.Heading != "" { if subItem.Heading != "" {
heading := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Heading)) if heading := e.htmlCleaner.CleanAndTrim(subItem.Heading); heading != "" {
if heading != "" {
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading) fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
} }
} }
if subItem.Paragraph != "" { if subItem.Paragraph != "" {
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
if paragraph != "" {
fmt.Fprintf(buf, "%s\n\n", paragraph) fmt.Fprintf(buf, "%s\n\n", paragraph)
} }
} }
@@ -184,8 +179,7 @@ func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item,
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) { func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
for _, subItem := range item.Items { for _, subItem := range item.Items {
if subItem.Paragraph != "" { if subItem.Paragraph != "" {
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
if paragraph != "" {
fmt.Fprintf(buf, "- %s\n", paragraph) fmt.Fprintf(buf, "- %s\n", paragraph)
} }
} }
@@ -209,8 +203,7 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models
e.processImageMedia(buf, subItem.Media) e.processImageMedia(buf, subItem.Media)
} }
if subItem.Caption != "" { if subItem.Caption != "" {
caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption)) if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
if caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption) fmt.Fprintf(buf, "*%s*\n", caption)
} }
} }
@@ -241,8 +234,7 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item,
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL) fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
} }
if subItem.Caption != "" { if subItem.Caption != "" {
caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption)) if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
if caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption) fmt.Fprintf(buf, "*%s*\n", caption)
} }
} }
@@ -262,8 +254,7 @@ func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item mod
// processQuestionSubItem processes individual question items. // processQuestionSubItem processes individual question items.
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) { func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" { if subItem.Title != "" {
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
if title != "" {
fmt.Fprintf(buf, "**Question**: %s\n\n", title) fmt.Fprintf(buf, "**Question**: %s\n\n", title)
} }
} }
@@ -271,8 +262,7 @@ func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem mod
e.processAnswers(buf, subItem.Answers) e.processAnswers(buf, subItem.Answers)
if subItem.Feedback != "" { if subItem.Feedback != "" {
feedback := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Feedback)) if feedback := e.htmlCleaner.CleanAndTrim(subItem.Feedback); feedback != "" {
if feedback != "" {
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback) fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
} }
} }
@@ -299,8 +289,7 @@ func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models
fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix) fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix)
for _, subItem := range item.Items { for _, subItem := range item.Items {
if subItem.Title != "" { if subItem.Title != "" {
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
if title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title) fmt.Fprintf(buf, "**%s**\n\n", title)
} }
} }
@@ -326,14 +315,12 @@ func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Ite
// processGenericSubItem processes sub-items for unknown types. // processGenericSubItem processes sub-items for unknown types.
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) { func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" { if subItem.Title != "" {
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
if title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title) fmt.Fprintf(buf, "**%s**\n\n", title)
} }
} }
if subItem.Paragraph != "" { if subItem.Paragraph != "" {
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
if paragraph != "" {
fmt.Fprintf(buf, "%s\n\n", paragraph) fmt.Fprintf(buf, "%s\n\n", paragraph)
} }
} }
+13
View File
@@ -0,0 +1,13 @@
package interfaces
// HTMLCleaner defines the interface for cleaning HTML content.
// Implementations convert HTML to plain text, stripping tags and decoding entities.
type HTMLCleaner interface {
// CleanHTML removes HTML tags and decodes HTML entities from a string.
// Returns plain text suitable for non-HTML output formats.
CleanHTML(htmlStr string) string
// CleanAndTrim removes HTML tags, decodes entities, and trims whitespace.
// Convenience method combining CleanHTML with strings.TrimSpace.
CleanAndTrim(htmlStr string) string
}
+41
View File
@@ -0,0 +1,41 @@
package models
// Item type constants used throughout the application.
// These match the type field values in Articulate Rise course data.
const (
// ItemTypeText represents text content blocks with headings and paragraphs.
ItemTypeText = "text"
// ItemTypeList represents bullet or numbered list content.
ItemTypeList = "list"
// ItemTypeKnowledgeCheck represents quiz or assessment items.
ItemTypeKnowledgeCheck = "knowledgecheck"
// ItemTypeMultimedia represents video or audio content.
ItemTypeMultimedia = "multimedia"
// ItemTypeImage represents standalone image content.
ItemTypeImage = "image"
// ItemTypeInteractive represents interactive elements like accordions or tabs.
ItemTypeInteractive = "interactive"
// ItemTypeDivider represents visual separator elements.
ItemTypeDivider = "divider"
// ItemTypeQuote represents quotation blocks.
ItemTypeQuote = "quote"
// ItemTypeFlipcard represents flashcard-style content.
ItemTypeFlipcard = "flipcard"
)
// Lesson type constants.
const (
// LessonTypeSection represents a section header (not a real lesson).
LessonTypeSection = "section"
// LessonTypeLesson represents a regular lesson.
LessonTypeLesson = "lesson"
)
+13
View File
@@ -7,13 +7,19 @@ import (
"strings" "strings"
"golang.org/x/net/html" "golang.org/x/net/html"
"github.com/kjanat/articulate-parser/internal/interfaces"
) )
// HTMLCleaner provides utilities for converting HTML content to plain text. // HTMLCleaner provides utilities for converting HTML content to plain text.
// It removes HTML tags while preserving their content and converts HTML entities // It removes HTML tags while preserving their content and converts HTML entities
// to their plain text equivalents using proper HTML parsing instead of regex. // to their plain text equivalents using proper HTML parsing instead of regex.
// Implements interfaces.HTMLCleaner.
type HTMLCleaner struct{} type HTMLCleaner struct{}
// Compile-time check that HTMLCleaner implements interfaces.HTMLCleaner.
var _ interfaces.HTMLCleaner = (*HTMLCleaner)(nil)
// NewHTMLCleaner creates a new HTML cleaner instance. // NewHTMLCleaner creates a new HTML cleaner instance.
// This service is typically injected into exporters that need to handle // This service is typically injected into exporters that need to handle
// HTML content from Articulate Rise courses. // HTML content from Articulate Rise courses.
@@ -46,6 +52,13 @@ func (h *HTMLCleaner) CleanHTML(htmlStr string) string {
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
// CleanAndTrim removes HTML tags, decodes entities, and trims whitespace.
// Convenience method that combines CleanHTML with strings.TrimSpace.
// Returns empty string if input is empty or contains only whitespace/tags.
func (h *HTMLCleaner) CleanAndTrim(htmlStr string) string {
return strings.TrimSpace(h.CleanHTML(htmlStr))
}
// extractText recursively traverses the HTML node tree and extracts text content. // extractText recursively traverses the HTML node tree and extracts text content.
// It skips script and style tags to avoid including their content in the output. // It skips script and style tags to avoid including their content in the output.
func extractText(w io.Writer, n *html.Node) { func extractText(w io.Writer, n *html.Node) {