mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 13:42:10 +01:00
Compare commits
12 Commits
v0.2.0
...
c4086a832f
| Author | SHA1 | Date | |
|---|---|---|---|
| c4086a832f | |||
| 903ee92e4c | |||
|
9c51c0d9e3
|
|||
|
ec5c8c099c
|
|||
| 9eaf7dfcf2 | |||
| b7f23b2387 | |||
| a0003983c4 | |||
|
1c1460ff04
|
|||
|
1b945ca2bc
|
|||
| fb343f9a23 | |||
|
ce5b5c20bb
|
|||
|
cc11d2fd84
|
68
.dockerignore
Normal file
68
.dockerignore
Normal 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
|
||||
50
.github/dependabot.yml
vendored
50
.github/dependabot.yml
vendored
@ -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
25
.github/workflows/autofix.yml
vendored
Normal 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
|
||||
390
.github/workflows/ci.yml
vendored
390
.github/workflows/ci.yml
vendored
@ -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 ./...
|
||||
@ -49,17 +87,17 @@ jobs:
|
||||
echo "- **OS:** ubuntu-latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Timestamp:** $(date -u)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
echo "Running tests with coverage..."
|
||||
go test -v -race -coverprofile=coverage.out ./... 2>&1 | tee test-output.log
|
||||
|
||||
|
||||
# Extract test results for summary
|
||||
TEST_STATUS=$?
|
||||
TOTAL_TESTS=$(grep -c "=== RUN" test-output.log || echo "0")
|
||||
PASSED_TESTS=$(grep -c "--- PASS:" test-output.log || echo "0")
|
||||
FAILED_TESTS=$(grep -c "--- FAIL:" test-output.log || echo "0")
|
||||
SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0")
|
||||
|
||||
|
||||
# Generate test summary
|
||||
echo "## 🧪 Test Results (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
@ -71,13 +109,13 @@ jobs:
|
||||
echo "| Skipped | ⏭️ $SKIPPED_TESTS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Status | $([ $TEST_STATUS -eq 0 ] && echo "✅ PASSED" || echo "❌ FAILED") |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
# Add package breakdown
|
||||
echo "### 📦 Package Test Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
# Extract package results
|
||||
grep "^ok\|^FAIL" test-output.log | while read line; do
|
||||
if [[ $line == ok* ]]; then
|
||||
@ -88,9 +126,9 @@ jobs:
|
||||
echo "| $pkg | ❌ FAIL |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
# Add detailed results if tests failed
|
||||
if [ $TEST_STATUS -ne 0 ]; then
|
||||
echo "### ❌ Failed Tests Details" >> $GITHUB_STEP_SUMMARY
|
||||
@ -100,13 +138,13 @@ jobs:
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
# Set outputs for other steps
|
||||
echo "test-status=$TEST_STATUS" >> $GITHUB_OUTPUT
|
||||
echo "total-tests=$TOTAL_TESTS" >> $GITHUB_OUTPUT
|
||||
echo "passed-tests=$PASSED_TESTS" >> $GITHUB_OUTPUT
|
||||
echo "failed-tests=$FAILED_TESTS" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Exit with the original test status
|
||||
exit $TEST_STATUS
|
||||
|
||||
@ -116,26 +154,55 @@ jobs:
|
||||
if [ -f coverage.out ]; then
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
|
||||
|
||||
|
||||
echo "## 📊 Code Coverage (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total Coverage: $COVERAGE**" >> $GITHUB_STEP_SUMMARY
|
||||
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
|
||||
echo "## ⚠️ Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
@ -158,10 +225,10 @@ jobs:
|
||||
run: |
|
||||
echo "## 🔍 Static Analysis (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
VET_OUTPUT=$(go vet ./... 2>&1 || echo "")
|
||||
VET_STATUS=$?
|
||||
|
||||
|
||||
if [ $VET_STATUS -eq 0 ]; then
|
||||
echo "✅ **go vet:** No issues found" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
@ -172,13 +239,13 @@ jobs:
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
exit $VET_STATUS
|
||||
|
||||
- name: Run go fmt
|
||||
run: |
|
||||
FMT_OUTPUT=$(gofmt -s -l . 2>&1 || echo "")
|
||||
|
||||
|
||||
if [ -z "$FMT_OUTPUT" ]; then
|
||||
echo "✅ **go fmt:** All files properly formatted" >> $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
|
||||
|
||||
# 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 "**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
|
||||
|
||||
- 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
|
||||
|
||||
204
.github/workflows/codeql.yml
vendored
204
.github/workflows/codeql.yml
vendored
@ -1,100 +1,104 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
schedule:
|
||||
- cron: '44 16 * * 6'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
# or others). This is typically only required for manual builds.
|
||||
# - name: Setup runtime (example)
|
||||
# uses: actions/setup-example@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
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:
|
||||
workflow_call:
|
||||
schedule:
|
||||
- cron: '44 16 * * 6'
|
||||
# push:
|
||||
# branches: [ "master" ]
|
||||
# pull_request:
|
||||
# branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
# or others). This is typically only required for manual builds.
|
||||
# - name: Setup runtime (example)
|
||||
# uses: actions/setup-example@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
25
.github/workflows/dependency-review.yml
vendored
Normal file
25
.github/workflows/dependency-review.yml
vendored
Normal 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
156
.github/workflows/release.yml
vendored
Normal 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
|
||||
133
.gitignore
vendored
133
.gitignore
vendored
@ -1,62 +1,71 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/go
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=go
|
||||
|
||||
### Go ###
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||
|
||||
# Shit
|
||||
.github/TODO
|
||||
|
||||
# Local test files
|
||||
output/
|
||||
articulate-sample.json
|
||||
test-output.*
|
||||
go-os-arch-matrix.csv
|
||||
test_godocx.go
|
||||
test_input.json
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
|
||||
# Old workflows
|
||||
.github/workflows/ci-old.yml
|
||||
.github/workflows/ci-enhanced.yml
|
||||
|
||||
# Test coverage files
|
||||
coverage.out
|
||||
coverage.txt
|
||||
coverage
|
||||
*.cover
|
||||
*.coverprofile
|
||||
|
||||
# Other common exclusions
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
/tmp/
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/go
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=go
|
||||
|
||||
### Go ###
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||
|
||||
# Shit
|
||||
.github/TODO
|
||||
|
||||
# Local test files
|
||||
output/
|
||||
outputs/
|
||||
articulate-sample.json
|
||||
test-output.*
|
||||
go-os-arch-matrix.csv
|
||||
test_godocx.go
|
||||
test_input.json
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
|
||||
# Old workflows
|
||||
.github/workflows/ci-old.yml
|
||||
.github/workflows/ci-enhanced.yml
|
||||
|
||||
# Test coverage files
|
||||
coverage.out
|
||||
coverage.txt
|
||||
coverage.html
|
||||
coverage.*
|
||||
coverage
|
||||
*.cover
|
||||
*.coverprofile
|
||||
main_coverage
|
||||
|
||||
# Other common exclusions
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
/tmp/
|
||||
.github/copilot-instructions.md
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
82
DOCKER.md
Normal file
82
DOCKER.md
Normal 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
78
Dockerfile
Normal 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
78
Dockerfile.dev
Normal 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
237
README.md
@ -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.
|
||||
|
||||
[][gomod]
|
||||
[][Package documentation]
|
||||
@ -9,13 +9,87 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
|
||||
[][MIT License] <!-- [][Commits] -->
|
||||
[][Commits]
|
||||
[][Issues]
|
||||
[][Docker image] <!-- [][Docker image] -->
|
||||
[][Docker workflow]
|
||||
[][Build]
|
||||
[][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 [][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
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
@ -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
39
docker-compose.yml
Normal 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
|
||||
@ -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"}
|
||||
}
|
||||
|
||||
@ -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
476
internal/exporters/html.go
Normal 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")
|
||||
}
|
||||
927
internal/exporters/html_test.go
Normal file
927
internal/exporters/html_test.go
Normal 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 & entities</h1>",
|
||||
Paragraph: "<p>Paragraph with <code> 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)
|
||||
}
|
||||
}
|
||||
@ -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
76
main.go
@ -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)
|
||||
}
|
||||
|
||||
474
main_test.go
474
main_test.go
@ -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.
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user