5 Commits

Author SHA1 Message Date
ec5c8c099c Update labels and bump version to 0.4.0
Standardizes dependabot labels to include 'dependencies/' prefix
for better organization and clarity.

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


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

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

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

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

Enhances README with updated code coverage and feature improvement lists.

Addresses improved maintainability and testability of the application.

Bumps version to 0.3.1
2025-05-28 14:23:56 +02:00
15 changed files with 1545 additions and 346 deletions

68
.dockerignore Normal file
View File

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

View File

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

View File

@ -2,11 +2,19 @@ name: CI
on:
push:
branches: [ "master", "develop" ]
branches: ['master', 'develop']
tags:
- "v*.*.*"
- 'v*.*.*'
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 +38,58 @@ jobs:
with:
go-version: ${{ matrix.go }}
check-latest: true
cache-dependency-path: "**/*.sum"
# Disable built-in cache to use our custom cache
cache: false
- name: Download dependencies
run: go mod download && echo "Download successful" || go mod tidy && echo "Tidy successful" || return 1
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('**/go.sum', '**/go.mod') }}-v1
restore-keys: |
${{ runner.os }}-go-${{ matrix.go }}-
continue-on-error: true
- name: Verify dependencies
run: go mod verify
- name: Download dependencies with retry
run: |
set -e
echo "Downloading Go dependencies..."
# Function to download with retry
download_with_retry() {
local attempt=1
local max_attempts=3
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts"
if go mod download; then
echo "Download successful on attempt $attempt"
return 0
else
echo "Download failed on attempt $attempt"
if [ $attempt -lt $max_attempts ]; then
echo "Cleaning cache and retrying..."
go clean -modcache
go clean -cache
sleep 2
fi
attempt=$((attempt + 1))
fi
done
echo "All download attempts failed"
return 1
}
# Try download with retry logic
download_with_retry
echo "Verifying module dependencies..."
go mod verify
echo "Dependencies verified successfully"
- name: Build
run: go build -v ./...
@ -123,18 +176,47 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
# Add coverage by package
echo "### 📋 Coverage by Package" >> $GITHUB_STEP_SUMMARY
echo "<details>" >> $GITHUB_STEP_SUMMARY
echo "<summary>Click to expand 📋 Coverage by Package details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Package | Coverage |" >> $GITHUB_STEP_SUMMARY
echo "|---------|----------|" >> $GITHUB_STEP_SUMMARY
# Create temporary file for package coverage aggregation
temp_coverage=$(mktemp)
# Extract package-level coverage data
go tool cover -func=coverage.out | grep -v total | while read line; do
if [[ $line == *".go:"* ]]; then
pkg=$(echo $line | awk '{print $1}' | cut -d'/' -f1-3)
coverage=$(echo $line | awk '{print $3}')
echo "| $pkg | $coverage |" >> $GITHUB_STEP_SUMMARY
# Extract package path from file path (everything before the filename)
filepath=$(echo "$line" | awk '{print $1}')
pkg_path=$(dirname "$filepath" | sed 's|github.com/kjanat/articulate-parser/||' | sed 's|^\./||')
coverage=$(echo "$line" | awk '{print $3}' | sed 's/%//')
# Use root package if no subdirectory
if [[ "$pkg_path" == "." || "$pkg_path" == "" ]]; then
pkg_path="root"
fi
echo "$pkg_path $coverage" >> "$temp_coverage"
fi
done | sort -u
done
# Aggregate coverage by package (average)
awk '{
packages[$1] += $2;
counts[$1]++
}
END {
for (pkg in packages) {
avg = packages[pkg] / counts[pkg]
printf "| %s | %.1f%% |\n", pkg, avg
}
}' $temp_coverage | sort >> $GITHUB_STEP_SUMMARY
rm -f $temp_coverage
echo "</details>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
@ -220,6 +302,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
@ -242,7 +371,7 @@ jobs:
if: github.ref_type == 'tag'
permissions:
contents: write
needs: [ "test" ]
needs: ['test']
steps:
- uses: actions/checkout@v4
with:
@ -286,8 +415,8 @@ jobs:
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')
# 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
@ -323,14 +452,19 @@ jobs:
# 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
echo "Compressing $binary..."
upx --best "$binary" || {
echo "Warning: UPX compression failed for $binary, keeping original"
}
# if [[ "$binary" == *"darwin"* ]]; then
# echo "Skipping UPX compression for $binary (macOS compatibility)"
# else
# echo "Compressing $binary..."
# upx --best "$binary" || { # removed `--lzma`
# echo "Warning: UPX compression failed for $binary, keeping original"
# }
# fi
done
echo "**Final sizes:**" >> $GITHUB_STEP_SUMMARY
@ -356,6 +490,118 @@ jobs:
files: build/*
generate_release_notes: true
draft: false
# Mark v0.x.x releases as prerelease (pre-1.0 versions are considered unstable)
prerelease: ${{ startsWith(github.ref, 'refs/tags/v0.') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
permissions:
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/tags/') || startsWith(github.ref, 'refs/heads/feature/docker'))
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ 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 "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** \`ghcr.io/${{ github.repository }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tags built:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Features:**" >> $GITHUB_STEP_SUMMARY
echo "- **Platforms:** linux/amd64, linux/arm64, linux/arm/v7, linux/386, linux/ppc64le, linux/s390x" >> $GITHUB_STEP_SUMMARY
echo "- **Architecture optimization:** ✅ Native compilation for each platform" >> $GITHUB_STEP_SUMMARY
echo "- **Multi-arch image description:** ✅ Enabled" >> $GITHUB_STEP_SUMMARY
echo "- **SBOM (Software Bill of Materials):** ✅ Generated" >> $GITHUB_STEP_SUMMARY
echo "- **Provenance attestation:** ✅ Generated" >> $GITHUB_STEP_SUMMARY
echo "- **Security scanning:** Ready for vulnerability analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Usage:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "# Pull the image" >> $GITHUB_STEP_SUMMARY
echo "docker pull ghcr.io/${{ github.repository }}:latest" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "# Run with help" >> $GITHUB_STEP_SUMMARY
echo "docker run --rm ghcr.io/${{ github.repository }}:latest --help" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "# Process a local file (mount current directory)" >> $GITHUB_STEP_SUMMARY
echo "docker run --rm -v \$(pwd):/workspace ghcr.io/${{ github.repository }}:latest /workspace/input.json markdown /workspace/output.md" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Security and quality analysis workflows
codeql-analysis:
name: CodeQL Analysis
uses: ./.github/workflows/codeql.yml
permissions:
security-events: write
packages: read
actions: read
contents: read

View File

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

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

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

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

@ -0,0 +1,74 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: 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 }}

8
.gitignore vendored
View File

@ -48,9 +48,12 @@ build/
# Test coverage files
coverage.out
coverage.txt
coverage.html
coverage.*
coverage
*.cover
*.coverprofile
main_coverage
# Other common exclusions
*.exe
@ -61,3 +64,8 @@ coverage
*.test
*.out
/tmp/
.github/copilot-instructions.md
# Editors
.vscode/
.idea/

82
DOCKER.md Normal file
View File

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

78
Dockerfile Normal file
View File

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

78
Dockerfile.dev Normal file
View File

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

165
README.md
View File

@ -2,6 +2,19 @@
A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown, HTML, and Word documents.
[![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?logo=Go&logoColor=white)][gomod]
[![Go Doc](https://godoc.org/github.com/kjanat/articulate-parser?status.svg)][Package documentation]
[![Go Report Card](https://goreportcard.com/badge/github.com/kjanat/articulate-parser)][Go report]
[![Tag](https://img.shields.io/github/v/tag/kjanat/articulate-parser?sort=semver&label=Tag)][Tags] <!-- [![Release Date](https://img.shields.io/github/release-date/kjanat/articulate-parser?label=Release%20date)][Latest release] -->
[![License](https://img.shields.io/github/license/kjanat/articulate-parser?label=License)][MIT License] <!-- [![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits] -->
[![Last commit](https://img.shields.io/github/last-commit/kjanat/articulate-parser?label=Last%20commit)][Commits]
[![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/kjanat/articulate-parser?label=Issues)][Issues]
[![CI](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/ci.yml?logo=github&label=CI)][Build]
[![Docker](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/docker.yml?logo=docker&label=Docker)][Docker workflow]
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker&logoColor=white)][Docker image]
[![Docker Size](https://img.shields.io/docker/image-size/kjanat/articulate-parser?logo=docker&label=Image%20Size)][Docker image]
[![Codecov](https://img.shields.io/codecov/c/gh/kjanat/articulate-parser?token=eHhaHY8nut&logo=codecov&logoColor=%23F01F7A&label=Codecov)][Codecov]
## System Architecture
```mermaid
@ -73,16 +86,6 @@ The system follows **Clean Architecture** principles with clear separation of co
- **📤 Export Layer**: Factory pattern for format-specific exporters
- **📊 Data Layer**: Domain models representing course structure
[![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?logo=Go&logoColor=white)][gomod]
[![Go Doc](https://godoc.org/github.com/kjanat/articulate-parser?status.svg)][Package documentation]
[![Go Report Card](https://goreportcard.com/badge/github.com/kjanat/articulate-parser)][Go report]
[![Tag](https://img.shields.io/github/v/tag/kjanat/articulate-parser?sort=semver&label=Tag)][Tags] <!-- [![Release Date](https://img.shields.io/github/release-date/kjanat/articulate-parser?label=Release%20date)][Latest release] -->
[![License](https://img.shields.io/github/license/kjanat/articulate-parser?label=License)][MIT License] <!-- [![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits] -->
[![Last commit](https://img.shields.io/github/last-commit/kjanat/articulate-parser?label=Last%20commit)][Commits]
[![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/kjanat/articulate-parser?label=Issues)][Issues]
[![CI](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/ci.yml?logo=github&label=CI)][Build]
[![Codecov](https://img.shields.io/codecov/c/gh/kjanat/articulate-parser?token=eHhaHY8nut&logo=codecov&logoColor=%23F01F7A&label=Codecov)][Codecov]
## Features
- Parse Articulate Rise JSON data from URLs or local files
@ -101,7 +104,7 @@ The system follows **Clean Architecture** principles with clear separation of co
### Prerequisites
- Go, I don't know the version, but I use go1.24.2 right now, and it works, see the [CI][Build] workflow where it is tested.
- Go, I don't know the version, but I have [![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?label=)][gomod] configured right now, and it works, see the [CI][Build] workflow where it is tested.
### Install from source
@ -200,6 +203,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
@ -291,7 +418,7 @@ The parser includes error handling for:
<!-- ## Code coverage
![Sunburst](https://codecov.io/gh/kjanat/articulate-parser/graphs/tree.svg?token=eHhaHY8nut)
![Sunburst](https://codecov.io/gh/kjanat/articulate-parser/graphs/sunburst.svg?token=eHhaHY8nut)
![Grid](https://codecov.io/gh/kjanat/articulate-parser/graphs/tree.svg?token=eHhaHY8nut)
@ -315,12 +442,12 @@ The parser includes error handling for:
Potential improvements could include:
- PDF export support
- Media file downloading
- ~~HTML export with preserved styling~~**Completed**
- 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
@ -329,6 +456,8 @@ This is a utility tool for educational content conversion. Please ensure you hav
[Build]: https://github.com/kjanat/articulate-parser/actions/workflows/ci.yml
[Codecov]: https://codecov.io/gh/kjanat/articulate-parser
[Commits]: https://github.com/kjanat/articulate-parser/commits/master/
[Docker workflow]: https://github.com/kjanat/articulate-parser/actions/workflows/docker.yml
[Docker image]: https://github.com/kjanat/articulate-parser/pkgs/container/articulate-parser
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
[gomod]: go.mod
[Issues]: https://github.com/kjanat/articulate-parser/issues

39
docker-compose.yml Normal file
View File

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

View File

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

76
main.go
View File

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

View File

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