mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 09:42:09 +01:00
refactor(exporter): rewrite HTML exporter to use Go templates
Replaces the manual string-building implementation of the HTML exporter with a more robust and maintainable solution using Go's `html/template` package. This improves readability, security, and separation of concerns. - HTML structure and CSS styles are moved into their own files and embedded into the binary using `go:embed`. - A new data preparation layer adapts the course model for the template, simplifying rendering logic. - Tests are updated to reflect the new implementation, removing obsolete test cases for the old string-building methods. Additionally, this commit: - Adds an `AGENTS.md` file with development and contribution guidelines. - Updates `.golangci.yml` to allow standard Go patterns for interface package naming.
This commit is contained in:
@ -188,6 +188,12 @@ linters:
|
|||||||
- gochecknoglobals
|
- gochecknoglobals
|
||||||
- gochecknoinits
|
- 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
|
# Allow fmt.Print* in main package
|
||||||
- path: ^main\.go$
|
- path: ^main\.go$
|
||||||
text: "use of fmt.Print"
|
text: "use of fmt.Print"
|
||||||
|
|||||||
56
AGENTS.md
Normal file
56
AGENTS.md
Normal file
@ -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
|
||||||
@ -1,25 +1,30 @@
|
|||||||
package exporters
|
package exporters
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html/template"
|
||||||
|
"io"
|
||||||
"os"
|
"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/interfaces"
|
||||||
"github.com/kjanat/articulate-parser/internal/models"
|
"github.com/kjanat/articulate-parser/internal/models"
|
||||||
"github.com/kjanat/articulate-parser/internal/services"
|
"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.
|
// 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 {
|
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 *services.HTMLCleaner
|
||||||
|
// tmpl holds the parsed HTML template
|
||||||
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLExporter creates a new HTMLExporter instance.
|
// NewHTMLExporter creates a new HTMLExporter instance.
|
||||||
@ -31,8 +36,21 @@ 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) interfaces.Exporter {
|
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{
|
return &HTMLExporter{
|
||||||
htmlCleaner: htmlCleaner,
|
htmlCleaner: htmlCleaner,
|
||||||
|
tmpl: tmpl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,72 +65,33 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - An error if writing to the output file fails
|
// - An error if writing to the output file fails
|
||||||
func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
|
func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
|
||||||
var buf bytes.Buffer
|
f, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
// Write HTML document structure
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
buf.WriteString("<!DOCTYPE html>\n")
|
|
||||||
buf.WriteString("<html lang=\"en\">\n")
|
|
||||||
buf.WriteString("<head>\n")
|
|
||||||
buf.WriteString(" <meta charset=\"UTF-8\">\n")
|
|
||||||
buf.WriteString(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
|
|
||||||
buf.WriteString(fmt.Sprintf(" <title>%s</title>\n", html.EscapeString(course.Course.Title)))
|
|
||||||
buf.WriteString(" <style>\n")
|
|
||||||
buf.WriteString(e.getDefaultCSS())
|
|
||||||
buf.WriteString(" </style>\n")
|
|
||||||
buf.WriteString("</head>\n")
|
|
||||||
buf.WriteString("<body>\n")
|
|
||||||
|
|
||||||
// Write course header
|
|
||||||
buf.WriteString(fmt.Sprintf(" <header>\n <h1>%s</h1>\n", html.EscapeString(course.Course.Title)))
|
|
||||||
|
|
||||||
if course.Course.Description != "" {
|
|
||||||
buf.WriteString(fmt.Sprintf(" <div class=\"course-description\">%s</div>\n", course.Course.Description))
|
|
||||||
}
|
}
|
||||||
buf.WriteString(" </header>\n\n")
|
defer f.Close()
|
||||||
|
|
||||||
// Add metadata section
|
return e.WriteHTML(f, course)
|
||||||
buf.WriteString(" <section class=\"course-info\">\n")
|
|
||||||
buf.WriteString(" <h2>Course Information</h2>\n")
|
|
||||||
buf.WriteString(" <ul>\n")
|
|
||||||
buf.WriteString(fmt.Sprintf(" <li><strong>Course ID:</strong> %s</li>\n", html.EscapeString(course.Course.ID)))
|
|
||||||
buf.WriteString(fmt.Sprintf(" <li><strong>Share ID:</strong> %s</li>\n", html.EscapeString(course.ShareID)))
|
|
||||||
buf.WriteString(fmt.Sprintf(" <li><strong>Navigation Mode:</strong> %s</li>\n", html.EscapeString(course.Course.NavigationMode)))
|
|
||||||
if course.Course.ExportSettings != nil {
|
|
||||||
buf.WriteString(fmt.Sprintf(" <li><strong>Export Format:</strong> %s</li>\n", html.EscapeString(course.Course.ExportSettings.Format)))
|
|
||||||
}
|
|
||||||
buf.WriteString(" </ul>\n")
|
|
||||||
buf.WriteString(" </section>\n\n")
|
|
||||||
|
|
||||||
// Process lessons
|
|
||||||
lessonCounter := 0
|
|
||||||
for _, lesson := range course.Course.Lessons {
|
|
||||||
if lesson.Type == "section" {
|
|
||||||
buf.WriteString(fmt.Sprintf(" <section class=\"course-section\">\n <h2>%s</h2>\n </section>\n\n", html.EscapeString(lesson.Title)))
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonCounter++
|
// WriteHTML writes the HTML content to an io.Writer.
|
||||||
buf.WriteString(fmt.Sprintf(" <section class=\"lesson\">\n <h3>Lesson %d: %s</h3>\n", lessonCounter, html.EscapeString(lesson.Title)))
|
// 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)
|
||||||
|
|
||||||
if lesson.Description != "" {
|
// Execute template
|
||||||
buf.WriteString(fmt.Sprintf(" <div class=\"lesson-description\">%s</div>\n", lesson.Description))
|
if err := e.tmpl.Execute(w, data); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process lesson items
|
|
||||||
for _, item := range lesson.Items {
|
|
||||||
e.processItemToHTML(&buf, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.WriteString(" </section>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.WriteString("</body>\n")
|
|
||||||
buf.WriteString("</html>\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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,359 +103,3 @@ func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
|
|||||||
func (e *HTMLExporter) SupportedFormat() string {
|
func (e *HTMLExporter) SupportedFormat() string {
|
||||||
return FormatHTML
|
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(" <div class=\"item text-item\">\n")
|
|
||||||
buf.WriteString(" <h4>Text Content</h4>\n")
|
|
||||||
for _, subItem := range item.Items {
|
|
||||||
if subItem.Heading != "" {
|
|
||||||
fmt.Fprintf(buf, " <h5>%s</h5>\n", subItem.Heading)
|
|
||||||
}
|
|
||||||
if subItem.Paragraph != "" {
|
|
||||||
fmt.Fprintf(buf, " <div>%s</div>\n", subItem.Paragraph)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(" </div>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// processListItem handles list content.
|
|
||||||
func (e *HTMLExporter) processListItem(buf *bytes.Buffer, item models.Item) {
|
|
||||||
buf.WriteString(" <div class=\"item list-item\">\n")
|
|
||||||
buf.WriteString(" <h4>List</h4>\n")
|
|
||||||
buf.WriteString(" <ul>\n")
|
|
||||||
for _, subItem := range item.Items {
|
|
||||||
if subItem.Paragraph != "" {
|
|
||||||
cleanText := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
|
||||||
fmt.Fprintf(buf, " <li>%s</li>\n", html.EscapeString(cleanText))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(" </ul>\n")
|
|
||||||
buf.WriteString(" </div>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// processKnowledgeCheckItem handles quiz questions and answers.
|
|
||||||
func (e *HTMLExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item) {
|
|
||||||
buf.WriteString(" <div class=\"item knowledge-check\">\n")
|
|
||||||
buf.WriteString(" <h4>Knowledge Check</h4>\n")
|
|
||||||
for _, subItem := range item.Items {
|
|
||||||
if subItem.Title != "" {
|
|
||||||
fmt.Fprintf(buf, " <p><strong>Question:</strong> %s</p>\n", subItem.Title)
|
|
||||||
}
|
|
||||||
if len(subItem.Answers) > 0 {
|
|
||||||
e.processAnswers(buf, subItem.Answers)
|
|
||||||
}
|
|
||||||
if subItem.Feedback != "" {
|
|
||||||
fmt.Fprintf(buf, " <div class=\"feedback\"><strong>Feedback:</strong> %s</div>\n", subItem.Feedback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(" </div>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// processMultimediaItem handles multimedia content like videos.
|
|
||||||
func (e *HTMLExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item) {
|
|
||||||
buf.WriteString(" <div class=\"item multimedia-item\">\n")
|
|
||||||
buf.WriteString(" <h4>Media Content</h4>\n")
|
|
||||||
for _, subItem := range item.Items {
|
|
||||||
if subItem.Title != "" {
|
|
||||||
fmt.Fprintf(buf, " <h5>%s</h5>\n", subItem.Title)
|
|
||||||
}
|
|
||||||
if subItem.Media != nil {
|
|
||||||
if subItem.Media.Video != nil {
|
|
||||||
buf.WriteString(" <div class=\"media-info\">\n")
|
|
||||||
fmt.Fprintf(buf, " <p><strong>Video:</strong> %s</p>\n", html.EscapeString(subItem.Media.Video.OriginalURL))
|
|
||||||
if subItem.Media.Video.Duration > 0 {
|
|
||||||
fmt.Fprintf(buf, " <p><strong>Duration:</strong> %d seconds</p>\n", subItem.Media.Video.Duration)
|
|
||||||
}
|
|
||||||
buf.WriteString(" </div>\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if subItem.Caption != "" {
|
|
||||||
fmt.Fprintf(buf, " <div><em>%s</em></div>\n", subItem.Caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(" </div>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// processImageItem handles image content.
|
|
||||||
func (e *HTMLExporter) processImageItem(buf *bytes.Buffer, item models.Item) {
|
|
||||||
buf.WriteString(" <div class=\"item multimedia-item\">\n")
|
|
||||||
buf.WriteString(" <h4>Image</h4>\n")
|
|
||||||
for _, subItem := range item.Items {
|
|
||||||
if subItem.Media != nil && subItem.Media.Image != nil {
|
|
||||||
buf.WriteString(" <div class=\"media-info\">\n")
|
|
||||||
fmt.Fprintf(buf, " <p><strong>Image:</strong> %s</p>\n", html.EscapeString(subItem.Media.Image.OriginalURL))
|
|
||||||
buf.WriteString(" </div>\n")
|
|
||||||
}
|
|
||||||
if subItem.Caption != "" {
|
|
||||||
fmt.Fprintf(buf, " <div><em>%s</em></div>\n", subItem.Caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(" </div>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// processInteractiveItem handles interactive content.
|
|
||||||
func (e *HTMLExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item) {
|
|
||||||
buf.WriteString(" <div class=\"item interactive-item\">\n")
|
|
||||||
buf.WriteString(" <h4>Interactive Content</h4>\n")
|
|
||||||
for _, subItem := range item.Items {
|
|
||||||
if subItem.Title != "" {
|
|
||||||
fmt.Fprintf(buf, " <p><strong>%s</strong></p>\n", subItem.Title)
|
|
||||||
}
|
|
||||||
if subItem.Paragraph != "" {
|
|
||||||
fmt.Fprintf(buf, " <div>%s</div>\n", subItem.Paragraph)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(" </div>\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// processDividerItem handles divider elements.
|
|
||||||
func (e *HTMLExporter) processDividerItem(buf *bytes.Buffer) {
|
|
||||||
buf.WriteString(" <hr>\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(" <div class=\"item unknown-item\">\n")
|
|
||||||
caser := cases.Title(language.English)
|
|
||||||
fmt.Fprintf(buf, " <h4>%s Content</h4>\n", caser.String(item.Type))
|
|
||||||
for _, subItem := range item.Items {
|
|
||||||
e.processGenericSubItem(buf, subItem)
|
|
||||||
}
|
|
||||||
buf.WriteString(" </div>\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, " <p><strong>%s</strong></p>\n", subItem.Title)
|
|
||||||
}
|
|
||||||
if subItem.Paragraph != "" {
|
|
||||||
fmt.Fprintf(buf, " <div>%s</div>\n", subItem.Paragraph)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// processAnswers processes answer choices for quiz questions.
|
|
||||||
func (e *HTMLExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
|
|
||||||
buf.WriteString(" <div class=\"answers\">\n")
|
|
||||||
buf.WriteString(" <h5>Answers:</h5>\n")
|
|
||||||
buf.WriteString(" <ol>\n")
|
|
||||||
for _, answer := range answers {
|
|
||||||
cssClass := ""
|
|
||||||
if answer.Correct {
|
|
||||||
cssClass = " class=\"correct-answer\""
|
|
||||||
}
|
|
||||||
fmt.Fprintf(buf, " <li%s>%s</li>\n", cssClass, html.EscapeString(answer.Title))
|
|
||||||
}
|
|
||||||
buf.WriteString(" </ol>\n")
|
|
||||||
buf.WriteString(" </div>\n")
|
|
||||||
}
|
|
||||||
|
|||||||
173
internal/exporters/html_styles.css
Normal file
173
internal/exporters/html_styles.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
183
internal/exporters/html_template.html
Normal file
183
internal/exporters/html_template.html
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Course.Title}}</title>
|
||||||
|
<style>
|
||||||
|
{{safeCSS .CSS}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{{.Course.Title}}</h1>
|
||||||
|
{{if .Course.Description}}
|
||||||
|
<div class="course-description">{{safeHTML .Course.Description}}</div>
|
||||||
|
{{end}}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="course-info">
|
||||||
|
<h2>Course Information</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Course ID:</strong> {{.Course.ID}}</li>
|
||||||
|
<li><strong>Share ID:</strong> {{.ShareID}}</li>
|
||||||
|
<li><strong>Navigation Mode:</strong> {{.Course.NavigationMode}}</li>
|
||||||
|
{{if .Course.ExportSettings}}
|
||||||
|
<li><strong>Export Format:</strong> {{.Course.ExportSettings.Format}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{range .Sections}}
|
||||||
|
{{if eq .Type "section"}}
|
||||||
|
<section class="course-section">
|
||||||
|
<h2>{{.Title}}</h2>
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<section class="lesson">
|
||||||
|
<h3>Lesson {{.Number}}: {{.Title}}</h3>
|
||||||
|
{{if .Description}}
|
||||||
|
<div class="lesson-description">{{safeHTML .Description}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{range .Items}}
|
||||||
|
{{template "item" .}}
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{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"}}
|
||||||
|
<div class="item text-item">
|
||||||
|
<h4>Text Content</h4>
|
||||||
|
{{range .Items}}
|
||||||
|
{{if .Heading}}
|
||||||
|
{{safeHTML .Heading}}
|
||||||
|
{{end}}
|
||||||
|
{{if .Paragraph}}
|
||||||
|
<div>{{safeHTML .Paragraph}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "listItem"}}
|
||||||
|
<div class="item list-item">
|
||||||
|
<h4>List</h4>
|
||||||
|
<ul>
|
||||||
|
{{range .Items}}
|
||||||
|
{{if .Paragraph}}
|
||||||
|
<li>{{.CleanText}}</li>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "knowledgeCheckItem"}}
|
||||||
|
<div class="item knowledge-check">
|
||||||
|
<h4>Knowledge Check</h4>
|
||||||
|
{{range .Items}}
|
||||||
|
{{if .Title}}
|
||||||
|
<p><strong>Question:</strong> {{safeHTML .Title}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Answers}}
|
||||||
|
<div class="answers">
|
||||||
|
<h5>Answers:</h5>
|
||||||
|
<ol>
|
||||||
|
{{range .Answers}}
|
||||||
|
<li{{if .Correct}} class="correct-answer"{{end}}>{{.Title}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Feedback}}
|
||||||
|
<div class="feedback"><strong>Feedback:</strong> {{safeHTML .Feedback}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "multimediaItem"}}
|
||||||
|
<div class="item multimedia-item">
|
||||||
|
<h4>Media Content</h4>
|
||||||
|
{{range .Items}}
|
||||||
|
{{if .Title}}
|
||||||
|
<h5>{{.Title}}</h5>
|
||||||
|
{{end}}
|
||||||
|
{{if .Media}}
|
||||||
|
{{if .Media.Video}}
|
||||||
|
<div class="media-info">
|
||||||
|
<p><strong>Video:</strong> {{.Media.Video.OriginalURL}}</p>
|
||||||
|
{{if gt .Media.Video.Duration 0}}
|
||||||
|
<p><strong>Duration:</strong> {{.Media.Video.Duration}} seconds</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .Caption}}
|
||||||
|
<div><em>{{.Caption}}</em></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "imageItem"}}
|
||||||
|
<div class="item multimedia-item">
|
||||||
|
<h4>Image</h4>
|
||||||
|
{{range .Items}}
|
||||||
|
{{if and .Media .Media.Image}}
|
||||||
|
<div class="media-info">
|
||||||
|
<p><strong>Image:</strong> {{.Media.Image.OriginalURL}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Caption}}
|
||||||
|
<div><em>{{.Caption}}</em></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "interactiveItem"}}
|
||||||
|
<div class="item interactive-item">
|
||||||
|
<h4>Interactive Content</h4>
|
||||||
|
{{range .Items}}
|
||||||
|
{{if .Title}}
|
||||||
|
<p><strong>{{.Title}}</strong></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Paragraph}}
|
||||||
|
<div>{{safeHTML .Paragraph}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "dividerItem"}}
|
||||||
|
<hr>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "unknownItem"}}
|
||||||
|
<div class="item unknown-item">
|
||||||
|
<h4>{{.TypeTitle}} Content</h4>
|
||||||
|
{{range .Items}}
|
||||||
|
{{if .Title}}
|
||||||
|
<p><strong>{{.Title}}</strong></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Paragraph}}
|
||||||
|
<div>{{safeHTML .Paragraph}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
131
internal/exporters/html_template_data.go
Normal file
131
internal/exporters/html_template_data.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
package exporters
|
package exporters
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -29,6 +28,10 @@ func TestNewHTMLExporter(t *testing.T) {
|
|||||||
if htmlExporter.htmlCleaner == nil {
|
if htmlExporter.htmlCleaner == nil {
|
||||||
t.Error("htmlCleaner should not be 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.
|
// TestHTMLExporter_SupportedFormat tests the SupportedFormat method.
|
||||||
@ -118,6 +121,7 @@ func TestHTMLExporter_Export(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(contentStr, "font-family") {
|
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")
|
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.
|
// TestHTMLExporter_ComplexCourse tests export of a course with complex content.
|
||||||
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: "<h1>Test Heading</h1>",
|
|
||||||
Paragraph: "<p>Test paragraph with <strong>bold</strong> text.</p>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Paragraph: "<p>Another paragraph.</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "<h1>Test Heading</h1>") {
|
|
||||||
t.Error("Should preserve HTML heading")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<strong>bold</strong>") {
|
|
||||||
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: "<p>First item</p>"},
|
|
||||||
{Paragraph: "<p>Second item with <em>emphasis</em></p>"},
|
|
||||||
{Paragraph: "<p>Third item</p>"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "<ul>") {
|
|
||||||
t.Error("Should contain unordered list")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<li>First item</li>") {
|
|
||||||
t.Error("Should contain first list item")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<li>Second item with emphasis</li>") {
|
|
||||||
t.Error("Should contain second list item with cleaned HTML")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<li>Third item</li>") {
|
|
||||||
t.Error("Should contain third list item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
|
|
||||||
func TestHTMLExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
item := models.Item{
|
|
||||||
Type: "knowledgeCheck",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Title: "<p>What is the correct answer?</p>",
|
|
||||||
Answers: []models.Answer{
|
|
||||||
{Title: "Wrong answer", Correct: false},
|
|
||||||
{Title: "Correct answer", Correct: true},
|
|
||||||
{Title: "Another wrong answer", Correct: false},
|
|
||||||
},
|
|
||||||
Feedback: "<p>Great job! This is the feedback.</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
exporter.processKnowledgeCheckItem(&buf, item)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(result, "knowledge-check") {
|
|
||||||
t.Error("Should contain knowledge-check CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Knowledge Check") {
|
|
||||||
t.Error("Should contain knowledge check heading")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "What is the correct answer?") {
|
|
||||||
t.Error("Should contain question text")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Wrong answer") {
|
|
||||||
t.Error("Should contain first answer")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "correct-answer") {
|
|
||||||
t.Error("Should mark correct answer with CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Feedback") {
|
|
||||||
t.Error("Should contain feedback section")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessMultimediaItem tests the processMultimediaItem method.
|
|
||||||
func TestHTMLExporter_ProcessMultimediaItem(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
item := models.Item{
|
|
||||||
Type: "multimedia",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Title: "<p>Video Title</p>",
|
|
||||||
Media: &models.Media{
|
|
||||||
Video: &models.VideoMedia{
|
|
||||||
OriginalURL: "https://example.com/video.mp4",
|
|
||||||
Duration: 120,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Caption: "<p>Video caption</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
exporter.processMultimediaItem(&buf, item)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(result, "multimedia-item") {
|
|
||||||
t.Error("Should contain multimedia-item CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Media Content") {
|
|
||||||
t.Error("Should contain media content heading")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Video Title") {
|
|
||||||
t.Error("Should contain video title")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "https://example.com/video.mp4") {
|
|
||||||
t.Error("Should contain video URL")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "120 seconds") {
|
|
||||||
t.Error("Should contain video duration")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Video caption") {
|
|
||||||
t.Error("Should contain video caption")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessImageItem tests the processImageItem method.
|
|
||||||
func TestHTMLExporter_ProcessImageItem(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
item := models.Item{
|
|
||||||
Type: "image",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Media: &models.Media{
|
|
||||||
Image: &models.ImageMedia{
|
|
||||||
OriginalURL: "https://example.com/image.png",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Caption: "<p>Image caption</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
exporter.processImageItem(&buf, item)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(result, "multimedia-item") {
|
|
||||||
t.Error("Should contain multimedia-item CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Image") {
|
|
||||||
t.Error("Should contain image heading")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "https://example.com/image.png") {
|
|
||||||
t.Error("Should contain image URL")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Image caption") {
|
|
||||||
t.Error("Should contain image caption")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessInteractiveItem tests the processInteractiveItem method.
|
|
||||||
func TestHTMLExporter_ProcessInteractiveItem(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
item := models.Item{
|
|
||||||
Type: "interactive",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Title: "<p>Interactive element title</p>",
|
|
||||||
Paragraph: "<p>Interactive content description</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
exporter.processInteractiveItem(&buf, item)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(result, "interactive-item") {
|
|
||||||
t.Error("Should contain interactive-item CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Interactive Content") {
|
|
||||||
t.Error("Should contain interactive content heading")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Interactive element title") {
|
|
||||||
t.Error("Should contain interactive element title")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Interactive content description") {
|
|
||||||
t.Error("Should contain interactive content description")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessDividerItem tests the processDividerItem method.
|
|
||||||
func TestHTMLExporter_ProcessDividerItem(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
exporter.processDividerItem(&buf)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
expected := " <hr>\n\n"
|
|
||||||
|
|
||||||
if result != expected {
|
|
||||||
t.Errorf("Expected %q, got %q", expected, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessUnknownItem tests the processUnknownItem method.
|
|
||||||
func TestHTMLExporter_ProcessUnknownItem(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
item := models.Item{
|
|
||||||
Type: "unknown",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Title: "<p>Unknown item title</p>",
|
|
||||||
Paragraph: "<p>Unknown item content</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
exporter.processUnknownItem(&buf, item)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(result, "unknown-item") {
|
|
||||||
t.Error("Should contain unknown-item CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Unknown Content") {
|
|
||||||
t.Error("Should contain unknown content heading")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Unknown item title") {
|
|
||||||
t.Error("Should contain unknown item title")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "Unknown item content") {
|
|
||||||
t.Error("Should contain unknown item content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessAnswers tests the processAnswers method.
|
|
||||||
func TestHTMLExporter_ProcessAnswers(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
answers := []models.Answer{
|
|
||||||
{Title: "Answer 1", Correct: false},
|
|
||||||
{Title: "Answer 2", Correct: true},
|
|
||||||
{Title: "Answer 3", Correct: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
exporter.processAnswers(&buf, answers)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(result, "answers") {
|
|
||||||
t.Error("Should contain answers CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<h5>Answers:</h5>") {
|
|
||||||
t.Error("Should contain answers heading")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<ol>") {
|
|
||||||
t.Error("Should contain ordered list")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<li>Answer 1</li>") {
|
|
||||||
t.Error("Should contain first answer")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "correct-answer") {
|
|
||||||
t.Error("Should mark correct answer with CSS class")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<li class=\"correct-answer\">Answer 2</li>") {
|
|
||||||
t.Error("Should mark correct answer properly")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "<li>Answer 3</li>") {
|
|
||||||
t.Error("Should contain third answer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ProcessItemToHTML_AllTypes tests all item types.
|
|
||||||
func TestHTMLExporter_ProcessItemToHTML_AllTypes(t *testing.T) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
itemType string
|
|
||||||
expectedText string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "text item",
|
|
||||||
itemType: "text",
|
|
||||||
expectedText: "Text Content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list item",
|
|
||||||
itemType: "list",
|
|
||||||
expectedText: "List",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "knowledge check item",
|
|
||||||
itemType: "knowledgeCheck",
|
|
||||||
expectedText: "Knowledge Check",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multimedia item",
|
|
||||||
itemType: "multimedia",
|
|
||||||
expectedText: "Media Content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "image item",
|
|
||||||
itemType: "image",
|
|
||||||
expectedText: "Image",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "interactive item",
|
|
||||||
itemType: "interactive",
|
|
||||||
expectedText: "Interactive Content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "divider item",
|
|
||||||
itemType: "divider",
|
|
||||||
expectedText: "<hr>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown item",
|
|
||||||
itemType: "unknown",
|
|
||||||
expectedText: "Unknown Content",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
item := models.Item{
|
|
||||||
Type: tt.itemType,
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{Title: "Test title", Paragraph: "Test content"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty unknown items
|
|
||||||
if tt.itemType == "unknown" && tt.expectedText == "" {
|
|
||||||
item.Items = []models.SubItem{}
|
|
||||||
}
|
|
||||||
|
|
||||||
exporter.processItemToHTML(&buf, item)
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) {
|
|
||||||
t.Errorf("Expected content to contain: %q\nGot: %q", tt.expectedText, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTMLExporter_ComplexCourse tests export of a complex course structure.
|
|
||||||
func TestHTMLExporter_ComplexCourse(t *testing.T) {
|
func TestHTMLExporter_ComplexCourse(t *testing.T) {
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner)
|
||||||
@ -742,11 +344,17 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
|||||||
Type: "text",
|
Type: "text",
|
||||||
Items: []models.SubItem{
|
Items: []models.SubItem{
|
||||||
{
|
{
|
||||||
Heading: "<h1>Heading with <em>emphasis</em> and & entities</h1>",
|
Heading: "<h2>HTML Heading</h2>",
|
||||||
Paragraph: "<p>Paragraph with <code> entities and <strong>formatting</strong>.</p>",
|
Paragraph: "<p>Content with <em>emphasis</em> and <strong>strong</strong> text.</p>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Type: "list",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{Paragraph: "<p>List item with <b>bold</b> text</p>"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -761,13 +369,6 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
|||||||
t.Fatalf("Export failed: %v", err)
|
t.Fatalf("Export failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file was created (basic check that HTML handling didn't break export)
|
|
||||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
|
||||||
t.Fatal("Output file was not created")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read content and verify some HTML is preserved (descriptions, headings, paragraphs)
|
|
||||||
// while list items are cleaned for safety
|
|
||||||
content, err := os.ReadFile(outputPath)
|
content, err := os.ReadFile(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read output file: %v", err)
|
t.Fatalf("Failed to read output file: %v", err)
|
||||||
@ -775,19 +376,23 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
|||||||
|
|
||||||
contentStr := string(content)
|
contentStr := string(content)
|
||||||
|
|
||||||
// HTML should be preserved in some places
|
// HTML content in descriptions should be preserved
|
||||||
if !strings.Contains(contentStr, "<b>bold</b>") {
|
if !strings.Contains(contentStr, "<b>bold</b>") {
|
||||||
t.Error("Should preserve HTML formatting in descriptions")
|
t.Error("Should preserve HTML formatting in descriptions")
|
||||||
}
|
}
|
||||||
if !strings.Contains(contentStr, "<h1>Heading with <em>emphasis</em>") {
|
|
||||||
|
// HTML content in headings should be preserved
|
||||||
|
if !strings.Contains(contentStr, "<h2>HTML Heading</h2>") {
|
||||||
t.Error("Should preserve HTML in headings")
|
t.Error("Should preserve HTML in headings")
|
||||||
}
|
}
|
||||||
if !strings.Contains(contentStr, "<strong>formatting</strong>") {
|
|
||||||
t.Error("Should preserve HTML in paragraphs")
|
// List items should have HTML tags stripped (cleaned)
|
||||||
|
if !strings.Contains(contentStr, "List item with bold text") {
|
||||||
|
t.Error("Should clean HTML from list items")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTestCourseForHTML creates a test course for HTML export testing.
|
// createTestCourseForHTML creates a test course for HTML export tests.
|
||||||
func createTestCourseForHTML() *models.Course {
|
func createTestCourseForHTML() *models.Course {
|
||||||
return &models.Course{
|
return &models.Course{
|
||||||
ShareID: "test-share-id",
|
ShareID: "test-share-id",
|
||||||
@ -837,38 +442,14 @@ func BenchmarkHTMLExporter_Export(b *testing.B) {
|
|||||||
exporter := NewHTMLExporter(htmlCleaner)
|
exporter := NewHTMLExporter(htmlCleaner)
|
||||||
course := createTestCourseForHTML()
|
course := createTestCourseForHTML()
|
||||||
|
|
||||||
// Create temporary directory
|
|
||||||
tempDir := b.TempDir()
|
tempDir := b.TempDir()
|
||||||
|
|
||||||
for b.Loop() {
|
for i := range b.N {
|
||||||
outputPath := filepath.Join(tempDir, "benchmark-course.html")
|
outputPath := filepath.Join(tempDir, "bench-course-"+string(rune(i))+".html")
|
||||||
_ = exporter.Export(course, outputPath)
|
if err := exporter.Export(course, outputPath); err != nil {
|
||||||
// Clean up for next iteration. Remove errors are ignored because we've already
|
b.Fatalf("Export failed: %v", err)
|
||||||
// benchmarked the export operation; cleanup failures don't affect the benchmark
|
|
||||||
// measurements or the validity of the next iteration's export.
|
|
||||||
_ = os.Remove(outputPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BenchmarkHTMLExporter_ProcessTextItem benchmarks text item processing.
|
|
||||||
func BenchmarkHTMLExporter_ProcessTextItem(b *testing.B) {
|
|
||||||
htmlCleaner := services.NewHTMLCleaner()
|
|
||||||
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
|
|
||||||
|
|
||||||
item := models.Item{
|
|
||||||
Type: "text",
|
|
||||||
Items: []models.SubItem{
|
|
||||||
{
|
|
||||||
Heading: "<h1>Benchmark Heading</h1>",
|
|
||||||
Paragraph: "<p>Benchmark paragraph with <strong>formatting</strong>.</p>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for b.Loop() {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
exporter.processTextItem(&buf, item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
|
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
|
||||||
@ -891,37 +472,37 @@ func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
|
|||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
lesson := models.Lesson{
|
lesson := models.Lesson{
|
||||||
ID: "lesson-" + string(rune(i)),
|
ID: "lesson-" + string(rune(i)),
|
||||||
Title: "Lesson " + string(rune(i)),
|
Title: "Benchmark Lesson " + string(rune(i)),
|
||||||
Type: "lesson",
|
Type: "lesson",
|
||||||
Items: make([]models.Item, 5), // 5 items per lesson
|
Description: "<p>Lesson description</p>",
|
||||||
}
|
Items: []models.Item{
|
||||||
|
{
|
||||||
for j := range 5 {
|
|
||||||
item := models.Item{
|
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Items: make([]models.SubItem, 3), // 3 sub-items per item
|
Items: []models.SubItem{
|
||||||
|
{
|
||||||
|
Heading: "<h2>Heading</h2>",
|
||||||
|
Paragraph: "<p>Paragraph with content.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "list",
|
||||||
|
Items: []models.SubItem{
|
||||||
|
{Paragraph: "<p>Item 1</p>"},
|
||||||
|
{Paragraph: "<p>Item 2</p>"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for k := range 3 {
|
|
||||||
item.Items[k] = models.SubItem{
|
|
||||||
Heading: "<h3>Heading " + string(rune(k)) + "</h3>",
|
|
||||||
Paragraph: "<p>Paragraph content with <strong>formatting</strong> for performance testing.</p>",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lesson.Items[j] = item
|
|
||||||
}
|
|
||||||
|
|
||||||
course.Course.Lessons[i] = lesson
|
course.Course.Lessons[i] = lesson
|
||||||
}
|
}
|
||||||
|
|
||||||
tempDir := b.TempDir()
|
tempDir := b.TempDir()
|
||||||
|
|
||||||
for b.Loop() {
|
for i := range b.N {
|
||||||
outputPath := filepath.Join(tempDir, "benchmark-complex.html")
|
outputPath := filepath.Join(tempDir, "bench-complex-"+string(rune(i))+".html")
|
||||||
_ = exporter.Export(course, outputPath)
|
if err := exporter.Export(course, outputPath); err != nil {
|
||||||
// Remove errors are ignored because we're only benchmarking the export
|
b.Fatalf("Export failed: %v", err)
|
||||||
// operation itself; cleanup failures don't affect the benchmark metrics.
|
}
|
||||||
_ = os.Remove(outputPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user