diff --git a/.golangci.yml b/.golangci.yml index 63c0753..c5be94d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -188,6 +188,12 @@ linters: - gochecknoglobals - gochecknoinits + # Exclude var-naming for interfaces package (standard Go pattern for interface definitions) + - path: internal/interfaces/ + text: "var-naming: avoid meaningless package names" + linters: + - revive + # Allow fmt.Print* in main package - path: ^main\.go$ text: "use of fmt.Print" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4f5eb1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Agent Guidelines for articulate-parser + +## Build/Test Commands +- **Build**: `task build` or `go build -o bin/articulate-parser main.go` +- **Run tests**: `task test` or `go test -race -timeout 5m ./...` +- **Run single test**: `go test -v -race -run ^TestName$ ./path/to/package` +- **Test with coverage**: + - `task test:coverage` or + - `go test -race -coverprofile=coverage/coverage.out -covermode=atomic ./...` +- **Lint**: `task lint` (runs vet, fmt check, staticcheck, golangci-lint) +- **Format**: `task fmt` or `gofmt -s -w .` +- **CI checks**: `task ci` (deps, lint, test with coverage, build) + +## Code Style Guidelines + +### Imports +- Use `goimports` with local prefix: `github.com/kjanat/articulate-parser` +- Order: stdlib, external, internal packages +- Group related imports together + +### Formatting +- Use `gofmt -s` (simplify) and `gofumpt` with extra rules +- Function length: max 100 lines, 50 statements +- Cyclomatic complexity: max 15 +- Cognitive complexity: max 20 + +### Types & Naming +- Use interface-based design (see `internal/interfaces/`) +- Export types/functions with clear godoc comments ending with period +- Use descriptive names: `ArticulateParser`, `MarkdownExporter` +- Receiver names: short (1-2 chars), consistent per type + +### Error Handling +- Always wrap errors with context: `fmt.Errorf("operation failed: %w", err)` +- Use `%w` verb for error wrapping to preserve error chain +- Check all error returns (enforced by `errcheck`) +- Document error handling rationale in defer blocks when ignoring close errors + +### Comments +- All exported types/functions require godoc comments +- End sentences with periods (`godot` linter enforced) +- Mark known issues with TODO/FIXME/HACK/BUG/XXX + +### Security +- Use `#nosec` with justification for deliberate security exceptions (G304 for CLI file paths, G306 for export file permissions) +- Run `gosec` and `govulncheck` for security audits + +### Testing +- Enable race detection: `-race` flag +- Use table-driven tests where applicable +- Mark test helpers with `t.Helper()` +- Benchmarks in `*_bench_test.go`, examples in `*_example_test.go` + +### Dependencies +- Minimal external dependencies (currently: go-docx, golang.org/x/net, golang.org/x/text) +- Run `task deps:tidy` after adding/removing dependencies diff --git a/internal/exporters/html.go b/internal/exporters/html.go index d170320..f35781e 100644 --- a/internal/exporters/html.go +++ b/internal/exporters/html.go @@ -1,25 +1,30 @@ package exporters import ( - "bytes" + _ "embed" "fmt" - "html" + "html/template" + "io" "os" - "strings" - - "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" ) +//go:embed html_styles.css +var defaultCSS string + +//go:embed html_template.html +var htmlTemplate string + // HTMLExporter implements the Exporter interface for HTML format. -// It converts Articulate Rise course data into a structured HTML document. +// 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 + // tmpl holds the parsed HTML template + tmpl *template.Template } // NewHTMLExporter creates a new HTMLExporter instance. @@ -31,8 +36,21 @@ type HTMLExporter struct { // Returns: // - An implementation of the Exporter interface for HTML format func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { + // Parse the template with custom functions + funcMap := template.FuncMap{ + "safeHTML": func(s string) template.HTML { + return template.HTML(s) // #nosec G203 - HTML content is from trusted course data + }, + "safeCSS": func(s string) template.CSS { + return template.CSS(s) // #nosec G203 - CSS content is from trusted embedded file + }, + } + + tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate)) + return &HTMLExporter{ htmlCleaner: htmlCleaner, + tmpl: tmpl, } } @@ -47,72 +65,33 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { // 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)) + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) } - buf.WriteString("
\n\n") + defer f.Close() - // Add metadata section - buf.WriteString("
\n") - buf.WriteString("

