Author SHA1 Message Date
kjanat a1a49a75b7 chore: Enhance developer tooling and documentation
Adds `actionlint` to the pre-commit configuration to validate GitHub Actions workflows.

Significantly expands the `AGENTS.md` file with a comprehensive summary of new features and changes in Go 1.24 and 1.25, along with actionable recommendations for the project.

Additionally, normalizes markdown list formatting across various documentation files for consistency.
2025-11-07 07:50:09 +01:00
kjanat 8d606706e2 chore(tooling): Improve CI pipeline and expand pre-commit hooks
Expands the pre-commit configuration with a wider range of hooks to enforce file quality, validation, security, and Git safety checks.

The CI pipeline is updated to:
- Correct the `golangci-lint` format command to `fmt`.
- Enable CGO for test execution to support the race detector.
- Improve the robustness of test report parsing scripts.

Additionally, this commit includes minor stylistic and formatting cleanups across various project files.
2025-11-07 07:30:11 +01:00
kjanat e7de5d044a chore(ci): remove Go versions below 1.24 from CI matrix
Remove CI test runs for Go 1.21.x, 1.22.x, and 1.23.x as the minimum
supported version is 1.24.0 (as defined in go.mod).

This change:
- Removes outdated Go versions from the test matrix
- Aligns CI testing with the minimum supported version
- Reduces CI execution time by removing unnecessary test runs
- Maintains testing coverage for supported versions (1.24.x, 1.25.x)

fix(ci): remove impossible dependencies from docker job

The docker job runs on push events to master/develop branches, but it
was depending on docker-test and dependency-review jobs which only run
on pull_request events. This created an impossible dependency chain
that prevented the docker job from ever running.

This change:
- Removes docker-test and dependency-review from docker job dependencies
- Keeps only the test job as a dependency (which runs on both events)
- Allows docker build & push to run correctly on push events
- Maintains PR-specific checks (docker-test, dependency-review) for PRs

chore(tooling): add pre-commit configuration

Introduces a `.pre-commit-config.yaml` file to automate code quality checks before commits.

