12 Commits

Author SHA1 Message Date
c4086a832f fix: prevent duplicate workflow execution on tag push
- Remove tag trigger from CI workflow to prevent simultaneous execution
- Add Docker build job to release workflow for proper release handling
- CI now only runs on branch pushes and PRs
- Release workflow handles both binary releases and Docker images for tags

Fixes issue where both CI and release workflows would start when pushing vX.X.X tags
2025-05-28 23:03:19 +00:00
903ee92e4c Update ci.yml
- Added docker hub to the login.
- Removed some cache BS.
2025-05-29 00:19:25 +02:00
9c51c0d9e3 Reorganizes badges in README for clarity
Switches CI and Docker badges to clarify workflow separation.
Promotes Docker image visibility by rearranging badge positions.
2025-05-28 23:50:54 +02:00
ec5c8c099c Update labels and bump version to 0.4.0
Standardizes dependabot labels to include 'dependencies/' prefix
for better organization and clarity.

Bumps application version to 0.4.0 to reflect recent changes
and improvements.
2025-05-28 23:31:16 +02:00
9eaf7dfcf2 docker(deps): bump golang in the docker-images group (#4)
Bumps the docker-images group with 1 update: golang.


Updates `golang` from 1.23-alpine to 1.24-alpine

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.24-alpine
  dependency-type: direct:production
  dependency-group: docker-images
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-28 23:09:09 +02:00
b7f23b2387 Add Docker support and GitHub Container Registry CI workflow (#3)
* Add comprehensive Docker support with multi-stage builds
* Set up GitHub Container Registry integration
* Enhance CI/CD workflows with Docker build and push capabilities
* Add --help and --version flags to main application
* Update documentation with Docker usage examples
* Implement security best practices for container deployment
2025-05-28 23:04:43 +02:00
a0003983c4 [autofix.ci] apply automated fixes 2025-05-28 12:24:31 +00:00
1c1460ff04 Refactors main function and enhances test suite
Refactors the main function for improved testability by extracting
the core logic into a new run function. Updates argument handling
and error reporting to use return codes instead of os.Exit.

Adds comprehensive test coverage for main functionality,
including integration tests and validation against edge cases.

Enhances README with updated code coverage and feature improvement lists.

Addresses improved maintainability and testability of the application.

Bumps version to 0.3.1
2025-05-28 14:23:56 +02:00
1b945ca2bc Updates version and improves diagram styling
Bumps application version to 0.3.0 for new features or fixes.
Enhances README diagram styling for better readability in both
light and dark GitHub themes by adjusting colors and adding
text color contrast.
2025-05-28 13:26:33 +02:00
fb343f9a23 Merge pull request #2
Merge pull request #2: Add HTML export functionality and fix build system cross-compilation issues
2025-05-28 13:23:45 +02:00
ce5b5c20bb Enhances README and script improvements
Updates README to include HTML format, architecture overview,
and comprehensive feature descriptions. Improves build scripts
by adding cleanup of environment variables and validating Go
installation, reducing cross-platform build issues.

Fixes README inconsistencies and improves script reliability.
2025-05-28 13:17:08 +02:00
cc11d2fd84 feat: Add HTML export functionality and GitHub workflow
- Implement HTMLExporter with professional styling and embedded CSS
- Add comprehensive test suite for HTML export functionality
- Update factory to support HTML format ('html' and 'htm')
- Add autofix.ci GitHub workflow for code formatting
- Support all content types: text, lists, quizzes, multimedia, etc.
- Include proper HTML escaping for security
- Add benchmark tests for performance validation
2025-05-28 13:00:27 +02:00
22 changed files with 3148 additions and 432 deletions

68
.dockerignore Normal file
View File

@ -0,0 +1,68 @@
# Git
.git
.gitignore
.gitattributes
# CI/CD
.github
.codecov.yml
# Documentation
README.md
*.md
docs/
# Build artifacts
build/
dist/
*.exe
*.tar.gz
*.zip
# Test files
*_test.go
test_*.go
test/
coverage.out
coverage.html
*.log
# Development
.vscode/
.idea/
*.swp
*.swo
*~
# OS specific
.DS_Store
Thumbs.db
# Output and temporary files
output/
tmp/
temp/
# Node.js (if any)
node_modules/
npm-debug.log
yarn-error.log
# Python (if any)
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
# Scripts (build scripts not needed in container)
scripts/
# Sample files
articulate-sample.json
test_input.json
# License
LICENSE

View File

@ -11,11 +11,57 @@ updates:
open-pull-requests-limit: 10
labels:
- 'dependencies'
- 'github-actions'
- 'dependencies/github-actions'
commit-message:
prefix: 'ci'
include: 'scope'
# Check for updates to Docker
- package-ecosystem: 'docker'
directory: '/'
schedule:
interval: 'weekly'
day: 'monday'
time: '07:00'
timezone: 'Europe/Amsterdam'
open-pull-requests-limit: 10
labels:
- 'dependencies'
- 'dependencies/docker'
commit-message:
prefix: 'docker'
include: 'scope'
groups:
docker-images:
patterns:
- '*'
update-types:
- 'minor'
- 'patch'
# Check for updates to Docker Compose
- package-ecosystem: 'docker-compose'
directory: '/'
schedule:
interval: 'weekly'
day: 'monday'
time: '07:00'
timezone: 'Europe/Amsterdam'
open-pull-requests-limit: 10
labels:
- 'dependencies'
- 'dependencies/docker-compose'
commit-message:
prefix: 'docker'
include: 'scope'
groups:
docker-compose:
patterns:
- '*'
update-types:
- 'minor'
- 'patch'
# Check for updates to Go modules
- package-ecosystem: 'gomod'
directory: '/'
@ -27,7 +73,7 @@ updates:
open-pull-requests-limit: 10
labels:
- 'dependencies'
- 'go'
- 'dependencies/go'
commit-message:
prefix: 'deps'
include: 'scope'

25
.github/workflows/autofix.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: autofix.ci
on:
pull_request:
push:
branches: [ "master" ]
permissions:
contents: read
jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
# goimports works like gofmt, but also fixes imports.
# see https://pkg.go.dev/golang.org/x/tools/cmd/goimports
- run: go install golang.org/x/tools/cmd/goimports@latest
- run: goimports -w .
# of course we can also do just this instead:
# - run: gofmt -w .
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

View File

@ -2,11 +2,17 @@ name: CI
on:
push:
branches: [ "master", "develop" ]
tags:
- "v*.*.*"
branches: ['master', 'develop']
pull_request:
branches: [ "master", "develop" ]
branches: ['master', 'develop', 'feature/*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
@ -30,13 +36,45 @@ jobs:
with:
go-version: ${{ matrix.go }}
check-latest: true
cache-dependency-path: "**/*.sum"
- name: Download dependencies
run: go mod download && echo "Download successful" || go mod tidy && echo "Tidy successful" || return 1
- name: Download dependencies with retry
run: |
set -e
echo "Downloading Go dependencies..."
- name: Verify dependencies
run: go mod verify
# Function to download with retry
download_with_retry() {
local attempt=1
local max_attempts=3
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts"
if go mod download; then
echo "Download successful on attempt $attempt"
return 0
else
echo "Download failed on attempt $attempt"
if [ $attempt -lt $max_attempts ]; then
echo "Cleaning cache and retrying..."
go clean -modcache
go clean -cache
sleep 2
fi
attempt=$((attempt + 1))
fi
done
echo "All download attempts failed"
return 1
}
# Try download with retry logic
download_with_retry
echo "Verifying module dependencies..."
go mod verify
echo "Dependencies verified successfully"
- name: Build
run: go build -v ./...
@ -123,18 +161,47 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
# Add coverage by package
echo "### 📋 Coverage by Package" >> $GITHUB_STEP_SUMMARY
echo "<details>" >> $GITHUB_STEP_SUMMARY
echo "<summary>Click to expand 📋 Coverage by Package details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Package | Coverage |" >> $GITHUB_STEP_SUMMARY
echo "|---------|----------|" >> $GITHUB_STEP_SUMMARY
# Create temporary file for package coverage aggregation
temp_coverage=$(mktemp)
# Extract package-level coverage data
go tool cover -func=coverage.out | grep -v total | while read line; do
if [[ $line == *".go:"* ]]; then
pkg=$(echo $line | awk '{print $1}' | cut -d'/' -f1-3)
coverage=$(echo $line | awk '{print $3}')
echo "| $pkg | $coverage |" >> $GITHUB_STEP_SUMMARY
# Extract package path from file path (everything before the filename)
filepath=$(echo "$line" | awk '{print $1}')
pkg_path=$(dirname "$filepath" | sed 's|github.com/kjanat/articulate-parser/||' | sed 's|^\./||')
coverage=$(echo "$line" | awk '{print $3}' | sed 's/%//')
# Use root package if no subdirectory
if [[ "$pkg_path" == "." || "$pkg_path" == "" ]]; then
pkg_path="root"
fi
echo "$pkg_path $coverage" >> "$temp_coverage"
fi
done | sort -u
done
# Aggregate coverage by package (average)
awk '{
packages[$1] += $2;
counts[$1]++
}
END {
for (pkg in packages) {
avg = packages[pkg] / counts[pkg]
printf "| %s | %.1f%% |\n", pkg, avg
}
}' $temp_coverage | sort >> $GITHUB_STEP_SUMMARY
rm -f $temp_coverage
echo "</details>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
@ -220,6 +287,53 @@ jobs:
flags: Go ${{ matrix.go }}
token: ${{ secrets.CODECOV_TOKEN }}
docker-test:
name: Docker Build Test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Capture build date
run: echo "BUILD_TIME=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
- name: Build Docker image (test)
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: test:latest
build-args: |
VERSION=test
BUILD_TIME=${{ env.BUILD_TIME }}
GIT_COMMIT=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test Docker image
run: |
echo "## 🧪 Docker Image Tests" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Test that the image runs and shows help
echo "**Testing help command:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
docker run --rm test:latest --help >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Test image size
IMAGE_SIZE=$(docker image inspect test:latest --format='{{.Size}}' | numfmt --to=iec-i --suffix=B)
echo "**Image size:** $IMAGE_SIZE" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
@ -236,126 +350,126 @@ jobs:
fail-on-severity: moderate
comment-summary-in-pr: always
release:
name: Release
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
if: github.ref_type == 'tag'
permissions:
contents: write
needs: [ "test" ]
contents: read
packages: write
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:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
fetch-depth: 0
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Go
uses: actions/setup-go@v5
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
go-version-file: 'go.mod'
check-latest: true
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run tests
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=Articulate Parser
org.opencontainers.image.description=A powerful CLI tool to parse Articulate Rise courses and export them to multiple formats including Markdown HTML and DOCX. Supports media extraction content cleaning and batch processing for educational content conversion.
org.opencontainers.image.vendor=kjanat
org.opencontainers.image.licenses=MIT
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
# Multi-architecture build - Docker automatically provides TARGETOS, TARGETARCH, etc.
# Based on Go's supported platforms from 'go tool dist list'
platforms: |
linux/amd64
linux/arm64
linux/arm/v7
linux/386
linux/ppc64le
linux/s390x
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }}
BUILD_TIME=${{ github.event.head_commit.timestamp }}
GIT_COMMIT=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A powerful CLI tool to parse Articulate Rise courses and export them to multiple formats including Markdown HTML and DOCX. Supports media extraction content cleaning and batch processing for educational content conversion.
sbom: true
provenance: true
- name: Generate Docker summary
run: |
echo "## 🚀 Release Tests" >> $GITHUB_STEP_SUMMARY
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
go test -v ./... 2>&1 | tee release-test-output.log
TEST_STATUS=$?
TOTAL_TESTS=$(grep -c "=== RUN" release-test-output.log || echo "0")
PASSED_TESTS=$(grep -c "--- PASS:" release-test-output.log || echo "0")
FAILED_TESTS=$(grep -c "--- FAIL:" release-test-output.log || echo "0")
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total Tests | $TOTAL_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Passed | ✅ $PASSED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Failed | ❌ $FAILED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Status | $([ $TEST_STATUS -eq 0 ] && echo "✅ PASSED" || echo "❌ FAILED") |" >> $GITHUB_STEP_SUMMARY
echo "**Image:** \`ghcr.io/${{ github.repository }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
exit $TEST_STATUS
- name: Install UPX
run: |
sudo apt-get update
sudo apt-get install -y upx
- name: Build binaries
run: |
echo "## 🔨 Build Process" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Set the build time environment variable
BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# Add run permissions to the build script
chmod +x ./scripts/build.sh
# Display help information for the build script
./scripts/build.sh --help
echo "**Build Configuration:**" >> $GITHUB_STEP_SUMMARY
echo "- Version: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "- Build Time: $BUILD_TIME" >> $GITHUB_STEP_SUMMARY
echo "- Git Commit: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Build for all platforms
./scripts/build.sh \
--verbose \
-ldflags "-s -w -X github.com/kjanat/articulate-parser/internal/version.Version=${{ github.ref_name }} -X github.com/kjanat/articulate-parser/internal/version.BuildTime=$BUILD_TIME -X github.com/kjanat/articulate-parser/internal/version.GitCommit=${{ github.sha }}"
- name: Compress binaries with UPX
run: |
echo "## 📦 Binary Compression" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Compressing binaries with UPX..."
cd build/
# Get original sizes
echo "**Original sizes:**" >> $GITHUB_STEP_SUMMARY
echo "**Tags built:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
ls -lah >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Features:**" >> $GITHUB_STEP_SUMMARY
echo "- **Platforms:** linux/amd64, linux/arm64, linux/arm/v7, linux/386, linux/ppc64le, linux/s390x" >> $GITHUB_STEP_SUMMARY
echo "- **Architecture optimization:** ✅ Native compilation for each platform" >> $GITHUB_STEP_SUMMARY
echo "- **Multi-arch image description:** ✅ Enabled" >> $GITHUB_STEP_SUMMARY
echo "- **SBOM (Software Bill of Materials):** ✅ Generated" >> $GITHUB_STEP_SUMMARY
echo "- **Provenance attestation:** ✅ Generated" >> $GITHUB_STEP_SUMMARY
echo "- **Security scanning:** Ready for vulnerability analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Usage:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "# Pull the image" >> $GITHUB_STEP_SUMMARY
echo "docker pull ghcr.io/${{ github.repository }}:latest" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "# Run with help" >> $GITHUB_STEP_SUMMARY
echo "docker run --rm ghcr.io/${{ github.repository }}:latest --help" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "# Process a local file (mount current directory)" >> $GITHUB_STEP_SUMMARY
echo "docker run --rm -v \$(pwd):/workspace ghcr.io/${{ github.repository }}:latest /workspace/input.json markdown /workspace/output.md" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Compress all binaries except Darwin (macOS) binaries as UPX doesn't work well with recent macOS versions
for binary in articulate-parser-*; do
if [[ "$binary" == *"darwin"* ]]; then
echo "Skipping UPX compression for $binary (macOS compatibility)"
else
echo "Compressing $binary..."
upx --best --lzma "$binary" || {
echo "Warning: UPX compression failed for $binary, keeping original"
}
fi
done
echo "**Final sizes:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
ls -lah >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- name: Upload a Build Artifact
uses: actions/upload-artifact@v4.6.2
with:
name: build-artifacts
path: build/
if-no-files-found: ignore
retention-days: 1
compression-level: 9
overwrite: true
include-hidden-files: true
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: build/*
generate_release_notes: true
draft: false
prerelease: ${{ startsWith(github.ref, 'refs/tags/v0.') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Security and quality analysis workflows
codeql-analysis:
name: CodeQL Analysis
uses: ./.github/workflows/codeql.yml
permissions:
security-events: write
packages: read
actions: read
contents: read

View File

@ -11,13 +11,17 @@
#
name: "CodeQL"
# This workflow is configured to be called by other workflows for more controlled execution
# This allows integration with the main CI pipeline and avoids redundant runs
# To enable automatic execution, uncomment the triggers below:
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
workflow_call:
schedule:
- cron: '44 16 * * 6'
# push:
# branches: [ "master" ]
# pull_request:
# branches: [ "master" ]
jobs:
analyze:

25
.github/workflows/dependency-review.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Dependency Review
# This workflow is designed to be called by other workflows rather than triggered automatically
# This allows for more controlled execution and integration with other CI/CD processes
# To enable automatic execution on pull requests, uncomment the line below:
# on: [pull_request]
on: [workflow_call]
permissions:
contents: read
# Required to post security advisories
security-events: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v4
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
comment-summary-in-pr: always

156
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,156 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
workflow_call:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: write
packages: write
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
check-latest: true
- name: Run tests
run: go test -v ./...
- name: Install UPX
run: |
sudo apt-get update
sudo apt-get install -y upx
- name: Build binaries
run: |
# Set the build time environment variable using git commit timestamp
BUILD_TIME=$(git log -1 --format=%cd --date=iso-strict)
# Add run permissions to the build script
chmod +x ./scripts/build.sh
# Build for all platforms
./scripts/build.sh \
--verbose \
-ldflags "-s -w -X github.com/kjanat/articulate-parser/internal/version.Version=${{ github.ref_name }} -X github.com/kjanat/articulate-parser/internal/version.BuildTime=$BUILD_TIME -X github.com/kjanat/articulate-parser/internal/version.GitCommit=${{ github.sha }}"
- name: Compress binaries
run: |
cd build/
for binary in articulate-parser-*; do
echo "Compressing $binary..."
upx --best "$binary" || {
echo "Warning: UPX compression failed for $binary, keeping original"
}
done
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
build/articulate-parser-linux-amd64
build/articulate-parser-linux-arm64
build/articulate-parser-windows-amd64.exe
build/articulate-parser-windows-arm64.exe
build/articulate-parser-darwin-amd64
build/articulate-parser-darwin-arm64
generate_release_notes: true
draft: false
# Mark pre-1.0 versions (v0.x.x) as prerelease since they are considered unstable
# This helps users understand that these releases may have breaking changes
prerelease: ${{ startsWith(github.ref, 'refs/tags/v0.') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: ['release']
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker Hub
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@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=Articulate Parser
org.opencontainers.image.description=A powerful CLI tool to parse Articulate Rise courses and export them to multiple formats including Markdown HTML and DOCX. Supports media extraction content cleaning and batch processing for educational content conversion.
org.opencontainers.image.vendor=kjanat
org.opencontainers.image.licenses=MIT
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/blob/master/DOCKER.md
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: |
linux/amd64
linux/arm64
linux/arm/v7
linux/386
linux/ppc64le
linux/s390x
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
BUILD_TIME=${{ github.event.head_commit.timestamp }}
GIT_COMMIT=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A powerful CLI tool to parse Articulate Rise courses and export them to multiple formats including Markdown HTML and DOCX. Supports media extraction content cleaning and batch processing for educational content conversion.
sbom: true
provenance: true

9
.gitignore vendored
View File

@ -31,6 +31,7 @@ go.work
# Local test files
output/
outputs/
articulate-sample.json
test-output.*
go-os-arch-matrix.csv
@ -47,9 +48,12 @@ build/
# Test coverage files
coverage.out
coverage.txt
coverage.html
coverage.*
coverage
*.cover
*.coverprofile
main_coverage
# Other common exclusions
*.exe
@ -60,3 +64,8 @@ coverage
*.test
*.out
/tmp/
.github/copilot-instructions.md
# Editors
.vscode/
.idea/

82
DOCKER.md Normal file
View File

@ -0,0 +1,82 @@
# Articulate Parser - Docker
A powerful command-line tool for parsing and processing articulate data files, now available as a lightweight Docker container.
## Quick Start
### Pull from GitHub Container Registry
```bash
docker pull ghcr.io/kjanat/articulate-parser:latest
```
### Run with Articulate Rise URL
```bash
docker run --rm -v $(pwd):/data ghcr.io/kjanat/articulate-parser:latest https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/ markdown /data/output.md
```
### Run with local files
```bash
docker run --rm -v $(pwd):/data ghcr.io/kjanat/articulate-parser:latest /data/input.json markdown /data/output.md
```
## Usage
### Basic File Processing
```bash
# Process from Articulate Rise URL
docker run --rm -v $(pwd):/data ghcr.io/kjanat/articulate-parser:latest https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/ markdown /data/output.md
# Process a local JSON file
docker run --rm -v $(pwd):/data ghcr.io/kjanat/articulate-parser:latest /data/document.json markdown /data/output.md
# Process with specific format and output
docker run --rm -v $(pwd):/data ghcr.io/kjanat/articulate-parser:latest /data/input.json docx /data/output.docx
```
### Display Help and Version
```bash
# Show help information
docker run --rm ghcr.io/kjanat/articulate-parser:latest --help
# Show version
docker run --rm ghcr.io/kjanat/articulate-parser:latest --version
```
## Available Tags
- `latest` - Latest stable release
- `v1.x.x` - Specific version tags
- `main` - Latest development build
## Image Details
- **Base Image**: `scratch` (minimal attack surface)
- **Architecture**: Multi-arch support (amd64, arm64)
- **Size**: < 10MB (optimized binary)
- **Security**: Runs as non-root user
- **Features**: SBOM and provenance attestation included
## Development
### Local Build
```bash
docker build -t articulate-parser .
```
### Docker Compose
```bash
docker-compose up --build
```
## Repository
- **Source**: [github.com/kjanat/articulate-parser](https://github.com/kjanat/articulate-parser)
- **Issues**: [Report bugs or request features](https://github.com/kjanat/articulate-parser/issues)
- **License**: See repository for license details

78
Dockerfile Normal file
View File

@ -0,0 +1,78 @@
# Build stage
FROM golang:1.24-alpine AS builder
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
RUN apk add --no-cache git ca-certificates tzdata file
# Create a non-root user for the final stage
RUN adduser -D -u 1000 appuser
# Set the working directory
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
# Disable CGO for a fully static binary
# Use linker flags to reduce binary size and embed version info
ARG VERSION=dev
ARG BUILD_TIME
ARG GIT_COMMIT
# Docker buildx automatically provides these for multi-platform builds
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
# Debug: Show build information
RUN echo "Building for platform: $TARGETPLATFORM (OS: $TARGETOS, Arch: $TARGETARCH, Variant: $TARGETVARIANT)" \
&& echo "Build platform: $BUILDPLATFORM" \
&& echo "Go version: $(go version)"
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags="-s -w -X github.com/kjanat/articulate-parser/internal/version.Version=${VERSION} -X github.com/kjanat/articulate-parser/internal/version.BuildTime=${BUILD_TIME} -X github.com/kjanat/articulate-parser/internal/version.GitCommit=${GIT_COMMIT}" \
-o articulate-parser \
./main.go
# Verify the binary architecture
RUN file /app/articulate-parser || echo "file command not available"
# Final stage - minimal runtime image
FROM scratch
# Copy CA certificates for HTTPS requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy timezone data
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Add a minimal /etc/passwd file to support non-root user
COPY --from=builder /etc/passwd /etc/passwd
# Copy the binary
COPY --from=builder /app/articulate-parser /articulate-parser
# Switch to non-root user (appuser with UID 1000)
USER appuser
# Set the binary as entrypoint
ENTRYPOINT ["/articulate-parser"]
# Default command shows help
CMD ["--help"]
# Add labels for metadata
LABEL org.opencontainers.image.title="Articulate Parser"
LABEL org.opencontainers.image.description="A powerful CLI tool to parse Articulate Rise courses and export them to multiple formats (Markdown, HTML, DOCX). Supports media extraction, content cleaning, and batch processing for educational content conversion."
LABEL org.opencontainers.image.vendor="kjanat"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.source="https://github.com/kjanat/articulate-parser"
LABEL org.opencontainers.image.documentation="https://github.com/kjanat/articulate-parser/blob/master/DOCKER.md"

78
Dockerfile.dev Normal file
View File

@ -0,0 +1,78 @@
# Development Dockerfile with shell access
# Uses Alpine instead of scratch for debugging
# Build stage - same as production
FROM golang:1.24-alpine AS builder
# Install git and ca-certificates (needed for fetching dependencies and HTTPS)
RUN apk add --no-cache git ca-certificates tzdata file
# Create a non-root user
RUN adduser -D -u 1000 appuser
# Set the working directory
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
# Disable CGO for a fully static binary
# Use linker flags to reduce binary size and embed version info
ARG VERSION=dev
ARG BUILD_TIME
ARG GIT_COMMIT
# Docker buildx automatically provides these for multi-platform builds
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
# Debug: Show build information
RUN echo "Building for platform: $TARGETPLATFORM (OS: $TARGETOS, Arch: $TARGETARCH, Variant: $TARGETVARIANT)" \
&& echo "Build platform: $BUILDPLATFORM" \
&& echo "Go version: $(go version)"
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags="-s -w -X github.com/kjanat/articulate-parser/internal/version.Version=${VERSION} -X github.com/kjanat/articulate-parser/internal/version.BuildTime=${BUILD_TIME} -X github.com/kjanat/articulate-parser/internal/version.GitCommit=${GIT_COMMIT}" \
-o articulate-parser \
./main.go
# Verify the binary architecture
RUN file /app/articulate-parser || echo "file command not available"
# Development stage - uses Alpine for shell access
FROM alpine:3.21.3
# Install minimal dependencies
RUN apk add --no-cache ca-certificates tzdata
# Copy the binary
COPY --from=builder /app/articulate-parser /articulate-parser
# Copy the non-root user configuration
COPY --from=builder /etc/passwd /etc/passwd
# Switch to non-root user
USER appuser
# Set the binary as entrypoint
ENTRYPOINT ["/articulate-parser"]
# Default command shows help
CMD ["--help"]
# Add labels for metadata
LABEL org.opencontainers.image.title="Articulate Parser (Dev)"
LABEL org.opencontainers.image.description="Development version of Articulate Parser with shell access"
LABEL org.opencontainers.image.vendor="kjanat"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.source="https://github.com/kjanat/articulate-parser"
LABEL org.opencontainers.image.documentation="https://github.com/kjanat/articulate-parser/blob/master/DOCKER.md"

237
README.md
View File

@ -1,6 +1,6 @@
# Articulate Rise Parser
A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown and Word documents.
A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown, HTML, and Word documents.
[![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?logo=Go&logoColor=white)][gomod]
[![Go Doc](https://godoc.org/github.com/kjanat/articulate-parser?status.svg)][Package documentation]
@ -9,13 +9,87 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
[![License](https://img.shields.io/github/license/kjanat/articulate-parser?label=License)][MIT License] <!-- [![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits] -->
[![Last commit](https://img.shields.io/github/last-commit/kjanat/articulate-parser?label=Last%20commit)][Commits]
[![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/kjanat/articulate-parser?label=Issues)][Issues]
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker&logoColor=white)][Docker image] <!-- [![Docker Size](https://img.shields.io/docker/image-size/kjanat/articulate-parser?logo=docker&label=Image%20Size)][Docker image] -->
[![Docker](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/docker.yml?logo=docker&label=Docker)][Docker workflow]
[![CI](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/ci.yml?logo=github&label=CI)][Build]
[![Codecov](https://img.shields.io/codecov/c/gh/kjanat/articulate-parser?token=eHhaHY8nut&logo=codecov&logoColor=%23F01F7A&label=Codecov)][Codecov]
## System Architecture
```mermaid
flowchart TD
%% User Input
CLI[Command Line Interface<br/>main.go] --> APP{App Service<br/>services/app.go}
%% Core Application Logic
APP --> |"ProcessCourseFromURI"| PARSER[Course Parser<br/>services/parser.go]
APP --> |"ProcessCourseFromFile"| PARSER
APP --> |"exportCourse"| FACTORY[Exporter Factory<br/>exporters/factory.go]
%% Data Sources
PARSER --> |"FetchCourse"| API[Articulate Rise API<br/>rise.articulate.com]
PARSER --> |"LoadCourseFromFile"| FILE[Local JSON File<br/>*.json]
%% Data Models
API --> MODELS[Data Models<br/>models/course.go]
FILE --> MODELS
MODELS --> |Course, Lesson, Item| APP
%% Export Factory Pattern
FACTORY --> |"CreateExporter"| MARKDOWN[Markdown Exporter<br/>exporters/markdown.go]
FACTORY --> |"CreateExporter"| HTML[HTML Exporter<br/>exporters/html.go]
FACTORY --> |"CreateExporter"| DOCX[DOCX Exporter<br/>exporters/docx.go]
%% HTML Cleaning Service
CLEANER[HTML Cleaner<br/>services/html_cleaner.go] --> MARKDOWN
CLEANER --> HTML
CLEANER --> DOCX
%% Output Files
MARKDOWN --> |"Export"| MD_OUT[Markdown Files<br/>*.md]
HTML --> |"Export"| HTML_OUT[HTML Files<br/>*.html]
DOCX --> |"Export"| DOCX_OUT[Word Documents<br/>*.docx]
%% Interfaces (Contracts)
IPARSER[CourseParser Interface<br/>interfaces/parser.go] -.-> PARSER
IEXPORTER[Exporter Interface<br/>interfaces/exporter.go] -.-> MARKDOWN
IEXPORTER -.-> HTML
IEXPORTER -.-> DOCX
IFACTORY[ExporterFactory Interface<br/>interfaces/exporter.go] -.-> FACTORY
%% Styling - Colors that work in both light and dark GitHub themes
classDef userInput fill:#dbeafe,stroke:#1e40af,stroke-width:2px,color:#1e40af
classDef coreLogic fill:#ede9fe,stroke:#6d28d9,stroke-width:2px,color:#6d28d9
classDef dataSource fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#059669
classDef exporter fill:#fed7aa,stroke:#ea580c,stroke-width:2px,color:#ea580c
classDef output fill:#fce7f3,stroke:#be185d,stroke-width:2px,color:#be185d
classDef interface fill:#ecfdf5,stroke:#16a34a,stroke-width:1px,stroke-dasharray: 5 5,color:#16a34a
classDef service fill:#cffafe,stroke:#0891b2,stroke-width:2px,color:#0891b2
class CLI userInput
class APP,FACTORY coreLogic
class API,FILE,MODELS dataSource
class MARKDOWN,HTML,DOCX exporter
class MD_OUT,HTML_OUT,DOCX_OUT output
class IPARSER,IEXPORTER,IFACTORY interface
class PARSER,CLEANER service
```
### Architecture Overview
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
## Features
- Parse Articulate Rise JSON data from URLs or local files
- Export to Markdown (.md) format
- Export to HTML (.html) format with professional styling
- Export to Word Document (.docx) format
- Support for various content types:
- Text content with headings and paragraphs
@ -29,7 +103,7 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
### Prerequisites
- Go, I don't know the version, but I use go1.24.2 right now, and it works, see the [CI][Build] workflow where it is tested.
- Go, I don't know the version, but I have [![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?label=)][gomod] configured right now, and it works, see the [CI][Build] workflow where it is tested.
### Install from source
@ -85,7 +159,7 @@ go run main.go <input_uri_or_file> <output_format> [output_path]
| Parameter | Description | Default |
| ------------------- | ---------------------------------------------------------------- | --------------- |
| `input_uri_or_file` | Either an Articulate Rise share URL or path to a local JSON file | None (required) |
| `output_format` | `md` for Markdown or `docx` for Word Document | None (required) |
| `output_format` | `md` for Markdown, `html` for HTML, or `docx` for Word Document | None (required) |
| `output_path` | Path where output file will be saved. | `./output/` |
#### Examples
@ -102,7 +176,13 @@ go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviD
go run main.go "articulate-sample.json" docx "my-course.docx"
```
3. **Parse from local file and export to Markdown:**
3. **Parse from local file and export to HTML:**
```bash
go run main.go "articulate-sample.json" html "output.html"
```
4. **Parse from local file and export to Markdown:**
```bash
go run main.go "articulate-sample.json" md "output.md"
@ -122,6 +202,130 @@ Then run:
./articulate-parser input.json md output.md
```
## Docker
The application is available as a Docker image from GitHub Container Registry.
### 🐳 Docker Image Information
- **Registry**: `ghcr.io/kjanat/articulate-parser`
- **Platforms**: linux/amd64, linux/arm64
- **Base Image**: Scratch (minimal footprint)
- **Size**: ~15-20MB compressed
### Quick Start
```bash
# Pull the latest image
docker pull ghcr.io/kjanat/articulate-parser:latest
# Show help
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 |
### Usage Examples
#### Process a local file
```bash
# Mount current directory and process a local JSON file
docker run --rm -v $(pwd):/workspace \
ghcr.io/kjanat/articulate-parser:latest \
/workspace/input.json markdown /workspace/output.md
```
#### Process from URL
```bash
# Mount output directory and process from Articulate Rise URL
docker run --rm -v $(pwd):/workspace \
ghcr.io/kjanat/articulate-parser:latest \
"https://rise.articulate.com/share/xyz" docx /workspace/output.docx
```
#### Export to different formats
```bash
# Export to HTML
docker run --rm -v $(pwd):/workspace \
ghcr.io/kjanat/articulate-parser:latest \
/workspace/course.json html /workspace/course.html
# Export to Word Document
docker run --rm -v $(pwd):/workspace \
ghcr.io/kjanat/articulate-parser:latest \
/workspace/course.json docx /workspace/course.docx
# Export to Markdown
docker run --rm -v $(pwd):/workspace \
ghcr.io/kjanat/articulate-parser:latest \
/workspace/course.json md /workspace/course.md
```
#### Batch Processing
```bash
# Process multiple files in a directory
docker run --rm -v $(pwd):/workspace \
ghcr.io/kjanat/articulate-parser:latest \
bash -c "for file in /workspace/*.json; do
/articulate-parser \"\$file\" md \"\${file%.json}.md\"
done"
```
### Docker Compose
For local development, you can use the provided `docker-compose.yml`:
```bash
# Build and run with default help command
docker-compose up articulate-parser
# Process files using mounted volumes
docker-compose up parser-with-files
```
### Building Locally
```bash
# Build the Docker image locally
docker build -t articulate-parser:local .
# Run the local image
docker run --rm articulate-parser:local --help
# Build with specific version
docker build --build-arg VERSION=local --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t articulate-parser:local .
```
### Environment Variables
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 |
### Docker Security
- **Non-root execution**: The application runs as a non-privileged user
- **Minimal attack surface**: Built from scratch base image
- **No shell access**: Only the application binary is available
- **Read-only filesystem**: Container filesystem is read-only except for mounted volumes
## Development
### Code Quality
@ -153,6 +357,15 @@ The project maintains high code quality standards:
- Media references included
- Course metadata at the top
### HTML (`.html`)
- Professional styling with embedded CSS
- Interactive and visually appealing layout
- Proper HTML structure with semantic elements
- Responsive design for different screen sizes
- All content types beautifully formatted
- Maintains course hierarchy and organization
### Word Document (`.docx`)
- Professional document formatting
@ -204,7 +417,7 @@ The parser includes error handling for:
<!-- ## Code coverage
![Sunburst](https://codecov.io/gh/kjanat/articulate-parser/graphs/tree.svg?token=eHhaHY8nut)
![Sunburst](https://codecov.io/gh/kjanat/articulate-parser/graphs/sunburst.svg?token=eHhaHY8nut)
![Grid](https://codecov.io/gh/kjanat/articulate-parser/graphs/tree.svg?token=eHhaHY8nut)
@ -228,12 +441,12 @@ The parser includes error handling for:
Potential improvements could include:
- PDF export support
- Media file downloading
- 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
@ -242,6 +455,8 @@ This is a utility tool for educational content conversion. Please ensure you hav
[Build]: https://github.com/kjanat/articulate-parser/actions/workflows/ci.yml
[Codecov]: https://codecov.io/gh/kjanat/articulate-parser
[Commits]: https://github.com/kjanat/articulate-parser/commits/master/
[Docker workflow]: https://github.com/kjanat/articulate-parser/actions/workflows/docker.yml
[Docker image]: https://github.com/kjanat/articulate-parser/pkgs/container/articulate-parser
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
[gomod]: go.mod
[Issues]: https://github.com/kjanat/articulate-parser/issues

39
docker-compose.yml Normal file
View File

@ -0,0 +1,39 @@
services:
articulate-parser: &articulate-parser
build:
context: .
dockerfile: Dockerfile
args:
VERSION: "dev"
BUILD_TIME: "2024-01-01T00:00:00Z"
GIT_COMMIT: "dev"
image: articulate-parser:local
volumes:
# Mount current directory to /workspace for file access
- .:/workspace
working_dir: /workspace
# Override entrypoint for interactive use
entrypoint: ["/articulate-parser"]
# Default to showing help
command: ["--help"]
# Service for processing files with volume mounts
parser-with-files:
<<: *articulate-parser
volumes:
- ./input:/input:ro
- ./output:/output
command: ["/input/sample.json", "markdown", "/output/result.md"]
# Service for development - with shell access
parser-dev:
build:
context: .
dockerfile: Dockerfile.dev
image: articulate-parser:dev
volumes:
- .:/workspace
working_dir: /workspace
entrypoint: ["/bin/sh"]
command: ["-c", "while true; do sleep 30; done"]
# Uses Dockerfile.dev with Alpine base instead of scratch for shell access

View File

@ -48,6 +48,8 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
return NewMarkdownExporter(f.htmlCleaner), nil
case "docx", "word":
return NewDocxExporter(f.htmlCleaner), nil
case "html", "htm":
return NewHTMLExporter(f.htmlCleaner), nil
default:
return nil, fmt.Errorf("unsupported export format: %s", format)
}
@ -59,5 +61,5 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
// Returns:
// - A string slice containing all supported format names
func (f *Factory) GetSupportedFormats() []string {
return []string{"markdown", "md", "docx", "word"}
return []string{"markdown", "md", "docx", "word", "html", "htm"}
}

View File

@ -70,6 +70,20 @@ func TestFactory_CreateExporter(t *testing.T) {
expectedFormat: "docx",
shouldError: false,
},
{
name: "html format",
format: "html",
expectedType: "*exporters.HTMLExporter",
expectedFormat: "html",
shouldError: false,
},
{
name: "htm format alias",
format: "htm",
expectedType: "*exporters.HTMLExporter",
expectedFormat: "html",
shouldError: false,
},
{
name: "unsupported format",
format: "pdf",
@ -139,6 +153,12 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
{"WORD", "docx"},
{"Word", "docx"},
{"WoRd", "docx"},
{"HTML", "html"},
{"Html", "html"},
{"HtMl", "html"},
{"HTM", "html"},
{"Htm", "html"},
{"HtM", "html"},
}
for _, tc := range testCases {
@ -168,7 +188,6 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
testCases := []string{
"pdf",
"html",
"txt",
"json",
"xml",
@ -213,7 +232,7 @@ func TestFactory_GetSupportedFormats(t *testing.T) {
t.Fatal("GetSupportedFormats() returned nil")
}
expected := []string{"markdown", "md", "docx", "word"}
expected := []string{"markdown", "md", "docx", "word", "html", "htm"}
// Sort both slices for comparison
sort.Strings(formats)
@ -321,6 +340,21 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
if docxImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to DocxExporter")
}
// Test with html exporter
htmlExporter, err := factory.CreateExporter("html")
if err != nil {
t.Fatalf("Failed to create html exporter: %v", err)
}
htmlImpl, ok := htmlExporter.(*HTMLExporter)
if !ok {
t.Fatal("Failed to cast to HTMLExporter")
}
if htmlImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to HTMLExporter")
}
}
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.

476
internal/exporters/html.go Normal file
View File

@ -0,0 +1,476 @@
// Package exporters provides implementations of the Exporter interface
// for converting Articulate Rise courses into various file formats.
package exporters
import (
"bytes"
"fmt"
"html"
"os"
"strings"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// HTMLExporter implements the Exporter interface for HTML format.
// It converts Articulate Rise course data into a structured HTML document.
type HTMLExporter struct {
// htmlCleaner is used to convert HTML content to plain text when needed
htmlCleaner *services.HTMLCleaner
}
// NewHTMLExporter creates a new HTMLExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
//
// Returns:
// - An implementation of the Exporter interface for HTML format
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &HTMLExporter{
htmlCleaner: htmlCleaner,
}
}
// Export exports a course to HTML format.
// It generates a structured HTML document from the course data
// and writes it to the specified output path.
//
// Parameters:
// - course: The course data model to export
// - outputPath: The file path where the HTML content will be written
//
// Returns:
// - An error if writing to the output file fails
func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
var buf bytes.Buffer
// Write HTML document structure
buf.WriteString("<!DOCTYPE html>\n")
buf.WriteString("<html lang=\"en\">\n")
buf.WriteString("<head>\n")
buf.WriteString(" <meta charset=\"UTF-8\">\n")
buf.WriteString(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
buf.WriteString(fmt.Sprintf(" <title>%s</title>\n", html.EscapeString(course.Course.Title)))
buf.WriteString(" <style>\n")
buf.WriteString(e.getDefaultCSS())
buf.WriteString(" </style>\n")
buf.WriteString("</head>\n")
buf.WriteString("<body>\n")
// Write course header
buf.WriteString(fmt.Sprintf(" <header>\n <h1>%s</h1>\n", html.EscapeString(course.Course.Title)))
if course.Course.Description != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"course-description\">%s</div>\n", course.Course.Description))
}
buf.WriteString(" </header>\n\n")
// Add metadata section
buf.WriteString(" <section class=\"course-info\">\n")
buf.WriteString(" <h2>Course Information</h2>\n")
buf.WriteString(" <ul>\n")
buf.WriteString(fmt.Sprintf(" <li><strong>Course ID:</strong> %s</li>\n", html.EscapeString(course.Course.ID)))
buf.WriteString(fmt.Sprintf(" <li><strong>Share ID:</strong> %s</li>\n", html.EscapeString(course.ShareID)))
buf.WriteString(fmt.Sprintf(" <li><strong>Navigation Mode:</strong> %s</li>\n", html.EscapeString(course.Course.NavigationMode)))
if course.Course.ExportSettings != nil {
buf.WriteString(fmt.Sprintf(" <li><strong>Export Format:</strong> %s</li>\n", html.EscapeString(course.Course.ExportSettings.Format)))
}
buf.WriteString(" </ul>\n")
buf.WriteString(" </section>\n\n")
// Process lessons
lessonCounter := 0
for _, lesson := range course.Course.Lessons {
if lesson.Type == "section" {
buf.WriteString(fmt.Sprintf(" <section class=\"course-section\">\n <h2>%s</h2>\n </section>\n\n", html.EscapeString(lesson.Title)))
continue
}
lessonCounter++
buf.WriteString(fmt.Sprintf(" <section class=\"lesson\">\n <h3>Lesson %d: %s</h3>\n", lessonCounter, html.EscapeString(lesson.Title)))
if lesson.Description != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"lesson-description\">%s</div>\n", lesson.Description))
}
// Process lesson items
for _, item := range lesson.Items {
e.processItemToHTML(&buf, item)
}
buf.WriteString(" </section>\n\n")
}
buf.WriteString("</body>\n")
buf.WriteString("</html>\n")
return os.WriteFile(outputPath, buf.Bytes(), 0644)
}
// GetSupportedFormat returns the format name this exporter supports
// It indicates the file format that the HTMLExporter can generate.
//
// Returns:
// - A string representing the supported format ("html")
func (e *HTMLExporter) GetSupportedFormat() string {
return "html"
}
// getDefaultCSS returns basic CSS styling for the HTML document
func (e *HTMLExporter) getDefaultCSS() string {
return `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 10px;
margin-bottom: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
header h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 300;
}
.course-description {
margin-top: 1rem;
font-size: 1.1rem;
opacity: 0.9;
}
.course-info {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.course-info h2 {
margin-top: 0;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.course-info ul {
list-style: none;
padding: 0;
}
.course-info li {
margin: 0.5rem 0;
padding: 0.5rem;
background: #f7fafc;
border-radius: 4px;
}
.course-section {
background: #4299e1;
color: white;
padding: 1.5rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.course-section h2 {
margin: 0;
font-weight: 400;
}
.lesson {
background: white;
padding: 2rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-left: 4px solid #4299e1;
}
.lesson h3 {
margin-top: 0;
color: #2d3748;
font-size: 1.5rem;
}
.lesson-description {
margin: 1rem 0;
padding: 1rem;
background: #f7fafc;
border-radius: 4px;
border-left: 3px solid #4299e1;
}
.item {
margin: 1.5rem 0;
padding: 1rem;
border-radius: 6px;
background: #fafafa;
border: 1px solid #e2e8f0;
}
.item h4 {
margin-top: 0;
color: #4a5568;
font-size: 1.2rem;
text-transform: capitalize;
}
.text-item {
background: #f0fff4;
border-left: 3px solid #48bb78;
}
.list-item {
background: #fffaf0;
border-left: 3px solid #ed8936;
}
.knowledge-check {
background: #e6fffa;
border-left: 3px solid #38b2ac;
}
.multimedia-item {
background: #faf5ff;
border-left: 3px solid #9f7aea;
}
.interactive-item {
background: #fff5f5;
border-left: 3px solid #f56565;
}
.unknown-item {
background: #f7fafc;
border-left: 3px solid #a0aec0;
}
.answers {
margin: 1rem 0;
}
.answers h5 {
margin: 0.5rem 0;
color: #4a5568;
}
.answers ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.answers li {
margin: 0.3rem 0;
padding: 0.3rem;
}
.correct-answer {
background: #c6f6d5;
border-radius: 3px;
font-weight: bold;
}
.correct-answer::after {
content: " ✓";
color: #38a169;
}
.feedback {
margin: 1rem 0;
padding: 1rem;
background: #edf2f7;
border-radius: 4px;
border-left: 3px solid #4299e1;
font-style: italic;
}
.media-info {
background: #edf2f7;
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
}
.media-info strong {
color: #4a5568;
}
hr {
border: none;
height: 2px;
background: linear-gradient(to right, #667eea, #764ba2);
margin: 2rem 0;
border-radius: 1px;
}
ul {
padding-left: 1.5rem;
}
li {
margin: 0.5rem 0;
}
`
}
// processItemToHTML converts a course item into HTML format
// and appends it to the provided buffer. It handles different item types
// with appropriate HTML formatting.
//
// Parameters:
// - buf: The buffer to write the HTML content to
// - item: The course item to process
func (e *HTMLExporter) processItemToHTML(buf *bytes.Buffer, item models.Item) {
switch strings.ToLower(item.Type) {
case "text":
e.processTextItem(buf, item)
case "list":
e.processListItem(buf, item)
case "knowledgecheck":
e.processKnowledgeCheckItem(buf, item)
case "multimedia":
e.processMultimediaItem(buf, item)
case "image":
e.processImageItem(buf, item)
case "interactive":
e.processInteractiveItem(buf, item)
case "divider":
e.processDividerItem(buf)
default:
e.processUnknownItem(buf, item)
}
}
// processTextItem handles text content with headings and paragraphs
func (e *HTMLExporter) processTextItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item text-item\">\n")
buf.WriteString(" <h4>Text Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Heading != "" {
buf.WriteString(fmt.Sprintf(" <h5>%s</h5>\n", subItem.Heading))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
buf.WriteString(" </div>\n\n")
}
// processListItem handles list content
func (e *HTMLExporter) processListItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item list-item\">\n")
buf.WriteString(" <h4>List</h4>\n")
buf.WriteString(" <ul>\n")
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
cleanText := e.htmlCleaner.CleanHTML(subItem.Paragraph)
buf.WriteString(fmt.Sprintf(" <li>%s</li>\n", html.EscapeString(cleanText)))
}
}
buf.WriteString(" </ul>\n")
buf.WriteString(" </div>\n\n")
}
// processKnowledgeCheckItem handles quiz questions and answers
func (e *HTMLExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item knowledge-check\">\n")
buf.WriteString(" <h4>Knowledge Check</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>Question:</strong> %s</p>\n", subItem.Title))
}
if len(subItem.Answers) > 0 {
e.processAnswers(buf, subItem.Answers)
}
if subItem.Feedback != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"feedback\"><strong>Feedback:</strong> %s</div>\n", subItem.Feedback))
}
}
buf.WriteString(" </div>\n\n")
}
// processMultimediaItem handles multimedia content like videos
func (e *HTMLExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item multimedia-item\">\n")
buf.WriteString(" <h4>Media Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <h5>%s</h5>\n", subItem.Title))
}
if subItem.Media != nil {
if subItem.Media.Video != nil {
buf.WriteString(" <div class=\"media-info\">\n")
buf.WriteString(fmt.Sprintf(" <p><strong>Video:</strong> %s</p>\n", html.EscapeString(subItem.Media.Video.OriginalUrl)))
if subItem.Media.Video.Duration > 0 {
buf.WriteString(fmt.Sprintf(" <p><strong>Duration:</strong> %d seconds</p>\n", subItem.Media.Video.Duration))
}
buf.WriteString(" </div>\n")
}
}
if subItem.Caption != "" {
buf.WriteString(fmt.Sprintf(" <div><em>%s</em></div>\n", subItem.Caption))
}
}
buf.WriteString(" </div>\n\n")
}
// processImageItem handles image content
func (e *HTMLExporter) processImageItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item multimedia-item\">\n")
buf.WriteString(" <h4>Image</h4>\n")
for _, subItem := range item.Items {
if subItem.Media != nil && subItem.Media.Image != nil {
buf.WriteString(" <div class=\"media-info\">\n")
buf.WriteString(fmt.Sprintf(" <p><strong>Image:</strong> %s</p>\n", html.EscapeString(subItem.Media.Image.OriginalUrl)))
buf.WriteString(" </div>\n")
}
if subItem.Caption != "" {
buf.WriteString(fmt.Sprintf(" <div><em>%s</em></div>\n", subItem.Caption))
}
}
buf.WriteString(" </div>\n\n")
}
// processInteractiveItem handles interactive content
func (e *HTMLExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item interactive-item\">\n")
buf.WriteString(" <h4>Interactive Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>%s</strong></p>\n", subItem.Title))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
buf.WriteString(" </div>\n\n")
}
// processDividerItem handles divider elements
func (e *HTMLExporter) processDividerItem(buf *bytes.Buffer) {
buf.WriteString(" <hr>\n\n")
}
// processUnknownItem handles unknown or unsupported item types
func (e *HTMLExporter) processUnknownItem(buf *bytes.Buffer, item models.Item) {
if len(item.Items) > 0 {
buf.WriteString(" <div class=\"item unknown-item\">\n")
buf.WriteString(fmt.Sprintf(" <h4>%s Content</h4>\n", strings.Title(item.Type)))
for _, subItem := range item.Items {
e.processGenericSubItem(buf, subItem)
}
buf.WriteString(" </div>\n\n")
}
}
// processGenericSubItem processes sub-items for unknown types
func (e *HTMLExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>%s</strong></p>\n", subItem.Title))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
// processAnswers processes answer choices for quiz questions
func (e *HTMLExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
buf.WriteString(" <div class=\"answers\">\n")
buf.WriteString(" <h5>Answers:</h5>\n")
buf.WriteString(" <ol>\n")
for _, answer := range answers {
cssClass := ""
if answer.Correct {
cssClass = " class=\"correct-answer\""
}
buf.WriteString(fmt.Sprintf(" <li%s>%s</li>\n", cssClass, html.EscapeString(answer.Title)))
}
buf.WriteString(" </ol>\n")
buf.WriteString(" </div>\n")
}

View File

@ -0,0 +1,927 @@
// Package exporters_test provides tests for the html exporter.
package exporters
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
func TestNewHTMLExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewHTMLExporter() returned nil")
}
// Type assertion to check internal structure
htmlExporter, ok := exporter.(*HTMLExporter)
if !ok {
t.Fatal("NewHTMLExporter() returned wrong type")
}
if htmlExporter.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestHTMLExporter_GetSupportedFormat tests the GetSupportedFormat method.
func TestHTMLExporter_GetSupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
expected := "html"
result := exporter.GetSupportedFormat()
if result != expected {
t.Errorf("Expected format '%s', got '%s'", expected, result)
}
}
// TestHTMLExporter_Export tests the Export method.
func TestHTMLExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForHTML()
// Create temporary directory and file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.html")
// Test successful export
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify HTML structure
if !strings.Contains(contentStr, "<!DOCTYPE html>") {
t.Error("Output should contain HTML doctype")
}
if !strings.Contains(contentStr, "<html lang=\"en\">") {
t.Error("Output should contain HTML tag with lang attribute")
}
if !strings.Contains(contentStr, "<title>Test Course</title>") {
t.Error("Output should contain course title in head")
}
// Verify main course title
if !strings.Contains(contentStr, "<h1>Test Course</h1>") {
t.Error("Output should contain course title as main heading")
}
// Verify course information section
if !strings.Contains(contentStr, "Course Information") {
t.Error("Output should contain course information section")
}
// Verify course metadata
if !strings.Contains(contentStr, "Course ID") {
t.Error("Output should contain course ID")
}
if !strings.Contains(contentStr, "Share ID") {
t.Error("Output should contain share ID")
}
// Verify lesson content
if !strings.Contains(contentStr, "Lesson 1: Test Lesson") {
t.Error("Output should contain lesson heading")
}
// Verify CSS is included
if !strings.Contains(contentStr, "<style>") {
t.Error("Output should contain CSS styles")
}
if !strings.Contains(contentStr, "font-family") {
t.Error("Output should contain CSS font-family")
}
}
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
testCourse := createTestCourseForHTML()
// Try to export to invalid path (non-existent directory)
invalidPath := "/non/existent/path/test.html"
err := exporter.Export(testCourse, invalidPath)
if err == nil {
t.Error("Expected error for invalid output path, but got nil")
}
}
// TestHTMLExporter_ProcessTextItem tests the processTextItem method.
func TestHTMLExporter_ProcessTextItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Test Heading</h1>",
Paragraph: "<p>Test paragraph with <strong>bold</strong> text.</p>",
},
{
Paragraph: "<p>Another paragraph.</p>",
},
},
}
exporter.processTextItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "text-item") {
t.Error("Should contain text-item CSS class")
}
if !strings.Contains(result, "Text Content") {
t.Error("Should contain text content heading")
}
if !strings.Contains(result, "<h1>Test Heading</h1>") {
t.Error("Should preserve HTML heading")
}
if !strings.Contains(result, "<strong>bold</strong>") {
t.Error("Should preserve HTML formatting in paragraph")
}
}
// TestHTMLExporter_ProcessListItem tests the processListItem method.
func TestHTMLExporter_ProcessListItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First item</p>"},
{Paragraph: "<p>Second item with <em>emphasis</em></p>"},
{Paragraph: "<p>Third item</p>"},
},
}
exporter.processListItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "list-item") {
t.Error("Should contain list-item CSS class")
}
if !strings.Contains(result, "<ul>") {
t.Error("Should contain unordered list")
}
if !strings.Contains(result, "<li>First item</li>") {
t.Error("Should contain first list item")
}
if !strings.Contains(result, "<li>Second item with emphasis</li>") {
t.Error("Should contain second list item with cleaned HTML")
}
if !strings.Contains(result, "<li>Third item</li>") {
t.Error("Should contain third list item")
}
}
// TestHTMLExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
func TestHTMLExporter_ProcessKnowledgeCheckItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What is the correct answer?</p>",
Answers: []models.Answer{
{Title: "Wrong answer", Correct: false},
{Title: "Correct answer", Correct: true},
{Title: "Another wrong answer", Correct: false},
},
Feedback: "<p>Great job! This is the feedback.</p>",
},
},
}
exporter.processKnowledgeCheckItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "knowledge-check") {
t.Error("Should contain knowledge-check CSS class")
}
if !strings.Contains(result, "Knowledge Check") {
t.Error("Should contain knowledge check heading")
}
if !strings.Contains(result, "What is the correct answer?") {
t.Error("Should contain question text")
}
if !strings.Contains(result, "Wrong answer") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "correct-answer") {
t.Error("Should mark correct answer with CSS class")
}
if !strings.Contains(result, "Feedback") {
t.Error("Should contain feedback section")
}
}
// TestHTMLExporter_ProcessMultimediaItem tests the processMultimediaItem method.
func TestHTMLExporter_ProcessMultimediaItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "multimedia",
Items: []models.SubItem{
{
Title: "<p>Video Title</p>",
Media: &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/video.mp4",
Duration: 120,
},
},
Caption: "<p>Video caption</p>",
},
},
}
exporter.processMultimediaItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "multimedia-item") {
t.Error("Should contain multimedia-item CSS class")
}
if !strings.Contains(result, "Media Content") {
t.Error("Should contain media content heading")
}
if !strings.Contains(result, "Video Title") {
t.Error("Should contain video title")
}
if !strings.Contains(result, "https://example.com/video.mp4") {
t.Error("Should contain video URL")
}
if !strings.Contains(result, "120 seconds") {
t.Error("Should contain video duration")
}
if !strings.Contains(result, "Video caption") {
t.Error("Should contain video caption")
}
}
// TestHTMLExporter_ProcessImageItem tests the processImageItem method.
func TestHTMLExporter_ProcessImageItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "image",
Items: []models.SubItem{
{
Media: &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/image.png",
},
},
Caption: "<p>Image caption</p>",
},
},
}
exporter.processImageItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "multimedia-item") {
t.Error("Should contain multimedia-item CSS class")
}
if !strings.Contains(result, "Image") {
t.Error("Should contain image heading")
}
if !strings.Contains(result, "https://example.com/image.png") {
t.Error("Should contain image URL")
}
if !strings.Contains(result, "Image caption") {
t.Error("Should contain image caption")
}
}
// TestHTMLExporter_ProcessInteractiveItem tests the processInteractiveItem method.
func TestHTMLExporter_ProcessInteractiveItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "interactive",
Items: []models.SubItem{
{
Title: "<p>Interactive element title</p>",
Paragraph: "<p>Interactive content description</p>",
},
},
}
exporter.processInteractiveItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "interactive-item") {
t.Error("Should contain interactive-item CSS class")
}
if !strings.Contains(result, "Interactive Content") {
t.Error("Should contain interactive content heading")
}
if !strings.Contains(result, "Interactive element title") {
t.Error("Should contain interactive element title")
}
if !strings.Contains(result, "Interactive content description") {
t.Error("Should contain interactive content description")
}
}
// TestHTMLExporter_ProcessDividerItem tests the processDividerItem method.
func TestHTMLExporter_ProcessDividerItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
exporter.processDividerItem(&buf)
result := buf.String()
expected := " <hr>\n\n"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
// TestHTMLExporter_ProcessUnknownItem tests the processUnknownItem method.
func TestHTMLExporter_ProcessUnknownItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "unknown",
Items: []models.SubItem{
{
Title: "<p>Unknown item title</p>",
Paragraph: "<p>Unknown item content</p>",
},
},
}
exporter.processUnknownItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "unknown-item") {
t.Error("Should contain unknown-item CSS class")
}
if !strings.Contains(result, "Unknown Content") {
t.Error("Should contain unknown content heading")
}
if !strings.Contains(result, "Unknown item title") {
t.Error("Should contain unknown item title")
}
if !strings.Contains(result, "Unknown item content") {
t.Error("Should contain unknown item content")
}
}
// TestHTMLExporter_ProcessAnswers tests the processAnswers method.
func TestHTMLExporter_ProcessAnswers(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
answers := []models.Answer{
{Title: "Answer 1", Correct: false},
{Title: "Answer 2", Correct: true},
{Title: "Answer 3", Correct: false},
}
exporter.processAnswers(&buf, answers)
result := buf.String()
if !strings.Contains(result, "answers") {
t.Error("Should contain answers CSS class")
}
if !strings.Contains(result, "<h5>Answers:</h5>") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "<ol>") {
t.Error("Should contain ordered list")
}
if !strings.Contains(result, "<li>Answer 1</li>") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "correct-answer") {
t.Error("Should mark correct answer with CSS class")
}
if !strings.Contains(result, "<li class=\"correct-answer\">Answer 2</li>") {
t.Error("Should mark correct answer properly")
}
if !strings.Contains(result, "<li>Answer 3</li>") {
t.Error("Should contain third answer")
}
}
// TestHTMLExporter_ProcessItemToHTML_AllTypes tests all item types.
func TestHTMLExporter_ProcessItemToHTML_AllTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
tests := []struct {
name string
itemType string
expectedText string
}{
{
name: "text item",
itemType: "text",
expectedText: "Text Content",
},
{
name: "list item",
itemType: "list",
expectedText: "List",
},
{
name: "knowledge check item",
itemType: "knowledgeCheck",
expectedText: "Knowledge Check",
},
{
name: "multimedia item",
itemType: "multimedia",
expectedText: "Media Content",
},
{
name: "image item",
itemType: "image",
expectedText: "Image",
},
{
name: "interactive item",
itemType: "interactive",
expectedText: "Interactive Content",
},
{
name: "divider item",
itemType: "divider",
expectedText: "<hr>",
},
{
name: "unknown item",
itemType: "unknown",
expectedText: "Unknown Content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
item := models.Item{
Type: tt.itemType,
Items: []models.SubItem{
{Title: "Test title", Paragraph: "Test content"},
},
}
// Handle empty unknown items
if tt.itemType == "unknown" && tt.expectedText == "" {
item.Items = []models.SubItem{}
}
exporter.processItemToHTML(&buf, item)
result := buf.String()
if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) {
t.Errorf("Expected content to contain: %q\nGot: %q", tt.expectedText, result)
}
})
}
}
// TestHTMLExporter_ComplexCourse tests export of a complex course structure.
func TestHTMLExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
ShareID: "complex-test-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "complex-course",
Title: "Complex Test Course",
Description: "<p>This is a <strong>complex</strong> course description.</p>",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Format: "scorm",
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction Lesson",
Type: "lesson",
Description: "<p>Introduction to the course</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Welcome</h2>",
Paragraph: "<p>Welcome to our course!</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First objective</p>"},
{Paragraph: "<p>Second objective</p>"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What will you learn?</p>",
Answers: []models.Answer{
{Title: "Nothing", Correct: false},
{Title: "Everything", Correct: true},
},
Feedback: "<p>Great choice!</p>",
},
},
},
},
},
},
},
}
// Create temporary output file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "complex-course.html")
// Export course
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify various elements are present
checks := []string{
"<title>Complex Test Course</title>",
"<h1>Complex Test Course</h1>",
"This is a <strong>complex</strong> course description.",
"Course Information",
"complex-course",
"complex-test-id",
"menu",
"scorm",
"Course Section",
"Lesson 1: Introduction Lesson",
"Introduction to the course",
"<h2>Welcome</h2>",
"Welcome to our course!",
"First objective",
"Second objective",
"Knowledge Check",
"What will you learn?",
"Nothing",
"Everything",
"correct-answer",
"Great choice!",
}
for _, check := range checks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain: %q", check)
}
}
// Verify HTML structure
structureChecks := []string{
"<!DOCTYPE html>",
"<html lang=\"en\">",
"<head>",
"<body>",
"</html>",
"<style>",
"font-family",
}
for _, check := range structureChecks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain HTML structure element: %q", check)
}
}
}
// TestHTMLExporter_EmptyCourse tests export of an empty course.
func TestHTMLExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create minimal course
course := &models.Course{
ShareID: "empty-id",
Course: models.CourseInfo{
ID: "empty-course",
Title: "Empty Course",
Lessons: []models.Lesson{},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "empty-course.html")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify basic structure
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify basic HTML structure even for empty course
if !strings.Contains(contentStr, "<!DOCTYPE html>") {
t.Error("Output should contain HTML doctype")
}
if !strings.Contains(contentStr, "<title>Empty Course</title>") {
t.Error("Output should contain course title")
}
if !strings.Contains(contentStr, "<h1>Empty Course</h1>") {
t.Error("Output should contain course heading")
}
}
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create course with HTML content that needs cleaning in some places
course := &models.Course{
ShareID: "html-test-id",
Course: models.CourseInfo{
ID: "html-test-course",
Title: "HTML Test Course",
Description: "<p>Description with <script>alert('xss')</script> and <b>bold</b> text.</p>",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<div>Lesson description with <span style='color:red'>styled</span> content.</div>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Heading with <em>emphasis</em> and &amp; entities</h1>",
Paragraph: "<p>Paragraph with &lt;code&gt; entities and <strong>formatting</strong>.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "html-test.html")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created (basic check that HTML handling didn't break export)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read content and verify some HTML is preserved (descriptions, headings, paragraphs)
// while list items are cleaned for safety
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// HTML should be preserved in some places
if !strings.Contains(contentStr, "<b>bold</b>") {
t.Error("Should preserve HTML formatting in descriptions")
}
if !strings.Contains(contentStr, "<h1>Heading with <em>emphasis</em>") {
t.Error("Should preserve HTML in headings")
}
if !strings.Contains(contentStr, "<strong>formatting</strong>") {
t.Error("Should preserve HTML in paragraphs")
}
}
// createTestCourseForHTML creates a test course for HTML export testing.
func createTestCourseForHTML() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "<p>Test course description with <strong>formatting</strong>.</p>",
NavigationMode: "free",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Test Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<p>Test lesson description</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Test Heading</h2>",
Paragraph: "<p>Test paragraph content.</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First list item</p>"},
{Paragraph: "<p>Second list item</p>"},
},
},
},
},
},
},
}
}
// BenchmarkHTMLExporter_Export benchmarks the Export method.
func BenchmarkHTMLExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
course := createTestCourseForHTML()
// Create temporary directory
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-course.html")
_ = exporter.Export(course, outputPath)
// Clean up for next iteration
os.Remove(outputPath)
}
}
// BenchmarkHTMLExporter_ProcessTextItem benchmarks text item processing.
func BenchmarkHTMLExporter_ProcessTextItem(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Benchmark Heading</h1>",
Paragraph: "<p>Benchmark paragraph with <strong>formatting</strong>.</p>",
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
exporter.processTextItem(&buf, item)
}
}
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create complex course for benchmarking
course := &models.Course{
ShareID: "benchmark-id",
Course: models.CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Description: "<p>Complex course for performance testing</p>",
Lessons: make([]models.Lesson, 10), // 10 lessons
},
}
// Fill with test data
for i := 0; i < 10; i++ {
lesson := models.Lesson{
ID: "lesson-" + string(rune(i)),
Title: "Lesson " + string(rune(i)),
Type: "lesson",
Items: make([]models.Item, 5), // 5 items per lesson
}
for j := 0; j < 5; j++ {
item := models.Item{
Type: "text",
Items: make([]models.SubItem, 3), // 3 sub-items per item
}
for k := 0; k < 3; k++ {
item.Items[k] = models.SubItem{
Heading: "<h3>Heading " + string(rune(k)) + "</h3>",
Paragraph: "<p>Paragraph content with <strong>formatting</strong> for performance testing.</p>",
}
}
lesson.Items[j] = item
}
course.Course.Lessons[i] = lesson
}
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-complex.html")
_ = exporter.Export(course, outputPath)
os.Remove(outputPath)
}
}

View File

@ -5,7 +5,7 @@ package version
// Version information.
var (
// Version is the current version of the application.
Version = "0.2.0"
Version = "0.4.1"
// BuildTime is the time the binary was built.
BuildTime = "unknown"

76
main.go
View File

@ -7,36 +7,52 @@ import (
"fmt"
"log"
"os"
"strings"
"github.com/kjanat/articulate-parser/internal/exporters"
"github.com/kjanat/articulate-parser/internal/services"
"github.com/kjanat/articulate-parser/internal/version"
)
// main is the entry point of the application.
// It handles command-line arguments, sets up dependencies,
// and coordinates the parsing and exporting of courses.
func main() {
os.Exit(run(os.Args))
}
// 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 {
// Dependency injection setup
htmlCleaner := services.NewHTMLCleaner()
parser := services.NewArticulateParser()
exporterFactory := exporters.NewFactory(htmlCleaner)
app := services.NewApp(parser, exporterFactory)
// Check for required command-line arguments
if len(os.Args) < 4 {
fmt.Printf("Usage: %s <source> <format> <output>\n", os.Args[0])
fmt.Printf(" source: URI or file path to the course\n")
fmt.Printf(" format: export format (%s)\n", joinStrings(app.GetSupportedFormats(), ", "))
fmt.Printf(" output: output file path\n")
fmt.Println("\nExample:")
fmt.Printf(" %s articulate-sample.json markdown output.md\n", os.Args[0])
fmt.Printf(" %s https://rise.articulate.com/share/xyz docx output.docx\n", os.Args[0])
os.Exit(1)
// Check for version flag
if len(args) > 1 && (args[1] == "--version" || args[1] == "-v") {
fmt.Printf("%s version %s\n", args[0], version.Version)
fmt.Printf("Build time: %s\n", version.BuildTime)
fmt.Printf("Git commit: %s\n", version.GitCommit)
return 0
}
source := os.Args[1]
format := os.Args[2]
output := os.Args[3]
// Check for help flag
if len(args) > 1 && (args[1] == "--help" || args[1] == "-h" || args[1] == "help") {
printUsage(args[0], app.GetSupportedFormats())
return 0
}
// Check for required command-line arguments
if len(args) < 4 {
printUsage(args[0], app.GetSupportedFormats())
return 1
}
source := args[1]
format := args[2]
output := args[3]
var err error
@ -48,10 +64,12 @@ func main() {
}
if err != nil {
log.Fatalf("Error processing course: %v", err)
log.Printf("Error processing course: %v", err)
return 1
}
fmt.Printf("Successfully exported course to %s\n", output)
return 0
}
// isURI checks if a string is a URI by looking for http:// or https:// prefixes.
@ -65,25 +83,17 @@ func isURI(str string) bool {
return len(str) > 7 && (str[:7] == "http://" || str[:8] == "https://")
}
// joinStrings concatenates a slice of strings using the specified separator.
// printUsage prints the command-line usage information.
//
// Parameters:
// - strs: The slice of strings to join
// - sep: The separator to insert between each string
//
// Returns:
// - A single string with all elements joined by the separator
func joinStrings(strs []string, sep string) string {
if len(strs) == 0 {
return ""
}
if len(strs) == 1 {
return strs[0]
}
result := strs[0]
for i := 1; i < len(strs); i++ {
result += sep + strs[i]
}
return result
// - programName: The name of the program (args[0])
// - supportedFormats: Slice of supported export formats
func printUsage(programName string, supportedFormats []string) {
fmt.Printf("Usage: %s <source> <format> <output>\n", programName)
fmt.Printf(" source: URI or file path to the course\n")
fmt.Printf(" format: export format (%s)\n", strings.Join(supportedFormats, ", "))
fmt.Printf(" output: output file path\n")
fmt.Println("\nExample:")
fmt.Printf(" %s articulate-sample.json markdown output.md\n", programName)
fmt.Printf(" %s https://rise.articulate.com/share/xyz docx output.docx\n", programName)
}

View File

@ -2,6 +2,12 @@
package main
import (
"bytes"
"fmt"
"io"
"log"
"os"
"strings"
"testing"
)
@ -79,80 +85,6 @@ func TestIsURI(t *testing.T) {
}
}
// TestJoinStrings tests the joinStrings function with various input scenarios.
func TestJoinStrings(t *testing.T) {
tests := []struct {
name string
strs []string
separator string
expected string
}{
{
name: "empty slice",
strs: []string{},
separator: ", ",
expected: "",
},
{
name: "single string",
strs: []string{"hello"},
separator: ", ",
expected: "hello",
},
{
name: "two strings with comma separator",
strs: []string{"markdown", "docx"},
separator: ", ",
expected: "markdown, docx",
},
{
name: "three strings with comma separator",
strs: []string{"markdown", "md", "docx"},
separator: ", ",
expected: "markdown, md, docx",
},
{
name: "multiple strings with pipe separator",
strs: []string{"option1", "option2", "option3"},
separator: " | ",
expected: "option1 | option2 | option3",
},
{
name: "strings with no separator",
strs: []string{"a", "b", "c"},
separator: "",
expected: "abc",
},
{
name: "strings with newline separator",
strs: []string{"line1", "line2", "line3"},
separator: "\n",
expected: "line1\nline2\nline3",
},
{
name: "empty strings in slice",
strs: []string{"", "middle", ""},
separator: "-",
expected: "-middle-",
},
{
name: "nil slice",
strs: nil,
separator: ", ",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := joinStrings(tt.strs, tt.separator)
if result != tt.expected {
t.Errorf("joinStrings(%v, %q) = %q, want %q", tt.strs, tt.separator, result, tt.expected)
}
})
}
}
// BenchmarkIsURI benchmarks the isURI function performance.
func BenchmarkIsURI(b *testing.B) {
testStr := "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/"
@ -163,13 +95,393 @@ func BenchmarkIsURI(b *testing.B) {
}
}
// BenchmarkJoinStrings benchmarks the joinStrings function performance.
func BenchmarkJoinStrings(b *testing.B) {
strs := []string{"markdown", "md", "docx", "word", "pdf", "html"}
separator := ", "
// TestRunWithInsufficientArgs tests the run function with insufficient command-line arguments.
func TestRunWithInsufficientArgs(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "no arguments",
args: []string{"articulate-parser"},
},
{
name: "one argument",
args: []string{"articulate-parser", "source"},
},
{
name: "two arguments",
args: []string{"articulate-parser", "source", "format"},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
joinStrings(strs, separator)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Run the function
exitCode := run(tt.args)
// Restore stdout
w.Close()
os.Stdout = oldStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify exit code
if exitCode != 1 {
t.Errorf("Expected exit code 1, got %d", exitCode)
}
// Verify usage message is displayed
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected usage message in output, got: %s", output)
}
if !strings.Contains(output, "export format") {
t.Errorf("Expected format information in output, got: %s", output)
}
})
}
}
// TestRunWithHelpFlags tests the run function with help flag arguments.
func TestRunWithHelpFlags(t *testing.T) {
helpFlags := []string{"--help", "-h", "help"}
for _, flag := range helpFlags {
t.Run("help_flag_"+flag, func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Run with help flag
args := []string{"articulate-parser", flag}
exitCode := run(args)
// Restore stdout
w.Close()
os.Stdout = oldStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify exit code is 0 (success)
if exitCode != 0 {
t.Errorf("Expected exit code 0 for help flag %s, got %d", flag, exitCode)
}
// Verify help content is displayed
expectedContent := []string{
"Usage:",
"source: URI or file path to the course",
"format: export format",
"output: output file path",
"Example:",
"articulate-sample.json markdown output.md",
"https://rise.articulate.com/share/xyz docx output.docx",
}
for _, expected := range expectedContent {
if !strings.Contains(output, expected) {
t.Errorf("Expected help output to contain %q when using flag %s, got: %s", expected, flag, output)
}
}
})
}
}
// TestRunWithVersionFlags tests the run function with version flag arguments.
func TestRunWithVersionFlags(t *testing.T) {
versionFlags := []string{"--version", "-v"}
for _, flag := range versionFlags {
t.Run("version_flag_"+flag, func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Run with version flag
args := []string{"articulate-parser", flag}
exitCode := run(args)
// Restore stdout
w.Close()
os.Stdout = oldStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify exit code is 0 (success)
if exitCode != 0 {
t.Errorf("Expected exit code 0 for version flag %s, got %d", flag, exitCode)
}
// Verify version content is displayed
expectedContent := []string{
"articulate-parser version",
"Build time:",
"Git commit:",
}
for _, expected := range expectedContent {
if !strings.Contains(output, expected) {
t.Errorf("Expected version output to contain %q when using flag %s, got: %s", expected, flag, output)
}
}
})
}
}
// TestRunWithInvalidFile tests the run function with a non-existent file.
func TestRunWithInvalidFile(t *testing.T) {
// Capture stdout and stderr
oldStdout := os.Stdout
oldStderr := os.Stderr
stdoutR, stdoutW, _ := os.Pipe()
stderrR, stderrW, _ := os.Pipe()
os.Stdout = stdoutW
os.Stderr = stderrW
// Also need to redirect log output
oldLogOutput := log.Writer()
log.SetOutput(stderrW)
// Run with non-existent file
args := []string{"articulate-parser", "nonexistent-file.json", "markdown", "output.md"}
exitCode := run(args)
// Restore stdout/stderr and log output
stdoutW.Close()
stderrW.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
log.SetOutput(oldLogOutput)
// Read captured output
var stdoutBuf, stderrBuf bytes.Buffer
io.Copy(&stdoutBuf, stdoutR)
io.Copy(&stderrBuf, stderrR)
stdoutR.Close()
stderrR.Close()
// Verify exit code
if exitCode != 1 {
t.Errorf("Expected exit code 1 for non-existent file, got %d", exitCode)
}
// Should have error output
errorOutput := stderrBuf.String()
if !strings.Contains(errorOutput, "Error processing course") {
t.Errorf("Expected error message about processing course, got: %s", errorOutput)
}
}
// TestRunWithInvalidURI tests the run function with an invalid URI.
func TestRunWithInvalidURI(t *testing.T) {
// Capture stdout and stderr
oldStdout := os.Stdout
oldStderr := os.Stderr
stdoutR, stdoutW, _ := os.Pipe()
stderrR, stderrW, _ := os.Pipe()
os.Stdout = stdoutW
os.Stderr = stderrW
// Also need to redirect log output
oldLogOutput := log.Writer()
log.SetOutput(stderrW)
// Run with invalid URI (will fail because we can't actually fetch)
args := []string{"articulate-parser", "https://example.com/invalid", "markdown", "output.md"}
exitCode := run(args)
// Restore stdout/stderr and log output
stdoutW.Close()
stderrW.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
log.SetOutput(oldLogOutput)
// Read captured output
var stdoutBuf, stderrBuf bytes.Buffer
io.Copy(&stdoutBuf, stdoutR)
io.Copy(&stderrBuf, stderrR)
stdoutR.Close()
stderrR.Close()
// Should fail because the URI is invalid/unreachable
if exitCode != 1 {
t.Errorf("Expected failure (exit code 1) for invalid URI, got %d", exitCode)
}
// Should have error output
errorOutput := stderrBuf.String()
if !strings.Contains(errorOutput, "Error processing course") {
t.Errorf("Expected error message about processing course, got: %s", errorOutput)
}
}
// TestRunWithValidJSONFile tests the run function with a valid JSON file.
func TestRunWithValidJSONFile(t *testing.T) {
// Create a temporary test JSON file
testContent := `{
"title": "Test Course",
"lessons": [
{
"id": "lesson1",
"title": "Test Lesson",
"blocks": [
{
"type": "text",
"id": "block1",
"data": {
"text": "Test content"
}
}
]
}
]
}`
tmpFile, err := os.CreateTemp("", "test-course-*.json")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(testContent); err != nil {
t.Fatalf("Failed to write test content: %v", err)
}
tmpFile.Close()
// Test successful run with valid file
outputFile := "test-output.md"
defer os.Remove(outputFile)
// Save original stdout
originalStdout := os.Stdout
defer func() { os.Stdout = originalStdout }()
// Capture stdout
r, w, _ := os.Pipe()
os.Stdout = w
args := []string{"articulate-parser", tmpFile.Name(), "markdown", outputFile}
exitCode := run(args)
// Close write end and restore stdout
w.Close()
os.Stdout = originalStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify successful execution
if exitCode != 0 {
t.Errorf("Expected successful execution (exit code 0), got %d", exitCode)
}
// Verify success message
expectedMsg := fmt.Sprintf("Successfully exported course to %s", outputFile)
if !strings.Contains(output, expectedMsg) {
t.Errorf("Expected success message '%s' in output, got: %s", expectedMsg, output)
}
// Verify output file was created
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
t.Errorf("Expected output file %s to be created", outputFile)
}
}
// TestRunIntegration tests the run function with different output formats using sample file.
func TestRunIntegration(t *testing.T) {
// Skip if sample file doesn't exist
if _, err := os.Stat("articulate-sample.json"); os.IsNotExist(err) {
t.Skip("Skipping integration test: articulate-sample.json not found")
}
formats := []struct {
format string
output string
}{
{"markdown", "test-output.md"},
{"html", "test-output.html"},
{"docx", "test-output.docx"},
}
for _, format := range formats {
t.Run("format_"+format.format, func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Run the function
args := []string{"articulate-parser", "articulate-sample.json", format.format, format.output}
exitCode := run(args)
// Restore stdout
w.Close()
os.Stdout = oldStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Clean up test file
defer os.Remove(format.output)
// Verify successful execution
if exitCode != 0 {
t.Errorf("Expected successful execution (exit code 0), got %d", exitCode)
}
// Verify success message
expectedMsg := "Successfully exported course to " + format.output
if !strings.Contains(output, expectedMsg) {
t.Errorf("Expected success message '%s' in output, got: %s", expectedMsg, output)
}
// Verify output file was created
if _, err := os.Stat(format.output); os.IsNotExist(err) {
t.Errorf("Expected output file %s to be created", format.output)
}
})
}
}
// TestMainFunction tests that the main function exists and is properly structured.
// We can't test os.Exit behavior directly, but we can verify the main function
// calls the run function correctly by testing run function behavior.
func TestMainFunction(t *testing.T) {
// Test that insufficient args return exit code 1
exitCode := run([]string{"program"})
if exitCode != 1 {
t.Errorf("Expected run to return exit code 1 for insufficient args, got %d", exitCode)
}
// Test that main function exists (this is mainly for coverage)
// The main function just calls os.Exit(run(os.Args)), which we can't test directly
// but we've tested the run function thoroughly above.
}

View File

@ -531,6 +531,11 @@ try {
if ($Failed -gt 0) {
exit 1
}
# Clean up environment variables to avoid contaminating future builds
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
} finally {
Pop-Location
}

View File

@ -217,6 +217,14 @@ if [ "$SHOW_TARGETS" = true ]; then
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
fi
# Validate entry point exists
if [ ! -f "$ENTRYPOINT" ]; then
echo "Error: Entry point file '$ENTRYPOINT' does not exist"
@ -315,7 +323,7 @@ for idx in "${!TARGETS[@]}"; do
fi
build_cmd+=("${GO_BUILD_FLAGS_ARRAY[@]}" -o "$OUTDIR/$BIN" "$ENTRYPOINT")
if CGO_ENABLED=1 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
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
@ -356,3 +364,6 @@ if [ "$VERBOSE" = true ]; then
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
unset GOOS GOARCH CGO_ENABLED