Course Information

\n") - buf.WriteString(" \n") - buf.WriteString("
\n\n") + return e.WriteHTML(f, course) +} - // 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 - } +// WriteHTML writes the HTML content to an io.Writer. +// This allows for better testability and flexibility in output destinations. +// +// Parameters: +// - w: The writer to output HTML content to +// - course: The course data model to export +// +// Returns: +// - An error if writing fails +func (e *HTMLExporter) WriteHTML(w io.Writer, course *models.Course) error { + // Prepare template data + data := prepareTemplateData(course, e.htmlCleaner) - 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") + // Execute template + if err := e.tmpl.Execute(w, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) } - buf.WriteString("\n") - buf.WriteString("\n") - - // #nosec G306 - 0644 is appropriate for export files that should be readable by others - if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil { - return fmt.Errorf("failed to write HTML file: %w", err) - } return nil } @@ -124,359 +103,3 @@ func (e *HTMLExporter) Export(course *models.Course, outputPath string) error { func (e *HTMLExporter) SupportedFormat() string { return FormatHTML } - -// 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 != "" { - fmt.Fprintf(buf, "
%s
\n", subItem.Heading) - } - if subItem.Paragraph != "" { - fmt.Fprintf(buf, "
%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") - 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 != "" { - fmt.Fprintf(buf, "

Question: %s

