3 Commits

Author SHA1 Message Date
kjanat d50a110351 docs: remove emojis from README 2026-06-15 17:13:09 +00:00
kjanat 7933f52003 ci: drop test matrix, run a single Go version from go.mod
The test job ran a 1.24.x/1.25.x matrix, but go.mod requires go 1.25.0, so the
1.24 entry just auto-downloaded the 1.25 toolchain and tested the same thing
twice. Replace the matrix with a single job that sources its Go version from
go.mod via go-version-file, and reference the resolved version through the
setup-go step output in summaries, artifact names, and Codecov flags.
2026-06-15 17:12:19 +00:00
kjanat bf07d6a172 fix(lint): resolve golangci-lint failures and modernize string handling
CI's golangci-lint (v2.12.2) job was failing with 14 issues, which blocked
the dependent test job. This addresses all of them:

- goconst: extract repeated string literals into constants
  - format aliases ("md", "word", "htm") in the exporter factory
  - "section" lesson type shared by markdown and HTML exporters
  - default Articulate Rise base URL and host in the parser
  - reuse existing itemType* constants in the markdown switch
- staticcheck (QF1012): replace buf.WriteString(fmt.Sprintf(...)) with
  fmt.Fprintf(...) in the markdown exporter

Also drop the hardcoded `go: "1.24"` from .golangci.yml so the target Go
version is autodetected from go.mod.
2026-06-15 16:59:06 +00:00
7 changed files with 63 additions and 48 deletions
+11 -15
View File
@@ -35,19 +35,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
strategy:
matrix:
go:
- 1.24.x
- 1.25.x
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go }} - name: Set up Go
id: setup-go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: ${{ matrix.go }} go-version-file: go.mod
check-latest: true check-latest: true
- name: Install Task - name: Install Task
@@ -70,7 +66,7 @@ jobs:
{ {
cat << EOF cat << EOF
## 🔧 Test Environment ## 🔧 Test Environment
- **Go Version:** ${{ matrix.go }} - **Go Version:** ${{ steps.setup-go.outputs.go-version }}
- **OS:** ubuntu-latest - **OS:** ubuntu-latest
- **Timestamp:** $(date -u) - **Timestamp:** $(date -u)
@@ -90,7 +86,7 @@ jobs:
# Generate test summary # Generate test summary
{ {
cat << EOF cat << EOF
## 🧪 Test Results (Go ${{ matrix.go }}) ## 🧪 Test Results (Go ${{ steps.setup-go.outputs.go-version }})
| Metric | Value | | Metric | Value |
| ----------- | ------------------------------------------------------------- | | ----------- | ------------------------------------------------------------- |
@@ -152,7 +148,7 @@ jobs:
{ {
cat << EOF cat << EOF
## 📊 Code Coverage (Go ${{ matrix.go }}) ## 📊 Code Coverage (Go ${{ steps.setup-go.outputs.go-version }})
**Total Coverage: $COVERAGE** **Total Coverage: $COVERAGE**
@@ -213,7 +209,7 @@ jobs:
if: failure() if: failure()
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:
name: test-results-go-${{ matrix.go }} name: test-results-go-${{ steps.setup-go.outputs.go-version }}
path: | path: |
test-output.log test-output.log
coverage/ coverage/
@@ -223,7 +219,7 @@ jobs:
run: | run: |
{ {
cat << EOF cat << EOF
## 🔍 Static Analysis (Go ${{ matrix.go }}) ## 🔍 Static Analysis (Go ${{ steps.setup-go.outputs.go-version }})
EOF EOF
@@ -268,7 +264,7 @@ jobs:
if: always() if: always()
run: | run: |
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
## 📋 Job Summary (Go ${{ matrix.go }}) ## 📋 Job Summary (Go ${{ steps.setup-go.outputs.go-version }})
| Step | Status | | Step | Status |
| --------------- | --------------------------------------------------------------- | | --------------- | --------------------------------------------------------------- |
@@ -284,7 +280,7 @@ jobs:
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:
files: ./coverage/coverage.out files: ./coverage/coverage.out
flags: Go ${{ matrix.go }} flags: Go ${{ steps.setup-go.outputs.go-version }}
slug: kjanat/articulate-parser slug: kjanat/articulate-parser
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -292,7 +288,7 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1 uses: codecov/test-results-action@v1
with: with:
flags: Go ${{ matrix.go }} flags: Go ${{ steps.setup-go.outputs.go-version }}
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
docker-test: docker-test:
+1 -2
View File
@@ -10,8 +10,7 @@ run:
# Skip directories (not allowed in config v2, will use issues exclude instead) # Skip directories (not allowed in config v2, will use issues exclude instead)
# Go version # Go version is autodetected from go.mod
go: "1.24"
# Include test files # Include test files
tests: true tests: true
+7 -7
View File
@@ -78,12 +78,12 @@ flowchart TD
The system follows **Clean Architecture** principles with clear separation of concerns: The system follows **Clean Architecture** principles with clear separation of concerns:
- **🎯 Entry Point**: Command-line interface handles user input and coordinates operations - **Entry Point**: Command-line interface handles user input and coordinates operations
- **🏗️ Application Layer**: Core business logic with dependency injection - **Application Layer**: Core business logic with dependency injection
- **📋 Interface Layer**: Contracts defining behavior without implementation details - **Interface Layer**: Contracts defining behavior without implementation details
- **🔧 Service Layer**: Concrete implementations of parsing and utility services - **Service Layer**: Concrete implementations of parsing and utility services
- **📤 Export Layer**: Factory pattern for format-specific exporters - **Export Layer**: Factory pattern for format-specific exporters
- **📊 Data Layer**: Domain models representing course structure - **Data Layer**: Domain models representing course structure
## Features ## Features
@@ -206,7 +206,7 @@ Then run:
The application is available as a Docker image from GitHub Container Registry. The application is available as a Docker image from GitHub Container Registry.
### 🐳 Docker Image Information ### Docker Image Information
- **Registry**: `ghcr.io/kjanat/articulate-parser` - **Registry**: `ghcr.io/kjanat/articulate-parser`
- **Platforms**: linux/amd64, linux/arm64 - **Platforms**: linux/amd64, linux/arm64
+13 -4
View File
@@ -13,6 +13,11 @@ const (
FormatMarkdown = "markdown" FormatMarkdown = "markdown"
FormatDocx = "docx" FormatDocx = "docx"
FormatHTML = "html" FormatHTML = "html"
// Format aliases accepted by CreateExporter.
formatAliasMarkdown = "md"
formatAliasDocx = "word"
formatAliasHTML = "htm"
) )
// Factory implements the ExporterFactory interface. // Factory implements the ExporterFactory interface.
@@ -41,11 +46,11 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
// Format strings are case-insensitive (e.g., "markdown", "DOCX"). // Format strings are case-insensitive (e.g., "markdown", "DOCX").
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) { func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
switch strings.ToLower(format) { switch strings.ToLower(format) {
case FormatMarkdown, "md": case FormatMarkdown, formatAliasMarkdown:
return NewMarkdownExporter(f.htmlCleaner), nil return NewMarkdownExporter(f.htmlCleaner), nil
case FormatDocx, "word": case FormatDocx, formatAliasDocx:
return NewDocxExporter(f.htmlCleaner), nil return NewDocxExporter(f.htmlCleaner), nil
case FormatHTML, "htm": case FormatHTML, formatAliasHTML:
return NewHTMLExporter(f.htmlCleaner), nil return NewHTMLExporter(f.htmlCleaner), nil
default: default:
return nil, fmt.Errorf("unsupported export format: %s", format) return nil, fmt.Errorf("unsupported export format: %s", format)
@@ -55,5 +60,9 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
// SupportedFormats returns a list of all supported export formats, // SupportedFormats returns a list of all supported export formats,
// including both primary format names and their aliases. // including both primary format names and their aliases.
func (f *Factory) SupportedFormats() []string { func (f *Factory) SupportedFormats() []string {
return []string{FormatMarkdown, "md", FormatDocx, "word", FormatHTML, "htm"} return []string{
FormatMarkdown, formatAliasMarkdown,
FormatDocx, formatAliasDocx,
FormatHTML, formatAliasHTML,
}
} }
+4 -1
View File
@@ -21,6 +21,9 @@ const (
itemTypeDivider = "divider" itemTypeDivider = "divider"
) )
// lessonTypeSection identifies a lesson that acts as a section header.
const lessonTypeSection = "section"
// templateData represents the data structure passed to the HTML template. // templateData represents the data structure passed to the HTML template.
type templateData struct { type templateData struct {
Course models.CourseInfo Course models.CourseInfo
@@ -74,7 +77,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane
Description: lesson.Description, Description: lesson.Description,
} }
if lesson.Type != "section" { if lesson.Type != lessonTypeSection {
lessonCounter++ lessonCounter++
section.Number = lessonCounter section.Number = lessonCounter
section.Items = prepareItems(lesson.Items, htmlCleaner) section.Items = prepareItems(lesson.Items, htmlCleaner)
+17 -17
View File
@@ -40,35 +40,35 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
var buf bytes.Buffer var buf bytes.Buffer
// Write course header // Write course header
buf.WriteString(fmt.Sprintf("# %s\n\n", course.Course.Title)) fmt.Fprintf(&buf, "# %s\n\n", course.Course.Title)
if course.Course.Description != "" { if course.Course.Description != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(course.Course.Description))) fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(course.Course.Description))
} }
// Add metadata // Add metadata
buf.WriteString("## Course Information\n\n") buf.WriteString("## Course Information\n\n")
buf.WriteString(fmt.Sprintf("- **Course ID**: %s\n", course.Course.ID)) fmt.Fprintf(&buf, "- **Course ID**: %s\n", course.Course.ID)
buf.WriteString(fmt.Sprintf("- **Share ID**: %s\n", course.ShareID)) fmt.Fprintf(&buf, "- **Share ID**: %s\n", course.ShareID)
buf.WriteString(fmt.Sprintf("- **Navigation Mode**: %s\n", course.Course.NavigationMode)) fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", course.Course.NavigationMode)
if course.Course.ExportSettings != nil { if course.Course.ExportSettings != nil {
buf.WriteString(fmt.Sprintf("- **Export Format**: %s\n", course.Course.ExportSettings.Format)) fmt.Fprintf(&buf, "- **Export Format**: %s\n", course.Course.ExportSettings.Format)
} }
buf.WriteString("\n---\n\n") buf.WriteString("\n---\n\n")
// Process lessons // Process lessons
lessonCounter := 0 lessonCounter := 0
for _, lesson := range course.Course.Lessons { for _, lesson := range course.Course.Lessons {
if lesson.Type == "section" { if lesson.Type == lessonTypeSection {
buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title)) fmt.Fprintf(&buf, "# %s\n\n", lesson.Title)
continue continue
} }
lessonCounter++ lessonCounter++
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", lessonCounter, lesson.Title)) fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, lesson.Title)
if lesson.Description != "" { if lesson.Description != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description))) fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description))
} }
// Process lesson items // Process lesson items
@@ -100,19 +100,19 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
itemType := strings.ToLower(item.Type) itemType := strings.ToLower(item.Type)
switch itemType { switch itemType {
case "text": case itemTypeText:
e.processTextItem(buf, item, headingPrefix) e.processTextItem(buf, item, headingPrefix)
case "list": case itemTypeList:
e.processListItem(buf, item) e.processListItem(buf, item)
case "multimedia": case itemTypeMultimedia:
e.processMultimediaItem(buf, item, headingPrefix) e.processMultimediaItem(buf, item, headingPrefix)
case "image": case itemTypeImage:
e.processImageItem(buf, item, headingPrefix) e.processImageItem(buf, item, headingPrefix)
case "knowledgecheck": case itemTypeKnowledgeCheck:
e.processKnowledgeCheckItem(buf, item, headingPrefix) e.processKnowledgeCheckItem(buf, item, headingPrefix)
case "interactive": case itemTypeInteractive:
e.processInteractiveItem(buf, item, headingPrefix) e.processInteractiveItem(buf, item, headingPrefix)
case "divider": case itemTypeDivider:
e.processDividerItem(buf) e.processDividerItem(buf)
default: default:
e.processUnknownItem(buf, item, headingPrefix) e.processUnknownItem(buf, item, headingPrefix)
+10 -2
View File
@@ -15,6 +15,14 @@ import (
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
) )
// Default endpoint configuration for the Articulate Rise API.
const (
// Root URL for the Articulate Rise API.
defaultBaseURL = "https://rise.articulate.com"
// Expected host for Articulate Rise share URLs.
riseHost = "rise.articulate.com"
)
// shareIDRegex is compiled once at package init for extracting share IDs from URIs. // shareIDRegex is compiled once at package init for extracting share IDs from URIs.
var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`) var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
@@ -37,7 +45,7 @@ func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.
logger = NewNoOpLogger() logger = NewNoOpLogger()
} }
if baseURL == "" { if baseURL == "" {
baseURL = "https://rise.articulate.com" baseURL = defaultBaseURL
} }
if timeout == 0 { if timeout == 0 {
timeout = 30 * time.Second timeout = 30 * time.Second
@@ -132,7 +140,7 @@ func (p *ArticulateParser) extractShareID(uri string) (string, error) {
} }
// Validate that it's an Articulate Rise domain // Validate that it's an Articulate Rise domain
if parsedURL.Host != "rise.articulate.com" { if parsedURL.Host != riseHost {
return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host) return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host)
} }