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/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// Font sizes for DOCX document headings (in half-points, so "32" = 16pt).
@@ -28,7 +27,7 @@ const (
// using the go-docx package.
type DocxExporter struct {
// 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 interfaces.Logger
}
@@ -42,7 +41,7 @@ type DocxExporter struct {
//
// Returns:
// - 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{
htmlCleaner: htmlCleaner,
logger: logger,
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"strings"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/services"
)
// Format constants for supported export formats.
@@ -24,7 +23,7 @@ const (
// It creates appropriate exporter instances based on the requested format.
type Factory struct {
// 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 interfaces.Logger
}
@@ -39,7 +38,7 @@ type Factory struct {
//
// Returns:
// - 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{
htmlCleaner: htmlCleaner,
logger: logger,
+2 -3
View File
@@ -9,7 +9,6 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
//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.
type HTMLExporter struct {
// 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 *template.Template
// logger is used for logging warnings and errors
@@ -39,7 +38,7 @@ type HTMLExporter struct {
//
// Returns:
// - 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
funcMap := template.FuncMap{
"safeHTML": func(s string) template.HTML {
+8 -22
View File
@@ -6,24 +6,10 @@ import (
"golang.org/x/text/cases"
"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/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.
type templateData struct {
Course models.CourseInfo
@@ -61,7 +47,7 @@ type templateSubItem struct {
}
// 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{
Course: course.Course,
ShareID: course.ShareID,
@@ -77,7 +63,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane
Description: lesson.Description,
}
if lesson.Type != lessonTypeSection {
if lesson.Type != models.LessonTypeSection {
lessonCounter++
section.Number = lessonCounter
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.
func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []templateItem {
func prepareItems(items []models.Item, htmlCleaner interfaces.HTMLCleaner) []templateItem {
result := make([]templateItem, 0, len(items))
for _, item := range items {
@@ -100,9 +86,9 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp
}
// Set type title for unknown items
if tItem.Type != itemTypeText && tItem.Type != itemTypeList && tItem.Type != itemTypeKnowledgeCheck &&
tItem.Type != itemTypeMultimedia && tItem.Type != itemTypeImage && tItem.Type != itemTypeInteractive &&
tItem.Type != itemTypeDivider {
if tItem.Type != models.ItemTypeText && tItem.Type != models.ItemTypeList && tItem.Type != models.ItemTypeKnowledgeCheck &&
tItem.Type != models.ItemTypeMultimedia && tItem.Type != models.ItemTypeImage && tItem.Type != models.ItemTypeInteractive &&
tItem.Type != models.ItemTypeDivider {
caser := cases.Title(language.English)
tItem.TypeTitle = caser.String(item.Type)
}
@@ -120,7 +106,7 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp
}
// Clean HTML for list items
if tItem.Type == itemTypeList && subItem.Paragraph != "" {
if tItem.Type == models.ItemTypeList && 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/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// MarkdownExporter implements the Exporter interface for Markdown format.
// It converts Articulate Rise course data into a structured Markdown document.
type MarkdownExporter struct {
// 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 interfaces.Logger
}
@@ -32,7 +31,7 @@ type MarkdownExporter struct {
//
// Returns:
// - 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{
htmlCleaner: htmlCleaner,
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))
if course.Course.Description != "" {
desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(course.Course.Description))
if desc != "" {
if desc := e.htmlCleaner.CleanAndTrim(course.Course.Description); desc != "" {
fmt.Fprintf(&buf, "%s\n\n", desc)
}
}
@@ -66,7 +64,7 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
// Process lessons
lessonCounter := 0
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))
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))
if lesson.Description != "" {
desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(lesson.Description))
if desc != "" {
if desc := e.htmlCleaner.CleanAndTrim(lesson.Description); 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)
switch itemType {
case itemTypeText:
case models.ItemTypeText:
e.processTextItem(buf, item, headingPrefix)
case itemTypeList:
case models.ItemTypeList:
e.processListItem(buf, item)
case itemTypeMultimedia:
case models.ItemTypeMultimedia:
e.processMultimediaItem(buf, item, headingPrefix)
case itemTypeImage:
case models.ItemTypeImage:
e.processImageItem(buf, item, headingPrefix)
case itemTypeKnowledgeCheck:
case models.ItemTypeKnowledgeCheck:
e.processKnowledgeCheckItem(buf, item, headingPrefix)
case itemTypeInteractive:
case models.ItemTypeInteractive:
e.processInteractiveItem(buf, item, headingPrefix)
case itemTypeDivider:
case models.ItemTypeDivider:
e.processDividerItem(buf)
default:
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) {
for _, subItem := range item.Items {
if subItem.Heading != "" {
heading := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Heading))
if heading != "" {
if heading := e.htmlCleaner.CleanAndTrim(subItem.Heading); heading != "" {
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
}
}
if subItem.Paragraph != "" {
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" {
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); 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) {
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" {
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); 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)
}
if subItem.Caption != "" {
caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption))
if caption != "" {
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); 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)
}
if subItem.Caption != "" {
caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption))
if caption != "" {
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); 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.
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); 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)
if subItem.Feedback != "" {
feedback := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Feedback))
if feedback != "" {
if feedback := e.htmlCleaner.CleanAndTrim(subItem.Feedback); 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)
for _, subItem := range item.Items {
if subItem.Title != "" {
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); 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.
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title)
}
}
if subItem.Paragraph != "" {
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" {
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); 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"
"golang.org/x/net/html"
"github.com/kjanat/articulate-parser/internal/interfaces"
)
// HTMLCleaner provides utilities for converting HTML content to plain text.
// It removes HTML tags while preserving their content and converts HTML entities
// to their plain text equivalents using proper HTML parsing instead of regex.
// Implements interfaces.HTMLCleaner.
type HTMLCleaner struct{}
// Compile-time check that HTMLCleaner implements interfaces.HTMLCleaner.
var _ interfaces.HTMLCleaner = (*HTMLCleaner)(nil)
// NewHTMLCleaner creates a new HTML cleaner instance.
// This service is typically injected into exporters that need to handle
// HTML content from Articulate Rise courses.
@@ -46,6 +52,13 @@ func (h *HTMLCleaner) CleanHTML(htmlStr string) string {
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.
// It skips script and style tags to avoid including their content in the output.
func extractText(w io.Writer, n *html.Node) {