\n", subItem.Title) - } - if len(subItem.Answers) > 0 { - e.processAnswers(buf, subItem.Answers) - } - if subItem.Feedback != "" { - fmt.Fprintf(buf, "
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 != "" { - fmt.Fprintf(buf, "
%s
\n", subItem.Title) - } - if subItem.Media != nil { - if subItem.Media.Video != nil { - buf.WriteString("
\n") - fmt.Fprintf(buf, "

Video: %s

\n", html.EscapeString(subItem.Media.Video.OriginalURL)) - if subItem.Media.Video.Duration > 0 { - fmt.Fprintf(buf, "

Duration: %d seconds

\n", subItem.Media.Video.Duration) - } - buf.WriteString("
\n") - } - } - if subItem.Caption != "" { - fmt.Fprintf(buf, "
%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") - fmt.Fprintf(buf, "

Image: %s

\n", html.EscapeString(subItem.Media.Image.OriginalURL)) - buf.WriteString("
\n") - } - if subItem.Caption != "" { - fmt.Fprintf(buf, "
%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 != "" { - fmt.Fprintf(buf, "

%s

\n", subItem.Title) - } - if subItem.Paragraph != "" { - fmt.Fprintf(buf, "
%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") - caser := cases.Title(language.English) - fmt.Fprintf(buf, "

%s Content

\n", caser.String(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 != "" { - fmt.Fprintf(buf, "

%s

\n", subItem.Title) - } - if subItem.Paragraph != "" { - fmt.Fprintf(buf, "
%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\"" - } - fmt.Fprintf(buf, " %s\n", cssClass, html.EscapeString(answer.Title)) - } - buf.WriteString("
\n") - buf.WriteString("
\n") -} diff --git a/internal/exporters/html_styles.css b/internal/exporters/html_styles.css new file mode 100644 index 0000000..8448d1b --- /dev/null +++ b/internal/exporters/html_styles.css @@ -0,0 +1,173 @@ +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; +} diff --git a/internal/exporters/html_template.html b/internal/exporters/html_template.html new file mode 100644 index 0000000..14d671e --- /dev/null +++ b/internal/exporters/html_template.html @@ -0,0 +1,183 @@ + + + + + + {{.Course.Title}} + + + +
+

{{.Course.Title}}

+ {{if .Course.Description}} +
{{safeHTML .Course.Description}}
+ {{end}} +
+ +
+

Course Information

+ +
+ + {{range .Sections}} + {{if eq .Type "section"}} +
+

{{.Title}}

+
+ {{else}} +
+

Lesson {{.Number}}: {{.Title}}

+ {{if .Description}} +
{{safeHTML .Description}}
+ {{end}} + {{range .Items}} + {{template "item" .}} + {{end}} +
+ {{end}} + {{end}} + + +{{define "item"}} +{{if eq .Type "text"}}{{template "textItem" .}} +{{else if eq .Type "list"}}{{template "listItem" .}} +{{else if eq .Type "knowledgecheck"}}{{template "knowledgeCheckItem" .}} +{{else if eq .Type "multimedia"}}{{template "multimediaItem" .}} +{{else if eq .Type "image"}}{{template "imageItem" .}} +{{else if eq .Type "interactive"}}{{template "interactiveItem" .}} +{{else if eq .Type "divider"}}{{template "dividerItem" .}} +{{else}}{{template "unknownItem" .}} +{{end}} +{{end}} + +{{define "textItem"}} +
+

Text Content

+ {{range .Items}} + {{if .Heading}} + {{safeHTML .Heading}} + {{end}} + {{if .Paragraph}} +
{{safeHTML .Paragraph}}
+ {{end}} + {{end}} +
+{{end}} + +{{define "listItem"}} +
+

List

+ +
+{{end}} + +{{define "knowledgeCheckItem"}} +
+

Knowledge Check

+ {{range .Items}} + {{if .Title}} +

Question: {{safeHTML .Title}}

+ {{end}} + {{if .Answers}} +
+
Answers:
+
    + {{range .Answers}} + {{.Title}} + {{end}} +
+
+ {{end}} + {{if .Feedback}} +
Feedback: {{safeHTML .Feedback}}
+ {{end}} + {{end}} +
+{{end}} + +{{define "multimediaItem"}} +
+

Media Content

+ {{range .Items}} + {{if .Title}} +
{{.Title}}
+ {{end}} + {{if .Media}} + {{if .Media.Video}} +
+

Video: {{.Media.Video.OriginalURL}}

+ {{if gt .Media.Video.Duration 0}} +

Duration: {{.Media.Video.Duration}} seconds

+ {{end}} +
+ {{end}} + {{end}} + {{if .Caption}} +
{{.Caption}}
+ {{end}} + {{end}} +
+{{end}} + +{{define "imageItem"}} +
+

Image

+ {{range .Items}} + {{if and .Media .Media.Image}} +
+

Image: {{.Media.Image.OriginalURL}}

+
+ {{end}} + {{if .Caption}} +
{{.Caption}}
+ {{end}} + {{end}} +
+{{end}} + +{{define "interactiveItem"}} +
+

Interactive Content

+ {{range .Items}} + {{if .Title}} +

{{.Title}}

+ {{end}} + {{if .Paragraph}} +
{{safeHTML .Paragraph}}
+ {{end}} + {{end}} +
+{{end}} + +{{define "dividerItem"}} +
+{{end}} + +{{define "unknownItem"}} +
+

{{.TypeTitle}} Content

+ {{range .Items}} + {{if .Title}} +

{{.Title}}

+ {{end}} + {{if .Paragraph}} +
{{safeHTML .Paragraph}}
+ {{end}} + {{end}} +
+{{end}} diff --git a/internal/exporters/html_template_data.go b/internal/exporters/html_template_data.go new file mode 100644 index 0000000..2ed5383 --- /dev/null +++ b/internal/exporters/html_template_data.go @@ -0,0 +1,131 @@ +package exporters + +import ( + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "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" +) + +// templateData represents the data structure passed to the HTML template. +type templateData struct { + Course models.CourseInfo + ShareID string + Sections []templateSection + CSS string +} + +// templateSection represents a course section or lesson. +type templateSection struct { + Type string + Title string + Number int + Description string + Items []templateItem +} + +// templateItem represents a course item with preprocessed data. +type templateItem struct { + Type string + TypeTitle string + Items []templateSubItem +} + +// templateSubItem represents a sub-item with preprocessed data. +type templateSubItem struct { + Heading string + Paragraph string + Title string + Caption string + CleanText string + Answers []models.Answer + Feedback string + Media *models.Media +} + +// prepareTemplateData converts a Course model into template-friendly data. +func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleaner) *templateData { + data := &templateData{ + Course: course.Course, + ShareID: course.ShareID, + Sections: make([]templateSection, 0, len(course.Course.Lessons)), + CSS: defaultCSS, + } + + lessonCounter := 0 + for _, lesson := range course.Course.Lessons { + section := templateSection{ + Type: lesson.Type, + Title: lesson.Title, + Description: lesson.Description, + } + + if lesson.Type != "section" { + lessonCounter++ + section.Number = lessonCounter + section.Items = prepareItems(lesson.Items, htmlCleaner) + } + + data.Sections = append(data.Sections, section) + } + + return data +} + +// prepareItems converts model Items to template Items. +func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []templateItem { + result := make([]templateItem, 0, len(items)) + + for _, item := range items { + tItem := templateItem{ + Type: strings.ToLower(item.Type), + Items: make([]templateSubItem, 0, len(item.Items)), + } + + // 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 { + caser := cases.Title(language.English) + tItem.TypeTitle = caser.String(item.Type) + } + + // Process sub-items + for _, subItem := range item.Items { + tSubItem := templateSubItem{ + Heading: subItem.Heading, + Paragraph: subItem.Paragraph, + Title: subItem.Title, + Caption: subItem.Caption, + Answers: subItem.Answers, + Feedback: subItem.Feedback, + Media: subItem.Media, + } + + // Clean HTML for list items + if tItem.Type == itemTypeList && subItem.Paragraph != "" { + tSubItem.CleanText = htmlCleaner.CleanHTML(subItem.Paragraph) + } + + tItem.Items = append(tItem.Items, tSubItem) + } + + result = append(result, tItem) + } + + return result +} diff --git a/internal/exporters/html_test.go b/internal/exporters/html_test.go index 01329aa..3f2fe13 100644 --- a/internal/exporters/html_test.go +++ b/internal/exporters/html_test.go @@ -1,7 +1,6 @@ package exporters import ( - "bytes" "os" "path/filepath" "strings" @@ -29,6 +28,10 @@ func TestNewHTMLExporter(t *testing.T) { if htmlExporter.htmlCleaner == nil { t.Error("htmlCleaner should not be nil") } + + if htmlExporter.tmpl == nil { + t.Error("template should not be nil") + } } // TestHTMLExporter_SupportedFormat tests the SupportedFormat method. @@ -118,6 +121,7 @@ func TestHTMLExporter_Export(t *testing.T) { } if !strings.Contains(contentStr, "font-family") { + t.Logf("Generated HTML (first 500 chars):\n%s", contentStr[:min(500, len(contentStr))]) t.Error("Output should contain CSS font-family") } } @@ -138,409 +142,7 @@ func TestHTMLExporter_Export_InvalidPath(t *testing.T) { } } -// TestHTMLExporter_ProcessTextItem tests the processTextItem method. -func TestHTMLExporter_ProcessTextItem(t *testing.T) { - htmlCleaner := services.NewHTMLCleaner() - exporter := &HTMLExporter{htmlCleaner: htmlCleaner} - - var buf bytes.Buffer - item := models.Item{ - Type: "text", - Items: []models.SubItem{ - { - Heading: "

Test Heading

", - Paragraph: "

Test paragraph with bold text.

", - }, - { - Paragraph: "

Another paragraph.

", - }, - }, - } - - exporter.processTextItem(&buf, item) - - result := buf.String() - - if !strings.Contains(result, "text-item") { - t.Error("Should contain text-item CSS class") - } - if !strings.Contains(result, "Text Content") { - t.Error("Should contain text content heading") - } - if !strings.Contains(result, "

Test Heading

") { - t.Error("Should preserve HTML heading") - } - if !strings.Contains(result, "bold") { - t.Error("Should preserve HTML formatting in paragraph") - } -} - -// TestHTMLExporter_ProcessListItem tests the processListItem method. -func TestHTMLExporter_ProcessListItem(t *testing.T) { - htmlCleaner := services.NewHTMLCleaner() - exporter := &HTMLExporter{htmlCleaner: htmlCleaner} - - var buf bytes.Buffer - item := models.Item{ - Type: "list", - Items: []models.SubItem{ - {Paragraph: "

First item

"}, - {Paragraph: "

Second item with emphasis

"}, - {Paragraph: "

Third item

"}, - }, - } - - exporter.processListItem(&buf, item) - - result := buf.String() - - if !strings.Contains(result, "list-item") { - t.Error("Should contain list-item CSS class") - } - if !strings.Contains(result, "