mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 11:42:09 +01:00
refactor(core)!: Add context, config, and structured logging
Introduces `context.Context` to the `FetchCourse` method and its call chain, allowing for cancellable network requests and timeouts. This improves application robustness when fetching remote course data. A new configuration package centralizes application settings, loading them from environment variables with sensible defaults for base URL, request timeout, and logging. Standard `log` and `fmt` calls are replaced with a structured logging system built on `slog`, supporting both JSON and human-readable text formats. This change also includes: - Extensive benchmarks and example tests. - Simplified Go doc comments across several packages. BREAKING CHANGE: The `NewArticulateParser` constructor signature has been updated to accept a logger, base URL, and timeout, which are now supplied via the new configuration system.
This commit is contained in:
201
internal/exporters/bench_test.go
Normal file
201
internal/exporters/bench_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
// Package exporters_test provides benchmarks for all exporters.
|
||||
package exporters
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation.
|
||||
func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = factory.CreateExporter("markdown")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types.
|
||||
func BenchmarkFactory_CreateExporter_All(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
formats := []string{"markdown", "docx", "html"}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
for _, format := range formats {
|
||||
_, _ = factory.CreateExporter(format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkAllExporters_Export benchmarks all exporters with the same course.
|
||||
func BenchmarkAllExporters_Export(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
course := createBenchmarkCourse()
|
||||
|
||||
exporters := map[string]struct {
|
||||
exporter any
|
||||
ext string
|
||||
}{
|
||||
"Markdown": {NewMarkdownExporter(htmlCleaner), ".md"},
|
||||
"Docx": {NewDocxExporter(htmlCleaner), ".docx"},
|
||||
"HTML": {NewHTMLExporter(htmlCleaner), ".html"},
|
||||
}
|
||||
|
||||
for name, exp := range exporters {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
exporter := exp.exporter.(interface {
|
||||
Export(*models.Course, string) error
|
||||
})
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
outputPath := filepath.Join(tempDir, "benchmark"+exp.ext)
|
||||
_ = exporter.Export(course, outputPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkExporters_LargeCourse benchmarks exporters with large course data.
|
||||
func BenchmarkExporters_LargeCourse(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
course := createLargeBenchmarkCourse()
|
||||
|
||||
b.Run("Markdown_Large", func(b *testing.B) {
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
outputPath := filepath.Join(tempDir, "large.md")
|
||||
_ = exporter.Export(course, outputPath)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Docx_Large", func(b *testing.B) {
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
outputPath := filepath.Join(tempDir, "large.docx")
|
||||
_ = exporter.Export(course, outputPath)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("HTML_Large", func(b *testing.B) {
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
outputPath := filepath.Join(tempDir, "large.html")
|
||||
_ = exporter.Export(course, outputPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createBenchmarkCourse creates a standard-sized course for benchmarking.
|
||||
func createBenchmarkCourse() *models.Course {
|
||||
return &models.Course{
|
||||
ShareID: "benchmark-id",
|
||||
Author: "Benchmark Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "bench-course",
|
||||
Title: "Benchmark Course",
|
||||
Description: "Performance testing course",
|
||||
NavigationMode: "menu",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "lesson1",
|
||||
Title: "Introduction",
|
||||
Type: "lesson",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "Welcome",
|
||||
Paragraph: "<p>This is a test paragraph with <strong>HTML</strong> content.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "Item 1"},
|
||||
{Paragraph: "Item 2"},
|
||||
{Paragraph: "Item 3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createLargeBenchmarkCourse creates a large course for stress testing.
|
||||
func createLargeBenchmarkCourse() *models.Course {
|
||||
lessons := make([]models.Lesson, 50)
|
||||
for i := 0; i < 50; i++ {
|
||||
lessons[i] = models.Lesson{
|
||||
ID: string(rune(i)),
|
||||
Title: "Lesson " + string(rune(i)),
|
||||
Type: "lesson",
|
||||
Description: "<p>This is lesson description with <em>formatting</em>.</p>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "Section Heading",
|
||||
Paragraph: "<p>Content with <strong>bold</strong> and <em>italic</em> text.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "Point 1"},
|
||||
{Paragraph: "Point 2"},
|
||||
{Paragraph: "Point 3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "knowledgeCheck",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "Quiz Question",
|
||||
Answers: []models.Answer{
|
||||
{Title: "Answer A", Correct: false},
|
||||
{Title: "Answer B", Correct: true},
|
||||
{Title: "Answer C", Correct: false},
|
||||
},
|
||||
Feedback: "Good job!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Course{
|
||||
ShareID: "large-benchmark-id",
|
||||
Author: "Benchmark Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "large-bench-course",
|
||||
Title: "Large Benchmark Course",
|
||||
Description: "Large performance testing course",
|
||||
Lessons: lessons,
|
||||
},
|
||||
}
|
||||
}
|
||||
101
internal/exporters/example_test.go
Normal file
101
internal/exporters/example_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
// Package exporters_test provides examples for the exporters package.
|
||||
package exporters_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/exporters"
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
// ExampleNewFactory demonstrates creating an exporter factory.
|
||||
func ExampleNewFactory() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := exporters.NewFactory(htmlCleaner)
|
||||
|
||||
// Get supported formats
|
||||
formats := factory.SupportedFormats()
|
||||
fmt.Printf("Supported formats: %d\n", len(formats))
|
||||
// Output: Supported formats: 6
|
||||
}
|
||||
|
||||
// ExampleFactory_CreateExporter demonstrates creating exporters.
|
||||
func ExampleFactory_CreateExporter() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := exporters.NewFactory(htmlCleaner)
|
||||
|
||||
// Create a markdown exporter
|
||||
exporter, err := factory.CreateExporter("markdown")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created: %s exporter\n", exporter.SupportedFormat())
|
||||
// Output: Created: markdown exporter
|
||||
}
|
||||
|
||||
// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names.
|
||||
func ExampleFactory_CreateExporter_caseInsensitive() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := exporters.NewFactory(htmlCleaner)
|
||||
|
||||
// All these work (case-insensitive)
|
||||
formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"}
|
||||
|
||||
for _, format := range formats {
|
||||
exporter, _ := factory.CreateExporter(format)
|
||||
fmt.Printf("%s -> %s\n", format, exporter.SupportedFormat())
|
||||
}
|
||||
// Output:
|
||||
// MARKDOWN -> markdown
|
||||
// Markdown -> markdown
|
||||
// markdown -> markdown
|
||||
// MD -> markdown
|
||||
}
|
||||
|
||||
// ExampleMarkdownExporter_Export demonstrates exporting to Markdown.
|
||||
func ExampleMarkdownExporter_Export() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := exporters.NewMarkdownExporter(htmlCleaner)
|
||||
|
||||
course := &models.Course{
|
||||
ShareID: "example-id",
|
||||
Course: models.CourseInfo{
|
||||
Title: "Example Course",
|
||||
Description: "<p>Course description</p>",
|
||||
},
|
||||
}
|
||||
|
||||
// Export to markdown file
|
||||
err := exporter.Export(course, "output.md")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Export complete")
|
||||
// Output: Export complete
|
||||
}
|
||||
|
||||
// ExampleDocxExporter_Export demonstrates exporting to DOCX.
|
||||
func ExampleDocxExporter_Export() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := exporters.NewDocxExporter(htmlCleaner)
|
||||
|
||||
course := &models.Course{
|
||||
ShareID: "example-id",
|
||||
Course: models.CourseInfo{
|
||||
Title: "Example Course",
|
||||
},
|
||||
}
|
||||
|
||||
// Export to Word document
|
||||
err := exporter.Export(course, "output.docx")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("DOCX export complete")
|
||||
// Output: DOCX export complete
|
||||
}
|
||||
@ -33,15 +33,7 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
|
||||
}
|
||||
|
||||
// CreateExporter creates an exporter for the specified format.
|
||||
// It returns an appropriate exporter implementation based on the format string.
|
||||
// Format strings are case-insensitive.
|
||||
//
|
||||
// Parameters:
|
||||
// - format: The desired export format (e.g., "markdown", "docx")
|
||||
//
|
||||
// Returns:
|
||||
// - An implementation of the Exporter interface if the format is supported
|
||||
// - An error if the format is not supported
|
||||
// Format strings are case-insensitive (e.g., "markdown", "DOCX").
|
||||
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
||||
switch strings.ToLower(format) {
|
||||
case "markdown", "md":
|
||||
@ -55,11 +47,8 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// SupportedFormats returns a list of all supported export formats.
|
||||
// This includes both primary format names and their aliases.
|
||||
//
|
||||
// Returns:
|
||||
// - A string slice containing all supported format names
|
||||
// SupportedFormats returns a list of all supported export formats,
|
||||
// including both primary format names and their aliases.
|
||||
func (f *Factory) SupportedFormats() []string {
|
||||
return []string{"markdown", "md", "docx", "word", "html", "htm"}
|
||||
}
|
||||
|
||||
@ -36,16 +36,7 @@ func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter
|
||||
}
|
||||
}
|
||||
|
||||
// Export exports a course to Markdown format.
|
||||
// It generates a structured Markdown document from the course data
|
||||
// and writes it to the specified output path.
|
||||
//
|
||||
// Parameters:
|
||||
// - course: The course data model to export
|
||||
// - outputPath: The file path where the Markdown content will be written
|
||||
//
|
||||
// Returns:
|
||||
// - An error if writing to the output file fails
|
||||
// Export converts the course to Markdown format and writes it to the output path.
|
||||
func (e *MarkdownExporter) Export(course *models.Course, outputPath string) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@ -92,23 +83,13 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
|
||||
return os.WriteFile(outputPath, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
// SupportedFormat returns the format name this exporter supports
|
||||
// It indicates the file format that the MarkdownExporter can generate.
|
||||
//
|
||||
// Returns:
|
||||
// - A string representing the supported format ("markdown")
|
||||
// SupportedFormat returns "markdown".
|
||||
func (e *MarkdownExporter) SupportedFormat() string {
|
||||
return "markdown"
|
||||
}
|
||||
|
||||
// processItemToMarkdown converts a course item into Markdown format
|
||||
// and appends it to the provided buffer. It handles different item types
|
||||
// with appropriate Markdown formatting.
|
||||
//
|
||||
// Parameters:
|
||||
// - buf: The buffer to write the Markdown content to
|
||||
// - item: The course item to process
|
||||
// - level: The heading level for the item (determines the number of # characters)
|
||||
// processItemToMarkdown converts a course item into Markdown format.
|
||||
// The level parameter determines the heading level (number of # characters).
|
||||
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
|
||||
headingPrefix := strings.Repeat("#", level)
|
||||
|
||||
|
||||
BIN
internal/exporters/output.docx
Normal file
BIN
internal/exporters/output.docx
Normal file
Binary file not shown.
12
internal/exporters/output.md
Normal file
12
internal/exporters/output.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Example Course
|
||||
|
||||
Course description
|
||||
|
||||
## Course Information
|
||||
|
||||
- **Course ID**:
|
||||
- **Share ID**: example-id
|
||||
- **Navigation Mode**:
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user