diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
new file mode 100644
index 0000000..79f51da
--- /dev/null
+++ b/.github/workflows/autofix.yml
@@ -0,0 +1,25 @@
+name: autofix.ci
+on:
+ pull_request:
+ push:
+ branches: [ "master" ]
+permissions:
+ contents: read
+
+jobs:
+ autofix:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.mod'
+
+ # goimports works like gofmt, but also fixes imports.
+ # see https://pkg.go.dev/golang.org/x/tools/cmd/goimports
+ - run: go install golang.org/x/tools/cmd/goimports@latest
+ - run: goimports -w .
+ # of course we can also do just this instead:
+ # - run: gofmt -w .
+
+ - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef
diff --git a/.gitignore b/.gitignore
index be0964d..2861ad0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ go.work
# Local test files
output/
+outputs/
articulate-sample.json
test-output.*
go-os-arch-matrix.csv
diff --git a/internal/exporters/factory.go b/internal/exporters/factory.go
index ce1ad53..a05a8cb 100644
--- a/internal/exporters/factory.go
+++ b/internal/exporters/factory.go
@@ -48,6 +48,8 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
return NewMarkdownExporter(f.htmlCleaner), nil
case "docx", "word":
return NewDocxExporter(f.htmlCleaner), nil
+ case "html", "htm":
+ return NewHTMLExporter(f.htmlCleaner), nil
default:
return nil, fmt.Errorf("unsupported export format: %s", format)
}
@@ -59,5 +61,5 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
// Returns:
// - A string slice containing all supported format names
func (f *Factory) GetSupportedFormats() []string {
- return []string{"markdown", "md", "docx", "word"}
+ return []string{"markdown", "md", "docx", "word", "html", "htm"}
}
diff --git a/internal/exporters/factory_test.go b/internal/exporters/factory_test.go
index d56de7a..f0cf853 100644
--- a/internal/exporters/factory_test.go
+++ b/internal/exporters/factory_test.go
@@ -70,6 +70,20 @@ func TestFactory_CreateExporter(t *testing.T) {
expectedFormat: "docx",
shouldError: false,
},
+ {
+ name: "html format",
+ format: "html",
+ expectedType: "*exporters.HTMLExporter",
+ expectedFormat: "html",
+ shouldError: false,
+ },
+ {
+ name: "htm format alias",
+ format: "htm",
+ expectedType: "*exporters.HTMLExporter",
+ expectedFormat: "html",
+ shouldError: false,
+ },
{
name: "unsupported format",
format: "pdf",
@@ -139,6 +153,12 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
{"WORD", "docx"},
{"Word", "docx"},
{"WoRd", "docx"},
+ {"HTML", "html"},
+ {"Html", "html"},
+ {"HtMl", "html"},
+ {"HTM", "html"},
+ {"Htm", "html"},
+ {"HtM", "html"},
}
for _, tc := range testCases {
@@ -168,7 +188,6 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
testCases := []string{
"pdf",
- "html",
"txt",
"json",
"xml",
@@ -213,7 +232,7 @@ func TestFactory_GetSupportedFormats(t *testing.T) {
t.Fatal("GetSupportedFormats() returned nil")
}
- expected := []string{"markdown", "md", "docx", "word"}
+ expected := []string{"markdown", "md", "docx", "word", "html", "htm"}
// Sort both slices for comparison
sort.Strings(formats)
@@ -321,6 +340,21 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
if docxImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to DocxExporter")
}
+
+ // Test with html exporter
+ htmlExporter, err := factory.CreateExporter("html")
+ if err != nil {
+ t.Fatalf("Failed to create html exporter: %v", err)
+ }
+
+ htmlImpl, ok := htmlExporter.(*HTMLExporter)
+ if !ok {
+ t.Fatal("Failed to cast to HTMLExporter")
+ }
+
+ if htmlImpl.htmlCleaner == nil {
+ t.Error("HTMLCleaner should be propagated to HTMLExporter")
+ }
}
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
diff --git a/internal/exporters/html.go b/internal/exporters/html.go
new file mode 100644
index 0000000..c8c4b0f
--- /dev/null
+++ b/internal/exporters/html.go
@@ -0,0 +1,476 @@
+// Package exporters provides implementations of the Exporter interface
+// for converting Articulate Rise courses into various file formats.
+package exporters
+
+import (
+ "bytes"
+ "fmt"
+ "html"
+ "os"
+ "strings"
+
+ "github.com/kjanat/articulate-parser/internal/interfaces"
+ "github.com/kjanat/articulate-parser/internal/models"
+ "github.com/kjanat/articulate-parser/internal/services"
+)
+
+// HTMLExporter implements the Exporter interface for HTML format.
+// It converts Articulate Rise course data into a structured HTML document.
+type HTMLExporter struct {
+ // htmlCleaner is used to convert HTML content to plain text when needed
+ htmlCleaner *services.HTMLCleaner
+}
+
+// NewHTMLExporter creates a new HTMLExporter instance.
+// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
+//
+// Parameters:
+// - htmlCleaner: Service for cleaning HTML content in course data
+//
+// Returns:
+// - An implementation of the Exporter interface for HTML format
+func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
+ return &HTMLExporter{
+ htmlCleaner: htmlCleaner,
+ }
+}
+
+// Export exports a course to HTML format.
+// It generates a structured HTML document from the course data
+// and writes it to the specified output path.
+//
+// Parameters:
+// - course: The course data model to export
+// - outputPath: The file path where the HTML content will be written
+//
+// Returns:
+// - An error if writing to the output file fails
+func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
+ var buf bytes.Buffer
+
+ // Write HTML document structure
+ buf.WriteString("\n")
+ buf.WriteString("\n")
+ buf.WriteString("
\n")
+ buf.WriteString(" \n")
+ buf.WriteString(" \n")
+ buf.WriteString(fmt.Sprintf(" %s\n", html.EscapeString(course.Course.Title)))
+ buf.WriteString(" \n")
+ buf.WriteString("\n")
+ buf.WriteString("\n")
+
+ // Write course header
+ buf.WriteString(fmt.Sprintf(" \n %s
\n", html.EscapeString(course.Course.Title)))
+
+ if course.Course.Description != "" {
+ buf.WriteString(fmt.Sprintf(" %s
\n", course.Course.Description))
+ }
+ buf.WriteString(" \n\n")
+
+ // Add metadata section
+ buf.WriteString(" \n")
+ buf.WriteString(" Course Information
\n")
+ buf.WriteString(" \n")
+ buf.WriteString(fmt.Sprintf(" - Course ID: %s
\n", html.EscapeString(course.Course.ID)))
+ buf.WriteString(fmt.Sprintf(" - Share ID: %s
\n", html.EscapeString(course.ShareID)))
+ buf.WriteString(fmt.Sprintf(" - Navigation Mode: %s
\n", html.EscapeString(course.Course.NavigationMode)))
+ if course.Course.ExportSettings != nil {
+ buf.WriteString(fmt.Sprintf(" - Export Format: %s
\n", html.EscapeString(course.Course.ExportSettings.Format)))
+ }
+ buf.WriteString("
\n")
+ buf.WriteString(" \n\n")
+
+ // Process lessons
+ lessonCounter := 0
+ for _, lesson := range course.Course.Lessons {
+ if lesson.Type == "section" {
+ buf.WriteString(fmt.Sprintf(" \n\n", html.EscapeString(lesson.Title)))
+ continue
+ }
+
+ lessonCounter++
+ buf.WriteString(fmt.Sprintf(" \n Lesson %d: %s
\n", lessonCounter, html.EscapeString(lesson.Title)))
+
+ if lesson.Description != "" {
+ buf.WriteString(fmt.Sprintf(" %s
\n", lesson.Description))
+ }
+
+ // Process lesson items
+ for _, item := range lesson.Items {
+ e.processItemToHTML(&buf, item)
+ }
+
+ buf.WriteString(" \n\n")
+ }
+
+ buf.WriteString("\n")
+ buf.WriteString("\n")
+
+ return os.WriteFile(outputPath, buf.Bytes(), 0644)
+}
+
+// GetSupportedFormat returns the format name this exporter supports
+// It indicates the file format that the HTMLExporter can generate.
+//
+// Returns:
+// - A string representing the supported format ("html")
+func (e *HTMLExporter) GetSupportedFormat() string {
+ return "html"
+}
+
+// getDefaultCSS returns basic CSS styling for the HTML document
+func (e *HTMLExporter) getDefaultCSS() string {
+ return `
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ background-color: #f9f9f9;
+ }
+ header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 2rem;
+ border-radius: 10px;
+ margin-bottom: 2rem;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ }
+ header h1 {
+ margin: 0;
+ font-size: 2.5rem;
+ font-weight: 300;
+ }
+ .course-description {
+ margin-top: 1rem;
+ font-size: 1.1rem;
+ opacity: 0.9;
+ }
+ .course-info {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+ .course-info h2 {
+ margin-top: 0;
+ color: #4a5568;
+ border-bottom: 2px solid #e2e8f0;
+ padding-bottom: 0.5rem;
+ }
+ .course-info ul {
+ list-style: none;
+ padding: 0;
+ }
+ .course-info li {
+ margin: 0.5rem 0;
+ padding: 0.5rem;
+ background: #f7fafc;
+ border-radius: 4px;
+ }
+ .course-section {
+ background: #4299e1;
+ color: white;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin: 2rem 0;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+ .course-section h2 {
+ margin: 0;
+ font-weight: 400;
+ }
+ .lesson {
+ background: white;
+ padding: 2rem;
+ border-radius: 8px;
+ margin: 2rem 0;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ border-left: 4px solid #4299e1;
+ }
+ .lesson h3 {
+ margin-top: 0;
+ color: #2d3748;
+ font-size: 1.5rem;
+ }
+ .lesson-description {
+ margin: 1rem 0;
+ padding: 1rem;
+ background: #f7fafc;
+ border-radius: 4px;
+ border-left: 3px solid #4299e1;
+ }
+ .item {
+ margin: 1.5rem 0;
+ padding: 1rem;
+ border-radius: 6px;
+ background: #fafafa;
+ border: 1px solid #e2e8f0;
+ }
+ .item h4 {
+ margin-top: 0;
+ color: #4a5568;
+ font-size: 1.2rem;
+ text-transform: capitalize;
+ }
+ .text-item {
+ background: #f0fff4;
+ border-left: 3px solid #48bb78;
+ }
+ .list-item {
+ background: #fffaf0;
+ border-left: 3px solid #ed8936;
+ }
+ .knowledge-check {
+ background: #e6fffa;
+ border-left: 3px solid #38b2ac;
+ }
+ .multimedia-item {
+ background: #faf5ff;
+ border-left: 3px solid #9f7aea;
+ }
+ .interactive-item {
+ background: #fff5f5;
+ border-left: 3px solid #f56565;
+ }
+ .unknown-item {
+ background: #f7fafc;
+ border-left: 3px solid #a0aec0;
+ }
+ .answers {
+ margin: 1rem 0;
+ }
+ .answers h5 {
+ margin: 0.5rem 0;
+ color: #4a5568;
+ }
+ .answers ol {
+ margin: 0.5rem 0;
+ padding-left: 1.5rem;
+ }
+ .answers li {
+ margin: 0.3rem 0;
+ padding: 0.3rem;
+ }
+ .correct-answer {
+ background: #c6f6d5;
+ border-radius: 3px;
+ font-weight: bold;
+ }
+ .correct-answer::after {
+ content: " ✓";
+ color: #38a169;
+ }
+ .feedback {
+ margin: 1rem 0;
+ padding: 1rem;
+ background: #edf2f7;
+ border-radius: 4px;
+ border-left: 3px solid #4299e1;
+ font-style: italic;
+ }
+ .media-info {
+ background: #edf2f7;
+ padding: 1rem;
+ border-radius: 4px;
+ margin: 0.5rem 0;
+ }
+ .media-info strong {
+ color: #4a5568;
+ }
+ hr {
+ border: none;
+ height: 2px;
+ background: linear-gradient(to right, #667eea, #764ba2);
+ margin: 2rem 0;
+ border-radius: 1px;
+ }
+ ul {
+ padding-left: 1.5rem;
+ }
+ li {
+ margin: 0.5rem 0;
+ }
+ `
+}
+
+// processItemToHTML converts a course item into HTML format
+// and appends it to the provided buffer. It handles different item types
+// with appropriate HTML formatting.
+//
+// Parameters:
+// - buf: The buffer to write the HTML content to
+// - item: The course item to process
+func (e *HTMLExporter) processItemToHTML(buf *bytes.Buffer, item models.Item) {
+ switch strings.ToLower(item.Type) {
+ case "text":
+ e.processTextItem(buf, item)
+ case "list":
+ e.processListItem(buf, item)
+ case "knowledgecheck":
+ e.processKnowledgeCheckItem(buf, item)
+ case "multimedia":
+ e.processMultimediaItem(buf, item)
+ case "image":
+ e.processImageItem(buf, item)
+ case "interactive":
+ e.processInteractiveItem(buf, item)
+ case "divider":
+ e.processDividerItem(buf)
+ default:
+ e.processUnknownItem(buf, item)
+ }
+}
+
+// processTextItem handles text content with headings and paragraphs
+func (e *HTMLExporter) processTextItem(buf *bytes.Buffer, item models.Item) {
+ buf.WriteString(" \n")
+ buf.WriteString("
Text Content
\n")
+ for _, subItem := range item.Items {
+ if subItem.Heading != "" {
+ buf.WriteString(fmt.Sprintf("
%s
\n", subItem.Heading))
+ }
+ if subItem.Paragraph != "" {
+ buf.WriteString(fmt.Sprintf("
%s
\n", subItem.Paragraph))
+ }
+ }
+ buf.WriteString("
\n\n")
+}
+
+// processListItem handles list content
+func (e *HTMLExporter) processListItem(buf *bytes.Buffer, item models.Item) {
+ buf.WriteString(" \n")
+ buf.WriteString("
List
\n")
+ buf.WriteString("
\n")
+ for _, subItem := range item.Items {
+ if subItem.Paragraph != "" {
+ cleanText := e.htmlCleaner.CleanHTML(subItem.Paragraph)
+ buf.WriteString(fmt.Sprintf(" - %s
\n", html.EscapeString(cleanText)))
+ }
+ }
+ buf.WriteString("
\n")
+ buf.WriteString("
\n\n")
+}
+
+// processKnowledgeCheckItem handles quiz questions and answers
+func (e *HTMLExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item) {
+ buf.WriteString(" \n")
+ buf.WriteString("
Knowledge Check
\n")
+ for _, subItem := range item.Items {
+ if subItem.Title != "" {
+ buf.WriteString(fmt.Sprintf("
Question: %s
\n", subItem.Title))
+ }
+ if len(subItem.Answers) > 0 {
+ e.processAnswers(buf, subItem.Answers)
+ }
+ if subItem.Feedback != "" {
+ buf.WriteString(fmt.Sprintf("
Feedback: %s
\n", subItem.Feedback))
+ }
+ }
+ buf.WriteString("
\n\n")
+}
+
+// processMultimediaItem handles multimedia content like videos
+func (e *HTMLExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item) {
+ buf.WriteString(" \n")
+ buf.WriteString("
Media Content
\n")
+ for _, subItem := range item.Items {
+ if subItem.Title != "" {
+ buf.WriteString(fmt.Sprintf("
%s
\n", subItem.Title))
+ }
+ if subItem.Media != nil {
+ if subItem.Media.Video != nil {
+ buf.WriteString("
\n")
+ }
+ }
+ if subItem.Caption != "" {
+ buf.WriteString(fmt.Sprintf("
%s
\n", subItem.Caption))
+ }
+ }
+ buf.WriteString("
\n\n")
+}
+
+// processImageItem handles image content
+func (e *HTMLExporter) processImageItem(buf *bytes.Buffer, item models.Item) {
+ buf.WriteString(" \n")
+ buf.WriteString("
Image
\n")
+ for _, subItem := range item.Items {
+ if subItem.Media != nil && subItem.Media.Image != nil {
+ buf.WriteString("
\n")
+ }
+ if subItem.Caption != "" {
+ buf.WriteString(fmt.Sprintf("
%s
\n", subItem.Caption))
+ }
+ }
+ buf.WriteString("
\n\n")
+}
+
+// processInteractiveItem handles interactive content
+func (e *HTMLExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item) {
+ buf.WriteString(" \n")
+ buf.WriteString("
Interactive Content
\n")
+ for _, subItem := range item.Items {
+ if subItem.Title != "" {
+ buf.WriteString(fmt.Sprintf("
%s
\n", subItem.Title))
+ }
+ if subItem.Paragraph != "" {
+ buf.WriteString(fmt.Sprintf("
%s
\n", subItem.Paragraph))
+ }
+ }
+ buf.WriteString("
\n\n")
+}
+
+// processDividerItem handles divider elements
+func (e *HTMLExporter) processDividerItem(buf *bytes.Buffer) {
+ buf.WriteString("
\n\n")
+}
+
+// processUnknownItem handles unknown or unsupported item types
+func (e *HTMLExporter) processUnknownItem(buf *bytes.Buffer, item models.Item) {
+ if len(item.Items) > 0 {
+ buf.WriteString(" \n")
+ buf.WriteString(fmt.Sprintf("
%s Content
\n", strings.Title(item.Type)))
+ for _, subItem := range item.Items {
+ e.processGenericSubItem(buf, subItem)
+ }
+ buf.WriteString(" \n\n")
+ }
+}
+
+// processGenericSubItem processes sub-items for unknown types
+func (e *HTMLExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
+ if subItem.Title != "" {
+ buf.WriteString(fmt.Sprintf(" %s
\n", subItem.Title))
+ }
+ if subItem.Paragraph != "" {
+ buf.WriteString(fmt.Sprintf(" %s
\n", subItem.Paragraph))
+ }
+}
+
+// processAnswers processes answer choices for quiz questions
+func (e *HTMLExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
+ buf.WriteString(" \n")
+ buf.WriteString("
Answers:
\n")
+ buf.WriteString("
\n")
+ for _, answer := range answers {
+ cssClass := ""
+ if answer.Correct {
+ cssClass = " class=\"correct-answer\""
+ }
+ buf.WriteString(fmt.Sprintf(" - %s
\n", cssClass, html.EscapeString(answer.Title)))
+ }
+ buf.WriteString("
\n")
+ buf.WriteString("
\n")
+}
diff --git a/internal/exporters/html_test.go b/internal/exporters/html_test.go
new file mode 100644
index 0000000..78e95a4
--- /dev/null
+++ b/internal/exporters/html_test.go
@@ -0,0 +1,927 @@
+// Package exporters_test provides tests for the html exporter.
+package exporters
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/kjanat/articulate-parser/internal/models"
+ "github.com/kjanat/articulate-parser/internal/services"
+)
+
+// TestNewHTMLExporter tests the NewHTMLExporter constructor.
+func TestNewHTMLExporter(t *testing.T) {
+ htmlCleaner := services.NewHTMLCleaner()
+ exporter := NewHTMLExporter(htmlCleaner)
+
+ if exporter == nil {
+ t.Fatal("NewHTMLExporter() returned nil")
+ }
+
+ // Type assertion to check internal structure
+ htmlExporter, ok := exporter.(*HTMLExporter)
+ if !ok {
+ t.Fatal("NewHTMLExporter() returned wrong type")
+ }
+
+ if htmlExporter.htmlCleaner == nil {
+ t.Error("htmlCleaner should not be nil")
+ }
+}
+
+// TestHTMLExporter_GetSupportedFormat tests the GetSupportedFormat method.
+func TestHTMLExporter_GetSupportedFormat(t *testing.T) {
+ htmlCleaner := services.NewHTMLCleaner()
+ exporter := NewHTMLExporter(htmlCleaner)
+
+ expected := "html"
+ result := exporter.GetSupportedFormat()
+
+ if result != expected {
+ t.Errorf("Expected format '%s', got '%s'", expected, result)
+ }
+}
+
+// TestHTMLExporter_Export tests the Export method.
+func TestHTMLExporter_Export(t *testing.T) {
+ htmlCleaner := services.NewHTMLCleaner()
+ exporter := NewHTMLExporter(htmlCleaner)
+
+ // Create test course
+ testCourse := createTestCourseForHTML()
+
+ // Create temporary directory and file
+ tempDir := t.TempDir()
+ outputPath := filepath.Join(tempDir, "test-course.html")
+
+ // Test successful export
+ err := exporter.Export(testCourse, outputPath)
+ if err != nil {
+ t.Fatalf("Export failed: %v", err)
+ }
+
+ // Check that file was created
+ if _, err := os.Stat(outputPath); os.IsNotExist(err) {
+ t.Fatal("Output file was not created")
+ }
+
+ // Read and verify content
+ content, err := os.ReadFile(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to read output file: %v", err)
+ }
+
+ contentStr := string(content)
+
+ // Verify HTML structure
+ if !strings.Contains(contentStr, "") {
+ t.Error("Output should contain HTML doctype")
+ }
+
+ if !strings.Contains(contentStr, "") {
+ t.Error("Output should contain HTML tag with lang attribute")
+ }
+
+ if !strings.Contains(contentStr, "Test Course") {
+ t.Error("Output should contain course title in head")
+ }
+
+ // Verify main course title
+ if !strings.Contains(contentStr, "Test Course
") {
+ t.Error("Output should contain course title as main heading")
+ }
+
+ // Verify course information section
+ if !strings.Contains(contentStr, "Course Information") {
+ t.Error("Output should contain course information section")
+ }
+
+ // Verify course metadata
+ if !strings.Contains(contentStr, "Course ID") {
+ t.Error("Output should contain course ID")
+ }
+
+ if !strings.Contains(contentStr, "Share ID") {
+ t.Error("Output should contain share ID")
+ }
+
+ // Verify lesson content
+ if !strings.Contains(contentStr, "Lesson 1: Test Lesson") {
+ t.Error("Output should contain lesson heading")
+ }
+
+ // Verify CSS is included
+ if !strings.Contains(contentStr, "