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/README.md b/README.md index 7a0d751..9126e95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,78 @@ # Articulate Rise Parser -A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown and Word documents. +A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown, HTML, and Word documents. + +## System Architecture + +```mermaid +flowchart TD + %% User Input + CLI[Command Line Interface
main.go] --> APP{App Service
services/app.go} + + %% Core Application Logic + APP --> |"ProcessCourseFromURI"| PARSER[Course Parser
services/parser.go] + APP --> |"ProcessCourseFromFile"| PARSER + APP --> |"exportCourse"| FACTORY[Exporter Factory
exporters/factory.go] + + %% Data Sources + PARSER --> |"FetchCourse"| API[Articulate Rise API
rise.articulate.com] + PARSER --> |"LoadCourseFromFile"| FILE[Local JSON File
*.json] + + %% Data Models + API --> MODELS[Data Models
models/course.go] + FILE --> MODELS + MODELS --> |Course, Lesson, Item| APP + + %% Export Factory Pattern + FACTORY --> |"CreateExporter"| MARKDOWN[Markdown Exporter
exporters/markdown.go] + FACTORY --> |"CreateExporter"| HTML[HTML Exporter
exporters/html.go] + FACTORY --> |"CreateExporter"| DOCX[DOCX Exporter
exporters/docx.go] + + %% HTML Cleaning Service + CLEANER[HTML Cleaner
services/html_cleaner.go] --> MARKDOWN + CLEANER --> HTML + CLEANER --> DOCX + + %% Output Files + MARKDOWN --> |"Export"| MD_OUT[Markdown Files
*.md] + HTML --> |"Export"| HTML_OUT[HTML Files
*.html] + DOCX --> |"Export"| DOCX_OUT[Word Documents
*.docx] + + %% Interfaces (Contracts) + IPARSER[CourseParser Interface
interfaces/parser.go] -.-> PARSER + IEXPORTER[Exporter Interface
interfaces/exporter.go] -.-> MARKDOWN + IEXPORTER -.-> HTML + IEXPORTER -.-> DOCX + IFACTORY[ExporterFactory Interface
interfaces/exporter.go] -.-> FACTORY + + %% Styling + classDef userInput fill:#e1f5fe,stroke:#01579b,stroke-width:2px + classDef coreLogic fill:#f3e5f5,stroke:#4a148c,stroke-width:2px + classDef dataSource fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px + classDef exporter fill:#fff3e0,stroke:#e65100,stroke-width:2px + classDef output fill:#fce4ec,stroke:#880e4f,stroke-width:2px + classDef interface fill:#f1f8e9,stroke:#33691e,stroke-width:1px,stroke-dasharray: 5 5 + classDef service fill:#e0f2f1,stroke:#00695c,stroke-width:2px + + class CLI userInput + class APP,FACTORY coreLogic + class API,FILE,MODELS dataSource + class MARKDOWN,HTML,DOCX exporter + class MD_OUT,HTML_OUT,DOCX_OUT output + class IPARSER,IEXPORTER,IFACTORY interface + class PARSER,CLEANER service +``` + +### Architecture Overview + +The system follows **Clean Architecture** principles with clear separation of concerns: + +- **🎯 Entry Point**: Command-line interface handles user input and coordinates operations +- **🏗️ Application Layer**: Core business logic with dependency injection +- **📋 Interface Layer**: Contracts defining behavior without implementation details +- **🔧 Service Layer**: Concrete implementations of parsing and utility services +- **📤 Export Layer**: Factory pattern for format-specific exporters +- **📊 Data Layer**: Domain models representing course structure [![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?logo=Go&logoColor=white)][gomod] [![Go Doc](https://godoc.org/github.com/kjanat/articulate-parser?status.svg)][Package documentation] @@ -16,6 +88,7 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo - Parse Articulate Rise JSON data from URLs or local files - Export to Markdown (.md) format +- Export to HTML (.html) format with professional styling - Export to Word Document (.docx) format - Support for various content types: - Text content with headings and paragraphs @@ -85,7 +158,7 @@ go run main.go [output_path] | Parameter | Description | Default | | ------------------- | ---------------------------------------------------------------- | --------------- | | `input_uri_or_file` | Either an Articulate Rise share URL or path to a local JSON file | None (required) | -| `output_format` | `md` for Markdown or `docx` for Word Document | None (required) | +| `output_format` | `md` for Markdown, `html` for HTML, or `docx` for Word Document | None (required) | | `output_path` | Path where output file will be saved. | `./output/` | #### Examples @@ -102,7 +175,13 @@ go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviD go run main.go "articulate-sample.json" docx "my-course.docx" ``` -3. **Parse from local file and export to Markdown:** +3. **Parse from local file and export to HTML:** + +```bash +go run main.go "articulate-sample.json" html "output.html" +``` + +4. **Parse from local file and export to Markdown:** ```bash go run main.go "articulate-sample.json" md "output.md" @@ -153,6 +232,15 @@ The project maintains high code quality standards: - Media references included - Course metadata at the top +### HTML (`.html`) + +- Professional styling with embedded CSS +- Interactive and visually appealing layout +- Proper HTML structure with semantic elements +- Responsive design for different screen sizes +- All content types beautifully formatted +- Maintains course hierarchy and organization + ### Word Document (`.docx`) - Professional document formatting @@ -230,7 +318,7 @@ Potential improvements could include: - PDF export support - Media file downloading -- HTML export with preserved styling +- ~~HTML export with preserved styling~~ ✅ **Completed** - SCORM package support - Batch processing capabilities - Custom template support 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

%s

\n
\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") + buf.WriteString(fmt.Sprintf("

Video: %s

\n", html.EscapeString(subItem.Media.Video.OriginalUrl))) + if subItem.Media.Video.Duration > 0 { + buf.WriteString(fmt.Sprintf("

Duration: %d seconds

\n", subItem.Media.Video.Duration)) + } + 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") + buf.WriteString(fmt.Sprintf("

Image: %s

\n", html.EscapeString(subItem.Media.Image.OriginalUrl))) + 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, "