This configuration includes standard hooks for file hygiene (e.g., trailing whitespace, end-of-file fixes) and integrates `golangci-lint` to lint and format Go code on staged files. This helps enforce code style and catch issues early in the development process.
2025-11-07 07:06:10 +01:00
58 changed files with 1339 additions and 2899 deletions
-19
View File
@@ -1,19 +0,0 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"extends": "https://github.com/kjanat/kjanat/raw/8cb7b67/configs/dprint.remote.json",
"excludes": ["**/node_modules", "**/.venv", "**/bun.lock*"],
"plugins": [
"npm:@dprint/prettier@0.70.0/plugin.json@97c0dccf54ed5b7343e7a3f6418e77bd80afcd2b74beed3a632689d47e392ddc",
"npm:@dprint/exec@0.7.3/plugin.json@704701df449dd7e942a71144773778ac529d68c2e4657bfc236d393b898b9a67",
],
"markdown": { "associations": ["!README.md"] },
"prettier": { "associations": ["README.md"], "useTabs": false },
"exec": {
"cwd": "${configDir}",
"commands": [{
"command": "bunx tombi format - --stdin-filename {{file_path}}",
"exts": ["toml"],
"cacheKeyFiles": ["tombi.toml"],
}],
},
}
+11 -11
View File
@@ -45,23 +45,23 @@ Enhancement suggestions are welcome! Please use the feature request template and
2. **Clone and setup:**
```bash
git clone https://github.com/your-username/articulate-parser.git
cd articulate-parser
go mod download
```
```bash
git clone https://github.com/your-username/articulate-parser.git
cd articulate-parser
go mod download
```
3. **Run tests:**
```bash
go test -v ./...
```
```bash
go test -v ./...
```
4. **Build:**
```bash
go build main.go
```
```bash
go build main.go
```
## Coding Standards
+2 -2
View File
@@ -1,7 +1,7 @@
name: Bug Report
description: Create a report to help us improve
title: "[BUG] "
labels: ["bug", "triage"]
title: '[BUG] '
labels: ['bug', 'triage']
body:
- type: markdown
attributes:
-4
View File
@@ -5,13 +5,11 @@
## Related Issue
<!-- Link to the issue this PR addresses using the syntax: Fixes #issue_number -->
Fixes #
## Type of Change
<!-- Mark the appropriate option with an "x" -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
@@ -23,7 +21,6 @@ Fixes #
## Checklist
<!-- Mark the items you've completed with an "x" -->
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have added comments to complex logic
@@ -45,7 +42,6 @@ Fixes #
## Testing Instructions
<!-- Provide steps to test the changes, if applicable -->
1.
2.
3.
+49 -49
View File
@@ -1,86 +1,86 @@
version: 2
updates:
# Check for updates to GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: "weekly"
day: "monday"
time: "07:00"
timezone: "Europe/Amsterdam"
interval: 'weekly'
day: 'monday'
time: '07:00'
timezone: 'Europe/Amsterdam'
open-pull-requests-limit: 2
labels:
- "dependencies"
- "dependencies/github-actions"
- 'dependencies'
- 'dependencies/github-actions'
commit-message:
prefix: "ci"
include: "scope"
prefix: 'ci'
include: 'scope'
# Check for updates to Docker
- package-ecosystem: "docker"
directory: "/"
- package-ecosystem: 'docker'
directory: '/'
schedule:
interval: "weekly"
day: "monday"
time: "07:00"
timezone: "Europe/Amsterdam"
interval: 'weekly'
day: 'monday'
time: '07:00'
timezone: 'Europe/Amsterdam'
open-pull-requests-limit: 2
labels:
- "dependencies"
- "dependencies/docker"
- 'dependencies'
- 'dependencies/docker'
commit-message:
prefix: "docker"
include: "scope"
prefix: 'docker'
include: 'scope'
groups:
docker:
patterns:
- "*"
- '*'
update-types:
- "minor"
- "patch"
- 'minor'
- 'patch'
# Check for updates to Docker Compose
- package-ecosystem: "docker-compose"
directory: "/"
- package-ecosystem: 'docker-compose'
directory: '/'
schedule:
interval: "weekly"
day: "monday"
time: "07:00"
timezone: "Europe/Amsterdam"
interval: 'weekly'
day: 'monday'
time: '07:00'
timezone: 'Europe/Amsterdam'
open-pull-requests-limit: 2
labels:
- "dependencies"
- "dependencies/docker-compose"
- 'dependencies'
- 'dependencies/docker-compose'
commit-message:
prefix: "docker"
include: "scope"
prefix: 'docker'
include: 'scope'
groups:
docker:
patterns:
- "*"
- '*'
update-types:
- "minor"
- "patch"
- 'minor'
- 'patch'
# Check for updates to Go modules
- package-ecosystem: "gomod"
directory: "/"
- package-ecosystem: 'gomod'
directory: '/'
schedule:
interval: "weekly"
day: "monday"
time: "07:00"
timezone: "Europe/Amsterdam"
interval: 'weekly'
day: 'monday'
time: '07:00'
timezone: 'Europe/Amsterdam'
open-pull-requests-limit: 2
labels:
- "dependencies"
- "dependencies/go"
- 'dependencies'
- 'dependencies/go'
commit-message:
prefix: "deps"
include: "scope"
prefix: 'deps'
include: 'scope'
groups:
go-modules:
patterns:
- "*"
- '*'
update-types:
- "minor"
- "patch"
- 'minor'
- 'patch'
+6 -6
View File
@@ -2,7 +2,7 @@ name: autofix.ci
on:
pull_request:
push:
branches: ["master"]
branches: [ "master" ]
permissions:
contents: read
@@ -11,18 +11,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v5
- name: Install Task
uses: go-task/setup-task@v2
uses: go-task/setup-task@v1
- uses: actions/setup-go@v7
with: { go-version-file: "go.mod" }
- uses: actions/setup-go@v6
with: { go-version-file: 'go.mod' }
- name: Setup go deps
run: |
# Install golangci-lint
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin"
# Install go-task dependencies
go install golang.org/x/tools/cmd/goimports@latest
+63 -68
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: ["master", "develop"]
branches: ['master', 'develop']
pull_request:
env:
@@ -21,12 +21,12 @@ jobs:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@v8
with: { version: latest }
test:
@@ -35,26 +35,23 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
matrix:
go:
- 1.24.x
- 1.25.x
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- name: Set up Go
id: setup-go
uses: actions/setup-go@v7
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v6
with:
go-version-file: go.mod
go-version: ${{ matrix.go }}
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@v2
uses: go-task/setup-task@v1
- name: Show build info
run: task info
@@ -73,7 +70,7 @@ jobs:
{
cat << EOF
## 🔧 Test Environment
- **Go Version:** $GO_VERSION
- **Go Version:** ${{ matrix.go }}
- **OS:** ubuntu-latest
- **Timestamp:** $(date -u)
@@ -86,14 +83,14 @@ jobs:
# Extract test results for summary
TEST_STATUS=$?
TOTAL_TESTS=$(grep -c "=== RUN" test-output.log || echo "0")
PASSED_TESTS=$(grep -c "--- PASS:" test-output.log || echo "0")
FAILED_TESTS=$(grep -c "--- FAIL:" test-output.log || echo "0")
SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0")
PASSED_TESTS=$(grep -c -- "--- PASS:" test-output.log || echo "0")
FAILED_TESTS=$(grep -c -- "--- FAIL:" test-output.log || echo "0")
SKIPPED_TESTS=$(grep -c -- "--- SKIP:" test-output.log || echo "0")
# Generate test summary
{
cat << EOF
## 🧪 Test Results (Go $GO_VERSION)
## 🧪 Test Results (Go ${{ matrix.go }})
| Metric | Value |
| ----------- | ------------------------------------------------------------- |
@@ -109,31 +106,31 @@ jobs:
|---------|--------|
EOF
# Extract package results
grep "^ok\|^FAIL" test-output.log | while read -r line; do
if [[ $line == ok* ]]; then
pkg=$(echo "$line" | awk '{print $2}')
echo "| $pkg | ✅ PASS |"
elif [[ $line == FAIL* ]]; then
pkg=$(echo "$line" | awk '{print $2}')
echo "| $pkg | ❌ FAIL |"
fi
done
# Extract package results
grep "^ok\|^FAIL" test-output.log | while read -r line; do
if [[ $line == ok* ]]; then
pkg=$(echo "$line" | awk '{print $2}')
echo "| $pkg | ✅ PASS |"
elif [[ $line == FAIL* ]]; then
pkg=$(echo "$line" | awk '{print $2}')
echo "| $pkg | ❌ FAIL |"
fi
done
echo ""
echo ""
# Add detailed results if tests failed
if [ "$TEST_STATUS" -ne 0 ]; then
cat << 'EOF'
# Add detailed results if tests failed
if [ "$TEST_STATUS" -ne 0 ]; then
cat << 'EOF'
### ❌ Failed Tests Details
```
EOF
grep -A 10 -- "--- FAIL:" test-output.log | head -100
cat << 'EOF'
grep -A 10 -- "--- FAIL:" test-output.log | head -100
cat << 'EOF'
```
EOF
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
# Set outputs for other steps
@@ -155,7 +152,7 @@ jobs:
{
cat << EOF
## 📊 Code Coverage (Go $GO_VERSION)
## 📊 Code Coverage (Go ${{ matrix.go }})
**Total Coverage: $COVERAGE**
@@ -214,9 +211,9 @@ jobs:
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
with:
name: test-results-go-${{ env.GO_VERSION }}
name: test-results-go-${{ matrix.go }}
path: |
test-output.log
coverage/
@@ -224,9 +221,10 @@ jobs:
- name: Run linters
run: |
# Initialize summary
{
cat << EOF
## 🔍 Static Analysis (Go $GO_VERSION)
## 🔍 Static Analysis (Go ${{ matrix.go }})
EOF
@@ -270,10 +268,9 @@ jobs:
- name: Job Summary
if: always()
run: |
{
echo "## 📋 Job Summary (Go $GO_VERSION)"
echo ""
cat << 'EOF'
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
## 📋 Job Summary (Go ${{ matrix.go }})
| Step | Status |
| --------------- | --------------------------------------------------------------- |
| Dependencies | Success |
@@ -283,13 +280,12 @@ 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@v7
uses: codecov/codecov-action@v5
with:
files: ./coverage/coverage.out
flags: Go ${{ env.GO_VERSION }}
flags: Go ${{ matrix.go }}
slug: kjanat/articulate-parser
token: ${{ secrets.CODECOV_TOKEN }}
@@ -297,7 +293,7 @@ jobs:
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: Go ${{ env.GO_VERSION }}
flags: Go ${{ matrix.go }}
token: ${{ secrets.CODECOV_TOKEN }}
docker-test:
@@ -308,19 +304,19 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- name: Install Task
uses: go-task/setup-task@v2
uses: go-task/setup-task@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Build Docker image using Task
run: task docker:build
@@ -353,14 +349,13 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
if: github.event_name == 'pull_request'
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
- name: 'Checkout Repository'
uses: actions/checkout@v5
- name: "Dependency Review"
uses: actions/dependency-review-action@v5
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
comment-summary-in-pr: always
@@ -371,37 +366,37 @@ jobs:
permissions:
contents: read
packages: write
needs: [test, docker-test]
needs: [test]
if: |
github.event_name == 'push' && (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/develop' ||
startsWith(github.ref, 'refs/heads/feature/docker'))
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -422,7 +417,7 @@ jobs:
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
# Multi-architecture build - Docker automatically provides TARGETOS, TARGETARCH, etc.
+50 -50
View File
@@ -17,7 +17,7 @@ name: "CodeQL"
on:
workflow_call:
schedule:
- cron: "44 16 * * 6"
- cron: '44 16 * * 6'
# push:
# branches: [ "master" ]
# pull_request:
@@ -47,58 +47,58 @@ jobs:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
- language: actions
build-mode: none
- language: go
build-mode: autobuild
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Checkout repository
uses: actions/checkout@v5
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -16,11 +16,11 @@ jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
- name: 'Checkout Repository'
uses: actions/checkout@v5
- name: "Dependency Review"
uses: actions/dependency-review-action@v5
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
comment-summary-in-pr: always
+11 -11
View File
@@ -20,12 +20,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
check-latest: true
@@ -62,7 +62,7 @@ jobs:
done
- name: Create Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: |
build/articulate-parser-linux-amd64
@@ -82,36 +82,36 @@ jobs:
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: ["release"]
needs: ['release']
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -131,7 +131,7 @@ jobs:
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: |
-2
View File
@@ -34,8 +34,6 @@ output/
outputs/
articulate-sample.json
test-output.*
# Example test exports (defensive; tests now write to a temp dir)
internal/exporters/output.*
go-os-arch-matrix.csv
test_godocx.go
test_input.json
+53 -59
View File
@@ -1,7 +1,6 @@
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
# golangci-lint configuration for articulate-parser
# https://golangci-lint.run/usage/configuration/
---
version: "2"
# Options for analysis running
@@ -9,6 +8,11 @@ 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
@@ -52,7 +56,6 @@ formatters:
- gofmt
- goimports
- gofumpt
exclusions: { paths: [".zed"] }
settings:
# gofmt settings
gofmt:
@@ -76,68 +79,68 @@ linters:
# Enable specific linters
enable:
# Default/standard linters
- errcheck # Check for unchecked errors
- govet # Go vet
- ineffassign # Detect ineffectual assignments
- staticcheck # Staticcheck
- unused # Find unused code
- errcheck # Check for unchecked errors
- govet # Go vet
- ineffassign # Detect ineffectual assignments
- staticcheck # Staticcheck
- unused # Find unused code
# Code quality
- revive # Fast, configurable linter
- gocritic # Opinionated Go source code linter
- godot # Check comment periods
- godox # Detect TODO/FIXME comments
- gocognit # Cognitive complexity
- gocyclo # Cyclomatic complexity
- funlen # Function length
- maintidx # Maintainability index
- revive # Fast, configurable linter
- gocritic # Opinionated Go source code linter
- godot # Check comment periods
- godox # Detect TODO/FIXME comments
- gocognit # Cognitive complexity
- gocyclo # Cyclomatic complexity
- funlen # Function length
- maintidx # Maintainability index
# Security
- gosec # Security problems
- gosec # Security problems
# Performance
- prealloc # Find slice preallocation opportunities
- bodyclose # Check HTTP response body closed
- prealloc # Find slice preallocation opportunities
- bodyclose # Check HTTP response body closed
# Style and formatting
- goconst # Find repeated strings
- misspell # Find misspellings
- whitespace # Find unnecessary blank lines
- unconvert # Remove unnecessary type conversions
- dupword # Check for duplicate words
- goconst # Find repeated strings
- misspell # Find misspellings
- whitespace # Find unnecessary blank lines
- unconvert # Remove unnecessary type conversions
- dupword # Check for duplicate words
# Error handling
- errorlint # Error handling improvements
- wrapcheck # Check error wrapping
- errorlint # Error handling improvements
- wrapcheck # Check error wrapping
# Testing
- testifylint # Testify usage
- tparallel # Detect improper t.Parallel() usage
- thelper # Detect test helpers without t.Helper()
- testifylint # Testify usage
- tparallel # Detect improper t.Parallel() usage
- thelper # Detect test helpers without t.Helper()
# Best practices
- exhaustive # Check exhaustiveness of enum switches
- nolintlint # Check nolint directives
- nakedret # Find naked returns
- nilnil # Check for redundant nil checks
- noctx # Check sending HTTP requests without context
- contextcheck # Check context propagation
- asciicheck # Check for non-ASCII identifiers
- bidichk # Check for dangerous unicode sequences
- exhaustive # Check exhaustiveness of enum switches
- nolintlint # Check nolint directives
- nakedret # Find naked returns
- nilnil # Check for redundant nil checks
- noctx # Check sending HTTP requests without context
- contextcheck # Check context propagation
- asciicheck # Check for non-ASCII identifiers
- bidichk # Check for dangerous unicode sequences
- durationcheck # Check for multiplied durations
- makezero # Find slice declarations with non-zero length
- nilerr # Find code returning nil with non-nil error
- predeclared # Find code shadowing predeclared identifiers
- promlinter # Check Prometheus metrics naming
- reassign # Check reassignment of package variables
- makezero # Find slice declarations with non-zero length
- nilerr # Find code returning nil with non-nil error
- predeclared # Find code shadowing predeclared identifiers
- promlinter # Check Prometheus metrics naming
- reassign # Check reassignment of package variables
- usestdlibvars # Use variables from stdlib
- wastedassign # Find wasted assignments
- wastedassign # Find wasted assignments
# Documentation
- godoclint # Check godoc comments
- godoclint # Check godoc comments
# New
- modernize # Suggest simplifications using new Go features
- modernize # Suggest simplifications using new Go features
# Exclusion rules for linters
exclusions:
@@ -218,8 +221,8 @@ linters:
govet:
enable-all: true
disable:
- fieldalignment # Too many false positives
- shadow # Can be noisy
- fieldalignment # Too many false positives
- shadow # Can be noisy
# goconst settings
goconst:
@@ -283,8 +286,8 @@ linters:
severity: medium
confidence: medium
excludes:
- G104 # Handled by errcheck
- G304 # File path provided as taint input
- G104 # Handled by errcheck
- G304 # File path provided as taint input
# revive settings
revive:
@@ -346,16 +349,7 @@ linters:
# stylecheck settings
staticcheck:
checks:
[
"all",
"-ST1000",
"-ST1003",
"-ST1016",
"-ST1020",
"-ST1021",
"-ST1022",
]
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"]
# maintidx settings
maintidx:
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"golangci-lint": {
"command": ["golangci-lint-langserver"],
"extensions": [".go"],
"initialization": {
"command": [
"golangci-lint",
"run",
"--output.json.path",
"stdout",
"--show-stats=false",
"--issues-exit-code=1",
],
},
},
},
"formatter": {
"dprint": {
"command": ["dprint", "fmt", "$FILE"],
"extensions": [".go", ".md", ".Dockerfile", ".Dockerfile.dev", ".md"],
},
},
}
+11 -11
View File
@@ -9,7 +9,7 @@ repos:
exclude: '^\.github/ISSUE_TEMPLATE/.*\.yml$'
- id: end-of-file-fixer
- id: mixed-line-ending
args: ["--fix=lf"]
args: ['--fix=lf']
# File validation
- id: check-yaml
@@ -23,7 +23,7 @@ repos:
- id: check-merge-conflict
- id: check-case-conflict
- id: no-commit-to-branch
args: ["--branch=master", "--branch=main"]
args: ['--branch=master', '--branch=main']
# File structure
- id: check-added-large-files
@@ -31,16 +31,16 @@ repos:
- id: check-executables-have-shebangs
- repo: local
hooks:
- id: actionlint
name: Lint GitHub Actions workflow files
description: Runs actionlint to lint GitHub Actions workflow files
language: golang
types: ["yaml"]
files: ^\.github/workflows/
entry: actionlint
minimum_pre_commit_version: 3.0.0
- id: actionlint
name: Lint GitHub Actions workflow files
description: Runs actionlint to lint GitHub Actions workflow files
language: golang
types: ["yaml"]
files: ^\.github/workflows/
entry: actionlint
minimum_pre_commit_version: 3.0.0
- repo: https://github.com/golangci/golangci-lint
rev: v2.7.2
rev: v2.6.1
hooks:
- id: golangci-lint
name: golangci-lint
-40
View File
@@ -1,40 +0,0 @@
{
"tab_size": 2,
"hard_tabs": true,
"prettier": { "allowed": false },
"formatter": [
{ "language_server": { "name": "dprint" } }
],
"languages": {
"Go": {
"language_servers": ["gopls", "golangci-lint-langserver", "..."]
}
},
"lsp": {
"gopls": {
"enable_lsp_tasks": true,
"initialization_options": {
"workspaceFiles": ["go.mod", "go.work", "*.go"]
}
},
"golangci-lint": {
"enable_lsp_tasks": false
},
"golangci-lint-langserver": {
"binary": {
"path": "golangci-lint-langserver",
"arguments": ["-debug"]
},
"initialization_options": {
"command": [
"golangci-lint",
"run",
"--output.json.path",
"stdout",
"--show-stats=false",
"--issues-exit-code=1"
]
}
}
}
}
+373 -129
View File
@@ -1,87 +1,36 @@
# Agent Guidelines for articulate-parser
A Go CLI tool that parses Articulate Rise courses from URLs or local JSON files and exports them to Markdown, HTML, or DOCX formats.
## Repository Info
- **GitHub**: https://github.com/kjanat/articulate-parser
- **Default branch**: `master` (not `main`)
## Build/Test Commands
### Primary Commands (using Taskfile)
```bash
task build # Build binary to bin/articulate-parser
task test # Run all tests with race detection
task lint # Run all linters (vet, fmt, staticcheck, golangci-lint)
task fmt # Format all Go files
task ci # Full CI pipeline: deps, lint, test with coverage, build
task qa # Quick QA: fmt + lint + test
```
### Direct Go Commands
```bash
# Build
go build -o bin/articulate-parser main.go
# Run all tests
go test -race -timeout 5m ./...
# Run single test by name
go test -v -race -run ^TestMarkdownExporter_Export$ ./internal/exporters
# Run tests in specific package
go test -v -race ./internal/services
# Run tests matching pattern
go test -v -race -run "TestParser" ./...
# Test with coverage
go test -race -coverprofile=coverage/coverage.out -covermode=atomic ./...
go tool cover -html=coverage/coverage.out -o coverage/coverage.html
# Benchmarks
go test -bench=. -benchmem ./...
go test -bench=BenchmarkMarkdownExporter ./internal/exporters
```
### Security & Auditing
```bash
task security:check # Run gosec security scanner
task security:audit # Run govulncheck for vulnerabilities
```
- **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, blank line, external packages, blank line, internal packages
```go
import (
"context"
"fmt"
"github.com/fumiama/go-docx"
"github.com/kjanat/articulate-parser/internal/interfaces"
)
```
- 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
- Cyclomatic complexity: max 15
- Cognitive complexity: max 20
### Types & Naming
- Use interface-based design (see `internal/interfaces/`)
- Exported types/functions require godoc comments ending with period
- Export types/functions with clear godoc comments ending with period
- Use descriptive names: `ArticulateParser`, `MarkdownExporter`
- Receiver names: short (1-2 chars), consistent per type
@@ -92,20 +41,6 @@ import (
- Check all error returns (enforced by `errcheck`)
- Document error handling rationale in defer blocks when ignoring close errors
```go
// Good: Error wrapping with context
if err := json.Unmarshal(body, &course); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
// Good: Documented defer with error handling
defer func() {
if err := resp.Body.Close(); err != nil {
p.Logger.Warn("failed to close response body", "error", err)
}
}()
```
### Comments
- All exported types/functions require godoc comments
@@ -114,70 +49,379 @@ defer func() {
### Security
- Use `#nosec` with justification for deliberate security exceptions
- G304: File paths from CLI args; G306: Export file permissions
```go
// #nosec G304 - File path provided by user via CLI argument
data, err := os.ReadFile(filePath)
```
- 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 always
- Enable race detection: `-race` flag
- Use table-driven tests where applicable
- Mark test helpers with `t.Helper()`
- Use `t.TempDir()` for temporary files
- Benchmarks in `*_bench_test.go`, examples in `*_example_test.go`
- Test naming: `Test<Type>_<Method>` or `Test<Function>`
```go
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
tests := []struct {
name, itemType, expectedText string
}{
{"text item", "text", ""},
{"divider item", "divider", "---"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// test implementation
})
}
}
```
### Dependencies
- Minimal external dependencies (go-docx, golang.org/x/net, golang.org/x/text)
- Minimal external dependencies (currently: go-docx, golang.org/x/net, golang.org/x/text)
- Run `task deps:tidy` after adding/removing dependencies
- CGO disabled by default (`CGO_ENABLED=0`)
## Project Structure
---
```
articulate-parser/
internal/
config/ # Configuration loading
exporters/ # Export implementations (markdown, html, docx)
interfaces/ # Core interfaces (Exporter, CourseParser, Logger)
models/ # Data models (Course, Lesson, Item, Media)
services/ # Core services (parser, html cleaner, app, logger)
version/ # Version information
main.go # Application entry point
```
## Go 1.24 & 1.25 New Features Reference
## Common Patterns
This project uses Go 1.24+. Below is a comprehensive summary of new features and changes in Go 1.24 and 1.25 that may be relevant for development and maintenance.
### Creating a new exporter
### Go 1.24 Major Changes (Released February 2025)
1. Implement `interfaces.Exporter` interface
2. Add factory method to `internal/exporters/factory.go`
3. Register format in `NewFactory()`
4. Add tests following existing patterns
#### Language Features
### Adding configuration options
**Generic Type Aliases**
1. Add field to `Config` struct in `internal/config/config.go`
2. Load from environment variable with sensible default
3. Document in config struct comments
- Type aliases can now be parameterized with type parameters
- Example: `type List[T any] = []T`
- Can be disabled via `GOEXPERIMENT=noaliastypeparams` (removed in 1.25)
#### Tooling Enhancements
**Module Tool Dependencies**
- New `tool` directive in go.mod tracks executable dependencies
- Use `go get -tool <package>` to add tool dependencies
- Use `go install tool` and `go get tool` to manage them
- Eliminates need for blank imports in `tools.go` files
**Build Output Formatting**
- Both `go build` and `go test` support `-json` flag for structured JSON output
- New action types distinguish build output from test results
**Authentication**
- New `GOAUTH` environment variable provides flexible authentication for private modules
**Automatic Version Tracking**
- `go build` automatically sets main module version in binaries based on VCS tags
- Adds `+dirty` suffix for uncommitted changes
**Cgo Performance Improvements**
- New `#cgo noescape` annotation: Prevents escape analysis overhead for C function calls
- New `#cgo nocallback` annotation: Indicates C function won't call back to Go
**Toolchain Tracing**
- `GODEBUG=toolchaintrace=1` enables tracing of toolchain selection
#### Runtime & Performance
**Performance Improvements**
- **2-3% CPU overhead reduction** across benchmark suites
- New Swiss Tables-based map implementation (faster lookups)
- Disable via `GOEXPERIMENT=noswissmap`
- More efficient small object allocation
- Redesigned runtime-internal mutexes
- Disable via `GOEXPERIMENT=nospinbitmutex`
#### Compiler & Linker
**Method Receiver Restrictions**
- Methods on cgo-generated types now prevented (both directly and through aliases)
**Build IDs**
- Linkers generate GNU build IDs (ELF) and UUIDs (macOS) by default
- Disable via `-B none` flag
#### Standard Library Additions
**File System Safety - `os.Root`**
- New `os.Root` type enables directory-limited operations
- Prevents path escape and symlink breakouts
- Essential for sandboxed file operations
**Cryptography Expansion**
- `crypto/mlkem`: ML-KEM-768/1024 post-quantum key exchange (FIPS 203)
- `crypto/hkdf`: HMAC-based Extract-and-Expand KDF (RFC 5869)
- `crypto/pbkdf2`: Password-based key derivation (RFC 8018)
- `crypto/sha3`: SHA-3 and SHAKE functions (FIPS 202)
**FIPS 140-3 Support**
- New `GOFIPS140` environment variable enables FIPS mode
- New `fips140` GODEBUG setting for cryptographic module compliance
**Weak References - `weak` Package**
- New `weak` package provides low-level weak pointers
- Enables memory-efficient structures like weak maps and caches
- Useful for preventing memory leaks in cache implementations
**Testing Improvements**
- `testing.B.Loop()`: Cleaner syntax replacing manual `b.N` iteration
- Prevents compiler from optimizing away benchmarked code
- New `testing/synctest` package (experimental) for testing concurrent code with fake clocks
**Iterator Support**
- Multiple packages now offer iterator-returning variants:
- `bytes`: Iterator-based functions
- `strings`: Iterator-based functions
- `go/types`: Iterator support
#### Security Enhancements
**TLS Post-Quantum Cryptography**
- `X25519MLKEM768` hybrid key exchange enabled by default in TLS
- Provides quantum-resistant security
**Encrypted Client Hello (ECH)**
- TLS servers can enable ECH via `Config.EncryptedClientHelloKeys`
- Protects client identity during TLS handshake
**RSA Key Validation**
- Keys smaller than 1024 bits now rejected by default
- Use `GODEBUG=rsa1024min=0` to revert (testing only)
**Constant-Time Execution**
- New `crypto/subtle.WithDataIndependentTiming()` enables architecture-specific timing guarantees
- Helps prevent timing attacks
#### Deprecations & Removals
- `runtime.GOROOT()`: Deprecated; use system path instead
- `crypto/cipher` OFB/CFB modes: Deprecated (unauthenticated encryption)
- `x509sha1` GODEBUG: Removed; SHA-1 certificates no longer verified
- Experimental `X25519Kyber768Draft00`: Removed
#### Platform Changes
- **Linux**: Now requires kernel 3.2+ (enforced)
- **macOS**: Go 1.24 is final release supporting Big Sur
- **Windows/ARM 32-bit**: Marked broken
- **WebAssembly**:
- New `go:wasmexport` directive
- Reactor/library builds supported via `-buildmode=c-shared`
#### Bootstrap Requirements
- Go 1.24 requires Go 1.22.6+ for bootstrapping
- Go 1.26 will require Go 1.24+
---
### Go 1.25 Major Changes (Released August 2025)
#### Language Changes
- No breaking language changes
- "Core types" concept removed from specification (replaced with clearer prose)
#### Tooling Improvements
**Go Command Enhancements**
- `go build -asan`: Now defaults to leak detection at program exit
- New `go.mod ignore` directive: Specify directories for go command to ignore
- `go doc -http`: Starts documentation server and opens in browser
- `go version -m -json`: Prints JSON-encoded BuildInfo structures
- Module path resolution now supports subdirectories using `<meta>` syntax
- New `work` package pattern matches all packages in work/workspace modules
- Removed automatic toolchain line additions when updating `go` version
**Vet Analyzers**
- **"waitgroup"**: Detects misplaced `sync.WaitGroup.Add` calls
- **"hostport"**: Warns against using `fmt.Sprintf` for constructing addresses
- Recommends `net.JoinHostPort` instead
#### Runtime Enhancements
**Container-Aware GOMAXPROCS**
- Linux now respects cgroup CPU bandwidth limits
- All OSes periodically update GOMAXPROCS if CPU availability changes
- Disable via environment variables or GODEBUG settings
- Critical for containerized applications
**New Garbage Collector - "Green Tea GC"**
- Experimental `GOEXPERIMENT=greenteagc` enables new GC
- **10-40% reduction in garbage collection overhead**
- Significant for GC-sensitive applications
**Trace Flight Recorder**
- New `runtime/trace.FlightRecorder` API
- Captures execution traces in in-memory ring buffer
- Essential for debugging rare events and production issues
**Other Runtime Changes**
- Simplified unhandled panic output
- VMA names on Linux identify memory purpose (debugging aid)
- New `SetDefaultGOMAXPROCS` function resets GOMAXPROCS to defaults
#### Compiler Fixes & Improvements
**Critical Nil Pointer Bug Fix**
- Fixed Go 1.21 regression where nil pointer checks were incorrectly delayed
- ⚠️ **May cause previously passing code to now panic** (correct behavior)
- Review code for assumptions about delayed nil checks
**DWARF5 Support**
- Debug information now uses DWARF version 5
- Reduces binary size and linking time
- Better debugging experience
**Faster Slices**
- Expanded stack allocation for slice backing stores
- Improved slice performance
#### Linker
- New `-funcalign=N` option specifies function entry alignment
#### Standard Library Highlights
**New Packages**
1. **`testing/synctest`** (Promoted from Experimental)
- Concurrent code testing with virtualized time
- Control time progression in tests
- Essential for testing time-dependent concurrent code
2. **`encoding/json/v2`** (Experimental)
- **Substantially better decoding performance**
- Improved API design
- Backward compatible with v1
**Major Package Updates**
| Package | Key Changes |
|---------|------------|
| `crypto` | New `MessageSigner` interface and `SignMessage` function |
| `crypto/ecdsa` | New raw key parsing/serialization functions |
| `crypto/rsa` | **Key generation now 3x faster** |
| `crypto/sha1` | **Hashing 2x faster on amd64 with SHA-NI** |
| `crypto/tls` | New `CurveID` field; SHA-1 algorithms disallowed in TLS 1.2 |
| `net` | Windows now supports file-to-connection conversion; IPv6 multicast improvements |
| `net/http` | **New `CrossOriginProtection` middleware for CSRF defense** |
| `os` | Windows async I/O support; `Root` type expanded with 12 new methods |
| `sync` | **New `WaitGroup.Go` method for convenient goroutine creation** |
| `testing` | New `Attr`, `Output` methods; `AllocsPerRun` panics with parallel tests |
| `unique` | More eager and parallel reclamation of interned values |
#### Performance Notes
**Performance Improvements**
- ECDSA and Ed25519 signing **4x faster** in FIPS 140-3 mode
- SHA3 hashing **2x faster** on Apple M processors
- AMD64 fused multiply-add instructions in v3+ mode
- ⚠️ **Changes floating-point results** (within IEEE 754 spec)
**Performance Regressions**
- SHA-1, SHA-256, SHA-512 slower without AVX2
- Most servers post-2015 support AVX2
#### Platform Changes
- **macOS**: Requires version 12 Monterey or later
- **Windows**: 32-bit windows/arm port marked for removal in Go 1.26
- **Loong64**: Race detector now supported
- **RISC-V**:
- Plugin build mode support
- New `GORISCV64=rva23u64` environment variable value
#### Deprecations
- `go/ast` functions: `FilterPackage`, `PackageExports`, `MergePackageFiles`
- `go/parser.ParseDir` function
- Old `testing/synctest` API (when `GOEXPERIMENT=synctest` set)
---
### Actionable Recommendations for This Project
#### Immediate Opportunities
1. **Replace `sync.WaitGroup` patterns with `WaitGroup.Go()`** (Go 1.25)
```go
// Old pattern
wg.Add(1)
go func() {
defer wg.Done()
// work
}()
// New pattern (Go 1.25)
wg.Go(func() {
// work
})
```
2. **Use `testing.B.Loop()` in benchmarks** (Go 1.24)
```go
// Old pattern
for i := 0; i < b.N; i++ {
// benchmark code
}
// New pattern (Go 1.24)
for b.Loop() {
// benchmark code
}
```
3. **Consider `os.Root` for file operations** (Go 1.24)
- Prevents path traversal vulnerabilities
- Safer for user-provided file paths
4. **Enable Green Tea GC for testing** (Go 1.25)
- Test with `GOEXPERIMENT=greenteagc`
- May reduce GC overhead by 10-40%
5. **Leverage container-aware GOMAXPROCS** (Go 1.25)
- No changes needed; automatic in containers
- Improves resource utilization
6. **Review floating-point operations** (Go 1.25)
- AMD64 v3+ uses FMA instructions
- May change floating-point results (within spec)
7. **Watch nil pointer checks** (Go 1.25)
- Compiler bug fix may expose latent nil pointer bugs
- Review crash reports carefully
#### Future Considerations
1. **Evaluate `encoding/json/v2`** when stable
- Better performance for JSON operations
- Currently experimental in Go 1.25
2. **Adopt tool directives** in go.mod
- Cleaner dependency management for build tools
- Remove `tools.go` if present
3. **Enable FIPS mode if required**
- Use `GOFIPS140=1` for compliance
- Performance improvements in Go 1.25 (4x faster signing)
4. **Use `runtime/trace.FlightRecorder`** for production debugging
- Capture traces of rare events
- Minimal overhead when not triggered
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.27-alpine AS builder
FROM golang:1.25-alpine AS builder
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
RUN apk add --no-cache git ca-certificates tzdata file
+1 -1
View File
@@ -2,7 +2,7 @@
# Uses Alpine instead of scratch for debugging
# Build stage - same as production
FROM golang:1.27-alpine AS builder
FROM golang:1.25-alpine AS builder
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
RUN apk add --no-cache git ca-certificates tzdata file
+26 -28
View File
@@ -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
@@ -225,14 +225,14 @@ docker run --rm ghcr.io/kjanat/articulate-parser:latest --help
### Available Tags
| Tag | Description | Use Case |
| --------------------- | ------------------------------------------- | ---------------------- |
| `latest` | Latest stable release from master branch | Production use |
| `edge` | Latest development build from master branch | Testing new features |
| `v1.x.x` | Specific version releases | Production pinning |
| `develop` | Development branch builds | Development/testing |
| `feature/docker-ghcr` | Feature branch builds | Feature testing |
| `master` | Latest master branch build | Continuous integration |
| Tag | Description | Use Case |
|-----|-------------|----------|
| `latest` | Latest stable release from master branch | Production use |
| `edge` | Latest development build from master branch | Testing new features |
| `v1.x.x` | Specific version releases | Production pinning |
| `develop` | Development branch builds | Development/testing |
| `feature/docker-ghcr` | Feature branch builds | Feature testing |
| `master` | Latest master branch build | Continuous integration |
### Usage Examples
@@ -313,11 +313,11 @@ docker build --build-arg VERSION=local --build-arg BUILD_TIME=$(date -u +%Y-%m-%
The Docker image supports the following build-time arguments:
| Argument | Description | Default |
| ------------ | ------------------------------------- | -------------- |
| `VERSION` | Version string embedded in the binary | `dev` |
| `BUILD_TIME` | Build timestamp | Current time |
| `GIT_COMMIT` | Git commit hash | Current commit |
| Argument | Description | Default |
|----------|-------------|---------|
| `VERSION` | Version string embedded in the binary | `dev` |
| `BUILD_TIME` | Build timestamp | Current time |
| `GIT_COMMIT` | Git commit hash | Current commit |
### Docker Security
@@ -441,12 +441,12 @@ The parser includes error handling for:
Potential improvements could include:
- [ ] PDF export support
- [ ] Media file downloading
- [x] ~~HTML export with preserved styling~~
- [ ] SCORM package support
- [ ] Batch processing capabilities
- [ ] Custom template support
- [ ] PDF export support
- [ ] Media file downloading
- [x] ~~HTML export with preserved styling~~
- [ ] SCORM package support
- [ ] Batch processing capabilities
- [ ] Custom template support
## License
@@ -460,9 +460,7 @@ This is a utility tool for educational content conversion. Please ensure you hav
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
[gomod]: go.mod
[Issues]: https://github.com/kjanat/articulate-parser/issues
<!-- [Latest release]: https://github.com/kjanat/articulate-parser/releases/latest -->
[MIT License]: LICENSE
[Package documentation]: https://godoc.org/github.com/kjanat/articulate-parser
[Tags]: https://github.com/kjanat/articulate-parser/tags
+34 -42
View File
@@ -1,7 +1,7 @@
# yaml-language-server: $schema=https://taskfile.dev/schema.json
# Articulate Parser - Task Automation
# https://taskfile.dev
version: "3"
version: '3'
# Global output settings
output: prefixed
@@ -47,11 +47,11 @@ vars:
# Environment variables
env:
CGO_ENABLED: "{{.CGO_ENABLED}}"
CGO_ENABLED: '{{.CGO_ENABLED}}'
GO111MODULE: on
# Load .env files if present
dotenv: [".env", ".env.local"]
dotenv: ['.env', '.env.local']
# Task definitions
tasks:
@@ -69,12 +69,12 @@ tasks:
interactive: true
watch: true
sources:
- "**/*.go"
- '**/*.go'
- go.mod
- go.sum
cmds:
- task: build
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} --help"
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} --help'
# Build tasks
build:
@@ -82,14 +82,14 @@ tasks:
aliases: [b]
deps: [clean-bin]
sources:
- "**/*.go"
- '**/*.go'
- go.mod
- go.sum
generates:
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}}"
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}}'
cmds:
- task: mkdir
vars: { DIR: "{{.OUTPUT_DIR}}" }
vars: { DIR: '{{.OUTPUT_DIR}}' }
- go build {{.GO_FLAGS}} -ldflags="{{.LDFLAGS}}" -o {{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} {{.MAIN_FILE}}
method: checksum
@@ -99,25 +99,25 @@ tasks:
deps: [clean-bin]
cmds:
- task: mkdir
vars: { DIR: "{{.OUTPUT_DIR}}" }
vars: { DIR: '{{.OUTPUT_DIR}}' }
- for:
matrix:
GOOS: [linux, darwin, windows]
GOARCH: [amd64, arm64]
task: build:platform
vars:
TARGET_GOOS: "{{.ITEM.GOOS}}"
TARGET_GOARCH: "{{.ITEM.GOARCH}}"
TARGET_GOOS: '{{.ITEM.GOOS}}'
TARGET_GOARCH: '{{.ITEM.GOARCH}}'
- echo "Built binaries for all platforms in {{.OUTPUT_DIR}}/"
build:platform:
internal: true
vars:
TARGET_EXT: '{{if eq .TARGET_GOOS "windows"}}.exe{{end}}'
OUTPUT_FILE: "{{.OUTPUT_DIR}}/{{.APP_NAME}}-{{.TARGET_GOOS}}-{{.TARGET_GOARCH}}{{.TARGET_EXT}}"
OUTPUT_FILE: '{{.OUTPUT_DIR}}/{{.APP_NAME}}-{{.TARGET_GOOS}}-{{.TARGET_GOARCH}}{{.TARGET_EXT}}'
env:
GOOS: "{{.TARGET_GOOS}}"
GOARCH: "{{.TARGET_GOARCH}}"
GOOS: '{{.TARGET_GOOS}}'
GOARCH: '{{.TARGET_GOARCH}}'
cmds:
- echo "Building {{.OUTPUT_FILE}}..."
- go build {{.GO_FLAGS}} -ldflags="{{.LDFLAGS}}" -o "{{.OUTPUT_FILE}}" {{.MAIN_FILE}}
@@ -134,8 +134,6 @@ tasks:
test:
desc: Run all tests
aliases: [t]
env:
CGO_ENABLED: 1
cmds:
- go test {{.GO_FLAGS}} -race -timeout {{.TEST_TIMEOUT}} ./...
@@ -143,11 +141,9 @@ tasks:
desc: Run tests with coverage report
aliases: [cover, cov]
deps: [clean-coverage]
env:
CGO_ENABLED: 1
cmds:
- task: mkdir
vars: { DIR: "{{.COVERAGE_DIR}}" }
vars: { DIR: '{{.COVERAGE_DIR}}' }
- go test {{.GO_FLAGS}} -race -coverprofile={{.COVERAGE_DIR}}/coverage.out -covermode=atomic -timeout {{.TEST_TIMEOUT}} ./...
- go tool cover -html={{.COVERAGE_DIR}}/coverage.out -o {{.COVERAGE_DIR}}/coverage.html
- go tool cover -func={{.COVERAGE_DIR}}/coverage.out
@@ -156,8 +152,6 @@ tasks:
test:verbose:
desc: Run tests with verbose output
aliases: [tv]
env:
CGO_ENABLED: 1
cmds:
- go test -v -race -timeout {{.TEST_TIMEOUT}} ./...
@@ -166,7 +160,7 @@ tasks:
aliases: [tw]
watch: true
sources:
- "**/*.go"
- '**/*.go'
cmds:
- task: test
@@ -178,8 +172,6 @@ tasks:
test:integration:
desc: Run integration tests
env:
CGO_ENABLED: 1
status:
- '{{if eq OS "windows"}}if not exist "main_test.go" exit 1{{else}}test ! -f "main_test.go"{{end}}'
cmds:
@@ -324,13 +316,13 @@ tasks:
docker:run:
desc: Run Docker container
aliases: [dr]
deps: [docker:build]
deps: ['docker:build']
cmds:
- docker run --rm {{.APP_NAME}}:{{.VERSION}} --help
docker:test:
desc: Test Docker image
deps: [docker:build]
deps: ['docker:build']
cmds:
- docker run --rm {{.APP_NAME}}:{{.VERSION}} --version
- echo "Docker image tested successfully"
@@ -360,14 +352,14 @@ tasks:
internal: true
cmds:
- task: rmdir
vars: { DIR: "{{.OUTPUT_DIR}}" }
vars: { DIR: '{{.OUTPUT_DIR}}' }
clean-coverage:
desc: Remove coverage files
internal: true
cmds:
- task: rmdir
vars: { DIR: "{{.COVERAGE_DIR}}" }
vars: { DIR: '{{.COVERAGE_DIR}}' }
clean-cache:
desc: Clean Go build and test cache
@@ -418,10 +410,10 @@ tasks:
requires:
vars: [VERSION]
preconditions:
- sh: "git diff --exit-code"
msg: "Working directory is not clean"
- sh: "git diff --cached --exit-code"
msg: "Staging area is not clean"
- sh: 'git diff --exit-code'
msg: 'Working directory is not clean'
- sh: 'git diff --cached --exit-code'
msg: 'Staging area is not clean'
cmds:
- git tag -a v{{.VERSION}} -m "Release v{{.VERSION}}"
- echo "Tagged v{{.VERSION}}"
@@ -442,7 +434,7 @@ tasks:
docs:coverage:
desc: Open coverage report in browser
deps: [test:coverage]
deps: ['test:coverage']
cmds:
- '{{if eq OS "darwin"}}open {{.COVERAGE_DIR}}/coverage.html{{else if eq OS "windows"}}start {{.COVERAGE_DIR}}/coverage.html{{else}}xdg-open {{.COVERAGE_DIR}}/coverage.html 2>/dev/null || echo "Please open {{.COVERAGE_DIR}}/coverage.html in your browser"{{end}}'
@@ -506,11 +498,11 @@ tasks:
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
deps: [build]
cmds:
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json md output-demo.md"
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json md output-demo.md'
- echo "Demo Markdown created{{:}} output-demo.md"
- defer:
task: rmfile
vars: { FILE: "output-demo.md" }
vars: { FILE: 'output-demo.md' }
demo:html:
desc: Demo - Convert sample to HTML
@@ -518,11 +510,11 @@ tasks:
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
deps: [build]
cmds:
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json html output-demo.html"
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json html output-demo.html'
- echo "Demo HTML created{{:}} output-demo.html"
- defer:
task: rmfile
vars: { FILE: "output-demo.html" }
vars: { FILE: 'output-demo.html' }
demo:docx:
desc: Demo - Convert sample to DOCX
@@ -530,11 +522,11 @@ tasks:
- '{{if eq OS "windows"}}if not exist "articulate-sample.json" exit 1{{else}}test ! -f "articulate-sample.json"{{end}}'
deps: [build]
cmds:
- "{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json docx output-demo.docx"
- '{{.OUTPUT_DIR}}/{{.APP_NAME}}{{.EXE_EXT}} articulate-sample.json docx output-demo.docx'
- echo "Demo DOCX created{{:}} output-demo.docx"
- defer:
task: rmfile
vars: { FILE: "output-demo.docx" }
vars: { FILE: 'output-demo.docx' }
# Performance profiling
profile:cpu:
@@ -544,7 +536,7 @@ tasks:
- go tool pprof -http=:8080 cpu.prof
- defer:
task: rmfile
vars: { FILE: "cpu.prof" }
vars: { FILE: 'cpu.prof' }
profile:mem:
desc: Run memory profiling
@@ -553,14 +545,14 @@ tasks:
- go tool pprof -http=:8080 mem.prof
- defer:
task: rmfile
vars: { FILE: "mem.prof" }
vars: { FILE: 'mem.prof' }
# Git hooks
hooks:install:
desc: Install git hooks
cmds:
- task: mkdir
vars: { DIR: ".git/hooks" }
vars: { DIR: '.git/hooks' }
- '{{if eq OS "windows"}}echo "#!/bin/sh" > .git/hooks/pre-commit && echo "task lint:fmt" >> .git/hooks/pre-commit{{else}}cat > .git/hooks/pre-commit << ''EOF''{{printf "\n"}}#!/bin/sh{{printf "\n"}}task lint:fmt{{printf "\n"}}EOF{{printf "\n"}}chmod +x .git/hooks/pre-commit{{end}}'
- echo "Git hooks installed"
+4 -4
View File
@@ -1,14 +1,14 @@
module github.com/kjanat/articulate-parser
go 1.26.0
go 1.24.0
require (
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b
golang.org/x/net v0.60.0
golang.org/x/text v0.42.0
golang.org/x/net v0.46.0
golang.org/x/text v0.30.0
)
require (
github.com/fumiama/imgsz v0.0.4 // indirect
golang.org/x/image v0.42.0 // indirect
golang.org/x/image v0.32.0 // indirect
)
+6 -6
View File
@@ -2,9 +2,9 @@ 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.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.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
-28
View File
@@ -1,28 +0,0 @@
// Package apperrors provides sentinel errors for the articulate-parser application.
package apperrors
import "errors"
// Export errors.
var (
// ErrUnsupportedFormat is returned when an unknown export format is requested.
ErrUnsupportedFormat = errors.New("unsupported export format")
)
// URI parsing errors.
var (
// ErrInvalidURI is returned when a URI cannot be parsed.
ErrInvalidURI = errors.New("invalid URI")
// ErrInvalidDomain is returned when the URI domain is not an Articulate Rise domain.
ErrInvalidDomain = errors.New("invalid domain for Articulate Rise URI")
// ErrNoShareID is returned when a share ID cannot be extracted from the URI.
ErrNoShareID = errors.New("could not extract share ID from URI")
)
// Operation errors.
var (
// ErrOperationCanceled is returned when an operation is canceled via context.
ErrOperationCanceled = errors.New("operation canceled")
)
-50
View File
@@ -1,50 +0,0 @@
package apperrors
import (
"errors"
"fmt"
"testing"
)
func TestSentinelErrors_Unwrap(t *testing.T) {
tests := []struct {
name string
sentinel error
wrapped error
}{
{"ErrUnsupportedFormat", ErrUnsupportedFormat, fmt.Errorf("%w: xyz", ErrUnsupportedFormat)},
{"ErrInvalidURI", ErrInvalidURI, fmt.Errorf("%w: bad", ErrInvalidURI)},
{"ErrInvalidDomain", ErrInvalidDomain, fmt.Errorf("%w: evil.com", ErrInvalidDomain)},
{"ErrNoShareID", ErrNoShareID, fmt.Errorf("%w: no match", ErrNoShareID)},
{"ErrOperationCanceled", ErrOperationCanceled, fmt.Errorf("%w: ctx done", ErrOperationCanceled)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !errors.Is(tt.wrapped, tt.sentinel) {
t.Errorf("errors.Is(%v, %v) = false, want true", tt.wrapped, tt.sentinel)
}
})
}
}
func TestSentinelErrors_Message(t *testing.T) {
tests := []struct {
err error
expected string
}{
{ErrUnsupportedFormat, "unsupported export format"},
{ErrInvalidURI, "invalid URI"},
{ErrInvalidDomain, "invalid domain for Articulate Rise URI"},
{ErrNoShareID, "could not extract share ID from URI"},
{ErrOperationCanceled, "operation canceled"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
if tt.err.Error() != tt.expected {
t.Errorf("got %q, want %q", tt.err.Error(), tt.expected)
}
})
}
}
-20
View File
@@ -1,20 +0,0 @@
package exporters
import "github.com/kjanat/articulate-parser/internal/interfaces"
// baseExporter contains common fields shared by all exporter implementations.
// Embed this struct in concrete exporters to inherit HTMLCleaner and Logger.
type baseExporter struct {
// htmlCleaner is used to convert HTML content to plain text.
htmlCleaner interfaces.HTMLCleaner
// logger is used for logging warnings and errors.
logger interfaces.Logger
}
// newBaseExporter creates a new baseExporter with the given dependencies.
func newBaseExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) baseExporter {
return baseExporter{
htmlCleaner: htmlCleaner,
logger: logger,
}
}
+107 -11
View File
@@ -6,13 +6,12 @@ import (
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
"github.com/kjanat/articulate-parser/internal/testdata"
)
// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation.
func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
b.ResetTimer()
for b.Loop() {
@@ -23,7 +22,7 @@ func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types.
func BenchmarkFactory_CreateExporter_All(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
formats := []string{"markdown", "docx", "html"}
b.ResetTimer()
@@ -37,15 +36,15 @@ func BenchmarkFactory_CreateExporter_All(b *testing.B) {
// BenchmarkAllExporters_Export benchmarks all exporters with the same course.
func BenchmarkAllExporters_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
course := testdata.BasicCourse()
course := createBenchmarkCourse()
exporters := map[string]struct {
exporter any
ext string
}{
"Markdown": {NewMarkdownExporter(htmlCleaner, nil), ".md"},
"Docx": {NewDocxExporter(htmlCleaner, nil), ".docx"},
"HTML": {NewHTMLExporter(htmlCleaner, nil), ".html"},
"Markdown": {NewMarkdownExporter(htmlCleaner), ".md"},
"Docx": {NewDocxExporter(htmlCleaner), ".docx"},
"HTML": {NewHTMLExporter(htmlCleaner), ".html"},
}
for name, exp := range exporters {
@@ -67,10 +66,10 @@ func BenchmarkAllExporters_Export(b *testing.B) {
// BenchmarkExporters_LargeCourse benchmarks exporters with large course data.
func BenchmarkExporters_LargeCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
course := testdata.LargeCourse(50)
course := createLargeBenchmarkCourse()
b.Run("Markdown_Large", func(b *testing.B) {
exporter := NewMarkdownExporter(htmlCleaner, nil)
exporter := NewMarkdownExporter(htmlCleaner)
tempDir := b.TempDir()
b.ResetTimer()
@@ -81,7 +80,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
})
b.Run("Docx_Large", func(b *testing.B) {
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
tempDir := b.TempDir()
b.ResetTimer()
@@ -92,7 +91,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
})
b.Run("HTML_Large", func(b *testing.B) {
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
tempDir := b.TempDir()
b.ResetTimer()
@@ -102,3 +101,100 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
}
})
}
// 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 := range 50 {
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,
},
}
}
+10 -16
View File
@@ -13,34 +13,28 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
)
// Font sizes for DOCX document headings (in half-points, so "32" = 16pt).
const (
docxTitleSize = "32" // Course title (16pt)
docxLessonSize = "28" // Lesson heading (14pt)
docxItemSize = "24" // Item heading (12pt)
"github.com/kjanat/articulate-parser/internal/services"
)
// DocxExporter implements the Exporter interface for DOCX format.
// It converts Articulate Rise course data into a Microsoft Word document
// using the go-docx package.
type DocxExporter struct {
baseExporter
// htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner
}
// NewDocxExporter creates a new DocxExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging.
// It takes an HTMLCleaner to handle HTML content conversion.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
//
// Returns:
// - An implementation of the Exporter interface for DOCX format
func NewDocxExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &DocxExporter{
baseExporter: newBaseExporter(htmlCleaner, logger),
htmlCleaner: htmlCleaner,
}
}
@@ -59,7 +53,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
// Add title
titlePara := doc.AddParagraph()
titlePara.AddText(course.Course.Title).Size(docxTitleSize).Bold()
titlePara.AddText(course.Course.Title).Size("32").Bold()
// Add description if available
if course.Course.Description != "" {
@@ -90,7 +84,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
// affect the validity of the exported file.
defer func() {
if err := file.Close(); err != nil {
e.logger.Warn("failed to close output file", "error", err)
fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", err)
}
}()
@@ -112,7 +106,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
func (e *DocxExporter) exportLesson(doc *docx.Docx, lesson *models.Lesson) {
// Add lesson title
lessonPara := doc.AddParagraph()
lessonPara.AddText(fmt.Sprintf("Lesson: %s", lesson.Title)).Size(docxLessonSize).Bold()
lessonPara.AddText(fmt.Sprintf("Lesson: %s", lesson.Title)).Size("28").Bold()
// Add lesson description if available
if lesson.Description != "" {
@@ -138,7 +132,7 @@ func (e *DocxExporter) exportItem(doc *docx.Docx, item *models.Item) {
if item.Type != "" {
itemPara := doc.AddParagraph()
caser := cases.Title(language.English)
itemPara.AddText(caser.String(item.Type)).Size(docxItemSize).Bold()
itemPara.AddText(caser.String(item.Type)).Size("24").Bold()
}
// Add sub-items
+15 -15
View File
@@ -12,7 +12,7 @@ import (
// TestNewDocxExporter tests the NewDocxExporter constructor.
func TestNewDocxExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewDocxExporter() returned nil")
@@ -32,7 +32,7 @@ func TestNewDocxExporter(t *testing.T) {
// TestDocxExporter_SupportedFormat tests the SupportedFormat method.
func TestDocxExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
expected := "docx"
result := exporter.SupportedFormat()
@@ -45,7 +45,7 @@ func TestDocxExporter_SupportedFormat(t *testing.T) {
// TestDocxExporter_Export tests the Export method.
func TestDocxExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForDocx()
@@ -79,7 +79,7 @@ func TestDocxExporter_Export(t *testing.T) {
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
@@ -102,7 +102,7 @@ func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
@@ -118,7 +118,7 @@ func TestDocxExporter_Export_InvalidPath(t *testing.T) {
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
func TestDocxExporter_ExportLesson(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create course with specific lesson content
course := &models.Course{
@@ -165,7 +165,7 @@ func TestDocxExporter_ExportLesson(t *testing.T) {
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
func TestDocxExporter_ExportItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create course with different item types
course := &models.Course{
@@ -231,7 +231,7 @@ func TestDocxExporter_ExportItem(t *testing.T) {
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
func TestDocxExporter_ExportSubItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create course with sub-item containing all possible fields
course := &models.Course{
@@ -284,7 +284,7 @@ func TestDocxExporter_ExportSubItem(t *testing.T) {
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
func TestDocxExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
@@ -421,7 +421,7 @@ func TestDocxExporter_ComplexCourse(t *testing.T) {
// TestDocxExporter_EmptyCourse tests export of an empty course.
func TestDocxExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create minimal course
course := &models.Course{
@@ -450,7 +450,7 @@ func TestDocxExporter_EmptyCourse(t *testing.T) {
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
func TestDocxExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create course with HTML content that needs cleaning
course := &models.Course{
@@ -498,7 +498,7 @@ func TestDocxExporter_HTMLCleaning(t *testing.T) {
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
@@ -526,7 +526,7 @@ func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
@@ -599,7 +599,7 @@ func createTestCourseForDocx() *models.Course {
// BenchmarkDocxExporter_Export benchmarks the Export method.
func BenchmarkDocxExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
course := createTestCourseForDocx()
// Create temporary directory
@@ -618,7 +618,7 @@ func BenchmarkDocxExporter_Export(b *testing.B) {
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner, nil)
exporter := NewDocxExporter(htmlCleaner)
// Create complex course for benchmarking
course := &models.Course{
+13 -57
View File
@@ -4,33 +4,16 @@ package exporters_test
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/kjanat/articulate-parser/internal/exporters"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// exportToTemp runs export against a path in a fresh temp dir and removes the
// dir afterwards. Example functions can't take *testing.T, so t.TempDir is
// unavailable here.
func exportToTemp(name string, export func(path string) error) {
dir, err := os.MkdirTemp("", "exporters-example-*")
if err != nil {
log.Fatal(err)
}
err = export(filepath.Join(dir, name))
_ = os.RemoveAll(dir)
if err != nil {
log.Fatal(err)
}
}
// ExampleNewFactory demonstrates creating an exporter factory.
func ExampleNewFactory() {
htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner, nil)
factory := exporters.NewFactory(htmlCleaner)
// Get supported formats
formats := factory.SupportedFormats()
@@ -41,7 +24,7 @@ func ExampleNewFactory() {
// ExampleFactory_CreateExporter demonstrates creating exporters.
func ExampleFactory_CreateExporter() {
htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner, nil)
factory := exporters.NewFactory(htmlCleaner)
// Create a markdown exporter
exporter, err := factory.CreateExporter("markdown")
@@ -56,7 +39,7 @@ func ExampleFactory_CreateExporter() {
// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names.
func ExampleFactory_CreateExporter_caseInsensitive() {
htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner, nil)
factory := exporters.NewFactory(htmlCleaner)
// All these work (case-insensitive)
formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"}
@@ -75,7 +58,7 @@ func ExampleFactory_CreateExporter_caseInsensitive() {
// ExampleMarkdownExporter_Export demonstrates exporting to Markdown.
func ExampleMarkdownExporter_Export() {
htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewMarkdownExporter(htmlCleaner, nil)
exporter := exporters.NewMarkdownExporter(htmlCleaner)
course := &models.Course{
ShareID: "example-id",
@@ -86,9 +69,10 @@ func ExampleMarkdownExporter_Export() {
}
// Export to markdown file
exportToTemp("output.md", func(path string) error {
return exporter.Export(course, path)
})
err := exporter.Export(course, "output.md")
if err != nil {
log.Fatal(err)
}
fmt.Println("Export complete")
// Output: Export complete
@@ -97,7 +81,7 @@ func ExampleMarkdownExporter_Export() {
// ExampleDocxExporter_Export demonstrates exporting to DOCX.
func ExampleDocxExporter_Export() {
htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewDocxExporter(htmlCleaner, nil)
exporter := exporters.NewDocxExporter(htmlCleaner)
course := &models.Course{
ShareID: "example-id",
@@ -107,39 +91,11 @@ func ExampleDocxExporter_Export() {
}
// Export to Word document
exportToTemp("output.docx", func(path string) error {
return exporter.Export(course, path)
})
err := exporter.Export(course, "output.docx")
if err != nil {
log.Fatal(err)
}
fmt.Println("DOCX export complete")
// Output: DOCX export complete
}
// ExampleHTMLExporter_Export demonstrates exporting to HTML.
func ExampleHTMLExporter_Export() {
htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewHTMLExporter(htmlCleaner, nil)
course := &models.Course{
ShareID: "example-id",
Course: models.CourseInfo{
Title: "Example Course",
Description: "<p>A sample course</p>",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Introduction",
Type: "lesson",
},
},
},
}
// Export to HTML file
exportToTemp("output.html", func(path string) error {
return exporter.Export(course, path)
})
fmt.Println("HTML export complete")
// Output: HTML export complete
}
+12 -25
View File
@@ -4,8 +4,8 @@ import (
"fmt"
"strings"
"github.com/kjanat/articulate-parser/internal/apperrors"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/services"
)
// Format constants for supported export formats.
@@ -13,36 +13,27 @@ const (
FormatMarkdown = "markdown"
FormatDocx = "docx"
FormatHTML = "html"
// Format aliases accepted by CreateExporter.
formatAliasMarkdown = "md"
formatAliasDocx = "word"
formatAliasHTML = "htm"
)
// Factory implements the ExporterFactory interface.
// It creates appropriate exporter instances based on the requested format.
type Factory struct {
// htmlCleaner is used by exporters to convert HTML content to plain text
htmlCleaner interfaces.HTMLCleaner
// logger is used by exporters for logging
logger interfaces.Logger
htmlCleaner *services.HTMLCleaner
}
// NewFactory creates a new exporter factory.
// It takes an HTMLCleaner instance and a logger that will be passed to the exporters
// It takes an HTMLCleaner instance that will be passed to the exporters
// created by this factory.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for exporter logging
//
// Returns:
// - An implementation of the ExporterFactory interface
func NewFactory(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory {
func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
return &Factory{
htmlCleaner: htmlCleaner,
logger: logger,
}
}
@@ -50,23 +41,19 @@ func NewFactory(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) in
// Format strings are case-insensitive (e.g., "markdown", "DOCX").
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
switch strings.ToLower(format) {
case FormatMarkdown, formatAliasMarkdown:
return NewMarkdownExporter(f.htmlCleaner, f.logger), nil
case FormatDocx, formatAliasDocx:
return NewDocxExporter(f.htmlCleaner, f.logger), nil
case FormatHTML, formatAliasHTML:
return NewHTMLExporter(f.htmlCleaner, f.logger), nil
case FormatMarkdown, "md":
return NewMarkdownExporter(f.htmlCleaner), nil
case FormatDocx, "word":
return NewDocxExporter(f.htmlCleaner), nil
case FormatHTML, "htm":
return NewHTMLExporter(f.htmlCleaner), nil
default:
return nil, fmt.Errorf("%w: %s", apperrors.ErrUnsupportedFormat, format)
return nil, fmt.Errorf("unsupported export format: %s", format)
}
}
// 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, formatAliasMarkdown,
FormatDocx, formatAliasDocx,
FormatHTML, formatAliasHTML,
}
return []string{FormatMarkdown, "md", FormatDocx, "word", FormatHTML, "htm"}
}
+14 -14
View File
@@ -12,7 +12,7 @@ import (
// TestNewFactory tests the NewFactory constructor.
func TestNewFactory(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
if factory == nil {
t.Fatal("NewFactory() returned nil")
@@ -32,7 +32,7 @@ func TestNewFactory(t *testing.T) {
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
func TestFactory_CreateExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
testCases := []struct {
name string
@@ -135,7 +135,7 @@ func TestFactory_CreateExporter(t *testing.T) {
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
testCases := []struct {
format string
@@ -182,7 +182,7 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
testCases := []string{
"pdf",
@@ -222,7 +222,7 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
// TestFactory_SupportedFormats tests the SupportedFormats method.
func TestFactory_SupportedFormats(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
formats := factory.SupportedFormats()
@@ -255,7 +255,7 @@ func TestFactory_SupportedFormats(t *testing.T) {
// TestFactory_SupportedFormats_Immutable tests that the returned slice is safe to modify.
func TestFactory_SupportedFormats_Immutable(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
// Get formats twice
formats1 := factory.SupportedFormats()
@@ -281,7 +281,7 @@ func TestFactory_SupportedFormats_Immutable(t *testing.T) {
// TestFactory_ExporterTypes tests that created exporters are of correct types.
func TestFactory_ExporterTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
// Test markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
@@ -307,7 +307,7 @@ func TestFactory_ExporterTypes(t *testing.T) {
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
// Test with markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
@@ -358,7 +358,7 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
func TestFactory_MultipleExporterCreation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
// Create multiple markdown exporters
exporter1, err := factory.CreateExporter("markdown")
@@ -395,7 +395,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
}
}()
factory := NewFactory(nil, nil)
factory := NewFactory(nil)
if factory == nil {
t.Fatal("NewFactory(nil) returned nil")
@@ -412,7 +412,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
// TestFactory_FormatNormalization tests that format strings are properly normalized.
func TestFactory_FormatNormalization(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
// Test formats with extra whitespace
testCases := []struct {
@@ -445,7 +445,7 @@ func TestFactory_FormatNormalization(t *testing.T) {
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
func BenchmarkFactory_CreateExporter(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
for b.Loop() {
_, _ = factory.CreateExporter("markdown")
@@ -455,7 +455,7 @@ func BenchmarkFactory_CreateExporter(b *testing.B) {
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
for b.Loop() {
_, _ = factory.CreateExporter("docx")
@@ -465,7 +465,7 @@ func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
// BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method.
func BenchmarkFactory_SupportedFormats(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner, nil)
factory := NewFactory(htmlCleaner)
for b.Loop() {
_ = factory.SupportedFormats()
+9 -18
View File
@@ -9,6 +9,7 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
//go:embed html_styles.css
@@ -20,22 +21,21 @@ var htmlTemplate string
// HTMLExporter implements the Exporter interface for HTML format.
// It converts Articulate Rise course data into a structured HTML document using templates.
type HTMLExporter struct {
baseExporter
// tmpl holds the parsed HTML template.
// htmlCleaner is used to convert HTML content to plain text when needed
htmlCleaner *services.HTMLCleaner
// tmpl holds the parsed HTML template
tmpl *template.Template
}
// NewHTMLExporter creates a new HTMLExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed
// and a logger for logging.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
//
// Returns:
// - An implementation of the Exporter interface for HTML format
func NewHTMLExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
// Parse the template with custom functions
funcMap := template.FuncMap{
"safeHTML": func(s string) template.HTML {
@@ -49,8 +49,8 @@ func NewHTMLExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logge
tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate))
return &HTMLExporter{
baseExporter: newBaseExporter(htmlCleaner, logger),
tmpl: tmpl,
htmlCleaner: htmlCleaner,
tmpl: tmpl,
}
}
@@ -69,16 +69,7 @@ func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer func() {
// Close errors are logged but not fatal since the content has already been written.
// The file must be closed to flush buffers, but a close error doesn't invalidate
// the data already written to disk.
if closeErr := f.Close(); closeErr != nil {
// Note: In production, this should log via a logger passed to the exporter.
// For now, we silently ignore close errors as they're non-fatal.
_ = closeErr
}
}()
defer f.Close()
return e.WriteHTML(f, course)
}
+107 -109
View File
@@ -1,175 +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;
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);
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;
margin: 0;
font-size: 2.5rem;
font-weight: 300;
}
.course-description {
margin-top: 1rem;
font-size: 1.1rem;
opacity: 0.9;
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);
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;
margin-top: 0;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.course-info ul {
list-style: none;
padding: 0;
list-style: none;
padding: 0;
}
.course-info li {
margin: 0.5rem 0;
padding: 0.5rem;
background: #f7fafc;
border-radius: 4px;
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);
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;
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;
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;
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;
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;
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;
margin-top: 0;
color: #4a5568;
font-size: 1.2rem;
text-transform: capitalize;
}
.text-item {
background: #f0fff4;
border-left: 3px solid #48bb78;
background: #f0fff4;
border-left: 3px solid #48bb78;
}
.list-item {
background: #fffaf0;
border-left: 3px solid #ed8936;
background: #fffaf0;
border-left: 3px solid #ed8936;
}
.knowledge-check {
background: #e6fffa;
border-left: 3px solid #38b2ac;
background: #e6fffa;
border-left: 3px solid #38b2ac;
}
.multimedia-item {
background: #faf5ff;
border-left: 3px solid #9f7aea;
background: #faf5ff;
border-left: 3px solid #9f7aea;
}
.interactive-item {
background: #fff5f5;
border-left: 3px solid #f56565;
background: #fff5f5;
border-left: 3px solid #f56565;
}
.unknown-item {
background: #f7fafc;
border-left: 3px solid #a0aec0;
background: #f7fafc;
border-left: 3px solid #a0aec0;
}
.answers {
margin: 1rem 0;
margin: 1rem 0;
}
.answers h5 {
margin: 0.5rem 0;
color: #4a5568;
margin: 0.5rem 0;
color: #4a5568;
}
.answers ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.answers li {
margin: 0.3rem 0;
padding: 0.3rem;
margin: 0.3rem 0;
padding: 0.3rem;
}
.correct-answer {
background: #c6f6d5;
border-radius: 3px;
font-weight: bold;
background: #c6f6d5;
border-radius: 3px;
font-weight: bold;
}
.correct-answer::after {
content: " ✓";
color: #38a169;
content: " ✓";
color: #38a169;
}
.feedback {
margin: 1rem 0;
padding: 1rem;
background: #edf2f7;
border-radius: 4px;
border-left: 3px solid #4299e1;
font-style: italic;
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;
background: #edf2f7;
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
}
.media-info strong {
color: #4a5568;
color: #4a5568;
}
hr {
border: none;
height: 2px;
background: linear-gradient(to right, #667eea, #764ba2);
margin: 2rem 0;
border-radius: 1px;
border: none;
height: 2px;
background: linear-gradient(to right, #667eea, #764ba2);
margin: 2rem 0;
border-radius: 1px;
}
ul {
padding-left: 1.5rem;
padding-left: 1.5rem;
}
li {
margin: 0.5rem 0;
margin: 0.5rem 0;
}
+19 -8
View File
@@ -6,8 +6,19 @@ import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// 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.
@@ -47,7 +58,7 @@ type templateSubItem struct {
}
// prepareTemplateData converts a Course model into template-friendly data.
func prepareTemplateData(course *models.Course, htmlCleaner interfaces.HTMLCleaner) *templateData {
func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleaner) *templateData {
data := &templateData{
Course: course.Course,
ShareID: course.ShareID,
@@ -63,7 +74,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner interfaces.HTMLClean
Description: lesson.Description,
}
if lesson.Type != models.LessonTypeSection {
if lesson.Type != "section" {
lessonCounter++
section.Number = lessonCounter
section.Items = prepareItems(lesson.Items, htmlCleaner)
@@ -76,7 +87,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner interfaces.HTMLClean
}
// prepareItems converts model Items to template Items.
func prepareItems(items []models.Item, htmlCleaner interfaces.HTMLCleaner) []templateItem {
func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []templateItem {
result := make([]templateItem, 0, len(items))
for _, item := range items {
@@ -86,9 +97,9 @@ func prepareItems(items []models.Item, htmlCleaner interfaces.HTMLCleaner) []tem
}
// Set type title for unknown items
if tItem.Type != models.ItemTypeText && tItem.Type != models.ItemTypeList && tItem.Type != models.ItemTypeKnowledgeCheck &&
tItem.Type != models.ItemTypeMultimedia && tItem.Type != models.ItemTypeImage && tItem.Type != models.ItemTypeInteractive &&
tItem.Type != models.ItemTypeDivider {
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)
}
@@ -106,7 +117,7 @@ func prepareItems(items []models.Item, htmlCleaner interfaces.HTMLCleaner) []tem
}
// Clean HTML for list items
if tItem.Type == models.ItemTypeList && subItem.Paragraph != "" {
if tItem.Type == itemTypeList && subItem.Paragraph != "" {
tSubItem.CleanText = htmlCleaner.CleanHTML(subItem.Paragraph)
}
+9 -230
View File
@@ -13,7 +13,7 @@ import (
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
func TestNewHTMLExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewHTMLExporter() returned nil")
@@ -37,7 +37,7 @@ func TestNewHTMLExporter(t *testing.T) {
// TestHTMLExporter_SupportedFormat tests the SupportedFormat method.
func TestHTMLExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
expected := "html"
result := exporter.SupportedFormat()
@@ -50,7 +50,7 @@ func TestHTMLExporter_SupportedFormat(t *testing.T) {
// TestHTMLExporter_Export tests the Export method.
func TestHTMLExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForHTML()
@@ -129,7 +129,7 @@ func TestHTMLExporter_Export(t *testing.T) {
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
testCourse := createTestCourseForHTML()
@@ -145,7 +145,7 @@ func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
// TestHTMLExporter_ComplexCourse tests export of a course with complex content.
func TestHTMLExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
@@ -276,7 +276,7 @@ func TestHTMLExporter_ComplexCourse(t *testing.T) {
// TestHTMLExporter_EmptyCourse tests export of an empty course.
func TestHTMLExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
// Create minimal course
course := &models.Course{
@@ -324,7 +324,7 @@ func TestHTMLExporter_EmptyCourse(t *testing.T) {
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
// Create course with HTML content that needs cleaning in some places
course := &models.Course{
@@ -392,227 +392,6 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) {
}
}
// TestHTMLExporter_Export_ErrorPaths tests various error conditions for the HTML exporter.
func TestHTMLExporter_Export_ErrorPaths(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
tests := []struct {
name string
course *models.Course
outputPath string
wantErr bool
errContains string
}{
{
name: "non-existent directory",
course: createTestCourseForHTML(),
outputPath: "/nonexistent/deeply/nested/path/output.html",
wantErr: true,
errContains: "failed to create file",
},
{
name: "empty output path",
course: createTestCourseForHTML(),
outputPath: "",
wantErr: true,
errContains: "failed to create file",
},
{
name: "output path is directory",
course: createTestCourseForHTML(),
outputPath: t.TempDir(), // directory, not a file
wantErr: true,
errContains: "failed to create file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := exporter.Export(tt.course, tt.outputPath)
if tt.wantErr {
if err == nil {
t.Errorf("Export() expected error containing %q, got nil", tt.errContains)
return
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Export() error = %v, want error containing %q", err, tt.errContains)
}
} else if err != nil {
t.Errorf("Export() unexpected error = %v", err)
}
})
}
}
// TestHTMLExporter_WriteHTML_ErrorPaths tests WriteHTML error conditions.
func TestHTMLExporter_WriteHTML_ErrorPaths(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
htmlExporter := exporter.(*HTMLExporter)
tests := []struct {
name string
course *models.Course
writer *failingWriter
wantErr bool
errContains string
}{
{
name: "writer fails immediately",
course: createTestCourseForHTML(),
writer: &failingWriter{failAfter: 0},
wantErr: true,
errContains: "failed to execute template",
},
{
name: "writer fails mid-write",
course: createTestCourseForHTML(),
writer: &failingWriter{failAfter: 100},
wantErr: true,
errContains: "failed to execute template",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := htmlExporter.WriteHTML(tt.writer, tt.course)
if tt.wantErr {
if err == nil {
t.Errorf("WriteHTML() expected error containing %q, got nil", tt.errContains)
return
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("WriteHTML() error = %v, want error containing %q", err, tt.errContains)
}
} else if err != nil {
t.Errorf("WriteHTML() unexpected error = %v", err)
}
})
}
}
// TestHTMLExporter_Export_NilCourse verifies that exporting a nil course panics.
// This documents the current behavior - nil course is not a supported input.
func TestHTMLExporter_Export_NilCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "nil-course.html")
defer func() {
if r := recover(); r == nil {
t.Error("Export(nil) expected panic, got none")
}
}()
// Export nil course - should panic
_ = exporter.Export(nil, outputPath)
}
// TestHTMLExporter_WriteHTML_NilCourse verifies that WriteHTML with nil course panics.
func TestHTMLExporter_WriteHTML_NilCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
htmlExporter := exporter.(*HTMLExporter)
defer func() {
if r := recover(); r == nil {
t.Error("WriteHTML(nil) expected panic, got none")
}
}()
var buf strings.Builder
_ = htmlExporter.WriteHTML(&buf, nil)
}
// TestHTMLExporter_WriteHTML_SuccessWithBuffer tests WriteHTML with a buffer.
func TestHTMLExporter_WriteHTML_SuccessWithBuffer(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
htmlExporter := exporter.(*HTMLExporter)
tests := []struct {
name string
course *models.Course
contains []string
}{
{
name: "basic course",
course: createTestCourseForHTML(),
contains: []string{
"<!DOCTYPE html>",
"<html lang=\"en\">",
"Test Course",
},
},
{
name: "course with special characters",
course: &models.Course{
ShareID: "special-chars-id",
Course: models.CourseInfo{
ID: "special-course",
Title: "Course with <special> & \"characters\"",
Description: "<p>Description with &amp; entities</p>",
Lessons: []models.Lesson{},
},
},
contains: []string{
"<!DOCTYPE html>",
"Course with",
"special",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
err := htmlExporter.WriteHTML(&buf, tt.course)
if err != nil {
t.Fatalf("WriteHTML() unexpected error: %v", err)
}
output := buf.String()
for _, want := range tt.contains {
if !strings.Contains(output, want) {
t.Errorf("WriteHTML() output missing %q", want)
}
}
})
}
}
// failingWriter is a test helper that fails after writing a certain number of bytes.
type failingWriter struct {
written int
failAfter int
}
func (w *failingWriter) Write(p []byte) (n int, err error) {
if w.written >= w.failAfter {
return 0, errWriteFailed
}
w.written += len(p)
if w.written > w.failAfter {
return 0, errWriteFailed
}
return len(p), nil
}
var errWriteFailed = &writeError{msg: "simulated write failure"}
type writeError struct {
msg string
}
func (e *writeError) Error() string {
return e.msg
}
// createTestCourseForHTML creates a test course for HTML export tests.
func createTestCourseForHTML() *models.Course {
return &models.Course{
@@ -660,7 +439,7 @@ func createTestCourseForHTML() *models.Course {
// BenchmarkHTMLExporter_Export benchmarks the Export method.
func BenchmarkHTMLExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
course := createTestCourseForHTML()
tempDir := b.TempDir()
@@ -676,7 +455,7 @@ func BenchmarkHTMLExporter_Export(b *testing.B) {
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
exporter := NewHTMLExporter(htmlCleaner)
// Create complex course for benchmarking
course := &models.Course{
+48 -95
View File
@@ -11,26 +11,27 @@ import (
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// MarkdownExporter implements the Exporter interface for Markdown format.
// It converts Articulate Rise course data into a structured Markdown document.
type MarkdownExporter struct {
baseExporter
// htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner
}
// NewMarkdownExporter creates a new MarkdownExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging.
// It takes an HTMLCleaner to handle HTML content conversion.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
//
// Returns:
// - An implementation of the Exporter interface for Markdown format
func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &MarkdownExporter{
baseExporter: newBaseExporter(htmlCleaner, logger),
htmlCleaner: htmlCleaner,
}
}
@@ -39,39 +40,35 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
var buf bytes.Buffer
// Write course header
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title))
buf.WriteString(fmt.Sprintf("# %s\n\n", course.Course.Title))
if course.Course.Description != "" {
if desc := e.htmlCleaner.CleanAndTrim(course.Course.Description); desc != "" {
fmt.Fprintf(&buf, "%s\n\n", desc)
}
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(course.Course.Description)))
}
// Add metadata
buf.WriteString("## Course Information\n\n")
fmt.Fprintf(&buf, "- **Course ID**: %s\n", strings.TrimSpace(course.Course.ID))
fmt.Fprintf(&buf, "- **Share ID**: %s\n", strings.TrimSpace(course.ShareID))
fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", strings.TrimSpace(course.Course.NavigationMode))
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))
if course.Course.ExportSettings != nil {
fmt.Fprintf(&buf, "- **Export Format**: %s\n", strings.TrimSpace(course.Course.ExportSettings.Format))
buf.WriteString(fmt.Sprintf("- **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 == models.LessonTypeSection {
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title))
if lesson.Type == "section" {
buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title))
continue
}
lessonCounter++
fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title))
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", lessonCounter, lesson.Title))
if lesson.Description != "" {
if desc := e.htmlCleaner.CleanAndTrim(lesson.Description); desc != "" {
fmt.Fprintf(&buf, "%s\n\n", desc)
}
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description)))
}
// Process lesson items
@@ -79,49 +76,16 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
e.processItemToMarkdown(&buf, item, 3)
}
buf.WriteString("---\n\n")
buf.WriteString("\n---\n\n")
}
// Clean up multiple consecutive blank lines before writing
output := cleanMarkdownWhitespace(buf.Bytes())
// #nosec G306 - 0644 is appropriate for export files that should be readable by others
if err := os.WriteFile(outputPath, output, 0o644); err != nil {
if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("failed to write markdown file: %w", err)
}
return nil
}
// cleanMarkdownWhitespace normalizes whitespace in markdown output.
// It removes trailing whitespace from lines and collapses multiple blank lines into one.
func cleanMarkdownWhitespace(data []byte) []byte {
lines := strings.Split(string(data), "\n")
result := make([]string, 0, len(lines))
prevBlank := false
for _, line := range lines {
// Trim trailing whitespace from each line
trimmed := strings.TrimRight(line, " \t")
// Collapse multiple consecutive blank lines
isBlank := trimmed == ""
if isBlank && prevBlank {
continue
}
result = append(result, trimmed)
prevBlank = isBlank
}
// Remove trailing blank lines
for len(result) > 0 && result[len(result)-1] == "" {
result = result[:len(result)-1]
}
// Ensure file ends with newline
return []byte(strings.Join(result, "\n") + "\n")
}
// SupportedFormat returns "markdown".
func (e *MarkdownExporter) SupportedFormat() string {
return FormatMarkdown
@@ -132,23 +96,20 @@ func (e *MarkdownExporter) SupportedFormat() string {
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
headingPrefix := strings.Repeat("#", level)
// Normalize item type to lowercase for consistent matching
itemType := strings.ToLower(item.Type)
switch itemType {
case models.ItemTypeText:
switch item.Type {
case "text":
e.processTextItem(buf, item, headingPrefix)
case models.ItemTypeList:
case "list":
e.processListItem(buf, item)
case models.ItemTypeMultimedia:
case "multimedia":
e.processMultimediaItem(buf, item, headingPrefix)
case models.ItemTypeImage:
case "image":
e.processImageItem(buf, item, headingPrefix)
case models.ItemTypeKnowledgeCheck:
case "knowledgeCheck":
e.processKnowledgeCheckItem(buf, item, headingPrefix)
case models.ItemTypeInteractive:
case "interactive":
e.processInteractiveItem(buf, item, headingPrefix)
case models.ItemTypeDivider:
case "divider":
e.processDividerItem(buf)
default:
e.processUnknownItem(buf, item, headingPrefix)
@@ -159,12 +120,14 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
for _, subItem := range item.Items {
if subItem.Heading != "" {
if heading := e.htmlCleaner.CleanAndTrim(subItem.Heading); heading != "" {
heading := e.htmlCleaner.CleanHTML(subItem.Heading)
if heading != "" {
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
}
}
if subItem.Paragraph != "" {
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
if paragraph != "" {
fmt.Fprintf(buf, "%s\n\n", paragraph)
}
}
@@ -175,7 +138,8 @@ func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item,
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
if paragraph != "" {
fmt.Fprintf(buf, "- %s\n", paragraph)
}
}
@@ -199,9 +163,8 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models
e.processImageMedia(buf, subItem.Media)
}
if subItem.Caption != "" {
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption)
}
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
fmt.Fprintf(buf, "*%s*\n", caption)
}
}
@@ -230,9 +193,8 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item,
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
}
if subItem.Caption != "" {
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption)
}
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
fmt.Fprintf(buf, "*%s*\n", caption)
}
}
buf.WriteString("\n")
@@ -250,33 +212,27 @@ func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item mod
// processQuestionSubItem processes individual question items.
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
fmt.Fprintf(buf, "**Question**: %s\n\n", title)
}
title := e.htmlCleaner.CleanHTML(subItem.Title)
fmt.Fprintf(buf, "**Question**: %s\n\n", title)
}
e.processAnswers(buf, subItem.Answers)
if subItem.Feedback != "" {
if feedback := e.htmlCleaner.CleanAndTrim(subItem.Feedback); feedback != "" {
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
}
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
}
}
// processAnswers processes answer choices for quiz questions.
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
if len(answers) == 0 {
return
}
buf.WriteString("**Answers**:\n")
for i, answer := range answers {
correctMark := ""
if answer.Correct {
correctMark = " [correct]"
correctMark = " ✓"
}
title := strings.TrimSpace(answer.Title)
fmt.Fprintf(buf, "%d. %s%s\n", i+1, title, correctMark)
fmt.Fprintf(buf, "%d. %s%s\n", i+1, answer.Title, correctMark)
}
}
@@ -285,9 +241,8 @@ func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models
fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix)
for _, subItem := range item.Items {
if subItem.Title != "" {
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title)
}
title := e.htmlCleaner.CleanHTML(subItem.Title)
fmt.Fprintf(buf, "**%s**\n\n", title)
}
}
}
@@ -311,13 +266,11 @@ func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Ite
// processGenericSubItem processes sub-items for unknown types.
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title)
}
title := e.htmlCleaner.CleanHTML(subItem.Title)
fmt.Fprintf(buf, "**%s**\n\n", title)
}
if subItem.Paragraph != "" {
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
fmt.Fprintf(buf, "%s\n\n", paragraph)
}
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
fmt.Fprintf(buf, "%s\n\n", paragraph)
}
}
+22 -22
View File
@@ -14,7 +14,7 @@ import (
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
func TestNewMarkdownExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner, nil)
exporter := NewMarkdownExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewMarkdownExporter() returned nil")
@@ -34,7 +34,7 @@ func TestNewMarkdownExporter(t *testing.T) {
// TestMarkdownExporter_SupportedFormat tests the SupportedFormat method.
func TestMarkdownExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner, nil)
exporter := NewMarkdownExporter(htmlCleaner)
expected := "markdown"
result := exporter.SupportedFormat()
@@ -47,7 +47,7 @@ func TestMarkdownExporter_SupportedFormat(t *testing.T) {
// TestMarkdownExporter_Export tests the Export method.
func TestMarkdownExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner, nil)
exporter := NewMarkdownExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForMarkdown()
@@ -108,7 +108,7 @@ func TestMarkdownExporter_Export(t *testing.T) {
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner, nil)
exporter := NewMarkdownExporter(htmlCleaner)
testCourse := createTestCourseForMarkdown()
@@ -124,7 +124,7 @@ func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
@@ -153,7 +153,7 @@ func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
// TestMarkdownExporter_ProcessListItem tests the processListItem method.
func TestMarkdownExporter_ProcessListItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
@@ -178,7 +178,7 @@ func TestMarkdownExporter_ProcessListItem(t *testing.T) {
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
@@ -217,7 +217,7 @@ func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
@@ -252,7 +252,7 @@ func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
@@ -283,7 +283,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
if !strings.Contains(result, "**Answers**:") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "2. Paris [correct]") {
if !strings.Contains(result, "2. Paris ✓") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
@@ -294,7 +294,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
@@ -319,7 +319,7 @@ func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
exporter.processDividerItem(&buf)
@@ -335,7 +335,7 @@ func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
@@ -366,7 +366,7 @@ func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
media := &models.Media{
@@ -391,7 +391,7 @@ func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
media := &models.Media{
@@ -413,7 +413,7 @@ func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
answers := []models.Answer{
@@ -432,7 +432,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
if !strings.Contains(result, "1. Answer 1") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "2. Answer 2 [correct]") {
if !strings.Contains(result, "2. Answer 2 ✓") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "3. Answer 3") {
@@ -443,7 +443,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
tests := []struct {
name string
@@ -510,7 +510,7 @@ func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner, nil)
exporter := NewMarkdownExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
@@ -603,7 +603,7 @@ func TestMarkdownExporter_ComplexCourse(t *testing.T) {
"- Second objective",
"### Knowledge Check",
"**Question**: What will you learn?",
"2. Everything [correct]",
"2. Everything ✓",
"**Feedback**: Great choice!",
}
@@ -654,7 +654,7 @@ func createTestCourseForMarkdown() *models.Course {
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
func BenchmarkMarkdownExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner, nil)
exporter := NewMarkdownExporter(htmlCleaner)
course := createTestCourseForMarkdown()
// Create temporary directory
@@ -673,7 +673,7 @@ func BenchmarkMarkdownExporter_Export(b *testing.B) {
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
item := models.Item{
Type: "text",
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
# Example Course
Course description
## Course Information
- **Course ID**:
- **Share ID**: example-id
- **Navigation Mode**:
---
-13
View File
@@ -1,13 +0,0 @@
package interfaces
// HTMLCleaner defines the interface for cleaning HTML content.
// Implementations convert HTML to plain text, stripping tags and decoding entities.
type HTMLCleaner interface {
// CleanHTML removes HTML tags and decodes HTML entities from a string.
// Returns plain text suitable for non-HTML output formats.
CleanHTML(htmlStr string) string
// CleanAndTrim removes HTML tags, decodes entities, and trims whitespace.
// Convenience method combining CleanHTML with strings.TrimSpace.
CleanAndTrim(htmlStr string) string
}
-47
View File
@@ -2,8 +2,6 @@
// These structures closely match the JSON format used by Articulate Rise.
package models
import "fmt"
// Course represents the top-level structure of an Articulate Rise course.
// It contains metadata and the actual course content.
type Course struct {
@@ -47,8 +45,6 @@ type ExportSettings struct {
// LabelSet contains customized labels used throughout the course.
// This allows course creators to modify standard terminology.
//
// NOTE: Currently parsed but not used in export logic.
type LabelSet struct {
// ID is the unique identifier for this label set
ID string `json:"id"`
@@ -57,46 +53,3 @@ type LabelSet struct {
// Labels is a mapping of label keys to their customized values
Labels map[string]string `json:"labels"`
}
// Validate checks whether the Course structure contains valid data.
// It returns an error describing the first validation failure found,
// or nil if the course is valid.
//
// Validation rules:
// - ShareID must not be empty
// - Course.ID must not be empty
// - Course.Title must not be empty
// - Each Lesson must have a non-empty ID and Title
func (c *Course) Validate() error {
if c.ShareID == "" {
return &ValidationError{Field: "ShareID", Message: "share ID is required"}
}
if c.Course.ID == "" {
return &ValidationError{Field: "Course.ID", Message: "course ID is required"}
}
if c.Course.Title == "" {
return &ValidationError{Field: "Course.Title", Message: "course title is required"}
}
for i, lesson := range c.Course.Lessons {
if lesson.ID == "" {
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty ID", i)}
}
if lesson.Title == "" {
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty title", i)}
}
}
return nil
}
// ValidationError represents a validation failure for a specific field.
type ValidationError struct {
// Field is the name of the field that failed validation
Field string
// Message describes the validation failure
Message string
}
// Error implements the error interface for ValidationError.
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
-41
View File
@@ -1,41 +0,0 @@
package models
// Item type constants used throughout the application.
// These match the type field values in Articulate Rise course data.
const (
// ItemTypeText represents text content blocks with headings and paragraphs.
ItemTypeText = "text"
// ItemTypeList represents bullet or numbered list content.
ItemTypeList = "list"
// ItemTypeKnowledgeCheck represents quiz or assessment items.
ItemTypeKnowledgeCheck = "knowledgecheck"
// ItemTypeMultimedia represents video or audio content.
ItemTypeMultimedia = "multimedia"
// ItemTypeImage represents standalone image content.
ItemTypeImage = "image"
// ItemTypeInteractive represents interactive elements like accordions or tabs.
ItemTypeInteractive = "interactive"
// ItemTypeDivider represents visual separator elements.
ItemTypeDivider = "divider"
// ItemTypeQuote represents quotation blocks.
ItemTypeQuote = "quote"
// ItemTypeFlipcard represents flashcard-style content.
ItemTypeFlipcard = "flipcard"
)
// Lesson type constants.
const (
// LessonTypeSection represents a section header (not a real lesson).
LessonTypeSection = "section"
// LessonTypeLesson represents a regular lesson.
LessonTypeLesson = "lesson"
)
+3 -6
View File
@@ -15,8 +15,7 @@ type Lesson struct {
Icon string `json:"icon"`
// Items is an ordered array of content items within the lesson
Items []Item `json:"items"`
// Position stores the ordering information for the lesson.
// NOTE: Currently parsed but not used in export logic.
// Position stores the ordering information for the lesson
Position any `json:"position"`
// Ready indicates whether the lesson is marked as complete
Ready bool `json:"ready"`
@@ -39,11 +38,9 @@ type Item struct {
Variant string `json:"variant"`
// Items contains the actual content elements (sub-items) of this item
Items []SubItem `json:"items"`
// Settings contains configuration options specific to this item type.
// NOTE: Currently parsed but not used in export logic.
// Settings contains configuration options specific to this item type
Settings any `json:"settings"`
// Data contains additional structured data for the item.
// NOTE: Currently parsed but not used in export logic.
// Data contains additional structured data for the item
Data any `json:"data"`
// Media contains any associated media for the item
Media *Media `json:"media,omitempty"`
-156
View File
@@ -2,7 +2,6 @@ package models
import (
"encoding/json"
"errors"
"reflect"
"testing"
)
@@ -665,161 +664,6 @@ func BenchmarkCourse_JSONUnmarshal(b *testing.B) {
}
}
// TestCourse_Validate tests the Course.Validate method.
func TestCourse_Validate(t *testing.T) {
tests := []struct {
name string
course Course
wantErr bool
errField string
errSubstr string
}{
{
name: "valid course",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "lesson-1", Title: "First Lesson"},
},
},
},
wantErr: false,
},
{
name: "valid course with no lessons",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{},
},
},
wantErr: false,
},
{
name: "missing share ID",
course: Course{
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
},
},
wantErr: true,
errField: "ShareID",
errSubstr: "share ID is required",
},
{
name: "missing course ID",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
Title: "Test Course",
},
},
wantErr: true,
errField: "Course.ID",
errSubstr: "course ID is required",
},
{
name: "missing course title",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
},
},
wantErr: true,
errField: "Course.Title",
errSubstr: "course title is required",
},
{
name: "lesson with empty ID",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "", Title: "First Lesson"},
},
},
},
wantErr: true,
errField: "Course.Lessons",
errSubstr: "lesson at index 0 has empty ID",
},
{
name: "lesson with empty title",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "lesson-1", Title: ""},
},
},
},
wantErr: true,
errField: "Course.Lessons",
errSubstr: "lesson at index 0 has empty title",
},
{
name: "second lesson invalid",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "lesson-1", Title: "First Lesson"},
{ID: "lesson-2", Title: ""},
},
},
},
wantErr: true,
errField: "Course.Lessons",
errSubstr: "lesson at index 1 has empty title",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.course.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Course.Validate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
var valErr *ValidationError
if ok := errors.As(err, &valErr); !ok {
t.Errorf("Expected ValidationError, got %T", err)
return
}
if valErr.Field != tt.errField {
t.Errorf("ValidationError.Field = %q, want %q", valErr.Field, tt.errField)
}
if valErr.Message != tt.errSubstr {
t.Errorf("ValidationError.Message = %q, want %q", valErr.Message, tt.errSubstr)
}
}
})
}
}
// TestValidationError_Error tests the ValidationError.Error method.
func TestValidationError_Error(t *testing.T) {
err := &ValidationError{Field: "TestField", Message: "test message"}
expected := "validation error on TestField: test message"
if err.Error() != expected {
t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected)
}
}
// compareMaps compares two any values that should be maps.
func compareMaps(original, unmarshaled any) bool {
origMap, origOk := original.(map[string]any)
+9 -23
View File
@@ -6,7 +6,6 @@ import (
"context"
"fmt"
"github.com/kjanat/articulate-parser/internal/apperrors"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
)
@@ -32,46 +31,33 @@ func NewApp(parser interfaces.CourseParser, exporterFactory interfaces.ExporterF
}
// ProcessCourseFromFile loads a course from a local file and exports it to the specified format.
// It takes a context for cancellation, the path to the course file, the desired export format,
// and the output file path. Returns an error if loading or exporting fails, or if the context
// is canceled.
func (a *App) ProcessCourseFromFile(ctx context.Context, filePath, format, outputPath string) error {
// Check for cancellation before loading
if err := ctx.Err(); err != nil {
return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
}
// It takes the path to the course file, the desired export format, and the output file path.
// Returns an error if loading or exporting fails.
func (a *App) ProcessCourseFromFile(filePath, format, outputPath string) error {
course, err := a.parser.LoadCourseFromFile(filePath)
if err != nil {
return fmt.Errorf("failed to load course from file: %w", err)
}
return a.exportCourse(ctx, course, format, outputPath)
return a.exportCourse(course, format, outputPath)
}
// ProcessCourseFromURI fetches a course from the provided URI and exports it to the specified format.
// It takes a context for cancellation, the URI to fetch the course from, the desired export format,
// and the output file path. Returns an error if fetching or exporting fails, or if the context
// is canceled.
// It takes the URI to fetch the course from, the desired export format, and the output file path.
// Returns an error if fetching or exporting fails.
func (a *App) ProcessCourseFromURI(ctx context.Context, uri, format, outputPath string) error {
course, err := a.parser.FetchCourse(ctx, uri)
if err != nil {
return fmt.Errorf("failed to fetch course: %w", err)
}
return a.exportCourse(ctx, course, format, outputPath)
return a.exportCourse(course, format, outputPath)
}
// exportCourse exports a course to the specified format and output path.
// It's a helper method that creates the appropriate exporter and performs the export.
// Returns an error if creating the exporter or exporting the course fails, or if the
// context is canceled.
func (a *App) exportCourse(ctx context.Context, course *models.Course, format, outputPath string) error {
// Check for cancellation before export
if err := ctx.Err(); err != nil {
return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
}
// Returns an error if creating the exporter or exporting the course fails.
func (a *App) exportCourse(course *models.Course, format, outputPath string) error {
exporter, err := a.exporterFactory.CreateExporter(format)
if err != nil {
return fmt.Errorf("failed to create exporter: %w", err)
+1 -1
View File
@@ -207,7 +207,7 @@ func TestApp_ProcessCourseFromFile(t *testing.T) {
tt.setupMocks(parser, factory, exporter)
app := NewApp(parser, factory)
err := app.ProcessCourseFromFile(context.Background(), tt.filePath, tt.format, tt.outputPath)
err := app.ProcessCourseFromFile(tt.filePath, tt.format, tt.outputPath)
if tt.expectedError != "" {
if err == nil {
+9 -60
View File
@@ -5,10 +5,7 @@ import (
"context"
"fmt"
"log"
"log/slog"
"time"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/services"
)
@@ -17,8 +14,8 @@ func ExampleNewArticulateParser() {
// Create a no-op logger for this example
logger := services.NewNoOpLogger()
// Create parser with defaults (nil config uses defaults)
parser := services.NewArticulateParser(logger, nil)
// Create parser with defaults
parser := services.NewArticulateParser(logger, "", 0)
fmt.Printf("Parser created: %T\n", parser)
// Output: Parser created: *services.ArticulateParser
@@ -29,11 +26,11 @@ func ExampleNewArticulateParser_custom() {
logger := services.NewNoOpLogger()
// Create parser with custom base URL and timeout
cfg := &config.Config{
BaseURL: "https://custom.articulate.com",
RequestTimeout: 60 * time.Second,
}
parser := services.NewArticulateParser(logger, cfg)
parser := services.NewArticulateParser(
logger,
"https://custom.articulate.com",
60_000_000_000, // 60 seconds in nanoseconds
)
fmt.Printf("Parser configured: %T\n", parser)
// Output: Parser configured: *services.ArticulateParser
@@ -42,7 +39,7 @@ func ExampleNewArticulateParser_custom() {
// ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file.
func ExampleArticulateParser_LoadCourseFromFile() {
logger := services.NewNoOpLogger()
parser := services.NewArticulateParser(logger, nil)
parser := services.NewArticulateParser(logger, "", 0)
// In a real scenario, you'd have an actual file
// This example shows the API usage
@@ -55,7 +52,7 @@ func ExampleArticulateParser_LoadCourseFromFile() {
// ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI.
func ExampleArticulateParser_FetchCourse() {
logger := services.NewNoOpLogger()
parser := services.NewArticulateParser(logger, nil)
parser := services.NewArticulateParser(logger, "", 0)
// Create a context with timeout
ctx := context.Background()
@@ -67,54 +64,6 @@ func ExampleArticulateParser_FetchCourse() {
}
}
// ExampleNewSlogLogger demonstrates creating a JSON logger.
func ExampleNewSlogLogger() {
// Create a JSON structured logger at Info level
// Output goes to stdout in JSON format
logger := services.NewSlogLogger(slog.LevelInfo)
// Logger is ready to use - calls like logger.Info("msg") output JSON
fmt.Printf("Logger type: %T\n", logger)
// Output: Logger type: *services.SlogLogger
}
// ExampleNewTextLogger demonstrates creating a human-readable text logger.
func ExampleNewTextLogger() {
// Create a text logger, more readable for development
logger := services.NewTextLogger(slog.LevelDebug)
// Logger is ready - calls like logger.Debug("msg") output text
fmt.Printf("Logger type: %T\n", logger)
// Output: Logger type: *services.SlogLogger
}
// ExampleNewNoOpLogger demonstrates creating a no-op logger for testing.
func ExampleNewNoOpLogger() {
logger := services.NewNoOpLogger()
// All log calls are silently discarded
logger.Debug("this won't appear")
logger.Info("neither will this")
logger.Error("or this")
fmt.Println("Logging silently completed")
// Output: Logging silently completed
}
// ExampleSlogLogger_With demonstrates creating a child logger with context.
func ExampleSlogLogger_With() {
logger := services.NewNoOpLogger()
// Create a child logger with additional context
requestLogger := logger.With("request_id", "abc123", "user", "john")
// All logs from requestLogger will include request_id and user
requestLogger.Info("processing request")
fmt.Printf("Child logger type: %T\n", requestLogger)
// Output: Child logger type: *services.NoOpLogger
}
// ExampleHTMLCleaner demonstrates cleaning HTML content.
func ExampleHTMLCleaner() {
cleaner := services.NewHTMLCleaner()
-13
View File
@@ -7,19 +7,13 @@ import (
"strings"
"golang.org/x/net/html"
"github.com/kjanat/articulate-parser/internal/interfaces"
)
// HTMLCleaner provides utilities for converting HTML content to plain text.
// It removes HTML tags while preserving their content and converts HTML entities
// to their plain text equivalents using proper HTML parsing instead of regex.
// Implements interfaces.HTMLCleaner.
type HTMLCleaner struct{}
// Compile-time check that HTMLCleaner implements interfaces.HTMLCleaner.
var _ interfaces.HTMLCleaner = (*HTMLCleaner)(nil)
// NewHTMLCleaner creates a new HTML cleaner instance.
// This service is typically injected into exporters that need to handle
// HTML content from Articulate Rise courses.
@@ -52,13 +46,6 @@ func (h *HTMLCleaner) CleanHTML(htmlStr string) string {
return strings.TrimSpace(cleaned)
}
// CleanAndTrim removes HTML tags, decodes entities, and trims whitespace.
// Convenience method that combines CleanHTML with strings.TrimSpace.
// Returns empty string if input is empty or contains only whitespace/tags.
func (h *HTMLCleaner) CleanAndTrim(htmlStr string) string {
return strings.TrimSpace(h.CleanHTML(htmlStr))
}
// extractText recursively traverses the HTML node tree and extracts text content.
// It skips script and style tags to avoid including their content in the output.
func extractText(w io.Writer, n *html.Node) {
-15
View File
@@ -1,15 +0,0 @@
package services
import (
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/interfaces"
)
// NewLoggerFromConfig creates a logger based on the configuration.
// Returns a JSON logger if cfg.LogFormat is "json", otherwise returns a text logger.
func NewLoggerFromConfig(cfg *config.Config) interfaces.Logger {
if cfg.LogFormat == "json" {
return NewSlogLogger(cfg.LogLevel)
}
return NewTextLogger(cfg.LogLevel)
}
+22 -24
View File
@@ -9,19 +9,12 @@ import (
"net/url"
"os"
"regexp"
"time"
"github.com/kjanat/articulate-parser/internal/apperrors"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
)
// riseHost is the only host accepted in Articulate Rise share URIs.
const riseHost = "rise.articulate.com"
// shareIDRegex is compiled once at package init for extracting share IDs from URIs.
var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
// ArticulateParser implements the CourseParser interface specifically for Articulate Rise courses.
// It can fetch courses from the Articulate Rise API or load them from local JSON files.
type ArticulateParser struct {
@@ -33,19 +26,23 @@ type ArticulateParser struct {
Logger interfaces.Logger
}
// NewArticulateParser creates a new ArticulateParser instance using the provided configuration.
// If cfg is nil, a default configuration will be used.
func NewArticulateParser(logger interfaces.Logger, cfg *config.Config) interfaces.CourseParser {
// NewArticulateParser creates a new ArticulateParser instance.
// If baseURL is empty, uses the default Articulate Rise API URL.
// If timeout is zero, uses a 30-second timeout.
func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.Duration) interfaces.CourseParser {
if logger == nil {
logger = NewNoOpLogger()
}
if cfg == nil {
cfg = config.Load()
if baseURL == "" {
baseURL = "https://rise.articulate.com"
}
if timeout == 0 {
timeout = 30 * time.Second
}
return &ArticulateParser{
BaseURL: cfg.BaseURL,
BaseURL: baseURL,
Client: &http.Client{
Timeout: cfg.RequestTimeout,
Timeout: timeout,
},
Logger: logger,
}
@@ -81,15 +78,15 @@ func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var course models.Course
if err := json.Unmarshal(body, &course); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
@@ -128,17 +125,18 @@ func (p *ArticulateParser) extractShareID(uri string) (string, error) {
// Parse the URL to validate the domain
parsedURL, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidURI, uri)
return "", fmt.Errorf("invalid URI: %s", uri)
}
// Validate that it's an Articulate Rise domain
if parsedURL.Host != riseHost {
return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidDomain, parsedURL.Host)
if parsedURL.Host != "rise.articulate.com" {
return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host)
}
matches := shareIDRegex.FindStringSubmatch(uri)
re := regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
matches := re.FindStringSubmatch(uri)
if len(matches) < 2 {
return "", fmt.Errorf("%w: %s", apperrors.ErrNoShareID, uri)
return "", fmt.Errorf("could not extract share ID from URI: %s", uri)
}
return matches[1], nil
}
+2 -12
View File
@@ -8,9 +8,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/models"
)
@@ -135,11 +133,7 @@ func BenchmarkArticulateParser_LoadCourseFromFile(b *testing.B) {
b.Fatalf("Failed to write file: %v", err)
}
cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
parser := NewArticulateParser(nil, "", 0)
b.ResetTimer()
for b.Loop() {
@@ -186,11 +180,7 @@ func BenchmarkArticulateParser_LoadCourseFromFile_Large(b *testing.B) {
b.Fatalf("Failed to write file: %v", err)
}
cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
parser := NewArticulateParser(nil, "", 0)
b.ResetTimer()
for b.Loop() {
-207
View File
@@ -1,207 +0,0 @@
package services
import (
"encoding/json"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
)
// FuzzArticulateParser_ParseJSON fuzzes JSON parsing to ensure no panics on malformed input.
// It tests that the parser gracefully handles any byte sequence without crashing.
func FuzzArticulateParser_ParseJSON(f *testing.F) {
// Add seed corpus with valid JSON structures
f.Add([]byte(`{"shareId":"test","course":{"id":"1","title":"Test"}}`))
f.Add([]byte(`{"shareId":"abc123","author":"John Doe","course":{"id":"c1","title":"Course Title","description":"A test course"}}`))
f.Add([]byte(`{}`))
f.Add([]byte(`{"course":{}}`))
f.Add([]byte(`{"shareId":"","course":{"id":"","title":"","lessons":[]}}`))
// Add seed corpus with invalid/edge case inputs
f.Add([]byte(`invalid`))
f.Add([]byte(``))
f.Add([]byte(`null`))
f.Add([]byte(`[]`))
f.Add([]byte(`"string"`))
f.Add([]byte(`123`))
f.Add([]byte(`true`))
f.Add([]byte(`{`))
f.Add([]byte(`}`))
f.Add([]byte(`{"unclosed": `))
f.Add([]byte(`{"key": "value with \x00 null byte"}`))
// Add seed corpus with nested structures
f.Add([]byte(`{"course":{"lessons":[{"id":"l1","title":"Lesson 1","items":[{"id":"i1","type":"text","data":"content"}]}]}}`))
f.Add([]byte(`{"course":{"lessons":[{},{},{}]}}`))
// Add seed corpus with special characters
f.Add([]byte(`{"shareId":"test<script>alert('xss')</script>"}`))
f.Add([]byte(`{"course":{"title":"Test\n\r\t\u0000Title"}}`))
f.Add([]byte(`{"course":{"description":"<p>HTML content</p>"}}`))
f.Fuzz(func(t *testing.T, data []byte) {
// Test that unmarshaling doesn't panic
var course models.Course
_ = json.Unmarshal(data, &course)
// Also test that re-marshaling doesn't panic
_, _ = json.Marshal(&course)
// Test accessing fields doesn't panic
_ = course.ShareID
_ = course.Author
_ = course.Course.ID
_ = course.Course.Title
_ = course.Course.Description
// Test iterating over lessons doesn't panic
for _, lesson := range course.Course.Lessons {
_ = lesson.ID
_ = lesson.Title
for _, item := range lesson.Items {
_ = item.ID
_ = item.Type
}
}
})
}
// FuzzArticulateParser_LoadCourseFromFile_JSON tests JSON parsing behavior used by LoadCourseFromFile.
// This fuzz test ensures the JSON unmarshaling logic handles arbitrary input gracefully.
func FuzzArticulateParser_LoadCourseFromFile_JSON(f *testing.F) {
// Seed corpus mimicking file contents
f.Add([]byte(`{"shareId":"file-test","course":{"id":"1","title":"From File"}}`))
f.Add([]byte(`{
"shareId": "multiline",
"course": {
"id": "2",
"title": "Multiline JSON"
}
}`))
f.Add([]byte("\xef\xbb\xbf{\"shareId\":\"bom\"}")) // UTF-8 BOM prefix
f.Add([]byte(`{"shareId": "spaces", "course": {}}`))
f.Fuzz(func(t *testing.T, data []byte) {
var course models.Course
err := json.Unmarshal(data, &course)
// If unmarshaling succeeded, verify the result is usable
if err == nil {
// Access all fields to ensure no panics on zero values
_ = course.ShareID
_ = course.Author
_ = course.Course.ID
_ = course.Course.Title
// Iterate through nested structures
for _, lesson := range course.Course.Lessons {
_ = lesson.ID
_ = lesson.Title
_ = lesson.Type
for _, item := range lesson.Items {
_ = item.ID
_ = item.Type
_ = item.Data
}
}
}
})
}
// FuzzHTMLCleaner_CleanHTML fuzzes the HTML cleaner to ensure no panics on malformed HTML.
// It tests that arbitrary HTML-like input is handled gracefully.
func FuzzHTMLCleaner_CleanHTML(f *testing.F) {
cleaner := NewHTMLCleaner()
// Add seed corpus with valid HTML patterns
f.Add("<p>Simple paragraph</p>")
f.Add("<div><p>Nested</p></div>")
f.Add("<h1>Title</h1><p>Content</p>")
f.Add("<strong>Bold</strong> and <em>italic</em>")
f.Add("<a href=\"https://example.com\">Link</a>")
f.Add("<img src=\"image.png\" alt=\"Image\"/>")
f.Add("<br/><hr/>")
f.Add("<ul><li>Item 1</li><li>Item 2</li></ul>")
f.Add("<table><tr><td>Cell</td></tr></table>")
// Add seed corpus with HTML entities
f.Add("AT&amp;T")
f.Add("&lt;script&gt;alert('xss')&lt;/script&gt;")
f.Add("&nbsp;&nbsp;&nbsp;")
f.Add("&#39;apostrophe&#39;")
f.Add("&quot;quoted&quot;")
f.Add("&eacute;&iuml;&ouml;")
f.Add("&#x27;hex entity&#x27;")
f.Add("&#8217;numeric entity&#8217;")
// Add seed corpus with malformed/edge case HTML
f.Add("")
f.Add("plain text no HTML")
f.Add("<p>Unclosed paragraph")
f.Add("</p>Close tag first<p>")
f.Add("<div><div><div>Deep nesting")
f.Add("<script>alert('xss')</script>")
f.Add("<style>body{color:red}</style>")
f.Add("<!--comment-->")
f.Add("<!DOCTYPE html>")
f.Add("<![CDATA[data]]>")
f.Add("<p class=\"test\" id='test' data-attr=unquoted>Attributes</p>")
f.Add("<p\n\t>Whitespace in tag</p\n>")
f.Add("<>empty tag</>")
f.Add("<<<nested angles>>>")
f.Add("<p onclick=\"evil()\">Event handler</p>")
// Add seed corpus with special characters
f.Add("<p>\x00null byte</p>")
f.Add("<p>\xffhigh byte</p>")
f.Add("<p>emoji: \xf0\x9f\x98\x80</p>")
f.Add("<p>unicode: \u2603</p>")
f.Add("<p>newlines\n\r\nand\rtabs\there</p>")
// Add seed corpus with potentially problematic patterns
f.Add(string(make([]byte, 0))) // Empty
f.Add(string(make([]byte, 1))) // Single null byte
f.Add("<" + string(make([]byte, 100)) + ">") // Tag with null bytes
f.Add("<p>" + string(make([]byte, 1000)) + "</p>") // Large content
f.Fuzz(func(t *testing.T, input string) {
// Test that CleanHTML doesn't panic on any input
result := cleaner.CleanHTML(input)
// Verify the result is a valid string (doesn't panic on access)
_ = len(result)
// Test that the result can be used in common string operations without panic
_ = result == ""
_ = result == input
// Test that calling CleanHTML on its own output doesn't panic (idempotency check)
_ = cleaner.CleanHTML(result)
})
}
// FuzzHTMLCleaner_CleanHTML_Bytes tests HTML cleaner with raw byte sequences.
// This catches issues with invalid UTF-8 and binary data in HTML content.
func FuzzHTMLCleaner_CleanHTML_Bytes(f *testing.F) {
cleaner := NewHTMLCleaner()
// Add seed corpus with byte sequences
f.Add([]byte("<p>normal</p>"))
f.Add([]byte{0x3c, 0x70, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x70, 0x3e}) // <p>..invalid..</p>
f.Add([]byte{0x00}) // Null byte
f.Add([]byte{0xff, 0xfe}) // Invalid UTF-8
f.Add([]byte{0xef, 0xbb, 0xbf, 0x3c, 0x70, 0x3e, 0x3c, 0x2f, 0x70, 0x3e}) // UTF-8 BOM + <p></p>
f.Add([]byte("<p>\xc0\xaf</p>")) // Overlong UTF-8
f.Fuzz(func(t *testing.T, data []byte) {
// Convert bytes to string and test
input := string(data)
// Test that CleanHTML doesn't panic
result := cleaner.CleanHTML(input)
// Verify result is usable
_ = len(result)
_ = []byte(result)
})
}
+3 -13
View File
@@ -11,14 +11,12 @@ import (
"testing"
"time"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/models"
)
// TestNewArticulateParser tests the NewArticulateParser constructor.
func TestNewArticulateParser(t *testing.T) {
// Test with nil config (should use defaults)
parser := NewArticulateParser(nil, nil)
parser := NewArticulateParser(nil, "", 0)
if parser == nil {
t.Fatal("NewArticulateParser() returned nil")
@@ -214,11 +212,7 @@ func TestArticulateParser_LoadCourseFromFile(t *testing.T) {
t.Fatalf("Failed to write test file: %v", err)
}
cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
parser := NewArticulateParser(nil, "", 0)
tests := []struct {
name string
@@ -277,11 +271,7 @@ func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
t.Fatalf("Failed to write test file: %v", err)
}
cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
parser := NewArticulateParser(nil, "", 0)
_, err := parser.LoadCourseFromFile(tempFile)
if err == nil {
-467
View File
@@ -1,467 +0,0 @@
// Package testdata provides shared test fixtures for Articulate Rise course testing.
// It contains builders for creating Course, Lesson, and Item structures with various
// configurations to support unit tests, integration tests, and benchmarks.
package testdata
import (
"fmt"
"github.com/kjanat/articulate-parser/internal/models"
)
// SampleAnswers provides a reusable set of quiz answers for testing.
var SampleAnswers = []models.Answer{
{ID: "ans-1", Title: "Option A", Correct: false},
{ID: "ans-2", Title: "Option B", Correct: true},
{ID: "ans-3", Title: "Option C", Correct: false},
{ID: "ans-4", Title: "Option D", Correct: false},
}
// SampleSubItems provides a reusable set of sub-items for testing.
var SampleSubItems = []models.SubItem{
{
ID: "sub-1",
Heading: "Sample Heading",
Paragraph: "<p>Sample paragraph with <strong>formatting</strong>.</p>",
},
{
ID: "sub-2",
Paragraph: "<p>Another paragraph with <em>emphasis</em>.</p>",
},
}
// MinimalCourse returns a course with just ShareID and Title set.
// Useful for testing edge cases with minimal data.
func MinimalCourse() *models.Course {
return &models.Course{
ShareID: "minimal-share-id",
Course: models.CourseInfo{
Title: "Minimal Course",
},
}
}
// BasicCourse returns a course with 1 section, 1 lesson, and 1 text item.
// Suitable for basic functionality tests.
func BasicCourse() *models.Course {
return &models.Course{
ShareID: "basic-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "basic-course-id",
Title: "Basic Test Course",
Description: "<p>A basic course for testing.</p>",
NavigationMode: "menu",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Introduction Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "First Lesson",
Type: "lesson",
Description: "<p>Lesson introduction text.</p>",
Items: []models.Item{
TextItem("Welcome", "<p>Welcome to the course!</p>"),
},
},
},
},
}
}
// CompleteCourse returns a course with all item types represented.
// Useful for comprehensive export and parsing tests.
func CompleteCourse() *models.Course {
return &models.Course{
ShareID: "complete-share-id",
Author: "Complete Test Author",
Course: models.CourseInfo{
ID: "complete-course-id",
Title: "Complete Test Course",
Description: "<p>A comprehensive course with <strong>all</strong> item types.</p>",
Color: "#3366CC",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Title: "Exported Complete Course",
Format: "scorm",
},
CoverImage: &models.Media{
Image: &models.ImageMedia{
Key: "cover-img-key",
Type: "jpg",
Width: 1920,
Height: 1080,
OriginalURL: "https://example.com/cover.jpg",
},
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Overview",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction",
Type: "lesson",
Description: "<p>Introduction to the course content.</p>",
Icon: "book",
Items: []models.Item{
TextItem("Welcome", "<p>Welcome to this comprehensive course!</p>"),
TextItem("Objectives", "<p>In this course you will learn:</p>"),
ListItem("First objective", "Second objective", "Third objective"),
DividerItem(),
MediaItem("video", "https://example.com/intro.mp4"),
MediaItem("image", "https://example.com/diagram.png"),
},
},
{
ID: "lesson-2",
Title: "Core Concepts",
Type: "lesson",
Description: "<p>Core concepts and fundamentals.</p>",
Items: []models.Item{
TextItem("Key Concepts", "<p>Let's explore the key concepts.</p>"),
KnowledgeCheckItem("What is the primary purpose of this course?", []models.Answer{
{ID: "kc-1-a", Title: "Learning basics", Correct: false},
{ID: "kc-1-b", Title: "Comprehensive understanding", Correct: true},
{ID: "kc-1-c", Title: "Quick overview", Correct: false},
}),
InteractiveItem("Interactive Exercise"),
},
},
{
ID: "lesson-3",
Title: "Advanced Topics",
Type: "lesson",
Description: "<p>Deep dive into advanced topics.</p>",
Items: []models.Item{
TextItem("Advanced Section", "<p>This section covers advanced material.</p>"),
FlipCardItem("Front content", "Back content"),
QuoteItem("Learning is not attained by chance.", "Abigail Adams"),
},
},
{
ID: "section-2",
Title: "Summary",
Type: "section",
},
{
ID: "lesson-4",
Title: "Conclusion",
Type: "lesson",
Description: "<p>Course wrap-up and next steps.</p>",
Items: []models.Item{
TextItem("Summary", "<p>Thank you for completing this course!</p>"),
ListItem("Review materials regularly", "Practice learned skills", "Share knowledge"),
},
},
},
},
LabelSet: models.LabelSet{
ID: "default-labels",
Name: "Default",
Labels: map[string]string{
"next": "Next",
"previous": "Previous",
"complete": "Mark Complete",
},
},
}
}
// LargeCourse returns a parameterized course for benchmarking with the specified number of lessons.
// Each lesson contains multiple item types for realistic performance testing.
func LargeCourse(lessons int) *models.Course {
courseLessons := make([]models.Lesson, 0, lessons+lessons/5) // +sections
for i := range lessons {
// Add section header every 5 lessons
if i%5 == 0 {
courseLessons = append(courseLessons, models.Lesson{
ID: fmt.Sprintf("section-%d", i/5+1),
Title: fmt.Sprintf("Section %d", i/5+1),
Type: "section",
})
}
lesson := models.Lesson{
ID: fmt.Sprintf("lesson-%d", i+1),
Title: fmt.Sprintf("Lesson %d", i+1),
Type: "lesson",
Description: fmt.Sprintf("<p>Description for lesson %d with <em>formatting</em>.</p>", i+1),
Items: []models.Item{
TextItem(
fmt.Sprintf("Heading %d", i+1),
fmt.Sprintf("<p>Content for lesson %d with <strong>bold</strong> and <em>italic</em> text.</p>", i+1),
),
ListItem("Point 1", "Point 2", "Point 3"),
KnowledgeCheckItem(
fmt.Sprintf("Quiz Question %d", i+1),
[]models.Answer{
{ID: fmt.Sprintf("q%d-a", i), Title: "Answer A", Correct: false},
{ID: fmt.Sprintf("q%d-b", i), Title: "Answer B", Correct: true},
{ID: fmt.Sprintf("q%d-c", i), Title: "Answer C", Correct: false},
},
),
},
}
courseLessons = append(courseLessons, lesson)
}
return &models.Course{
ShareID: "large-benchmark-id",
Author: "Benchmark Author",
Course: models.CourseInfo{
ID: "large-benchmark-course",
Title: fmt.Sprintf("Large Benchmark Course (%d lessons)", lessons),
Description: "<p>Large course for performance testing.</p>",
NavigationMode: "menu",
Lessons: courseLessons,
},
}
}
// TextItem creates a text item with the specified heading and paragraph content.
func TextItem(heading, paragraph string) models.Item {
return models.Item{
Type: "text",
Family: "text",
Items: []models.SubItem{
{
ID: "text-sub-1",
Heading: heading,
Paragraph: paragraph,
},
},
}
}
// ListItem creates a list item with the specified items as list entries.
func ListItem(items ...string) models.Item {
subItems := make([]models.SubItem, len(items))
for i, item := range items {
subItems[i] = models.SubItem{
ID: fmt.Sprintf("list-sub-%d", i+1),
Paragraph: fmt.Sprintf("<p>%s</p>", item),
}
}
return models.Item{
Type: "list",
Family: "list",
Items: subItems,
}
}
// KnowledgeCheckItem creates a knowledge check item with the specified question and answers.
func KnowledgeCheckItem(question string, answers []models.Answer) models.Item {
return models.Item{
Type: "knowledgeCheck",
Family: "knowledge",
Items: []models.SubItem{
{
ID: "kc-sub-1",
Title: fmt.Sprintf("<p>%s</p>", question),
Answers: answers,
Feedback: "<p>Thank you for your response.</p>",
},
},
}
}
// MediaItem creates a media item (video or image) with the specified type and URL.
// The mediaType should be "video" or "image".
func MediaItem(mediaType, url string) models.Item {
var media *models.Media
switch mediaType {
case "video":
media = &models.Media{
Video: &models.VideoMedia{
Key: "video-key",
URL: url,
Type: "mp4",
Duration: 120,
OriginalURL: url,
},
}
case "image":
media = &models.Media{
Image: &models.ImageMedia{
Key: "image-key",
Type: "png",
Width: 800,
Height: 600,
OriginalURL: url,
},
}
default:
media = &models.Media{}
}
return models.Item{
Type: "multimedia",
Family: "media",
Items: []models.SubItem{
{
ID: "media-sub-1",
Title: fmt.Sprintf("<p>%s content</p>", mediaType),
Caption: fmt.Sprintf("<p>Caption for %s</p>", mediaType),
Media: media,
},
},
}
}
// InteractiveItem creates an interactive content item with the specified title.
func InteractiveItem(title string) models.Item {
return models.Item{
Type: "interactive",
Family: "interactive",
Items: []models.SubItem{
{
ID: "interactive-sub-1",
Title: fmt.Sprintf("<p>%s</p>", title),
},
},
}
}
// DividerItem creates a divider/separator item.
func DividerItem() models.Item {
return models.Item{
Type: "divider",
Family: "divider",
}
}
// FlipCardItem creates a flashcard-style item with front and back content.
func FlipCardItem(front, back string) models.Item {
return models.Item{
Type: "flashcard",
Family: "interactive",
Items: []models.SubItem{
{
ID: "flipcard-sub-1",
Front: &models.CardSide{
Description: fmt.Sprintf("<p>%s</p>", front),
},
Back: &models.CardSide{
Description: fmt.Sprintf("<p>%s</p>", back),
},
},
},
}
}
// QuoteItem creates a quote/blockquote item with text and attribution.
func QuoteItem(quote, author string) models.Item {
return models.Item{
Type: "quote",
Family: "text",
Items: []models.SubItem{
{
ID: "quote-sub-1",
Paragraph: fmt.Sprintf("<blockquote>%s</blockquote>", quote),
Title: author,
},
},
}
}
// ImageItem creates a standalone image item with caption.
func ImageItem(url, caption string) models.Item {
return models.Item{
Type: "image",
Family: "media",
Items: []models.SubItem{
{
ID: "image-sub-1",
Caption: fmt.Sprintf("<p>%s</p>", caption),
Media: &models.Media{
Image: &models.ImageMedia{
Key: "standalone-img-key",
Type: "jpg",
Width: 1200,
Height: 800,
OriginalURL: url,
},
},
},
},
}
}
// VideoItem creates a standalone video item with caption.
func VideoItem(url string, duration int, caption string) models.Item {
return models.Item{
Type: "video",
Family: "media",
Items: []models.SubItem{
{
ID: "video-sub-1",
Caption: fmt.Sprintf("<p>%s</p>", caption),
Media: &models.Media{
Video: &models.VideoMedia{
Key: "standalone-video-key",
URL: url,
Type: "mp4",
Duration: duration,
OriginalURL: url,
},
},
},
},
}
}
// MatchingItem creates a matching/pairing knowledge check item.
func MatchingItem(pairs map[string]string) models.Item {
answers := make([]models.Answer, 0, len(pairs))
i := 0
for term, match := range pairs {
answers = append(answers, models.Answer{
ID: fmt.Sprintf("match-%d", i),
Title: term,
MatchTitle: match,
Correct: true,
})
i++
}
return models.Item{
Type: "matching",
Family: "knowledge",
Items: []models.SubItem{
{
ID: "matching-sub-1",
Title: "<p>Match the following items:</p>",
Answers: answers,
Feedback: "<p>Check your matches!</p>",
},
},
}
}
// EmptyLesson returns a lesson with no items for edge case testing.
func EmptyLesson(id, title string) models.Lesson {
return models.Lesson{
ID: id,
Title: title,
Type: "lesson",
Items: []models.Item{},
}
}
// SectionLesson returns a section header (not a content lesson).
func SectionLesson(id, title string) models.Lesson {
return models.Lesson{
ID: id,
Title: title,
Type: "section",
}
}
-273
View File
@@ -1,273 +0,0 @@
package testdata
import (
"testing"
"github.com/kjanat/articulate-parser/internal/models"
)
func TestMinimalCourse(t *testing.T) {
course := MinimalCourse()
if course == nil {
t.Fatal("MinimalCourse() returned nil")
}
if course.ShareID == "" {
t.Error("MinimalCourse() should have ShareID set")
}
if course.Course.Title == "" {
t.Error("MinimalCourse() should have Title set")
}
}
func TestBasicCourse(t *testing.T) {
course := BasicCourse()
if course == nil {
t.Fatal("BasicCourse() returned nil")
}
if len(course.Course.Lessons) != 2 {
t.Errorf("BasicCourse() should have 2 lessons, got %d", len(course.Course.Lessons))
}
if course.Course.Lessons[0].Type != "section" {
t.Error("First lesson should be a section")
}
if course.Course.Lessons[1].Type != "lesson" {
t.Error("Second lesson should be a lesson")
}
if len(course.Course.Lessons[1].Items) != 1 {
t.Errorf("Lesson should have 1 item, got %d", len(course.Course.Lessons[1].Items))
}
}
func TestCompleteCourse(t *testing.T) {
course := CompleteCourse()
if course == nil {
t.Fatal("CompleteCourse() returned nil")
}
if len(course.Course.Lessons) == 0 {
t.Error("CompleteCourse() should have lessons")
}
// Verify all expected item types exist
itemTypes := make(map[string]bool)
for _, lesson := range course.Course.Lessons {
for _, item := range lesson.Items {
itemTypes[item.Type] = true
}
}
expectedTypes := []string{"text", "list", "divider", "multimedia", "knowledgeCheck", "interactive", "flashcard", "quote"}
for _, et := range expectedTypes {
if !itemTypes[et] {
t.Errorf("CompleteCourse() should have item type %q", et)
}
}
}
func TestLargeCourse(t *testing.T) {
tests := []struct {
lessons int
expected int
}{
{1, 2}, // 1 lesson + 1 section
{5, 6}, // 5 lessons + 1 section
{10, 12}, // 10 lessons + 2 sections
{50, 60}, // 50 lessons + 10 sections
}
for _, tt := range tests {
course := LargeCourse(tt.lessons)
if course == nil {
t.Fatalf("LargeCourse(%d) returned nil", tt.lessons)
}
if len(course.Course.Lessons) != tt.expected {
t.Errorf("LargeCourse(%d) should have %d lessons+sections, got %d",
tt.lessons, tt.expected, len(course.Course.Lessons))
}
}
}
func TestTextItem(t *testing.T) {
item := TextItem("Test Heading", "<p>Test paragraph</p>")
if item.Type != "text" {
t.Errorf("TextItem Type should be 'text', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("TextItem should have 1 sub-item, got %d", len(item.Items))
}
if item.Items[0].Heading != "Test Heading" {
t.Errorf("TextItem heading mismatch: got %q", item.Items[0].Heading)
}
}
func TestListItem(t *testing.T) {
item := ListItem("Item 1", "Item 2", "Item 3")
if item.Type != "list" {
t.Errorf("ListItem Type should be 'list', got %q", item.Type)
}
if len(item.Items) != 3 {
t.Errorf("ListItem should have 3 sub-items, got %d", len(item.Items))
}
}
func TestKnowledgeCheckItem(t *testing.T) {
answers := []models.Answer{
{Title: "A", Correct: false},
{Title: "B", Correct: true},
}
item := KnowledgeCheckItem("Test question?", answers)
if item.Type != "knowledgeCheck" {
t.Errorf("KnowledgeCheckItem Type should be 'knowledgeCheck', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("KnowledgeCheckItem should have 1 sub-item, got %d", len(item.Items))
}
if len(item.Items[0].Answers) != 2 {
t.Errorf("KnowledgeCheckItem should have 2 answers, got %d", len(item.Items[0].Answers))
}
}
func TestMediaItem(t *testing.T) {
tests := []struct {
name string
mediaType string
hasVideo bool
hasImage bool
}{
{"video", "video", true, false},
{"image", "image", false, true},
{"unknown", "unknown", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
item := MediaItem(tt.mediaType, "https://example.com/media")
if item.Type != "multimedia" {
t.Errorf("MediaItem Type should be 'multimedia', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("MediaItem should have 1 sub-item, got %d", len(item.Items))
}
if tt.hasVideo && item.Items[0].Media.Video == nil {
t.Error("Expected video media to be set")
}
if tt.hasImage && item.Items[0].Media.Image == nil {
t.Error("Expected image media to be set")
}
})
}
}
func TestInteractiveItem(t *testing.T) {
item := InteractiveItem("Interactive Title")
if item.Type != "interactive" {
t.Errorf("InteractiveItem Type should be 'interactive', got %q", item.Type)
}
}
func TestDividerItem(t *testing.T) {
item := DividerItem()
if item.Type != "divider" {
t.Errorf("DividerItem Type should be 'divider', got %q", item.Type)
}
}
func TestFlipCardItem(t *testing.T) {
item := FlipCardItem("Front text", "Back text")
if item.Type != "flashcard" {
t.Errorf("FlipCardItem Type should be 'flashcard', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("FlipCardItem should have 1 sub-item, got %d", len(item.Items))
}
if item.Items[0].Front == nil || item.Items[0].Back == nil {
t.Error("FlipCardItem should have Front and Back set")
}
}
func TestQuoteItem(t *testing.T) {
item := QuoteItem("Test quote", "Test author")
if item.Type != "quote" {
t.Errorf("QuoteItem Type should be 'quote', got %q", item.Type)
}
}
func TestImageItem(t *testing.T) {
item := ImageItem("https://example.com/img.jpg", "Image caption")
if item.Type != "image" {
t.Errorf("ImageItem Type should be 'image', got %q", item.Type)
}
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Image == nil {
t.Error("ImageItem should have image media set")
}
}
func TestVideoItem(t *testing.T) {
item := VideoItem("https://example.com/video.mp4", 120, "Video caption")
if item.Type != "video" {
t.Errorf("VideoItem Type should be 'video', got %q", item.Type)
}
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Video == nil {
t.Error("VideoItem should have video media set")
}
if item.Items[0].Media.Video.Duration != 120 {
t.Errorf("VideoItem duration should be 120, got %d", item.Items[0].Media.Video.Duration)
}
}
func TestMatchingItem(t *testing.T) {
pairs := map[string]string{
"Term 1": "Match 1",
"Term 2": "Match 2",
}
item := MatchingItem(pairs)
if item.Type != "matching" {
t.Errorf("MatchingItem Type should be 'matching', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("MatchingItem should have 1 sub-item, got %d", len(item.Items))
}
if len(item.Items[0].Answers) != 2 {
t.Errorf("MatchingItem should have 2 answers, got %d", len(item.Items[0].Answers))
}
}
func TestEmptyLesson(t *testing.T) {
lesson := EmptyLesson("test-id", "Test Title")
if lesson.ID != "test-id" {
t.Errorf("EmptyLesson ID mismatch: got %q", lesson.ID)
}
if lesson.Type != "lesson" {
t.Errorf("EmptyLesson Type should be 'lesson', got %q", lesson.Type)
}
if len(lesson.Items) != 0 {
t.Errorf("EmptyLesson should have no items, got %d", len(lesson.Items))
}
}
func TestSectionLesson(t *testing.T) {
lesson := SectionLesson("section-id", "Section Title")
if lesson.Type != "section" {
t.Errorf("SectionLesson Type should be 'section', got %q", lesson.Type)
}
}
func TestSampleVariables(t *testing.T) {
if len(SampleAnswers) == 0 {
t.Error("SampleAnswers should not be empty")
}
if len(SampleSubItems) == 0 {
t.Error("SampleSubItems should not be empty")
}
// Verify one correct answer exists in SampleAnswers
hasCorrect := false
for _, a := range SampleAnswers {
if a.Correct {
hasCorrect = true
break
}
}
if !hasCorrect {
t.Error("SampleAnswers should have at least one correct answer")
}
}
+12 -12
View File
@@ -7,12 +7,11 @@ import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/exporters"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/services"
"github.com/kjanat/articulate-parser/internal/version"
)
@@ -27,19 +26,20 @@ func main() {
// run contains the main application logic and returns an exit code.
// This function is testable as it doesn't call os.Exit directly.
func run(args []string) int {
// Create context that cancels on interrupt signals (SIGINT, SIGTERM)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Load configuration
cfg := config.Load()
// Dependency injection setup with configuration
logger := services.NewLoggerFromConfig(cfg)
var logger interfaces.Logger
if cfg.LogFormat == "json" {
logger = services.NewSlogLogger(cfg.LogLevel)
} else {
logger = services.NewTextLogger(cfg.LogLevel)
}
htmlCleaner := services.NewHTMLCleaner()
parser := services.NewArticulateParser(logger, cfg)
exporterFactory := exporters.NewFactory(htmlCleaner, logger)
parser := services.NewArticulateParser(logger, cfg.BaseURL, cfg.RequestTimeout)
exporterFactory := exporters.NewFactory(htmlCleaner)
app := services.NewApp(parser, exporterFactory)
// Check for version flag
@@ -70,9 +70,9 @@ func run(args []string) int {
// Determine if source is a URI or file path
if isURI(source) {
err = app.ProcessCourseFromURI(ctx, source, format, output)
err = app.ProcessCourseFromURI(context.Background(), source, format, output)
} else {
err = app.ProcessCourseFromFile(ctx, source, format, output)
err = app.ProcessCourseFromFile(source, format, output)
}
if err != nil {
@@ -92,7 +92,7 @@ func run(args []string) int {
// Returns:
// - true if the string appears to be a URI, false otherwise
func isURI(str string) bool {
return strings.HasPrefix(str, "http://") || strings.HasPrefix(str, "https://")
return len(str) > 7 && (str[:7] == "http://" || str[:8] == "https://")
}
// printUsage prints the command-line usage information.
+174 -174
View File
@@ -20,7 +20,7 @@ DEFAULT_LDFLAGS="-s -w"
# Function to show help
show_help() {
cat <<'EOF'
cat <<'EOF'
articulate-parser Build Script (Bash)
=====================================
@@ -106,129 +106,129 @@ EOF
# Function to show available Go build targets
show_targets() {
echo "Available Go Build Targets:"
echo "=========================="
echo
if command -v go >/dev/null 2>&1; then
echo "Getting targets from 'go tool dist list'..."
echo
echo "Available Go Build Targets:"
echo "=========================="
echo
if command -v go >/dev/null 2>&1; then
echo "Getting targets from 'go tool dist list'..."
echo
# Get all targets and format them nicely
local targets
targets=$(go tool dist list 2>/dev/null)
# Get all targets and format them nicely
local targets
targets=$(go tool dist list 2>/dev/null)
if [ $? -eq 0 ] && [ -n "$targets" ]; then
# Show formatted output
printf "%-15s %-10s %s\n" "OS" "ARCH" "STATUS"
printf "%-15s %-10s %s\n" "---------------" "----------" "------"
if [ $? -eq 0 ] && [ -n "$targets" ]; then
# Show formatted output
printf "%-15s %-10s %s\n" "OS" "ARCH" "STATUS"
printf "%-15s %-10s %s\n" "---------------" "----------" "------"
# Track our default targets
local default_targets=()
for os in "${OS[@]}"; do
for arch in "${ARCH[@]}"; do
default_targets+=("$os/$arch")
done
done
# Track our default targets
local default_targets=()
for os in "${OS[@]}"; do
for arch in "${ARCH[@]}"; do
default_targets+=("$os/$arch")
done
done
# Display all targets with status
echo "$targets" | sort | while IFS='/' read -r os arch; do
local status="available"
if printf '%s\n' "${default_targets[@]}" | grep -q "^$os/$arch$"; then
status="default"
fi
printf "%-15s %-10s %s\n" "$os" "$arch" "$status"
done
# Display all targets with status
echo "$targets" | sort | while IFS='/' read -r os arch; do
local status="available"
if printf '%s\n' "${default_targets[@]}" | grep -q "^$os/$arch$"; then
status="default"
fi
printf "%-15s %-10s %s\n" "$os" "$arch" "$status"
done
echo
echo "Summary:"
echo " Total available targets: $(echo "$targets" | wc -l)"
echo " Default targets used by this script: ${#default_targets[@]}"
echo
echo "Default script targets:"
for target in "${default_targets[@]}"; do
echo " - $target"
done
else
echo "Error: Failed to get target list from 'go tool dist list'"
exit 1
fi
else
echo "Error: Go is not installed or not in PATH"
exit 1
fi
echo
echo "Summary:"
echo " Total available targets: $(echo "$targets" | wc -l)"
echo " Default targets used by this script: ${#default_targets[@]}"
echo
echo "Default script targets:"
for target in "${default_targets[@]}"; do
echo " - $target"
done
else
echo "Error: Failed to get target list from 'go tool dist list'"
exit 1
fi
else
echo "Error: Go is not installed or not in PATH"
exit 1
fi
}
# Parse parameters
while (("$#")); do
case $1 in
-h | --help)
SHOW_HELP=true
shift
;;
--show-targets)
SHOW_TARGETS=true
shift
;;
-v | --verbose)
VERBOSE=true
shift
;;
-j)
if [[ ${2-} =~ ^[0-9]+$ ]]; then
JOBS=$2
shift 2
else
echo "Error: Missing number of jobs after -j"
exit 1
fi
;;
-o)
if [ -n "${2-}" ]; then
OUTDIR=$2
shift 2
else
echo "Error: Missing output directory after -o"
exit 1
fi
;;
-e)
if [ -n "${2-}" ]; then
ENTRYPOINT=$2
shift 2
else
echo "Error: Missing entry point file after -e"
exit 1
fi
;;
*)
break
;;
esac
case $1 in
-h | --help)
SHOW_HELP=true
shift
;;
--show-targets)
SHOW_TARGETS=true
shift
;;
-v | --verbose)
VERBOSE=true
shift
;;
-j)
if [[ ${2-} =~ ^[0-9]+$ ]]; then
JOBS=$2
shift 2
else
echo "Error: Missing number of jobs after -j"
exit 1
fi
;;
-o)
if [ -n "${2-}" ]; then
OUTDIR=$2
shift 2
else
echo "Error: Missing output directory after -o"
exit 1
fi
;;
-e)
if [ -n "${2-}" ]; then
ENTRYPOINT=$2
shift 2
else
echo "Error: Missing entry point file after -e"
exit 1
fi
;;
*)
break
;;
esac
done
# Handle help and show-targets early
if [ "$SHOW_HELP" = true ]; then
show_help
exit 0
show_help
exit 0
fi
if [ "$SHOW_TARGETS" = true ]; then
show_targets
exit 0
show_targets
exit 0
fi
# Validate Go installation
if ! command -v go >/dev/null 2>&1; then
echo "Error: Go is not installed or not in PATH"
echo "Please install Go from https://golang.org/dl/"
echo "Or if running on Windows, use the PowerShell script: scripts\\build.ps1"
exit 1
echo "Error: Go is not installed or not in PATH"
echo "Please install Go from https://golang.org/dl/"
echo "Or if running on Windows, use the PowerShell script: scripts\\build.ps1"
exit 1
fi
# Validate entry point exists
if [ ! -f "$ENTRYPOINT" ]; then
echo "Error: Entry point file '$ENTRYPOINT' does not exist"
exit 1
echo "Error: Entry point file '$ENTRYPOINT' does not exist"
exit 1
fi
# Store remaining arguments as an array to preserve argument boundaries
@@ -237,30 +237,30 @@ GO_BUILD_FLAGS_ARRAY=("$@")
# Apply default ldflags if no custom ldflags were provided
HAS_CUSTOM_LDFLAGS=false
for arg in "${GO_BUILD_FLAGS_ARRAY[@]}"; do
if [[ "$arg" == "-ldflags" ]]; then
HAS_CUSTOM_LDFLAGS=true
break
fi
if [[ "$arg" == "-ldflags" ]]; then
HAS_CUSTOM_LDFLAGS=true
break
fi
done
if [[ "$HAS_CUSTOM_LDFLAGS" == false ]] && [[ -n "$DEFAULT_LDFLAGS" ]]; then
# Add default ldflags at the beginning
GO_BUILD_FLAGS_ARRAY=("-ldflags" "$DEFAULT_LDFLAGS" "${GO_BUILD_FLAGS_ARRAY[@]}")
# Add default ldflags at the beginning
GO_BUILD_FLAGS_ARRAY=("-ldflags" "$DEFAULT_LDFLAGS" "${GO_BUILD_FLAGS_ARRAY[@]}")
fi
# Verbose output
if [ "$VERBOSE" = true ]; then
echo "Build Configuration:"
echo " Entry Point: $ENTRYPOINT"
echo " Output Dir: $OUTDIR"
echo " Parallel Jobs: $JOBS"
if [ ${#GO_BUILD_FLAGS_ARRAY[@]} -gt 0 ]; then
echo " Go Build Flags: ${GO_BUILD_FLAGS_ARRAY[*]}"
else
echo " Go Build Flags: none"
fi
echo " Targets: ${#OS[@]}×${#ARCH[@]} = $((${#OS[@]} * ${#ARCH[@]})) total"
echo
echo "Build Configuration:"
echo " Entry Point: $ENTRYPOINT"
echo " Output Dir: $OUTDIR"
echo " Parallel Jobs: $JOBS"
if [ ${#GO_BUILD_FLAGS_ARRAY[@]} -gt 0 ]; then
echo " Go Build Flags: ${GO_BUILD_FLAGS_ARRAY[*]}"
else
echo " Go Build Flags: none"
fi
echo " Targets: ${#OS[@]}×${#ARCH[@]} = $((${#OS[@]} * ${#ARCH[@]})) total"
echo
fi
rm -rf "$OUTDIR"
@@ -272,27 +272,27 @@ BUILD_START=$(date +%s)
# Compose all targets in an array
TARGETS=()
for os in "${OS[@]}"; do
for arch in "${ARCH[@]}"; do
BIN="articulate-parser-$os-$arch"
[[ "$os" == "windows" ]] && BIN="$BIN.exe"
TARGETS+=("$BIN|$os|$arch")
done
for arch in "${ARCH[@]}"; do
BIN="articulate-parser-$os-$arch"
[[ "$os" == "windows" ]] && BIN="$BIN.exe"
TARGETS+=("$BIN|$os|$arch")
done
done
# Show targets info if verbose
if [ "$VERBOSE" = true ]; then
echo "Building targets:"
for target in "${TARGETS[@]}"; do
BIN="${target%%|*}"
echo " - $BIN"
done
echo
echo "Building targets:"
for target in "${TARGETS[@]}"; do
BIN="${target%%|*}"
echo " - $BIN"
done
echo
fi
# Print pending statuses and save line numbers
for idx in "${!TARGETS[@]}"; do
BIN="${TARGETS[$idx]%%|*}"
printf "[ ] %-35s ... pending\n" "$BIN"
BIN="${TARGETS[$idx]%%|*}"
printf "[ ] %-35s ... pending\n" "$BIN"
done
# Make sure output isn't buffered
@@ -300,36 +300,36 @@ export PYTHONUNBUFFERED=1
# Function to update a line in-place (1-based index)
update_status() {
local idx=$1
local symbol=$2
local msg=$3
# Move cursor up to the correct line
printf "\0337" # Save cursor position
printf "\033[%dA" $((${#TARGETS[@]} - idx + 1)) # Move up
printf "\r\033[K[%s] %-35s\n" "$symbol" "$msg" # Clear & update line
printf "\0338" # Restore cursor position
local idx=$1
local symbol=$2
local msg=$3
# Move cursor up to the correct line
printf "\0337" # Save cursor position
printf "\033[%dA" $((${#TARGETS[@]} - idx + 1)) # Move up
printf "\r\033[K[%s] %-35s\n" "$symbol" "$msg" # Clear & update line
printf "\0338" # Restore cursor position
}
for idx in "${!TARGETS[@]}"; do
while (($(jobs -rp | wc -l) >= JOBS)); do sleep 0.2; done
(
IFS='|' read -r BIN os arch <<<"${TARGETS[$idx]}"
update_status $((idx + 1)) '>' "$BIN ... building"
while (($(jobs -rp | wc -l) >= JOBS)); do sleep 0.2; done
(
IFS='|' read -r BIN os arch <<<"${TARGETS[$idx]}"
update_status $((idx + 1)) '>' "$BIN ... building"
# Prepare build command as an array to properly handle arguments with spaces
build_cmd=(go build)
if [ "$VERBOSE" = true ]; then
build_cmd+=(-v)
fi
build_cmd+=("${GO_BUILD_FLAGS_ARRAY[@]}" -o "$OUTDIR/$BIN" "$ENTRYPOINT")
# Prepare build command as an array to properly handle arguments with spaces
build_cmd=(go build)
if [ "$VERBOSE" = true ]; then
build_cmd+=(-v)
fi
build_cmd+=("${GO_BUILD_FLAGS_ARRAY[@]}" -o "$OUTDIR/$BIN" "$ENTRYPOINT")
if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
update_status $((idx + 1)) '✔' "$BIN done"
rm -f "$OUTDIR/$BIN.log"
else
update_status $((idx + 1)) '✖' "$BIN FAILED (see $OUTDIR/$BIN.log)"
fi
) &
if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
update_status $((idx + 1)) '✔' "$BIN done"
rm -f "$OUTDIR/$BIN.log"
else
update_status $((idx + 1)) '✖' "$BIN FAILED (see $OUTDIR/$BIN.log)"
fi
) &
done
wait
@@ -342,27 +342,27 @@ echo -e "\nAll builds completed in ${BUILD_DURATION}s. Find them in $OUTDIR/"
# Show build summary if verbose
if [ "$VERBOSE" = true ]; then
echo
echo "Build Summary:"
echo "=============="
success_count=0
total_size=0
echo
echo "Build Summary:"
echo "=============="
success_count=0
total_size=0
for target in "${TARGETS[@]}"; do
BIN="${target%%|*}"
if [ -f "$OUTDIR/$BIN" ]; then
success_count=$((success_count + 1))
size=$(stat -f%z "$OUTDIR/$BIN" 2>/dev/null || stat -c%s "$OUTDIR/$BIN" 2>/dev/null || echo "0")
total_size=$((total_size + size))
rm -f "$OUTDIR/$BIN.log"
printf " ✔ %-42s %s\n" "$OUTDIR/$BIN" "$(numfmt --to=iec-i --suffix=B $size 2>/dev/null || echo "${size} bytes")"
else
printf " ✖ %-42s %s\n" "$OUTDIR/$BIN" "FAILED"
fi
done
for target in "${TARGETS[@]}"; do
BIN="${target%%|*}"
if [ -f "$OUTDIR/$BIN" ]; then
success_count=$((success_count + 1))
size=$(stat -f%z "$OUTDIR/$BIN" 2>/dev/null || stat -c%s "$OUTDIR/$BIN" 2>/dev/null || echo "0")
total_size=$((total_size + size))
rm -f "$OUTDIR/$BIN.log"
printf " ✔ %-42s %s\n" "$OUTDIR/$BIN" "$(numfmt --to=iec-i --suffix=B $size 2>/dev/null || echo "${size} bytes")"
else
printf " ✖ %-42s %s\n" "$OUTDIR/$BIN" "FAILED"
fi
done
echo " ────────────────────────────────────────────────"
printf " Total: %d/%d successful, %s total size\n" "$success_count" "${#TARGETS[@]}" "$(numfmt --to=iec-i --suffix=B $total_size 2>/dev/null || echo "${total_size} bytes")"
echo " ────────────────────────────────────────────────"
printf " Total: %d/%d successful, %s total size\n" "$success_count" "${#TARGETS[@]}" "$(numfmt --to=iec-i --suffix=B $total_size 2>/dev/null || echo "${total_size} bytes")"
fi
# Clean up environment variables to avoid contaminating future builds
-49
View File
@@ -1,49 +0,0 @@
# :schema tombi://www.schemastore.org/tombi.json
# :tombi toml-version = "v1.1.0"
toml-version = "v1.1.0"
[files]
include = ["**/*.toml", "**/.*.toml"]
exclude = ["**/fixtures"]
[format]
[format.rules]
array-bracket-space-width = 0
array-comma-space-width = 1
date-time-delimiter = "T"
indent-style = "space"
indent-sub-tables = false
indent-table-key-value-pairs = false
indent-width = 2
inline-table-brace-space-width = 1
inline-table-comma-space-width = 1
key-value-equals-sign-alignment = true
key-value-equals-sign-space-width = 1
line-ending = "lf"
line-width = 140
string-quote-style = "double"
trailing-comment-alignment = true
trailing-comment-space-width = 2
[lint]
[lint.rules]
dotted-keys-out-of-order = "warn"
key-empty = "warn"
tables-out-of-order = "error"
[lsp]
code-action.enabled = true
completion.enabled = true
diagnostic.enabled = true
document-link.enabled = true
formatting.enabled = true
goto-declaration.enabled = true
goto-definition.enabled = true
goto-type-definition.enabled = true
hover.enabled = true
workspace-diagnostic.enabled = true
[schema]
enabled = true
strict = true