From 144125d355bec770c2d22c69987a5c53705ef750 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Mon, 15 Jun 2026 21:46:57 +0200 Subject: [PATCH] fix: resolve golangci-lint CI failures, simplify CI, tidy docs (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * 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. * docs: remove emojis from README * ci: pin modernize tool to gopls v0.21.x for Go 1.25 compatibility The autofix workflow's `task modernize` step installed the modernize analyzer from gopls@latest, which as of v0.22.0 requires Go 1.26. The project targets Go 1.25 (go.mod) and CI runs with GOTOOLCHAIN=local, so the install failed. Pin to the v0.21.x line, which supports Go >= 1.25. * ci: keep modernize@latest, fetch its toolchain via GOTOOLCHAIN=auto Replaces the earlier v0.21.0 pin. The modernize analyzer (gopls v0.22+) requires Go 1.26, which the project doesn't target yet โ€” and bumping the module to 1.26 isn't viable because the current golangci-lint release is built with Go 1.25 and refuses to lint a newer target. Instead, let the modernize task fetch the toolchain it needs on demand via GOTOOLCHAIN=auto (setup-go pins GOTOOLCHAIN=local in CI), so we stay on the latest analyzer without touching the module's Go version. Also bump golang.org/x/image v0.34.0 -> v0.42.0 via `go get -u ./...`. * ci: set GOTOOLCHAIN=auto inline for the modernize task A task-level env: entry does not override GOTOOLCHAIN when setup-go has already exported GOTOOLCHAIN=local job-wide, so the autofix job still failed. Set GOTOOLCHAIN=auto inline on the modernize command itself, which reliably overrides the inherited value and lets Go fetch the toolchain the modernize analyzer requires. * ci: bump to Go 1.26 and address PR review feedback The autofix failure is fixed properly by moving to Go 1.26 instead of the GOTOOLCHAIN workaround. The official golangci-lint v2.12.2 binary is built with go1.26.2 and lints a Go 1.26 target fine (the earlier "not ready" claim was from a locally go-installed binary compiled with Go 1.25), and the Dockerfiles already use golang:1.26-alpine, so this also aligns the module with the images. - go.mod: go 1.25.0 -> 1.26.0, drop the toolchain pin (keeps the lint target at the go directive). - Taskfile: revert modernize to plain modernize@latest; on Go 1.26 the GOTOOLCHAIN dance and its comments are unnecessary. - ci.yml: pass the resolved Go version through an env var (GO_VERSION) instead of interpolating steps.setup-go.outputs.go-version directly into shell scripts (script-injection hygiene); grant the dependency-review job pull-requests: write so it can post its summary. - parser.go: derive defaultBaseURL from riseHost instead of duplicating the host string; drop the redundant per-const comments. - .golangci.yml: remove the redundant go-version comment. - regenerate internal/exporters/output.docx. --- .github/workflows/ci.yml | 40 +++++++++++++---------- .golangci.yml | 5 --- README.md | 14 ++++---- go.mod | 6 ++-- go.sum | 4 +-- internal/exporters/factory.go | 17 +++++++--- internal/exporters/html_template_data.go | 5 ++- internal/exporters/markdown.go | 34 +++++++++---------- internal/exporters/output.docx | Bin 775 -> 775 bytes internal/services/parser.go | 10 ++++-- 10 files changed, 76 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9fba9e..0f09b92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,21 +35,24 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - strategy: - matrix: - go: - - 1.24.x - - 1.25.x steps: - uses: actions/checkout@v6 - - name: Set up Go ${{ matrix.go }} + - name: Set up Go + id: setup-go uses: actions/setup-go@v6 with: - go-version: ${{ matrix.go }} + go-version-file: go.mod check-latest: true + # Expose the resolved Go version as an env var so it is never + # interpolated directly into a shell script (avoids script injection). + - name: Export Go version + env: + GO_VERSION: ${{ steps.setup-go.outputs.go-version }} + run: echo "GO_VERSION=$GO_VERSION" >> "$GITHUB_ENV" + - name: Install Task uses: go-task/setup-task@v1 @@ -70,7 +73,7 @@ jobs: { cat << EOF ## ๐Ÿ”ง Test Environment - - **Go Version:** ${{ matrix.go }} + - **Go Version:** $GO_VERSION - **OS:** ubuntu-latest - **Timestamp:** $(date -u) @@ -90,7 +93,7 @@ jobs: # Generate test summary { cat << EOF - ## ๐Ÿงช Test Results (Go ${{ matrix.go }}) + ## ๐Ÿงช Test Results (Go $GO_VERSION) | Metric | Value | | ----------- | ------------------------------------------------------------- | @@ -152,7 +155,7 @@ jobs: { cat << EOF - ## ๐Ÿ“Š Code Coverage (Go ${{ matrix.go }}) + ## ๐Ÿ“Š Code Coverage (Go $GO_VERSION) **Total Coverage: $COVERAGE** @@ -213,7 +216,7 @@ jobs: if: failure() uses: actions/upload-artifact@v6 with: - name: test-results-go-${{ matrix.go }} + name: test-results-go-${{ env.GO_VERSION }} path: | test-output.log coverage/ @@ -223,7 +226,7 @@ jobs: run: | { cat << EOF - ## ๐Ÿ” Static Analysis (Go ${{ matrix.go }}) + ## ๐Ÿ” Static Analysis (Go $GO_VERSION) EOF @@ -267,9 +270,10 @@ jobs: - name: Job Summary if: always() run: | - cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' - ## ๐Ÿ“‹ Job Summary (Go ${{ matrix.go }}) - + { + echo "## ๐Ÿ“‹ Job Summary (Go $GO_VERSION)" + echo "" + cat << 'EOF' | Step | Status | | --------------- | --------------------------------------------------------------- | | Dependencies | Success | @@ -279,12 +283,13 @@ jobs: | Static Analysis | ${{ job.status == 'success' && 'Clean' || 'Issues' }} | | Code Formatting | ${{ job.status == 'success' && 'Clean' || 'Issues' }} | EOF + } >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: files: ./coverage/coverage.out - flags: Go ${{ matrix.go }} + flags: Go ${{ env.GO_VERSION }} slug: kjanat/articulate-parser token: ${{ secrets.CODECOV_TOKEN }} @@ -292,7 +297,7 @@ jobs: if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: - flags: Go ${{ matrix.go }} + flags: Go ${{ env.GO_VERSION }} token: ${{ secrets.CODECOV_TOKEN }} docker-test: @@ -348,6 +353,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + pull-requests: write if: github.event_name == 'pull_request' steps: - name: "Checkout Repository" diff --git a/.golangci.yml b/.golangci.yml index a81c519..75daaba 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,11 +8,6 @@ run: # Timeout for total work timeout: 5m - # Skip directories (not allowed in config v2, will use issues exclude instead) - - # Go version - go: "1.24" - # Include test files tests: true diff --git a/README.md b/README.md index 198b4c8..d61614f 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,12 @@ flowchart TD The system follows **Clean Architecture** principles with clear separation of concerns: -- **๐ŸŽฏ Entry Point**: Command-line interface handles user input and coordinates operations -- **๐Ÿ—๏ธ Application Layer**: Core business logic with dependency injection -- **๐Ÿ“‹ Interface Layer**: Contracts defining behavior without implementation details -- **๐Ÿ”ง Service Layer**: Concrete implementations of parsing and utility services -- **๐Ÿ“ค Export Layer**: Factory pattern for format-specific exporters -- **๐Ÿ“Š Data Layer**: Domain models representing course structure +- **Entry Point**: Command-line interface handles user input and coordinates operations +- **Application Layer**: Core business logic with dependency injection +- **Interface Layer**: Contracts defining behavior without implementation details +- **Service Layer**: Concrete implementations of parsing and utility services +- **Export Layer**: Factory pattern for format-specific exporters +- **Data Layer**: Domain models representing course structure ## Features @@ -206,7 +206,7 @@ Then run: The application is available as a Docker image from GitHub Container Registry. -### ๐Ÿณ Docker Image Information +### Docker Image Information - **Registry**: `ghcr.io/kjanat/articulate-parser` - **Platforms**: linux/amd64, linux/arm64 diff --git a/go.mod b/go.mod index fc0c5a6..1e9cd08 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/kjanat/articulate-parser -go 1.25.0 - -toolchain go1.25.5 +go 1.26.0 require ( github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b @@ -12,5 +10,5 @@ require ( require ( github.com/fumiama/imgsz v0.0.4 // indirect - golang.org/x/image v0.34.0 // indirect + golang.org/x/image v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 4060a42..96a67a4 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b h1:/mxSugRc4SgN7Xg github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b/go.mod h1:ssRF0IaB1hCcKIObp3FkZOsjTcAHpgii70JelNb4H8M= github.com/fumiama/imgsz v0.0.4 h1:Lsasu2hdSSFS+vnD+nvR1UkiRMK7hcpyYCC0FzgSMFI= github.com/fumiama/imgsz v0.0.4/go.mod h1:bISOQVTlw9sRytPwe8ir7tAaEmyz9hSNj9n8mXMBG0E= -golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= -golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= +golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= +golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= diff --git a/internal/exporters/factory.go b/internal/exporters/factory.go index bda72b0..9291449 100644 --- a/internal/exporters/factory.go +++ b/internal/exporters/factory.go @@ -13,6 +13,11 @@ const ( FormatMarkdown = "markdown" FormatDocx = "docx" FormatHTML = "html" + + // Format aliases accepted by CreateExporter. + formatAliasMarkdown = "md" + formatAliasDocx = "word" + formatAliasHTML = "htm" ) // 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"). func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) { switch strings.ToLower(format) { - case FormatMarkdown, "md": + case FormatMarkdown, formatAliasMarkdown: return NewMarkdownExporter(f.htmlCleaner), nil - case FormatDocx, "word": + case FormatDocx, formatAliasDocx: return NewDocxExporter(f.htmlCleaner), nil - case FormatHTML, "htm": + case FormatHTML, formatAliasHTML: return NewHTMLExporter(f.htmlCleaner), nil default: 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, // including both primary format names and their aliases. func (f *Factory) SupportedFormats() []string { - return []string{FormatMarkdown, "md", FormatDocx, "word", FormatHTML, "htm"} + return []string{ + FormatMarkdown, formatAliasMarkdown, + FormatDocx, formatAliasDocx, + FormatHTML, formatAliasHTML, + } } diff --git a/internal/exporters/html_template_data.go b/internal/exporters/html_template_data.go index 2ed5383..d57b5f8 100644 --- a/internal/exporters/html_template_data.go +++ b/internal/exporters/html_template_data.go @@ -21,6 +21,9 @@ const ( 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. type templateData struct { Course models.CourseInfo @@ -74,7 +77,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane Description: lesson.Description, } - if lesson.Type != "section" { + if lesson.Type != lessonTypeSection { lessonCounter++ section.Number = lessonCounter section.Items = prepareItems(lesson.Items, htmlCleaner) diff --git a/internal/exporters/markdown.go b/internal/exporters/markdown.go index d66a849..a39a4ab 100644 --- a/internal/exporters/markdown.go +++ b/internal/exporters/markdown.go @@ -40,35 +40,35 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro var buf bytes.Buffer // 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 != "" { - 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 buf.WriteString("## Course Information\n\n") - buf.WriteString(fmt.Sprintf("- **Course ID**: %s\n", course.Course.ID)) - buf.WriteString(fmt.Sprintf("- **Share ID**: %s\n", course.ShareID)) - buf.WriteString(fmt.Sprintf("- **Navigation Mode**: %s\n", course.Course.NavigationMode)) + fmt.Fprintf(&buf, "- **Course ID**: %s\n", course.Course.ID) + fmt.Fprintf(&buf, "- **Share ID**: %s\n", course.ShareID) + fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", course.Course.NavigationMode) 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") // Process lessons lessonCounter := 0 for _, lesson := range course.Course.Lessons { - if lesson.Type == "section" { - buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title)) + if lesson.Type == lessonTypeSection { + fmt.Fprintf(&buf, "# %s\n\n", lesson.Title) continue } 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 != "" { - 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 @@ -100,19 +100,19 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models. itemType := strings.ToLower(item.Type) switch itemType { - case "text": + case itemTypeText: e.processTextItem(buf, item, headingPrefix) - case "list": + case itemTypeList: e.processListItem(buf, item) - case "multimedia": + case itemTypeMultimedia: e.processMultimediaItem(buf, item, headingPrefix) - case "image": + case itemTypeImage: e.processImageItem(buf, item, headingPrefix) - case "knowledgecheck": + case itemTypeKnowledgeCheck: e.processKnowledgeCheckItem(buf, item, headingPrefix) - case "interactive": + case itemTypeInteractive: e.processInteractiveItem(buf, item, headingPrefix) - case "divider": + case itemTypeDivider: e.processDividerItem(buf) default: e.processUnknownItem(buf, item, headingPrefix) diff --git a/internal/exporters/output.docx b/internal/exporters/output.docx index 3bc4c8769773046d55fbc36db8c43f443586e60a..db1d20cfc76239cd4d25138f5922bf0da606cde2 100644 GIT binary patch delta 55 zcmZo?YiFAvwb_W#f^j301mol_Od6sLKv14vl%k)KpIn-onpdJ%k()C)gGq}ifN`RX J+~ki;VgRa{58wa* delta 53 zcmZo?YiHXWz$nGIktu+2@=7KRMuy4RjPjE+n6yMC7#SGK^NUjSQ}UBbb5rw5^eS?5 JCVylS0|0x+4}Aat diff --git a/internal/services/parser.go b/internal/services/parser.go index 076848a..a6a058c 100644 --- a/internal/services/parser.go +++ b/internal/services/parser.go @@ -15,6 +15,12 @@ import ( "github.com/kjanat/articulate-parser/internal/models" ) +// Default endpoint configuration for the Articulate Rise API. +const ( + riseHost = "rise.articulate.com" + defaultBaseURL = "https://" + riseHost +) + // shareIDRegex is compiled once at package init for extracting share IDs from URIs. var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`) @@ -37,7 +43,7 @@ func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time. logger = NewNoOpLogger() } if baseURL == "" { - baseURL = "https://rise.articulate.com" + baseURL = defaultBaseURL } if timeout == 0 { timeout = 30 * time.Second @@ -132,7 +138,7 @@ func (p *ArticulateParser) extractShareID(uri string) (string, error) { } // 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) }